From cfc5864ac23b14398faa7225a745c5f6d2e53ffd Mon Sep 17 00:00:00 2001 From: Jack Yuan Date: Tue, 29 Jul 2025 11:53:22 -0400 Subject: [PATCH 1/4] fix: fix non-serializable parameter of agent from toolUse block --- src/strands/agent/agent.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 2022142c6..975c96fc5 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -628,6 +628,10 @@ def _record_tool_execution( if user_message_override: user_msg_content.insert(0, {"text": f"{user_message_override}\n"}) + sanitized_input = json.loads(input_parameters) + sanitized_tool = tool.copy() + sanitized_tool["input"] = sanitized_input + # Create the message sequence user_msg: Message = { "role": "user", @@ -635,7 +639,7 @@ def _record_tool_execution( } tool_use_msg: Message = { "role": "assistant", - "content": [{"toolUse": tool}], + "content": [{"toolUse": sanitized_tool}], } tool_result_msg: Message = { "role": "user", From 8bd916e4fadc7ece7357c26770973d183cd25397 Mon Sep 17 00:00:00 2001 From: fhwilton55 <81768750+fhwilton55@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:16:53 -0400 Subject: [PATCH 2/4] feat: Add configuration option to MCP Client for server init timeout (#657) Co-authored-by: Harry Wilton --- src/strands/tools/mcp/mcp_client.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index c1aa96df3..7cb03e46f 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -63,17 +63,23 @@ class MCPClient: from MCP tools, it will be returned as the last item in the content array of the ToolResult. """ - def __init__(self, transport_callable: Callable[[], MCPTransport]): + def __init__(self, transport_callable: Callable[[], MCPTransport], *, startup_timeout: int = 30): """Initialize a new MCP Server connection. Args: transport_callable: A callable that returns an MCPTransport (read_stream, write_stream) tuple + startup_timeout: Timeout after which MCP server initialization should be cancelled + Defaults to 30. """ + self._startup_timeout = startup_timeout + mcp_instrumentation() self._session_id = uuid.uuid4() self._log_debug_with_thread("initializing MCPClient connection") - self._init_future: futures.Future[None] = futures.Future() # Main thread blocks until future completes - self._close_event = asyncio.Event() # Do not want to block other threads while close event is false + # Main thread blocks until future completesock + self._init_future: futures.Future[None] = futures.Future() + # Do not want to block other threads while close event is false + self._close_event = asyncio.Event() self._transport_callable = transport_callable self._background_thread: threading.Thread | None = None @@ -109,7 +115,7 @@ def start(self) -> "MCPClient": self._log_debug_with_thread("background thread started, waiting for ready event") try: # Blocking main thread until session is initialized in other thread or if the thread stops - self._init_future.result(timeout=30) + self._init_future.result(timeout=self._startup_timeout) self._log_debug_with_thread("the client initialization was successful") except futures.TimeoutError as e: raise MCPClientInitializationError("background thread did not start in 30 seconds") from e @@ -347,7 +353,8 @@ async def _async_background_thread(self) -> None: self._log_debug_with_thread("session initialized successfully") # Store the session for use while we await the close event self._background_thread_session = session - self._init_future.set_result(None) # Signal that the session has been created and is ready for use + # Signal that the session has been created and is ready for use + self._init_future.set_result(None) self._log_debug_with_thread("waiting for close signal") # Keep background thread running until signaled to close. From e00f3fd96a1e4b0543cc65b0ad84b7a77b170797 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow <3211021+zastrowm@users.noreply.github.com> Date: Wed, 13 Aug 2025 08:58:33 -0400 Subject: [PATCH 3/4] fix: Bedrock hang when exception occurs during message conversion (#643) Previously (#642) bedrock would hang during message conversion because the exception was not being caught and thus the queue was always empty. Now all exceptions during conversion are caught Co-authored-by: Mackenzie Zastrow --- pyproject.toml | 2 +- src/strands/models/bedrock.py | 12 ++++++------ tests/strands/models/test_bedrock.py | 9 +++++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 586a956af..d4a4b79dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -234,8 +234,8 @@ test-integ = [ "hatch test tests_integ {args}" ] prepare = [ - "hatch fmt --linter", "hatch fmt --formatter", + "hatch fmt --linter", "hatch run test-lint", "hatch test --all" ] diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index 4ea1453a4..ace35640a 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -418,14 +418,14 @@ def _stream( ContextWindowOverflowException: If the input exceeds the model's context window. ModelThrottledException: If the model service is throttling requests. """ - logger.debug("formatting request") - request = self.format_request(messages, tool_specs, system_prompt) - logger.debug("request=<%s>", request) + try: + logger.debug("formatting request") + request = self.format_request(messages, tool_specs, system_prompt) + logger.debug("request=<%s>", request) - logger.debug("invoking model") - streaming = self.config.get("streaming", True) + logger.debug("invoking model") + streaming = self.config.get("streaming", True) - try: logger.debug("got response from model") if streaming: response = self.client.converse_stream(**request) diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index 0a2846adf..09e508845 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -419,6 +419,15 @@ async def test_stream_throttling_exception_from_event_stream_error(bedrock_clien ) +@pytest.mark.asyncio +async def test_stream_with_invalid_content_throws(bedrock_client, model, alist): + # We used to hang on None, so ensure we don't regress: https://github.com/strands-agents/sdk-python/issues/642 + messages = [{"role": "user", "content": None}] + + with pytest.raises(TypeError): + await alist(model.stream(messages)) + + @pytest.mark.asyncio async def test_stream_throttling_exception_from_general_exception(bedrock_client, model, messages, alist): error_message = "ThrottlingException: Rate exceeded for ConverseStream" From e5499dbe2ecfbce3846323c60fad4f99a0354505 Mon Sep 17 00:00:00 2001 From: Jack Yuan Date: Thu, 14 Aug 2025 10:50:26 -0400 Subject: [PATCH 4/4] fix: only include parameters that defined in tool spec --- src/strands/agent/agent.py | 35 ++++++-- tests/strands/agent/test_agent.py | 127 ++++++++---------------------- 2 files changed, 64 insertions(+), 98 deletions(-) diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 975c96fc5..88254dbd0 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -617,8 +617,11 @@ def _record_tool_execution( tool_result: The result returned by the tool. user_message_override: Optional custom message to include. """ + # Filter tool input parameters to only include those defined in tool spec + filtered_input = self._filter_tool_parameters_for_recording(tool["name"], tool["input"]) + # Create user message describing the tool call - input_parameters = json.dumps(tool["input"], default=lambda o: f"<>") + input_parameters = json.dumps(filtered_input, default=lambda o: f"<>") user_msg_content: list[ContentBlock] = [ {"text": (f"agent.tool.{tool['name']} direct tool call.\nInput parameters: {input_parameters}\n")} @@ -628,9 +631,12 @@ def _record_tool_execution( if user_message_override: user_msg_content.insert(0, {"text": f"{user_message_override}\n"}) - sanitized_input = json.loads(input_parameters) - sanitized_tool = tool.copy() - sanitized_tool["input"] = sanitized_input + # Create filtered tool use for message history + filtered_tool: ToolUse = { + "toolUseId": tool["toolUseId"], + "name": tool["name"], + "input": filtered_input, + } # Create the message sequence user_msg: Message = { @@ -639,7 +645,7 @@ def _record_tool_execution( } tool_use_msg: Message = { "role": "assistant", - "content": [{"toolUse": sanitized_tool}], + "content": [{"toolUse": filtered_tool}], } tool_result_msg: Message = { "role": "user", @@ -696,6 +702,25 @@ def _end_agent_trace_span( self.tracer.end_agent_span(**trace_attributes) + def _filter_tool_parameters_for_recording(self, tool_name: str, input_params: dict[str, Any]) -> dict[str, Any]: + """Filter input parameters to only include those defined in the tool specification. + + Args: + tool_name: Name of the tool to get specification for + input_params: Original input parameters + + Returns: + Filtered parameters containing only those defined in tool spec + """ + all_tools_config = self.tool_registry.get_all_tools_config() + tool_spec = all_tools_config.get(tool_name) + + if not tool_spec or "inputSchema" not in tool_spec: + return input_params.copy() + + properties = tool_spec["inputSchema"]["json"]["properties"] + return {k: v for k, v in input_params.items() if k in properties} + def _append_message(self, message: Message) -> None: """Appends a message to the agent's list of messages and invokes the callbacks for the MessageCreatedEvent.""" self.messages.append(message) diff --git a/tests/strands/agent/test_agent.py b/tests/strands/agent/test_agent.py index c27243dfe..210710a69 100644 --- a/tests/strands/agent/test_agent.py +++ b/tests/strands/agent/test_agent.py @@ -1687,99 +1687,7 @@ def test_agent_tool_non_serializable_parameter_filtering(agent, mock_randint): tool_call_text = user_message["content"][1]["text"] assert "agent.tool.tool_decorated direct tool call." in tool_call_text assert '"random_string": "test_value"' in tool_call_text - assert '"non_serializable_agent": "<>"' in tool_call_text - - -def test_agent_tool_multiple_non_serializable_types(agent, mock_randint): - """Test filtering of various non-serializable object types.""" - mock_randint.return_value = 123 - - # Create various non-serializable objects - class CustomClass: - def __init__(self, value): - self.value = value - - non_serializable_objects = { - "agent": Agent(), - "custom_object": CustomClass("test"), - "function": lambda x: x, - "set_object": {1, 2, 3}, - "complex_number": 3 + 4j, - "serializable_string": "this_should_remain", - "serializable_number": 42, - "serializable_list": [1, 2, 3], - "serializable_dict": {"key": "value"}, - } - - # This should not crash - result = agent.tool.tool_decorated(random_string="test_filtering", **non_serializable_objects) - - # Verify tool executed successfully - expected_result = { - "content": [{"text": "test_filtering"}], - "status": "success", - "toolUseId": "tooluse_tool_decorated_123", - } - assert result == expected_result - - # Check the recorded message for proper parameter filtering - assert len(agent.messages) > 0 - user_message = agent.messages[0] - tool_call_text = user_message["content"][0]["text"] - - # Verify serializable objects remain unchanged - assert '"serializable_string": "this_should_remain"' in tool_call_text - assert '"serializable_number": 42' in tool_call_text - assert '"serializable_list": [1, 2, 3]' in tool_call_text - assert '"serializable_dict": {"key": "value"}' in tool_call_text - - # Verify non-serializable objects are replaced with descriptive strings - assert '"agent": "<>"' in tool_call_text - assert ( - '"custom_object": "<.CustomClass>>"' - in tool_call_text - ) - assert '"function": "<>"' in tool_call_text - assert '"set_object": "<>"' in tool_call_text - assert '"complex_number": "<>"' in tool_call_text - - -def test_agent_tool_serialization_edge_cases(agent, mock_randint): - """Test edge cases in parameter serialization filtering.""" - mock_randint.return_value = 999 - - # Test with None values, empty containers, and nested structures - edge_case_params = { - "none_value": None, - "empty_list": [], - "empty_dict": {}, - "nested_list_with_non_serializable": [1, 2, Agent()], # This should be filtered out - "nested_dict_serializable": {"nested": {"key": "value"}}, # This should remain - } - - result = agent.tool.tool_decorated(random_string="edge_cases", **edge_case_params) - - # Verify successful execution - expected_result = { - "content": [{"text": "edge_cases"}], - "status": "success", - "toolUseId": "tooluse_tool_decorated_999", - } - assert result == expected_result - - # Check parameter filtering in recorded message - assert len(agent.messages) > 0 - user_message = agent.messages[0] - tool_call_text = user_message["content"][0]["text"] - - # Verify serializable values remain - assert '"none_value": null' in tool_call_text - assert '"empty_list": []' in tool_call_text - assert '"empty_dict": {}' in tool_call_text - assert '"nested_dict_serializable": {"nested": {"key": "value"}}' in tool_call_text - - # Verify non-serializable nested structure is replaced - assert '"nested_list_with_non_serializable": [1, 2, "<>"]' in tool_call_text + assert '"non_serializable_agent": "<>"' not in tool_call_text def test_agent_tool_no_non_serializable_parameters(agent, mock_randint): @@ -1831,3 +1739,36 @@ def test_agent_tool_record_direct_tool_call_disabled_with_non_serializable(agent # Verify no messages were recorded assert len(agent.messages) == 0 + + +def test_agent_tool_call_parameter_filtering_integration(mock_randint): + """Test that tool calls properly filter parameters in message recording.""" + mock_randint.return_value = 42 + + @strands.tool + def test_tool(action: str) -> str: + """Test tool with single parameter.""" + return action + + agent = Agent(tools=[test_tool]) + + # Call tool with extra non-spec parameters + result = agent.tool.test_tool( + action="test_value", + agent=agent, # Should be filtered out + extra_param="filtered", # Should be filtered out + ) + + # Verify tool executed successfully + assert result["status"] == "success" + assert result["content"] == [{"text": "test_value"}] + + # Check that only spec parameters are recorded in message history + assert len(agent.messages) > 0 + user_message = agent.messages[0] + tool_call_text = user_message["content"][0]["text"] + + # Should only contain the 'action' parameter + assert '"action": "test_value"' in tool_call_text + assert '"agent"' not in tool_call_text + assert '"extra_param"' not in tool_call_text