From 2b58c6a31adc321606e2688796183663ff18dc30 Mon Sep 17 00:00:00 2001 From: betacatsling <113584199+betacatsling@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:35:59 +0800 Subject: [PATCH] fix(core): keep tool output custom_data structured in RunState serialization ToolCallOutputItem.custom_data is SDK-only metadata that persists through RunState. _serialize_item passed it straight to _ensure_json_compatible, which uses json.dumps(default=str) and degrades Pydantic models and dataclasses to repr strings. Route it through _serialize_output_value first, the same pipeline already used for the item's output field, so structured payloads persist as plain JSON data on save and restore. --- src/agents/run_state.py | 2 +- tests/test_run_state.py | 55 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index d79e0781a4..62606cca1b 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -2078,7 +2078,7 @@ def _serialize_item( result["tool_origin"] = tool_origin.to_json_dict() custom_data = getattr(item, "custom_data", None) if isinstance(custom_data, dict) and custom_data: - result["custom_data"] = _ensure_json_compatible(custom_data) + result["custom_data"] = _ensure_json_compatible(_serialize_output_value(custom_data)) return result diff --git a/tests/test_run_state.py b/tests/test_run_state.py index cd2daa51b6..7787f6690b 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -4089,6 +4089,61 @@ async def test_deserializes_tool_call_output_custom_data(self): assert isinstance(restored_item, ToolCallOutputItem) assert restored_item.custom_data == {"ui": {"kind": "chart"}, "ids": ["a", "b"]} + async def test_tool_output_custom_data_preserves_structured_values(self): + """Pydantic and dataclass values in tool output custom_data stay structured. + + ``custom_data`` is SDK-only metadata that persists through ``RunState``. Like + ``output``, it must route through ``_serialize_output_value`` so model and + dataclass payloads survive as plain JSON data instead of repr strings. + """ + context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={}) + agent = Agent(name="ItemAgent") + state = make_state(agent, context=context, original_input="test", max_turns=5) + + class Score(BaseModel): + value: float + label: str + + @dataclass + class Meta: + cached: bool + attempts: int + + raw_tool_output = { + "type": "function_call_output", + "call_id": "call_custom_data_structured", + "output": "result", + } + state._generated_items.append( + ToolCallOutputItem( + agent=agent, + raw_item=raw_tool_output, + output="result", + custom_data={ + "score": Score(value=0.9, label="high"), + "meta": Meta(cached=True, attempts=2), + "tags": ["a", "b"], + }, + ) + ) + + json_data = state.to_json() + assert json_data["generated_items"][0]["custom_data"] == { + "score": {"value": 0.9, "label": "high"}, + "meta": {"cached": True, "attempts": 2}, + "tags": ["a", "b"], + } + + new_state = await RunState.from_json(agent, json_data) + + restored_item = new_state._generated_items[0] + assert isinstance(restored_item, ToolCallOutputItem) + assert restored_item.custom_data == { + "score": {"value": 0.9, "label": "high"}, + "meta": {"cached": True, "attempts": 2}, + "tags": ["a", "b"], + } + async def test_pydantic_tool_output_preserves_default_fields(self): """A structured tool output's default-valued fields must survive RunState roundtrips.