You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[roundtrip-sweep] ModelMessagesTypeAdapter: NativeToolReturnPart placed in ModelRequest.parts silently fails JSON round-trip (different gap fr
[Content truncated due to length] #8234
A NativeToolReturnPart placed inside a ModelRequest.parts cannot survive a JSON round-trip through ModelMessagesTypeAdapter:
dump_json succeeds (with PydanticSerializationUnexpectedValuewarnings, because the request-side union doesn't list the part type).
The resulting JSON carries part_kind: 'builtin-tool-return' and 'timestamp': ..., and the content/tool-id/provider_name all serialize.
validate_json then raises pydantic_core.ValidationError: 1 validation error for list[tagged-union[ModelRequest,ModelResponse]] — 1.request.parts.0 — Input tag 'builtin-tool-return' found using _model_request_part_discriminator() does not match any of the expected tags.
Affected paths that produceModelRequest(parts=[NativeToolReturnPart(...)]) in real code:
pydantic_ai/ui/ag_ui/_adapter.py:533 — when an AG-UI client sends a ToolMessage whose toolCallId carries the BUILTIN_TOOL_CALL_ID_PREFIX (i.e. a server-side native tool return), the adapter builds a NativeToolReturnPart and adds it to a ModelRequest. Anyone who then reads AgentRun.all_messages_json() / new_messages_json() and re-loads (e.g. logfire exporters, Temporal payload converters, downstream consumers) hits this gap.
Any user code that constructs such a history directly and tries to feed it back via result.new_messages() / message_history=.
The response-side path is fine: ModelResponse(parts=[NativeToolReturnPart(...)]) round-trips cleanly today (the tag is registered at messages.py:2756). Only the request-side path is broken.
Boundary & Code Path
Serializer/Deserializer: ModelMessagesTypeAdapter (defined at pydantic_ai_slim/pydantic_ai/messages.py:2996).
Union missing the tag: ModelRequestPart at pydantic_ai_slim/pydantic_ai/messages.py:2698 — its 8 members are system-prompt | user-prompt | speech | tool-search-return | capability-load-return | tool-return | retry-prompt | tool-availability-delta. The builtin-tool-return tag is not among them.
Mirror union (correct one): ModelResponsePart at messages.py:2756 does include Annotated[NativeToolReturnPart, pydantic.Tag('builtin-tool-return')] — this is the pattern the request side is missing.
Maintainer acknowledgment of the intent: NativeToolReturnPart.narrow_type's docstring at messages.py:1725-1729 literally says "keeping it on a base part would break a ModelMessagesTypeAdapter round-trip" — the runtime already wants this to round-trip; the discriminator just isn't wired up symmetrically.
Reproduction
Drop this into tests/test_messages.py — it fails today on the JSON round-trip with the same ValidationError documented above:
deftest_native_tool_return_part_in_model_request_roundtrips():
"""`NativeToolReturnPart` is registered in `ModelResponsePart` (tag `builtin-tool-return`) but omitted from `ModelRequestPart`. Real AG-UI clients can build a `ModelRequest` carrying it — see `ag_ui/_adapter.py:533` — so dumping such a history (e.g. `all_messages_json()`) and re-validating raises `ValidationError: Input tag 'builtin-tool-return' does not match any of the expected tags` instead of round-tripping cleanly. Construction and response-side round-trips work; only the request path is broken. See: `pydantic_ai/messages.py:2698` for `ModelRequestPart` (gap) and `:2756` for `ModelResponsePart` (registration). """messages: list[ModelMessage] = [
ModelResponse(
parts=[
NativeToolCallPart(
tool_name='web_search',
args={'query': 'pydantic-ai'},
tool_call_id='ws_1',
provider_name='openai',
)
]
),
ModelRequest(
parts=[
NativeToolReturnPart(
tool_name='web_search',
content={'results': [{'title': 'Pydantic AI', 'url': 'https://ai.pydantic.dev'}]},
tool_call_id='ws_1',
provider_name='openai',
)
]
),
]
withwarnings.catch_warnings():
warnings.simplefilter('ignore', category=UserWarning)
json_bytes=ModelMessagesTypeAdapter.dump_json(messages)
json_roundtripped=ModelMessagesTypeAdapter.validate_json(json_bytes)
assertjson_roundtripped==messages
pydantic_core._pydantic_core.ValidationError: 1 validation error for list[tagged-union[ModelRequest,ModelResponse]]
1.request.parts.0
Input tag 'builtin-tool-return' found using _model_request_part_discriminator() does not match
any of the expected tags: 'system-prompt', 'user-prompt', 'speech', 'tool-search-return',
'capability-load-return', 'tool-return', 'retry-prompt', 'tool-availability-delta'
[type=union_tag_invalid, input_value={'tool_name': 'web_search', ...,
'part_kind': 'builtin-tool-return'}, input_type=dict]
Expected vs Actual
Expected:ModelMessagesTypeAdapter.validate_json(ModelMessagesTypeAdapter.dump_json(messages)) == messages for any input the runtime can produce via AgentRun.all_messages().
Actual: A ValidationError aborts the round-trip whenever the request side carries a NativeToolReturnPart. The same JSON deserializes fine if the part is rebuilt as part of a ModelResponse (already registered at messages.py:2756).
Evidence
pydantic_ai_slim/pydantic_ai/messages.py:2698-2708 — ModelRequestPart declaration (8 tags, no builtin-tool-return).
pydantic_ai_slim/pydantic_ai/messages.py:2748-2762 — ModelResponsePart declaration (includes the tag at line 2756).
pydantic_ai_slim/pydantic_ai/messages.py:1718 — part_kind: Literal['builtin-tool-return'] = 'builtin-tool-return' on the base class.
pydantic_ai_slim/pydantic_ai/ui/ag_ui/_adapter.py:533 — produces NativeToolReturnPart inside a ModelRequest.
pydantic_ai_slim/pydantic_ai/messages.py:1725-1729 — docstring on narrow_type already states the round-trip intent.
pydantic_ai/run.py:171-192 / pydantic_ai/result.py:545-562 — AgentRun/StreamedRunResult JSON dump methods, which silently emit UserWarnings today and break on any consumer that tries to re-parse.
Adversarial review
Reproduced on main:ModelMessagesTypeAdapter.dump_json(...) produces ~390 bytes of JSON including part_kind: 'builtin-tool-return' for the request-side part; ModelMessagesTypeAdapter.validate_json(...) then raises pydantic_core.ValidationError. Concrete command + output above.
Existing tests checked:tests/test_messages.py covers ToolReturnPart, ToolSearchReturnPart, LoadCapabilityReturnPart, and NativeToolReturnPart inside ModelResponse (e.g. test_speech_part_serialization_roundtrip:2821, the round-trip cluster at :2742-2840), but no test places NativeToolReturnPart directly inside ModelRequest.parts. Across the full repo I found 30+ NativeToolReturnPart constructions, all on the response side. No existing test asserts the broken behaviour is intentional. Adding the Annotated[NativeToolReturnPart, pydantic.Tag('builtin-tool-return')] member to ModelRequestPart does not collide with any existing test assertion.
Ruled out by-design: No comment/docstring anywhere marks the request-side NativeToolReturnPart path as intentionally lossy. Maintainers' own docstring on narrow_type (messages.py:1725-1729) explicitly says keeping it on a base part would break the round-trip and signals they want it to round-trip. AG-UI adapter builds these parts into ModelRequests on the client path (ag_ui/_adapter.py:533) — the runtime does produce them. Conclusion: unintentional one-sided gap, not a design choice.
Impact
A
NativeToolReturnPartplaced inside aModelRequest.partscannot survive a JSON round-trip throughModelMessagesTypeAdapter:dump_jsonsucceeds (withPydanticSerializationUnexpectedValuewarnings, because the request-side union doesn't list the part type).part_kind: 'builtin-tool-return'and'timestamp': ..., and the content/tool-id/provider_name all serialize.validate_jsonthen raisespydantic_core.ValidationError: 1 validation error for list[tagged-union[ModelRequest,ModelResponse]] — 1.request.parts.0 — Input tag 'builtin-tool-return' found using _model_request_part_discriminator() does not match any of the expected tags.Affected paths that produce
ModelRequest(parts=[NativeToolReturnPart(...)])in real code:pydantic_ai/ui/ag_ui/_adapter.py:533— when an AG-UI client sends aToolMessagewhosetoolCallIdcarries theBUILTIN_TOOL_CALL_ID_PREFIX(i.e. a server-side native tool return), the adapter builds aNativeToolReturnPartand adds it to aModelRequest. Anyone who then readsAgentRun.all_messages_json()/new_messages_json()and re-loads (e.g. logfire exporters, Temporal payload converters, downstream consumers) hits this gap.result.new_messages()/message_history=.The response-side path is fine:
ModelResponse(parts=[NativeToolReturnPart(...)])round-trips cleanly today (the tag is registered atmessages.py:2756). Only the request-side path is broken.Boundary & Code Path
ModelMessagesTypeAdapter(defined atpydantic_ai_slim/pydantic_ai/messages.py:2996).ModelRequestPartatpydantic_ai_slim/pydantic_ai/messages.py:2698— its 8 members aresystem-prompt | user-prompt | speech | tool-search-return | capability-load-return | tool-return | retry-prompt | tool-availability-delta. Thebuiltin-tool-returntag is not among them.ModelResponsePartatmessages.py:2756does includeAnnotated[NativeToolReturnPart, pydantic.Tag('builtin-tool-return')]— this is the pattern the request side is missing.NativeToolReturnPart.narrow_type's docstring atmessages.py:1725-1729literally says "keeping it on a base part would break aModelMessagesTypeAdapterround-trip" — the runtime already wants this to round-trip; the discriminator just isn't wired up symmetrically.Reproduction
Drop this into
tests/test_messages.py— it fails today on the JSON round-trip with the sameValidationErrordocumented above:Run:
Captured output (full failure):
Expected vs Actual
Expected:
ModelMessagesTypeAdapter.validate_json(ModelMessagesTypeAdapter.dump_json(messages)) == messagesfor any input the runtime can produce viaAgentRun.all_messages().Actual: A
ValidationErroraborts the round-trip whenever the request side carries aNativeToolReturnPart. The same JSON deserializes fine if the part is rebuilt as part of aModelResponse(already registered atmessages.py:2756).Evidence
pydantic_ai_slim/pydantic_ai/messages.py:2698-2708—ModelRequestPartdeclaration (8 tags, nobuiltin-tool-return).pydantic_ai_slim/pydantic_ai/messages.py:2748-2762—ModelResponsePartdeclaration (includes the tag at line 2756).pydantic_ai_slim/pydantic_ai/messages.py:1718—part_kind: Literal['builtin-tool-return'] = 'builtin-tool-return'on the base class.pydantic_ai_slim/pydantic_ai/ui/ag_ui/_adapter.py:533— producesNativeToolReturnPartinside aModelRequest.pydantic_ai_slim/pydantic_ai/messages.py:1725-1729— docstring onnarrow_typealready states the round-trip intent.pydantic_ai/run.py:171-192/pydantic_ai/result.py:545-562—AgentRun/StreamedRunResultJSON dump methods, which silently emitUserWarnings today and break on any consumer that tries to re-parse.Adversarial review
main:ModelMessagesTypeAdapter.dump_json(...)produces ~390 bytes of JSON includingpart_kind: 'builtin-tool-return'for the request-side part;ModelMessagesTypeAdapter.validate_json(...)then raisespydantic_core.ValidationError. Concrete command + output above.tests/test_messages.pycoversToolReturnPart,ToolSearchReturnPart,LoadCapabilityReturnPart, andNativeToolReturnPartinsideModelResponse(e.g.test_speech_part_serialization_roundtrip:2821, the round-trip cluster at:2742-2840), but no test placesNativeToolReturnPartdirectly insideModelRequest.parts. Across the full repo I found 30+NativeToolReturnPartconstructions, all on the response side. No existing test asserts the broken behaviour is intentional. Adding theAnnotated[NativeToolReturnPart, pydantic.Tag('builtin-tool-return')]member toModelRequestPartdoes not collide with any existing test assertion.NativeToolReturnPartpath as intentionally lossy. Maintainers' own docstring onnarrow_type(messages.py:1725-1729) explicitly says keeping it on a base part would break the round-trip and signals they want it to round-trip. AG-UI adapter builds these parts intoModelRequests on the client path (ag_ui/_adapter.py:533) — the runtime does produce them. Conclusion: unintentional one-sided gap, not a design choice.roundtrip-sweepissue: label-filtered scan of/tmp/gh-aw/agent/github-context/open-issues.jsonreturned issues [roundtrip-sweep]ModelMessagesTypeAdapter:tool_kind='tool-search'return part with non-typed content fails JSON round-trip (ValidationError...) #7211, [roundtrip-sweep]ModelMessagesTypeAdapter:tool_kind='capability-load'return part silently emptiescontenton round-trip [Content truncated due to length] #7805, [roundtrip-sweep]ModelMessagesTypeAdapter: typedtool_kindnarrowers (tool-searchcall/return,capability-loadcall) silently drop extra [Content truncated due to length] #7929, [roundtrip-sweep] ModelMessagesTypeAdapter: LoadCapabilityReturnPart.content silently drops unknown keys on round-trip (gap in PR #7933 fix) #8002, [roundtrip-sweep] ModelMessagesTypeAdapter:InstructionPartis missing fromModelRequestPartdiscriminator, so it fails round-trip when placed [Content truncated due to length] #8143. Their titles cover:tool_kind='tool-search'return non-typed content ([roundtrip-sweep]ModelMessagesTypeAdapter:tool_kind='tool-search'return part with non-typed content fails JSON round-trip (ValidationError...) #7211);tool_kind='capability-load'return empties content ([roundtrip-sweep]ModelMessagesTypeAdapter:tool_kind='capability-load'return part silently emptiescontenton round-trip [Content truncated due to length] #7805); typedtool_kindnarrowers dropping extra keys ([roundtrip-sweep]ModelMessagesTypeAdapter: typedtool_kindnarrowers (tool-searchcall/return,capability-loadcall) silently drop extra [Content truncated due to length] #7929);LoadCapabilityReturnPart.contentdropping unknown keys ([roundtrip-sweep] ModelMessagesTypeAdapter: LoadCapabilityReturnPart.content silently drops unknown keys on round-trip (gap in PR #7933 fix) #8002);InstructionPartmissing fromModelRequestPart([roundtrip-sweep] ModelMessagesTypeAdapter:InstructionPartis missing fromModelRequestPartdiscriminator, so it fails round-trip when placed [Content truncated due to length] #8143). None of these namesNativeToolReturnPartor the'builtin-tool-return'tag. Open PR Preserve extra keys intool_searchandload_capabilitytyped message parts #7933 ("Preserve extra keys intool_searchandload_capabilitytyped message parts") addresses the extra-keys cluster ([roundtrip-sweep]ModelMessagesTypeAdapter: typedtool_kindnarrowers (tool-searchcall/return,capability-loadcall) silently drop extra [Content truncated due to length] #7929/[roundtrip-sweep] ModelMessagesTypeAdapter: LoadCapabilityReturnPart.content silently drops unknown keys on round-trip (gap in PR #7933 fix) #8002) but not the discriminator gap. Issue [roundtrip-sweep] ModelMessagesTypeAdapter:InstructionPartis missing fromModelRequestPartdiscriminator, so it fails round-trip when placed [Content truncated due to length] #8143 (the closest cousin) is about a different part class (InstructionPart, tag'instruction'); fixing it does not address thisNativeToolReturnPartgap.