Skip to content

Commit a0e8b83

Browse files
authored
Add tool timeout support (#3594)
1 parent b2cbbea commit a0e8b83

File tree

8 files changed

+418
-8
lines changed

8 files changed

+418
-8
lines changed

docs/tools-advanced.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,38 @@ def my_flaky_tool(query: str) -> str:
371371

372372
Raising `ModelRetry` also generates a `RetryPromptPart` containing the exception message, which is sent back to the LLM to guide its next attempt. Both `ValidationError` and `ModelRetry` respect the `retries` setting configured on the `Tool` or `Agent`.
373373

374+
### Tool Timeout
375+
376+
You can set a timeout for tool execution to prevent tools from running indefinitely. If a tool exceeds its timeout, it is treated as a failure and a retry prompt is sent to the model (counting towards the retry limit).
377+
378+
```python
379+
import asyncio
380+
381+
from pydantic_ai import Agent
382+
383+
# Set a default timeout for all tools on the agent
384+
agent = Agent('test', tool_timeout=30)
385+
386+
387+
@agent.tool_plain
388+
async def slow_tool() -> str:
389+
"""This tool will use the agent's default timeout (30 seconds)."""
390+
await asyncio.sleep(10)
391+
return 'Done'
392+
393+
394+
@agent.tool_plain(timeout=5)
395+
async def fast_tool() -> str:
396+
"""This tool has its own timeout (5 seconds) that overrides the agent default."""
397+
await asyncio.sleep(1)
398+
return 'Done'
399+
```
400+
401+
- **Agent-level timeout**: Set `tool_timeout` on the [`Agent`][pydantic_ai.Agent] to apply a default timeout to all tools.
402+
- **Per-tool timeout**: Set `timeout` on individual tools via [`@agent.tool`][pydantic_ai.Agent.tool], [`@agent.tool_plain`][pydantic_ai.Agent.tool_plain], or the [`Tool`][pydantic_ai.tools.Tool] dataclass. This overrides the agent-level default.
403+
404+
When a timeout occurs, the tool is considered to have failed and the model receives a retry prompt with the message `"Timed out after {timeout} seconds."`. This counts towards the tool's retry limit just like validation errors or explicit [`ModelRetry`][pydantic_ai.exceptions.ModelRetry] exceptions.
405+
374406
### Parallel tool calls & concurrency
375407

376408
When a model returns multiple tool calls in one response, Pydantic AI schedules them concurrently using `asyncio.create_task`.

pydantic_ai_slim/pydantic_ai/_tool_manager.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,7 @@ async def _call_tool(
172172
call.args or {}, allow_partial=pyd_allow_partial, context=ctx.validation_context
173173
)
174174

175-
result = await self.toolset.call_tool(name, args_dict, ctx, tool)
176-
177-
return result
175+
return await self.toolset.call_tool(name, args_dict, ctx, tool)
178176
except (ValidationError, ModelRetry) as e:
179177
max_retries = tool.max_retries if tool is not None else 1
180178
current_retry = self.ctx.retries.get(name, 0)

pydantic_ai_slim/pydantic_ai/agent/__init__.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ class Agent(AbstractAgent[AgentDepsT, OutputDataT]):
152152
_prepare_output_tools: ToolsPrepareFunc[AgentDepsT] | None = dataclasses.field(repr=False)
153153
_max_result_retries: int = dataclasses.field(repr=False)
154154
_max_tool_retries: int = dataclasses.field(repr=False)
155+
_tool_timeout: float | None = dataclasses.field(repr=False)
155156
_validation_context: Any | Callable[[RunContext[AgentDepsT]], Any] = dataclasses.field(repr=False)
156157

157158
_event_stream_handler: EventStreamHandler[AgentDepsT] | None = dataclasses.field(repr=False)
@@ -184,6 +185,7 @@ def __init__(
184185
instrument: InstrumentationSettings | bool | None = None,
185186
history_processors: Sequence[HistoryProcessor[AgentDepsT]] | None = None,
186187
event_stream_handler: EventStreamHandler[AgentDepsT] | None = None,
188+
tool_timeout: float | None = None,
187189
) -> None: ...
188190

189191
@overload
@@ -211,6 +213,7 @@ def __init__(
211213
instrument: InstrumentationSettings | bool | None = None,
212214
history_processors: Sequence[HistoryProcessor[AgentDepsT]] | None = None,
213215
event_stream_handler: EventStreamHandler[AgentDepsT] | None = None,
216+
tool_timeout: float | None = None,
214217
) -> None: ...
215218

216219
def __init__(
@@ -236,6 +239,7 @@ def __init__(
236239
instrument: InstrumentationSettings | bool | None = None,
237240
history_processors: Sequence[HistoryProcessor[AgentDepsT]] | None = None,
238241
event_stream_handler: EventStreamHandler[AgentDepsT] | None = None,
242+
tool_timeout: float | None = None,
239243
**_deprecated_kwargs: Any,
240244
):
241245
"""Create an agent.
@@ -290,6 +294,9 @@ def __init__(
290294
Each processor takes a list of messages and returns a modified list of messages.
291295
Processors can be sync or async and are applied in sequence.
292296
event_stream_handler: Optional handler for events from the model's streaming response and the agent's execution of tools.
297+
tool_timeout: Default timeout in seconds for tool execution. If a tool takes longer than this,
298+
the tool is considered to have failed and a retry prompt is returned to the model (counting towards the retry limit).
299+
Individual tools can override this with their own timeout. Defaults to None (no timeout).
293300
"""
294301
if model is None or defer_model_check:
295302
self._model = model
@@ -323,6 +330,7 @@ def __init__(
323330

324331
self._max_result_retries = output_retries if output_retries is not None else retries
325332
self._max_tool_retries = retries
333+
self._tool_timeout = tool_timeout
326334

327335
self._validation_context = validation_context
328336

@@ -336,7 +344,10 @@ def __init__(
336344
self._output_toolset.max_retries = self._max_result_retries
337345

338346
self._function_toolset = _AgentFunctionToolset(
339-
tools, max_retries=self._max_tool_retries, output_schema=self._output_schema
347+
tools,
348+
max_retries=self._max_tool_retries,
349+
timeout=self._tool_timeout,
350+
output_schema=self._output_schema,
340351
)
341352
self._dynamic_toolsets = [
342353
DynamicToolset[AgentDepsT](toolset_func=toolset)
@@ -1036,6 +1047,7 @@ def tool(
10361047
sequential: bool = False,
10371048
requires_approval: bool = False,
10381049
metadata: dict[str, Any] | None = None,
1050+
timeout: float | None = None,
10391051
) -> Callable[[ToolFuncContext[AgentDepsT, ToolParams]], ToolFuncContext[AgentDepsT, ToolParams]]: ...
10401052

10411053
def tool(
@@ -1054,6 +1066,7 @@ def tool(
10541066
sequential: bool = False,
10551067
requires_approval: bool = False,
10561068
metadata: dict[str, Any] | None = None,
1069+
timeout: float | None = None,
10571070
) -> Any:
10581071
"""Decorator to register a tool function which takes [`RunContext`][pydantic_ai.tools.RunContext] as its first argument.
10591072
@@ -1103,6 +1116,8 @@ async def spam(ctx: RunContext[str], y: float) -> float:
11031116
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
11041117
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
11051118
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
1119+
timeout: Timeout in seconds for tool execution. If the tool takes longer, a retry prompt is returned to the model.
1120+
Overrides the agent-level `tool_timeout` if set. Defaults to None (no timeout).
11061121
"""
11071122

11081123
def tool_decorator(
@@ -1123,6 +1138,7 @@ def tool_decorator(
11231138
sequential=sequential,
11241139
requires_approval=requires_approval,
11251140
metadata=metadata,
1141+
timeout=timeout,
11261142
)
11271143
return func_
11281144

@@ -1147,6 +1163,7 @@ def tool_plain(
11471163
sequential: bool = False,
11481164
requires_approval: bool = False,
11491165
metadata: dict[str, Any] | None = None,
1166+
timeout: float | None = None,
11501167
) -> Callable[[ToolFuncPlain[ToolParams]], ToolFuncPlain[ToolParams]]: ...
11511168

11521169
def tool_plain(
@@ -1165,6 +1182,7 @@ def tool_plain(
11651182
sequential: bool = False,
11661183
requires_approval: bool = False,
11671184
metadata: dict[str, Any] | None = None,
1185+
timeout: float | None = None,
11681186
) -> Any:
11691187
"""Decorator to register a tool function which DOES NOT take `RunContext` as an argument.
11701188
@@ -1214,6 +1232,8 @@ async def spam(ctx: RunContext[str]) -> float:
12141232
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
12151233
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
12161234
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
1235+
timeout: Timeout in seconds for tool execution. If the tool takes longer, a retry prompt is returned to the model.
1236+
Overrides the agent-level `tool_timeout` if set. Defaults to None (no timeout).
12171237
"""
12181238

12191239
def tool_decorator(func_: ToolFuncPlain[ToolParams]) -> ToolFuncPlain[ToolParams]:
@@ -1232,6 +1252,7 @@ def tool_decorator(func_: ToolFuncPlain[ToolParams]) -> ToolFuncPlain[ToolParams
12321252
sequential=sequential,
12331253
requires_approval=requires_approval,
12341254
metadata=metadata,
1255+
timeout=timeout,
12351256
)
12361257
return func_
12371258

@@ -1409,7 +1430,10 @@ def toolsets(self) -> Sequence[AbstractToolset[AgentDepsT]]:
14091430

14101431
if some_tools := self._override_tools.get():
14111432
function_toolset = _AgentFunctionToolset(
1412-
some_tools.value, max_retries=self._max_tool_retries, output_schema=self._output_schema
1433+
some_tools.value,
1434+
max_retries=self._max_tool_retries,
1435+
timeout=self._tool_timeout,
1436+
output_schema=self._output_schema,
14131437
)
14141438
else:
14151439
function_toolset = self._function_toolset
@@ -1516,11 +1540,12 @@ def __init__(
15161540
tools: Sequence[Tool[AgentDepsT] | ToolFuncEither[AgentDepsT, ...]] = [],
15171541
*,
15181542
max_retries: int = 1,
1543+
timeout: float | None = None,
15191544
id: str | None = None,
15201545
output_schema: _output.OutputSchema[Any],
15211546
):
15221547
self.output_schema = output_schema
1523-
super().__init__(tools, max_retries=max_retries, id=id)
1548+
super().__init__(tools, max_retries=max_retries, timeout=timeout, id=id)
15241549

15251550
@property
15261551
def id(self) -> str:

pydantic_ai_slim/pydantic_ai/tools.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ class Tool(Generic[ToolAgentDepsT]):
273273
sequential: bool
274274
requires_approval: bool
275275
metadata: dict[str, Any] | None
276+
timeout: float | None
276277
function_schema: _function_schema.FunctionSchema
277278
"""
278279
The base JSON schema for the tool's parameters.
@@ -296,6 +297,7 @@ def __init__(
296297
sequential: bool = False,
297298
requires_approval: bool = False,
298299
metadata: dict[str, Any] | None = None,
300+
timeout: float | None = None,
299301
function_schema: _function_schema.FunctionSchema | None = None,
300302
):
301303
"""Create a new tool instance.
@@ -352,6 +354,8 @@ async def prep_my_tool(
352354
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
353355
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
354356
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
357+
timeout: Timeout in seconds for tool execution. If the tool takes longer, a retry prompt is returned to the model.
358+
Defaults to None (no timeout).
355359
function_schema: The function schema to use for the tool. If not provided, it will be generated.
356360
"""
357361
self.function = function
@@ -373,6 +377,7 @@ async def prep_my_tool(
373377
self.sequential = sequential
374378
self.requires_approval = requires_approval
375379
self.metadata = metadata
380+
self.timeout = timeout
376381

377382
@classmethod
378383
def from_schema(
@@ -428,6 +433,7 @@ def tool_def(self):
428433
strict=self.strict,
429434
sequential=self.sequential,
430435
metadata=self.metadata,
436+
timeout=self.timeout,
431437
kind='unapproved' if self.requires_approval else 'function',
432438
)
433439

@@ -514,6 +520,13 @@ class ToolDefinition:
514520
For MCP tools, this contains the `meta`, `annotations`, and `output_schema` fields from the tool definition.
515521
"""
516522

523+
timeout: float | None = None
524+
"""Timeout in seconds for tool execution.
525+
526+
If the tool takes longer than this, a retry prompt is returned to the model.
527+
Defaults to None (no timeout).
528+
"""
529+
517530
@property
518531
def defer(self) -> bool:
519532
"""Whether calls to this tool will be deferred.

0 commit comments

Comments
 (0)