From 403a3b541e1b61aedd6cd68058cb9b98a682ac06 Mon Sep 17 00:00:00 2001 From: safina57 Date: Fri, 26 Sep 2025 08:05:52 +0100 Subject: [PATCH 1/4] feat: Add builtin_tools parameter to Agent, WrapperAgent and DBOSAgent and implement merge_builtin_tools function --- .../pydantic_ai/agent/__init__.py | 10 +- .../pydantic_ai/agent/abstract.py | 20 ++++ pydantic_ai_slim/pydantic_ai/agent/wrapper.py | 6 ++ pydantic_ai_slim/pydantic_ai/builtin_tools.py | 41 ++++++++ .../pydantic_ai/durable_exec/dbos/_agent.py | 28 +++++- .../durable_exec/temporal/_agent.py | 21 +++++ tests/test_builtin_tools_runtime.py | 94 +++++++++++++++++++ 7 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 tests/test_builtin_tools_runtime.py diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index f1d3d4e02e..686f51b1a6 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -36,7 +36,7 @@ ) from .._output import OutputToolset from .._tool_manager import ToolManager -from ..builtin_tools import AbstractBuiltinTool +from ..builtin_tools import AbstractBuiltinTool, merge_builtin_tools from ..models.instrumented import InstrumentationSettings, InstrumentedModel, instrument_model from ..output import OutputDataT, OutputSpec from ..profiles import ModelProfile @@ -438,6 +438,7 @@ def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, ) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, OutputDataT]]: ... @overload @@ -455,6 +456,7 @@ def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, ) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, RunOutputDataT]]: ... @asynccontextmanager @@ -472,6 +474,7 @@ async def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, ) -> AsyncIterator[AgentRun[AgentDepsT, Any]]: """A contextmanager which can be used to iterate over the agent graph's nodes as they are executed. @@ -544,6 +547,7 @@ async def main(): usage: Optional usage to start with, useful for resuming a conversation or agents used in tools. infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. + builtin_tools: Optional additional builtin tools for this run. Returns: The result of the run. @@ -626,7 +630,9 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None: output_schema=output_schema, output_validators=output_validators, history_processors=self.history_processors, - builtin_tools=list(self._builtin_tools), + builtin_tools=merge_builtin_tools( + list(self._builtin_tools), list(builtin_tools) if builtin_tools else None + ), tool_manager=tool_manager, tracer=tracer, get_instructions=get_instructions, diff --git a/pydantic_ai_slim/pydantic_ai/agent/abstract.py b/pydantic_ai_slim/pydantic_ai/agent/abstract.py index 8d6c9ff293..c544ab809a 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/abstract.py +++ b/pydantic_ai_slim/pydantic_ai/agent/abstract.py @@ -22,6 +22,7 @@ usage as _usage, ) from .._tool_manager import ToolManager +from ..builtin_tools import AbstractBuiltinTool from ..output import OutputDataT, OutputSpec from ..result import AgentStream, FinalResult, StreamedRunResult from ..run import AgentRun, AgentRunResult @@ -126,6 +127,7 @@ async def run( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[OutputDataT]: ... @@ -144,6 +146,7 @@ async def run( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[RunOutputDataT]: ... @@ -161,6 +164,7 @@ async def run( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[Any]: """Run the agent with a user prompt in async mode. @@ -194,6 +198,7 @@ async def main(): infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. event_stream_handler: Optional handler for events from the model's streaming response and the agent's execution of tools to use for this run. + builtin_tools: Optional additional builtin tools for this run. Returns: The result of the run. @@ -214,6 +219,7 @@ async def main(): usage_limits=usage_limits, usage=usage, toolsets=toolsets, + builtin_tools=builtin_tools, ) as agent_run: async for node in agent_run: if event_stream_handler is not None and ( @@ -240,6 +246,7 @@ def run_sync( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[OutputDataT]: ... @@ -258,6 +265,7 @@ def run_sync( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[RunOutputDataT]: ... @@ -275,6 +283,7 @@ def run_sync( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[Any]: """Synchronously run the agent with a user prompt. @@ -307,6 +316,7 @@ def run_sync( infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. event_stream_handler: Optional handler for events from the model's streaming response and the agent's execution of tools to use for this run. + builtin_tools: Optional additional builtin tools for this run. Returns: The result of the run. @@ -327,6 +337,7 @@ def run_sync( usage=usage, infer_name=False, toolsets=toolsets, + builtin_tools=builtin_tools, event_stream_handler=event_stream_handler, ) ) @@ -346,6 +357,7 @@ def run_stream( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AbstractAsyncContextManager[result.StreamedRunResult[AgentDepsT, OutputDataT]]: ... @@ -364,6 +376,7 @@ def run_stream( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AbstractAsyncContextManager[result.StreamedRunResult[AgentDepsT, RunOutputDataT]]: ... @@ -382,6 +395,7 @@ async def run_stream( # noqa C901 usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AsyncIterator[result.StreamedRunResult[AgentDepsT, Any]]: """Run the agent with a user prompt in async streaming mode. @@ -421,6 +435,7 @@ async def main(): usage: Optional usage to start with, useful for resuming a conversation or agents used in tools. infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. + builtin_tools: Optional additional builtin tools for this run. event_stream_handler: Optional handler for events from the model's streaming response and the agent's execution of tools to use for this run. It will receive all the events up until the final result is found, which you can then read or stream from inside the context manager. Note that it does _not_ receive any events after the final result is found. @@ -448,6 +463,7 @@ async def main(): usage=usage, infer_name=False, toolsets=toolsets, + builtin_tools=builtin_tools, ) as agent_run: first_node = agent_run.next_node # start with the first node assert isinstance(first_node, _agent_graph.UserPromptNode) # the first node should be a user prompt node @@ -558,6 +574,7 @@ def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, ) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, OutputDataT]]: ... @overload @@ -575,6 +592,7 @@ def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, ) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, RunOutputDataT]]: ... @asynccontextmanager @@ -593,6 +611,7 @@ async def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, ) -> AsyncIterator[AgentRun[AgentDepsT, Any]]: """A contextmanager which can be used to iterate over the agent graph's nodes as they are executed. @@ -665,6 +684,7 @@ async def main(): usage: Optional usage to start with, useful for resuming a conversation or agents used in tools. infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. + builtin_tools: Optional additional builtin tools for this run. Returns: The result of the run. diff --git a/pydantic_ai_slim/pydantic_ai/agent/wrapper.py b/pydantic_ai_slim/pydantic_ai/agent/wrapper.py index 36f7969323..64b9ee8388 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/wrapper.py +++ b/pydantic_ai_slim/pydantic_ai/agent/wrapper.py @@ -10,6 +10,7 @@ models, usage as _usage, ) +from ..builtin_tools import AbstractBuiltinTool from ..output import OutputDataT, OutputSpec from ..run import AgentRun from ..settings import ModelSettings @@ -81,6 +82,7 @@ def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, ) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, OutputDataT]]: ... @overload @@ -98,6 +100,7 @@ def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, ) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, RunOutputDataT]]: ... @asynccontextmanager @@ -115,6 +118,7 @@ async def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, ) -> AsyncIterator[AgentRun[AgentDepsT, Any]]: """A contextmanager which can be used to iterate over the agent graph's nodes as they are executed. @@ -187,6 +191,7 @@ async def main(): usage: Optional usage to start with, useful for resuming a conversation or agents used in tools. infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. + builtin_tools: Optional additional builtin tools for this run. Returns: The result of the run. @@ -203,6 +208,7 @@ async def main(): usage=usage, infer_name=infer_name, toolsets=toolsets, + builtin_tools=builtin_tools, ) as run: yield run diff --git a/pydantic_ai_slim/pydantic_ai/builtin_tools.py b/pydantic_ai_slim/pydantic_ai/builtin_tools.py index 2903edd9df..42d75c4992 100644 --- a/pydantic_ai_slim/pydantic_ai/builtin_tools.py +++ b/pydantic_ai_slim/pydantic_ai/builtin_tools.py @@ -1,5 +1,10 @@ from __future__ import annotations as _annotations +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .builtin_tools import AbstractBuiltinTool from abc import ABC from dataclasses import dataclass from typing import Literal @@ -121,3 +126,39 @@ class UrlContextTool(AbstractBuiltinTool): * Google """ + + +def merge_builtin_tools( + base: Sequence[AbstractBuiltinTool] | None, runtime: Sequence[AbstractBuiltinTool] | None +) -> list[AbstractBuiltinTool]: + """Merge two sets of builtin tools, with runtime tools having priority over base tools. + + Args: + base: The base builtin tools (e.g., from agent initialization) + runtime: The runtime builtin tools (e.g., from agent.run()) + + Returns: + A merged list of builtin tools with duplicates removed by type. + Runtime tools take priority over base tools when both have the same type. + """ + if not base and not runtime: + return [] + + if not base: + return list(runtime) if runtime else [] + + if not runtime: + return list(base) + + # Create a mapping of tool types to tools, with runtime tools taking priority + tool_map: dict[type[AbstractBuiltinTool], AbstractBuiltinTool] = {} + + # Add base tools first + for tool in base: + tool_map[type(tool)] = tool + + # Add runtime tools, which will override base tools of the same type + for tool in runtime: + tool_map[type(tool)] = tool + + return list(tool_map.values()) diff --git a/pydantic_ai_slim/pydantic_ai/durable_exec/dbos/_agent.py b/pydantic_ai_slim/pydantic_ai/durable_exec/dbos/_agent.py index a12c9e70c0..37313adb11 100644 --- a/pydantic_ai_slim/pydantic_ai/durable_exec/dbos/_agent.py +++ b/pydantic_ai_slim/pydantic_ai/durable_exec/dbos/_agent.py @@ -13,7 +13,9 @@ models, usage as _usage, ) -from pydantic_ai.agent import AbstractAgent, AgentRun, AgentRunResult, EventStreamHandler, RunOutputDataT, WrapperAgent +from pydantic_ai.agent import AbstractAgent, AgentRun, AgentRunResult, EventStreamHandler, WrapperAgent +from pydantic_ai.agent.abstract import RunOutputDataT +from pydantic_ai.builtin_tools import AbstractBuiltinTool from pydantic_ai.exceptions import UserError from pydantic_ai.models import Model from pydantic_ai.output import OutputDataT, OutputSpec @@ -119,6 +121,7 @@ async def wrapped_run_workflow( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, **_deprecated_kwargs: Never, ) -> AgentRunResult[Any]: @@ -135,6 +138,7 @@ async def wrapped_run_workflow( usage=usage, infer_name=infer_name, toolsets=toolsets, + builtin_tools=builtin_tools, event_stream_handler=event_stream_handler, **_deprecated_kwargs, ) @@ -156,6 +160,7 @@ def wrapped_run_sync_workflow( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, **_deprecated_kwargs: Never, ) -> AgentRunResult[Any]: @@ -172,6 +177,7 @@ def wrapped_run_sync_workflow( usage=usage, infer_name=infer_name, toolsets=toolsets, + builtin_tools=builtin_tools, event_stream_handler=event_stream_handler, **_deprecated_kwargs, ) @@ -244,6 +250,7 @@ async def run( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[OutputDataT]: ... @@ -262,6 +269,7 @@ async def run( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[RunOutputDataT]: ... @@ -279,6 +287,7 @@ async def run( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, **_deprecated_kwargs: Never, ) -> AgentRunResult[Any]: @@ -312,6 +321,7 @@ async def main(): usage: Optional usage to start with, useful for resuming a conversation or agents used in tools. infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. + builtin_tools: Optional additional builtin tools for this run. event_stream_handler: Optional event stream handler to use for this run. Returns: @@ -329,6 +339,7 @@ async def main(): usage=usage, infer_name=infer_name, toolsets=toolsets, + builtin_tools=builtin_tools, event_stream_handler=event_stream_handler, **_deprecated_kwargs, ) @@ -348,6 +359,7 @@ def run_sync( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[OutputDataT]: ... @@ -366,6 +378,7 @@ def run_sync( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[RunOutputDataT]: ... @@ -383,6 +396,7 @@ def run_sync( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, **_deprecated_kwargs: Never, ) -> AgentRunResult[Any]: @@ -415,6 +429,7 @@ def run_sync( usage: Optional usage to start with, useful for resuming a conversation or agents used in tools. infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. + builtin_tools: Optional additional builtin tools for this run. event_stream_handler: Optional event stream handler to use for this run. Returns: @@ -432,6 +447,7 @@ def run_sync( usage=usage, infer_name=infer_name, toolsets=toolsets, + builtin_tools=builtin_tools, event_stream_handler=event_stream_handler, **_deprecated_kwargs, ) @@ -451,6 +467,7 @@ def run_stream( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AbstractAsyncContextManager[StreamedRunResult[AgentDepsT, OutputDataT]]: ... @@ -469,6 +486,7 @@ def run_stream( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AbstractAsyncContextManager[StreamedRunResult[AgentDepsT, RunOutputDataT]]: ... @@ -487,6 +505,7 @@ async def run_stream( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, **_deprecated_kwargs: Never, ) -> AsyncIterator[StreamedRunResult[AgentDepsT, Any]]: @@ -517,6 +536,7 @@ async def main(): usage: Optional usage to start with, useful for resuming a conversation or agents used in tools. infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. + builtin_tools: Optional additional builtin tools for this run. event_stream_handler: Optional event stream handler to use for this run. It will receive all the events up until the final result is found, which you can then read or stream from inside the context manager. Returns: @@ -541,6 +561,7 @@ async def main(): usage=usage, infer_name=infer_name, toolsets=toolsets, + builtin_tools=builtin_tools, event_stream_handler=event_stream_handler, **_deprecated_kwargs, ) as result: @@ -561,6 +582,7 @@ def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, **_deprecated_kwargs: Never, ) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, OutputDataT]]: ... @@ -579,6 +601,7 @@ def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, **_deprecated_kwargs: Never, ) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, RunOutputDataT]]: ... @@ -597,6 +620,7 @@ async def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, **_deprecated_kwargs: Never, ) -> AsyncIterator[AgentRun[AgentDepsT, Any]]: """A contextmanager which can be used to iterate over the agent graph's nodes as they are executed. @@ -670,6 +694,7 @@ async def main(): usage: Optional usage to start with, useful for resuming a conversation or agents used in tools. infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. + builtin_tools: Optional additional builtin tools for this run. Returns: The result of the run. @@ -692,6 +717,7 @@ async def main(): usage=usage, infer_name=infer_name, toolsets=toolsets, + builtin_tools=builtin_tools, **_deprecated_kwargs, ) as run: yield run diff --git a/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_agent.py b/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_agent.py index cb284b6097..2dffb4881d 100644 --- a/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_agent.py +++ b/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_agent.py @@ -22,6 +22,7 @@ usage as _usage, ) from pydantic_ai.agent import AbstractAgent, AgentRun, AgentRunResult, EventStreamHandler, RunOutputDataT, WrapperAgent +from pydantic_ai.builtin_tools import AbstractBuiltinTool from pydantic_ai.exceptions import UserError from pydantic_ai.models import Model from pydantic_ai.output import OutputDataT, OutputSpec @@ -266,6 +267,7 @@ async def run( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[OutputDataT]: ... @@ -284,6 +286,7 @@ async def run( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[RunOutputDataT]: ... @@ -301,6 +304,7 @@ async def run( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, **_deprecated_kwargs: Never, ) -> AgentRunResult[Any]: @@ -335,6 +339,7 @@ async def main(): infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. event_stream_handler: Optional event stream handler to use for this run. + builtin_tools: Optional additional builtin tools for this run. Returns: The result of the run. @@ -357,6 +362,7 @@ async def main(): usage=usage, infer_name=infer_name, toolsets=toolsets, + builtin_tools=builtin_tools, event_stream_handler=event_stream_handler or self.event_stream_handler, **_deprecated_kwargs, ) @@ -376,6 +382,7 @@ def run_sync( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[OutputDataT]: ... @@ -394,6 +401,7 @@ def run_sync( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AgentRunResult[RunOutputDataT]: ... @@ -411,6 +419,7 @@ def run_sync( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, **_deprecated_kwargs: Never, ) -> AgentRunResult[Any]: @@ -444,6 +453,7 @@ def run_sync( infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. event_stream_handler: Optional event stream handler to use for this run. + builtin_tools: Optional additional builtin tools for this run. Returns: The result of the run. @@ -465,6 +475,7 @@ def run_sync( usage=usage, infer_name=infer_name, toolsets=toolsets, + builtin_tools=builtin_tools, event_stream_handler=event_stream_handler, **_deprecated_kwargs, ) @@ -484,6 +495,7 @@ def run_stream( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AbstractAsyncContextManager[StreamedRunResult[AgentDepsT, OutputDataT]]: ... @@ -502,6 +514,7 @@ def run_stream( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, ) -> AbstractAsyncContextManager[StreamedRunResult[AgentDepsT, RunOutputDataT]]: ... @@ -520,6 +533,7 @@ async def run_stream( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, event_stream_handler: EventStreamHandler[AgentDepsT] | None = None, **_deprecated_kwargs: Never, ) -> AsyncIterator[StreamedRunResult[AgentDepsT, Any]]: @@ -550,6 +564,7 @@ async def main(): usage: Optional usage to start with, useful for resuming a conversation or agents used in tools. infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. + builtin_tools: Optional additional builtin tools for this run. event_stream_handler: Optional event stream handler to use for this run. It will receive all the events up until the final result is found, which you can then read or stream from inside the context manager. Returns: @@ -575,6 +590,7 @@ async def main(): infer_name=infer_name, toolsets=toolsets, event_stream_handler=event_stream_handler, + builtin_tools=builtin_tools, **_deprecated_kwargs, ) as result: yield result @@ -593,6 +609,7 @@ def iter( usage_limits: _usage.UsageLimits | None = None, usage: _usage.RunUsage | None = None, infer_name: bool = True, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, **_deprecated_kwargs: Never, ) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, OutputDataT]]: ... @@ -612,6 +629,7 @@ def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, **_deprecated_kwargs: Never, ) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, RunOutputDataT]]: ... @@ -630,6 +648,7 @@ async def iter( usage: _usage.RunUsage | None = None, infer_name: bool = True, toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None, + builtin_tools: Sequence[AbstractBuiltinTool] | None = None, **_deprecated_kwargs: Never, ) -> AsyncIterator[AgentRun[AgentDepsT, Any]]: """A contextmanager which can be used to iterate over the agent graph's nodes as they are executed. @@ -703,6 +722,7 @@ async def main(): usage: Optional usage to start with, useful for resuming a conversation or agents used in tools. infer_name: Whether to try to infer the agent name from the call frame if it's not set. toolsets: Optional additional toolsets for this run. + builtin_tools: Optional additional builtin tools for this run. Returns: The result of the run. @@ -736,6 +756,7 @@ async def main(): usage=usage, infer_name=infer_name, toolsets=toolsets, + builtin_tools=builtin_tools, **_deprecated_kwargs, ) as run: yield run diff --git a/tests/test_builtin_tools_runtime.py b/tests/test_builtin_tools_runtime.py new file mode 100644 index 0000000000..b51732b754 --- /dev/null +++ b/tests/test_builtin_tools_runtime.py @@ -0,0 +1,94 @@ +import pytest + +from pydantic_ai import Agent +from pydantic_ai.builtin_tools import CodeExecutionTool, WebSearchTool, merge_builtin_tools +from pydantic_ai.models.test import TestModel + + +def test_merge_builtin_tools_basic(): + """Test that merge_builtin_tools function works correctly.""" + # Test merging with different tool types + base_tools = [WebSearchTool(allowed_domains=['base.com'])] + runtime_tools = [CodeExecutionTool()] + + merged = merge_builtin_tools(base_tools, runtime_tools) + assert len(merged) == 2 + + # Test merging with same tool types (runtime should override) + base_tools = [WebSearchTool(allowed_domains=['base.com'])] + runtime_tools = [WebSearchTool(allowed_domains=['runtime.com'])] + + merged = merge_builtin_tools(base_tools, runtime_tools) + assert len(merged) == 1 + # Check that we got the runtime tool (need to check specific attributes) + web_tool = None + for tool in merged: + if isinstance(tool, WebSearchTool): + web_tool = tool + break + assert web_tool is not None + assert web_tool.allowed_domains == ['runtime.com'] + + +def test_merge_builtin_tools_none_handling(): + """Test that merge_builtin_tools handles None correctly.""" + base_tools = [WebSearchTool(allowed_domains=['base.com'])] + runtime_tools = [WebSearchTool(allowed_domains=['runtime.com'])] + + # Test with None runtime tools + merged = merge_builtin_tools(base_tools, None) + assert len(merged) == 1 + web_tool = None + for tool in merged: + if isinstance(tool, WebSearchTool): + web_tool = tool + break + assert web_tool is not None + assert web_tool.allowed_domains == ['base.com'] + + # Test with None base tools + merged = merge_builtin_tools(None, runtime_tools) + assert len(merged) == 1 + web_tool = None + for tool in merged: + if isinstance(tool, WebSearchTool): + web_tool = tool + break + assert web_tool is not None + assert web_tool.allowed_domains == ['runtime.com'] + + # Test with both None + merged = merge_builtin_tools(None, None) + assert len(merged) == 0 + + +def test_agent_builtin_tools_runtime_parameter(): + """Test that Agent.run_sync accepts builtin_tools parameter.""" + agent = Agent(model=TestModel(), builtin_tools=[]) + + # Should work with empty builtin_tools + result = agent.run_sync('Hello', builtin_tools=[]) + assert result.output == 'success (no tool calls)' + + +async def test_agent_builtin_tools_runtime_parameter_async(): + """Test that Agent.run and Agent.run_stream accept builtin_tools parameter.""" + agent = Agent(model=TestModel(), builtin_tools=[]) + + # Test async run + result = await agent.run('Hello', builtin_tools=[]) + assert result.output == 'success (no tool calls)' + + # Test run_stream + async with agent.run_stream('Hello', builtin_tools=[]) as stream: + output = await stream.get_output() + assert output == 'success (no tool calls)' + + +def test_agent_builtin_tools_testmodel_rejection(): + """Test that TestModel rejects builtin tools as expected.""" + agent = Agent(model=TestModel(), builtin_tools=[]) + + # Should raise error when builtin_tools contains actual tools + with pytest.raises(Exception, match='TestModel does not support built-in tools'): + agent.run_sync('Hello', builtin_tools=[WebSearchTool()]) From af478262805d785073ee9f7a8a9feed39aedc3a2 Mon Sep 17 00:00:00 2001 From: safina57 Date: Fri, 26 Sep 2025 09:08:28 +0100 Subject: [PATCH 2/4] test: Enhance test converage --- tests/test_builtin_tools_runtime.py | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_builtin_tools_runtime.py b/tests/test_builtin_tools_runtime.py index b51732b754..a58ee0bb58 100644 --- a/tests/test_builtin_tools_runtime.py +++ b/tests/test_builtin_tools_runtime.py @@ -26,9 +26,25 @@ def test_merge_builtin_tools_basic(): if isinstance(tool, WebSearchTool): web_tool = tool break + else: + web_tool = None assert web_tool is not None assert web_tool.allowed_domains == ['runtime.com'] + base_tools = [CodeExecutionTool()] + runtime_tools = [CodeExecutionTool()] + + merged = merge_builtin_tools(base_tools, runtime_tools) + assert len(merged) == 1 + web_tool = None + for tool in merged: + if isinstance(tool, WebSearchTool): + web_tool = tool + break + else: + web_tool = None + assert web_tool is None + def test_merge_builtin_tools_none_handling(): """Test that merge_builtin_tools handles None correctly.""" @@ -43,6 +59,8 @@ def test_merge_builtin_tools_none_handling(): if isinstance(tool, WebSearchTool): web_tool = tool break + else: + web_tool = None assert web_tool is not None assert web_tool.allowed_domains == ['base.com'] @@ -54,6 +72,8 @@ def test_merge_builtin_tools_none_handling(): if isinstance(tool, WebSearchTool): web_tool = tool break + else: + web_tool = None assert web_tool is not None assert web_tool.allowed_domains == ['runtime.com'] @@ -61,6 +81,30 @@ def test_merge_builtin_tools_none_handling(): merged = merge_builtin_tools(None, None) assert len(merged) == 0 + base_tools = [CodeExecutionTool()] + merged = merge_builtin_tools(base_tools, None) + assert len(merged) == 1 + web_tool = None + for tool in merged: + if isinstance(tool, WebSearchTool): + web_tool = tool + break + else: + web_tool = None + assert web_tool is None + + runtime_tools = [CodeExecutionTool()] + merged = merge_builtin_tools(None, runtime_tools) + assert len(merged) == 1 + web_tool = None + for tool in merged: + if isinstance(tool, WebSearchTool): + web_tool = tool + break + else: + web_tool = None + assert web_tool is None + def test_agent_builtin_tools_runtime_parameter(): """Test that Agent.run_sync accepts builtin_tools parameter.""" From 00d2448a8d6490b2e2e13b052ac4288fb7d6a80e Mon Sep 17 00:00:00 2001 From: safina57 Date: Fri, 3 Oct 2025 08:06:39 +0100 Subject: [PATCH 3/4] refactor: Remove merge_builtin_tools function and update Agent to handle builtin_tools directly + update unit tests --- .../pydantic_ai/agent/__init__.py | 9 +- pydantic_ai_slim/pydantic_ai/builtin_tools.py | 37 ---- tests/test_builtin_tools_runtime.py | 163 ++++++------------ 3 files changed, 63 insertions(+), 146 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index 37e712a80f..176e431be2 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -36,7 +36,7 @@ ) from .._output import OutputToolset from .._tool_manager import ToolManager -from ..builtin_tools import AbstractBuiltinTool, merge_builtin_tools +from ..builtin_tools import AbstractBuiltinTool from ..models.instrumented import InstrumentationSettings, InstrumentedModel, instrument_model from ..output import OutputDataT, OutputSpec from ..profiles import ModelProfile @@ -631,8 +631,11 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None: output_schema=output_schema, output_validators=output_validators, history_processors=self.history_processors, - builtin_tools=merge_builtin_tools( - list(self._builtin_tools), list(builtin_tools) if builtin_tools else None + builtin_tools=list( + { + **({type(tool): tool for tool in self._builtin_tools or []}), + **({type(tool): tool for tool in builtin_tools or []}), + }.values() ), tool_manager=tool_manager, tracer=tracer, diff --git a/pydantic_ai_slim/pydantic_ai/builtin_tools.py b/pydantic_ai_slim/pydantic_ai/builtin_tools.py index f68884916f..ca421bba0a 100644 --- a/pydantic_ai_slim/pydantic_ai/builtin_tools.py +++ b/pydantic_ai_slim/pydantic_ai/builtin_tools.py @@ -1,6 +1,5 @@ from __future__ import annotations as _annotations -from collections.abc import Sequence from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -138,39 +137,3 @@ class UrlContextTool(AbstractBuiltinTool): kind: str = 'url_context' """The kind of tool.""" - - -def merge_builtin_tools( - base: Sequence[AbstractBuiltinTool] | None, runtime: Sequence[AbstractBuiltinTool] | None -) -> list[AbstractBuiltinTool]: - """Merge two sets of builtin tools, with runtime tools having priority over base tools. - - Args: - base: The base builtin tools (e.g., from agent initialization) - runtime: The runtime builtin tools (e.g., from agent.run()) - - Returns: - A merged list of builtin tools with duplicates removed by type. - Runtime tools take priority over base tools when both have the same type. - """ - if not base and not runtime: - return [] - - if not base: - return list(runtime) if runtime else [] - - if not runtime: - return list(base) - - # Create a mapping of tool types to tools, with runtime tools taking priority - tool_map: dict[type[AbstractBuiltinTool], AbstractBuiltinTool] = {} - - # Add base tools first - for tool in base: - tool_map[type(tool)] = tool - - # Add runtime tools, which will override base tools of the same type - for tool in runtime: - tool_map[type(tool)] = tool - - return list(tool_map.values()) diff --git a/tests/test_builtin_tools_runtime.py b/tests/test_builtin_tools_runtime.py index a58ee0bb58..dec9e64a5a 100644 --- a/tests/test_builtin_tools_runtime.py +++ b/tests/test_builtin_tools_runtime.py @@ -1,138 +1,89 @@ import pytest from pydantic_ai import Agent -from pydantic_ai.builtin_tools import CodeExecutionTool, WebSearchTool, merge_builtin_tools +from pydantic_ai.builtin_tools import WebSearchTool from pydantic_ai.models.test import TestModel -def test_merge_builtin_tools_basic(): - """Test that merge_builtin_tools function works correctly.""" - # Test merging with different tool types - base_tools = [WebSearchTool(allowed_domains=['base.com'])] - runtime_tools = [CodeExecutionTool()] - - merged = merge_builtin_tools(base_tools, runtime_tools) - assert len(merged) == 2 - - # Test merging with same tool types (runtime should override) - base_tools = [WebSearchTool(allowed_domains=['base.com'])] - runtime_tools = [WebSearchTool(allowed_domains=['runtime.com'])] - - merged = merge_builtin_tools(base_tools, runtime_tools) - assert len(merged) == 1 - # Check that we got the runtime tool (need to check specific attributes) - web_tool = None - for tool in merged: - if isinstance(tool, WebSearchTool): - web_tool = tool - break - else: - web_tool = None - assert web_tool is not None - assert web_tool.allowed_domains == ['runtime.com'] - - base_tools = [CodeExecutionTool()] - runtime_tools = [CodeExecutionTool()] - - merged = merge_builtin_tools(base_tools, runtime_tools) - assert len(merged) == 1 - web_tool = None - for tool in merged: - if isinstance(tool, WebSearchTool): - web_tool = tool - break - else: - web_tool = None - assert web_tool is None - - -def test_merge_builtin_tools_none_handling(): - """Test that merge_builtin_tools handles None correctly.""" - base_tools = [WebSearchTool(allowed_domains=['base.com'])] - runtime_tools = [WebSearchTool(allowed_domains=['runtime.com'])] - - # Test with None runtime tools - merged = merge_builtin_tools(base_tools, None) - assert len(merged) == 1 - web_tool = None - for tool in merged: - if isinstance(tool, WebSearchTool): - web_tool = tool - break - else: - web_tool = None - assert web_tool is not None - assert web_tool.allowed_domains == ['base.com'] - - # Test with None base tools - merged = merge_builtin_tools(None, runtime_tools) - assert len(merged) == 1 - web_tool = None - for tool in merged: - if isinstance(tool, WebSearchTool): - web_tool = tool - break - else: - web_tool = None - assert web_tool is not None - assert web_tool.allowed_domains == ['runtime.com'] - - # Test with both None - merged = merge_builtin_tools(None, None) - assert len(merged) == 0 - - base_tools = [CodeExecutionTool()] - merged = merge_builtin_tools(base_tools, None) - assert len(merged) == 1 - web_tool = None - for tool in merged: - if isinstance(tool, WebSearchTool): - web_tool = tool - break - else: - web_tool = None - assert web_tool is None - - runtime_tools = [CodeExecutionTool()] - merged = merge_builtin_tools(None, runtime_tools) - assert len(merged) == 1 - web_tool = None - for tool in merged: - if isinstance(tool, WebSearchTool): - web_tool = tool - break - else: - web_tool = None - assert web_tool is None - - def test_agent_builtin_tools_runtime_parameter(): """Test that Agent.run_sync accepts builtin_tools parameter.""" - agent = Agent(model=TestModel(), builtin_tools=[]) + model = TestModel() + agent = Agent(model=model, builtin_tools=[]) # Should work with empty builtin_tools result = agent.run_sync('Hello', builtin_tools=[]) assert result.output == 'success (no tool calls)' + assert model.last_model_request_parameters is not None + assert model.last_model_request_parameters.builtin_tools == [] + async def test_agent_builtin_tools_runtime_parameter_async(): """Test that Agent.run and Agent.run_stream accept builtin_tools parameter.""" - agent = Agent(model=TestModel(), builtin_tools=[]) + model = TestModel() + agent = Agent(model=model, builtin_tools=[]) # Test async run result = await agent.run('Hello', builtin_tools=[]) assert result.output == 'success (no tool calls)' + assert model.last_model_request_parameters is not None + assert model.last_model_request_parameters.builtin_tools == [] + # Test run_stream async with agent.run_stream('Hello', builtin_tools=[]) as stream: output = await stream.get_output() assert output == 'success (no tool calls)' + assert model.last_model_request_parameters is not None + assert model.last_model_request_parameters.builtin_tools == [] + def test_agent_builtin_tools_testmodel_rejection(): """Test that TestModel rejects builtin tools as expected.""" - agent = Agent(model=TestModel(), builtin_tools=[]) + model = TestModel() + agent = Agent(model=model, builtin_tools=[]) # Should raise error when builtin_tools contains actual tools + web_search_tool = WebSearchTool() + with pytest.raises(Exception, match='TestModel does not support built-in tools'): + agent.run_sync('Hello', builtin_tools=[web_search_tool]) + + assert model.last_model_request_parameters is not None + assert len(model.last_model_request_parameters.builtin_tools) == 1 + assert model.last_model_request_parameters.builtin_tools[0] == web_search_tool + + +def test_agent_builtin_tools_runtime_vs_agent_level(): + """Test that runtime builtin_tools parameter is merged with agent-level builtin_tools.""" + model = TestModel() + web_search_tool = WebSearchTool() + + # Agent has builtin tools, and we provide same type at runtime + agent = Agent(model=model, builtin_tools=[web_search_tool]) + + # Runtime tool of same type should override agent-level tool + different_web_search = WebSearchTool(search_context_size='high') with pytest.raises(Exception, match='TestModel does not support built-in tools'): - agent.run_sync('Hello', builtin_tools=[WebSearchTool()]) + agent.run_sync('Hello', builtin_tools=[different_web_search]) + + assert model.last_model_request_parameters is not None + assert len(model.last_model_request_parameters.builtin_tools) == 1 + runtime_tool = model.last_model_request_parameters.builtin_tools[0] + assert isinstance(runtime_tool, WebSearchTool) + assert runtime_tool.search_context_size == 'high' + + +def test_agent_builtin_tools_runtime_additional(): + """Test that runtime builtin_tools can add to agent-level builtin_tools when different types.""" + model = TestModel() + web_search_tool = WebSearchTool() + + agent = Agent(model=model, builtin_tools=[]) + + with pytest.raises(Exception, match='TestModel does not support built-in tools'): + agent.run_sync('Hello', builtin_tools=[web_search_tool]) + + assert model.last_model_request_parameters is not None + assert len(model.last_model_request_parameters.builtin_tools) == 1 + assert model.last_model_request_parameters.builtin_tools[0] == web_search_tool From 3d71ff7a0578f4a3d312752afeb5daf09c0d7986 Mon Sep 17 00:00:00 2001 From: safina57 Date: Sat, 4 Oct 2025 16:52:54 +0100 Subject: [PATCH 4/4] refactor: move builtin_tools tests to agent tests and update builtin_tools merge logic placement --- .../pydantic_ai/agent/__init__.py | 18 ++-- pydantic_ai_slim/pydantic_ai/builtin_tools.py | 9 +- tests/test_agent.py | 85 ++++++++++++++++++ tests/test_builtin_tools_runtime.py | 89 ------------------- 4 files changed, 100 insertions(+), 101 deletions(-) delete mode 100644 tests/test_builtin_tools_runtime.py diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index c303701bc9..a3ec7024c6 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -605,7 +605,16 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None: else: instrumentation_settings = None tracer = NoOpTracer() - + if builtin_tools: + # Deduplicate builtin tools passed to the agent and the run based on type + builtin_tools = list( + { + **({type(tool): tool for tool in self._builtin_tools or []}), + **({type(tool): tool for tool in builtin_tools}), + }.values() + ) + else: + builtin_tools = list(self._builtin_tools) graph_deps = _agent_graph.GraphAgentDeps[AgentDepsT, RunOutputDataT]( user_deps=deps, prompt=user_prompt, @@ -618,12 +627,7 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None: output_schema=output_schema, output_validators=output_validators, history_processors=self.history_processors, - builtin_tools=list( - { - **({type(tool): tool for tool in self._builtin_tools or []}), - **({type(tool): tool for tool in builtin_tools or []}), - }.values() - ), + builtin_tools=builtin_tools, tool_manager=tool_manager, tracer=tracer, get_instructions=get_instructions, diff --git a/pydantic_ai_slim/pydantic_ai/builtin_tools.py b/pydantic_ai_slim/pydantic_ai/builtin_tools.py index 3910e07062..d15c587a6c 100644 --- a/pydantic_ai_slim/pydantic_ai/builtin_tools.py +++ b/pydantic_ai_slim/pydantic_ai/builtin_tools.py @@ -1,15 +1,14 @@ from __future__ import annotations as _annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .builtin_tools import AbstractBuiltinTool from abc import ABC from dataclasses import dataclass -from typing import Literal +from typing import TYPE_CHECKING, Literal from typing_extensions import TypedDict +if TYPE_CHECKING: + from .builtin_tools import AbstractBuiltinTool + __all__ = ( 'AbstractBuiltinTool', 'WebSearchTool', diff --git a/tests/test_agent.py b/tests/test_agent.py index cf3a416a1a..30d4c733ae 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -57,6 +57,7 @@ ToolOutputSchema, ) from pydantic_ai.agent import AgentRunResult, WrapperAgent +from pydantic_ai.builtin_tools import WebSearchTool from pydantic_ai.models.function import AgentInfo, FunctionModel from pydantic_ai.models.test import TestModel from pydantic_ai.output import StructuredDict, ToolOutput @@ -5511,3 +5512,87 @@ def roll_dice() -> int: ) assert not any(isinstance(p, ToolReturnPart) and p.tool_name == 'final_result' for p in new_messages[0].parts) + + +def test_agent_builtin_tools_runtime_parameter(): + """Test that Agent.run_sync accepts builtin_tools parameter.""" + model = TestModel() + agent = Agent(model=model, builtin_tools=[]) + + # Should work with empty builtin_tools + result = agent.run_sync('Hello', builtin_tools=[]) + assert result.output == 'success (no tool calls)' + + assert model.last_model_request_parameters is not None + assert model.last_model_request_parameters.builtin_tools == [] + + +async def test_agent_builtin_tools_runtime_parameter_async(): + """Test that Agent.run and Agent.run_stream accept builtin_tools parameter.""" + model = TestModel() + agent = Agent(model=model, builtin_tools=[]) + + # Test async run + result = await agent.run('Hello', builtin_tools=[]) + assert result.output == 'success (no tool calls)' + + assert model.last_model_request_parameters is not None + assert model.last_model_request_parameters.builtin_tools == [] + + # Test run_stream + async with agent.run_stream('Hello', builtin_tools=[]) as stream: + output = await stream.get_output() + assert output == 'success (no tool calls)' + + assert model.last_model_request_parameters is not None + assert model.last_model_request_parameters.builtin_tools == [] + + +def test_agent_builtin_tools_testmodel_rejection(): + """Test that TestModel rejects builtin tools as expected.""" + model = TestModel() + agent = Agent(model=model, builtin_tools=[]) + + # Should raise error when builtin_tools contains actual tools + web_search_tool = WebSearchTool() + with pytest.raises(Exception, match='TestModel does not support built-in tools'): + agent.run_sync('Hello', builtin_tools=[web_search_tool]) + + assert model.last_model_request_parameters is not None + assert len(model.last_model_request_parameters.builtin_tools) == 1 + assert model.last_model_request_parameters.builtin_tools[0] == web_search_tool + + +def test_agent_builtin_tools_runtime_vs_agent_level(): + """Test that runtime builtin_tools parameter is merged with agent-level builtin_tools.""" + model = TestModel() + web_search_tool = WebSearchTool() + + # Agent has builtin tools, and we provide same type at runtime + agent = Agent(model=model, builtin_tools=[web_search_tool]) + + # Runtime tool of same type should override agent-level tool + different_web_search = WebSearchTool(search_context_size='high') + with pytest.raises(Exception, match='TestModel does not support built-in tools'): + agent.run_sync('Hello', builtin_tools=[different_web_search]) + + assert model.last_model_request_parameters is not None + assert len(model.last_model_request_parameters.builtin_tools) == 1 + runtime_tool = model.last_model_request_parameters.builtin_tools[0] + assert isinstance(runtime_tool, WebSearchTool) + assert runtime_tool.search_context_size == 'high' + + +def test_agent_builtin_tools_runtime_additional(): + """Test that runtime builtin_tools can add to agent-level builtin_tools when different types.""" + model = TestModel() + web_search_tool = WebSearchTool() + + agent = Agent(model=model, builtin_tools=[]) + + with pytest.raises(Exception, match='TestModel does not support built-in tools'): + agent.run_sync('Hello', builtin_tools=[web_search_tool]) + + assert model.last_model_request_parameters is not None + assert len(model.last_model_request_parameters.builtin_tools) == 1 + assert model.last_model_request_parameters.builtin_tools[0] == web_search_tool diff --git a/tests/test_builtin_tools_runtime.py b/tests/test_builtin_tools_runtime.py deleted file mode 100644 index dec9e64a5a..0000000000 --- a/tests/test_builtin_tools_runtime.py +++ /dev/null @@ -1,89 +0,0 @@ -import pytest - -from pydantic_ai import Agent -from pydantic_ai.builtin_tools import WebSearchTool -from pydantic_ai.models.test import TestModel - - -def test_agent_builtin_tools_runtime_parameter(): - """Test that Agent.run_sync accepts builtin_tools parameter.""" - model = TestModel() - agent = Agent(model=model, builtin_tools=[]) - - # Should work with empty builtin_tools - result = agent.run_sync('Hello', builtin_tools=[]) - assert result.output == 'success (no tool calls)' - - assert model.last_model_request_parameters is not None - assert model.last_model_request_parameters.builtin_tools == [] - - -async def test_agent_builtin_tools_runtime_parameter_async(): - """Test that Agent.run and Agent.run_stream accept builtin_tools parameter.""" - model = TestModel() - agent = Agent(model=model, builtin_tools=[]) - - # Test async run - result = await agent.run('Hello', builtin_tools=[]) - assert result.output == 'success (no tool calls)' - - assert model.last_model_request_parameters is not None - assert model.last_model_request_parameters.builtin_tools == [] - - # Test run_stream - async with agent.run_stream('Hello', builtin_tools=[]) as stream: - output = await stream.get_output() - assert output == 'success (no tool calls)' - - assert model.last_model_request_parameters is not None - assert model.last_model_request_parameters.builtin_tools == [] - - -def test_agent_builtin_tools_testmodel_rejection(): - """Test that TestModel rejects builtin tools as expected.""" - model = TestModel() - agent = Agent(model=model, builtin_tools=[]) - - # Should raise error when builtin_tools contains actual tools - web_search_tool = WebSearchTool() - with pytest.raises(Exception, match='TestModel does not support built-in tools'): - agent.run_sync('Hello', builtin_tools=[web_search_tool]) - - assert model.last_model_request_parameters is not None - assert len(model.last_model_request_parameters.builtin_tools) == 1 - assert model.last_model_request_parameters.builtin_tools[0] == web_search_tool - - -def test_agent_builtin_tools_runtime_vs_agent_level(): - """Test that runtime builtin_tools parameter is merged with agent-level builtin_tools.""" - model = TestModel() - web_search_tool = WebSearchTool() - - # Agent has builtin tools, and we provide same type at runtime - agent = Agent(model=model, builtin_tools=[web_search_tool]) - - # Runtime tool of same type should override agent-level tool - different_web_search = WebSearchTool(search_context_size='high') - with pytest.raises(Exception, match='TestModel does not support built-in tools'): - agent.run_sync('Hello', builtin_tools=[different_web_search]) - - assert model.last_model_request_parameters is not None - assert len(model.last_model_request_parameters.builtin_tools) == 1 - runtime_tool = model.last_model_request_parameters.builtin_tools[0] - assert isinstance(runtime_tool, WebSearchTool) - assert runtime_tool.search_context_size == 'high' - - -def test_agent_builtin_tools_runtime_additional(): - """Test that runtime builtin_tools can add to agent-level builtin_tools when different types.""" - model = TestModel() - web_search_tool = WebSearchTool() - - agent = Agent(model=model, builtin_tools=[]) - - with pytest.raises(Exception, match='TestModel does not support built-in tools'): - agent.run_sync('Hello', builtin_tools=[web_search_tool]) - - assert model.last_model_request_parameters is not None - assert len(model.last_model_request_parameters.builtin_tools) == 1 - assert model.last_model_request_parameters.builtin_tools[0] == web_search_tool