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.