From f36096ce1a0258d62810561df5aeedd22e2448c0 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:41:52 +0900 Subject: [PATCH] Python: Fix core observability unsafe serialization of function-call arguments containing dataclass/framework objects (#6026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: safely serialize function-call arguments in core observability Apply make_json_safe() to content.arguments in _to_otel_part() before building the otel message dict, so that dataclass/framework payloads (e.g. workflow request_info events) do not cause a TypeError when _capture_messages() calls json.dumps(). Lift make_json_safe() into agent_framework._serialization (no new external deps — dataclasses/datetime only) so the core observability path can use it without a dependency on the ag-ui adapter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(core): safely serialize workflow request_info payloads in observability (#5733) - Add make_json_safe() helper to recursively convert non-serializable objects - Use make_json_safe() in _to_otel_part() for function_call arguments - Fix CustomPayload test class to use @dataclass (resolves B903 lint error) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(serialization): guard callability and normalize dict keys in make_json_safe (#5733) - Use callable(getattr(obj, method, None)) instead of hasattr() so that non-callable attributes named model_dump/to_dict/dict do not raise TypeError at runtime. - Wrap each call in try/except TypeError to handle callables with mandatory arguments gracefully. - Convert dict keys to str() so that non-string keys (e.g. datetime, int) cannot cause json.dumps to raise TypeError. - Add regression tests for both scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address observability serialization review feedback --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_serialization.py | 45 ++++++++ .../core/agent_framework/_workflows/_agent.py | 3 +- .../agent_framework/_workflows/_functional.py | 3 +- .../core/tests/core/test_observability.py | 102 ++++++++++++++++++ .../workflow/test_functional_workflow.py | 32 ++++++ .../tests/workflow/test_workflow_agent.py | 30 ++++++ 6 files changed, 213 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index ccd28e7f76..d9fdd618f7 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -7,6 +7,8 @@ import json import logging import re from collections.abc import Mapping, MutableMapping +from dataclasses import asdict, is_dataclass +from datetime import date, datetime from typing import Any, ClassVar, Protocol, TypeVar, runtime_checkable logger = logging.getLogger("agent_framework") @@ -614,3 +616,46 @@ class SerializationMixin: # Fallback and default # Convert class name to snake_case return _CAMEL_TO_SNAKE_PATTERN.sub("_", cls.__name__).lower() + + +def make_json_safe(obj: Any) -> Any: + """Recursively convert an object to a JSON-serializable form. + + Handles dataclasses, Pydantic models, objects with ``to_dict``/``dict``/``__dict__``, + datetimes, lists, dicts, and primitives. Falls back to ``str()`` for any remaining + non-serializable value so that ``json.dumps`` never raises a ``TypeError``. + + Args: + obj: Object to make JSON safe. + + Returns: + A JSON-serializable version of the object. + """ + if obj is None or isinstance(obj, (str, int, float, bool)): + return obj + if isinstance(obj, (datetime, date)): + return obj.isoformat() + if is_dataclass(obj) and not isinstance(obj, type): + return make_json_safe(asdict(obj)) # type: ignore[arg-type] + if callable(getattr(obj, "model_dump", None)): + try: + return make_json_safe(obj.model_dump()) # type: ignore[no-any-return] + except TypeError: + pass + if callable(getattr(obj, "to_dict", None)): + try: + return make_json_safe(obj.to_dict()) # type: ignore[no-any-return] + except TypeError: + pass + if callable(getattr(obj, "dict", None)): + try: + return make_json_safe(obj.dict()) # type: ignore[no-any-return] + except TypeError: + pass + if isinstance(obj, dict): + return {str(key): make_json_safe(value) for key, value in obj.items()} # type: ignore[misc] + if isinstance(obj, (list, tuple)): + return [make_json_safe(item) for item in obj] # type: ignore[misc] + if hasattr(obj, "__dict__"): + return {key: make_json_safe(value) for key, value in vars(obj).items()} # type: ignore[misc] + return str(obj) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 2d9b37e1f5..7b3bdbb911 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -12,6 +12,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload from .._agents import BaseAgent +from .._serialization import make_json_safe from .._sessions import ( AgentSession, ContextProvider, @@ -61,7 +62,7 @@ class WorkflowAgent(BaseAgent): data: Any def to_dict(self) -> dict[str, Any]: - return {"request_id": self.request_id, "data": self.data} + return {"request_id": self.request_id, "data": make_json_safe(self.data)} def to_json(self) -> str: return json.dumps(self.to_dict()) diff --git a/python/packages/core/agent_framework/_workflows/_functional.py b/python/packages/core/agent_framework/_workflows/_functional.py index 5746c2161c..73c0815862 100644 --- a/python/packages/core/agent_framework/_workflows/_functional.py +++ b/python/packages/core/agent_framework/_workflows/_functional.py @@ -47,6 +47,7 @@ from copy import deepcopy from typing import Any, Generic, Literal, TypeVar, overload from .._feature_stage import ExperimentalFeature, experimental +from .._serialization import make_json_safe from .._types import AgentResponse, AgentResponseUpdate, ResponseStream from ..observability import OtelAttr, capture_exception, create_workflow_span from ._checkpoint import CheckpointStorage, WorkflowCheckpoint @@ -1515,7 +1516,7 @@ class FunctionalWorkflowAgent: function_call = Content.from_function_call( call_id=request_id, name=self.REQUEST_INFO_FUNCTION_NAME, - arguments={"request_id": request_id, "data": event.data}, + arguments={"request_id": request_id, "data": make_json_safe(event.data)}, ) return Content.from_function_approval_request( id=request_id, diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 4b48226e63..372cb8a7dd 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1691,6 +1691,65 @@ def test_to_otel_part_function_call(): } +def test_to_otel_part_function_call_reuses_prepared_arguments(): + """Test _to_otel_part does not re-serialize function-call arguments in the observability hot path.""" + from agent_framework import Content + from agent_framework.observability import _to_otel_part + + arguments = {"payload": object()} + content = Content(type="function_call", call_id="call_789", name="handoff", arguments=arguments) + result = _to_otel_part(content) + + assert result is not None + assert result["arguments"] is arguments + + +def test_make_json_safe_non_callable_method_attribute(): + """Test make_json_safe handles objects where model_dump/to_dict/dict are non-callable attributes.""" + from agent_framework._serialization import make_json_safe + + class ObjWithNonCallableModelDump: + model_dump = 42 # not callable + + obj = ObjWithNonCallableModelDump() + result = make_json_safe(obj) + assert result == {} + + +def test_make_json_safe_callable_method_type_error_falls_through(): + """Test make_json_safe falls through when serializer-like methods require arguments.""" + from agent_framework._serialization import make_json_safe + + class ObjWithRequiredArgModelDump: + def __init__(self) -> None: + self.value = "fallback" + + def model_dump(self, required: str) -> dict[str, str]: + return {"required": required} + + obj = ObjWithRequiredArgModelDump() + result = make_json_safe(obj) + assert result == {"value": "fallback"} + + +def test_make_json_safe_dict_with_non_string_keys(): + """Test make_json_safe converts non-primitive dict keys to strings.""" + import json + from datetime import datetime + + from agent_framework._serialization import make_json_safe + + dt_key = datetime(2024, 1, 1) + obj = {dt_key: "value", 42: "num_value", "str_key": "normal"} + result = make_json_safe(obj) + # json.dumps must not raise TypeError + serialized = json.dumps(result) + parsed = json.loads(serialized) + assert parsed[str(dt_key)] == "value" + assert parsed["42"] == "num_value" + assert parsed["str_key"] == "normal" + + def test_to_otel_part_function_result(): """Test _to_otel_part with function_result content.""" from agent_framework import Content @@ -3019,6 +3078,49 @@ async def test_system_instructions_preserves_non_ascii_characters(span_exporter: assert [msg.get("role") for msg in input_messages] == ["user"] +@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True) +def test_capture_messages_with_prepared_request_info_function_call_arguments(span_exporter: InMemorySpanExporter): + """Test _capture_messages handles request-info function-call arguments prepared at Content creation.""" + import dataclasses + import json + + from opentelemetry import trace + + from agent_framework import WorkflowAgent + + @dataclasses.dataclass + class HandoffRequest: + target_agent: str + reason: str + + arguments = WorkflowAgent.RequestInfoFunctionArgs( + request_id="call_dc", + data=HandoffRequest(target_agent="helper", reason="overflow"), + ).to_dict() + msg = Message( + role="assistant", + contents=[ + Content( + type="function_call", + call_id="call_dc", + name="request_info", + arguments=arguments, + ) + ], + ) + span_exporter.clear() + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("test_span") as span: + _capture_messages(span=span, provider_name="test_provider", messages=[msg]) + + spans = span_exporter.get_finished_spans() + span = spans[0] + input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES]) + tool_part = input_messages[0]["parts"][0] + assert tool_part["type"] == "tool_call" + assert tool_part["arguments"]["data"] == {"target_agent": "helper", "reason": "overflow"} + + def test_capture_messages_keeps_framework_instructions_out_of_logs_and_span_messages( span_exporter: InMemorySpanExporter, ): diff --git a/python/packages/core/tests/workflow/test_functional_workflow.py b/python/packages/core/tests/workflow/test_functional_workflow.py index 6502a0e353..d52c5497f9 100644 --- a/python/packages/core/tests/workflow/test_functional_workflow.py +++ b/python/packages/core/tests/workflow/test_functional_workflow.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio +import json import logging from collections.abc import Iterator from contextlib import contextmanager @@ -1642,6 +1643,37 @@ class TestFunctionalWorkflowAgentHITL: break assert approval_found, "expected FunctionApprovalRequestContent in agent response" + async def test_request_info_dataclass_arguments_are_serialized_for_agent(self): + @dataclass + class HandoffRequest: + target_agent: str + reason: str + + @workflow + async def wf(x: str, ctx: RunContext) -> str: + answer = await ctx.request_info( + HandoffRequest(target_agent=x, reason="overflow"), + response_type=str, + request_id="rid-1", + ) + return f"got:{answer}" + + agent = wf.as_agent() + response = await agent.run("helper") + + function_call_arguments = None + for message in response.messages: + for content in message.contents: + if getattr(content, "type", None) == "function_approval_request" and content.function_call is not None: + function_call_arguments = content.function_call.arguments + break + + assert function_call_arguments == { + "request_id": "rid-1", + "data": {"target_agent": "helper", "reason": "overflow"}, + } + assert json.loads(json.dumps(function_call_arguments)) == function_call_arguments + async def test_resume_via_agent_responses_kwarg(self): @workflow async def wf(x: str, ctx: RunContext) -> str: diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 3dcdd26c86..c473fdaaf8 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -1,7 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. +import json import uuid from collections.abc import Awaitable, Sequence +from dataclasses import dataclass from typing import Any, Literal, overload import pytest @@ -23,6 +25,7 @@ from agent_framework import ( WorkflowAgent, WorkflowBuilder, WorkflowContext, + WorkflowEvent, executor, handler, response_handler, @@ -293,6 +296,33 @@ class TestWorkflowAgent: # Verify cleanup - pending requests should be cleared after function response handling assert len(agent.pending_requests) == 0 + def test_request_info_dataclass_arguments_are_serialized_when_content_is_created(self) -> None: + """Test WorkflowAgent prepares request_info arguments before observability captures messages.""" + + @dataclass + class HandoffRequest: + target_agent: str + reason: str + + executor = SimpleExecutor(id="executor1", response_text="Response") + workflow = WorkflowBuilder(start_executor=executor).build() + agent = WorkflowAgent(workflow=workflow, name="Request Test Agent") + event = WorkflowEvent.request_info( + request_id="request_123", + source_executor_id="executor1", + request_data=HandoffRequest(target_agent="helper", reason="overflow"), + response_type=str, + ) + + function_call, approval_request = agent._process_request_info_event(event) # pyright: ignore[reportPrivateUsage] + + assert function_call.arguments == { + "request_id": "request_123", + "data": {"target_agent": "helper", "reason": "overflow"}, + } + assert approval_request.function_call is function_call + assert json.loads(json.dumps(function_call.arguments)) == function_call.arguments + def test_workflow_as_agent_method(self) -> None: """Test that Workflow.as_agent() creates a properly configured WorkflowAgent.""" # Create a simple workflow