Skip to content

Commit 96276fd

Browse files
DouweMclaude
andauthored
Document BuiltinOrLocalTool capability (#4828)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 41766d9 commit 96276fd

File tree

16 files changed

+117
-98
lines changed

16 files changed

+117
-98
lines changed

docs/capabilities.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,15 @@ Constraint fields like `allowed_domains` or `blocked_domains` require the builti
126126
WebSearch(allowed_domains=['example.com'])
127127
```
128128

129+
All of these capabilities are subclasses of [`BuiltinOrLocalTool`][pydantic_ai.capabilities.BuiltinOrLocalTool], which you can use directly or subclass to build your own provider-adaptive tools. For example, to pair [`CodeExecutionTool`][pydantic_ai.builtin_tools.CodeExecutionTool] with a local fallback:
130+
131+
```python {title="custom_builtin_or_local.py" test="skip" lint="skip"}
132+
from pydantic_ai.builtin_tools import CodeExecutionTool
133+
from pydantic_ai.capabilities import BuiltinOrLocalTool
134+
135+
cap = BuiltinOrLocalTool(builtin=CodeExecutionTool(), local=my_local_executor)
136+
```
137+
129138
### PrepareTools
130139

131140
[`PrepareTools`][pydantic_ai.capabilities.PrepareTools] wraps a [`ToolsPrepareFunc`][pydantic_ai.tools.ToolsPrepareFunc] as a capability, for filtering or modifying [tool definitions](tools.md) per step:

pydantic_ai_slim/pydantic_ai/agent/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
from .._tool_manager import ParallelExecutionMode, ToolManager
4848
from ..builtin_tools import AbstractBuiltinTool
4949
from ..capabilities import AbstractCapability, CombinedCapability
50-
from ..capabilities.builtin_or_local import BuiltinTool as BuiltinToolCap
50+
from ..capabilities.builtin_tool import BuiltinTool as BuiltinToolCap
5151
from ..capabilities.history_processor import HistoryProcessor as HistoryProcessorCap
5252
from ..models.instrumented import InstrumentationSettings, InstrumentedModel, instrument_model
5353
from ..output import OutputDataT, OutputSpec, StructuredDict

pydantic_ai_slim/pydantic_ai/capabilities/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
WrapToolExecuteHandler,
1313
WrapToolValidateHandler,
1414
)
15-
from .builtin_or_local import BuiltinOrLocalTool, BuiltinTool
15+
from .builtin_or_local import BuiltinOrLocalTool
16+
from .builtin_tool import BuiltinTool
1617
from .combined import CombinedCapability
1718
from .history_processor import HistoryProcessor
1819
from .hooks import Hooks, HookTimeoutError

pydantic_ai_slim/pydantic_ai/capabilities/abstract.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,17 @@ async def wrap_run(
197197
*,
198198
handler: WrapRunHandler,
199199
) -> AgentRunResult[Any]:
200-
"""Wraps the entire agent run. ``handler()`` executes the run.
200+
"""Wraps the entire agent run. `handler()` executes the run.
201201
202-
If ``handler()`` raises and this method catches the exception and
202+
If `handler()` raises and this method catches the exception and
203203
returns a result instead, the error is suppressed and the recovery
204204
result is used.
205205
206-
If this method does not call ``handler()`` (short-circuit), the run
206+
If this method does not call `handler()` (short-circuit), the run
207207
is skipped and the returned result is used directly.
208208
209209
Note: if the caller cancels the run (e.g. by breaking out of an
210-
``iter()`` loop), this method receives an :class:`asyncio.CancelledError`.
210+
`iter()` loop), this method receives an :class:`asyncio.CancelledError`.
211211
Implementations that hold resources should handle cleanup accordingly.
212212
"""
213213
return await handler()
@@ -222,15 +222,15 @@ async def on_run_error(
222222
223223
This is the error counterpart to
224224
[`after_run`][pydantic_ai.capabilities.AbstractCapability.after_run]:
225-
while ``after_run`` is called on success, ``on_run_error`` is called on
225+
while `after_run` is called on success, `on_run_error` is called on
226226
failure (after [`wrap_run`][pydantic_ai.capabilities.AbstractCapability.wrap_run]
227227
has had its chance to recover).
228228
229-
**Raise** the original ``error`` (or a different exception) to propagate it.
229+
**Raise** the original `error` (or a different exception) to propagate it.
230230
**Return** an [`AgentRunResult`][pydantic_ai.run.AgentRunResult] to suppress
231231
the error and recover the run.
232232
233-
Not called for ``GeneratorExit`` or ``KeyboardInterrupt``.
233+
Not called for `GeneratorExit` or `KeyboardInterrupt`.
234234
"""
235235
raise error
236236

@@ -252,7 +252,7 @@ async def after_node_run(
252252
node: AgentNode[AgentDepsT],
253253
result: NodeResult[AgentDepsT],
254254
) -> NodeResult[AgentDepsT]:
255-
"""Called after each graph node succeeds. Can modify the result (next node or ``End``)."""
255+
"""Called after each graph node succeeds. Can modify the result (next node or `End`)."""
256256
return result
257257

258258
async def wrap_node_run(
@@ -299,8 +299,8 @@ async def on_node_run_error(
299299
This is the error counterpart to
300300
[`after_node_run`][pydantic_ai.capabilities.AbstractCapability.after_node_run].
301301
302-
**Raise** the original ``error`` (or a different exception) to propagate it.
303-
**Return** a next node or ``End`` to recover and continue the graph.
302+
**Raise** the original `error` (or a different exception) to propagate it.
303+
**Return** a next node or `End` to recover and continue the graph.
304304
305305
Useful for recovering from
306306
[`UnexpectedModelBehavior`][pydantic_ai.exceptions.UnexpectedModelBehavior]
@@ -362,7 +362,7 @@ async def on_model_request_error(
362362
This is the error counterpart to
363363
[`after_model_request`][pydantic_ai.capabilities.AbstractCapability.after_model_request].
364364
365-
**Raise** the original ``error`` (or a different exception) to propagate it.
365+
**Raise** the original `error` (or a different exception) to propagate it.
366366
**Return** a [`ModelResponse`][pydantic_ai.messages.ModelResponse] to suppress
367367
the error and use the response as if the model call succeeded.
368368
@@ -422,7 +422,7 @@ async def on_tool_validate_error(
422422
Fires for [`ValidationError`][pydantic.ValidationError] (schema mismatch) and
423423
[`ModelRetry`][pydantic_ai.exceptions.ModelRetry] (custom validator rejection).
424424
425-
**Raise** the original ``error`` (or a different exception) to propagate it.
425+
**Raise** the original `error` (or a different exception) to propagate it.
426426
**Return** validated args to suppress the error and continue as if validation passed.
427427
428428
Not called for [`SkipToolValidation`][pydantic_ai.exceptions.SkipToolValidation].
@@ -480,7 +480,7 @@ async def on_tool_execute_error(
480480
This is the error counterpart to
481481
[`after_tool_execute`][pydantic_ai.capabilities.AbstractCapability.after_tool_execute].
482482
483-
**Raise** the original ``error`` (or a different exception) to propagate it.
483+
**Raise** the original `error` (or a different exception) to propagate it.
484484
**Return** any value to suppress the error and use it as the tool result.
485485
486486
Not called for control flow exceptions

pydantic_ai_slim/pydantic_ai/capabilities/builtin_or_local.py

Lines changed: 16 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,55 +4,15 @@
44
from dataclasses import dataclass, replace
55
from typing import Any, Literal
66

7-
import pydantic
8-
97
from pydantic_ai.builtin_tools import AbstractBuiltinTool
108
from pydantic_ai.exceptions import UserError
119
from pydantic_ai.tools import AgentBuiltinTool, AgentDepsT, RunContext, Tool, ToolDefinition
1210
from pydantic_ai.toolsets import AbstractToolset
11+
from pydantic_ai.toolsets.function import FunctionToolset
12+
from pydantic_ai.toolsets.prepared import PreparedToolset
1313

1414
from .abstract import AbstractCapability
1515

16-
_BUILTIN_TOOL_ADAPTER = pydantic.TypeAdapter(AbstractBuiltinTool)
17-
18-
19-
@dataclass
20-
class BuiltinTool(AbstractCapability[AgentDepsT]):
21-
"""A capability that registers a builtin tool with the agent.
22-
23-
Wraps a single [`AgentBuiltinTool`][pydantic_ai.tools.AgentBuiltinTool] — either a static
24-
[`AbstractBuiltinTool`][pydantic_ai.builtin_tools.AbstractBuiltinTool] instance or a callable
25-
that dynamically produces one.
26-
27-
When `builtin_tools` is passed to [`Agent.__init__`][pydantic_ai.Agent.__init__], each item is
28-
automatically wrapped in a `BuiltinTool` capability.
29-
"""
30-
31-
tool: AgentBuiltinTool[AgentDepsT]
32-
33-
def get_builtin_tools(self) -> Sequence[AgentBuiltinTool[AgentDepsT]]:
34-
return [self.tool]
35-
36-
@classmethod
37-
def from_spec(cls, tool: AbstractBuiltinTool | None = None, **kwargs: Any) -> BuiltinTool[Any]:
38-
"""Create from spec.
39-
40-
Supports two YAML forms:
41-
42-
- Flat: `{BuiltinTool: {kind: web_search, search_context_size: high}}`
43-
- Explicit: `{BuiltinTool: {tool: {kind: web_search}}}`
44-
"""
45-
if tool is not None:
46-
validated = _BUILTIN_TOOL_ADAPTER.validate_python(tool)
47-
elif kwargs:
48-
validated = _BUILTIN_TOOL_ADAPTER.validate_python(kwargs)
49-
else:
50-
raise TypeError(
51-
'`BuiltinTool.from_spec()` requires either a `tool` argument or keyword arguments'
52-
' specifying the builtin tool type (e.g. `kind="web_search"`)'
53-
)
54-
return cls(tool=validated)
55-
5616

5717
@dataclass
5818
class BuiltinOrLocalTool(AbstractCapability[AgentDepsT]):
@@ -61,8 +21,20 @@ class BuiltinOrLocalTool(AbstractCapability[AgentDepsT]):
6121
When the model supports the builtin natively, the local fallback is removed.
6222
When the model doesn't support the builtin, it is removed and the local tool stays.
6323
64-
Can be used directly by providing `builtin` and `local` arguments, or subclassed
65-
to set defaults via `_default_builtin`, `_default_local`, and `_requires_builtin`.
24+
Can be used directly:
25+
26+
```python {test="skip" lint="skip"}
27+
from pydantic_ai.capabilities import BuiltinOrLocalTool
28+
29+
cap = BuiltinOrLocalTool(builtin=WebSearchTool(), local=my_search_func)
30+
```
31+
32+
Or subclassed to set defaults by overriding `_default_builtin`, `_default_local`,
33+
and `_requires_builtin`.
34+
The built-in [`WebSearch`][pydantic_ai.capabilities.WebSearch],
35+
[`WebFetch`][pydantic_ai.capabilities.WebFetch], and
36+
[`ImageGeneration`][pydantic_ai.capabilities.ImageGeneration] capabilities
37+
are all subclasses.
6638
"""
6739

6840
builtin: AgentBuiltinTool[AgentDepsT] | bool = True
@@ -159,9 +131,6 @@ def get_toolset(self) -> AbstractToolset[AgentDepsT] | None:
159131
if local is None or local is False or self._requires_builtin():
160132
return None
161133

162-
from pydantic_ai.toolsets.function import FunctionToolset
163-
from pydantic_ai.toolsets.prepared import PreparedToolset
164-
165134
# local is Tool | AbstractToolset after __post_init__ resolution
166135
toolset: AbstractToolset[AgentDepsT] = local if isinstance(local, AbstractToolset) else FunctionToolset([local]) # pyright: ignore[reportUnknownVariableType]
167136

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Sequence
4+
from dataclasses import dataclass
5+
from typing import Any
6+
7+
import pydantic
8+
9+
from pydantic_ai.builtin_tools import AbstractBuiltinTool
10+
from pydantic_ai.tools import AgentBuiltinTool, AgentDepsT
11+
12+
from .abstract import AbstractCapability
13+
14+
_BUILTIN_TOOL_ADAPTER = pydantic.TypeAdapter(AbstractBuiltinTool)
15+
16+
17+
@dataclass
18+
class BuiltinTool(AbstractCapability[AgentDepsT]):
19+
"""A capability that registers a builtin tool with the agent.
20+
21+
Wraps a single [`AgentBuiltinTool`][pydantic_ai.tools.AgentBuiltinTool] — either a static
22+
[`AbstractBuiltinTool`][pydantic_ai.builtin_tools.AbstractBuiltinTool] instance or a callable
23+
that dynamically produces one.
24+
25+
When `builtin_tools` is passed to [`Agent.__init__`][pydantic_ai.Agent.__init__], each item is
26+
automatically wrapped in a `BuiltinTool` capability.
27+
"""
28+
29+
tool: AgentBuiltinTool[AgentDepsT]
30+
31+
def get_builtin_tools(self) -> Sequence[AgentBuiltinTool[AgentDepsT]]:
32+
return [self.tool]
33+
34+
@classmethod
35+
def from_spec(cls, tool: AbstractBuiltinTool | None = None, **kwargs: Any) -> BuiltinTool[Any]:
36+
"""Create from spec.
37+
38+
Supports two YAML forms:
39+
40+
- Flat: `{BuiltinTool: {kind: web_search, search_context_size: high}}`
41+
- Explicit: `{BuiltinTool: {tool: {kind: web_search}}}`
42+
"""
43+
if tool is not None:
44+
validated = _BUILTIN_TOOL_ADAPTER.validate_python(tool)
45+
elif kwargs:
46+
validated = _BUILTIN_TOOL_ADAPTER.validate_python(kwargs)
47+
else:
48+
raise TypeError(
49+
'`BuiltinTool.from_spec()` requires either a `tool` argument or keyword arguments'
50+
' specifying the builtin tool type (e.g. `kind="web_search"`)'
51+
)
52+
return cls(tool=validated)

pydantic_ai_slim/pydantic_ai/capabilities/hooks.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ def decorator(f: _FuncT) -> _FuncT:
267267
class _HookRegistration(Generic[AgentDepsT]):
268268
"""Decorator namespace for registering hooks on a :class:`Hooks` instance.
269269
270-
Accessed via ``hooks.on``. Each method corresponds to a lifecycle hook and
270+
Accessed via `hooks.on`. Each method corresponds to a lifecycle hook and
271271
can be used as a bare decorator or a parameterized decorator::
272272
273273
@hooks.on.before_model_request
@@ -355,7 +355,7 @@ def node_run_error(self, func: OnNodeRunErrorHookFunc | None = None, *, timeout:
355355
# --- Event stream ---
356356

357357
def run_event_stream(self, func: WrapRunEventStreamHookFunc, /) -> WrapRunEventStreamHookFunc:
358-
"""Register a ``wrap_run_event_stream`` hook. Timeout not supported for stream wrappers."""
358+
"""Register a `wrap_run_event_stream` hook. Timeout not supported for stream wrappers."""
359359
self._r.setdefault('wrap_run_event_stream', []).append(_HookEntry(func))
360360
return func
361361

@@ -554,7 +554,7 @@ class Hooks(AbstractCapability[AgentDepsT]):
554554
555555
For extension developers building reusable capabilities, subclass
556556
:class:`AbstractCapability` directly. For application code that needs
557-
a few hooks without the ceremony of a subclass, use ``Hooks``.
557+
a few hooks without the ceremony of a subclass, use `Hooks`.
558558
559559
Example using decorators::
560560

pydantic_ai_slim/pydantic_ai/capabilities/prefix_tools.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from pydantic_ai.tools import AgentDepsT
77
from pydantic_ai.toolsets import AbstractToolset, AgentToolset
8+
from pydantic_ai.toolsets._dynamic import DynamicToolset
89
from pydantic_ai.toolsets.prefixed import PrefixedToolset
910

1011
from .wrapper import WrapperCapability
@@ -46,8 +47,8 @@ def from_spec(cls, *, prefix: str, capability: dict[str, Any] | str) -> PrefixTo
4647
"""Create from spec with a nested capability specification.
4748
4849
Args:
49-
prefix: The prefix to add to tool names (e.g. ``'mcp'`` turns ``'search'`` into ``'mcp_search'``).
50-
capability: A capability spec (same format as entries in the ``capabilities`` list).
50+
prefix: The prefix to add to tool names (e.g. `'mcp'` turns `'search'` into `'mcp_search'`).
51+
capability: A capability spec (same format as entries in the `capabilities` list).
5152
"""
5253
from pydantic_ai.agent.spec import load_capability_from_nested_spec
5354

@@ -62,6 +63,4 @@ def get_toolset(self) -> AgentToolset[AgentDepsT] | None:
6263
# Pyright can't narrow Callable type aliases out of unions after isinstance check
6364
return PrefixedToolset(toolset, prefix=self.prefix) # pyright: ignore[reportUnknownArgumentType]
6465
# ToolsetFunc callable — wrap in DynamicToolset so PrefixedToolset can delegate
65-
from pydantic_ai.toolsets._dynamic import DynamicToolset
66-
6766
return PrefixedToolset(DynamicToolset[AgentDepsT](toolset_func=toolset), prefix=self.prefix)

pydantic_ai_slim/pydantic_ai/capabilities/thinking.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,18 @@
1212
class Thinking(AbstractCapability[AgentDepsT]):
1313
"""Enables and configures model thinking/reasoning.
1414
15-
Uses the unified ``thinking`` setting in
15+
Uses the unified `thinking` setting in
1616
[`ModelSettings`][pydantic_ai.settings.ModelSettings] to work portably across providers.
17-
Provider-specific thinking settings (e.g., ``anthropic_thinking``,
18-
``openai_reasoning_effort``) take precedence when both are set.
17+
Provider-specific thinking settings (e.g., `anthropic_thinking`,
18+
`openai_reasoning_effort`) take precedence when both are set.
1919
"""
2020

2121
effort: ThinkingLevel = True
2222
"""The thinking effort level.
2323
24-
- ``True``: Enable thinking with the provider's default effort.
25-
- ``False``: Disable thinking (silently ignored on always-on models).
26-
- ``'minimal'``/``'low'``/``'medium'``/``'high'``/``'xhigh'``: Enable thinking at a specific effort level.
24+
- `True`: Enable thinking with the provider's default effort.
25+
- `False`: Disable thinking (silently ignored on always-on models).
26+
- `'minimal'`/`'low'`/`'medium'`/`'high'`/`'xhigh'`: Enable thinking at a specific effort level.
2727
"""
2828

2929
def get_model_settings(self) -> _ModelSettings | None:

pydantic_ai_slim/pydantic_ai/models/bedrock.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
from pydantic_ai.profiles.openai import OPENAI_REASONING_EFFORT_MAP
5050
from pydantic_ai.providers import Provider, infer_provider
5151
from pydantic_ai.providers.bedrock import BedrockModelProfile, remove_bedrock_geo_prefix
52-
from pydantic_ai.settings import ModelSettings
52+
from pydantic_ai.settings import ModelSettings, ThinkingLevel
5353
from pydantic_ai.tools import ToolDefinition
5454

5555
if TYPE_CHECKING:
@@ -590,8 +590,6 @@ def _get_thinking_fields(
590590
elif variant == 'qwen' and 'reasoning_config' not in existing:
591591
if thinking is not False:
592592
# Qwen only supports low/high; map others to closest
593-
from ..settings import ThinkingLevel
594-
595593
level_map: dict[ThinkingLevel, str] = {
596594
True: 'high',
597595
'minimal': 'low',

0 commit comments

Comments
 (0)