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. 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."}]