Render retries that answer no tool call as a new `RetryFeedbackPart` in the system voice

Closed
DS
1mo ago
+3893-294

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.

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 retryPartReaches the model as
answers a tool callRetryPromptPart (unchanged)that call's result, on the provider's error channel
answers nothingRetryFeedbackPart (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 from pydantic_ai and pydantic_ai.messages) — content: list[ErrorDetails] | str plus a cause discriminator ('validation_error' | 'no_output' | 'model_retry'). No tool fields: it is never bound to a call. The stored part carries no wording — cause picks 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 outside pydantic_ai needs 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 RetryFeedbackPart dumps as a system-role message holding the rendered text, with the part itself in the protocol's metadata channel (Vercel providerMetadata, AG-UI encrypted_value). It reloads as a RetryFeedbackPart only from that marker — a system message the client wrote stays a SystemPromptPart and can never acquire harness provenance. Forgery is tested on both adapters. The marker discloses no more than the rendered text beside it: retry_feedback_payload drops each validation error's ctx and empties its input, matching the include_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_messages now strips RetryFeedbackParts alongside SystemPromptParts whenever strip_system_prompts is on, which is what actually stops a forged one reaching the model.
  • OTel emits harness feedback under the system role (#7235).

Notes for the reviewer

  • Needs pydantic/pydantic-ai-harness#653. RetryFeedbackPart joins ModelRequestPart, so it reaches two assert_never chains in the harness; that PR decides each. The harness compat check here runs harness main, 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 a ModelRetry-raising tool — wider than the defect #6404 reports — and needs a deprecation path for RetryPromptPart before it can land. It is now #7799, with the implementation and its three live provider cassettes recoverable from 9a38fa09c. #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 UserError when the history holds any part that only becomes sendable at prepare_messages time. It reproduces on main today with ToolAvailabilityDeltaPart, so the hole predates this PR — but RetryFeedbackPart is 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.