-
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
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)