Skip to content

Issue draft (not filed): RunState stringifies structured ToolCallOutputItem.custom_data during persistence #5014

Description

@betacatsling

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions