Skip to content

fix(messages): add ToolReturnPart to ModelResponsePart union#5723

Open
Bartok9 wants to merge 5 commits into
pydantic:mainfrom
Bartok9:fix/5721-toolreturnpart-modelresponse-union
Open

fix(messages): add ToolReturnPart to ModelResponsePart union#5723
Bartok9 wants to merge 5 commits into
pydantic:mainfrom
Bartok9:fix/5721-toolreturnpart-modelresponse-union

Conversation

@Bartok9
Copy link
Copy Markdown

@Bartok9 Bartok9 commented May 29, 2026

Summary

ToolReturnPart (part_kind='tool-return') is a member of the ModelRequestPart discriminated union but was missing from ModelResponsePart, even though both unions share the same discriminator. As a result, a ToolReturnPart embedded in a ModelResponse serializes fine but fails to deserialize with a union_tag_invalid ValidationError — the writer emits the 'tool-return' tag, the reader finds no response-union member claiming it.

This PR adds the base ToolReturnPart to ModelResponsePart and handles the new variant everywhere ModelResponse.parts is consumed, so the change stays fully type-checked (no assert_never regressions).

msg = ModelResponse(parts=[ToolReturnPart(tool_name='t', content={'r': 'ok'}, tool_call_id='c1')])
dumped = ModelMessagesTypeAdapter.dump_python([msg])
ModelMessagesTypeAdapter.validate_python(dumped)  # before: ValidationError; after: round-trips

Motivation

Closes #5721.

