Skip to content

FunctionToolCallEvent event is emitted for unapproved/deferred tool calls, and then again on resumption #3368

@tibbe

Description

@tibbe

Initial Checks

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/

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