-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Open
Labels
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
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"