This is a type-system + serialization-contract consistency fix, not a fix for a shape the framework emits today. To be precise about reachability (the issue's "Root Cause" is inaccurate on this point — see my comment there):

  • A normal agent run never puts a base ToolReturnPart on a ModelResponse. The framework synthesizes tool returns (including the output-tool "Final result processed." parts) into output_parts: list[ModelRequestPart] → a ModelRequest. Verified empirically: across a tool-calling run, every ToolReturnPart sits on a ModelRequest; the matching ModelResponse holds the ToolCallPart. So all_messages_json() round-trips — and therefore resumed runs, durable workflows, and the AG-UI/Vercel adapters that round-trip persisted history — are unaffected in normal operation.
  • The reachable trigger is user-constructed response history (the reproduction in [roundtrip-sweep] ModelResponsePart: ToolReturnPart with part_kind='tool-return' missing from discriminated union — breaks message history round-trip #5721): code that builds ModelResponse(parts=[ToolReturnPart(...)]) itself, then persists and reloads it.

Why fix it anyway:

  1. Union symmetry. ModelRequestPart already admits ToolReturnPart under the same shared discriminator. A part_kind that one union accepts and its sibling rejects is a latent hole in a public, documented serialization contract.
  2. The discriminator already routes the typed subclass. ToolSearchReturnPart (a ToolReturnPart subclass) round-trips via its own 'tool-search-return' tag (_TYPED_PART_TAGS); only the base part fell through. Adding the base member completes an existing pattern and cannot shadow the subclass — pinned by test_tool_search_return_part_in_model_request_still_narrows.
  3. Completing the fan-out surfaced two genuinely latent bugs on the NativeToolReturnPart sibling path (see below): a Bedrock AssertionError and an OTel telemetry drop.

(If a narrower serialization-only approach is preferred — e.g. routing the tag without widening the static union — happy to refactor. But that desyncs the type from its discriminator; widening the union is what makes the round-trip type-correct end to end.)

What changed

The union addition is the core fix. Because it makes ToolReturnPart a valid member everywhere ModelResponse.parts is iterated, every consumer now handles the new variant explicitly instead of falling through assert_never:

Site Handling Rationale
Model request mappers (anthropic, openai, google, gemini, groq, mistral, cohere, huggingface, xai, outlines, bedrock, test) skip A response-embedded tool return is persisted history, not assistant content the model emitted, so it is not replayed to the provider — mirrors the existing CompactionPart/FilePart skip pattern.
function._estimate_usage count content Shares the NativeToolReturnPart token path.
_agent_graph event loop, AG-UI, Vercel adapters no event These parts produce no streamed/rendered event.
_event_stream start/end dispatch grouped with delta-less parts No deltas to start/end.
ModelResponse.otel_message_parts render as tool_call_response Mirrors NativeToolReturnPart (minus builtin) so a response-embedded tool return isn't dropped from telemetry. (otel_events v1 unchanged — it emits no tool-return part at all, not even for NativeToolReturnPart.)
PartStartEvent.previous_part_kind / PartEndEvent.next_part_kind add 'tool-return' literal The kind is now reachable in the union.
_parts_manager._resolve_provider_name isinstance narrow ToolReturnPart has no provider_name and is never tracked by the streaming parts manager; narrowing keeps provider_name access statically typed.
google._handle_executable_code_streaming narrow return to NativeToolCallPart (its real return) removes the spurious widening to the full union.

Two latent bugs the fan-out surfaced

  • Bedrock AssertionError. BedrockConverseModel._map_messages guarded its response-part loop with a bare else: assert isinstance(item, ToolCallPart) rather than assert_never, so the missing branch wasn't caught at type-check time and raised at runtime. Its tail is now restructured to an explicit ToolCallPart branch + assert_never, matching the other providers and catching any future union member statically.
  • OTel drop. ModelResponse.otel_message_parts mapped NativeToolReturnPart to a tool_call_response part but silently dropped the base ToolReturnPart (the ladder has no assert_never, so nothing flagged it). Now mapped via the shared BaseToolReturnPart.otel_message_parts.

The provider/UI skip branches are # pragma: no cover (defensive, like their CompactionPart/FilePart siblings); the round-trip and OTel guarantees are pinned by the test_messages.py tests below.

Verification

  • pre-commit (full pyright typecheck, ruff format --check, ruff check) — clean
  • uv run pytest tests/test_messages.py — passes (round-trip, subclass-narrowing, OTel)
  • uv run pytest tests/models/test_model_function.py — passes (_estimate_usage regression)
  • Bedrock _map_messages no longer raises on a user-constructed ModelResponse([ToolReturnPart(...)]); the previously-crashing path now maps cleanly.

Tests added

  • tests/test_messages.py::test_tool_return_part_in_model_response_round_trips
  • tests/test_messages.py::test_tool_search_return_part_in_model_request_still_narrows
  • tests/test_messages.py::test_tool_return_part_in_model_response_otel_message_parts
  • tests/models/test_model_function.py::test_estimate_usage_handles_tool_return_part_in_response

@github-actions github-actions Bot added size: S Small PR (≤100 weighted lines) bug Report that something isn't working, or PR implementing a fix labels May 29, 2026
@Bartok9 Bartok9 force-pushed the fix/5721-toolreturnpart-modelresponse-union branch from 9224d6d to c866101 Compare May 31, 2026 08:19
@github-actions github-actions Bot added size: M Medium PR (101-500 weighted lines) and removed size: S Small PR (≤100 weighted lines) labels May 31, 2026
Closes pydantic#5721.

`ModelRequestPart` already carries `Annotated[ToolReturnPart, pydantic.Tag('tool-return')]`,
but `ModelResponsePart` omitted the base `ToolReturnPart` — it only listed the `builtin-*`
native variants. `_agent_graph` directly constructs base `ToolReturnPart` instances for
user-defined / output-tool results and stores them on a `ModelResponse`, so persisting and
reloading that history (resumed runs, durable workflows, AG-UI / Vercel adapters) failed to
deserialize with a `union_tag_invalid` ValidationError on the `'tool-return'` discriminator.

Fix: add the base `ToolReturnPart` to the `ModelResponsePart` discriminated union. Because
that makes `ToolReturnPart` a valid member everywhere `ModelResponse.parts` is consumed, the
new variant is now handled explicitly at each site rather than falling through to
`assert_never`:

- Model request mappers (anthropic, openai, google, gemini, groq, mistral, cohere,
  huggingface, xai, outlines, test): skip the part — framework-stored user tool returns are
  not replayed to the provider, mirroring the existing CompactionPart handling.
- `function._estimate_usage`: count the tool-return content (shares NativeToolReturnPart path).
- `_agent_graph` event loop, AG-UI and Vercel adapters: no streamed/rendered event.
- `_event_stream` part start/end dispatch: grouped with the other delta-less parts.
- `PartStartEvent.previous_part_kind` / `PartEndEvent.next_part_kind`: add `'tool-return'`.
- `_parts_manager._resolve_provider_name`: getattr guard (ToolReturnPart has no provider_name).
- `google._handle_executable_code_streaming`: narrow return to its actual NativeToolCallPart;
  `_decode_inline_thought_signature`: short-circuit for ToolReturnPart.

Tests:
- `test_messages.py`: ToolReturnPart in a ModelResponse round-trips (python + json); the
  ToolSearchReturnPart subclass still narrows to its own tag in a ModelRequest.
- `test_model_function.py`: `_estimate_usage` handles a response-embedded ToolReturnPart.

Verification: `uv run pyright` clean (0 errors), `uv run ruff format --check` / `ruff check`
clean, targeted message + function-model + UI adapter suites pass.
@Bartok9 Bartok9 force-pushed the fix/5721-toolreturnpart-modelresponse-union branch from c866101 to 46b8a08 Compare June 1, 2026 08:12
@dsfaccini dsfaccini changed the title fix(messages): add ToolReturnPart to ModelResponsePart union fix(messages): add ToolReturnPart to ModelResponsePart union Jun 2, 2026
dsfaccini added 2 commits June 1, 2026 22:22
…s-manager typing

Completes the `ToolReturnPart`-in-`ModelResponsePart` fan-out and addresses review findings:

- `BedrockConverseModel._map_messages` was missed and raised `AssertionError` for a base
  `ToolReturnPart` in a `ModelResponse` (its guard was `else: assert isinstance(item, ToolCallPart)`,
  not `assert_never`, so pyright didn't catch it). Restructure the tail to an explicit `ToolCallPart`
  branch + `assert_never`, with a no-op `ToolReturnPart` branch matching the other providers.
- `_parts_manager._resolve_provider_name` narrows with `isinstance(ToolReturnPart)` instead of
  `getattr`, keeping `provider_name` access statically typed.
- Add the missing `# pragma: no cover` on the `_agent_graph` streaming branch (siblings all have it).
- Correct the repeated branch comments: a base `ToolReturnPart` reaches a `ModelResponse` via
  user-constructed message history, not "stored on a ModelResponse" by the framework.
…_parts`

`ModelResponse.otel_message_parts` maps `NativeToolReturnPart` to a `tool_call_response`
part but silently dropped the base `ToolReturnPart`, which is now reachable in
user-constructed message history. Reuse `BaseToolReturnPart.otel_message_parts` so it
renders the same way (without the `builtin` flag) instead of being omitted from telemetry.

(`otel_events` deliberately left unchanged: it represents no tool-return part — not
`NativeToolReturnPart` either — so omitting the base part there is pre-existing-consistent.)
| Annotated[NativeToolCallPart, pydantic.Tag('builtin-tool-call')]
| Annotated[NativeToolSearchReturnPart, pydantic.Tag('builtin-tool-search-return')]
| Annotated[NativeToolReturnPart, pydantic.Tag('builtin-tool-return')]
| Annotated[ToolReturnPart, pydantic.Tag('tool-return')]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DouweM This is the key design decision in the PR and I think it warrants explicit maintainer sign-off before proceeding.

Context: Issue #5721 was auto-generated by a roundtrip-sweep bot. No maintainer has commented on the issue or endorsed a specific approach. The PR author acknowledges the design choice in the PR description ("If you'd prefer a narrower serialization-only approach… happy to refactor").

The question: Should ToolReturnPart be a valid member of ModelResponsePart? The framework itself never places a ToolReturnPart into a ModelResponse_agent_graph.py always stores tool returns in ModelRequest.parts, and model adapters use NativeToolReturnPart for builtin returns. The only way a ToolReturnPart ends up in a ModelResponse is via user-constructed message history, which would currently fail at deserialization.

Trade-offs:

  • This approach (widening the union): fixes the deserialization crash and is defensive, but forces every consumer of ModelResponse.parts across ~20 files to handle a case the framework never produces. All new handler branches are # pragma: no cover dead code.
  • Alternative: serialization-only fix (e.g. a lenient custom discriminator that gracefully handles unknown/unexpected tags without widening the static type union): narrower change, fewer files touched, but less type-safe.
  • Alternative: reject at construction: validate that ModelResponse.parts doesn't contain request-side parts, making the user error explicit rather than silently accepting it.

The right call depends on whether "user-constructed ModelResponse containing ToolReturnPart" is something the framework should support or discourage.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

David's AICA here: Decision on the design question (Douwe is away, so I'm making the call): we'll keep the union-widening approach — adding the base ToolReturnPart to ModelResponsePart. It's the approach with direct precedent and explicit version-policy cover; the two alternatives both reintroduce problems we've deliberately avoided.

Reasoning:

One correction to the framing above: the framework never produces a base ToolReturnPart on a ModelResponse — it routes tool returns into ModelRequest.parts. The shape is reachable only via user-constructed or deserialized message history, which is exactly the round-trip case #5721 hits, so the fix belongs at the type/deserialization layer.

Comment thread pydantic_ai_slim/pydantic_ai/models/google.py
Comment thread pydantic_ai_slim/pydantic_ai/_parts_manager.py
Comment thread tests/models/test_model_function.py
Comment thread pydantic_ai_slim/pydantic_ai/messages.py
Comment thread pydantic_ai_slim/pydantic_ai/models/bedrock.py
Comment thread pydantic_ai_slim/pydantic_ai/messages.py
…_name

The 100% coverage gate flagged _parts_manager.py:533 — the
`isinstance(existing_part, ToolReturnPart)` short-circuit added when
ToolReturnPart joined the ModelResponsePart union. That branch is
defensive (no public caller passes a ToolReturnPart as existing_part)
so no existing test exercised it. Add a focused unit test asserting the
resolver returns the incoming provider_name for a ToolReturnPart (which
carries none of its own).
@Bartok9
Copy link
Copy Markdown
Author

Bartok9 commented Jun 2, 2026

Pushed a small test to green up CI. The only failing job was coverage (the check aggregate was failing solely because of it) — the 100% gate flagged _parts_manager.py:533, the isinstance(existing_part, ToolReturnPart) short-circuit in _resolve_provider_name.

That branch is defensive — no public caller passes a ToolReturnPart as existing_part (the text/thinking/tool-call paths all narrow the type first), so nothing exercised it. Added test_resolve_provider_name_tool_return_part asserting the resolver returns the incoming provider_name for a ToolReturnPart (which carries none of its own) and None when none is passed.

Locally: line 533 now covered, ruff/ruff format/pyright all clean. Thanks @dsfaccini for the bedrock/otel follow-ups. 🤝

- exercise `_estimate_usage` via a public `Agent(FunctionModel)` run instead of
  importing the private function, so the test goes through the public API
- correct the test docstring: a base `ToolReturnPart` reaches a `ModelResponse`
  only via user-constructed/deserialized history, not framework production
- hoist the function-local `ToolReturnPart` import to the module-top import block
Copy link
Copy Markdown

@biswajeetdev biswajeetdev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix correctly closes the gap: ToolReturnPart is a valid member of the ModelResponsePart union (representing a tool result in user-constructed message history) but was missing from the discriminated union definition, causing deserialization failures when a persisted conversation contained one.

Three places touched, all consistent:

  • messages.py — adds ToolReturnPart to the union with its discriminator tag
  • _parts_manager.py — short-circuits _resolve_provider_name since ToolReturnPart carries no provider_name; returning the incoming provider_name unchanged is correct
  • _agent_graph.pypass on ToolReturnPart in the stream iterator since user-defined tool returns in pre-constructed history produce no streamed event

The # pragma: no cover on the _agent_graph.py branch is honest — this path requires user-constructed message history with a ToolReturnPart, which is hard to reach via the normal test harness. A note in the PR or a follow-up issue tracking a dedicated integration test for this path would be helpful, so it does not stay permanently uncovered.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Report that something isn't working, or PR implementing a fix size: M Medium PR (101-500 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[roundtrip-sweep] ModelResponsePart: ToolReturnPart with part_kind='tool-return' missing from discriminated union — breaks message history round-trip

3 participants