Dedup root-level `input` in tool-call `RetryPromptPart` serialization

PY
1mo ago
+48-2

Closes #7171

Problem

When a tool call (function tool or ToolOutput) fails validation with multiple root-level errors, the retry message serializes the entire invalid argument object once per error — O(N_errors × arg_size). The shared RetryPromptPart.model_response() path affects both ToolOutput (flagship structured output) and regular function tools. With a large structured output this can push the next model request past the context window and block self-correction.

Root cause: the tool-bound branch of RetryPromptPart.model_response() used exclude = {'__all__': {'ctx'}}, which kept input on every error. Every root-level error carries the identical full input object (it's the whole args dict), so it was emitted N times. This N-fold duplication was never intended — it is an unaddressed side effect of #5181's single-inclusion restoration.

The NativeOutput branch (tool_name is None) already drops root-level input because the generated JSON is already in context.

Fix

Dedup the root-level input once: the first root-level error keeps the full input object; subsequent root-level errors drop it. Nested errors keep their own small, loc-scoped input. ctx is stripped from all, as before.

This mirrors the existing NativeOutput root/nested distinction while preserving the #5181 self-correction signal — single-error tool-call retries still keep input, and the first root-level error of a multi-error retry keeps it too.

Reproducer (current checked-in code, FunctionModel): tool-bound path produced 13287 chars with a unique marker appearing 20×; after the fix the marker appears once. NativeOutput path unchanged (2007 chars, marker 0×). Playground share: https://play.pydantic.work/p/o6yws-5i

Scope (intentionally narrow)

The broader design question — treat ToolOutput like NativeOutput and drop root-level input entirely (since invalid args are already in the preceding assistant ToolCallPart) — is undecided by maintainers and intentionally out of scope, since it risks the ~0.5% permanent-failure regression from #5178. This PR does the conservative dedup-once fix only.

Tests

  • New regression test test_retry_prompt_tool_call_dedups_root_level_input: two root-level errors sharing the same input dict + one nested error; asserts the shared input appears exactly once, all error messages present, nested input kept.
  • Existing single-error tests (test_retry_prompt_tool_call_keeps_input_at_top_level, test_retry_prompt_tool_call_keeps_input_for_nested_errors) still pass under dedup-once.
  • tests/test_agent.py::test_result_pydantic_model_validation_error (single-error ToolOutput snapshot) unchanged and passing.
💰 Run usage: 357,628 tokens, 17 model requests (est. cost $0.1463)
AgentCostInputOutputRequestsTool callsModel
pydanty-pr-opener$0.1334342,4034,2131520glm-5.2
pydanty-test-runner$0.012910,62838422gpt-5.6-luna
total$0.1463353,0314,5971722
💰 Run usage: 1,121,511 tokens, 43 model requests (est. cost $0.4733)
AgentCostInputOutputRequestsTool callsModel
pydanty-pr-opener$0.40861,046,42419,5993639glm-5.3
pydanty-edit-executor$0.031427,33967832gpt-5.6-luna
pydanty-test-runner$0.033326,3061,16543gpt-5.6-luna
total$0.47331,100,06921,4424344