From a328740115d94085ee035a639fbf612ae142a4a0 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:02:40 -0400 Subject: [PATCH 1/8] fix(context): strip thinking blocks when a history edit invalidates them 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. --- .../_context_manager/context_manager.py | 42 ++++++++ .../_context_manager/methods/summarize.py | 7 +- .../methods/test_summarize.py | 13 +++ .../_context_manager/test_context_manager.py | 81 ++++++++++++++ .../__tests__/context-manager.test.ts | 100 +++++++++++++++++- .../src/context-manager/context-manager.ts | 47 ++++++++ .../methods/__tests__/summarize.test.ts | 16 +++ .../src/context-manager/methods/summarize.ts | 5 +- 8 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 strands-ts/src/context-manager/methods/__tests__/summarize.test.ts diff --git a/strands-py/src/strands/_context_manager/context_manager.py b/strands-py/src/strands/_context_manager/context_manager.py index 006900b6b1..e345d597e6 100644 --- a/strands-py/src/strands/_context_manager/context_manager.py +++ b/strands-py/src/strands/_context_manager/context_manager.py @@ -18,6 +18,7 @@ from .retrieval_tool import _create_retrieval_tool, _track_retrieval_tool_use_ids from .stash import Stash from .strategies.offload import Offload +from .strategies.offload.base import _repair_alternation from .strategies.offload.truncate import EmergencyTruncateStrategy from .types import ContextManagerConfig, ContextState, ContextStrategy, StashConfig @@ -25,6 +26,7 @@ from ..agent.agent import Agent from ..agent.conversation_manager import ConversationManager from ..storage.storage import Storage + from ..types.content import Messages logger = logging.getLogger(__name__) @@ -32,6 +34,7 @@ _AGENTIC_TRUNCATE_THRESHOLD = 8_000 _TRUNCATE_PREVIEW_TOKENS = 750 _AUTO_SUMMARIZE_UTILIZATION = 0.85 +_THINKING_BINDING_ERROR = "bound to a different conversation" CONTEXT_MANAGER_PRESETS: tuple[str, ...] = ("auto", "agentic") @@ -233,6 +236,15 @@ async def _on_before_model_call(event: BeforeModelCallEvent) -> None: async def _on_after_model_call(event: AfterModelCallEvent) -> None: nonlocal overflow_retries + if _is_thinking_binding_error(event.exception): + if _strip_reasoning(event.agent.messages): + logger.warning( + "agent_id=<%s> | history edit invalidated thinking blocks, stripping them and retrying", + event.agent.agent_id, + ) + event.retry = True + return + if not isinstance(event.exception, ContextWindowOverflowException): overflow_retries = 0 return @@ -315,3 +327,33 @@ async def _run_strategies( exc_info=True, ) return any_acted + + +def _is_thinking_binding_error(exception: Exception | None) -> bool: + """Whether the provider rejected a replayed thinking block because history before it changed.""" + return exception is not None and _THINKING_BINDING_ERROR in str(exception) + + +def _strip_reasoning(messages: Messages) -> bool: + """Remove reasoning blocks from assistant messages, dropping any assistant message left empty. + + Returns: + True if any reasoning block was removed. + """ + stripped = False + emptied = False + for index, message in enumerate(messages): + if message["role"] != "assistant": + continue + kept = [block for block in message["content"] if "reasoningContent" not in block] + if len(kept) == len(message["content"]): + continue + stripped = True + emptied = emptied or not kept + replacement = message.copy() + replacement["content"] = kept + messages[index] = replacement + if emptied: + messages[:] = [message for message in messages if message["content"] or message["role"] != "assistant"] + _repair_alternation(messages) + return stripped diff --git a/strands-py/src/strands/_context_manager/methods/summarize.py b/strands-py/src/strands/_context_manager/methods/summarize.py index 82beaacfa8..8a3610a7d1 100644 --- a/strands-py/src/strands/_context_manager/methods/summarize.py +++ b/strands-py/src/strands/_context_manager/methods/summarize.py @@ -92,14 +92,17 @@ def _tool_result_to_content_blocks(content: list[ToolResultContent]) -> list[Con def _flatten_messages_to_content(messages: Messages) -> list[ContentBlock]: - """Flatten messages into a single ContentBlock list with role markers.""" + """Flatten messages into a single ContentBlock list with role markers. + + Reasoning blocks are left out: their signatures are bound to the conversation that produced them. + """ blocks: list[ContentBlock] = [] for message in messages: blocks.append(ContentBlock(text=f"\n---\n[{message['role']}]")) for block in message["content"]: if "toolResult" in block: blocks.extend(_tool_result_to_content_blocks(block["toolResult"]["content"])) - else: + elif "reasoningContent" not in block: blocks.append(block) return blocks diff --git a/strands-py/tests/strands/_context_manager/methods/test_summarize.py b/strands-py/tests/strands/_context_manager/methods/test_summarize.py index 263c01dd0c..bb58c994ed 100644 --- a/strands-py/tests/strands/_context_manager/methods/test_summarize.py +++ b/strands-py/tests/strands/_context_manager/methods/test_summarize.py @@ -91,6 +91,19 @@ def test_extracts_tool_result_content(self): assert tru_result[0] == ContentBlock(text="\n---\n[user]") assert tru_result[1] == ContentBlock(text="result data") + def test_excludes_reasoning(self): + messages = [ + Message( + role="assistant", + content=[ + ContentBlock(reasoningContent={"reasoningText": {"text": "plan", "signature": "sig"}}), + ContentBlock(text="hi"), + ], + ), + ] + tru_result = _flatten_messages_to_content(messages) + assert tru_result == [ContentBlock(text="\n---\n[assistant]"), ContentBlock(text="hi")] + class TestSummarizeContent: """Tests for the summarize_content function.""" diff --git a/strands-py/tests/strands/_context_manager/test_context_manager.py b/strands-py/tests/strands/_context_manager/test_context_manager.py index a308c86c38..46051a192c 100644 --- a/strands-py/tests/strands/_context_manager/test_context_manager.py +++ b/strands-py/tests/strands/_context_manager/test_context_manager.py @@ -212,6 +212,87 @@ async def test_recomputes_utilization_after_strategy_acts(self, mock_agent): assert call_args.utilization == 0.4 +def _reasoning(text): + return ContentBlock(reasoningContent={"reasoningText": {"text": text, "signature": "sig"}}) + + +_BINDING_ERROR = ( + "messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different " + 'conversation. Remove the block, or set `thinking.block_binding.prefix_mismatch_behavior` to "drop_block".' +) + + +class TestThinkingBindingRecovery: + """Tests for recovering from thinking blocks invalidated by a history edit.""" + + @pytest.mark.asyncio + async def test_strips_reasoning_and_retries(self, mock_agent): + mock_agent.messages = [ + Message(role="user", content=[ContentBlock(text="hello")]), + Message(role="assistant", content=[_reasoning("plan"), ContentBlock(text="hi")]), + Message(role="user", content=[ContentBlock(text="again")]), + Message(role="assistant", content=[_reasoning("plan more"), ContentBlock(text="sure")]), + ] + cm = ContextManager() + cm.init_agent(mock_agent) + + event = AfterModelCallEvent(agent=mock_agent, exception=RuntimeError(_BINDING_ERROR)) + await mock_agent.hooks.invoke_callbacks_async(event) + + assert event.retry is True + assert mock_agent.messages == [ + Message(role="user", content=[ContentBlock(text="hello")]), + Message(role="assistant", content=[ContentBlock(text="hi")]), + Message(role="user", content=[ContentBlock(text="again")]), + Message(role="assistant", content=[ContentBlock(text="sure")]), + ] + + @pytest.mark.asyncio + async def test_drops_reasoning_only_message_and_repairs_alternation(self, mock_agent): + mock_agent.messages = [ + Message(role="user", content=[ContentBlock(text="hello")]), + Message(role="assistant", content=[_reasoning("plan")]), + Message(role="user", content=[ContentBlock(text="again")]), + Message(role="assistant", content=[ContentBlock(text="sure")]), + ] + cm = ContextManager() + cm.init_agent(mock_agent) + + event = AfterModelCallEvent(agent=mock_agent, exception=RuntimeError(_BINDING_ERROR)) + await mock_agent.hooks.invoke_callbacks_async(event) + + assert event.retry is True + assert mock_agent.messages == [ + Message(role="user", content=[ContentBlock(text="hello"), ContentBlock(text="again")]), + Message(role="assistant", content=[ContentBlock(text="sure")]), + ] + + @pytest.mark.asyncio + async def test_no_retry_when_no_reasoning_left(self, mock_agent): + cm = ContextManager() + cm.init_agent(mock_agent) + + event = AfterModelCallEvent(agent=mock_agent, exception=RuntimeError(_BINDING_ERROR)) + await mock_agent.hooks.invoke_callbacks_async(event) + + assert event.retry is not True + + @pytest.mark.asyncio + async def test_other_errors_keep_reasoning(self, mock_agent): + mock_agent.messages = [ + Message(role="user", content=[ContentBlock(text="hello")]), + Message(role="assistant", content=[_reasoning("plan"), ContentBlock(text="hi")]), + ] + cm = ContextManager() + cm.init_agent(mock_agent) + + event = AfterModelCallEvent(agent=mock_agent, exception=RuntimeError("Invalid model identifier")) + await mock_agent.hooks.invoke_callbacks_async(event) + + assert event.retry is not True + assert mock_agent.messages[1]["content"][0] == _reasoning("plan") + + class TestFromStrategy: """Tests for ContextManager.from_strategy() factory.""" diff --git a/strands-ts/src/context-manager/__tests__/context-manager.test.ts b/strands-ts/src/context-manager/__tests__/context-manager.test.ts index a155bc6bb4..e1a56d9224 100644 --- a/strands-ts/src/context-manager/__tests__/context-manager.test.ts +++ b/strands-ts/src/context-manager/__tests__/context-manager.test.ts @@ -3,7 +3,7 @@ import { ContextManager } from '../context-manager.js' import { createMockAgent, invokeTrackedHook } from '../../__fixtures__/agent-helpers.js' import { AfterModelCallEvent, BeforeModelCallEvent } from '../../hooks/events.js' import { ContextWindowOverflowError } from '../../errors.js' -import { Message, TextBlock, ToolResultBlock, ToolUseBlock } from '../../types/messages.js' +import { Message, ReasoningBlock, TextBlock, ToolResultBlock, ToolUseBlock } from '../../types/messages.js' import type { Agent } from '../../agent/agent.js' import { InMemoryStorage } from '../../storage/in-memory-storage.js' @@ -38,6 +38,24 @@ function makeOverflowEvent(agent: ReturnType) { }) } +const BINDING_ERROR = + 'messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different ' + + 'conversation. Remove the block, or set `thinking.block_binding.prefix_mismatch_behavior` to "drop_block".' + +function makeErrorEvent(agent: ReturnType, message: string) { + return new AfterModelCallEvent({ + agent, + model: agent.model, + invocationState: {}, + attemptCount: 1, + error: new Error(message), + }) +} + +function reasoning(text: string) { + return new ReasoningBlock({ text, signature: 'sig' }) +} + function makeBeforeEvent(agent: ReturnType, projectedInputTokens?: number) { return new BeforeModelCallEvent({ agent, @@ -328,6 +346,86 @@ describe('ContextManager', () => { }) }) + describe('thinking binding recovery', () => { + it('strips reasoning and retries', async () => { + const agent = makeMockAgent({ + messages: [ + new Message({ role: 'user', content: [new TextBlock('hello')] }), + new Message({ role: 'assistant', content: [reasoning('plan'), new TextBlock('hi')] }), + new Message({ role: 'user', content: [new TextBlock('again')] }), + new Message({ role: 'assistant', content: [reasoning('plan more'), new TextBlock('sure')] }), + ], + }) + const trackingIds = agent.messages.map((message) => message.trackingId) + await new ContextManager().initAgent(agent) + + const event = makeErrorEvent(agent, BINDING_ERROR) + await invokeTrackedHook(agent, event) + + expect(event.retry).toBe(true) + expect(agent.messages.map((message) => [message.role, message.content])).toEqual([ + ['user', [new TextBlock('hello')]], + ['assistant', [new TextBlock('hi')]], + ['user', [new TextBlock('again')]], + ['assistant', [new TextBlock('sure')]], + ]) + expect(agent.messages.map((message) => message.trackingId)).toEqual(trackingIds) + }) + + it('drops reasoning-only messages and repairs alternation', async () => { + const agent = makeMockAgent({ + messages: [ + new Message({ role: 'user', content: [new TextBlock('hello')] }), + new Message({ role: 'assistant', content: [reasoning('plan')] }), + new Message({ role: 'user', content: [new TextBlock('again')] }), + new Message({ role: 'assistant', content: [new TextBlock('sure')] }), + ], + }) + await new ContextManager().initAgent(agent) + + const event = makeErrorEvent(agent, BINDING_ERROR) + await invokeTrackedHook(agent, event) + + expect(event.retry).toBe(true) + expect(agent.messages.map((message) => [message.role, message.content])).toEqual([ + ['user', [new TextBlock('hello'), new TextBlock('again')]], + ['assistant', [new TextBlock('sure')]], + ]) + }) + + it('does not retry when no reasoning is left', async () => { + const agent = makeMockAgent({ + messages: [ + new Message({ role: 'user', content: [new TextBlock('hello')] }), + new Message({ role: 'assistant', content: [new TextBlock('hi')] }), + ], + }) + await new ContextManager().initAgent(agent) + + const event = makeErrorEvent(agent, BINDING_ERROR) + await invokeTrackedHook(agent, event) + + expect(event.retry).toBeUndefined() + }) + + it('keeps reasoning on other errors', async () => { + const block = reasoning('plan') + const agent = makeMockAgent({ + messages: [ + new Message({ role: 'user', content: [new TextBlock('hello')] }), + new Message({ role: 'assistant', content: [block, new TextBlock('hi')] }), + ], + }) + await new ContextManager().initAgent(agent) + + const event = makeErrorEvent(agent, 'Invalid model identifier') + await invokeTrackedHook(agent, event) + + expect(event.retry).toBeUndefined() + expect(agent.messages[1]!.content[0]).toBe(block) + }) + }) + describe('truncation', () => { it('preserves the first message content', async () => { const messages = [ diff --git a/strands-ts/src/context-manager/context-manager.ts b/strands-ts/src/context-manager/context-manager.ts index 98df738123..36643cf49e 100644 --- a/strands-ts/src/context-manager/context-manager.ts +++ b/strands-ts/src/context-manager/context-manager.ts @@ -7,6 +7,7 @@ import type { Plugin } from '../plugins/plugin.js' import type { Tool } from '../tools/tool.js' import type { LocalAgent } from '../types/agent.js' +import { Message, ReasoningBlock } from '../types/messages.js' import { AfterModelCallEvent, BeforeModelCallEvent, MessageAddedEvent } from '../hooks/events.js' import { ContextWindowOverflowError } from '../errors.js' import { InMemoryStorage } from '../storage/in-memory-storage.js' @@ -15,6 +16,7 @@ import type { Storage } from '../storage/storage.js' import { logger } from '../logging/logger.js' import type { ContextManagerConfig, ContextStrategy, ContextState } from './types.js' import { EmergencyTruncateStrategy, Offload } from './strategies/offload/index.js' +import { repairAlternation } from './strategies/offload/base.js' import { resolveStrategies } from './presets.js' import { Stash } from './stash.js' import { createRetrievalTool, trackRetrievalToolUseIds } from './retrieval-tool.js' @@ -23,6 +25,7 @@ const AUTO_TRUNCATE_THRESHOLD = 1_500 const AGENTIC_TRUNCATE_THRESHOLD = 8_000 const TRUNCATE_PREVIEW_TOKENS = 750 const AUTO_SUMMARIZE_UTILIZATION = 0.85 +const THINKING_BINDING_ERROR = 'bound to a different conversation' /** @internal */ export const CONTEXT_MANAGER_PRESETS = ['auto', 'agentic'] as const @@ -159,6 +162,16 @@ export class ContextManager implements Plugin { // Assumes sequential invocations on this agent (no concurrent calls) let overflowRetries = 0 agent.addHook(AfterModelCallEvent, async (event) => { + if (isThinkingBindingError(event.error)) { + if (stripReasoning(event.agent.messages)) { + logger.warn( + `agentId=<${event.agent.id}> | history edit invalidated thinking blocks, stripping them and retrying` + ) + event.retry = true + } + return + } + if (!(event.error instanceof ContextWindowOverflowError)) { overflowRetries = 0 return @@ -235,3 +248,37 @@ export class ContextManager implements Plugin { return anyActed } } + +/** Whether the provider rejected a replayed thinking block because history before it changed. */ +function isThinkingBindingError(error: Error | undefined): boolean { + return error?.message.includes(THINKING_BINDING_ERROR) ?? false +} + +/** + * Removes reasoning blocks from assistant messages, dropping any assistant message left empty. + * + * @returns true if any reasoning block was removed + */ +function stripReasoning(messages: Message[]): boolean { + let stripped = false + let emptied = false + for (const [index, message] of messages.entries()) { + if (message.role !== 'assistant') continue + const kept = message.content.filter((block) => !(block instanceof ReasoningBlock)) + if (kept.length === message.content.length) continue + stripped = true + emptied ||= kept.length === 0 + messages[index] = new Message({ + role: 'assistant', + content: kept, + trackingId: message.trackingId, + ...(message.metadata ? { metadata: message.metadata } : {}), + }) + } + if (emptied) { + const nonEmpty = messages.filter((message) => message.content.length > 0 || message.role !== 'assistant') + messages.splice(0, messages.length, ...nonEmpty) + repairAlternation(messages) + } + return stripped +} diff --git a/strands-ts/src/context-manager/methods/__tests__/summarize.test.ts b/strands-ts/src/context-manager/methods/__tests__/summarize.test.ts new file mode 100644 index 0000000000..360f9891ab --- /dev/null +++ b/strands-ts/src/context-manager/methods/__tests__/summarize.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { flattenMessagesToContent } from '../summarize.js' +import { Message, ReasoningBlock, TextBlock } from '../../../types/messages.js' + +describe('flattenMessagesToContent', () => { + it('excludes reasoning blocks', () => { + const messages = [ + new Message({ + role: 'assistant', + content: [new ReasoningBlock({ text: 'plan', signature: 'sig' }), new TextBlock('hi')], + }), + ] + + expect(flattenMessagesToContent(messages)).toEqual([new TextBlock('\n---\n[assistant]'), new TextBlock('hi')]) + }) +}) diff --git a/strands-ts/src/context-manager/methods/summarize.ts b/strands-ts/src/context-manager/methods/summarize.ts index 839c2ca83f..7277204cc7 100644 --- a/strands-ts/src/context-manager/methods/summarize.ts +++ b/strands-ts/src/context-manager/methods/summarize.ts @@ -7,7 +7,7 @@ */ import type { Model } from '../../models/model.js' -import { JsonBlock, Message, TextBlock, ToolResultBlock } from '../../types/messages.js' +import { JsonBlock, Message, ReasoningBlock, TextBlock, ToolResultBlock } from '../../types/messages.js' import type { ContentBlock, ToolResultContent } from '../../types/messages.js' import { logger } from '../../logging/logger.js' @@ -87,6 +87,7 @@ export function toolResultToContentBlocks(content: ToolResultContent[]): Content /** * Flattens a range of messages into a single ContentBlock array for multimodal summarization. * Inserts role markers and separators so the summarizer understands message boundaries. + * Reasoning blocks are left out: their signatures are bound to the conversation that produced them. */ export function flattenMessagesToContent(messages: Message[]): ContentBlock[] { const blocks: ContentBlock[] = [] @@ -95,7 +96,7 @@ export function flattenMessagesToContent(messages: Message[]): ContentBlock[] { for (const block of message.content) { if (block instanceof ToolResultBlock) { blocks.push(...toolResultToContentBlocks(block.content)) - } else { + } else if (!(block instanceof ReasoningBlock)) { blocks.push(block) } } From c09b236654ef45aa6b05d5015424e66bc118daa5 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:35:46 -0400 Subject: [PATCH 2/8] fix(models): retry with drop_block when a history edit invalidates replayed 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. --- .../_context_manager/context_manager.py | 42 -------- strands-py/src/strands/models/anthropic.py | 53 +++++++++- strands-py/src/strands/models/bedrock.py | 49 ++++++++- strands-py/src/strands/models/model.py | 21 ++++ .../_context_manager/test_context_manager.py | 81 -------------- .../tests/strands/models/test_anthropic.py | 83 +++++++++++++++ .../tests/strands/models/test_bedrock.py | 98 +++++++++++++++++ .../__tests__/context-manager.test.ts | 100 +----------------- .../src/context-manager/context-manager.ts | 47 -------- .../src/models/__tests__/anthropic.test.ts | 72 +++++++++++++ .../src/models/__tests__/bedrock.test.ts | 81 ++++++++++++++ strands-ts/src/models/anthropic.ts | 30 +++++- strands-ts/src/models/bedrock.ts | 70 ++++++++++-- strands-ts/src/models/model.ts | 37 +++++++ 14 files changed, 578 insertions(+), 286 deletions(-) diff --git a/strands-py/src/strands/_context_manager/context_manager.py b/strands-py/src/strands/_context_manager/context_manager.py index e345d597e6..006900b6b1 100644 --- a/strands-py/src/strands/_context_manager/context_manager.py +++ b/strands-py/src/strands/_context_manager/context_manager.py @@ -18,7 +18,6 @@ from .retrieval_tool import _create_retrieval_tool, _track_retrieval_tool_use_ids from .stash import Stash from .strategies.offload import Offload -from .strategies.offload.base import _repair_alternation from .strategies.offload.truncate import EmergencyTruncateStrategy from .types import ContextManagerConfig, ContextState, ContextStrategy, StashConfig @@ -26,7 +25,6 @@ from ..agent.agent import Agent from ..agent.conversation_manager import ConversationManager from ..storage.storage import Storage - from ..types.content import Messages logger = logging.getLogger(__name__) @@ -34,7 +32,6 @@ _AGENTIC_TRUNCATE_THRESHOLD = 8_000 _TRUNCATE_PREVIEW_TOKENS = 750 _AUTO_SUMMARIZE_UTILIZATION = 0.85 -_THINKING_BINDING_ERROR = "bound to a different conversation" CONTEXT_MANAGER_PRESETS: tuple[str, ...] = ("auto", "agentic") @@ -236,15 +233,6 @@ async def _on_before_model_call(event: BeforeModelCallEvent) -> None: async def _on_after_model_call(event: AfterModelCallEvent) -> None: nonlocal overflow_retries - if _is_thinking_binding_error(event.exception): - if _strip_reasoning(event.agent.messages): - logger.warning( - "agent_id=<%s> | history edit invalidated thinking blocks, stripping them and retrying", - event.agent.agent_id, - ) - event.retry = True - return - if not isinstance(event.exception, ContextWindowOverflowException): overflow_retries = 0 return @@ -327,33 +315,3 @@ async def _run_strategies( exc_info=True, ) return any_acted - - -def _is_thinking_binding_error(exception: Exception | None) -> bool: - """Whether the provider rejected a replayed thinking block because history before it changed.""" - return exception is not None and _THINKING_BINDING_ERROR in str(exception) - - -def _strip_reasoning(messages: Messages) -> bool: - """Remove reasoning blocks from assistant messages, dropping any assistant message left empty. - - Returns: - True if any reasoning block was removed. - """ - stripped = False - emptied = False - for index, message in enumerate(messages): - if message["role"] != "assistant": - continue - kept = [block for block in message["content"] if "reasoningContent" not in block] - if len(kept) == len(message["content"]): - continue - stripped = True - emptied = emptied or not kept - replacement = message.copy() - replacement["content"] = kept - messages[index] = replacement - if emptied: - messages[:] = [message for message in messages if message["content"] or message["role"] != "assistant"] - _repair_alternation(messages) - return stripped diff --git a/strands-py/src/strands/models/anthropic.py b/strands-py/src/strands/models/anthropic.py index 4286a5475d..ef604381d3 100644 --- a/strands-py/src/strands/models/anthropic.py +++ b/strands-py/src/strands/models/anthropic.py @@ -33,7 +33,15 @@ from ..types.tools import ToolChoice, ToolChoiceToolDict, ToolSpec from ._defaults import resolve_config_metadata from ._validation import _has_location_source, _warn_on_deprecated_cache_tools, validate_config_keys -from .model import BaseModelConfig, CacheConfig, CacheToolsConfig, Model +from .model import ( + _THINKING_BINDING_BETA, + BaseModelConfig, + CacheConfig, + CacheToolsConfig, + Model, + _is_thinking_binding_error, + _with_drop_block, +) logger = logging.getLogger(__name__) @@ -148,6 +156,7 @@ def __init__(self, *, client_args: dict[str, Any] | None = None, **model_config: client_args = client_args or {} self.client = anthropic.AsyncAnthropic(**client_args) + self._drop_stale_thinking_model_id: str | None = None @override def update_config(self, **model_config: Unpack[AnthropicConfig]) -> None: # type: ignore[override] @@ -536,8 +545,25 @@ def format_request( "tools": tools, } + if self._drop_stale_thinking_model_id == self.config["model_id"]: + request = self._with_drop_block(request) or request + return request + def _with_drop_block(self, request: dict[str, Any]) -> dict[str, Any] | None: + """Return the request with stale thinking blocks dropped, or None if it cannot change.""" + thinking = _with_drop_block(request.get("thinking")) + if thinking is None: + return None + extra_headers = dict(request.get("extra_headers") or {}) + # Request headers replace client headers of the same name, so betas set on the client are carried over. + configured = extra_headers.get("anthropic-beta") or self.client.default_headers.get("anthropic-beta") + betas = [beta.strip() for beta in configured.split(",")] if isinstance(configured, str) else [] + if _THINKING_BINDING_BETA not in betas: + betas.append(_THINKING_BINDING_BETA) + extra_headers["anthropic-beta"] = ",".join(beta for beta in betas if beta) + return {**request, "thinking": thinking, "extra_headers": extra_headers} + def _format_system_prompt( self, system_prompt: str | None, system_prompt_content: list[SystemContentBlock] | None ) -> str | list[dict[str, Any]] | None: @@ -937,6 +963,7 @@ async def stream( usage: dict[str, int] = {} block_index_offset = 0 continuations = 0 + retry_with_drop_block = False try: while True: async with self.client.messages.stream(**request) as stream: @@ -1021,7 +1048,29 @@ async def stream( if any(overflow_message in str(error).lower() for overflow_message in AnthropicModel.OVERFLOW_MESSAGES): raise ContextWindowOverflowException(str(error)) from error - raise error + retry_with_drop_block = ( + continuations == 0 and _is_thinking_binding_error(error) and self._with_drop_block(request) is not None + ) + if not retry_with_drop_block: + raise error + + if retry_with_drop_block: + # Stale blocks stay in history, so drop_block stays on for every later request to this model. + logger.warning( + "model_id=<%s> | history edit invalidated replayed thinking blocks, retrying with drop_block", + request["model"], + ) + self._drop_stale_thinking_model_id = request["model"] + async for chunk in self.stream( + messages, + tool_specs, + system_prompt, + tool_choice=tool_choice, + system_prompt_content=system_prompt_content, + **kwargs, + ): + yield chunk + return logger.debug("finished streaming response from model") diff --git a/strands-py/src/strands/models/bedrock.py b/strands-py/src/strands/models/bedrock.py index 82e91bc980..e66e63dd7a 100644 --- a/strands-py/src/strands/models/bedrock.py +++ b/strands-py/src/strands/models/bedrock.py @@ -36,7 +36,15 @@ from ._defaults import resolve_config_metadata from ._strict_schema import ensure_strict_json_schema from ._validation import _warn_on_deprecated_cache_tools, validate_config_keys -from .model import BaseModelConfig, CacheConfig, CacheToolsConfig, Model +from .model import ( + _THINKING_BINDING_BETA, + BaseModelConfig, + CacheConfig, + CacheToolsConfig, + Model, + _is_thinking_binding_error, + _with_drop_block, +) logger = logging.getLogger(__name__) @@ -141,6 +149,17 @@ async def _next_stream_event( _CANCEL_POLL_INTERVAL = 0.05 +def _with_drop_block_fields(fields: dict[str, Any]) -> dict[str, Any] | None: + """Return additional request fields that drop stale thinking blocks, or None if they cannot change.""" + thinking = _with_drop_block(fields.get("thinking")) + if thinking is None: + return None + betas = list(fields.get("anthropic_beta") or []) + if _THINKING_BINDING_BETA not in betas: + betas.append(_THINKING_BINDING_BETA) + return {**fields, "anthropic_beta": betas, "thinking": thinking} + + class BedrockModel(Model): """AWS Bedrock model provider implementation. @@ -260,6 +279,7 @@ def __init__( include_tool_result_status="auto", ) self._tool_result_turn_separation_model_id: str | None = None + self._drop_stale_thinking_model_id: str | None = None self.update_config(**model_config) logger.debug("config=<%s> | initializing", self.config) @@ -489,8 +509,31 @@ def _get_additional_request_fields(self, tool_choice: ToolChoice | None) -> dict return {"additionalModelRequestFields": fields_without_thinking} return {} + if self._drop_stale_thinking_model_id == self.config["model_id"]: + additional_fields = _with_drop_block_fields(additional_fields) or additional_fields + return {"additionalModelRequestFields": additional_fields} + def _converse(self, converse_method: Callable[..., Any], request: dict[str, Any]) -> Any: + """Invoke the model, retrying once with drop_block when an edit before replayed thinking made it stale. + + The retry turns drop_block on for every later request to this model, since the stale blocks stay in history. + """ + try: + return converse_method(**request) + except ClientError as error: + fields = request.get("additionalModelRequestFields") or {} + retry_fields = _with_drop_block_fields(fields) if _is_thinking_binding_error(error) else None + if retry_fields is None: + raise + + logger.warning( + "model_id=<%s> | history edit invalidated replayed thinking blocks, retrying with drop_block", + request["modelId"], + ) + self._drop_stale_thinking_model_id = request["modelId"] + return converse_method(**{**request, "additionalModelRequestFields": retry_fields}) + def _apply_system_cache_ttl( self, system_blocks: list[SystemContentBlock], tools_cache_point: list[dict[str, Any]] ) -> list[SystemContentBlock]: @@ -1456,7 +1499,7 @@ def _stream( converse_method = self.client.converse_stream if streaming else self.client.converse try: - response = converse_method(**request) + response = self._converse(converse_method, request) except ClientError as error: error_details = error.response.get("Error", {}) if error_details.get( @@ -1472,7 +1515,7 @@ def _stream( logger.debug("model_id=<%s> | separating tool result and conversation turns", model_id) request = {**request, "messages": separated_messages} - response = converse_method(**request) + response = self._converse(converse_method, request) self._tool_result_turn_separation_model_id = model_id logger.debug("got response from model") diff --git a/strands-py/src/strands/models/model.py b/strands-py/src/strands/models/model.py index 6aa0cb74dd..34596f5088 100644 --- a/strands-py/src/strands/models/model.py +++ b/strands-py/src/strands/models/model.py @@ -122,6 +122,27 @@ def _estimate_tokens_with_heuristic( return total +# Preserved-thinking Claude models bind each thinking block to the content before it. When that content +# changes, enforcing accounts reject the request unless it asks the API to drop the stale blocks. +_THINKING_BINDING_BETA = "thinking-binding-controls-2026-08-01" +_THINKING_BINDING_ERROR = "bound to a different conversation" + + +def _is_thinking_binding_error(error: BaseException) -> bool: + """Whether the provider rejected a replayed thinking block because history before it changed.""" + return _THINKING_BINDING_ERROR in str(error) + + +def _with_drop_block(thinking: Any) -> dict[str, Any] | None: + """Return the thinking config with stale blocks dropped, or None if it cannot or should not change. + + A caller-set ``block_binding`` is left as written. + """ + if not isinstance(thinking, dict) or "block_binding" in thinking: + return None + return {**thinking, "block_binding": {"prefix_mismatch_behavior": "drop_block"}} + + class BaseModelConfig(TypedDict, total=False): """Base configuration shared by all model providers. diff --git a/strands-py/tests/strands/_context_manager/test_context_manager.py b/strands-py/tests/strands/_context_manager/test_context_manager.py index 46051a192c..a308c86c38 100644 --- a/strands-py/tests/strands/_context_manager/test_context_manager.py +++ b/strands-py/tests/strands/_context_manager/test_context_manager.py @@ -212,87 +212,6 @@ async def test_recomputes_utilization_after_strategy_acts(self, mock_agent): assert call_args.utilization == 0.4 -def _reasoning(text): - return ContentBlock(reasoningContent={"reasoningText": {"text": text, "signature": "sig"}}) - - -_BINDING_ERROR = ( - "messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different " - 'conversation. Remove the block, or set `thinking.block_binding.prefix_mismatch_behavior` to "drop_block".' -) - - -class TestThinkingBindingRecovery: - """Tests for recovering from thinking blocks invalidated by a history edit.""" - - @pytest.mark.asyncio - async def test_strips_reasoning_and_retries(self, mock_agent): - mock_agent.messages = [ - Message(role="user", content=[ContentBlock(text="hello")]), - Message(role="assistant", content=[_reasoning("plan"), ContentBlock(text="hi")]), - Message(role="user", content=[ContentBlock(text="again")]), - Message(role="assistant", content=[_reasoning("plan more"), ContentBlock(text="sure")]), - ] - cm = ContextManager() - cm.init_agent(mock_agent) - - event = AfterModelCallEvent(agent=mock_agent, exception=RuntimeError(_BINDING_ERROR)) - await mock_agent.hooks.invoke_callbacks_async(event) - - assert event.retry is True - assert mock_agent.messages == [ - Message(role="user", content=[ContentBlock(text="hello")]), - Message(role="assistant", content=[ContentBlock(text="hi")]), - Message(role="user", content=[ContentBlock(text="again")]), - Message(role="assistant", content=[ContentBlock(text="sure")]), - ] - - @pytest.mark.asyncio - async def test_drops_reasoning_only_message_and_repairs_alternation(self, mock_agent): - mock_agent.messages = [ - Message(role="user", content=[ContentBlock(text="hello")]), - Message(role="assistant", content=[_reasoning("plan")]), - Message(role="user", content=[ContentBlock(text="again")]), - Message(role="assistant", content=[ContentBlock(text="sure")]), - ] - cm = ContextManager() - cm.init_agent(mock_agent) - - event = AfterModelCallEvent(agent=mock_agent, exception=RuntimeError(_BINDING_ERROR)) - await mock_agent.hooks.invoke_callbacks_async(event) - - assert event.retry is True - assert mock_agent.messages == [ - Message(role="user", content=[ContentBlock(text="hello"), ContentBlock(text="again")]), - Message(role="assistant", content=[ContentBlock(text="sure")]), - ] - - @pytest.mark.asyncio - async def test_no_retry_when_no_reasoning_left(self, mock_agent): - cm = ContextManager() - cm.init_agent(mock_agent) - - event = AfterModelCallEvent(agent=mock_agent, exception=RuntimeError(_BINDING_ERROR)) - await mock_agent.hooks.invoke_callbacks_async(event) - - assert event.retry is not True - - @pytest.mark.asyncio - async def test_other_errors_keep_reasoning(self, mock_agent): - mock_agent.messages = [ - Message(role="user", content=[ContentBlock(text="hello")]), - Message(role="assistant", content=[_reasoning("plan"), ContentBlock(text="hi")]), - ] - cm = ContextManager() - cm.init_agent(mock_agent) - - event = AfterModelCallEvent(agent=mock_agent, exception=RuntimeError("Invalid model identifier")) - await mock_agent.hooks.invoke_callbacks_async(event) - - assert event.retry is not True - assert mock_agent.messages[1]["content"][0] == _reasoning("plan") - - class TestFromStrategy: """Tests for ContextManager.from_strategy() factory.""" diff --git a/strands-py/tests/strands/models/test_anthropic.py b/strands-py/tests/strands/models/test_anthropic.py index b01c4755a7..0e0de7afbf 100644 --- a/strands-py/tests/strands/models/test_anthropic.py +++ b/strands-py/tests/strands/models/test_anthropic.py @@ -1055,6 +1055,89 @@ async def test_stream_bad_request_error(anthropic_client, model): await anext(model.stream(messages)) +def _thinking_binding_error(): + return anthropic.BadRequestError( + "messages.1.content.0: Invalid `signature` in `thinking` block. " + "The block is bound to a different conversation.", + response=unittest.mock.Mock(), + body=None, + ) + + +def _message_start_stream(): + event = unittest.mock.Mock(type="message_start", model_dump=lambda: {"type": "message_start"}) + final_message = unittest.mock.Mock(usage=unittest.mock.Mock(model_dump=lambda: {})) + return generate_mock_stream_context([event], final_message=final_message) + + +@pytest.mark.asyncio +async def test_stream_retries_with_drop_block_on_thinking_binding_error(anthropic_client, alist, messages, caplog): + anthropic_client.default_headers = {"anthropic-beta": "client-beta"} + anthropic_client.messages.stream.side_effect = [_thinking_binding_error(), _message_start_stream()] + model = AnthropicModel(model_id="m1", max_tokens=1, params={"thinking": {"type": "adaptive"}}) + + with caplog.at_level(logging.WARNING, logger="strands.models.anthropic"): + tru_events = await alist(model.stream(messages)) + + assert tru_events == [{"messageStart": {"role": "assistant"}}] + assert "retrying with drop_block" in caplog.text + + first_request, retry_request = [call.kwargs for call in anthropic_client.messages.stream.call_args_list] + assert first_request["thinking"] == {"type": "adaptive"} + assert "extra_headers" not in first_request + exp_thinking = {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "drop_block"}} + exp_headers = {"anthropic-beta": "client-beta,thinking-binding-controls-2026-08-01"} + assert retry_request["thinking"] == exp_thinking + assert retry_request["extra_headers"] == exp_headers + + anthropic_client.messages.stream.reset_mock(side_effect=True) + anthropic_client.messages.stream.return_value = _message_start_stream() + + await alist(model.stream(messages)) + + anthropic_client.messages.stream.assert_called_once() + assert anthropic_client.messages.stream.call_args.kwargs["thinking"] == exp_thinking + assert anthropic_client.messages.stream.call_args.kwargs["extra_headers"] == exp_headers + + +@pytest.mark.parametrize( + "params", + [ + {"thinking": {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}}}, + {"temperature": 1}, + ], +) +@pytest.mark.asyncio +async def test_stream_does_not_retry_thinking_binding_error_without_drop_block(anthropic_client, messages, params): + anthropic_client.messages.stream.side_effect = _thinking_binding_error() + model = AnthropicModel(model_id="m1", max_tokens=1, params=params) + + with pytest.raises(anthropic.BadRequestError, match="bound to a different conversation"): + await anext(model.stream(messages)) + + anthropic_client.messages.stream.assert_called_once() + assert model._drop_stale_thinking_model_id is None + + +def test_format_request_drops_stale_thinking_only_for_remembered_model(anthropic_client, messages): + _ = anthropic_client + model = AnthropicModel( + model_id="m1", + max_tokens=1, + params={"thinking": {"type": "adaptive"}, "extra_headers": {"anthropic-beta": "other-beta"}}, + ) + model._drop_stale_thinking_model_id = "m1" + + tru_request = model.format_request(messages) + + assert tru_request["thinking"] == {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "drop_block"}} + assert tru_request["extra_headers"] == {"anthropic-beta": "other-beta,thinking-binding-controls-2026-08-01"} + + model.update_config(model_id="m2") + + assert model.format_request(messages)["thinking"] == {"type": "adaptive"} + + @pytest.mark.asyncio async def test_structured_output(anthropic_client, model, test_output_model_cls, alist): messages = [{"role": "user", "content": [{"text": "Generate a person"}]}] diff --git a/strands-py/tests/strands/models/test_bedrock.py b/strands-py/tests/strands/models/test_bedrock.py index 5a4e8d781d..9e7478ab2d 100644 --- a/strands-py/tests/strands/models/test_bedrock.py +++ b/strands-py/tests/strands/models/test_bedrock.py @@ -1179,6 +1179,104 @@ def test_separate_tool_result_turns_ignores_conversation_only_user_turns(): assert BedrockModel._separate_tool_result_turns(messages) == messages +THINKING = {"type": "adaptive"} +DROP_BLOCK_THINKING = {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "drop_block"}} + + +def _thinking_binding_error(operation_name): + return ClientError( + { + "Error": { + "Code": "ValidationException", + "Message": ( + "messages.1.content.0: Invalid `signature` in `thinking` block. " + "The block is bound to a different conversation." + ), + } + }, + operation_name, + ) + + +def _converse_response(streaming): + if streaming: + return {"stream": []} + return {"output": {"message": {"role": "assistant", "content": [{"text": "Done"}]}}, "stopReason": "end_turn"} + + +@pytest.mark.parametrize("streaming", [True, False]) +@pytest.mark.asyncio +async def test_stream_retries_with_drop_block_on_thinking_binding_error( + bedrock_client, alist, messages, streaming, caplog +): + converse_method = bedrock_client.converse_stream if streaming else bedrock_client.converse + converse_method.side_effect = [ + _thinking_binding_error("ConverseStream" if streaming else "Converse"), + _converse_response(streaming), + ] + model = BedrockModel( + model_id="m1", + streaming=streaming, + additional_request_fields={"thinking": THINKING, "anthropic_beta": ["other-beta"]}, + ) + + with caplog.at_level(logging.WARNING, logger="strands.models.bedrock"): + await alist(model.stream(messages)) + + tru_fields = [call.kwargs["additionalModelRequestFields"] for call in converse_method.call_args_list] + exp_fields = [ + {"thinking": THINKING, "anthropic_beta": ["other-beta"]}, + {"thinking": DROP_BLOCK_THINKING, "anthropic_beta": ["other-beta", "thinking-binding-controls-2026-08-01"]}, + ] + assert tru_fields == exp_fields + assert "retrying with drop_block" in caplog.text + + converse_method.reset_mock(side_effect=True) + converse_method.return_value = _converse_response(streaming) + + await alist(model.stream(messages)) + + converse_method.assert_called_once() + assert converse_method.call_args.kwargs["additionalModelRequestFields"] == exp_fields[1] + + +@pytest.mark.parametrize( + "additional_request_fields", + [ + {"thinking": {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}}}, + {"reasoning_config": {"type": "enabled"}}, + None, + ], +) +@pytest.mark.asyncio +async def test_stream_does_not_retry_thinking_binding_error_without_drop_block( + bedrock_client, alist, messages, additional_request_fields +): + bedrock_client.converse_stream.side_effect = _thinking_binding_error("ConverseStream") + model = BedrockModel(model_id="m1", additional_request_fields=additional_request_fields) + + with pytest.raises(ClientError, match="bound to a different conversation"): + await alist(model.stream(messages)) + + bedrock_client.converse_stream.assert_called_once() + assert model._drop_stale_thinking_model_id is None + + +def test_format_request_drops_stale_thinking_only_for_remembered_model(bedrock_client, messages): + _ = bedrock_client + model = BedrockModel(model_id="m1", additional_request_fields={"thinking": THINKING}) + model._drop_stale_thinking_model_id = "m1" + + assert model.format_request(messages)["additionalModelRequestFields"] == { + "thinking": DROP_BLOCK_THINKING, + "anthropic_beta": ["thinking-binding-controls-2026-08-01"], + } + + model.update_config(model_id="m2") + + assert model.format_request(messages)["additionalModelRequestFields"] == {"thinking": THINKING} + + @pytest.mark.asyncio async def test_general_exception_is_raised(bedrock_client, model, messages, alist): error_message = "Should be raised up" diff --git a/strands-ts/src/context-manager/__tests__/context-manager.test.ts b/strands-ts/src/context-manager/__tests__/context-manager.test.ts index e1a56d9224..a155bc6bb4 100644 --- a/strands-ts/src/context-manager/__tests__/context-manager.test.ts +++ b/strands-ts/src/context-manager/__tests__/context-manager.test.ts @@ -3,7 +3,7 @@ import { ContextManager } from '../context-manager.js' import { createMockAgent, invokeTrackedHook } from '../../__fixtures__/agent-helpers.js' import { AfterModelCallEvent, BeforeModelCallEvent } from '../../hooks/events.js' import { ContextWindowOverflowError } from '../../errors.js' -import { Message, ReasoningBlock, TextBlock, ToolResultBlock, ToolUseBlock } from '../../types/messages.js' +import { Message, TextBlock, ToolResultBlock, ToolUseBlock } from '../../types/messages.js' import type { Agent } from '../../agent/agent.js' import { InMemoryStorage } from '../../storage/in-memory-storage.js' @@ -38,24 +38,6 @@ function makeOverflowEvent(agent: ReturnType) { }) } -const BINDING_ERROR = - 'messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different ' + - 'conversation. Remove the block, or set `thinking.block_binding.prefix_mismatch_behavior` to "drop_block".' - -function makeErrorEvent(agent: ReturnType, message: string) { - return new AfterModelCallEvent({ - agent, - model: agent.model, - invocationState: {}, - attemptCount: 1, - error: new Error(message), - }) -} - -function reasoning(text: string) { - return new ReasoningBlock({ text, signature: 'sig' }) -} - function makeBeforeEvent(agent: ReturnType, projectedInputTokens?: number) { return new BeforeModelCallEvent({ agent, @@ -346,86 +328,6 @@ describe('ContextManager', () => { }) }) - describe('thinking binding recovery', () => { - it('strips reasoning and retries', async () => { - const agent = makeMockAgent({ - messages: [ - new Message({ role: 'user', content: [new TextBlock('hello')] }), - new Message({ role: 'assistant', content: [reasoning('plan'), new TextBlock('hi')] }), - new Message({ role: 'user', content: [new TextBlock('again')] }), - new Message({ role: 'assistant', content: [reasoning('plan more'), new TextBlock('sure')] }), - ], - }) - const trackingIds = agent.messages.map((message) => message.trackingId) - await new ContextManager().initAgent(agent) - - const event = makeErrorEvent(agent, BINDING_ERROR) - await invokeTrackedHook(agent, event) - - expect(event.retry).toBe(true) - expect(agent.messages.map((message) => [message.role, message.content])).toEqual([ - ['user', [new TextBlock('hello')]], - ['assistant', [new TextBlock('hi')]], - ['user', [new TextBlock('again')]], - ['assistant', [new TextBlock('sure')]], - ]) - expect(agent.messages.map((message) => message.trackingId)).toEqual(trackingIds) - }) - - it('drops reasoning-only messages and repairs alternation', async () => { - const agent = makeMockAgent({ - messages: [ - new Message({ role: 'user', content: [new TextBlock('hello')] }), - new Message({ role: 'assistant', content: [reasoning('plan')] }), - new Message({ role: 'user', content: [new TextBlock('again')] }), - new Message({ role: 'assistant', content: [new TextBlock('sure')] }), - ], - }) - await new ContextManager().initAgent(agent) - - const event = makeErrorEvent(agent, BINDING_ERROR) - await invokeTrackedHook(agent, event) - - expect(event.retry).toBe(true) - expect(agent.messages.map((message) => [message.role, message.content])).toEqual([ - ['user', [new TextBlock('hello'), new TextBlock('again')]], - ['assistant', [new TextBlock('sure')]], - ]) - }) - - it('does not retry when no reasoning is left', async () => { - const agent = makeMockAgent({ - messages: [ - new Message({ role: 'user', content: [new TextBlock('hello')] }), - new Message({ role: 'assistant', content: [new TextBlock('hi')] }), - ], - }) - await new ContextManager().initAgent(agent) - - const event = makeErrorEvent(agent, BINDING_ERROR) - await invokeTrackedHook(agent, event) - - expect(event.retry).toBeUndefined() - }) - - it('keeps reasoning on other errors', async () => { - const block = reasoning('plan') - const agent = makeMockAgent({ - messages: [ - new Message({ role: 'user', content: [new TextBlock('hello')] }), - new Message({ role: 'assistant', content: [block, new TextBlock('hi')] }), - ], - }) - await new ContextManager().initAgent(agent) - - const event = makeErrorEvent(agent, 'Invalid model identifier') - await invokeTrackedHook(agent, event) - - expect(event.retry).toBeUndefined() - expect(agent.messages[1]!.content[0]).toBe(block) - }) - }) - describe('truncation', () => { it('preserves the first message content', async () => { const messages = [ diff --git a/strands-ts/src/context-manager/context-manager.ts b/strands-ts/src/context-manager/context-manager.ts index 36643cf49e..98df738123 100644 --- a/strands-ts/src/context-manager/context-manager.ts +++ b/strands-ts/src/context-manager/context-manager.ts @@ -7,7 +7,6 @@ import type { Plugin } from '../plugins/plugin.js' import type { Tool } from '../tools/tool.js' import type { LocalAgent } from '../types/agent.js' -import { Message, ReasoningBlock } from '../types/messages.js' import { AfterModelCallEvent, BeforeModelCallEvent, MessageAddedEvent } from '../hooks/events.js' import { ContextWindowOverflowError } from '../errors.js' import { InMemoryStorage } from '../storage/in-memory-storage.js' @@ -16,7 +15,6 @@ import type { Storage } from '../storage/storage.js' import { logger } from '../logging/logger.js' import type { ContextManagerConfig, ContextStrategy, ContextState } from './types.js' import { EmergencyTruncateStrategy, Offload } from './strategies/offload/index.js' -import { repairAlternation } from './strategies/offload/base.js' import { resolveStrategies } from './presets.js' import { Stash } from './stash.js' import { createRetrievalTool, trackRetrievalToolUseIds } from './retrieval-tool.js' @@ -25,7 +23,6 @@ const AUTO_TRUNCATE_THRESHOLD = 1_500 const AGENTIC_TRUNCATE_THRESHOLD = 8_000 const TRUNCATE_PREVIEW_TOKENS = 750 const AUTO_SUMMARIZE_UTILIZATION = 0.85 -const THINKING_BINDING_ERROR = 'bound to a different conversation' /** @internal */ export const CONTEXT_MANAGER_PRESETS = ['auto', 'agentic'] as const @@ -162,16 +159,6 @@ export class ContextManager implements Plugin { // Assumes sequential invocations on this agent (no concurrent calls) let overflowRetries = 0 agent.addHook(AfterModelCallEvent, async (event) => { - if (isThinkingBindingError(event.error)) { - if (stripReasoning(event.agent.messages)) { - logger.warn( - `agentId=<${event.agent.id}> | history edit invalidated thinking blocks, stripping them and retrying` - ) - event.retry = true - } - return - } - if (!(event.error instanceof ContextWindowOverflowError)) { overflowRetries = 0 return @@ -248,37 +235,3 @@ export class ContextManager implements Plugin { return anyActed } } - -/** Whether the provider rejected a replayed thinking block because history before it changed. */ -function isThinkingBindingError(error: Error | undefined): boolean { - return error?.message.includes(THINKING_BINDING_ERROR) ?? false -} - -/** - * Removes reasoning blocks from assistant messages, dropping any assistant message left empty. - * - * @returns true if any reasoning block was removed - */ -function stripReasoning(messages: Message[]): boolean { - let stripped = false - let emptied = false - for (const [index, message] of messages.entries()) { - if (message.role !== 'assistant') continue - const kept = message.content.filter((block) => !(block instanceof ReasoningBlock)) - if (kept.length === message.content.length) continue - stripped = true - emptied ||= kept.length === 0 - messages[index] = new Message({ - role: 'assistant', - content: kept, - trackingId: message.trackingId, - ...(message.metadata ? { metadata: message.metadata } : {}), - }) - } - if (emptied) { - const nonEmpty = messages.filter((message) => message.content.length > 0 || message.role !== 'assistant') - messages.splice(0, messages.length, ...nonEmpty) - repairAlternation(messages) - } - return stripped -} diff --git a/strands-ts/src/models/__tests__/anthropic.test.ts b/strands-ts/src/models/__tests__/anthropic.test.ts index 3ffbf9b691..b59588316f 100644 --- a/strands-ts/src/models/__tests__/anthropic.test.ts +++ b/strands-ts/src/models/__tests__/anthropic.test.ts @@ -510,6 +510,78 @@ describe('AnthropicModel', () => { }) }) + describe('stale thinking recovery', () => { + const bindingError = new Error( + '400 messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation.' + ) + const dropBlockThinking = { type: 'adaptive', block_binding: { prefix_mismatch_behavior: 'drop_block' } } + const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })] + + const createRejectingOnceClient = () => { + let calls = 0 + const stream = vi.fn(() => + (async function* () { + calls += 1 + if (calls === 1) throw bindingError + yield { type: 'message_start', message: { role: 'assistant', usage: {} } } + })() + ) + return { stream, client: { messages: { stream } } as unknown as Anthropic } + } + + it('retries once with drop_block and keeps it on for later requests', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const { stream, client } = createRejectingOnceClient() + const provider = new AnthropicModel({ + client, + modelId: 'm1', + betas: ['other-beta'], + params: { thinking: { type: 'adaptive' } }, + }) + + const events = await collectIterator(provider.stream(messages)) + + expect(events[0]).toStrictEqual({ type: 'modelMessageStartEvent', role: 'assistant' }) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('retrying with drop_block')) + const expectedOptions = { headers: { 'anthropic-beta': 'other-beta,thinking-binding-controls-2026-08-01' } } + expect(stream.mock.calls.map((call: unknown[]) => [(call[0] as any).thinking, call[1]])).toStrictEqual([ + [{ type: 'adaptive' }, { headers: { 'anthropic-beta': 'other-beta' } }], + [dropBlockThinking, expectedOptions], + ]) + + await collectIterator(provider.stream(messages)) + + expect(stream).toHaveBeenCalledTimes(3) + expect(stream).toHaveBeenLastCalledWith(expect.objectContaining({ thinking: dropBlockThinking }), expectedOptions) + warnSpy.mockRestore() + }) + + it.each([ + { + name: 'a caller-set block_binding', + params: { thinking: { type: 'adaptive', block_binding: { prefix_mismatch_behavior: 'error' } } }, + }, + { name: 'no thinking config', params: undefined }, + ])('re-throws the binding error with $name', async ({ params }) => { + const { stream, client } = createRejectingOnceClient() + const provider = new AnthropicModel({ client, modelId: 'm1', ...(params && { params }) }) + + await expect(collectIterator(provider.stream(messages))).rejects.toThrow('bound to a different conversation') + expect(stream).toHaveBeenCalledOnce() + }) + + it('stops dropping stale thinking after the model changes', async () => { + const { stream, client } = createRejectingOnceClient() + const provider = new AnthropicModel({ client, modelId: 'm1', params: { thinking: { type: 'adaptive' } } }) + await collectIterator(provider.stream(messages)) + + provider.updateConfig({ modelId: 'm2' }) + await collectIterator(provider.stream(messages)) + + expect(stream).toHaveBeenLastCalledWith(expect.objectContaining({ thinking: { type: 'adaptive' } })) + }) + }) + describe('request formatting', () => { // Helper to capture request arguments const setupCapture = () => { diff --git a/strands-ts/src/models/__tests__/bedrock.test.ts b/strands-ts/src/models/__tests__/bedrock.test.ts index d4239eb49c..e3f9f32ee0 100644 --- a/strands-ts/src/models/__tests__/bedrock.test.ts +++ b/strands-ts/src/models/__tests__/bedrock.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { BedrockRuntimeClient, + ConverseCommand, ConverseStreamCommand, CountTokensCommand, ValidationException, @@ -18,6 +19,7 @@ import type { StreamOptions } from '../model.js' import { collectIterator } from '../../__fixtures__/model-test-helpers.js' import { NOOP_TOOL_SPEC } from '../../tools/noop-tool.js' import { warnOnce } from '../../logging/warn-once.js' +import { logger } from '../../logging/logger.js' /** * Helper function to mock BedrockRuntimeClient implementation with customizable config. @@ -1434,6 +1436,85 @@ describe('BedrockModel', () => { }) describe('stream', () => { + describe('stale thinking recovery', () => { + const bindingError = new ValidationException({ + message: + 'messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation.', + $metadata: {}, + }) + const thinking = { type: 'adaptive' } + const dropBlockFields = { + thinking: { type: 'adaptive', block_binding: { prefix_mismatch_behavior: 'drop_block' } }, + anthropic_beta: ['other-beta', 'thinking-binding-controls-2026-08-01'], + } + const messages = [new Message({ role: 'user', content: [new TextBlock('Hello')] })] + const sentFields = (command: typeof ConverseStreamCommand | typeof ConverseCommand): unknown[] => + vi.mocked(command).mock.calls.map((call) => call[0].additionalModelRequestFields) + + it.each([ + { mode: 'streaming', stream: true, command: ConverseStreamCommand }, + { mode: 'non-streaming', stream: false, command: ConverseCommand }, + ])('retries a $mode request once with drop_block and keeps it on', async ({ stream, command }) => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const response = stream + ? { stream: (async function* (): AsyncGenerator {})() } + : { + output: { message: { role: 'assistant', content: [{ text: 'Hello' }] } }, + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + metrics: { latencyMs: 100 }, + } + const mockSend = vi.fn().mockRejectedValueOnce(bindingError).mockResolvedValue(response) + mockBedrockClientImplementation({ send: mockSend }) + const provider = new BedrockModel({ + modelId: 'm1', + stream, + additionalRequestFields: { thinking, anthropic_beta: ['other-beta'] }, + }) + + await collectIterator(provider.stream(messages)) + + expect(sentFields(command)).toStrictEqual([{ thinking, anthropic_beta: ['other-beta'] }, dropBlockFields]) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('retrying with drop_block')) + + await collectIterator(provider.stream(messages)) + + expect(mockSend).toHaveBeenCalledTimes(3) + expect(sentFields(command).at(-1)).toStrictEqual(dropBlockFields) + warnSpy.mockRestore() + }) + + it.each([ + { + name: 'a caller-set block_binding', + fields: { thinking: { ...thinking, block_binding: { prefix_mismatch_behavior: 'error' } } }, + }, + { name: 'no thinking config', fields: { reasoning_config: { type: 'enabled' } } }, + { name: 'no additional request fields', fields: undefined }, + ])('re-throws the binding error with $name', async ({ fields }) => { + const mockSend = vi.fn().mockRejectedValue(bindingError) + mockBedrockClientImplementation({ send: mockSend }) + const provider = new BedrockModel({ modelId: 'm1', ...(fields && { additionalRequestFields: fields }) }) + + await expect(collectIterator(provider.stream(messages))).rejects.toThrow('bound to a different conversation') + expect(mockSend).toHaveBeenCalledOnce() + }) + + it('keeps thinking off when a forced tool choice follows the retry', async () => { + const mockSend = vi + .fn() + .mockRejectedValueOnce(bindingError) + .mockResolvedValue({ stream: (async function* (): AsyncGenerator {})() }) + mockBedrockClientImplementation({ send: mockSend }) + const provider = new BedrockModel({ modelId: 'm1', additionalRequestFields: { thinking } }) + await collectIterator(provider.stream(messages)) + + await collectIterator(provider.stream(messages, { toolChoice: { any: {} } })) + + expect(sentFields(ConverseStreamCommand).at(-1)).toBeUndefined() + }) + }) + it.each([ { mode: 'streaming', stream: true }, { mode: 'non-streaming', stream: false }, diff --git a/strands-ts/src/models/anthropic.ts b/strands-ts/src/models/anthropic.ts index 7861e9b574..2d580edafa 100644 --- a/strands-ts/src/models/anthropic.ts +++ b/strands-ts/src/models/anthropic.ts @@ -8,6 +8,9 @@ import { resolveCacheSection, type CacheConfig, type ResolvedCacheSection, + THINKING_BINDING_BETA, + isThinkingBindingError, + withDropBlock, } from '../models/model.js' import type { Message, ContentBlock, SystemPrompt } from '../types/messages.js' import type { ModelStreamEvent } from '../models/streaming.js' @@ -142,6 +145,7 @@ export interface AnthropicModelOptions extends AnthropicModelConfig { export class AnthropicModel extends Model { private _config: AnthropicModelConfig private _client: Anthropic + private _dropStaleThinkingModelId: string | undefined constructor(options?: AnthropicModelOptions) { super() @@ -239,11 +243,13 @@ export class AnthropicModel extends Model { * @returns Async iterable of streaming events */ async *stream(messages: Message[], options?: StreamOptions): AsyncIterable { + let thinking: Anthropic.ThinkingConfigParam | undefined + let continuations = 0 try { let request = this._formatRequest(messages, options) + thinking = request.thinking const requestOptions = this._buildRequestOptions() const usage = createEmptyUsage() - let continuations = 0 while (true) { const stream = requestOptions @@ -417,6 +423,16 @@ export class AnthropicModel extends Model { return } } catch (unknownError) { + if (continuations === 0 && isThinkingBindingError(unknownError) && withDropBlock(thinking)) { + // Stale blocks stay in history, so drop_block stays on for every later request to this model. + logger.warn( + `model_id=<${this._config.modelId}> | history edit invalidated replayed thinking blocks, retrying with drop_block` + ) + this._dropStaleThinkingModelId = this._config.modelId + yield* this.stream(messages, options) + return + } + const error = normalizeError(unknownError) const lowered = error.message.toLowerCase() @@ -436,11 +452,17 @@ export class AnthropicModel extends Model { } private _buildRequestOptions(): Anthropic.RequestOptions | undefined { - const betas = this._config.betas - if (!betas || betas.length === 0) return undefined + let betas = this._config.betas ?? [] + if (this._dropsStaleThinking() && !betas.includes(THINKING_BINDING_BETA)) betas = [...betas, THINKING_BINDING_BETA] + if (betas.length === 0) return undefined return { headers: { 'anthropic-beta': betas.join(',') } } } + /** Whether a binding error already turned drop_block on for the configured model. */ + private _dropsStaleThinking(): boolean { + return this._dropStaleThinkingModelId === this._config.modelId + } + /** Resolves a cache section, disabled when `cacheConfig` is unset or its strategy is unknown. */ private _cacheSection(section: 'toolsTTL' | 'messagesTTL' | 'systemPromptTTL'): ResolvedCacheSection { const cacheConfig = this._config.cacheConfig @@ -601,6 +623,8 @@ export class AnthropicModel extends Model { if (this._config.topP !== undefined) request.top_p = this._config.topP if (this._config.stopSequences !== undefined) request.stop_sequences = this._config.stopSequences if (this._config.params) Object.assign(request, this._config.params) + const dropBlockThinking = this._dropsStaleThinking() ? withDropBlock(request.thinking) : undefined + if (dropBlockThinking) request.thinking = dropBlockThinking // params.tools is already merged into tools above; never let it bypass the forced-turn rule. if (tools.length > 0) request.tools = tools else delete request.tools diff --git a/strands-ts/src/models/bedrock.ts b/strands-ts/src/models/bedrock.ts index b5caffbd42..75019a59c3 100644 --- a/strands-ts/src/models/bedrock.ts +++ b/strands-ts/src/models/bedrock.ts @@ -58,6 +58,9 @@ import { type StreamOptions, resolveCacheSection, resolveConfigMetadata, + THINKING_BINDING_BETA, + isThinkingBindingError, + withDropBlock, } from '../models/model.js' import type { ContentBlock, Message, StopReason, ToolUseBlock } from '../types/messages.js' import type { AudioSource, ImageSource, VideoSource, DocumentSource } from '../types/media.js' @@ -404,6 +407,7 @@ export interface BedrockModelOptions extends BedrockModelConfig { export class BedrockModel extends Model { private _config: BedrockModelConfig private _client: BedrockRuntimeClient + private _dropStaleThinkingModelId: string | undefined /** * Clears the cache of model IDs for which CountTokens is skipped. @@ -706,10 +710,12 @@ export class BedrockModel extends Model { const request = this._formatRequest(messages, options) if (this._config.stream !== false) { // Create and send the command - const command = new ConverseStreamCommand(request) - const response = options?.cancelSignal - ? await this._client.send(command, { abortSignal: options.cancelSignal }) - : await this._client.send(command) + const response = await this._send(request, (input) => { + const command = new ConverseStreamCommand(input) + return options?.cancelSignal + ? this._client.send(command, { abortSignal: options.cancelSignal }) + : this._client.send(command) + }) // Stream the response if (response.stream) { let lastStopReason: string | undefined @@ -751,10 +757,12 @@ export class BedrockModel extends Model { } } } else { - const command = new ConverseCommand(request) - const response = options?.cancelSignal - ? await this._client.send(command, { abortSignal: options.cancelSignal }) - : await this._client.send(command) + const response = await this._send(request, (input) => { + const command = new ConverseCommand(input) + return options?.cancelSignal + ? this._client.send(command, { abortSignal: options.cancelSignal }) + : this._client.send(command) + }) for (const event of this._mapBedrockEventToSDKEvent(response)) { yield event } @@ -772,6 +780,35 @@ export class BedrockModel extends Model { } } + /** + * Sends the request, retrying once with drop_block when an edit before replayed thinking made it stale. + * + * The retry turns drop_block on for every later request to this model, since the stale blocks stay in history. + * + * @param request - Formatted Bedrock request + * @param send - Sends a request to the Converse or ConverseStream API + * @returns The API response + */ + private async _send( + request: ConverseStreamCommandInput, + send: (input: ConverseStreamCommandInput) => Promise + ): Promise { + try { + return await send(request) + } catch (error) { + const retryFields = isThinkingBindingError(error) + ? withDropBlockFields(request.additionalModelRequestFields) + : undefined + if (!retryFields) throw error + + logger.warn( + `model_id=<${request.modelId}> | history edit invalidated replayed thinking blocks, retrying with drop_block` + ) + this._dropStaleThinkingModelId = request.modelId + return send({ ...request, additionalModelRequestFields: retryFields }) + } + } + /** * Formats a request for the Bedrock Converse Stream API. * @@ -915,7 +952,7 @@ export class BedrockModel extends Model { const toolChoice = options?.toolChoice if (!toolChoice || 'auto' in toolChoice) { - return fields + return this._dropStaleThinkingModelId === this._config.modelId ? (withDropBlockFields(fields) ?? fields) : fields } const { thinking: _, ...rest } = fields @@ -2083,6 +2120,21 @@ export class BedrockModel extends Model { * Return the strict-mode copy of a tool's input schema, warning once per tool when it still uses * keywords Bedrock's strict mode rejects. */ +/** + * Return additional request fields that drop stale thinking blocks, or undefined if they cannot change. + * + * @param fields - The additional model request fields + * @returns The fields with drop_block and its beta, or undefined + */ +function withDropBlockFields(fields: JSONValue | undefined): Record | undefined { + if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) return undefined + const thinking = withDropBlock(fields.thinking) + if (!thinking) return undefined + const betas = Array.isArray(fields.anthropic_beta) ? fields.anthropic_beta : [] + const anthropicBeta = betas.includes(THINKING_BINDING_BETA) ? betas : [...betas, THINKING_BINDING_BETA] + return { ...fields, anthropic_beta: anthropicBeta, thinking } +} + function toStrictInputSchema(toolName: string, inputSchema: JSONSchema): JSONSchema { const strictSchema = ensureStrictJsonSchema(inputSchema) const unsupported = findUnsupportedStrictKeywords(strictSchema) diff --git a/strands-ts/src/models/model.ts b/strands-ts/src/models/model.ts index 5d4a66e515..dad0b69d4f 100644 --- a/strands-ts/src/models/model.ts +++ b/strands-ts/src/models/model.ts @@ -168,6 +168,43 @@ export function resolveCacheSection( return ttl ? { enabled, ttl } : { enabled } } +/** + * Beta that lets a request drop thinking blocks invalidated by an edit to the history before them. + * + * Preserved-thinking Claude models bind each thinking block to the content before it. When that content + * changes, enforcing accounts reject the request unless it asks the API to drop the stale blocks. + * + * @internal + */ +export const THINKING_BINDING_BETA = 'thinking-binding-controls-2026-08-01' + +const THINKING_BINDING_ERROR = 'bound to a different conversation' + +/** + * Whether the provider rejected a replayed thinking block because history before it changed. + * + * @internal + */ +export function isThinkingBindingError(error: unknown): boolean { + return error instanceof Error && error.message.includes(THINKING_BINDING_ERROR) +} + +/** + * Return the thinking config with stale blocks dropped, or undefined if it cannot or should not change. + * + * A caller-set `block_binding` is left as written. + * + * @internal + */ +export function withDropBlock( + thinking: T +): (T & { block_binding: { prefix_mismatch_behavior: 'drop_block' } }) | undefined { + if (typeof thinking !== 'object' || thinking === null || Array.isArray(thinking) || 'block_binding' in thinking) { + return undefined + } + return { ...thinking, block_binding: { prefix_mismatch_behavior: 'drop_block' } } +} + /** * Base configuration interface for all model providers. * From c9aa37596e1cc703e88d461c54fec57ec1eee8e9 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:14:08 -0400 Subject: [PATCH 3/8] fix(anthropic): key drop_block latch on configured model and keep client betas on retry Python: the latch is written from config model_id, matching where it is read, so a params model override no longer recurses without bound. TypeScript: betas set via clientConfig.defaultHeaders are carried into the drop_block request header, since per-request anthropic-beta replaces the client default. --- strands-py/src/strands/models/anthropic.py | 4 ++-- .../tests/strands/models/test_anthropic.py | 13 ++++++++++++ .../src/models/__tests__/anthropic.test.ts | 21 +++++++++++++++++++ strands-ts/src/models/anthropic.ts | 17 ++++++++++++++- 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/strands-py/src/strands/models/anthropic.py b/strands-py/src/strands/models/anthropic.py index ef604381d3..5d6b6dfe16 100644 --- a/strands-py/src/strands/models/anthropic.py +++ b/strands-py/src/strands/models/anthropic.py @@ -1058,9 +1058,9 @@ async def stream( # Stale blocks stay in history, so drop_block stays on for every later request to this model. logger.warning( "model_id=<%s> | history edit invalidated replayed thinking blocks, retrying with drop_block", - request["model"], + self.config["model_id"], ) - self._drop_stale_thinking_model_id = request["model"] + self._drop_stale_thinking_model_id = self.config["model_id"] async for chunk in self.stream( messages, tool_specs, diff --git a/strands-py/tests/strands/models/test_anthropic.py b/strands-py/tests/strands/models/test_anthropic.py index 0e0de7afbf..024bae1834 100644 --- a/strands-py/tests/strands/models/test_anthropic.py +++ b/strands-py/tests/strands/models/test_anthropic.py @@ -1119,6 +1119,19 @@ async def test_stream_does_not_retry_thinking_binding_error_without_drop_block(a assert model._drop_stale_thinking_model_id is None +@pytest.mark.asyncio +async def test_stream_retries_drop_block_once_when_params_override_model(anthropic_client, alist, messages): + anthropic_client.messages.stream.side_effect = [_thinking_binding_error(), _thinking_binding_error()] + model = AnthropicModel( + model_id="m1", max_tokens=1, params={"model": "m1-override", "thinking": {"type": "adaptive"}} + ) + + with pytest.raises(anthropic.BadRequestError, match="bound to a different conversation"): + await alist(model.stream(messages)) + + assert anthropic_client.messages.stream.call_count == 2 + + def test_format_request_drops_stale_thinking_only_for_remembered_model(anthropic_client, messages): _ = anthropic_client model = AnthropicModel( diff --git a/strands-ts/src/models/__tests__/anthropic.test.ts b/strands-ts/src/models/__tests__/anthropic.test.ts index b59588316f..a9c637fad3 100644 --- a/strands-ts/src/models/__tests__/anthropic.test.ts +++ b/strands-ts/src/models/__tests__/anthropic.test.ts @@ -556,6 +556,27 @@ describe('AnthropicModel', () => { warnSpy.mockRestore() }) + it('keeps betas from the client default headers on the retry', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const { stream, client } = createRejectingOnceClient() + vi.mocked(Anthropic).mockImplementationOnce(function () { + return client + }) + const provider = new AnthropicModel({ + apiKey: 'sk-test', + clientConfig: { defaultHeaders: { 'anthropic-beta': 'client-beta' } }, + modelId: 'm1', + params: { thinking: { type: 'adaptive' } }, + }) + + await collectIterator(provider.stream(messages)) + + expect(stream).toHaveBeenLastCalledWith(expect.objectContaining({ thinking: dropBlockThinking }), { + headers: { 'anthropic-beta': 'client-beta,thinking-binding-controls-2026-08-01' }, + }) + warnSpy.mockRestore() + }) + it.each([ { name: 'a caller-set block_binding', diff --git a/strands-ts/src/models/anthropic.ts b/strands-ts/src/models/anthropic.ts index 2d580edafa..7dd2e71d58 100644 --- a/strands-ts/src/models/anthropic.ts +++ b/strands-ts/src/models/anthropic.ts @@ -87,6 +87,15 @@ function mergeUsage(target: Record, source: object): void { } } +/** + * Reads the `anthropic-beta` values from client default headers. + */ +function headerBetas(headers: ClientOptions['defaultHeaders']): string[] { + const value = headers ? new Headers(headers as HeadersInit).get('anthropic-beta') : null + if (!value) return [] + return value.split(',').flatMap((beta) => beta.trim() || []) +} + export interface AnthropicModelConfig extends BaseModelConfig { /** * Maximum number of tokens the model can generate in a response. @@ -146,10 +155,12 @@ export class AnthropicModel extends Model { private _config: AnthropicModelConfig private _client: Anthropic private _dropStaleThinkingModelId: string | undefined + private _clientBetas: string[] constructor(options?: AnthropicModelOptions) { super() const { apiKey, client, clientConfig, ...modelConfig } = options || {} + this._clientBetas = headerBetas(clientConfig?.defaultHeaders) this._config = { modelId: MODEL_DEFAULTS.anthropic.modelId, @@ -453,7 +464,11 @@ export class AnthropicModel extends Model { private _buildRequestOptions(): Anthropic.RequestOptions | undefined { let betas = this._config.betas ?? [] - if (this._dropsStaleThinking() && !betas.includes(THINKING_BINDING_BETA)) betas = [...betas, THINKING_BINDING_BETA] + if (this._dropsStaleThinking()) { + // Request headers replace client headers of the same name, so betas set on the client are carried over. + if (betas.length === 0) betas = this._clientBetas + if (!betas.includes(THINKING_BINDING_BETA)) betas = [...betas, THINKING_BINDING_BETA] + } if (betas.length === 0) return undefined return { headers: { 'anthropic-beta': betas.join(',') } } } From a4a725206ee1d3f604de3ed6c89751116ae31686 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:43:21 -0400 Subject: [PATCH 4/8] docs(bedrock): move toStrictInputSchema doc comment back onto its function --- strands-ts/src/models/bedrock.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/strands-ts/src/models/bedrock.ts b/strands-ts/src/models/bedrock.ts index 75019a59c3..bd6fe1d5ae 100644 --- a/strands-ts/src/models/bedrock.ts +++ b/strands-ts/src/models/bedrock.ts @@ -2116,10 +2116,6 @@ export class BedrockModel extends Model { } } -/** - * Return the strict-mode copy of a tool's input schema, warning once per tool when it still uses - * keywords Bedrock's strict mode rejects. - */ /** * Return additional request fields that drop stale thinking blocks, or undefined if they cannot change. * @@ -2135,6 +2131,10 @@ function withDropBlockFields(fields: JSONValue | undefined): Record Date: Fri, 16 Oct 2026 10:54:07 -0400 Subject: [PATCH 5/8] fix(models): only treat validation errors as thinking binding errors The binding message is matched only on a Bedrock ValidationException or an Anthropic 400, so the same text on another error type is re-raised without a drop_block retry. --- strands-py/src/strands/models/bedrock.py | 4 +++- strands-py/tests/strands/models/test_bedrock.py | 13 +++++++++++++ .../src/models/__tests__/anthropic.test.ts | 16 ++++++++++++---- strands-ts/src/models/__tests__/bedrock.test.ts | 9 +++++++++ strands-ts/src/models/anthropic.ts | 7 ++++++- strands-ts/src/models/bedrock.ts | 6 +++--- 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/strands-py/src/strands/models/bedrock.py b/strands-py/src/strands/models/bedrock.py index e66e63dd7a..f43227529b 100644 --- a/strands-py/src/strands/models/bedrock.py +++ b/strands-py/src/strands/models/bedrock.py @@ -523,7 +523,9 @@ def _converse(self, converse_method: Callable[..., Any], request: dict[str, Any] return converse_method(**request) except ClientError as error: fields = request.get("additionalModelRequestFields") or {} - retry_fields = _with_drop_block_fields(fields) if _is_thinking_binding_error(error) else None + error_code = error.response.get("Error", {}).get("Code") + is_binding_error = error_code == "ValidationException" and _is_thinking_binding_error(error) + retry_fields = _with_drop_block_fields(fields) if is_binding_error else None if retry_fields is None: raise diff --git a/strands-py/tests/strands/models/test_bedrock.py b/strands-py/tests/strands/models/test_bedrock.py index 9e7478ab2d..644ce33baf 100644 --- a/strands-py/tests/strands/models/test_bedrock.py +++ b/strands-py/tests/strands/models/test_bedrock.py @@ -1262,6 +1262,19 @@ async def test_stream_does_not_retry_thinking_binding_error_without_drop_block( assert model._drop_stale_thinking_model_id is None +@pytest.mark.asyncio +async def test_stream_does_not_retry_binding_message_on_non_validation_error(bedrock_client, alist, messages): + error = _thinking_binding_error("ConverseStream") + error.response["Error"]["Code"] = "InternalServerException" + bedrock_client.converse_stream.side_effect = error + model = BedrockModel(model_id="m1", additional_request_fields={"thinking": THINKING}) + + with pytest.raises(ClientError, match="bound to a different conversation"): + await alist(model.stream(messages)) + + bedrock_client.converse_stream.assert_called_once() + + def test_format_request_drops_stale_thinking_only_for_remembered_model(bedrock_client, messages): _ = bedrock_client model = BedrockModel(model_id="m1", additional_request_fields={"thinking": THINKING}) diff --git a/strands-ts/src/models/__tests__/anthropic.test.ts b/strands-ts/src/models/__tests__/anthropic.test.ts index a9c637fad3..e3d3e9db73 100644 --- a/strands-ts/src/models/__tests__/anthropic.test.ts +++ b/strands-ts/src/models/__tests__/anthropic.test.ts @@ -511,18 +511,18 @@ describe('AnthropicModel', () => { }) describe('stale thinking recovery', () => { - const bindingError = new Error( + const bindingMessage = '400 messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation.' - ) + const bindingError = Object.assign(new Error(bindingMessage), { status: 400 }) const dropBlockThinking = { type: 'adaptive', block_binding: { prefix_mismatch_behavior: 'drop_block' } } const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })] - const createRejectingOnceClient = () => { + const createRejectingOnceClient = (error: Error = bindingError) => { let calls = 0 const stream = vi.fn(() => (async function* () { calls += 1 - if (calls === 1) throw bindingError + if (calls === 1) throw error yield { type: 'message_start', message: { role: 'assistant', usage: {} } } })() ) @@ -591,6 +591,14 @@ describe('AnthropicModel', () => { expect(stream).toHaveBeenCalledOnce() }) + it('re-throws the binding message on a non-400 error', async () => { + const { stream, client } = createRejectingOnceClient(Object.assign(new Error(bindingMessage), { status: 500 })) + const provider = new AnthropicModel({ client, modelId: 'm1', params: { thinking: { type: 'adaptive' } } }) + + await expect(collectIterator(provider.stream(messages))).rejects.toThrow('bound to a different conversation') + expect(stream).toHaveBeenCalledOnce() + }) + it('stops dropping stale thinking after the model changes', async () => { const { stream, client } = createRejectingOnceClient() const provider = new AnthropicModel({ client, modelId: 'm1', params: { thinking: { type: 'adaptive' } } }) diff --git a/strands-ts/src/models/__tests__/bedrock.test.ts b/strands-ts/src/models/__tests__/bedrock.test.ts index e3f9f32ee0..096aa7707e 100644 --- a/strands-ts/src/models/__tests__/bedrock.test.ts +++ b/strands-ts/src/models/__tests__/bedrock.test.ts @@ -1500,6 +1500,15 @@ describe('BedrockModel', () => { expect(mockSend).toHaveBeenCalledOnce() }) + it('re-throws the binding message on a non-validation error', async () => { + const mockSend = vi.fn().mockRejectedValue(new Error(bindingError.message)) + mockBedrockClientImplementation({ send: mockSend }) + const provider = new BedrockModel({ modelId: 'm1', additionalRequestFields: { thinking } }) + + await expect(collectIterator(provider.stream(messages))).rejects.toThrow('bound to a different conversation') + expect(mockSend).toHaveBeenCalledOnce() + }) + it('keeps thinking off when a forced tool choice follows the retry', async () => { const mockSend = vi .fn() diff --git a/strands-ts/src/models/anthropic.ts b/strands-ts/src/models/anthropic.ts index 7dd2e71d58..f066eca5c8 100644 --- a/strands-ts/src/models/anthropic.ts +++ b/strands-ts/src/models/anthropic.ts @@ -434,7 +434,12 @@ export class AnthropicModel extends Model { return } } catch (unknownError) { - if (continuations === 0 && isThinkingBindingError(unknownError) && withDropBlock(thinking)) { + if ( + continuations === 0 && + (unknownError as { status?: number }).status === 400 && + isThinkingBindingError(unknownError) && + withDropBlock(thinking) + ) { // Stale blocks stay in history, so drop_block stays on for every later request to this model. logger.warn( `model_id=<${this._config.modelId}> | history edit invalidated replayed thinking blocks, retrying with drop_block` diff --git a/strands-ts/src/models/bedrock.ts b/strands-ts/src/models/bedrock.ts index bd6fe1d5ae..5034f7e949 100644 --- a/strands-ts/src/models/bedrock.ts +++ b/strands-ts/src/models/bedrock.ts @@ -796,9 +796,9 @@ export class BedrockModel extends Model { try { return await send(request) } catch (error) { - const retryFields = isThinkingBindingError(error) - ? withDropBlockFields(request.additionalModelRequestFields) - : undefined + const isBindingError = + error instanceof Error && error.name === 'ValidationException' && isThinkingBindingError(error) + const retryFields = isBindingError ? withDropBlockFields(request.additionalModelRequestFields) : undefined if (!retryFields) throw error logger.warn( From 8aaab2a8dcb49255fdf40eb34c33cd6eb10d0bbd Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:00:55 -0400 Subject: [PATCH 6/8] docs(models): explain drop_block recovery for thinking binding errors --- .../sdk/model-providers/amazon-bedrock.mdx | 39 +++++++++++++++++ .../sdk/model-providers/anthropic.mdx | 42 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/site/src/content/docs/user-guide/sdk/model-providers/amazon-bedrock.mdx b/site/src/content/docs/user-guide/sdk/model-providers/amazon-bedrock.mdx index 6c75fd8d68..74c4b0d96e 100644 --- a/site/src/content/docs/user-guide/sdk/model-providers/amazon-bedrock.mdx +++ b/site/src/content/docs/user-guide/sdk/model-providers/amazon-bedrock.mdx @@ -978,6 +978,45 @@ const agent = new Agent({ model: bedrockModel }) `strategy="anthropic"` has identical performance to `strategy="auto"` and requires no additional API calls or IAM permissions. +### Thinking block is bound to a different conversation + +When Amazon Bedrock rejects a request with this error: + +> ValidationException: The block is bound to a different conversation. + +The provider retries the request once and logs this warning: + +``` +model_id=<...> | history edit invalidated replayed thinking blocks, retrying with drop_block +``` + +Claude models that preserve thinking bind each thinking block to the conversation that +came before it. Summarizing or removing messages with +[context management](../context-management/) or a conversation manager, restoring a +session, or editing messages from a hook changes that earlier conversation, so the +thinking blocks that remain in the history no longer match it. Accounts that enforce this binding, the +default for accounts created on or after August 31, 2026, reject the next request. + +The retry sets `prefix_mismatch_behavior` to `drop_block` in the `thinking.block_binding` +request field and adds the +`thinking-binding-controls-2026-08-01` beta to any betas already in `anthropic_beta`. +Claude then skips the stale thinking blocks instead of rejecting the request. The stale +blocks stay in the history, so the provider keeps sending `drop_block` for that model ID +until the model instance is discarded. Requests before the first rejection are sent +unchanged. + +- **Thinking must be configured.** The retry applies only when `thinking` is set in + . Other errors, + and this error without a thinking configuration, are raised as is. +- **Your `block_binding` wins.** If the thinking configuration already contains + `block_binding`, the provider sends it as written and does not retry. Set it yourself + to choose the behavior up front or to keep the error. +- **Dropping has a cost.** Claude loses the reasoning in the dropped blocks, the prompt + cache restarts at the edited message, and responses can use more output tokens. +- **Some models require adaptive thinking.** Claude Sonnet 5.5 and Claude Haiku 5.5 + accept `drop_block` only with `"type": "adaptive"`. With any other thinking type, the + retry fails and its error is raised. + ## Related Resources - [Amazon Bedrock Documentation](https://docs.aws.amazon.com/bedrock/) diff --git a/site/src/content/docs/user-guide/sdk/model-providers/anthropic.mdx b/site/src/content/docs/user-guide/sdk/model-providers/anthropic.mdx index f6e3ef7b25..560237f719 100644 --- a/site/src/content/docs/user-guide/sdk/model-providers/anthropic.mdx +++ b/site/src/content/docs/user-guide/sdk/model-providers/anthropic.mdx @@ -129,6 +129,48 @@ If you encounter import errors for `@anthropic-ai/sdk`, ensure the package is in +### Thinking block is bound to a different conversation + +When the Anthropic API rejects a request with this 400 error: + +> The block is bound to a different conversation. + +The provider retries the request once and logs this warning: + +``` +model_id=<...> | history edit invalidated replayed thinking blocks, retrying with drop_block +``` + +Claude models that preserve thinking bind each thinking block to the conversation that +came before it. Summarizing or removing messages with +[context management](../context-management/) or a conversation manager, restoring a +session, or editing messages from a hook changes that earlier conversation, so the +thinking blocks that remain in the history no longer match it. Accounts that enforce this binding, the +default for accounts created on or after August 31, 2026, reject the next request. + +The retry sets `prefix_mismatch_behavior` to `drop_block` in the `thinking.block_binding` +request field and adds the +`thinking-binding-controls-2026-08-01` beta to the `anthropic-beta` header. Claude then +skips the stale thinking blocks instead of rejecting the request. The stale blocks stay +in the history, so the provider keeps sending `drop_block` for that model ID until the +model instance is discarded. Requests before the first rejection are sent unchanged. + +- **Thinking must be configured.** The retry applies only when `thinking` is set in + `params`. Other errors, and this error without a thinking configuration, are raised as + is. +- **Your `block_binding` wins.** If the thinking configuration already contains + `block_binding`, the provider sends it as written and does not retry. Set it yourself + to choose the behavior up front or to keep the error. +- **Existing betas are kept.** The beta is added to the betas the model already sends, + from or, when that is not set, from the + `anthropic-beta` default header of the client. A pre-configured client does not expose + its default headers, so set its betas with . +- **Dropping has a cost.** Claude loses the reasoning in the dropped blocks, the prompt + cache restarts at the edited message, and responses can use more output tokens. +- **Some models require adaptive thinking.** Claude Sonnet 5.5 and Claude Haiku 5.5 + accept `drop_block` only with `"type": "adaptive"`. With any other thinking type, the + retry fails and its error is raised. + ## Advanced Features ### Custom Client From 00ddf0f8bee1af3096bb0a55b52690d3f79c2021 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:11:42 -0400 Subject: [PATCH 7/8] fix(context): skip role markers for messages the summarizer leaves empty --- .../_context_manager/methods/summarize.py | 24 ++++++++++----- .../methods/test_summarize.py | 11 +++++++ .../methods/__tests__/summarize.test.ts | 8 +++++ .../src/context-manager/methods/summarize.ts | 29 +++++++++++++------ 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/strands-py/src/strands/_context_manager/methods/summarize.py b/strands-py/src/strands/_context_manager/methods/summarize.py index 8a3610a7d1..a07e324b7b 100644 --- a/strands-py/src/strands/_context_manager/methods/summarize.py +++ b/strands-py/src/strands/_context_manager/methods/summarize.py @@ -91,19 +91,29 @@ def _tool_result_to_content_blocks(content: list[ToolResultContent]) -> list[Con return blocks +def _message_to_content_blocks(message: Message) -> list[ContentBlock]: + """Convert a message's content to ContentBlocks for the summarizer, leaving out reasoning.""" + blocks: list[ContentBlock] = [] + for block in message["content"]: + if "toolResult" in block: + blocks.extend(_tool_result_to_content_blocks(block["toolResult"]["content"])) + elif "reasoningContent" not in block: + blocks.append(block) + return blocks + + def _flatten_messages_to_content(messages: Messages) -> list[ContentBlock]: """Flatten messages into a single ContentBlock list with role markers. - Reasoning blocks are left out: their signatures are bound to the conversation that produced them. + Reasoning blocks are left out: they are assistant-only content, and their signatures are bound to the + conversation that produced them. Messages with nothing left get no role marker. """ blocks: list[ContentBlock] = [] for message in messages: - blocks.append(ContentBlock(text=f"\n---\n[{message['role']}]")) - for block in message["content"]: - if "toolResult" in block: - blocks.extend(_tool_result_to_content_blocks(block["toolResult"]["content"])) - elif "reasoningContent" not in block: - blocks.append(block) + content = _message_to_content_blocks(message) + if content: + blocks.append(ContentBlock(text=f"\n---\n[{message['role']}]")) + blocks.extend(content) return blocks diff --git a/strands-py/tests/strands/_context_manager/methods/test_summarize.py b/strands-py/tests/strands/_context_manager/methods/test_summarize.py index bb58c994ed..95a851061f 100644 --- a/strands-py/tests/strands/_context_manager/methods/test_summarize.py +++ b/strands-py/tests/strands/_context_manager/methods/test_summarize.py @@ -104,6 +104,17 @@ def test_excludes_reasoning(self): tru_result = _flatten_messages_to_content(messages) assert tru_result == [ContentBlock(text="\n---\n[assistant]"), ContentBlock(text="hi")] + def test_skips_marker_for_reasoning_only_message(self): + messages = [ + Message( + role="assistant", + content=[ContentBlock(reasoningContent={"reasoningText": {"text": "plan", "signature": "sig"}})], + ), + Message(role="user", content=[ContentBlock(text="next")]), + ] + tru_result = _flatten_messages_to_content(messages) + assert tru_result == [ContentBlock(text="\n---\n[user]"), ContentBlock(text="next")] + class TestSummarizeContent: """Tests for the summarize_content function.""" diff --git a/strands-ts/src/context-manager/methods/__tests__/summarize.test.ts b/strands-ts/src/context-manager/methods/__tests__/summarize.test.ts index 360f9891ab..4cbcb087a2 100644 --- a/strands-ts/src/context-manager/methods/__tests__/summarize.test.ts +++ b/strands-ts/src/context-manager/methods/__tests__/summarize.test.ts @@ -13,4 +13,12 @@ describe('flattenMessagesToContent', () => { expect(flattenMessagesToContent(messages)).toEqual([new TextBlock('\n---\n[assistant]'), new TextBlock('hi')]) }) + it('skips the role marker for a message with only reasoning', () => { + const messages = [ + new Message({ role: 'assistant', content: [new ReasoningBlock({ text: 'plan', signature: 'sig' })] }), + new Message({ role: 'user', content: [new TextBlock('next')] }), + ] + + expect(flattenMessagesToContent(messages)).toEqual([new TextBlock('\n---\n[user]'), new TextBlock('next')]) + }) }) diff --git a/strands-ts/src/context-manager/methods/summarize.ts b/strands-ts/src/context-manager/methods/summarize.ts index 7277204cc7..7a7c4c8461 100644 --- a/strands-ts/src/context-manager/methods/summarize.ts +++ b/strands-ts/src/context-manager/methods/summarize.ts @@ -84,22 +84,33 @@ export function toolResultToContentBlocks(content: ToolResultContent[]): Content }) } +/** + * Converts a message's content to ContentBlocks for the summarizer, leaving out reasoning. + */ +function messageToContentBlocks(message: Message): ContentBlock[] { + const blocks: ContentBlock[] = [] + for (const block of message.content) { + if (block instanceof ToolResultBlock) { + blocks.push(...toolResultToContentBlocks(block.content)) + } else if (!(block instanceof ReasoningBlock)) { + blocks.push(block) + } + } + return blocks +} + /** * Flattens a range of messages into a single ContentBlock array for multimodal summarization. * Inserts role markers and separators so the summarizer understands message boundaries. - * Reasoning blocks are left out: their signatures are bound to the conversation that produced them. + * Reasoning blocks are left out: they are assistant-only content, and their signatures are bound to the + * conversation that produced them. Messages with nothing left get no role marker. */ export function flattenMessagesToContent(messages: Message[]): ContentBlock[] { const blocks: ContentBlock[] = [] for (const message of messages) { - blocks.push(new TextBlock(`\n---\n[${message.role}]`)) - for (const block of message.content) { - if (block instanceof ToolResultBlock) { - blocks.push(...toolResultToContentBlocks(block.content)) - } else if (!(block instanceof ReasoningBlock)) { - blocks.push(block) - } - } + const content = messageToContentBlocks(message) + if (content.length === 0) continue + blocks.push(new TextBlock(`\n---\n[${message.role}]`), ...content) } return blocks } From 380d4caf39c4c7f70c93b3a6db2949ea55eba691 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:05:59 -0400 Subject: [PATCH 8/8] refactor(models): move thinking binding helpers to their own module and skip unset client betas Move the drop_block helpers out of the base model module into models/_thinking_binding.py and models/thinking-binding.ts, and give the request-level and thinking-level helpers distinct names. In TS, read client default betas the way the Anthropic SDK does, so an undefined or null anthropic-beta value is skipped instead of being sent as a literal beta on every request after a retry. Cover error mapping on the retry: throttles and context window overflows raise the SDK exceptions, and a second binding error is re-raised. --- .../src/strands/models/_thinking_binding.py | 25 +++++++++ strands-py/src/strands/models/anthropic.py | 25 ++++----- strands-py/src/strands/models/bedrock.py | 25 ++++----- strands-py/src/strands/models/model.py | 21 -------- .../tests/strands/models/test_anthropic.py | 21 ++++++++ .../tests/strands/models/test_bedrock.py | 30 +++++++++++ .../src/models/__tests__/anthropic.test.ts | 51 +++++++++++++++++-- .../src/models/__tests__/bedrock.test.ts | 39 ++++++++++++++ strands-ts/src/models/anthropic.ts | 22 ++++---- strands-ts/src/models/bedrock.ts | 12 ++--- strands-ts/src/models/model.ts | 37 -------------- strands-ts/src/models/thinking-binding.ts | 40 +++++++++++++++ 12 files changed, 239 insertions(+), 109 deletions(-) create mode 100644 strands-py/src/strands/models/_thinking_binding.py create mode 100644 strands-ts/src/models/thinking-binding.ts diff --git a/strands-py/src/strands/models/_thinking_binding.py b/strands-py/src/strands/models/_thinking_binding.py new file mode 100644 index 0000000000..caae0ab961 --- /dev/null +++ b/strands-py/src/strands/models/_thinking_binding.py @@ -0,0 +1,25 @@ +"""Recovery for replayed thinking blocks that a history edit invalidated. + +Preserved-thinking Claude models bind each thinking block to the content before it. When that content +changes, enforcing accounts reject the request unless it asks the API to drop the stale blocks. +""" + +from typing import Any + +THINKING_BINDING_BETA = "thinking-binding-controls-2026-08-01" +_THINKING_BINDING_ERROR = "bound to a different conversation" + + +def is_thinking_binding_error(error: BaseException) -> bool: + """Whether the provider rejected a replayed thinking block because history before it changed.""" + return _THINKING_BINDING_ERROR in str(error) + + +def thinking_with_drop_block(thinking: Any) -> dict[str, Any] | None: + """Return the thinking config with stale blocks dropped, or None if it cannot or should not change. + + A caller-set ``block_binding`` is left as written. + """ + if not isinstance(thinking, dict) or "block_binding" in thinking: + return None + return {**thinking, "block_binding": {"prefix_mismatch_behavior": "drop_block"}} diff --git a/strands-py/src/strands/models/anthropic.py b/strands-py/src/strands/models/anthropic.py index 5d6b6dfe16..d3e764baf6 100644 --- a/strands-py/src/strands/models/anthropic.py +++ b/strands-py/src/strands/models/anthropic.py @@ -32,16 +32,9 @@ from ..types.streaming import CitationsDelta, StreamEvent from ..types.tools import ToolChoice, ToolChoiceToolDict, ToolSpec from ._defaults import resolve_config_metadata +from ._thinking_binding import THINKING_BINDING_BETA, is_thinking_binding_error, thinking_with_drop_block from ._validation import _has_location_source, _warn_on_deprecated_cache_tools, validate_config_keys -from .model import ( - _THINKING_BINDING_BETA, - BaseModelConfig, - CacheConfig, - CacheToolsConfig, - Model, - _is_thinking_binding_error, - _with_drop_block, -) +from .model import BaseModelConfig, CacheConfig, CacheToolsConfig, Model logger = logging.getLogger(__name__) @@ -546,21 +539,21 @@ def format_request( } if self._drop_stale_thinking_model_id == self.config["model_id"]: - request = self._with_drop_block(request) or request + request = self._request_with_drop_block(request) or request return request - def _with_drop_block(self, request: dict[str, Any]) -> dict[str, Any] | None: + def _request_with_drop_block(self, request: dict[str, Any]) -> dict[str, Any] | None: """Return the request with stale thinking blocks dropped, or None if it cannot change.""" - thinking = _with_drop_block(request.get("thinking")) + thinking = thinking_with_drop_block(request.get("thinking")) if thinking is None: return None extra_headers = dict(request.get("extra_headers") or {}) # Request headers replace client headers of the same name, so betas set on the client are carried over. configured = extra_headers.get("anthropic-beta") or self.client.default_headers.get("anthropic-beta") betas = [beta.strip() for beta in configured.split(",")] if isinstance(configured, str) else [] - if _THINKING_BINDING_BETA not in betas: - betas.append(_THINKING_BINDING_BETA) + if THINKING_BINDING_BETA not in betas: + betas.append(THINKING_BINDING_BETA) extra_headers["anthropic-beta"] = ",".join(beta for beta in betas if beta) return {**request, "thinking": thinking, "extra_headers": extra_headers} @@ -1049,7 +1042,9 @@ async def stream( raise ContextWindowOverflowException(str(error)) from error retry_with_drop_block = ( - continuations == 0 and _is_thinking_binding_error(error) and self._with_drop_block(request) is not None + continuations == 0 + and is_thinking_binding_error(error) + and self._request_with_drop_block(request) is not None ) if not retry_with_drop_block: raise error diff --git a/strands-py/src/strands/models/bedrock.py b/strands-py/src/strands/models/bedrock.py index f43227529b..894a3e5e7d 100644 --- a/strands-py/src/strands/models/bedrock.py +++ b/strands-py/src/strands/models/bedrock.py @@ -35,16 +35,9 @@ from ..types.tools import ToolChoice, ToolSpec from ._defaults import resolve_config_metadata from ._strict_schema import ensure_strict_json_schema +from ._thinking_binding import THINKING_BINDING_BETA, is_thinking_binding_error, thinking_with_drop_block from ._validation import _warn_on_deprecated_cache_tools, validate_config_keys -from .model import ( - _THINKING_BINDING_BETA, - BaseModelConfig, - CacheConfig, - CacheToolsConfig, - Model, - _is_thinking_binding_error, - _with_drop_block, -) +from .model import BaseModelConfig, CacheConfig, CacheToolsConfig, Model logger = logging.getLogger(__name__) @@ -149,14 +142,14 @@ async def _next_stream_event( _CANCEL_POLL_INTERVAL = 0.05 -def _with_drop_block_fields(fields: dict[str, Any]) -> dict[str, Any] | None: +def _fields_with_drop_block(fields: dict[str, Any]) -> dict[str, Any] | None: """Return additional request fields that drop stale thinking blocks, or None if they cannot change.""" - thinking = _with_drop_block(fields.get("thinking")) + thinking = thinking_with_drop_block(fields.get("thinking")) if thinking is None: return None betas = list(fields.get("anthropic_beta") or []) - if _THINKING_BINDING_BETA not in betas: - betas.append(_THINKING_BINDING_BETA) + if THINKING_BINDING_BETA not in betas: + betas.append(THINKING_BINDING_BETA) return {**fields, "anthropic_beta": betas, "thinking": thinking} @@ -510,7 +503,7 @@ def _get_additional_request_fields(self, tool_choice: ToolChoice | None) -> dict return {} if self._drop_stale_thinking_model_id == self.config["model_id"]: - additional_fields = _with_drop_block_fields(additional_fields) or additional_fields + additional_fields = _fields_with_drop_block(additional_fields) or additional_fields return {"additionalModelRequestFields": additional_fields} @@ -524,8 +517,8 @@ def _converse(self, converse_method: Callable[..., Any], request: dict[str, Any] except ClientError as error: fields = request.get("additionalModelRequestFields") or {} error_code = error.response.get("Error", {}).get("Code") - is_binding_error = error_code == "ValidationException" and _is_thinking_binding_error(error) - retry_fields = _with_drop_block_fields(fields) if is_binding_error else None + is_binding_error = error_code == "ValidationException" and is_thinking_binding_error(error) + retry_fields = _fields_with_drop_block(fields) if is_binding_error else None if retry_fields is None: raise diff --git a/strands-py/src/strands/models/model.py b/strands-py/src/strands/models/model.py index 34596f5088..6aa0cb74dd 100644 --- a/strands-py/src/strands/models/model.py +++ b/strands-py/src/strands/models/model.py @@ -122,27 +122,6 @@ def _estimate_tokens_with_heuristic( return total -# Preserved-thinking Claude models bind each thinking block to the content before it. When that content -# changes, enforcing accounts reject the request unless it asks the API to drop the stale blocks. -_THINKING_BINDING_BETA = "thinking-binding-controls-2026-08-01" -_THINKING_BINDING_ERROR = "bound to a different conversation" - - -def _is_thinking_binding_error(error: BaseException) -> bool: - """Whether the provider rejected a replayed thinking block because history before it changed.""" - return _THINKING_BINDING_ERROR in str(error) - - -def _with_drop_block(thinking: Any) -> dict[str, Any] | None: - """Return the thinking config with stale blocks dropped, or None if it cannot or should not change. - - A caller-set ``block_binding`` is left as written. - """ - if not isinstance(thinking, dict) or "block_binding" in thinking: - return None - return {**thinking, "block_binding": {"prefix_mismatch_behavior": "drop_block"}} - - class BaseModelConfig(TypedDict, total=False): """Base configuration shared by all model providers. diff --git a/strands-py/tests/strands/models/test_anthropic.py b/strands-py/tests/strands/models/test_anthropic.py index 024bae1834..ffe8c8addb 100644 --- a/strands-py/tests/strands/models/test_anthropic.py +++ b/strands-py/tests/strands/models/test_anthropic.py @@ -1132,6 +1132,27 @@ async def test_stream_retries_drop_block_once_when_params_override_model(anthrop assert anthropic_client.messages.stream.call_count == 2 +@pytest.mark.parametrize( + ("retry_error", "exp_exception"), + [ + (anthropic.RateLimitError("rate limit", response=unittest.mock.Mock(), body=None), ModelThrottledException), + ( + anthropic.BadRequestError("input is too long", response=unittest.mock.Mock(), body=None), + ContextWindowOverflowException, + ), + ], +) +@pytest.mark.asyncio +async def test_stream_maps_retry_error_to_sdk_exception(anthropic_client, alist, messages, retry_error, exp_exception): + anthropic_client.messages.stream.side_effect = [_thinking_binding_error(), retry_error] + model = AnthropicModel(model_id="m1", max_tokens=1, params={"thinking": {"type": "adaptive"}}) + + with pytest.raises(exp_exception): + await alist(model.stream(messages)) + + assert anthropic_client.messages.stream.call_count == 2 + + def test_format_request_drops_stale_thinking_only_for_remembered_model(anthropic_client, messages): _ = anthropic_client model = AnthropicModel( diff --git a/strands-py/tests/strands/models/test_bedrock.py b/strands-py/tests/strands/models/test_bedrock.py index 644ce33baf..7d5754cd0a 100644 --- a/strands-py/tests/strands/models/test_bedrock.py +++ b/strands-py/tests/strands/models/test_bedrock.py @@ -1275,6 +1275,36 @@ async def test_stream_does_not_retry_binding_message_on_non_validation_error(bed bedrock_client.converse_stream.assert_called_once() +@pytest.mark.parametrize("streaming", [True, False]) +@pytest.mark.parametrize( + ("retry_error", "exp_exception"), + [ + ({"Code": "ThrottlingException", "Message": "Rate exceeded"}, ModelThrottledException), + ( + {"Code": "ValidationException", "Message": "Input is too long for requested model"}, + ContextWindowOverflowException, + ), + ({"Code": "ValidationException", "Message": "bound to a different conversation"}, ClientError), + ], +) +@pytest.mark.asyncio +async def test_stream_maps_retry_error_to_sdk_exception( + bedrock_client, alist, messages, streaming, retry_error, exp_exception +): + operation_name = "ConverseStream" if streaming else "Converse" + converse_method = bedrock_client.converse_stream if streaming else bedrock_client.converse + converse_method.side_effect = [ + _thinking_binding_error(operation_name), + ClientError({"Error": retry_error}, operation_name), + ] + model = BedrockModel(model_id="m1", streaming=streaming, additional_request_fields={"thinking": THINKING}) + + with pytest.raises(exp_exception): + await alist(model.stream(messages)) + + assert converse_method.call_count == 2 + + def test_format_request_drops_stale_thinking_only_for_remembered_model(bedrock_client, messages): _ = bedrock_client model = BedrockModel(model_id="m1", additional_request_fields={"thinking": THINKING}) diff --git a/strands-ts/src/models/__tests__/anthropic.test.ts b/strands-ts/src/models/__tests__/anthropic.test.ts index e3d3e9db73..5cc54d5559 100644 --- a/strands-ts/src/models/__tests__/anthropic.test.ts +++ b/strands-ts/src/models/__tests__/anthropic.test.ts @@ -517,12 +517,13 @@ describe('AnthropicModel', () => { const dropBlockThinking = { type: 'adaptive', block_binding: { prefix_mismatch_behavior: 'drop_block' } } const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })] - const createRejectingOnceClient = (error: Error = bindingError) => { + const createRejectingOnceClient = (error: Error = bindingError, retryError?: Error) => { let calls = 0 const stream = vi.fn(() => (async function* () { calls += 1 if (calls === 1) throw error + if (retryError) throw retryError yield { type: 'message_start', message: { role: 'assistant', usage: {} } } })() ) @@ -556,7 +557,18 @@ describe('AnthropicModel', () => { warnSpy.mockRestore() }) - it('keeps betas from the client default headers on the retry', async () => { + it.each([ + { name: 'a record', defaultHeaders: { 'anthropic-beta': 'client-beta' }, betas: 'client-beta,' }, + { name: 'a record with a list value', defaultHeaders: { 'Anthropic-Beta': ['a', 'b'] }, betas: 'a,b,' }, + { + name: 'a Headers instance', + defaultHeaders: new Headers({ 'anthropic-beta': 'client-beta' }), + betas: 'client-beta,', + }, + { name: 'header tuples', defaultHeaders: [['anthropic-beta', 'client-beta']], betas: 'client-beta,' }, + { name: 'an undefined value', defaultHeaders: { 'anthropic-beta': undefined }, betas: '' }, + { name: 'a null value', defaultHeaders: { 'anthropic-beta': null }, betas: '' }, + ])('keeps betas from client default headers given as $name on the retry', async ({ defaultHeaders, betas }) => { const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) const { stream, client } = createRejectingOnceClient() vi.mocked(Anthropic).mockImplementationOnce(function () { @@ -564,7 +576,7 @@ describe('AnthropicModel', () => { }) const provider = new AnthropicModel({ apiKey: 'sk-test', - clientConfig: { defaultHeaders: { 'anthropic-beta': 'client-beta' } }, + clientConfig: { defaultHeaders }, modelId: 'm1', params: { thinking: { type: 'adaptive' } }, }) @@ -572,11 +584,42 @@ describe('AnthropicModel', () => { await collectIterator(provider.stream(messages)) expect(stream).toHaveBeenLastCalledWith(expect.objectContaining({ thinking: dropBlockThinking }), { - headers: { 'anthropic-beta': 'client-beta,thinking-binding-controls-2026-08-01' }, + headers: { 'anthropic-beta': `${betas}thinking-binding-controls-2026-08-01` }, }) warnSpy.mockRestore() }) + it.each([ + { + name: 'a throttle', + retryError: Object.assign(new Error('Rate limit exceeded'), { status: 429 }), + expected: ModelThrottledError, + }, + { + name: 'a context window overflow', + retryError: Object.assign(new Error('prompt is too long'), { status: 400 }), + expected: ContextWindowOverflowError, + }, + ])('maps $name on the retry to its SDK error', async ({ retryError, expected }) => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const { stream, client } = createRejectingOnceClient(bindingError, retryError) + const provider = new AnthropicModel({ client, modelId: 'm1', params: { thinking: { type: 'adaptive' } } }) + + await expect(collectIterator(provider.stream(messages))).rejects.toThrow(expected) + expect(stream).toHaveBeenCalledTimes(2) + warnSpy.mockRestore() + }) + + it('re-throws a second binding error without retrying again', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const { stream, client } = createRejectingOnceClient(bindingError, bindingError) + const provider = new AnthropicModel({ client, modelId: 'm1', params: { thinking: { type: 'adaptive' } } }) + + await expect(collectIterator(provider.stream(messages))).rejects.toThrow('bound to a different conversation') + expect(stream).toHaveBeenCalledTimes(2) + warnSpy.mockRestore() + }) + it.each([ { name: 'a caller-set block_binding', diff --git a/strands-ts/src/models/__tests__/bedrock.test.ts b/strands-ts/src/models/__tests__/bedrock.test.ts index 096aa7707e..a1f0ef323f 100644 --- a/strands-ts/src/models/__tests__/bedrock.test.ts +++ b/strands-ts/src/models/__tests__/bedrock.test.ts @@ -1509,6 +1509,45 @@ describe('BedrockModel', () => { expect(mockSend).toHaveBeenCalledOnce() }) + it.each([ + { + name: 'a throttle', + retry: async () => ({ + stream: (async function* (): AsyncGenerator { + yield { throttlingException: { message: 'Rate exceeded' } } + })(), + }), + expected: ModelThrottledError, + }, + { + name: 'a context window overflow', + retry: async () => { + throw new Error('Input is too long for requested model') + }, + expected: ContextWindowOverflowError, + }, + ])('maps $name on the retry to its SDK error', async ({ retry, expected }) => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const mockSend = vi.fn().mockRejectedValueOnce(bindingError).mockImplementationOnce(retry) + mockBedrockClientImplementation({ send: mockSend }) + const provider = new BedrockModel({ modelId: 'm1', additionalRequestFields: { thinking } }) + + await expect(collectIterator(provider.stream(messages))).rejects.toThrow(expected) + expect(mockSend).toHaveBeenCalledTimes(2) + warnSpy.mockRestore() + }) + + it('re-throws a second binding error without retrying again', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const mockSend = vi.fn().mockRejectedValue(bindingError) + mockBedrockClientImplementation({ send: mockSend }) + const provider = new BedrockModel({ modelId: 'm1', additionalRequestFields: { thinking } }) + + await expect(collectIterator(provider.stream(messages))).rejects.toThrow('bound to a different conversation') + expect(mockSend).toHaveBeenCalledTimes(2) + warnSpy.mockRestore() + }) + it('keeps thinking off when a forced tool choice follows the retry', async () => { const mockSend = vi .fn() diff --git a/strands-ts/src/models/anthropic.ts b/strands-ts/src/models/anthropic.ts index f066eca5c8..ebc2d2d0e2 100644 --- a/strands-ts/src/models/anthropic.ts +++ b/strands-ts/src/models/anthropic.ts @@ -8,10 +8,8 @@ import { resolveCacheSection, type CacheConfig, type ResolvedCacheSection, - THINKING_BINDING_BETA, - isThinkingBindingError, - withDropBlock, } from '../models/model.js' +import { THINKING_BINDING_BETA, isThinkingBindingError, thinkingWithDropBlock } from './thinking-binding.js' import type { Message, ContentBlock, SystemPrompt } from '../types/messages.js' import type { ModelStreamEvent } from '../models/streaming.js' import { createEmptyUsage } from '../models/streaming.js' @@ -88,12 +86,18 @@ function mergeUsage(target: Record, source: object): void { } /** - * Reads the `anthropic-beta` values from client default headers. + * Reads the `anthropic-beta` values from client default headers, skipping the `undefined` and `null` values + * the SDK leaves out of the request. */ function headerBetas(headers: ClientOptions['defaultHeaders']): string[] { - const value = headers ? new Headers(headers as HeadersInit).get('anthropic-beta') : null - if (!value) return [] - return value.split(',').flatMap((beta) => beta.trim() || []) + if (!headers) return [] + const rows: Iterable = + headers instanceof Headers || Array.isArray(headers) ? headers : Object.entries(headers) + return [...rows] + .filter(([name]) => typeof name === 'string' && name.toLowerCase() === 'anthropic-beta') + .flatMap(([, value]) => (Array.isArray(value) ? value : [value])) + .filter((value): value is string => typeof value === 'string') + .flatMap((value) => value.split(',').flatMap((beta) => beta.trim() || [])) } export interface AnthropicModelConfig extends BaseModelConfig { @@ -438,7 +442,7 @@ export class AnthropicModel extends Model { continuations === 0 && (unknownError as { status?: number }).status === 400 && isThinkingBindingError(unknownError) && - withDropBlock(thinking) + thinkingWithDropBlock(thinking) ) { // Stale blocks stay in history, so drop_block stays on for every later request to this model. logger.warn( @@ -643,7 +647,7 @@ export class AnthropicModel extends Model { if (this._config.topP !== undefined) request.top_p = this._config.topP if (this._config.stopSequences !== undefined) request.stop_sequences = this._config.stopSequences if (this._config.params) Object.assign(request, this._config.params) - const dropBlockThinking = this._dropsStaleThinking() ? withDropBlock(request.thinking) : undefined + const dropBlockThinking = this._dropsStaleThinking() ? thinkingWithDropBlock(request.thinking) : undefined if (dropBlockThinking) request.thinking = dropBlockThinking // params.tools is already merged into tools above; never let it bypass the forced-turn rule. if (tools.length > 0) request.tools = tools diff --git a/strands-ts/src/models/bedrock.ts b/strands-ts/src/models/bedrock.ts index 5034f7e949..59246dc007 100644 --- a/strands-ts/src/models/bedrock.ts +++ b/strands-ts/src/models/bedrock.ts @@ -58,10 +58,8 @@ import { type StreamOptions, resolveCacheSection, resolveConfigMetadata, - THINKING_BINDING_BETA, - isThinkingBindingError, - withDropBlock, } from '../models/model.js' +import { THINKING_BINDING_BETA, isThinkingBindingError, thinkingWithDropBlock } from './thinking-binding.js' import type { ContentBlock, Message, StopReason, ToolUseBlock } from '../types/messages.js' import type { AudioSource, ImageSource, VideoSource, DocumentSource } from '../types/media.js' import type { CitationsDelta, ModelStreamEvent, ReasoningContentDelta, Usage } from '../models/streaming.js' @@ -798,7 +796,7 @@ export class BedrockModel extends Model { } catch (error) { const isBindingError = error instanceof Error && error.name === 'ValidationException' && isThinkingBindingError(error) - const retryFields = isBindingError ? withDropBlockFields(request.additionalModelRequestFields) : undefined + const retryFields = isBindingError ? fieldsWithDropBlock(request.additionalModelRequestFields) : undefined if (!retryFields) throw error logger.warn( @@ -952,7 +950,7 @@ export class BedrockModel extends Model { const toolChoice = options?.toolChoice if (!toolChoice || 'auto' in toolChoice) { - return this._dropStaleThinkingModelId === this._config.modelId ? (withDropBlockFields(fields) ?? fields) : fields + return this._dropStaleThinkingModelId === this._config.modelId ? (fieldsWithDropBlock(fields) ?? fields) : fields } const { thinking: _, ...rest } = fields @@ -2122,9 +2120,9 @@ export class BedrockModel extends Model { * @param fields - The additional model request fields * @returns The fields with drop_block and its beta, or undefined */ -function withDropBlockFields(fields: JSONValue | undefined): Record | undefined { +function fieldsWithDropBlock(fields: JSONValue | undefined): Record | undefined { if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) return undefined - const thinking = withDropBlock(fields.thinking) + const thinking = thinkingWithDropBlock(fields.thinking) if (!thinking) return undefined const betas = Array.isArray(fields.anthropic_beta) ? fields.anthropic_beta : [] const anthropicBeta = betas.includes(THINKING_BINDING_BETA) ? betas : [...betas, THINKING_BINDING_BETA] diff --git a/strands-ts/src/models/model.ts b/strands-ts/src/models/model.ts index dad0b69d4f..5d4a66e515 100644 --- a/strands-ts/src/models/model.ts +++ b/strands-ts/src/models/model.ts @@ -168,43 +168,6 @@ export function resolveCacheSection( return ttl ? { enabled, ttl } : { enabled } } -/** - * Beta that lets a request drop thinking blocks invalidated by an edit to the history before them. - * - * Preserved-thinking Claude models bind each thinking block to the content before it. When that content - * changes, enforcing accounts reject the request unless it asks the API to drop the stale blocks. - * - * @internal - */ -export const THINKING_BINDING_BETA = 'thinking-binding-controls-2026-08-01' - -const THINKING_BINDING_ERROR = 'bound to a different conversation' - -/** - * Whether the provider rejected a replayed thinking block because history before it changed. - * - * @internal - */ -export function isThinkingBindingError(error: unknown): boolean { - return error instanceof Error && error.message.includes(THINKING_BINDING_ERROR) -} - -/** - * Return the thinking config with stale blocks dropped, or undefined if it cannot or should not change. - * - * A caller-set `block_binding` is left as written. - * - * @internal - */ -export function withDropBlock( - thinking: T -): (T & { block_binding: { prefix_mismatch_behavior: 'drop_block' } }) | undefined { - if (typeof thinking !== 'object' || thinking === null || Array.isArray(thinking) || 'block_binding' in thinking) { - return undefined - } - return { ...thinking, block_binding: { prefix_mismatch_behavior: 'drop_block' } } -} - /** * Base configuration interface for all model providers. * diff --git a/strands-ts/src/models/thinking-binding.ts b/strands-ts/src/models/thinking-binding.ts new file mode 100644 index 0000000000..968341fa3a --- /dev/null +++ b/strands-ts/src/models/thinking-binding.ts @@ -0,0 +1,40 @@ +/** + * Recovery for replayed thinking blocks that a history edit invalidated. + * + * Preserved-thinking Claude models bind each thinking block to the content before it. When that content + * changes, enforcing accounts reject the request unless it asks the API to drop the stale blocks. + */ + +/** + * Beta that lets a request drop thinking blocks invalidated by an edit to the history before them. + * + * @internal + */ +export const THINKING_BINDING_BETA = 'thinking-binding-controls-2026-08-01' + +const THINKING_BINDING_ERROR = 'bound to a different conversation' + +/** + * Whether the provider rejected a replayed thinking block because history before it changed. + * + * @internal + */ +export function isThinkingBindingError(error: unknown): boolean { + return error instanceof Error && error.message.includes(THINKING_BINDING_ERROR) +} + +/** + * Return the thinking config with stale blocks dropped, or undefined if it cannot or should not change. + * + * A caller-set `block_binding` is left as written. + * + * @internal + */ +export function thinkingWithDropBlock( + thinking: T +): (T & { block_binding: { prefix_mismatch_behavior: 'drop_block' } }) | undefined { + if (typeof thinking !== 'object' || thinking === null || Array.isArray(thinking) || 'block_binding' in thinking) { + return undefined + } + return { ...thinking, block_binding: { prefix_mismatch_behavior: 'drop_block' } } +}