Dedup root-level `input` in tool-call `RetryPromptPart` serialization
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-errorToolOutputsnapshot) unchanged and passing.
💰 Run usage: 357,628 tokens, 17 model requests (est. cost $0.1463)
| Agent | Cost | Input | Output | Requests | Tool calls | Model |
|---|---|---|---|---|---|---|
pydanty-pr-opener | $0.1334 | 342,403 | 4,213 | 15 | 20 | glm-5.2 |
pydanty-test-runner | $0.0129 | 10,628 | 384 | 2 | 2 | gpt-5.6-luna |
| total | $0.1463 | 353,031 | 4,597 | 17 | 22 |
💰 Run usage: 1,121,511 tokens, 43 model requests (est. cost $0.4733)
| Agent | Cost | Input | Output | Requests | Tool calls | Model |
|---|---|---|---|---|---|---|
pydanty-pr-opener | $0.4086 | 1,046,424 | 19,599 | 36 | 39 | glm-5.3 |
pydanty-edit-executor | $0.0314 | 27,339 | 678 | 3 | 2 | gpt-5.6-luna |
pydanty-test-runner | $0.0333 | 26,306 | 1,165 | 4 | 3 | gpt-5.6-luna |
| total | $0.4733 | 1,100,069 | 21,442 | 43 | 44 |