From bb2d11f328a772ffe8e1b1fa5759d019e4c58f13 Mon Sep 17 00:00:00 2001 From: gingeekrishna Date: Sun, 9 Aug 2026 00:04:25 +0530 Subject: [PATCH 1/3] fix: strip reasoningContent blocks in filter_restored_tool_context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When extended thinking is enabled, assistant messages contain reasoningContent blocks alongside toolUse blocks. The filter was stripping toolUse/toolResult but leaving reasoningContent intact, producing a partial assistant message that Bedrock rejects with: ValidationException: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. reasoningContent blocks are semantically coupled to the tool calls that follow them — stripping the tool context without the accompanying reasoning leaves an incoherent and API-rejected message. The fix adds reasoningContent to the set of block types removed by the filter. Fixes #621 Signed-off-by: gingeekrishna --- .../integrations/strands/session_manager.py | 12 ++- .../test_agentcore_memory_session_manager.py | 102 +++++++++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/bedrock_agentcore/memory/integrations/strands/session_manager.py b/src/bedrock_agentcore/memory/integrations/strands/session_manager.py index f930b548..90f6ceea 100644 --- a/src/bedrock_agentcore/memory/integrations/strands/session_manager.py +++ b/src/bedrock_agentcore/memory/integrations/strands/session_manager.py @@ -799,14 +799,22 @@ def list_messages( return [] def _filter_restored_tool_context(self, messages: list[SessionMessage]) -> list[SessionMessage]: - """Strip historical toolUse/toolResult context from restored messages.""" + """Strip historical toolUse/toolResult context from restored messages. + + Extended-thinking (reasoningContent) blocks are coupled to the tool + calls that follow them. Bedrock rejects an assistant message whose + reasoningContent blocks have been separated from their companion + toolUse blocks, so we must strip both together. + """ filtered_messages: list[SessionMessage] = [] for session_message in messages: message = session_message.to_message() filtered_content = [ content for content in message.get("content", []) - if "toolUse" not in content and "toolResult" not in content + if "toolUse" not in content + and "toolResult" not in content + and "reasoningContent" not in content ] if not filtered_content: diff --git a/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py b/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py index b225cfa9..9ef30c84 100644 --- a/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py +++ b/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py @@ -3925,4 +3925,104 @@ def test_same_millisecond_different_microseconds_is_a_tie(self, session_manager) # through as a false non-tie. assert r1 == datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) assert r2 == datetime(2024, 1, 1, 12, 0, 0, 1000, tzinfo=timezone.utc) - assert r2 > r1 + + +class TestFilterRestoredToolContext: + """Regression tests for _filter_restored_tool_context with extended thinking. + + Regression for https://github.com/aws/bedrock-agentcore-sdk-python/issues/621. + + When extended thinking is enabled, Bedrock rejects assistant messages whose + reasoningContent blocks have been stripped of their companion toolUse blocks + (or vice-versa). The filter must remove reasoningContent blocks alongside + toolUse/toolResult so that no partial assistant message reaches the API. + """ + + def _make_session_message(self, role: str, content: list) -> SessionMessage: + return SessionMessage.from_message({"role": role, "content": content}, 0) + + def test_strips_reasoning_content_alongside_tool_use(self, session_manager): + """reasoningContent blocks paired with toolUse must both be removed.""" + messages = [ + self._make_session_message( + "assistant", + [ + {"reasoningContent": {"reasoningText": {"text": "I should call weather.", "signature": "sig1"}}}, + {"toolUse": {"toolUseId": "tu_1", "name": "get_weather", "input": {"city": "Paris"}}}, + ], + ), + self._make_session_message("user", [{"toolResult": {"toolUseId": "tu_1", "content": [{"text": "22C"}]}}]), + self._make_session_message("assistant", [{"text": "It is 22°C in Paris."}]), + ] + + result = session_manager._filter_restored_tool_context(messages) + + # The tool-use assistant turn and its toolResult are dropped entirely. + # The plain-text assistant turn survives. + assert len(result) == 1 + assert result[0].to_message()["content"] == [{"text": "It is 22°C in Paris."}] + + def test_message_with_only_reasoning_and_tool_use_is_dropped_entirely(self, session_manager): + """An assistant message whose entire content is reasoningContent + toolUse + produces an empty filtered_content and must be excluded from the output.""" + messages = [ + self._make_session_message( + "assistant", + [ + {"reasoningContent": {"reasoningText": {"text": "Reasoning.", "signature": "s"}}}, + {"toolUse": {"toolUseId": "tu_x", "name": "calc", "input": {}}}, + ], + ), + ] + + result = session_manager._filter_restored_tool_context(messages) + assert result == [] + + def test_text_alongside_reasoning_and_tool_use_is_preserved(self, session_manager): + """If the assistant message has text content in addition to reasoningContent + and toolUse, the text survives after the other blocks are stripped.""" + messages = [ + self._make_session_message( + "assistant", + [ + {"reasoningContent": {"reasoningText": {"text": "Let me look this up.", "signature": "s"}}}, + {"text": "Checking now…"}, + {"toolUse": {"toolUseId": "tu_2", "name": "search", "input": {"q": "Paris weather"}}}, + ], + ), + ] + + result = session_manager._filter_restored_tool_context(messages) + + assert len(result) == 1 + content = result[0].to_message()["content"] + assert content == [{"text": "Checking now…"}] + + def test_messages_without_tool_context_are_unchanged(self, session_manager): + """Plain user/assistant messages (no tool or reasoning blocks) pass through.""" + messages = [ + self._make_session_message("user", [{"text": "What is the weather?"}]), + self._make_session_message("assistant", [{"text": "I don't know."}]), + ] + + result = session_manager._filter_restored_tool_context(messages) + + assert len(result) == 2 + assert result[0].to_message()["content"] == [{"text": "What is the weather?"}] + assert result[1].to_message()["content"] == [{"text": "I don't know."}] + + def test_existing_tool_use_filtering_still_works(self, session_manager): + """Original toolUse/toolResult filtering behaviour is preserved.""" + messages = [ + self._make_session_message( + "assistant", + [{"toolUse": {"toolUseId": "tu_3", "name": "calc", "input": {"expr": "1+1"}}}], + ), + self._make_session_message("user", [{"toolResult": {"toolUseId": "tu_3", "content": [{"text": "2"}]}}]), + self._make_session_message("assistant", [{"text": "The answer is 2."}]), + ] + + result = session_manager._filter_restored_tool_context(messages) + + assert len(result) == 1 + assert result[0].to_message()["content"] == [{"text": "The answer is 2."}] From d4f9b5d6e90728dd8cf96295912dba059c645fa6 Mon Sep 17 00:00:00 2001 From: gingeekrishna Date: Sun, 9 Aug 2026 00:23:47 +0530 Subject: [PATCH 2/3] docs: update AgentCoreMemoryConfig docstring for filter_restored_tool_context Clarify that the flag strips reasoningContent (extended thinking) blocks in addition to toolUse/toolResult, and explain why the two are always removed together. Signed-off-by: gingeekrishna --- .../memory/integrations/strands/config.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/bedrock_agentcore/memory/integrations/strands/config.py b/src/bedrock_agentcore/memory/integrations/strands/config.py index a42e824e..4efee95b 100644 --- a/src/bedrock_agentcore/memory/integrations/strands/config.py +++ b/src/bedrock_agentcore/memory/integrations/strands/config.py @@ -55,8 +55,12 @@ class AgentCoreMemoryConfig(BaseModel): Default is None (disabled). context_tag: XML tag name used to wrap retrieved memory context injected into messages. Default is "user_context". - filter_restored_tool_context: When True, strip historical toolUse/toolResult blocks from - restored messages before loading them into Strands runtime memory. Default is False. + filter_restored_tool_context: When True, strip historical toolUse, toolResult, and + reasoningContent (extended thinking) blocks from restored messages before loading them + into Strands runtime memory. Default is False. + Note: reasoningContent blocks are always stripped together with their companion toolUse + blocks because Bedrock rejects assistant messages that contain reasoning blocks without + the tool calls that followed them. default_metadata: Optional default metadata key-value pairs to attach to every message event. Merged with any per-call metadata. Maximum 15 total keys per event (including internal keys). Accepts plain strings (auto-wrapped) or explicit MetadataValue dicts. From 4c44c6e7191f12169b1364f623579449dcfa233f Mon Sep 17 00:00:00 2001 From: Radhakrishnan Pachyappan Date: Fri, 16 Oct 2026 04:00:57 +0530 Subject: [PATCH 3/3] test: cover reasoningContent stripping with no tool blocks present reasoningContent is invalidated by any change earlier in the context, not just tool stripping, so the filter must drop it unconditionally rather than only when paired with toolUse. Adds the missing text-only case and clarifies the docstring accordingly. --- .../integrations/strands/session_manager.py | 10 ++++++---- .../test_agentcore_memory_session_manager.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/bedrock_agentcore/memory/integrations/strands/session_manager.py b/src/bedrock_agentcore/memory/integrations/strands/session_manager.py index 90f6ceea..9fc6dff7 100644 --- a/src/bedrock_agentcore/memory/integrations/strands/session_manager.py +++ b/src/bedrock_agentcore/memory/integrations/strands/session_manager.py @@ -801,10 +801,12 @@ def list_messages( def _filter_restored_tool_context(self, messages: list[SessionMessage]) -> list[SessionMessage]: """Strip historical toolUse/toolResult context from restored messages. - Extended-thinking (reasoningContent) blocks are coupled to the tool - calls that follow them. Bedrock rejects an assistant message whose - reasoningContent blocks have been separated from their companion - toolUse blocks, so we must strip both together. + Extended-thinking (reasoningContent) blocks are bound to the exact + context they were produced in; any edit to earlier history, including + this filter's own tool stripping, invalidates them. Bedrock rejects + an assistant message carrying a reasoningContent block on replay if + anything before it changed, so we strip reasoningContent unconditionally + alongside toolUse/toolResult rather than only when paired with a tool. """ filtered_messages: list[SessionMessage] = [] for session_message in messages: diff --git a/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py b/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py index 9ef30c84..e01a9d90 100644 --- a/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py +++ b/tests/bedrock_agentcore/memory/integrations/strands/test_agentcore_memory_session_manager.py @@ -3998,6 +3998,26 @@ def test_text_alongside_reasoning_and_tool_use_is_preserved(self, session_manage content = result[0].to_message()["content"] assert content == [{"text": "Checking now…"}] + def test_strips_reasoning_content_with_no_tool_blocks(self, session_manager): + """reasoningContent is bound to the context it was produced in, not just + to a following toolUse: a system-prompt change between turns (e.g. one + that embeds the current time) invalidates it in a text-only assistant + turn too, so it must still be stripped even with no tool blocks present.""" + messages = [ + self._make_session_message( + "assistant", + [ + {"reasoningContent": {"reasoningText": {"text": "Thinking it through.", "signature": "s"}}}, + {"text": "Here is the answer."}, + ], + ), + ] + + result = session_manager._filter_restored_tool_context(messages) + + assert len(result) == 1 + assert result[0].to_message()["content"] == [{"text": "Here is the answer."}] + def test_messages_without_tool_context_are_unchanged(self, session_manager): """Plain user/assistant messages (no tool or reasoning blocks) pass through.""" messages = [