-
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
First problem: if a tool call hasn't been approved the CallToolsNode.stream will still include a FunctionToolCallEvent for it, as if it w was being executed. There's no corresponding FunctionToolResultEvent event and the call doesn't actually happen but if you e.g. use FunctionToolCallEvent to stream what's going on to the user it will appear as if the call is happening.
Second problem: it's weird that there's a CallToolsNode node at all. I'd expect us to immediately get an End. Is this because you're required to provide deferred_tool_results if message_history contains unapproved calls? I guess that could make sense but then we'd have to store some data to the side (i.e. we'd have to serialize state in addition to message_history) to remember that the previous End we got included DeferredToolRequests. Alternatively the programmer would have to inspect message_history for unapproved calls manually to make sure that Agent.iter isn't called at all if we have unapproved calls.
Example Code
import asyncio
from pydantic_ai import (
Agent,
DeferredToolRequests,
ModelMessage,
ModelRequest,
ModelResponse,
ToolCallPart,
UserPromptPart,
)
from pydantic_ai.models.function import AgentInfo, FunctionModel
def _should_not_call_model(
messages: list[ModelMessage], info: AgentInfo
) -> ModelResponse:
del messages # Unused.
del info # Unused.
raise ValueError('The agent was not supposed to call the model.')
agent = Agent(
model=FunctionModel(function=_should_not_call_model),
output_type=[str, DeferredToolRequests],
)
@agent.tool_plain(requires_approval=True)
def delete_file() -> None:
print('File deleted.')
async def main() -> None:
async with agent.iter(
message_history=[
ModelRequest(parts=[UserPromptPart(content='Hello')]),
ModelResponse(parts=[ToolCallPart(tool_name='delete_file')]),
],
) as run:
next_node = run.next_node
while not Agent.is_end_node(next_node):
print('Next node:', next_node)
if Agent.is_call_tools_node(next_node):
async with next_node.stream(run.ctx) as streamed_calls:
async for call_event in streamed_calls:
print('Tool call event:', call_event)
next_node = await run.next(next_node)
asyncio.run(main())The above prints:
Next node: UserPromptNode(user_prompt=None, instructions_functions=[], system_prompts=(), system_prompt_functions=[], system_prompt_dynamic_functions={})
Next node: CallToolsNode(model_response=ModelResponse(parts=[ToolCallPart(tool_name='delete_file', tool_call_id='pyd_ai_dd8fbddad95f402d8f2f6f0855012a7e')], usage=RequestUsage(), timestamp=datetime.datetime(2025, 11, 7, 13, 45, 7, 808720, tzinfo=datetime.timezone.utc)))
Tool call event: FunctionToolCallEvent(part=ToolCallPart(tool_name='delete_file', tool_call_id='pyd_ai_dd8fbddad95f402d8f2f6f0855012a7e'))
I'd expect no tool call events as no tools have been approved.
Python, Pydantic AI & LLM client version
Python: 3.13
pydantic-ai: 1.12.0
LLM client version: N/A/