diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index c8208ac9e6..f2c4c0bcf4 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -1142,6 +1142,7 @@ def tool_plain( sequential: bool = False, requires_approval: bool = False, metadata: dict[str, Any] | None = None, + input_examples: list[dict[str, Any]] | None = None, ) -> Callable[[ToolFuncPlain[ToolParams]], ToolFuncPlain[ToolParams]]: ... def tool_plain( @@ -1160,6 +1161,7 @@ def tool_plain( sequential: bool = False, requires_approval: bool = False, metadata: dict[str, Any] | None = None, + input_examples: list[dict[str, Any]] | None = None, ) -> Any: """Decorator to register a tool function which DOES NOT take `RunContext` as an argument. @@ -1209,6 +1211,8 @@ 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. + input_examples: Example inputs demonstrating correct tool usage. Defaults to None. + See [`ToolDefinition.input_examples`][pydantic_ai.tools.ToolDefinition.input_examples] for more info. """ def tool_decorator(func_: ToolFuncPlain[ToolParams]) -> ToolFuncPlain[ToolParams]: @@ -1227,6 +1231,7 @@ def tool_decorator(func_: ToolFuncPlain[ToolParams]) -> ToolFuncPlain[ToolParams sequential=sequential, requires_approval=requires_approval, metadata=metadata, + input_examples=input_examples, ) return func_ diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index ecdb9fe61f..19432181a3 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -431,6 +431,11 @@ def _get_betas_and_extra_headers( if has_strict_tools or model_request_parameters.output_mode == 'native': betas.add('structured-outputs-2025-11-13') + # Check if any tools use input_examples (advanced tool use feature) + has_input_examples = any(tool.get('input_examples') for tool in tools) + if has_input_examples: + betas.add('advanced-tool-use-2025-11-20') + if beta_header := extra_headers.pop('anthropic-beta', None): betas.update({stripped_beta for beta in beta_header.split(',') if (stripped_beta := beta.strip())}) @@ -1041,6 +1046,8 @@ def _map_tool_definition(self, f: ToolDefinition) -> BetaToolParam: } if f.strict and self.profile.supports_json_schema_output: tool_param['strict'] = f.strict + if f.input_examples: + tool_param['input_examples'] = f.input_examples return tool_param @staticmethod diff --git a/pydantic_ai_slim/pydantic_ai/tools.py b/pydantic_ai_slim/pydantic_ai/tools.py index e54b829bfb..6f6a12ff64 100644 --- a/pydantic_ai_slim/pydantic_ai/tools.py +++ b/pydantic_ai_slim/pydantic_ai/tools.py @@ -262,6 +262,7 @@ class Tool(Generic[ToolAgentDepsT]): sequential: bool requires_approval: bool metadata: dict[str, Any] | None + input_examples: list[dict[str, Any]] | None function_schema: _function_schema.FunctionSchema """ The base JSON schema for the tool's parameters. @@ -285,6 +286,7 @@ def __init__( sequential: bool = False, requires_approval: bool = False, metadata: dict[str, Any] | None = None, + input_examples: list[dict[str, Any]] | None = None, function_schema: _function_schema.FunctionSchema | None = None, ): """Create a new tool instance. @@ -341,6 +343,8 @@ 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. + input_examples: Example inputs demonstrating correct tool usage. Defaults to None. + See [`ToolDefinition.input_examples`][pydantic_ai.tools.ToolDefinition.input_examples] for more info. function_schema: The function schema to use for the tool. If not provided, it will be generated. """ self.function = function @@ -362,6 +366,7 @@ async def prep_my_tool( self.sequential = sequential self.requires_approval = requires_approval self.metadata = metadata + self.input_examples = input_examples @classmethod def from_schema( @@ -418,6 +423,7 @@ def tool_def(self): sequential=self.sequential, metadata=self.metadata, kind='unapproved' if self.requires_approval else 'function', + input_examples=self.input_examples, ) async def prepare_tool_def(self, ctx: RunContext[ToolAgentDepsT]) -> ToolDefinition | None: @@ -503,6 +509,18 @@ class ToolDefinition: For MCP tools, this contains the `meta`, `annotations`, and `output_schema` fields from the tool definition. """ + input_examples: list[dict[str, Any]] | None = None + """Example inputs demonstrating correct tool usage patterns. + + Provide 1-5 realistic examples showing parameter conventions, optional field patterns, + nested structures, and API-specific conventions. Each example must validate against + the tool's `parameters_json_schema`. + + Supported by: + + * [Anthropic](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/tool-use-examples) + """ + @property def defer(self) -> bool: """Whether calls to this tool will be deferred. diff --git a/pydantic_ai_slim/pydantic_ai/toolsets/function.py b/pydantic_ai_slim/pydantic_ai/toolsets/function.py index e185ed0273..f3d57f1a61 100644 --- a/pydantic_ai_slim/pydantic_ai/toolsets/function.py +++ b/pydantic_ai_slim/pydantic_ai/toolsets/function.py @@ -119,6 +119,7 @@ def tool( sequential: bool | None = None, requires_approval: bool | None = None, metadata: dict[str, Any] | None = None, + input_examples: list[dict[str, Any]] | None = None, ) -> Callable[[ToolFuncEither[AgentDepsT, ToolParams]], ToolFuncEither[AgentDepsT, ToolParams]]: ... def tool( @@ -137,6 +138,7 @@ def tool( sequential: bool | None = None, requires_approval: bool | None = None, metadata: dict[str, Any] | None = None, + input_examples: list[dict[str, Any]] | None = None, ) -> Any: """Decorator to register a tool function which takes [`RunContext`][pydantic_ai.tools.RunContext] as its first argument. @@ -193,6 +195,8 @@ 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. + input_examples: Example inputs demonstrating correct tool usage. Defaults to None. + See [`ToolDefinition.input_examples`][pydantic_ai.tools.ToolDefinition.input_examples] for more info. """ def tool_decorator( @@ -213,6 +217,7 @@ def tool_decorator( sequential=sequential, requires_approval=requires_approval, metadata=metadata, + input_examples=input_examples, ) return func_ @@ -233,6 +238,7 @@ def add_function( sequential: bool | None = None, requires_approval: bool | None = None, metadata: dict[str, Any] | None = None, + input_examples: list[dict[str, Any]] | None = None, ) -> None: """Add a function as a tool to the toolset. @@ -267,6 +273,8 @@ 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. + input_examples: Example inputs demonstrating correct tool usage. Defaults to None. + See [`ToolDefinition.input_examples`][pydantic_ai.tools.ToolDefinition.input_examples] for more info. """ if docstring_format is None: docstring_format = self.docstring_format @@ -295,6 +303,7 @@ def add_function( sequential=sequential, requires_approval=requires_approval, metadata=metadata, + input_examples=input_examples, ) self.add_tool(tool) diff --git a/tests/models/anthropic/test_input_examples.py b/tests/models/anthropic/test_input_examples.py new file mode 100644 index 0000000000..e1945089f5 --- /dev/null +++ b/tests/models/anthropic/test_input_examples.py @@ -0,0 +1,149 @@ +"""Tests for Anthropic input_examples feature on ToolDefinition. + +This feature allows providing example inputs to help the model understand +correct tool usage patterns. +""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest + +from pydantic_ai import Agent, Tool +from pydantic_ai.tools import ToolDefinition + +from ...conftest import try_import +from ..test_anthropic import MockAnthropic, completion_message + +with try_import() as imports_successful: + from anthropic.types.beta import BetaTextBlock, BetaUsage + + from pydantic_ai.models.anthropic import AnthropicModel + from pydantic_ai.providers.anthropic import AnthropicProvider + + +pytestmark = [ + pytest.mark.skipif(not imports_successful(), reason='anthropic not installed'), +] + + +class TestToolDefinitionInputExamples: + """Tests for ToolDefinition.input_examples field.""" + + def test_input_examples_defaults_to_none(self): + """Test that input_examples defaults to None.""" + tool_def = ToolDefinition(name='test_tool') + assert tool_def.input_examples is None + + def test_input_examples_can_be_set(self): + """Test setting input_examples.""" + examples = [ + {'param1': 'value1', 'param2': 123}, + {'param1': 'value2'}, + ] + tool_def = ToolDefinition(name='test_tool', input_examples=examples) + assert tool_def.input_examples == examples + + +class TestToolInputExamples: + """Tests for Tool class with input_examples.""" + + def test_tool_input_examples_default(self): + """Test that Tool.input_examples defaults to None.""" + + def my_tool(x: int) -> str: + return str(x) + + tool = Tool(my_tool) + assert tool.input_examples is None + assert tool.tool_def.input_examples is None + + def test_tool_input_examples_set(self): + """Test setting Tool.input_examples.""" + + def my_tool(x: int) -> str: + return str(x) + + examples = [{'x': 1}, {'x': 42}] + tool = Tool(my_tool, input_examples=examples) + assert tool.input_examples == examples + assert tool.tool_def.input_examples == examples + + +class TestAnthropicMapToolDefinition: + """Tests for AnthropicModel._map_tool_definition with input_examples.""" + + def test_map_tool_definition_without_input_examples(self): + """Test tool definition mapping without input_examples.""" + tool_def = ToolDefinition( + name='test_tool', + description='A test tool', + parameters_json_schema={'type': 'object', 'properties': {'x': {'type': 'integer'}}}, + ) + c = completion_message( + [BetaTextBlock(text='Hello', type='text')], + BetaUsage(input_tokens=5, output_tokens=10), + ) + mock_client = MockAnthropic.create_mock(c) + model = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + result = model._map_tool_definition(tool_def) # pyright: ignore[reportPrivateUsage] + result_dict = cast(dict[str, Any], result) + assert result_dict['name'] == 'test_tool' + assert result_dict['description'] == 'A test tool' + assert 'input_examples' not in result_dict + + def test_map_tool_definition_with_input_examples(self): + """Test tool definition mapping with input_examples.""" + examples = [{'x': 1}, {'x': 2}] + tool_def = ToolDefinition( + name='test_tool', + description='A test tool', + input_examples=examples, + ) + c = completion_message( + [BetaTextBlock(text='Hello', type='text')], + BetaUsage(input_tokens=5, output_tokens=10), + ) + mock_client = MockAnthropic.create_mock(c) + model = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(anthropic_client=mock_client)) + result = model._map_tool_definition(tool_def) # pyright: ignore[reportPrivateUsage] + result_dict = cast(dict[str, Any], result) + assert result_dict['input_examples'] == examples + + +class TestAgentWithInputExamples: + """Tests for Agent with input_examples on tools.""" + + def test_agent_with_input_examples_tool(self): + """Test creating an agent with a tool that has input_examples.""" + + def my_tool(x: int) -> str: + """A test tool.""" + return str(x) + + examples = [{'x': 1}, {'x': 42}] + agent = Agent( + 'test', + tools=[Tool(my_tool, input_examples=examples)], + ) + + # Verify the tool was registered with input_examples + tool = agent._function_toolset.tools.get('my_tool') # pyright: ignore[reportPrivateUsage] + assert tool is not None + assert tool.input_examples == examples + + def test_agent_tool_plain_decorator_with_input_examples(self): + """Test the @agent.tool_plain decorator with input_examples.""" + agent: Agent[None, str] = Agent('test') + + examples = [{'x': 1}, {'x': 42}] + + @agent.tool_plain(input_examples=examples) + def my_example_tool(x: int) -> str: + """A tool with examples.""" + return str(x) + + tool = agent._function_toolset.tools.get('my_example_tool') # pyright: ignore[reportPrivateUsage] + assert tool is not None + assert tool.input_examples == examples diff --git a/tests/models/test_model_request_parameters.py b/tests/models/test_model_request_parameters.py index 78a8dffa79..30a8393545 100644 --- a/tests/models/test_model_request_parameters.py +++ b/tests/models/test_model_request_parameters.py @@ -68,6 +68,7 @@ def test_model_request_parameters_are_serializable(): 'sequential': False, 'kind': 'function', 'metadata': None, + 'input_examples': None, } ], 'builtin_tools': [ @@ -131,6 +132,7 @@ def test_model_request_parameters_are_serializable(): 'sequential': False, 'kind': 'function', 'metadata': None, + 'input_examples': None, } ], 'prompted_output_template': None, diff --git a/tests/test_logfire.py b/tests/test_logfire.py index dadb930dd0..1e0aee490d 100644 --- a/tests/test_logfire.py +++ b/tests/test_logfire.py @@ -547,6 +547,7 @@ async def my_ret(x: int) -> str: 'sequential': False, 'kind': 'function', 'metadata': None, + 'input_examples': None, } ], 'builtin_tools': [], @@ -994,6 +995,7 @@ class MyOutput: 'sequential': False, 'kind': 'output', 'metadata': None, + 'input_examples': None, } ], 'prompted_output_template': None, diff --git a/tests/test_tools.py b/tests/test_tools.py index bcdf537994..38b171d701 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -150,6 +150,7 @@ def test_docstring_google(docstring_format: Literal['google', 'auto']): 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -184,6 +185,7 @@ def test_docstring_sphinx(docstring_format: Literal['sphinx', 'auto']): 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -226,6 +228,7 @@ def test_docstring_numpy(docstring_format: Literal['numpy', 'auto']): 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -268,6 +271,7 @@ def my_tool(x: int) -> str: # pragma: no cover 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -308,6 +312,7 @@ def my_tool(x: int) -> str: # pragma: no cover 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -354,6 +359,7 @@ def my_tool(x: int) -> str: # pragma: no cover 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -388,6 +394,7 @@ def test_only_returns_type(): 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -413,6 +420,7 @@ def test_docstring_unknown(): 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -456,6 +464,7 @@ def test_docstring_google_no_body(docstring_format: Literal['google', 'auto']): 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -492,6 +501,7 @@ def takes_just_model(model: Foo) -> str: 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -537,6 +547,7 @@ def takes_just_model(model: Foo, z: int) -> str: 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -902,6 +913,7 @@ def test_suppress_griffe_logging(caplog: LogCaptureFixture): 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } ) @@ -974,6 +986,7 @@ def my_tool_plain(*, a: int = 1, b: int) -> int: 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, }, { 'description': None, @@ -989,6 +1002,7 @@ def my_tool_plain(*, a: int = 1, b: int) -> int: 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, }, ] ) @@ -1077,6 +1091,7 @@ def my_tool(x: Annotated[str | None, WithJsonSchema({'type': 'string'})] = None, 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, }, { 'description': None, @@ -1090,6 +1105,7 @@ def my_tool(x: Annotated[str | None, WithJsonSchema({'type': 'string'})] = None, 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, }, ] ) @@ -1127,6 +1143,7 @@ def get_score(data: Data) -> int: ... # pragma: no branch 'kind': 'function', 'sequential': False, 'metadata': None, + 'input_examples': None, } )