Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion pydantic_ai_slim/pydantic_ai/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager, contextmanager
from contextvars import ContextVar
from typing import TYPE_CHECKING, Any, ClassVar, overload
from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload

from opentelemetry.trace import NoOpTracer, use_span
from pydantic.json_schema import GenerateJsonSchema
Expand Down Expand Up @@ -1031,6 +1031,7 @@ def tool(
sequential: bool = False,
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
programmatically_callable: bool | Literal['only'] = False,
) -> Callable[[ToolFuncContext[AgentDepsT, ToolParams]], ToolFuncContext[AgentDepsT, ToolParams]]: ...

def tool(
Expand All @@ -1049,6 +1050,7 @@ def tool(
sequential: bool = False,
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
programmatically_callable: bool | Literal['only'] = False,
) -> Any:
"""Decorator to register a tool function which takes [`RunContext`][pydantic_ai.tools.RunContext] as its first argument.

Expand Down Expand Up @@ -1098,6 +1100,10 @@ async def spam(ctx: RunContext[str], y: float) -> float:
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
programmatically_callable: Whether this tool can be called from code execution.
Set to `True` to allow both direct model calls and calls from code execution.
Set to `'only'` to only allow calls from code execution.
Defaults to `False`. See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
"""

def tool_decorator(
Expand All @@ -1118,6 +1124,7 @@ def tool_decorator(
sequential=sequential,
requires_approval=requires_approval,
metadata=metadata,
programmatically_callable=programmatically_callable,
)
return func_

Expand All @@ -1142,6 +1149,7 @@ def tool_plain(
sequential: bool = False,
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
programmatically_callable: bool | Literal['only'] = False,
) -> Callable[[ToolFuncPlain[ToolParams]], ToolFuncPlain[ToolParams]]: ...

def tool_plain(
Expand All @@ -1160,6 +1168,7 @@ def tool_plain(
sequential: bool = False,
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
programmatically_callable: bool | Literal['only'] = False,
) -> Any:
"""Decorator to register a tool function which DOES NOT take `RunContext` as an argument.

Expand Down Expand Up @@ -1209,6 +1218,10 @@ async def spam(ctx: RunContext[str]) -> float:
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
programmatically_callable: Whether this tool can be called from code execution.
Set to `True` to allow both direct model calls and calls from code execution.
Set to `'only'` to only allow calls from code execution.
Defaults to `False`. See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
"""

def tool_decorator(func_: ToolFuncPlain[ToolParams]) -> ToolFuncPlain[ToolParams]:
Expand All @@ -1227,6 +1240,7 @@ def tool_decorator(func_: ToolFuncPlain[ToolParams]) -> ToolFuncPlain[ToolParams
sequential=sequential,
requires_approval=requires_approval,
metadata=metadata,
programmatically_callable=programmatically_callable,
)
return func_

Expand Down
57 changes: 54 additions & 3 deletions pydantic_ai_slim/pydantic_ai/models/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
BetaCitationsConfigParam,
BetaCitationsDelta,
BetaCodeExecutionTool20250522Param,
BetaCodeExecutionTool20250825Param,
BetaCodeExecutionToolResultBlock,
BetaCodeExecutionToolResultBlockContent,
BetaCodeExecutionToolResultBlockParam,
Expand Down Expand Up @@ -510,11 +511,19 @@ def _process_response(self, response: BetaMessage) -> ModelResponse:
items.append(_map_mcp_server_result_block(item, call_part, self.system))
else:
assert isinstance(item, BetaToolUseBlock), f'unexpected item type {type(item)}'
# Include caller info in provider_details if tool was called programmatically
tool_provider_details: dict[str, Any] | None = None
if item.caller is not None:
tool_provider_details = {'caller': item.caller.type}
# If called from code execution, include the container_id (tool_id)
if hasattr(item.caller, 'tool_id'):
tool_provider_details['container_id'] = item.caller.tool_id
items.append(
ToolCallPart(
tool_name=item.name,
args=cast(dict[str, Any], item.input),
tool_call_id=item.id,
provider_details=tool_provider_details,
)
)

Expand Down Expand Up @@ -574,7 +583,24 @@ def _add_builtin_tools(
) -> tuple[list[BetaToolUnionParam], list[BetaRequestMCPServerURLDefinitionParam], set[str]]:
beta_features: set[str] = set()
mcp_servers: list[BetaRequestMCPServerURLDefinitionParam] = []
for tool in model_request_parameters.builtin_tools:

# Check if any tool has programmatically_callable set - if so, we need newer code execution
has_programmatically_callable = any(
t.programmatically_callable for t in model_request_parameters.tool_defs.values()
)
# Check if CodeExecutionTool is in builtin_tools
has_code_execution = any(isinstance(t, CodeExecutionTool) for t in model_request_parameters.builtin_tools)
# Use newer code execution (20250825) when programmatically_callable is used
use_newer_code_execution = has_programmatically_callable

# If any tool has programmatically_callable but CodeExecutionTool is not present, auto-add it
# (tools can only be called programmatically from code execution)
builtin_tools_to_process = list(model_request_parameters.builtin_tools)
if has_programmatically_callable and not has_code_execution:
builtin_tools_to_process.append(CodeExecutionTool())
has_code_execution = True

for tool in builtin_tools_to_process:
if isinstance(tool, WebSearchTool):
user_location = UserLocation(type='approximate', **tool.user_location) if tool.user_location else None
tools.append(
Expand All @@ -588,8 +614,17 @@ def _add_builtin_tools(
)
)
elif isinstance(tool, CodeExecutionTool): # pragma: no branch
tools.append(BetaCodeExecutionTool20250522Param(name='code_execution', type='code_execution_20250522'))
beta_features.add('code-execution-2025-05-22')
if use_newer_code_execution:
# Use newer code execution tool that supports programmatic tool calling
tools.append(
BetaCodeExecutionTool20250825Param(name='code_execution', type='code_execution_20250825')
)
beta_features.add('code-execution-2025-08-25')
else:
tools.append(
BetaCodeExecutionTool20250522Param(name='code_execution', type='code_execution_20250522')
)
beta_features.add('code-execution-2025-05-22')
elif isinstance(tool, WebFetchTool): # pragma: no branch
citations = BetaCitationsConfigParam(enabled=tool.enable_citations) if tool.enable_citations else None
tools.append(
Expand Down Expand Up @@ -1041,6 +1076,14 @@ def _map_tool_definition(self, f: ToolDefinition) -> BetaToolParam:
}
if f.strict and self.profile.supports_json_schema_output:
tool_param['strict'] = f.strict
# Handle programmatically_callable - maps to Anthropic's allowed_callers
if f.programmatically_callable:
if f.programmatically_callable == 'only':
# Only callable from code execution, not directly by the model
tool_param['allowed_callers'] = ['code_execution_20250825']
else:
# Callable both directly and from code execution
tool_param['allowed_callers'] = ['direct', 'code_execution_20250825']
return tool_param

@staticmethod
Expand Down Expand Up @@ -1126,11 +1169,19 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
provider_name=self.provider_name,
)
elif isinstance(current_block, BetaToolUseBlock):
# Include caller info in provider_details if tool was called programmatically
tool_provider_details: dict[str, Any] | None = None
if current_block.caller is not None:
tool_provider_details = {'caller': current_block.caller.type}
# If called from code execution, include the container_id (tool_id)
if hasattr(current_block.caller, 'tool_id'):
tool_provider_details['container_id'] = current_block.caller.tool_id
maybe_event = self._parts_manager.handle_tool_call_delta(
vendor_part_id=event.index,
tool_name=current_block.name,
args=cast(dict[str, Any], current_block.input) or None,
tool_call_id=current_block.id,
provider_details=tool_provider_details,
)
if maybe_event is not None: # pragma: no branch
yield maybe_event
Expand Down
18 changes: 18 additions & 0 deletions pydantic_ai_slim/pydantic_ai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ class Tool(Generic[ToolAgentDepsT]):
sequential: bool
requires_approval: bool
metadata: dict[str, Any] | None
programmatically_callable: bool | Literal['only']
function_schema: _function_schema.FunctionSchema
"""
The base JSON schema for the tool's parameters.
Expand All @@ -285,6 +286,7 @@ def __init__(
sequential: bool = False,
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
programmatically_callable: bool | Literal['only'] = False,
function_schema: _function_schema.FunctionSchema | None = None,
):
"""Create a new tool instance.
Expand Down Expand Up @@ -341,6 +343,10 @@ async def prep_my_tool(
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
programmatically_callable: Whether this tool can be called from code execution.
Set to `True` to allow both direct model calls and calls from code execution.
Set to `'only'` to only allow calls from code execution.
Defaults to `False`. See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
function_schema: The function schema to use for the tool. If not provided, it will be generated.
"""
self.function = function
Expand All @@ -362,6 +368,7 @@ async def prep_my_tool(
self.sequential = sequential
self.requires_approval = requires_approval
self.metadata = metadata
self.programmatically_callable = programmatically_callable

@classmethod
def from_schema(
Expand Down Expand Up @@ -418,6 +425,7 @@ def tool_def(self):
sequential=self.sequential,
metadata=self.metadata,
kind='unapproved' if self.requires_approval else 'function',
programmatically_callable=self.programmatically_callable,
)

async def prepare_tool_def(self, ctx: RunContext[ToolAgentDepsT]) -> ToolDefinition | None:
Expand Down Expand Up @@ -503,6 +511,16 @@ class ToolDefinition:
For MCP tools, this contains the `meta`, `annotations`, and `output_schema` fields from the tool definition.
"""

programmatically_callable: bool | Literal['only'] = False
"""Whether this tool can be called programmatically from code execution.

- `False` (default): The tool can only be called directly by the model.
- `True`: The tool can be called both directly by the model and from code execution.
- `'only'`: The tool can only be called from code execution, not directly by the model.

Note: this is currently only supported by Anthropic models with the code execution feature.
"""

@property
def defer(self) -> bool:
"""Whether calls to this tool will be deferred.
Expand Down
23 changes: 22 additions & 1 deletion pydantic_ai_slim/pydantic_ai/toolsets/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, replace
from typing import Any, overload
from typing import Any, Literal, overload

from pydantic.json_schema import GenerateJsonSchema

Expand Down Expand Up @@ -52,6 +52,7 @@ def __init__(
sequential: bool = False,
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
programmatically_callable: bool | Literal['only'] = False,
id: str | None = None,
):
"""Build a new function toolset.
Expand All @@ -76,6 +77,10 @@ def __init__(
Applies to all tools, unless overridden when adding a tool.
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
Applies to all tools, unless overridden when adding a tool, which will be merged with the toolset's metadata.
programmatically_callable: Whether this tool can be called from code execution.
Set to `True` to allow both direct model calls and calls from code execution.
Set to `'only'` to only allow calls from code execution.
Defaults to `False`. Applies to all tools, unless overridden when adding a tool.
id: An optional unique ID for the toolset. A toolset needs to have an ID in order to be used in a durable execution environment like Temporal,
in which case the ID will be used to identify the toolset's activities within the workflow.
"""
Expand All @@ -88,6 +93,7 @@ def __init__(
self.sequential = sequential
self.requires_approval = requires_approval
self.metadata = metadata
self.programmatically_callable = programmatically_callable

self.tools = {}
for tool in tools:
Expand Down Expand Up @@ -119,6 +125,7 @@ def tool(
sequential: bool | None = None,
requires_approval: bool | None = None,
metadata: dict[str, Any] | None = None,
programmatically_callable: bool | Literal['only'] | None = None,
) -> Callable[[ToolFuncEither[AgentDepsT, ToolParams]], ToolFuncEither[AgentDepsT, ToolParams]]: ...

def tool(
Expand All @@ -137,6 +144,7 @@ def tool(
sequential: bool | None = None,
requires_approval: bool | None = None,
metadata: dict[str, Any] | None = None,
programmatically_callable: bool | Literal['only'] | None = None,
) -> Any:
"""Decorator to register a tool function which takes [`RunContext`][pydantic_ai.tools.RunContext] as its first argument.

Expand Down Expand Up @@ -193,6 +201,10 @@ async def spam(ctx: RunContext[str], y: float) -> float:
If `None`, the default value is determined by the toolset.
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
If `None`, the default value is determined by the toolset. If provided, it will be merged with the toolset's metadata.
programmatically_callable: Whether this tool can be called from code execution.
Set to `True` to allow both direct model calls and calls from code execution.
Set to `'only'` to only allow calls from code execution.
If `None`, the default value is determined by the toolset.
"""

def tool_decorator(
Expand All @@ -213,6 +225,7 @@ def tool_decorator(
sequential=sequential,
requires_approval=requires_approval,
metadata=metadata,
programmatically_callable=programmatically_callable,
)
return func_

Expand All @@ -233,6 +246,7 @@ def add_function(
sequential: bool | None = None,
requires_approval: bool | None = None,
metadata: dict[str, Any] | None = None,
programmatically_callable: bool | Literal['only'] | None = None,
) -> None:
"""Add a function as a tool to the toolset.

Expand Down Expand Up @@ -267,6 +281,10 @@ def add_function(
If `None`, the default value is determined by the toolset.
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
If `None`, the default value is determined by the toolset. If provided, it will be merged with the toolset's metadata.
programmatically_callable: Whether this tool can be called from code execution.
Set to `True` to allow both direct model calls and calls from code execution.
Set to `'only'` to only allow calls from code execution.
If `None`, the default value is determined by the toolset.
"""
if docstring_format is None:
docstring_format = self.docstring_format
Expand All @@ -280,6 +298,8 @@ def add_function(
sequential = self.sequential
if requires_approval is None:
requires_approval = self.requires_approval
if programmatically_callable is None:
programmatically_callable = self.programmatically_callable

tool = Tool[AgentDepsT](
func,
Expand All @@ -295,6 +315,7 @@ def add_function(
sequential=sequential,
requires_approval=requires_approval,
metadata=metadata,
programmatically_callable=programmatically_callable,
)
self.add_tool(tool)

Expand Down
Loading
Loading