Skip to content

Commit 9faadbf

Browse files
authored
fix(agent): prevent JSON serialization errors with non-serializable direct tool parameters (#498)
* fix(agent): prevent JSON serialization errors with non-serializable tool parameters Fixes issue where passing non-serializable objects (Agent instances, custom classes, functions, etc.) as tool parameters would cause JSON serialization errors during tool call recording. Changes: - Add parameter filtering in _record_tool_execution() to test each parameter with json.dumps() and replace non-serializable objects with descriptive strings like '<non-serializable: TypeName>' - Remove unused 'messages' parameter from _record_tool_execution method - Fix message format consistency (agent.tool.{name} vs agent.{name}) - Add comprehensive test coverage for various non-serializable object types including Agent instances, custom classes, functions, sets, complex numbers - Add edge case testing for nested structures and None values - Add regression testing for normal serializable parameters - Add testing for disabled recording scenarios The fix maintains full functionality while preventing crashes, providing clear error indicators instead of cryptic JSON serialization errors. Closes #350 * refactor: use json.dumps default parameter for non-serializable objects - Replace manual serialization loop with json.dumps default parameter - Use __qualname__ for better type representation - Change format from <non-serializable> to <<non-serializable>> - Update tests to match new serialization format - Addresses PR feedback to simplify serialization approach
1 parent a2d58d3 commit 9faadbf

File tree

2 files changed

+189
-7
lines changed

2 files changed

+189
-7
lines changed

src/strands/agent/agent.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,7 @@ def tcall() -> ToolResult:
157157

158158
if should_record_direct_tool_call:
159159
# Create a record of this tool execution in the message history
160-
self._agent._record_tool_execution(
161-
tool_use, tool_result, user_message_override, self._agent.messages
162-
)
160+
self._agent._record_tool_execution(tool_use, tool_result, user_message_override)
163161

164162
# Apply window management
165163
self._agent.conversation_manager.apply_management(self._agent)
@@ -602,7 +600,6 @@ def _record_tool_execution(
602600
tool: ToolUse,
603601
tool_result: ToolResult,
604602
user_message_override: Optional[str],
605-
messages: Messages,
606603
) -> None:
607604
"""Record a tool execution in the message history.
608605
@@ -617,11 +614,12 @@ def _record_tool_execution(
617614
tool: The tool call information.
618615
tool_result: The result returned by the tool.
619616
user_message_override: Optional custom message to include.
620-
messages: The message history to append to.
621617
"""
622618
# Create user message describing the tool call
619+
input_parameters = json.dumps(tool["input"], default=lambda o: f"<<non-serializable: {type(o).__qualname__}>>")
620+
623621
user_msg_content: list[ContentBlock] = [
624-
{"text": (f"agent.tool.{tool['name']} direct tool call.\nInput parameters: {json.dumps(tool['input'])}\n")}
622+
{"text": (f"agent.tool.{tool['name']} direct tool call.\nInput parameters: {input_parameters}\n")}
625623
]
626624

627625
# Add override message if provided
@@ -643,7 +641,7 @@ def _record_tool_execution(
643641
}
644642
assistant_msg: Message = {
645643
"role": "assistant",
646-
"content": [{"text": f"agent.{tool['name']} was called"}],
644+
"content": [{"text": f"agent.tool.{tool['name']} was called."}],
647645
}
648646

649647
# Add to message history

tests/strands/agent/test_agent.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,3 +1576,187 @@ def test_agent_with_session_and_conversation_manager():
15761576
assert agent.messages == agent_2.messages
15771577
# Asser the conversation manager was initialized properly
15781578
assert agent.conversation_manager.removed_message_count == agent_2.conversation_manager.removed_message_count
1579+
1580+
1581+
def test_agent_tool_non_serializable_parameter_filtering(agent, mock_randint):
1582+
"""Test that non-serializable objects in tool parameters are properly filtered during tool call recording."""
1583+
mock_randint.return_value = 42
1584+
1585+
# Create a non-serializable object (Agent instance)
1586+
another_agent = Agent()
1587+
1588+
# This should not crash even though we're passing non-serializable objects
1589+
result = agent.tool.tool_decorated(
1590+
random_string="test_value",
1591+
non_serializable_agent=another_agent, # This would previously cause JSON serialization error
1592+
user_message_override="Testing non-serializable parameter filtering",
1593+
)
1594+
1595+
# Verify the tool executed successfully
1596+
expected_result = {
1597+
"content": [{"text": "test_value"}],
1598+
"status": "success",
1599+
"toolUseId": "tooluse_tool_decorated_42",
1600+
}
1601+
assert result == expected_result
1602+
1603+
# The key test: this should not crash during execution
1604+
# Check that we have messages recorded (exact count may vary)
1605+
assert len(agent.messages) > 0
1606+
1607+
# Check user message with filtered parameters - this is the main test for the bug fix
1608+
user_message = agent.messages[0]
1609+
assert user_message["role"] == "user"
1610+
assert len(user_message["content"]) == 2
1611+
1612+
# Check override message
1613+
assert user_message["content"][0]["text"] == "Testing non-serializable parameter filtering\n"
1614+
1615+
# Check tool call description with filtered parameters - this is where JSON serialization would fail
1616+
tool_call_text = user_message["content"][1]["text"]
1617+
assert "agent.tool.tool_decorated direct tool call." in tool_call_text
1618+
assert '"random_string": "test_value"' in tool_call_text
1619+
assert '"non_serializable_agent": "<<non-serializable: Agent>>"' in tool_call_text
1620+
1621+
1622+
def test_agent_tool_multiple_non_serializable_types(agent, mock_randint):
1623+
"""Test filtering of various non-serializable object types."""
1624+
mock_randint.return_value = 123
1625+
1626+
# Create various non-serializable objects
1627+
class CustomClass:
1628+
def __init__(self, value):
1629+
self.value = value
1630+
1631+
non_serializable_objects = {
1632+
"agent": Agent(),
1633+
"custom_object": CustomClass("test"),
1634+
"function": lambda x: x,
1635+
"set_object": {1, 2, 3},
1636+
"complex_number": 3 + 4j,
1637+
"serializable_string": "this_should_remain",
1638+
"serializable_number": 42,
1639+
"serializable_list": [1, 2, 3],
1640+
"serializable_dict": {"key": "value"},
1641+
}
1642+
1643+
# This should not crash
1644+
result = agent.tool.tool_decorated(random_string="test_filtering", **non_serializable_objects)
1645+
1646+
# Verify tool executed successfully
1647+
expected_result = {
1648+
"content": [{"text": "test_filtering"}],
1649+
"status": "success",
1650+
"toolUseId": "tooluse_tool_decorated_123",
1651+
}
1652+
assert result == expected_result
1653+
1654+
# Check the recorded message for proper parameter filtering
1655+
assert len(agent.messages) > 0
1656+
user_message = agent.messages[0]
1657+
tool_call_text = user_message["content"][0]["text"]
1658+
1659+
# Verify serializable objects remain unchanged
1660+
assert '"serializable_string": "this_should_remain"' in tool_call_text
1661+
assert '"serializable_number": 42' in tool_call_text
1662+
assert '"serializable_list": [1, 2, 3]' in tool_call_text
1663+
assert '"serializable_dict": {"key": "value"}' in tool_call_text
1664+
1665+
# Verify non-serializable objects are replaced with descriptive strings
1666+
assert '"agent": "<<non-serializable: Agent>>"' in tool_call_text
1667+
assert (
1668+
'"custom_object": "<<non-serializable: test_agent_tool_multiple_non_serializable_types.<locals>.CustomClass>>"'
1669+
in tool_call_text
1670+
)
1671+
assert '"function": "<<non-serializable: function>>"' in tool_call_text
1672+
assert '"set_object": "<<non-serializable: set>>"' in tool_call_text
1673+
assert '"complex_number": "<<non-serializable: complex>>"' in tool_call_text
1674+
1675+
1676+
def test_agent_tool_serialization_edge_cases(agent, mock_randint):
1677+
"""Test edge cases in parameter serialization filtering."""
1678+
mock_randint.return_value = 999
1679+
1680+
# Test with None values, empty containers, and nested structures
1681+
edge_case_params = {
1682+
"none_value": None,
1683+
"empty_list": [],
1684+
"empty_dict": {},
1685+
"nested_list_with_non_serializable": [1, 2, Agent()], # This should be filtered out
1686+
"nested_dict_serializable": {"nested": {"key": "value"}}, # This should remain
1687+
}
1688+
1689+
result = agent.tool.tool_decorated(random_string="edge_cases", **edge_case_params)
1690+
1691+
# Verify successful execution
1692+
expected_result = {
1693+
"content": [{"text": "edge_cases"}],
1694+
"status": "success",
1695+
"toolUseId": "tooluse_tool_decorated_999",
1696+
}
1697+
assert result == expected_result
1698+
1699+
# Check parameter filtering in recorded message
1700+
assert len(agent.messages) > 0
1701+
user_message = agent.messages[0]
1702+
tool_call_text = user_message["content"][0]["text"]
1703+
1704+
# Verify serializable values remain
1705+
assert '"none_value": null' in tool_call_text
1706+
assert '"empty_list": []' in tool_call_text
1707+
assert '"empty_dict": {}' in tool_call_text
1708+
assert '"nested_dict_serializable": {"nested": {"key": "value"}}' in tool_call_text
1709+
1710+
# Verify non-serializable nested structure is replaced
1711+
assert '"nested_list_with_non_serializable": [1, 2, "<<non-serializable: Agent>>"]' in tool_call_text
1712+
1713+
1714+
def test_agent_tool_no_non_serializable_parameters(agent, mock_randint):
1715+
"""Test that normal tool calls with only serializable parameters work unchanged."""
1716+
mock_randint.return_value = 555
1717+
1718+
# Call with only serializable parameters
1719+
result = agent.tool.tool_decorated(random_string="normal_call", user_message_override="Normal tool call test")
1720+
1721+
# Verify successful execution
1722+
expected_result = {
1723+
"content": [{"text": "normal_call"}],
1724+
"status": "success",
1725+
"toolUseId": "tooluse_tool_decorated_555",
1726+
}
1727+
assert result == expected_result
1728+
1729+
# Check message recording works normally
1730+
assert len(agent.messages) > 0
1731+
user_message = agent.messages[0]
1732+
tool_call_text = user_message["content"][1]["text"]
1733+
1734+
# Verify normal parameter serialization (no filtering needed)
1735+
assert "agent.tool.tool_decorated direct tool call." in tool_call_text
1736+
assert '"random_string": "normal_call"' in tool_call_text
1737+
# Should not contain any "<<non-serializable:" strings
1738+
assert "<<non-serializable:" not in tool_call_text
1739+
1740+
1741+
def test_agent_tool_record_direct_tool_call_disabled_with_non_serializable(agent, mock_randint):
1742+
"""Test that when record_direct_tool_call is disabled, non-serializable parameters don't cause issues."""
1743+
mock_randint.return_value = 777
1744+
1745+
# Disable tool call recording
1746+
agent.record_direct_tool_call = False
1747+
1748+
# This should work fine even with non-serializable parameters since recording is disabled
1749+
result = agent.tool.tool_decorated(
1750+
random_string="no_recording", non_serializable_agent=Agent(), user_message_override="This shouldn't be recorded"
1751+
)
1752+
1753+
# Verify successful execution
1754+
expected_result = {
1755+
"content": [{"text": "no_recording"}],
1756+
"status": "success",
1757+
"toolUseId": "tooluse_tool_decorated_777",
1758+
}
1759+
assert result == expected_result
1760+
1761+
# Verify no messages were recorded
1762+
assert len(agent.messages) == 0

0 commit comments

Comments
 (0)