-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
Initial Checks
- I confirm that I'm using the latest version of Pydantic AI
- I confirm that I searched for my issue in https://github.com/pydantic/pydantic-ai/issues before opening this issue
Description
In pydantic-ai v1.9+, the parent_message_id field in TOOL_CALL_START SSE events incorrectly points to TOOL_CALL_RESULT message IDs (the most recent message in the stream) instead of the assistant message that requested the tool call. This causes ag-ui clients to reconstruct an invalid conversation structure where tool result messages have toolCalls arrays, leading to schema validation failures with extra_forbidden errors.
Impact
- Severity: Critical - breaks round-trip ag-ui communication
- Affected versions: pydantic-ai >= 1.9
- Error message:
messages.N.tool.toolCalls: Extra inputs are not permitted [type=extra_forbidden] - Root cause: AG-UI integration rewrite in v1.9
The Bug Explained
Expected Behavior
Each TOOL_CALL_START event should have parent_message_id pointing to the assistant TEXT_MESSAGE_START message that requested the tool call.
Correct structure:
TEXT_MESSAGE_START (id: A, role: assistant)
└─ TOOL_CALL_START (parent: A) ✓ Correct
└─ TOOL_CALL_RESULT (id: B, role: tool)
└─ TEXT_MESSAGE_START (id: C, role: assistant)
└─ TOOL_CALL_START (parent: C) ✓ Correct
Actual Behavior in v1.9+
parent_message_id is set to the most recent message ID in the stream (regardless of message type), which is often a TOOL_CALL_RESULT.
Broken structure in v1.9+:
TEXT_MESSAGE_START (id: A, role: assistant)
└─ TOOL_CALL_START (parent: A) ✓ First call is correct
└─ TOOL_CALL_RESULT (id: B, role: tool) ← Most recent message!
└─ TEXT_MESSAGE_START (id: C, role: assistant)
└─ TOOL_CALL_START (parent: B) ✗ Points to tool result!
How This Breaks
-
Stream generation: pydantic-ai emits
TOOL_CALL_STARTwithparent: B(a tool result) -
Client reconstruction: ag-ui frontend sees tool call pointing to message B and builds:
{ "messages": [ {"id": "A", "role": "assistant", "toolCalls": [{"id": "tool-1"}]}, {"id": "B", "role": "tool", "toolCallId": "tool-1", "toolCalls": [{"id": "tool-2"}]}, ← INVALID! {"id": "C", "role": "assistant"} ] } -
Validation failure: When sent back to server:
messages.1.tool.toolCalls: Extra inputs are not permitted [type=extra_forbidden]Tool messages cannot have
toolCallsarrays per the schema.
Version Comparison
v1.6-1.8 (Imperfect but harmless)
Tool calls after the first round used internally-generated parent IDs that didn't appear in the SSE stream. ag-ui clients ignored these phantom IDs. No validation errors occurred.
Example from v1.6:
Line 7: TEXT_MESSAGE_START (id: 5f9e63b3..., role: assistant)
Line 95: TOOL_CALL_START (parent: 5f9e63b3...) ✓
Line 119: TOOL_CALL_RESULT (id: cf4253d1...)
Line 127: TOOL_CALL_START (parent: d98f8f8f...) ← Phantom ID, harmless
v1.9+ (Critical regression)
Tool calls point to real TOOL_CALL_RESULT message IDs that exist in the stream, causing clients to build invalid structures.
Example from v1.9.1:
Line 7: TEXT_MESSAGE_START (id: 125aa36e..., role: assistant)
Line 67: TOOL_CALL_START (parent: 125aa36e...) ✓
Line 131: TOOL_CALL_RESULT (id: 0174d851...)
Line 135: TOOL_CALL_RESULT (id: 27c6d89f...) ← Most recent!
Line 143: TOOL_CALL_START (parent: 27c6d89f...) ✗ Points to tool result!
The Fix
The ag-ui event emission code needs to:
- Track the current assistant message ID separately from "most recent message ID"
- When
TEXT_MESSAGE_STARTis emitted withrole=assistant, save this as the "current assistant message ID" - Use the "current assistant message ID" (not "most recent message ID") as
parent_message_idfor all subsequentTOOL_CALL_STARTevents - Do NOT update this tracked ID when
TOOL_CALL_RESULTor other non-assistant messages are emitted
Pseudocode:
current_assistant_id = None
for event in agent_run:
if event.type == "TEXT_MESSAGE_START" and event.role == "assistant":
current_assistant_id = event.message_id
elif event.type == "TOOL_CALL_START":
# Use tracked assistant ID, not most recent message ID
event.parent_message_id = current_assistant_idReproduction
Minimal Reproduction Script
see example code
Expected Output
When run with pydantic-ai v1.9.1:
✓ ASSISTANT: 067205ce-2dd3-483e-a27e-0855a0c739fe
└─ TOOL_CALL: add (parent: 854a99b7-6f94-4c99-b6cf-fb6d09f7882d)
RESULT: 1bcaf5a2-cf4d-4ca2-9fa1-64dbddd58b98
└─ TOOL_CALL: multiply (parent: 1bcaf5a2-cf4d-4ca2-9fa1-64dbddd58b98)
RESULT: 3f21dd90-5b02-4fe9-87e1-34400a1fedf6
======================================================================
BUG CHECK
======================================================================
❌ CRITICAL BUG: Tool call #2 (multiply)
parent_message_id: 1bcaf5a2-cf4d-4ca2-9fa1-64dbddd58b98
This points to a TOOL_CALL_RESULT message!
This causes 'extra_forbidden' validation errors.
The second tool call's parent points to the first tool's result message (the most recent message at that point), not to an assistant message.
Additional Evidence
We have captured real production SSE streams showing:
v1.6 output (60KB): Shows phantom parent IDs that don't cause issues
v1.9.1 output (57KB): Shows parent IDs pointing to tool results, breaking validation
Key differences in the streams demonstrate the exact line where parent_message_id switches from pointing to an assistant to pointing to a tool result.
Test Case
A proper test would:
- Run an agent with a prompt that triggers multiple rounds of tool calls
- Collect all SSE events from the stream
- Extract all
TOOL_CALL_STARTevents and theirparent_message_idvalues - Extract all
TEXT_MESSAGE_START(assistant) andTOOL_CALL_RESULTmessage IDs - Verify that each
parent_message_idmatches aTEXT_MESSAGE_STARTevent, not aTOOL_CALL_RESULT - Fail the test if any tool call points to a tool result as its parent
Related Information
- Bug introduced: v1.9 (ag-ui integration rewrite)
- Affected: All versions >= 1.9
- Last working version: v1.8
Example Code
"""
Minimal reproduction of pydantic-ai v1.9+ parent_message_id bug.
To reproduce:
1. Install: pip install "pydantic-ai[ag-ui]"
2. Set OPENAI_API_KEY environment variable
"""
from __future__ import annotations
import asyncio
from ag_ui import core as ag_ui_core
from pydantic_ai import Agent
from pydantic_ai.ag_ui import run_ag_ui
from dotenv import load_dotenv
load_dotenv()
async def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
async def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b
async def main():
print("="*70)
print("Pydantic AI v1.9+ parent_message_id Bug Reproduction")
print("="*70)
agent = Agent(
"openai:gpt-4o-mini",
tools=[add, multiply],
system_prompt="You are a calculator. Use the tools to compute results.",
)
run_input = ag_ui_core.RunAgentInput(
thread_id="test",
run_id="test",
messages=[ag_ui_core.UserMessage(
id="user-1",
role="user",
content="Calculate 2+2, then multiply that result by 3"
)],
tools=[], context=[], forwarded_props={}, state={}
)
print("\nTracking ASSISTANT messages and TOOL_CALL events:\n")
assistant_messages = []
tool_result_messages = []
tool_calls = []
async for event_str in run_ag_ui(agent, run_input):
if event_str.startswith("data: "):
import json
try:
event_data = json.loads(event_str[6:])
event_type = event_data.get("type")
if event_type == "TEXT_MESSAGE_START":
msg_id = event_data.get("messageId")
if msg_id:
assistant_messages.append(msg_id)
print(f"✓ ASSISTANT: {msg_id}")
elif event_type == "TOOL_CALL_START":
tool_call_id = event_data.get("toolCallId")
parent_id = event_data.get("parentMessageId")
tool_name = event_data.get("toolCallName")
tool_calls.append({
"id": tool_call_id,
"parent": parent_id,
"name": tool_name
})
print(f" └─ TOOL_CALL: {tool_name} (parent: {parent_id})")
elif event_type == "TOOL_CALL_RESULT":
msg_id = event_data.get("messageId")
if msg_id:
tool_result_messages.append(msg_id)
print(f" RESULT: {msg_id}")
except:
pass
# Analysis
print("\n" + "="*70)
print("BUG CHECK")
print("="*70)
for i, tc in enumerate(tool_calls):
if tc['parent'] in tool_result_messages:
print(f"\n❌ CRITICAL BUG: Tool call #{i+1} ({tc['name']})")
print(f" parent_message_id: {tc['parent']}")
print(f" This points to a TOOL_CALL_RESULT message!")
print(f" This causes 'extra_forbidden' validation errors.")
break
elif tc['parent'] not in assistant_messages:
print(f"\n⚠️ Tool call #{i+1} has unknown parent (v1.6 style)")
print("\n" + "="*70)
if __name__ == "__main__":
asyncio.run(main())Python, Pydantic AI & LLM client version
Python 3.12
Pydantic 1.9.1
LLM openai:gpt-5-nano (but reproducible on ~any model)