Python: fix(ag-ui): properly handle json serialize with handoff workflows as agent (#3275)

* fix(ag-ui): properly handle json serialize with handoff workflows as agent

* Other improvements around handling non-serializable objects
This commit is contained in:
Evan Mattson
2026-01-21 11:43:14 +09:00
committed by GitHub
Unverified
parent 6b5437e4ec
commit 6d7690e485
11 changed files with 329 additions and 17 deletions
@@ -825,3 +825,93 @@ async def test_tool_result_with_model_dump_objects():
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=[
Content.from_function_call(
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=[
Content.from_function_call(
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"}]
@@ -642,3 +642,51 @@ def test_agent_framework_to_agui_function_result_multiple_text_contents():
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
@@ -870,3 +870,60 @@ async def test_response_format_skip_text_content() -> None:
# 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=[Content.from_text(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
@@ -49,3 +49,57 @@ def test_state_context_only_when_new_user_turn() -> None:
assert isinstance(message, ChatMessage)
assert message.contents[0].type == "text"
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
+49
View File
@@ -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