Skip to content

fix: coerce structured tool outputs for stop_on_first_tool - #3997

Closed
hsusul wants to merge 1 commit into
openai:mainfrom
hsusul:fix/stop-on-first-tool-structured-output
Closed

fix: coerce structured tool outputs for stop_on_first_tool#3997
hsusul wants to merge 1 commit into
openai:mainfrom
hsusul:fix/stop-on-first-tool-structured-output

Conversation

@hsusul

@hsusul hsusul commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

When tool_use_behavior is stop_on_first_tool or stop_at_tool_names, plain-text agents finalize through _maybe_finalize_from_tool_results, which previously called str(...) on the tool result. For structured returns such as ToolOutputText / ToolOutputImage (and TypedDict equivalents), that produced Pydantic or dict __str__/__repr__ values like type='text' text='hello structured' instead of usable plain-text output.

This pull request fixes that by coercing structured tool outputs through the existing ItemHelpers._convert_tool_output_as_structured path:

  • single ToolOutputText / text TypedDict → the text content string
  • other structured outputs (image/file/lists) → stable JSON matching the Responses wire shape
  • non-structured values (e.g. int) → unchanged str(...) behavior

Affected component: src/agents/run_internal/turn_resolution.py (_maybe_finalize_from_tool_results)

Problem: stop_on_first_tool / stop_at_tool_names turned structured tool returns into Pydantic/dict string representations for default plain-text agents.

Minimal reproduction (FakeModel, no API key):

@function_tool
def text_tool():
    return ToolOutputText(text="hello structured")

agent = Agent(
    name="a",
    model=FakeModel(initial_output=[get_function_tool_call("text_tool", "{}")]),
    tools=[text_tool],
    tool_use_behavior="stop_on_first_tool",
)
result = await Runner.run(agent, "hi")
# before: "type='text' text='hello structured'"
# after:  "hello structured"

Root cause: Blind str(final_output) for plain-text agents does not use the structured tool-output conversion already used for tool wire payloads.

Why minimal: One shared helper in the finalization path used by both streamed and non-streamed runs. No public API changes. Does not change the rejected None-preservation behavior from #3943 (plain-text agents still stringify non-structured values, including None"None").

Compatibility: Corrects buggy released behavior for structured tool finals on plain-text agents (v0.19.0). Callers that depended on Pydantic/dict __str__ text were relying on the bug.

Non-goals: No change to custom output_type agents, approval/sticky-reject behavior, session persistence, or intentional empty-string as_tool fallbacks.

Test plan

  • Added regression tests in tests/test_tool_use_behavior.py covering:
    • ToolOutputText via stop_on_first_tool
    • ToolOutputTextDict via stop_on_first_tool
    • ToolOutputText via stop_at_tool_names
    • ToolOutputImage (JSON, not Pydantic repr)
    • streamed Runner.run_streamed path
    • non-structured int still stringified to "42"
  • Pre-fix: focused tests failed with Pydantic/dict string outputs
  • Post-fix: uv run pytest tests/test_tool_use_behavior.py -q → 15 passed (ran repeatedly)
  • Ran .agents/skills/code-change-verification/scripts/run.sh
    • make format passed
    • make lint passed
    • make typecheck passed
    • make tests passed
  • Confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

Issue number

N/A — focused correctness fix submitted directly (duplicate search found no open issue for this symptom).

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

Plain-text agents finalized via stop_on_first_tool/stop_at_tool_names were
receiving Pydantic/dict __str__ representations for ToolOutputText and related
structured returns. Reuse ItemHelpers structured conversion so text becomes
the content string and non-text structured outputs become stable JSON.
@seratch

seratch commented Jul 28, 2026

Copy link
Copy Markdown
Member

Thanks for the contribution. I confirmed that ToolOutputText currently becomes its Pydantic representation when a default plain-text agent uses one of the built-in stop behaviors.

However, the structured tool-output types define how results are sent back to the model; they do not define a public plain-text final-result serialization. The desired outcome is already supported by returning a string, using a custom ToolsToFinalOutputFunction to extract or serialize the result, or declaring a matching output_type when the structured value itself should be final. This change would additionally expose private Responses input_* wire shapes as public final-output JSON and reinterpret values from custom handlers.

I am going to close this PR. We can revisit a narrower behavior if there is a concrete supported scenario where the existing approaches are insufficient.

@seratch seratch closed this Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants