Skip to content

Pydantic AI v1.9+ Bug: parent_message_id Regression Causes extra_forbidden Errors #3316

@marr75

Description

@marr75

Initial Checks

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

  1. Stream generation: pydantic-ai emits TOOL_CALL_START with parent: B (a tool result)

  2. 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"}
      ]
    }
  3. Validation failure: When sent back to server:

    messages.1.tool.toolCalls: Extra inputs are not permitted [type=extra_forbidden]
    

    Tool messages cannot have toolCalls arrays 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:

  1. Track the current assistant message ID separately from "most recent message ID"
  2. When TEXT_MESSAGE_START is emitted with role=assistant, save this as the "current assistant message ID"
  3. Use the "current assistant message ID" (not "most recent message ID") as parent_message_id for all subsequent TOOL_CALL_START events
  4. Do NOT update this tracked ID when TOOL_CALL_RESULT or 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_id

Reproduction

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:

  1. Run an agent with a prompt that triggers multiple rounds of tool calls
  2. Collect all SSE events from the stream
  3. Extract all TOOL_CALL_START events and their parent_message_id values
  4. Extract all TEXT_MESSAGE_START (assistant) and TOOL_CALL_RESULT message IDs
  5. Verify that each parent_message_id matches a TEXT_MESSAGE_START event, not a TOOL_CALL_RESULT
  6. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions