From 3e5bd97e314a7d0c6907462b21ce7b829ddfecce Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 19 Jan 2026 12:39:57 +0900 Subject: [PATCH 1/2] fix(ag-ui): properly handle json serialize with handoff workflows as agent --- .../ag-ui/agent_framework_ag_ui/_events.py | 12 ++- .../ag-ui/agent_framework_ag_ui/_utils.py | 9 +- .../ag-ui/tests/test_events_comprehensive.py | 90 +++++++++++++++++++ python/packages/ag-ui/tests/test_utils.py | 49 ++++++++++ 4 files changed, 153 insertions(+), 7 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_events.py b/python/packages/ag-ui/agent_framework_ag_ui/_events.py index ddf3ebba01..effd651074 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_events.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_events.py @@ -32,7 +32,7 @@ prepare_function_call_results, ) -from ._utils import extract_state_from_tool_args, generate_event_id, safe_json_parse +from ._utils import extract_state_from_tool_args, generate_event_id, make_json_safe, safe_json_parse logger = logging.getLogger(__name__) @@ -178,7 +178,11 @@ def _handle_function_call_content(self, content: FunctionCallContent) -> list[Ba self.current_tool_call_id = tool_call_id if content.arguments: - delta_str = content.arguments if isinstance(content.arguments, str) else json.dumps(content.arguments) + delta_str = ( + content.arguments + if isinstance(content.arguments, str) + else json.dumps(make_json_safe(content.arguments)) + ) logger.info(f"Emitting ToolCallArgsEvent with delta_length={len(delta_str)}, id='{tool_call_id}'") args_event = ToolCallArgsEvent( tool_call_id=tool_call_id, @@ -392,7 +396,7 @@ def _emit_confirm_changes_tool_call(self, function_call: FunctionCallContent | N args_dict = { "function_name": function_call.name, "function_call_id": function_call.call_id, - "function_arguments": function_call.parse_arguments() or {}, + "function_arguments": make_json_safe(function_call.parse_arguments() or {}), "steps": [ { "description": f"Execute {function_call.name}", @@ -436,7 +440,7 @@ def _emit_function_approval_tool_call(self, function_call: FunctionCallContent) args_dict = { "function_name": function_call.name, "function_call_id": function_call.call_id, - "function_arguments": function_call.parse_arguments() or {}, + "function_arguments": make_json_safe(function_call.parse_arguments() or {}), "steps": [ { "description": f"Execute {function_call.name}", diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py index 9f42e24770..967653fff8 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py @@ -141,11 +141,14 @@ def make_json_safe(obj: Any) -> Any: # noqa: ANN401 if isinstance(obj, (datetime, date)): return obj.isoformat() if is_dataclass(obj): - return asdict(obj) # type: ignore[arg-type] + # asdict may return nested non-dataclass objects, so recursively make them safe + return make_json_safe(asdict(obj)) # type: ignore[arg-type] if hasattr(obj, "model_dump"): - return obj.model_dump() # type: ignore[no-any-return] + return make_json_safe(obj.model_dump()) # type: ignore[no-any-return] + if hasattr(obj, "to_dict"): + return make_json_safe(obj.to_dict()) # type: ignore[no-any-return] if hasattr(obj, "dict"): - return obj.dict() # type: ignore[no-any-return] + return make_json_safe(obj.dict()) # type: ignore[no-any-return] if hasattr(obj, "__dict__"): return {key: make_json_safe(value) for key, value in vars(obj).items()} # type: ignore[misc] if isinstance(obj, (list, tuple)): diff --git a/python/packages/ag-ui/tests/test_events_comprehensive.py b/python/packages/ag-ui/tests/test_events_comprehensive.py index 295ba00372..f43cc07599 100644 --- a/python/packages/ag-ui/tests/test_events_comprehensive.py +++ b/python/packages/ag-ui/tests/test_events_comprehensive.py @@ -820,3 +820,93 @@ class MockModel(BaseModel): assert events[1].type == "TOOL_CALL_RESULT" # Should be properly serialized JSON array without double escaping assert events[1].content == '[{"value": 1}, {"value": 2}]' + + +async def test_function_call_with_dataclass_arguments(): + """Test FunctionCallContent with dataclass arguments is serialized correctly. + + This test verifies the fix for the AG-UI JSON serialization error when + HandoffAgentUserRequest (a dataclass) is passed as FunctionCallContent.arguments. + """ + from dataclasses import dataclass + + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + @dataclass + class TestRequest: + field1: str + field2: int + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # FunctionCallContent with a dataclass as arguments (not a string) + update = AgentResponseUpdate( + contents=[ + FunctionCallContent( + name="request_info", + call_id="call_dataclass", + arguments=TestRequest(field1="value", field2=42), + ) + ] + ) + + events = await bridge.from_agent_run_update(update) + + # Should have ToolCallStartEvent and ToolCallArgsEvent + tool_args_events = [e for e in events if e.type == "TOOL_CALL_ARGS"] + assert len(tool_args_events) == 1 + + # Verify the delta is valid JSON + delta = tool_args_events[0].delta + parsed = json.loads(delta) + assert parsed == {"field1": "value", "field2": 42} + + +async def test_function_call_with_nested_dataclass_arguments(): + """Test FunctionCallContent with nested dataclass arguments is serialized correctly. + + This test covers the scenario where HandoffAgentUserRequest contains an AgentResponse + with nested content objects. + """ + from dataclasses import dataclass + + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + @dataclass + class InnerContent: + text: str + + @dataclass + class AgentResponseMock: + contents: list[InnerContent] + + @dataclass + class HandoffRequest: + agent_response: AgentResponseMock + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + # Simulate a HandoffAgentUserRequest-like structure + update = AgentResponseUpdate( + contents=[ + FunctionCallContent( + name="request_info", + call_id="call_nested", + arguments=HandoffRequest( + agent_response=AgentResponseMock(contents=[InnerContent(text="Hello from agent")]) + ), + ) + ] + ) + + events = await bridge.from_agent_run_update(update) + + # Should have ToolCallStartEvent and ToolCallArgsEvent + tool_args_events = [e for e in events if e.type == "TOOL_CALL_ARGS"] + assert len(tool_args_events) == 1 + + # Verify the delta is valid JSON and contains nested structure + delta = tool_args_events[0].delta + parsed = json.loads(delta) + assert "agent_response" in parsed + assert parsed["agent_response"]["contents"] == [{"text": "Hello from agent"}] diff --git a/python/packages/ag-ui/tests/test_utils.py b/python/packages/ag-ui/tests/test_utils.py index b077468b81..e053bbeb8d 100644 --- a/python/packages/ag-ui/tests/test_utils.py +++ b/python/packages/ag-ui/tests/test_utils.py @@ -122,6 +122,20 @@ def test_make_json_safe_model_dump(): assert result == {"type": "model", "data": "dump"} +class ToDictObject: + """Object with to_dict method (like SerializationMixin).""" + + def to_dict(self): + return {"type": "serialization_mixin", "method": "to_dict"} + + +def test_make_json_safe_to_dict(): + """Test object with to_dict method (SerializationMixin pattern).""" + obj = ToDictObject() + result = make_json_safe(obj) + assert result == {"type": "serialization_mixin", "method": "to_dict"} + + class DictObject: """Object with dict method.""" @@ -203,6 +217,41 @@ def test_make_json_safe_fallback(): assert isinstance(result, dict) +def test_make_json_safe_dataclass_with_nested_to_dict_object(): + """Test dataclass containing a to_dict object (like HandoffAgentUserRequest with AgentResponse). + + This test verifies the fix for the AG-UI JSON serialization error when + HandoffAgentUserRequest (a dataclass) contains an AgentResponse (SerializationMixin). + """ + + class NestedToDictObject: + """Simulates SerializationMixin objects like AgentResponse.""" + + def __init__(self, contents: list[str]): + self.contents = contents + + def to_dict(self): + return {"type": "response", "contents": self.contents} + + @dataclass + class ContainerDataclass: + """Simulates HandoffAgentUserRequest dataclass.""" + + response: NestedToDictObject + + obj = ContainerDataclass(response=NestedToDictObject(contents=["hello", "world"])) + result = make_json_safe(obj) + + # Verify the nested to_dict object was properly serialized + assert result == {"response": {"type": "response", "contents": ["hello", "world"]}} + + # Verify the result is actually JSON serializable + import json + + json_str = json.dumps(result) + assert json_str is not None + + def test_convert_tools_to_agui_format_with_ai_function(): """Test converting AIFunction to AG-UI format.""" from agent_framework import ai_function From 63f720987b409bf3dca9ed2868ccfeef807e7997 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 19 Jan 2026 14:35:56 +0900 Subject: [PATCH 2/2] Other improvements around handling non-serializable objects --- .../_message_adapters.py | 15 +++-- .../_orchestration/_helpers.py | 4 +- .../_orchestration/_state_manager.py | 4 +- .../agent_framework_ag_ui/_orchestrators.py | 4 +- .../ag-ui/tests/test_message_adapters.py | 48 ++++++++++++++++ .../tests/test_orchestrators_coverage.py | 57 +++++++++++++++++++ .../ag-ui/tests/test_state_manager.py | 54 ++++++++++++++++++ 7 files changed, 176 insertions(+), 10 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index 1ff858e9f5..f02eca7cd1 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -20,6 +20,7 @@ AGUI_TO_FRAMEWORK_ROLE, FRAMEWORK_TO_AGUI_ROLE, get_role_value, + make_json_safe, normalize_agui_role, safe_json_parse, ) @@ -270,7 +271,7 @@ def _update_tool_call_arguments( function_payload_dict = cast(dict[str, Any], function_payload) existing_args = function_payload_dict.get("arguments") if isinstance(existing_args, str): - function_payload_dict["arguments"] = json.dumps(modified_args) + function_payload_dict["arguments"] = json.dumps(make_json_safe(modified_args)) else: function_payload_dict["arguments"] = modified_args return @@ -383,7 +384,9 @@ def _filter_modified_args( # a proper FunctionApprovalResponseContent. This enables the agent framework # to execute the approved tool (fix for GitHub issue #3034). accepted = parsed.get("accepted", False) if parsed is not None else False - approval_payload_text = result_content if isinstance(result_content, str) else json.dumps(parsed) + approval_payload_text = ( + result_content if isinstance(result_content, str) else json.dumps(make_json_safe(parsed)) + ) # Log the full approval payload to debug modified arguments import logging @@ -460,7 +463,9 @@ def _filter_modified_args( # Keep the original tool call and AG-UI snapshot in sync with approved args. updated_args = ( - json.dumps(merged_args) if isinstance(matching_func_call.arguments, str) else merged_args + json.dumps(make_json_safe(merged_args)) + if isinstance(matching_func_call.arguments, str) + else merged_args ) matching_func_call.arguments = updated_args _update_tool_call_arguments(messages, str(approval_call_id), merged_args) @@ -468,7 +473,7 @@ def _filter_modified_args( func_call_for_approval = FunctionCallContent( call_id=matching_func_call.call_id, name=matching_func_call.name, - arguments=json.dumps(filtered_args), + arguments=json.dumps(make_json_safe(filtered_args)), ) logger.info(f"Using modified arguments from approval: {filtered_args}") else: @@ -768,7 +773,7 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic if arguments is None: function_payload_dict["arguments"] = "" elif not isinstance(arguments, str): - function_payload_dict["arguments"] = json.dumps(arguments) + function_payload_dict["arguments"] = json.dumps(make_json_safe(arguments)) # Normalize tool_call_id to toolCallId for tool messages normalized_msg["role"] = normalize_agui_role(normalized_msg.get("role")) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_helpers.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_helpers.py index ebf6ef6f57..1eacb29892 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_helpers.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_helpers.py @@ -15,7 +15,7 @@ TextContent, ) -from .._utils import get_role_value, safe_json_parse +from .._utils import get_role_value, make_json_safe, safe_json_parse if TYPE_CHECKING: from .._events import AgentFrameworkEventBridge @@ -255,7 +255,7 @@ def build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any return {} safe_metadata: dict[str, Any] = {} for key, value in thread_metadata.items(): - value_str = value if isinstance(value, str) else json.dumps(value) + value_str = value if isinstance(value, str) else json.dumps(make_json_safe(value)) if len(value_str) > 512: value_str = value_str[:512] safe_metadata[key] = value_str diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_state_manager.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_state_manager.py index 7d8a23d84c..8074da7f20 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_state_manager.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_state_manager.py @@ -8,6 +8,8 @@ from ag_ui.core import CustomEvent, EventType from agent_framework import ChatMessage, TextContent +from .._utils import make_json_safe + class StateManager: """Coordinates state defaults, snapshots, and structured updates.""" @@ -67,7 +69,7 @@ def state_context_message(self, is_new_user_turn: bool, conversation_has_tool_ca if conversation_has_tool_calls and not self._state_from_input: return None - state_json = json.dumps(self.current_state, indent=2) + state_json = json.dumps(make_json_safe(self.current_state), indent=2) return ChatMessage( role="system", contents=[ diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py index b5566f0aec..81e74cb3be 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py @@ -245,7 +245,7 @@ def can_handle(self, context: ExecutionContext) -> bool: if not msg: return False - return bool(msg.additional_properties.get("is_tool_result", False)) + return bool((msg.additional_properties or {}).get("is_tool_result", False)) async def run( self, @@ -390,7 +390,7 @@ async def run( response_format = None if isinstance(context.agent, ChatAgent): - response_format = context.agent.default_options.get("response_format") + response_format = (context.agent.default_options or {}).get("response_format") skip_text_content = response_format is not None client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools")) diff --git a/python/packages/ag-ui/tests/test_message_adapters.py b/python/packages/ag-ui/tests/test_message_adapters.py index 9173314a28..a381702c6a 100644 --- a/python/packages/ag-ui/tests/test_message_adapters.py +++ b/python/packages/ag-ui/tests/test_message_adapters.py @@ -654,3 +654,51 @@ class MockTextContent: agui_msg = messages[0] # Multiple items should return JSON array assert agui_msg["content"] == '["First result", "Second result"]' + + +def test_agui_tool_approval_with_dataclass_modified_args(): + """Test that agui_messages_to_agent_framework handles dataclass in modified args. + + This tests the fix for json.dumps() serialization errors at line 274 + when modified_args contains non-serializable objects via make_json_safe. + """ + from dataclasses import dataclass + + @dataclass + class ModifiedData: + field1: str + field2: int + + # Create AG-UI format messages that simulate tool approval flow + # where modified args could contain a dataclass after parsing + + # First, an assistant message with a tool call (string arguments) + assistant_msg = { + "id": "msg-1", + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call-test", + "type": "function", + "function": { + "name": "update_state", + "arguments": '{"data": "original"}', # String args + }, + } + ], + } + + # Then a user approval message (the approval path will merge modified args) + approval_msg = { + "id": "msg-2", + "role": "user", + "content": '{"approved": true}', + "toolCallId": "call-test", + } + + # This should NOT raise TypeError + result = agui_messages_to_agent_framework([assistant_msg, approval_msg]) + + # Should have processed both messages without error + assert len(result) == 2 diff --git a/python/packages/ag-ui/tests/test_orchestrators_coverage.py b/python/packages/ag-ui/tests/test_orchestrators_coverage.py index 6c311d593a..6ccc6d65e0 100644 --- a/python/packages/ag-ui/tests/test_orchestrators_coverage.py +++ b/python/packages/ag-ui/tests/test_orchestrators_coverage.py @@ -876,3 +876,60 @@ class OutputModel(BaseModel): # Test passes if no errors occur - verifies response_format code path assert len(events) > 0 + + +async def test_human_in_the_loop_handles_none_additional_properties() -> None: + """Test that HumanInTheLoopOrchestrator handles None additional_properties gracefully. + + This test ensures the null safety fix for msg.additional_properties.get() works. + """ + orchestrator = HumanInTheLoopOrchestrator() + + # Create a message with None additional_properties + msg = ChatMessage( + role="user", + contents=[TextContent(text="Hello")], + ) + # Explicitly set additional_properties to None + msg.additional_properties = None # type: ignore[assignment] + + agent = StubAgent() # Use default StubAgent + config = AgentConfig() + context = TestExecutionContext( + input_data={"messages": [{"role": "user", "content": "Hello"}]}, + agent=agent, + config=config, + ) + context.set_messages([msg]) + + # can_handle should return False (not crash) when additional_properties is None + result = orchestrator.can_handle(context) + assert result is False + + +async def test_default_orchestrator_handles_none_default_options() -> None: + """Test that DefaultOrchestrator handles None default_options gracefully. + + This test ensures the null safety fix for context.agent.default_options.get() works. + """ + orchestrator = DefaultOrchestrator() + + # Use StubAgent with default_options set to None + agent = StubAgent(default_options=None) + config = AgentConfig() + context = TestExecutionContext( + input_data={"messages": [{"role": "user", "content": "Hello"}]}, + agent=agent, + config=config, + ) + + # This should NOT crash when accessing default_options.get() + events: list[Any] = [] + async for event in orchestrator.run(context): + events.append(event) + # Just check a few events to verify it's working + if len(events) > 3: + break + + # Test passes if no AttributeError occurred + assert True diff --git a/python/packages/ag-ui/tests/test_state_manager.py b/python/packages/ag-ui/tests/test_state_manager.py index bc0a7b6a19..062904f4dc 100644 --- a/python/packages/ag-ui/tests/test_state_manager.py +++ b/python/packages/ag-ui/tests/test_state_manager.py @@ -49,3 +49,57 @@ def test_state_context_only_when_new_user_turn() -> None: assert isinstance(message, ChatMessage) assert isinstance(message.contents[0], TextContent) assert "Current state of the application" in message.contents[0].text + + +def test_state_manager_with_dataclass_in_state() -> None: + """Test that state containing dataclasses can be serialized without crashing. + + This test ensures the fix for JSON serialization errors when state + contains dataclass or other non-JSON-serializable objects. + """ + from dataclasses import dataclass + + @dataclass + class UserData: + name: str + age: int + + state_manager = StateManager( + state_schema={"user": {"type": "object"}}, + predict_state_config=None, + require_confirmation=True, + ) + # Initialize with a dataclass object in the state + state_manager.initialize({"user": UserData(name="Alice", age=30)}) + + # This should NOT raise TypeError when generating the context message + message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False) + + assert message is not None + assert isinstance(message, ChatMessage) + # The dataclass should be serialized to JSON in the message + assert "Alice" in message.contents[0].text + assert "30" in message.contents[0].text + + +def test_state_manager_with_pydantic_in_state() -> None: + """Test that state containing Pydantic models can be serialized without crashing.""" + from pydantic import BaseModel + + class UserModel(BaseModel): + email: str + active: bool + + state_manager = StateManager( + state_schema={"user": {"type": "object"}}, + predict_state_config=None, + require_confirmation=True, + ) + # Initialize with a Pydantic model in the state + state_manager.initialize({"user": UserModel(email="test@example.com", active=True)}) + + # This should NOT raise TypeError + message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False) + + assert message is not None + assert "test@example.com" in message.contents[0].text