Skip to content

An extra ToolReturnPart is appended when streaming with ExternalToolSets #3091

@TheRobotCarlson

Description

@TheRobotCarlson

Initial Checks

Description

When streaming agent results that have an ExternalToolSet, an extra ToolReturnPart is appended with this content:

'Tool not executed - a final result was already processed.'

This is despite the call before that returning a finish_reason='tool_call'.

I've seen other issues around streaming problems, but none about this specific discrepancy.

Example Code

# /// script
# requires-python = ">=3.13"
# dependencies = [
#   "pydantic-ai-slim[openai]>=1.0.15",
# ]
# ///
"""Minimal reproduction for ExternalToolset/DeferredToolRequests issue.

When an agent with external tools returns DeferredToolRequests, an unexpected
ToolReturnPart is added to the message history.

Run with:
    uv run test_external_tools_bug.py
"""

from __future__ import annotations

import asyncio

from pydantic import BaseModel
from pydantic_ai import Agent, DeferredToolRequests, ToolDefinition
from pydantic_ai.messages import (
    ModelMessage,
    ModelRequest,
    ToolReturnPart,
    UserPromptPart,
)
from pydantic_ai.toolsets import ExternalToolset


class ChangeScreen(BaseModel):
    """Change to a different screen."""

    screen_name: str


external_tool = ToolDefinition(
    name="change_screen",
    description="Change to a different screen in the app.",
    parameters_json_schema=ChangeScreen.model_json_schema(),
    kind="external",
)

agent = Agent(
    "openai:gpt-4o-mini",
    output_type=[str, DeferredToolRequests],
    instructions="You are a helpful assistant. Use the change_screen tool when asked to navigate.",
    toolsets=[ExternalToolset(tool_defs=[external_tool])],
)


async def test_external_tool_bug() -> None:
    """Demonstrates unexpected ToolReturnPart in message history."""
    messages: list[ModelMessage] = [
        ModelRequest(parts=[UserPromptPart(content="Change to the dashboard screen")]),
    ]

    async with agent.run_stream(message_history=messages) as result:
        async for _update, _finished in result.stream_responses():
            pass
        messages.extend(result.new_messages())
        output = await result.get_output()

    print(f"Output type: {type(output).__name__}")
    print(f"Message count: {len(messages)}")
    print("\nMessage history:")
    for i, msg in enumerate(messages):
        print(f"  {i}. {type(msg).__name__}")
        for part in msg.parts:
            part_type = type(part).__name__
            if isinstance(part, ToolReturnPart):
                print(f"     - {part_type}: '{part.content}'")
            else:
                print(f"     - {part_type}")

    # Issue: An unexpected ToolReturnPart is added to the message history
    if isinstance(messages[-1], ModelRequest) and isinstance(
        messages[-1].parts[-1],
        ToolReturnPart,
    ):
        print(
            "\nIssue: Last message contains unexpected ToolReturnPart with "
            '"Tool not executed - a final result was already processed."',
        )


if __name__ == "__main__":
    asyncio.run(test_external_tool_bug())

Python, Pydantic AI & LLM client version

# requires-python = ">=3.13"
# dependencies = [
#   "pydantic-ai-slim[openai]>=1.0.15",
# ]

OpenAI client version:
version = "2.1.0"

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions