Skip to content

tool_call_approved even though it isn't if run_step == 0Β #3350

@tibbe

Description

@tibbe

Initial Checks

Description

In one of my tests (which uses a single-stepping approach to running the agent) I was surprised when pydantic-ai claimed that a tool call was approved (i.e. run_ctx.tool_call_approved was true inside the function tool body) when it clearly wasn't. I eventually tracked down the cause to

tool_call_approved=ctx.state.run_step == 0,

I guess this above "approved if the first step" logic came from something like "the first message is never a tool call" but if you resume the agent from some previous state (e.g. pass a list of messages, the last one being a ModelResponse with a tool call) it can be!

There's no way that I can see to affect run_step upon creating the Agent or when calling e.g. agent.iter so there seems to be no way to work around this.

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)
            next_node = await run.next(next_node)


asyncio.run(main())

This outputs:

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_d0c067b087e2467794853ba0bdadc275')], usage=RequestUsage(), timestamp=datetime.datetime(2025, 11, 6, 9, 30, 54, 660072, tzinfo=datetime.timezone.utc)))
File deleted.
Next node: ModelRequestNode(request=ModelRequest(parts=[ToolReturnPart(tool_name='delete_file', content=None, tool_call_id='pyd_ai_d0c067b087e2467794853ba0bdadc275', timestamp=datetime.datetime(2025, 11, 6, 9, 30, 54, 670087, tzinfo=datetime.timezone.utc))]))

followed by raising the ValueError indicating that the model was called with the result of the (unapproved!) tool call.

Python, Pydantic AI & LLM client version

pydantic-ai: 1.11.0
Python: 3.13.5
LLM: N/A (happens even in tests with a dummy FunctionModel)

Metadata

Metadata

Assignees

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