feat: add built-in repair_orphaned_tool_parts history processor#5090
feat: add built-in repair_orphaned_tool_parts history processor#5090anmolg1997 wants to merge 6 commits intopydantic:mainfrom
repair_orphaned_tool_parts history processor#5090Conversation
The judge agents' system prompts now explicitly instruct the model to keep the `reason` field to a concise 1-2 sentence summary, preventing reasoning/thinking text from leaking into the public reason. The GradingOutput.reason field also gains a description that reinforces this constraint via the JSON schema. This makes `reason` stable and suitable for use in ModelRetry feedback loops, where verbose or self-contradictory reasoning text would otherwise degrade retry quality. Fixes pydantic#5034
Adds a ready-to-use history processor that removes structurally invalid tool call/return pairs from message history. This prevents 400 errors from providers (especially Anthropic) that reject orphaned tool references after streaming timeouts, deferred tool drops, or history trimming. Two-pass repair: 1. Remove ToolReturnPart/RetryPromptPart whose tool_call_id has no matching ToolCallPart 2. Remove ToolCallPart whose tool_call_id has no matching return Output-validation RetryPromptParts (tool_name=None) are preserved since they are not tied to tool calls. Closes pydantic#4728
- Remove unused `import pytest` from test file - Fix formatting (pre-commit auto-format compliance) - Remove redundant `tool_call_id` truthiness guards (always set by default) - Add `pragma: no branch` for exhaustive ModelMessage union branch - Achieves 100% branch coverage
Split repair_orphaned_tool_parts into focused helpers: - _collect_tool_call_ids / _collect_tool_return_ids - _is_orphaned_request_part - _repair_request / _repair_response - _rebuild_or_drop All 11 tests pass, 100% branch coverage, ruff clean.
- Fix "possibly unbound" by using if/else instead of if/elif for exhaustive ModelMessage union - Fix list invariance errors by inlining rebuild logic into _repair_request and _repair_response with proper return types - Remove _rebuild_or_drop helper and its type: ignore comment Locally verified: pyright 0 errors, ruff clean, 100% branch coverage.
|
Hey @anmolg1997 Thanks for this though with the changes in place we are aiming to introduce this as a capability in the harness: pydantic/pydantic-ai-harness#184 Feel free to discuss this with Douwe there :) Closing this for now. |
|
Thanks @adtyavrdhn and @DouweM, makes sense to land this as a harness capability ( For anyone finding this later: pydantic/pydantic-ai-harness#184 is the canonical impl. Same orphaning scenarios (orphaned calls -> synthetic returns, orphaned returns/retries -> strip), plus edge cases I hadn't covered (trailing responses, empty requests after repair, built-in tool parts). Closing in favor of the harness PR. Happy to help review there if useful. |
Summary
pydantic_ai.history_processors.repair_orphaned_tool_parts, a ready-to-use history processor that removes structurally invalid tool call/return pairs from message historyProblem
Multi-turn conversations with tools can accumulate broken message history: tool calls without matching results, or results referencing calls that don't exist. This is structurally invalid (a tool call without a result before the next turn doesn't make sense), and providers rightfully reject it. Anthropic is the strictest about enforcement (you get a 400), but even providers that silently tolerate it produce worse results from garbled history.
Common causes:
ToolCallPartgets persisted, but agent times out beforeToolReturnPartarrivestool_call_idApproach
Two-pass repair:
ToolReturnPartorRetryPromptPart(withtool_name) whosetool_call_idhas no matchingToolCallPart→ removedToolCallPartwhosetool_call_idhas no matchingToolReturnPartorRetryPromptPart→ removedOutput-validation
RetryPromptParts (tool_name=None) are preserved since they're not tied to tool calls.Empty messages (all parts removed) are dropped entirely.
Usage
Changes
pydantic_ai_slim/pydantic_ai/history_processors.pyrepair_orphaned_tool_partsfunctiontests/test_history_processors.pyTest plan
Closes #4728