Repository navigation
Conversation
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.
|
@strandly-the-agent pls review |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
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 fromrequest["model"]but read viaself.config["model_id"];params={"model": …}makes them disagree andstream()recurses without bound (~970 billable 400s thenRecursionError). One-word fix, inline. - 🟡
anthropic.ts:455— a beta set viaclientConfig.defaultHeadersis silently dropped on the retry and every later request:_buildRequestOptionsonly reads_config.betas, and the SDK's per-requestanthropic-betareplaces the client default. Python handles this (anthropic.py:559-560); TS doesn't. Observed on the wire with the real SDK: req0client-beta→ req1thinking-binding-controls-2026-08-01alone. Fold the client default in, or documentbetasas 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 runon the touched TS test files → 816 passed, no type errors (fullsrc/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
ValidationExceptionfor bothconverseandconverse_stream, message matches the substring verbatim;_conversecatching onlyClientErroris sufficient. E2E through both SDKs with enforcement simulated client-side (as the author did): edited history → real 400 → retry succeeds → next turn sendsdrop_blockdirectly; exactly onemessageStart. - No duplicate events on the recursive retry: the Python 400 is raised at
__aenter__before anything is yielded;continuations == 0correctly excludes continuation requests (a binding error there raises — right, since events were already emitted). anthropic0.125:client.default_headersexposesanthropic-beta, and per-requestextra_headersreplaces it — so the Python merge is needed and correct.count_tokensstripsthinking/headers/additionalModelRequestFieldsin both providers; unaffected by the latch.- 8 concurrent streams on one
BedrockModel→ one retry, flag set once.thinkingremoved / model_id switched with the flag set → request unchanged. Caller config not mutated. - Codecov's 13.6% is stale —
codecov/patchis now SUCCESS; locallyanthropic.py99%,bedrock.py95%. a328740's context-manager strip-and-retry is fully reverted; no leftovers. Public surface unchanged (TS exports are@internal, absent fromindex.ts, perstrands-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)
- When the retry itself fails, the original binding error is lost —
__cause__/causeisNonein both SDKs; the WARNING is the only trace. Worth chaining (raise … from error) so the second 400 is diagnosable? bedrock.py:505-509stripsthinkingon forced tool choice before the latch applies, so a stale-block 400 on that path has nothing to hangblock_bindingon. Is it immune because the API ignores thinking blocks when thinking is off, or a real hole? (Not reproduced live — asking.)
Reading order
bedrock.py:517-535(_converse) — the retry and latch, clearest version.anthropic.py:1051-1074— same, but recursive; the 🟡 lives here.model.py:125-143— shared helpers.- TS twins:
bedrock.ts:792-810,anthropic.ts:425-434+454-459.
Appendix — non-blocking (5)
- ⚪
bedrock.py:1512-1518— if thedrop_blockretry is the request that trips turn separation, the separated request is rebuilt from the pre-flagrequest: 4 calls instead of 3 and the warning logs twice. Self-healing; never observed live (Bedrock's structural validation precedes the binding check). - ⚪
model.py:131— bare substring match, no error-code check, unlikebedrock.py:1505-1509. No false positive found. - ⚪ Untested edges: binding error on a continuation (must raise), retry itself failing (must surface, not recurse), TS client-default-header path.
- ⚪
summarize.py:104— a reasoning-only message now flattens to a bare[assistant]marker. Request stays valid. - ⚪ Three near-identically named helpers (
model._with_drop_block,AnthropicModel._with_drop_block,bedrock._with_drop_block_fields) take three shapes;anthropic.py:555calls 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"] |
There was a problem hiding this comment.
🟡 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.
| self._drop_stale_thinking_model_id = request["model"] | |
| self._drop_stale_thinking_model_id = self.config["model_id"] |
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 thethinking-binding-controls-2026-08-01beta. 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-setblock_bindingis 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.summarizefiring 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 senddrop_blockdirectly. 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.hatch run prepareAdditional 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_blocka 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_blockis 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_blockonly 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_headersor the client's default headers in Python,betasin TypeScript.Complexity. Labeled high because the change touches TypeScript
AnthropicModel::stream, which was already near 90; the retry branch adds about 4.Checklist
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