Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions python/packages/ag-ui/agent_framework_ag_ui/_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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__)

Expand Down Expand Up @@ -177,7 +177,11 @@ def _handle_function_call_content(self, content: Content) -> list[BaseEvent]:
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,
Expand Down Expand Up @@ -391,7 +395,7 @@ def _emit_confirm_changes_tool_call(self, function_call: Content | None = None)
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}",
Expand Down Expand Up @@ -435,7 +439,7 @@ def _emit_function_approval_tool_call(self, function_call: Content) -> list[Base
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}",
Expand Down
15 changes: 10 additions & 5 deletions python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
AGUI_TO_FRAMEWORK_ROLE,
FRAMEWORK_TO_AGUI_ROLE,
get_role_value,
make_json_safe,
normalize_agui_role,
safe_json_parse,
)
Expand Down Expand Up @@ -265,7 +266,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
Expand Down Expand Up @@ -377,7 +378,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
Expand Down Expand Up @@ -454,15 +457,17 @@ 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)
# Create a new FunctionCallContent with the modified arguments
func_call_for_approval = Content.from_function_call(
call_id=matching_func_call.call_id, # type: ignore[arg-type]
name=matching_func_call.name, # type: ignore[arg-type]
arguments=json.dumps(filtered_args),
arguments=json.dumps(make_json_safe(filtered_args)),
)
logger.info(f"Using modified arguments from approval: {filtered_args}")
else:
Expand Down Expand Up @@ -767,7 +772,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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
Content,
)

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
Expand Down Expand Up @@ -252,7 +252,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from ag_ui.core import CustomEvent, EventType
from agent_framework import ChatMessage, Content

from .._utils import make_json_safe


class StateManager:
"""Coordinates state defaults, snapshots, and structured updates."""
Expand Down Expand Up @@ -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=[
Expand Down
4 changes: 2 additions & 2 deletions python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,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,
Expand Down Expand Up @@ -388,7 +388,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"))
Expand Down
9 changes: 6 additions & 3 deletions python/packages/ag-ui/agent_framework_ag_ui/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
Expand Down
90 changes: 90 additions & 0 deletions python/packages/ag-ui/tests/test_events_comprehensive.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,3 +825,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=[
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"}]
48 changes: 48 additions & 0 deletions python/packages/ag-ui/tests/test_message_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,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
57 changes: 57 additions & 0 deletions python/packages/ag-ui/tests/test_orchestrators_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -870,3 +870,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=[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
Loading
Loading