Skip to content

fix(models): retry with drop_block when a history edit invalidates replayed thinking - #5171

Draft
lizradway wants to merge 2 commits into
strands-agents:mainfrom
lizradway:dev/strip-stale-thinking
Draft

lizradway wants to merge 2 commits into
strands-agents:mainfrom
lizradway:dev/strip-stale-thinking

Conversation

@lizradway

Copy link
Copy Markdown
Member

Human Overview

Description

Preserved-thinking Claude models (Opus 5.5, Fable 5.1, Sonnet/Haiku 5.5) bind each thinking block to the content before it. On accounts that enforce this, which is the default for accounts created on or after 2026-08-31, any edit to earlier history fails the next request with a non-retryable 400 ("The block is bound to a different conversation"). Context manager summarize and drop do exactly that kind of edit, so they break agents on these accounts.

Bedrock and Anthropic now catch that error, log a warning, and retry once with thinking.block_binding.prefix_mismatch_behavior: "drop_block" and the thinking-binding-controls-2026-08-01 beta. This is Anthropic's and AWS's documented fix for apps that rewrite history. The setting is only sent after the error, because setting it opts the request into enforcement; accounts that don't enforce are left unchanged. After the first retry it stays on for that model, since the stale blocks remain in history. A caller-set block_binding is respected and never retried.

The summarizer also stops copying reasoning blocks into its own request.

Related Issues

Fixes #4933

Related: #4917

Documentation PR

N/A. No public API change; the recovery is automatic and logs a warning when it engages.

Type of Change

Bug fix

Testing

Ran the full Python and TypeScript unit suites, lint, and type checks. Added unit tests in both SDKs for the retry, the sticky follow-up requests, the warning, respecting a caller-set block_binding, and keeping thinking off for forced tool choice.

Ran an agent end to end against Bedrock Opus 5.5 in both SDKs, with Offload.summarize firing every turn and enforcement simulated at the client, since the test account predates enforcement. The edited turn hits the real 400, the retry succeeds, and later turns send drop_block directly. With the retry disabled, the same run fails with the 400. The Anthropic provider is covered by unit tests only; I didn't have an API key to run it end to end.

  • I ran hatch run prepare

Additional Details

Design notes and trade-offs

Why the provider, not the context manager. The binding error depends on the provider and the account, not on which component edited history. Handling it in the provider covers every source of edits (context manager, conversation managers, session restore, hooks), and other providers never see the error. An earlier version of this branch stripped reasoning blocks inside the context manager; this replaces that with the provider-native escape hatch.

Cost. Anthropic calls drop_block a stopgap: stale thinking is dropped, the cache restarts at the edit, and output tokens can rise because Claude re-thinks. The longer-term path is the move to the Claude APIs and server-side compaction.

Sticky before success. The setting is remembered when the retry is sent, not after it succeeds (unlike tool-result turn separation). Once the binding error appears, drop_block is the only way that history can be sent, so remembering it early saves a failed call. It is in-memory only, so a restored session pays one failed call before recovering.

Constraints. Sonnet/Haiku 5.5 accept drop_block only with adaptive thinking. If the retry is rejected, that error surfaces as-is. The retry's beta header is appended to the caller's betas: extra_headers or the client's default headers in Python, betas in TypeScript.

Complexity. Labeled high because the change touches TypeScript AnthropicModel::stream, which was already near 90; the retry branch adds about 4.

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

🤖 Generated with Claude Code

On preserved-thinking Claude models (Fable 5.1, Opus 5.5, Sonnet/Haiku 5.5),
summarize, drop, and overflow truncation rewrite history before replayed
thinking blocks, and enforced accounts reject the request with a 400 the
context manager did not recover from. On that error, strip reasoning blocks
from history and retry once. Also keep reasoning out of the summarizer input.
…played thinking

Preserved-thinking Claude models bind each thinking block to the content before it,
and enforcing accounts reject a request once that content changes (for example after
context management summarizes older turns). Bedrock and Anthropic now catch that
error, warn, and retry once with thinking.block_binding.prefix_mismatch_behavior set
to drop_block plus the thinking-binding-controls-2026-08-01 beta. The setting stays
on for later requests to that model, since the stale blocks remain in history.

The field is only sent after the error: setting it opts the request into
enforcement, so accounts that don't enforce are left unchanged. A caller-set
block_binding is respected and not retried.

This replaces the context manager's strip-and-retry of reasoning blocks.
@github-actions github-actions Bot added bug Something isn't working area-model Related to models or model providers area-context Session or context related python Pull requests that update python code complexity/high A touched function exceeds cognitive complexity 25; may be worth splitting size/m labels Oct 15, 2026
@lizradway

Copy link
Copy Markdown
Member Author

@strandly-the-agent pls review

@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.61017% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
strands-py/src/strands/models/anthropic.py 96.00% 0 Missing and 1 partial ⚠️
strands-py/src/strands/models/bedrock.py 96.00% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@strandly-the-agent strandly-the-agent left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve with two should-fixes. The provider-level retry holds up under adversarial testing including live Bedrock e2e in both SDKs; what's left is small.

Edit: an earlier version of this review flagged the sticky latch as 🔴. Withdrawn — reasoning below.

  • 🟡 anthropic.py:1063 — latch is written from request["model"] but read via self.config["model_id"]; params={"model": …} makes them disagree and stream() recurses without bound (~970 billable 400s then RecursionError). One-word fix, inline.
  • 🟡 anthropic.ts:455 — a beta set via clientConfig.defaultHeaders is silently dropped on the retry and every later request: _buildRequestOptions only reads _config.betas, and the SDK's per-request anthropic-beta replaces the client default. Python handles this (anthropic.py:559-560); TS doesn't. Observed on the wire with the real SDK: req0 client-beta → req1 thinking-binding-controls-2026-08-01 alone. Fold the client default in, or document betas as the only supported path.
