Summary
RunState persistence degrades structured values stored in ToolCallOutputItem.custom_data to repr() strings. custom_data is a public dict[str, Any] field ("SDK-only custom data attached to this tool output") that is serialized into generated_items and restored on RunState.from_json. Unlike the sibling output field, it is passed directly to _ensure_json_compatible, which falls back to json.dumps(default=str) for anything that is not natively JSON-serializable. Pydantic models and dataclasses therefore persist as strings like "value=0.9 label='high'" instead of JSON objects, and a restored run observes corrupted metadata.
Reproduction
import asyncio
from pydantic import BaseModel
from agents import Agent
from agents.items import ToolCallOutputItem
from agents.run_context import RunContextWrapper
from agents.run_state import RunState
class Score(BaseModel):
value: float
label: str
async def main() -> None:
agent = Agent(name="AuditAgent")
state = RunState(
context=RunContextWrapper(context={}),
original_input="input",
starting_agent=agent,
max_turns=1,
)
state._generated_items.append(
ToolCallOutputItem(
agent=agent,
raw_item={"type": "function_call_output", "call_id": "c1", "output": "r"},
output="r",
custom_data={"score": Score(value=0.9, label="high")},
)
)
json_data = state.to_json()
print(json_data["generated_items"][0]["custom_data"])
# {"score": "value=0.9 label='high'"} <- repr string, not a JSON object
asyncio.run(main())
Root cause
RunState._serialize_item (src/agents/run_state.py) serializes item.output via _ensure_json_compatible(_serialize_output_value(item.output)), which recursively converts Pydantic models, dataclasses, mappings, and sequences to plain JSON values. A few lines later, custom_data is serialized via _ensure_json_compatible(custom_data) alone, skipping _serialize_output_value, so structured values hit the default=str fallback.
Proposed fix
Route custom_data through _serialize_output_value first, matching output:
result["custom_data"] = _ensure_json_compatible(_serialize_output_value(custom_data))
Note: the custom_data_extractor boundary (normalize_custom_data in src/agents/util/_custom_data.py) still enforces the documented JSON-compatible contract for extractor-produced data; this change only makes the persistence layer faithful for values set directly on the public field, consistent with how output is already handled.
Summary
RunStatepersistence degrades structured values stored inToolCallOutputItem.custom_datatorepr()strings.custom_datais a publicdict[str, Any]field ("SDK-only custom data attached to this tool output") that is serialized intogenerated_itemsand restored onRunState.from_json. Unlike the siblingoutputfield, it is passed directly to_ensure_json_compatible, which falls back tojson.dumps(default=str)for anything that is not natively JSON-serializable. Pydantic models and dataclasses therefore persist as strings like"value=0.9 label='high'"instead of JSON objects, and a restored run observes corrupted metadata.Reproduction
Root cause
RunState._serialize_item(src/agents/run_state.py) serializesitem.outputvia_ensure_json_compatible(_serialize_output_value(item.output)), which recursively converts Pydantic models, dataclasses, mappings, and sequences to plain JSON values. A few lines later,custom_datais serialized via_ensure_json_compatible(custom_data)alone, skipping_serialize_output_value, so structured values hit thedefault=strfallback.Proposed fix
Route
custom_datathrough_serialize_output_valuefirst, matchingoutput:Note: the
custom_data_extractorboundary (normalize_custom_datainsrc/agents/util/_custom_data.py) still enforces the documented JSON-compatible contract for extractor-produced data; this change only makes the persistence layer faithful for values set directly on the public field, consistent with howoutputis already handled.