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 02:43:14 +00:00
committed by GitHub
parent 6b5437e4ec
commit 6d7690e485
11 changed files with 329 additions and 17 deletions
@@ -29,7 +29,7 @@ from agent_framework import (
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__)
@@ -177,7 +177,11 @@ class AgentFrameworkEventBridge:
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,
@@ -391,7 +395,7 @@ class AgentFrameworkEventBridge:
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}",
@@ -435,7 +439,7 @@ class AgentFrameworkEventBridge:
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}",
@@ -17,6 +17,7 @@ from ._utils import (
AGUI_TO_FRAMEWORK_ROLE,
FRAMEWORK_TO_AGUI_ROLE,
get_role_value,
make_json_safe,
normalize_agui_role,
safe_json_parse,
)
@@ -265,7 +266,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
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
@@ -377,7 +378,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# 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
@@ -454,7 +457,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# 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)
@@ -462,7 +467,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
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:
@@ -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"))
@@ -12,7 +12,7 @@ from agent_framework import (
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
@@ -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
@@ -8,6 +8,8 @@ from typing import Any
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."""
@@ -67,7 +69,7 @@ class StateManager:
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=[
@@ -243,7 +243,7 @@ class HumanInTheLoopOrchestrator(Orchestrator):
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,
@@ -388,7 +388,7 @@ class DefaultOrchestrator(Orchestrator):
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"))
@@ -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)):