✅ Verified, withdrawn 🔴, Questions, Reading order, Appendix (5)

✅ Verified at c09b236

  • pytest tests/strands/models/test_bedrock.py tests/strands/models/test_anthropic.py tests/strands/_context_manager → 777 passed. vitest run on the touched TS test files → 816 passed, no type errors (full src/models/ src/context-manager/ → 1989 passed).
  • Mutation check: reverting each of the three fixes (anthropic retry, bedrock retry, summarize filter) fails 4 Python / 6 TS PR tests — the tests prove behaviour.
  • Live Bedrock (us-west-2): the binding error is a synchronous ValidationException for both converse and converse_stream, message matches the substring verbatim; _converse catching only ClientError is sufficient. E2E through both SDKs with enforcement simulated client-side (as the author did): edited history → real 400 → retry succeeds → next turn sends drop_block directly; exactly one messageStart.
  • No duplicate events on the recursive retry: the Python 400 is raised at __aenter__ before anything is yielded; continuations == 0 correctly excludes continuation requests (a binding error there raises — right, since events were already emitted).
  • anthropic 0.125: client.default_headers exposes anthropic-beta, and per-request extra_headers replaces it — so the Python merge is needed and correct. count_tokens strips thinking/headers/additionalModelRequestFields in both providers; unaffected by the latch.
  • 8 concurrent streams on one BedrockModel → one retry, flag set once. thinking removed / model_id switched with the flag set → request unchanged. Caller config not mutated.
  • Codecov's 13.6% is stale — codecov/patch is now SUCCESS; locally anthropic.py 99%, bedrock.py 95%.
  • a328740's context-manager strip-and-retry is fully reverted; no leftovers. Public surface unchanged (TS exports are @internal, absent from index.ts, per strands-ts/AGENTS.md).

Withdrawn 🔴 — sticky before success

I first flagged that the latch is set before the retry succeeds, so a rejected retry poisons the instance for fresh conversations. For that to bite, a provider has to emit the binding error and reject drop_block/the beta. Nothing reachable does: live Bedrock accepts both; thinking.type: enabled on preserved-thinking models is rejected before any binding error; a string anthropic_beta is rejected up front. drop_block on a fresh conversation is a no-op. It's the trade-off the PR body already documents, so it isn't a finding.

Questions (non-blocking)

  1. When the retry itself fails, the original binding error is lost — __cause__/cause is None in both SDKs; the WARNING is the only trace. Worth chaining (raise … from error) so the second 400 is diagnosable?
  2. bedrock.py:505-509 strips thinking on forced tool choice before the latch applies, so a stale-block 400 on that path has nothing to hang block_binding on. Is it immune because the API ignores thinking blocks when thinking is off, or a real hole? (Not reproduced live — asking.)

Reading order

  1. bedrock.py:517-535 (_converse) — the retry and latch, clearest version.
  2. anthropic.py:1051-1074 — same, but recursive; the 🟡 lives here.
  3. model.py:125-143 — shared helpers.
  4. TS twins: bedrock.ts:792-810, anthropic.ts:425-434 + 454-459.

Appendix — non-blocking (5)

  1. ⚪ bedrock.py:1512-1518 — if the drop_block retry is the request that trips turn separation, the separated request is rebuilt from the pre-flag request: 4 calls instead of 3 and the warning logs twice. Self-healing; never observed live (Bedrock's structural validation precedes the binding check).
  2. ⚪ model.py:131 — bare substring match, no error-code check, unlike bedrock.py:1505-1509. No false positive found.
  3. ⚪ Untested edges: binding error on a continuation (must raise), retry itself failing (must surface, not recurse), TS client-default-header path.
  4. ⚪ summarize.py:104 — a reasoning-only message now flattens to a bare [assistant] marker. Request stays valid.
  5. ⚪ Three near-identically named helpers (model._with_drop_block, AnthropicModel._with_drop_block, bedrock._with_drop_block_fields) take three shapes; anthropic.py:555 calls the module one from the method of the same name.

Process

typescript label missing (~half the diff). TypeScript / Test (Node 22 on macos-latest) is red with only "exit code 1"; full suite passes locally, likely infra — re-run.

Evidence (scripts + raw output): s3://strandly-prod-artifacts-319426459652/strands-agents/harness-sdk/pr/5171/adversarial-*.

"model_id=<%s> | history edit invalidated replayed thinking blocks, retrying with drop_block",
request["model"],
)
self._drop_stale_thinking_model_id = request["model"]

@strandly-the-agent strandly-the-agent Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The latch is written from request["model"] but read against self.config["model_id"] (line 548), so if they disagree the retry recurses without bound. params is spread after "model" at line 544, so params={"model": ...} makes format_request never add block_binding, the retry condition never clears, and stream() re-enters once per failed request:

PR    RecursionError, ~970 HTTP 400s
main  BadRequestError, 1 request

Unusual-but-legal config via the documented params passthrough. anthropic.ts:431 already writes this._config.modelId, so this is also a parity fix. A loop with a retried flag (like _converse) would bound it structurally instead of relying on two pieces of state agreeing.

Suggested change
self._drop_stale_thinking_model_id = request["model"]
self._drop_stale_thinking_model_id = self.config["model_id"]

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-context Session or context related area-model Related to models or model providers bug Something isn't working complexity/high A touched function exceeds cognitive complexity 25; may be worth splitting python Pull requests that update python code size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Context Manager Opus 5.5 Distillation-protection support

2 participants