From 3457fc9dd3a4f065395bcc103688318aab97e176 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 22 May 2026 05:49:30 +0000 Subject: [PATCH 1/4] fix: safely serialize function-call arguments in core observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- REPRODUCTION_REPORT.md | 19 +++++ .../core/agent_framework/_serialization.py | 36 +++++++++ .../core/agent_framework/observability.py | 4 +- .../core/tests/core/test_observability.py | 80 +++++++++++++++++++ python/uv.lock | 2 +- 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 REPRODUCTION_REPORT.md diff --git a/REPRODUCTION_REPORT.md b/REPRODUCTION_REPORT.md new file mode 100644 index 00000000000..56f5dd457a1 --- /dev/null +++ b/REPRODUCTION_REPORT.md @@ -0,0 +1,19 @@ +# Reproduction Report — microsoft/agent-framework#5733 + +## Issue + +- Title: Python: Core observability cannot safely serialize workflow request_info payloads +- Worktree: `/repos/agent-framework/.worktrees/agent/fix-5733-1` + +## Reused DevFlow triage reproduction + +A prior trusted DevFlow triage run already reproduced this issue, so the fix workflow skipped active reproduction. + +- Source: https://github.com/microsoft/agent-framework/issues/5733#issuecomment-4515369380 +- Failing test: `python/packages/core/tests/core/test_observability_serialization_bug.py` +- Files examined: python/packages/core/agent_framework/observability.py, python/packages/core/agent_framework/_types.py, python/packages/core/tests/core/test_observability.py +- Tests run: python/packages/core/tests/core/test_observability_serialization_bug.py + +## Triage notes for fix agent + +Repro: _capture_messages() in python/packages/core/agent_framework/observability.py:2176 calls json.dumps(otel_messages, ensure_ascii=False) without a default= handler; _to_otel_part() at line 2214 passes content.arguments raw into the dict. Trigger: create Content.from_function_call with arguments={"payload": } (simulating workflow request_info), wrap in Message, call _capture_messages — raises TypeError. Minimal repro: see test_observability_serialization_bug.py::TestIssue5733::test_capture_messages_raises_typeerror_for_dataclass_arguments. diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index ccd28e7f763..9fe7f35c10f 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -7,6 +7,8 @@ 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,37 @@ def _get_type_identifier(cls, value: Mapping[str, Any] | None = None) -> str: # 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: # noqa: ANN401 + """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 hasattr(obj, "model_dump"): + 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 make_json_safe(obj.dict()) # type: ignore[no-any-return] + if isinstance(obj, dict): + return {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/observability.py b/python/packages/core/agent_framework/observability.py index 362be2146e3..3ddc0016552 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -2211,7 +2211,9 @@ def _to_otel_part(content: Content) -> dict[str, Any] | None: "modality": content.media_type.split("/")[0] if content.media_type else None, } case "function_call": - return {"type": "tool_call", "id": content.call_id, "name": content.name, "arguments": content.arguments} + from ._serialization import make_json_safe # pyright: ignore[reportPrivateUsage] + + return {"type": "tool_call", "id": content.call_id, "name": content.name, "arguments": make_json_safe(content.arguments)} case "function_result": return { "type": "tool_call_response", diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index d4403043af8..37adf71423a 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1691,6 +1691,54 @@ def test_to_otel_part_function_call(): } + +def test_to_otel_part_function_call_with_dataclass_arguments(): + """Test _to_otel_part safely serializes dataclass instances in function_call arguments.""" + import dataclasses + import json + + from agent_framework import Content + from agent_framework.observability import _to_otel_part + + @dataclasses.dataclass + class RequestPayload: + target: str + value: int + + content = Content(type="function_call", call_id="call_456", name="request_info", arguments={"payload": RequestPayload(target="agent", value=42)}) + result = _to_otel_part(content) + + assert result is not None + assert result["type"] == "tool_call" + assert result["id"] == "call_456" + assert result["name"] == "request_info" + # arguments must be JSON-serializable + serialized = json.dumps(result["arguments"]) + parsed = json.loads(serialized) + assert parsed["payload"] == {"target": "agent", "value": 42} + + +def test_to_otel_part_function_call_with_nested_object_arguments(): + """Test _to_otel_part safely serializes nested non-primitive objects in function_call arguments.""" + import json + + from agent_framework import Content + from agent_framework.observability import _to_otel_part + + class CustomPayload: + def __init__(self, name: str): + self.name = name + + content = Content(type="function_call", call_id="call_789", name="handoff", arguments={"payload": CustomPayload("target_agent")}) + result = _to_otel_part(content) + + assert result is not None + # arguments must be JSON-serializable — this would raise TypeError before the fix + serialized = json.dumps(result["arguments"]) + parsed = json.loads(serialized) + assert parsed["payload"]["name"] == "target_agent" + + def test_to_otel_part_function_result(): """Test _to_otel_part with function_result content.""" from agent_framework import Content @@ -3019,6 +3067,38 @@ 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_dataclass_function_call_arguments(span_exporter: InMemorySpanExporter): + """Test that _capture_messages serializes dataclass payloads in function-call arguments without error.""" + import dataclasses + import json + + from opentelemetry import trace + + @dataclasses.dataclass + class HandoffRequest: + target_agent: str + reason: str + + msg = Message( + role="assistant", + contents=[Content(type="function_call", call_id="call_dc", name="request_info", arguments={"payload": HandoffRequest(target_agent="helper", reason="overflow")})], + ) + span_exporter.clear() + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("test_span") as span: + # Must not raise TypeError for non-serializable dataclass argument + _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"]["payload"] == {"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/uv.lock b/python/uv.lock index 58c0ed50ee5..dee89c9f0a0 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -604,7 +604,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" }, ] [[package]] From 28f52ceb4393d2992801f581102a362731aaee27 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 22 May 2026 07:45:23 +0000 Subject: [PATCH 2/4] 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> --- REPRODUCTION_REPORT.md | 19 ------------- python/packages/a2a/tests/test_a2a_agent.py | 4 +-- .../core/agent_framework/_serialization.py | 2 +- .../core/agent_framework/observability.py | 7 ++++- .../core/tests/core/test_observability.py | 27 ++++++++++++++----- .../_responses.py | 3 +-- .../foundry_hosting/tests/test_responses.py | 2 ++ 7 files changed, 31 insertions(+), 33 deletions(-) delete mode 100644 REPRODUCTION_REPORT.md diff --git a/REPRODUCTION_REPORT.md b/REPRODUCTION_REPORT.md deleted file mode 100644 index 56f5dd457a1..00000000000 --- a/REPRODUCTION_REPORT.md +++ /dev/null @@ -1,19 +0,0 @@ -# Reproduction Report — microsoft/agent-framework#5733 - -## Issue - -- Title: Python: Core observability cannot safely serialize workflow request_info payloads -- Worktree: `/repos/agent-framework/.worktrees/agent/fix-5733-1` - -## Reused DevFlow triage reproduction - -A prior trusted DevFlow triage run already reproduced this issue, so the fix workflow skipped active reproduction. - -- Source: https://github.com/microsoft/agent-framework/issues/5733#issuecomment-4515369380 -- Failing test: `python/packages/core/tests/core/test_observability_serialization_bug.py` -- Files examined: python/packages/core/agent_framework/observability.py, python/packages/core/agent_framework/_types.py, python/packages/core/tests/core/test_observability.py -- Tests run: python/packages/core/tests/core/test_observability_serialization_bug.py - -## Triage notes for fix agent - -Repro: _capture_messages() in python/packages/core/agent_framework/observability.py:2176 calls json.dumps(otel_messages, ensure_ascii=False) without a default= handler; _to_otel_part() at line 2214 passes content.arguments raw into the dict. Trigger: create Content.from_function_call with arguments={"payload": } (simulating workflow request_info), wrap in Message, call _capture_messages — raises TypeError. Minimal repro: see test_observability_serialization_bug.py::TestIssue5733::test_capture_messages_raises_typeerror_for_dataclass_arguments. diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index f5474bc3740..f735889c2c5 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -758,9 +758,7 @@ async def test_background_sets_return_immediately_on_request( assert mock_a2a_client.last_request.configuration.return_immediately is True -async def test_foreground_does_not_set_return_immediately( - a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient -) -> None: +async def test_foreground_does_not_set_return_immediately(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: """Test that background=False (default) does not set configuration on SendMessageRequest.""" mock_a2a_client.add_task_response("task-fg2", [{"id": "art-1", "content": "Done"}]) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index 9fe7f35c10f..21b982f81f6 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -618,7 +618,7 @@ def _get_type_identifier(cls, value: Mapping[str, Any] | None = None) -> str: return _CAMEL_TO_SNAKE_PATTERN.sub("_", cls.__name__).lower() -def make_json_safe(obj: Any) -> Any: # noqa: ANN401 +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__``, diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 3ddc0016552..5bd384fc1d1 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -2213,7 +2213,12 @@ def _to_otel_part(content: Content) -> dict[str, Any] | None: case "function_call": from ._serialization import make_json_safe # pyright: ignore[reportPrivateUsage] - return {"type": "tool_call", "id": content.call_id, "name": content.name, "arguments": make_json_safe(content.arguments)} + return { + "type": "tool_call", + "id": content.call_id, + "name": content.name, + "arguments": make_json_safe(content.arguments), + } case "function_result": return { "type": "tool_call_response", diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 37adf71423a..82a8452fd1b 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1691,7 +1691,6 @@ def test_to_otel_part_function_call(): } - def test_to_otel_part_function_call_with_dataclass_arguments(): """Test _to_otel_part safely serializes dataclass instances in function_call arguments.""" import dataclasses @@ -1705,7 +1704,12 @@ class RequestPayload: target: str value: int - content = Content(type="function_call", call_id="call_456", name="request_info", arguments={"payload": RequestPayload(target="agent", value=42)}) + content = Content( + type="function_call", + call_id="call_456", + name="request_info", + arguments={"payload": RequestPayload(target="agent", value=42)}, + ) result = _to_otel_part(content) assert result is not None @@ -1721,15 +1725,18 @@ class RequestPayload: def test_to_otel_part_function_call_with_nested_object_arguments(): """Test _to_otel_part safely serializes nested non-primitive objects in function_call arguments.""" import json + from dataclasses import dataclass from agent_framework import Content from agent_framework.observability import _to_otel_part + @dataclass class CustomPayload: - def __init__(self, name: str): - self.name = name + name: str - content = Content(type="function_call", call_id="call_789", name="handoff", arguments={"payload": CustomPayload("target_agent")}) + content = Content( + type="function_call", call_id="call_789", name="handoff", arguments={"payload": CustomPayload("target_agent")} + ) result = _to_otel_part(content) assert result is not None @@ -3067,7 +3074,6 @@ 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_dataclass_function_call_arguments(span_exporter: InMemorySpanExporter): """Test that _capture_messages serializes dataclass payloads in function-call arguments without error.""" @@ -3083,7 +3089,14 @@ class HandoffRequest: msg = Message( role="assistant", - contents=[Content(type="function_call", call_id="call_dc", name="request_info", arguments={"payload": HandoffRequest(target_agent="helper", reason="overflow")})], + contents=[ + Content( + type="function_call", + call_id="call_dc", + name="request_info", + arguments={"payload": HandoffRequest(target_agent="helper", reason="overflow")}, + ) + ], ) span_exporter.clear() tracer = trace.get_tracer("test") diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 49a461f9b1e..688bda74ca5 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -10,9 +10,8 @@ import tempfile import threading from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence -from contextlib import suppress -from pathlib import Path from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress +from pathlib import Path from typing import Protocol, cast from agent_framework import ( diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 46a3d7f8ef7..e04b9e0553f 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -2892,6 +2892,8 @@ async def test_malicious_context_id_rejected_e2e(self, tmp_path: Any, context_fi f"before={before} after={after}" ) assert list(root.iterdir()) == [], f"Checkpoint directory created inside root for {context_field}={bad_id!r}" + + # region Agent lifecycle (lazy entry & OAuth consent surfacing) From b8211136fdee5674d75c4c6d9d54c2319bd61485 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 22 May 2026 08:07:00 +0000 Subject: [PATCH 3/4] 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> --- .../core/agent_framework/_serialization.py | 23 +++++++++---- .../core/tests/core/test_observability.py | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index 21b982f81f6..d9fdd618f7d 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -637,14 +637,23 @@ def make_json_safe(obj: Any) -> Any: return obj.isoformat() if is_dataclass(obj) and not isinstance(obj, type): return make_json_safe(asdict(obj)) # type: ignore[arg-type] - if hasattr(obj, "model_dump"): - 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 make_json_safe(obj.dict()) # type: ignore[no-any-return] + 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 {key: make_json_safe(value) for key, value in obj.items()} # type: ignore[misc] + 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__"): diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 82a8452fd1b..190aa7c4839 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1746,6 +1746,39 @@ class CustomPayload: assert parsed["payload"]["name"] == "target_agent" +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.""" + import json + + from agent_framework._serialization import make_json_safe + + class ObjWithNonCallableModelDump: + model_dump = 42 # not callable + + obj = ObjWithNonCallableModelDump() + result = make_json_safe(obj) + # Falls back to __dict__ or str — must not raise TypeError + assert json.dumps(result) is not None + + +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 From c7f4d9c7dd82c58895054676ec6fa1297951e032 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 1 Jun 2026 18:38:18 +0900 Subject: [PATCH 4/4] Address observability serialization review feedback --- .../core/agent_framework/_workflows/_agent.py | 3 +- .../agent_framework/_workflows/_functional.py | 3 +- .../core/agent_framework/observability.py | 9 +- .../core/tests/core/test_observability.py | 88 +++++++------------ .../workflow/test_functional_workflow.py | 32 +++++++ .../tests/workflow/test_workflow_agent.py | 30 +++++++ 6 files changed, 99 insertions(+), 66 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 2d9b37e1f50..7b3bdbb911c 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -12,6 +12,7 @@ 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 RequestInfoFunctionArgs: 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 5746c2161c7..73c0815862e 100644 --- a/python/packages/core/agent_framework/_workflows/_functional.py +++ b/python/packages/core/agent_framework/_workflows/_functional.py @@ -47,6 +47,7 @@ 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 @@ def _request_info_to_approval_request(self, event: WorkflowEvent[Any]) -> Any: 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/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index ead3ecbaf1f..d7734f2457f 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -2229,14 +2229,7 @@ def _to_otel_part(content: Content) -> dict[str, Any] | None: "modality": content.media_type.split("/")[0] if content.media_type else None, } case "function_call": - from ._serialization import make_json_safe # pyright: ignore[reportPrivateUsage] - - return { - "type": "tool_call", - "id": content.call_id, - "name": content.name, - "arguments": make_json_safe(content.arguments), - } + return {"type": "tool_call", "id": content.call_id, "name": content.name, "arguments": content.arguments} case "function_result": return { "type": "tool_call_response", diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 3c449660ca3..372cb8a7dde 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1691,65 +1691,21 @@ def test_to_otel_part_function_call(): } -def test_to_otel_part_function_call_with_dataclass_arguments(): - """Test _to_otel_part safely serializes dataclass instances in function_call arguments.""" - import dataclasses - import json - - from agent_framework import Content - from agent_framework.observability import _to_otel_part - - @dataclasses.dataclass - class RequestPayload: - target: str - value: int - - content = Content( - type="function_call", - call_id="call_456", - name="request_info", - arguments={"payload": RequestPayload(target="agent", value=42)}, - ) - result = _to_otel_part(content) - - assert result is not None - assert result["type"] == "tool_call" - assert result["id"] == "call_456" - assert result["name"] == "request_info" - # arguments must be JSON-serializable - serialized = json.dumps(result["arguments"]) - parsed = json.loads(serialized) - assert parsed["payload"] == {"target": "agent", "value": 42} - - -def test_to_otel_part_function_call_with_nested_object_arguments(): - """Test _to_otel_part safely serializes nested non-primitive objects in function_call arguments.""" - import json - from dataclasses import dataclass - +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 - @dataclass - class CustomPayload: - name: str - - content = Content( - type="function_call", call_id="call_789", name="handoff", arguments={"payload": CustomPayload("target_agent")} - ) + 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 - # arguments must be JSON-serializable — this would raise TypeError before the fix - serialized = json.dumps(result["arguments"]) - parsed = json.loads(serialized) - assert parsed["payload"]["name"] == "target_agent" + 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.""" - import json - from agent_framework._serialization import make_json_safe class ObjWithNonCallableModelDump: @@ -1757,8 +1713,23 @@ class ObjWithNonCallableModelDump: obj = ObjWithNonCallableModelDump() result = make_json_safe(obj) - # Falls back to __dict__ or str — must not raise TypeError - assert json.dumps(result) is not None + 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(): @@ -3108,18 +3079,24 @@ async def test_system_instructions_preserves_non_ascii_characters(span_exporter: @pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True) -def test_capture_messages_with_dataclass_function_call_arguments(span_exporter: InMemorySpanExporter): - """Test that _capture_messages serializes dataclass payloads in function-call arguments without error.""" +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=[ @@ -3127,14 +3104,13 @@ class HandoffRequest: type="function_call", call_id="call_dc", name="request_info", - arguments={"payload": HandoffRequest(target_agent="helper", reason="overflow")}, + arguments=arguments, ) ], ) span_exporter.clear() tracer = trace.get_tracer("test") with tracer.start_as_current_span("test_span") as span: - # Must not raise TypeError for non-serializable dataclass argument _capture_messages(span=span, provider_name="test_provider", messages=[msg]) spans = span_exporter.get_finished_spans() @@ -3142,7 +3118,7 @@ class HandoffRequest: 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"]["payload"] == {"target_agent": "helper", "reason": "overflow"} + assert tool_part["arguments"]["data"] == {"target_agent": "helper", "reason": "overflow"} def test_capture_messages_keeps_framework_instructions_out_of_logs_and_span_messages( diff --git a/python/packages/core/tests/workflow/test_functional_workflow.py b/python/packages/core/tests/workflow/test_functional_workflow.py index 6502a0e353e..d52c5497f9f 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 @@ async def wf(x: str, ctx: RunContext) -> str: 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 3dcdd26c86c..c473fdaaf82 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 @@ WorkflowAgent, WorkflowBuilder, WorkflowContext, + WorkflowEvent, executor, handler, response_handler, @@ -293,6 +296,33 @@ async def test_end_to_end_request_info_handling(self): # 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