Render retries that answer no tool call as a new `RetryFeedbackPart` in the system voice
This pull request was posted by Claude Code using claude-opus-5 on behalf of David.
David has not reviewed the code; the design was settled with maintainers and the implementation and tests are AI-written.
- Closes #6404
Supersedes the closed #6571 (cause-field approach).
Why
A retry that answers a tool call is that call's result, and the model reads it that way. A retry that answers nothing — structured output failed validation, an output validator raised ModelRetry, the response held nothing usable — was the same RetryPromptPart, and reached every provider as an ordinary user message. The model could not tell "your JSON failed validation" from something a person typed, and every consumer branched on tool_name is None to guess which of the two it was holding.
This gives the second case its own type:
| The retry | Part | Reaches the model as |
|---|---|---|
| answers a tool call | RetryPromptPart (unchanged) | that call's result, on the provider's error channel |
| answers nothing | RetryFeedbackPart (new) | a mid-conversation system message, or <system>-tagged user text where the provider has none |
⚠️ Compatibility impact
Who is affected: agents that use output validators, output functions, structured/native/prompted output, or model hooks that raise ModelRetry — i.e. anything that can produce a retry not bound to a tool call. Agents whose retries all come from tool calls are unaffected.
What changes. A retry that answers no tool call used to appear in all_messages() as a RetryPromptPart with tool_name=None. It is now a RetryFeedbackPart:
# before
[p for m in result.all_messages() for p in m.parts if isinstance(p, RetryPromptPart) and p.tool_name is None]
# after
[p for m in result.all_messages() for p in m.parts if isinstance(p, RetryFeedbackPart)]
Code matching isinstance(part, RetryPromptPart) to find these retries stops matching — silently, since nothing raises. Tool retries still match exactly as before.
ToolRetryError.tool_retry widens to RetryPromptPart | RetryFeedbackPart to match, so code that catches it and reads .tool_name / .tool_call_id needs to narrow first.
OTel keeps its old role on instrumentation format versions below 6: a tool-less retry still reports role='user' there, and only v6+ reports system, following the same contract #7582 set for the tool role.
The text the model sees also changes for those retries: it arrives in the system voice rather than as user text, and loses the Fix the errors and try again. suffix, per the TOOL_AVAILABILITY_ANNOUNCEMENT tone rule from #6793 — state the fact, don't spend the harness's system voice on a directive nobody authored. This rendering change is the defect #6404 reported, and is the deliberate exception agreed with maintainers.
Stored histories are unaffected. RetryPromptPart stays in the union, deserializes identically, and renders exactly as it always has — including the suffix. A history serialized before this change loads to the same objects.
New public surface
Flagging for review, since this is the only chance to shape it before it ships.
RetryFeedbackPart(exported frompydantic_aiandpydantic_ai.messages) —content: list[ErrorDetails] | strplus acausediscriminator ('validation_error' | 'no_output' | 'model_retry'). No tool fields: it is never bound to a call. The stored part carries no wording —causepicks it at render time, so one history stays valid across providers and a cached prefix survives.
That is the whole of it. The renderer and its three wording templates are private (_render_retry_feedback,_RETRY_FEEDBACK_*), imported across subpackages with the# pyright: ignore[reportPrivateUsage]idiom this PR already uses for_unrendered_retry_feedback_error— no consumer outsidepydantic_aineeds them, and unpublishing after release would need a deprecation cycle. Say the word if you'd rather they were public.
How it renders
Rendering happens in Model.prepare_messages, replacing the part in place, so a request that also holds a user prompt keeps its authored order and the run's standing system prompt is untouched. Where the profile takes no mid-conversation system message, the existing _wrap_non_leading_system_prompts degrades it to <system>-tagged user text — the same pipeline ToolAvailabilityDeltaPart uses, not a new one.
Both sides are recorded live: o3-mini takes the real system message, claude-sonnet-4-5 gets the wrapped one, and both models obey it.
Because the rendered text now nests validation values the model itself wrote, every spelling of a closing </system> tag is escaped before wrapping — otherwise a model-authored tag could end the wrapped statement early. That escape also protects tool-availability announcements, which carry MCP-chosen tool names on the same path.
UI adapters and OTel
Both adapters bypass prepare_messages, so they learn the same distinction:
- A
RetryFeedbackPartdumps as a system-role message holding the rendered text, with the part itself in the protocol's metadata channel (VercelproviderMetadata, AG-UIencrypted_value). It reloads as aRetryFeedbackPartonly from that marker — a system message the client wrote stays aSystemPromptPartand can never acquire harness provenance. Forgery is tested on both adapters. The marker discloses no more than the rendered text beside it:retry_feedback_payloaddrops each validation error'sctxand empties itsinput, matching theinclude_input='none'render, since the client reads and echoes that channel. The round-trip is rendering-equivalent rather than byte-equivalent as a result. - The marker is client-echoed, so it separates provenance rather than proving it.
sanitize_messagesnow stripsRetryFeedbackParts alongsideSystemPromptParts wheneverstrip_system_promptsis on, which is what actually stops a forged one reaching the model. - OTel emits harness feedback under the
systemrole (#7235).
Notes for the reviewer
- Needs pydantic/pydantic-ai-harness#653.
RetryFeedbackPartjoinsModelRequestPart, so it reaches twoassert_neverchains in the harness; that PR decides each. Theharness compatcheck here runs harnessmain, so it stays red until #653 merges, and #653's own lint stays red until this ships the part. Same ordering as harness#586. - Scope was narrowed on 2026-08-26. This PR originally also replaced tool-scoped retries with
ToolReturnPart(outcome='retried'). That half moved observable history for every agent with aModelRetry-raising tool — wider than the defect #6404 reports — and needs a deprecation path forRetryPromptPartbefore it can land. It is now #7799, with the implementation and its three live provider cassettes recoverable from9a38fa09c. #7171 and #7172 went with it and are untouched by this PR. - Tool-return media provenance (#6662) is not in this PR — #7618 owns it.
- A local review round found one thing this PR does not fix: #7802. Resuming a turn the provider suspended raises
UserErrorwhen the history holds any part that only becomes sendable atprepare_messagestime. It reproduces onmaintoday withToolAvailabilityDeltaPart, so the hole predates this PR — butRetryFeedbackPartis far more common than the parts that hit it before, so this PR widens its reach. The fix touches_agent_graph's continuation/seed machinery, which is not this PR's to reshape.
Why this PR touches harness-compat.yml
pydantic-ai-harness closes over the request-part union with assert_never under TYPE_CHECKING, on purpose, so that a part added here fails its typecheck and becomes a decision rather than a silent omission. Adding RetryFeedbackPart trips it — which is the tripwire working.
It has no green path of its own, though. The job reads harness main, which cannot know the part until harness #653 merges; #653 installs the released pydantic-ai, so it cannot go green until the part ships. Neither side can go first, and neither repo merges red. This is the first PR to hit it: every other open PR touching pydantic_ai_slim passes the check, because none of them adds a union member.
So the job now reads a harness-ref:<branch> label and tests that harness branch instead of main. This PR carries harness-ref:retry-feedback-part, so the check verifies the pairing that exists once both merge, rather than a combination that never ships. Without the label nothing moves — every other PR, every push to main and every tag still reads main. Applying a label needs write access, so a fork PR cannot redirect the ref.
Merge order: this PR, then a release, then harness #653 raises its floor to that release and drops the label's counterpart. main's own harness-compat run stays red for that window, which is inherent to adding a part rather than something this mechanism introduces.
Checklist
- Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
- No breaking changes in accordance with the version policy — behavior moves only where #6404's defect lives; see Compatibility impact above for the migration.
- PR title is fit for the release changelog.