diff --git a/docs/durable_execution/temporal.md b/docs/durable_execution/temporal.md index f52626caff..3ce48c83e8 100644 --- a/docs/durable_execution/temporal.md +++ b/docs/durable_execution/temporal.md @@ -92,7 +92,7 @@ from pydantic_ai.durable_exec.temporal import ( ) agent = Agent( - 'gpt-5', + 'openai:gpt-5', instructions="You're an expert in geography.", name='geography', # (10)! ) @@ -158,6 +158,8 @@ To ensure that Temporal knows what code to run when an activity fails or is inte When `TemporalAgent` dynamically creates activities for the wrapped agent's model requests and toolsets (specifically those that implement their own tool listing and calling, i.e. [`FunctionToolset`][pydantic_ai.toolsets.FunctionToolset] and [`MCPServer`][pydantic_ai.mcp.MCPServer]), their names are derived from the agent's [`name`][pydantic_ai.agent.AbstractAgent.name] and the toolsets' [`id`s][pydantic_ai.toolsets.AbstractToolset.id]. These fields are normally optional, but are required to be set when using Temporal. They should not be changed once the durable agent has been deployed to production as this would break active workflows. +For dynamic toolsets created with the [`@agent.toolset`][pydantic_ai.Agent.toolset] decorator, the `id` parameter must be set explicitly. Note that with Temporal, `per_run_step=False` is not respected, as the toolset always needs to be created on-the-fly in the activity. + Other than that, any agent and toolset will just work! ### Instructions Functions, Output Functions, and History Processors diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index 19edb4a619..c0affbd74a 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -1242,6 +1242,7 @@ def toolset( /, *, per_run_step: bool = True, + id: str | None = None, ) -> Callable[[ToolsetFunc[AgentDepsT]], ToolsetFunc[AgentDepsT]]: ... def toolset( @@ -1250,6 +1251,7 @@ def toolset( /, *, per_run_step: bool = True, + id: str | None = None, ) -> Any: """Decorator to register a toolset function which takes [`RunContext`][pydantic_ai.tools.RunContext] as its only argument. @@ -1271,10 +1273,12 @@ async def simple_toolset(ctx: RunContext[str]) -> AbstractToolset[str]: Args: func: The toolset function to register. per_run_step: Whether to re-evaluate the toolset for each run step. Defaults to True. + id: An optional unique ID for the dynamic toolset. Required for use with durable execution + environments like Temporal, where the ID identifies the toolset's activities within the workflow. """ def toolset_decorator(func_: ToolsetFunc[AgentDepsT]) -> ToolsetFunc[AgentDepsT]: - self._dynamic_toolsets.append(DynamicToolset(func_, per_run_step=per_run_step)) + self._dynamic_toolsets.append(DynamicToolset(func_, per_run_step=per_run_step, id=id)) return func_ return toolset_decorator if func is None else toolset_decorator(func) @@ -1378,7 +1382,7 @@ def _get_toolset( # Copy the dynamic toolsets to ensure each run has its own instances def copy_dynamic_toolsets(toolset: AbstractToolset[AgentDepsT]) -> AbstractToolset[AgentDepsT]: if isinstance(toolset, DynamicToolset): - return dataclasses.replace(toolset) + return toolset.copy() else: return toolset diff --git a/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_dynamic_toolset.py b/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_dynamic_toolset.py new file mode 100644 index 0000000000..2e3e86768c --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_dynamic_toolset.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Literal + +from pydantic import ConfigDict, with_config +from temporalio import activity, workflow +from temporalio.workflow import ActivityConfig +from typing_extensions import Self + +from pydantic_ai import ToolsetTool +from pydantic_ai.exceptions import UserError +from pydantic_ai.tools import AgentDepsT, RunContext, ToolDefinition +from pydantic_ai.toolsets._dynamic import DynamicToolset +from pydantic_ai.toolsets.external import TOOL_SCHEMA_VALIDATOR + +from ._run_context import TemporalRunContext +from ._toolset import ( + CallToolParams, + CallToolResult, + TemporalWrapperToolset, +) + + +@dataclass +@with_config(ConfigDict(arbitrary_types_allowed=True)) +class _GetToolsParams: + serialized_run_context: Any + + +@dataclass +class _ToolInfo: + """Serializable tool information returned from get_tools_activity.""" + + tool_def: ToolDefinition + max_retries: int + + +class TemporalDynamicToolset(TemporalWrapperToolset[AgentDepsT]): + """Temporal wrapper for DynamicToolset. + + This provides static activities (get_tools, call_tool) that are registered at worker start time, + while the actual toolset selection happens dynamically inside the activities where I/O is allowed. + """ + + def __init__( + self, + toolset: DynamicToolset[AgentDepsT], + *, + activity_name_prefix: str, + activity_config: ActivityConfig, + tool_activity_config: dict[str, ActivityConfig | Literal[False]], + deps_type: type[AgentDepsT], + run_context_type: type[TemporalRunContext[AgentDepsT]] = TemporalRunContext[AgentDepsT], + ): + super().__init__(toolset) + self.activity_config = activity_config + self.tool_activity_config = tool_activity_config + self.run_context_type = run_context_type + + async def get_tools_activity(params: _GetToolsParams, deps: AgentDepsT) -> dict[str, _ToolInfo]: + """Activity that calls the dynamic function and returns tool definitions.""" + ctx = self.run_context_type.deserialize_run_context(params.serialized_run_context, deps=deps) + + async with self.wrapped: + tools = await self.wrapped.get_tools(ctx) + return { + name: _ToolInfo(tool_def=tool.tool_def, max_retries=tool.max_retries) + for name, tool in tools.items() + } + + get_tools_activity.__annotations__['deps'] = deps_type + + self.get_tools_activity = activity.defn(name=f'{activity_name_prefix}__dynamic_toolset__{self.id}__get_tools')( + get_tools_activity + ) + + async def call_tool_activity(params: CallToolParams, deps: AgentDepsT) -> CallToolResult: + """Activity that instantiates the dynamic toolset and calls the tool.""" + ctx = self.run_context_type.deserialize_run_context(params.serialized_run_context, deps=deps) + + async with self.wrapped: + tools = await self.wrapped.get_tools(ctx) + tool = tools.get(params.name) + if tool is None: # pragma: no cover + raise UserError( + f'Tool {params.name!r} not found in dynamic toolset {self.id!r}. ' + 'The dynamic toolset function may have returned a different toolset than expected.' + ) + + return await self._call_tool_in_activity(params.name, params.tool_args, ctx, tool) + + call_tool_activity.__annotations__['deps'] = deps_type + + self.call_tool_activity = activity.defn(name=f'{activity_name_prefix}__dynamic_toolset__{self.id}__call_tool')( + call_tool_activity + ) + + @property + def temporal_activities(self) -> list[Callable[..., Any]]: + return [self.get_tools_activity, self.call_tool_activity] + + async def __aenter__(self) -> Self: + if not workflow.in_workflow(): + await self.wrapped.__aenter__() + return self + + async def __aexit__(self, *args: Any) -> bool | None: + if not workflow.in_workflow(): + return await self.wrapped.__aexit__(*args) + return None + + async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]: + if not workflow.in_workflow(): + return await super().get_tools(ctx) + + serialized_run_context = self.run_context_type.serialize_run_context(ctx) + tool_infos = await workflow.execute_activity( + activity=self.get_tools_activity, + args=[ + _GetToolsParams(serialized_run_context=serialized_run_context), + ctx.deps, + ], + **self.activity_config, + ) + return {name: self._tool_for_tool_info(tool_info) for name, tool_info in tool_infos.items()} + + async def call_tool( + self, + name: str, + tool_args: dict[str, Any], + ctx: RunContext[AgentDepsT], + tool: ToolsetTool[AgentDepsT], + ) -> Any: + if not workflow.in_workflow(): + return await super().call_tool(name, tool_args, ctx, tool) + + tool_activity_config = self.tool_activity_config.get(name) + if tool_activity_config is False: # pragma: no cover + return await super().call_tool(name, tool_args, ctx, tool) + + merged_config = self.activity_config | (tool_activity_config or {}) + serialized_run_context = self.run_context_type.serialize_run_context(ctx) + return self._unwrap_call_tool_result( + await workflow.execute_activity( + activity=self.call_tool_activity, + args=[ + CallToolParams( + name=name, + tool_args=tool_args, + serialized_run_context=serialized_run_context, + tool_def=tool.tool_def, + ), + ctx.deps, + ], + **merged_config, + ) + ) + + def _tool_for_tool_info(self, tool_info: _ToolInfo) -> ToolsetTool[AgentDepsT]: + """Create a ToolsetTool from a _ToolInfo for use outside activities. + + We use `TOOL_SCHEMA_VALIDATOR` here which just parses JSON without additional validation, + because the actual args validation happens inside `call_tool_activity`. + """ + return ToolsetTool( + toolset=self, + tool_def=tool_info.tool_def, + max_retries=tool_info.max_retries, + args_validator=TOOL_SCHEMA_VALIDATOR, + ) diff --git a/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_function_toolset.py b/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_function_toolset.py index 05bc3f5f2c..8f9a2cd85b 100644 --- a/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_function_toolset.py +++ b/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_function_toolset.py @@ -46,10 +46,7 @@ async def call_tool_activity(params: CallToolParams, deps: AgentDepsT) -> CallTo 'Removing or renaming tools during an agent run is not supported with Temporal.' ) from e - # The tool args will already have been validated into their proper types in the `ToolManager`, - # but `execute_activity` would have turned them into simple Python types again, so we need to re-validate them. - args_dict = tool.args_validator.validate_python(params.tool_args) - return await self._wrap_call_tool_result(self.wrapped.call_tool(name, args_dict, ctx, tool)) + return await self._call_tool_in_activity(name, params.tool_args, ctx, tool) # Set type hint explicitly so that Temporal can take care of serialization and deserialization call_tool_activity.__annotations__['deps'] = deps_type diff --git a/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_toolset.py b/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_toolset.py index 850f001a46..c8c8535e5e 100644 --- a/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_toolset.py +++ b/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_toolset.py @@ -9,9 +9,10 @@ from temporalio.workflow import ActivityConfig from typing_extensions import assert_never -from pydantic_ai import AbstractToolset, FunctionToolset, WrapperToolset +from pydantic_ai import AbstractToolset, FunctionToolset, ToolsetTool, WrapperToolset from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry -from pydantic_ai.tools import AgentDepsT, ToolDefinition +from pydantic_ai.tools import AgentDepsT, RunContext, ToolDefinition +from pydantic_ai.toolsets._dynamic import DynamicToolset from ._run_context import TemporalRunContext @@ -96,6 +97,21 @@ def _unwrap_call_tool_result(self, result: CallToolResult) -> Any: else: assert_never(result) + async def _call_tool_in_activity( + self, + name: str, + tool_args: dict[str, Any], + ctx: RunContext[AgentDepsT], + tool: ToolsetTool[AgentDepsT], + ) -> CallToolResult: + """Call a tool inside an activity, re-validating args that were deserialized. + + The tool args will already have been validated into their proper types in the `ToolManager`, + but `execute_activity` would have turned them into simple Python types again, so we need to re-validate them. + """ + args_dict = tool.args_validator.validate_python(tool_args) + return await self._wrap_call_tool_result(self.wrapped.call_tool(name, args_dict, ctx, tool)) + def temporalize_toolset( toolset: AbstractToolset[AgentDepsT], @@ -127,6 +143,18 @@ def temporalize_toolset( run_context_type=run_context_type, ) + if isinstance(toolset, DynamicToolset): + from ._dynamic_toolset import TemporalDynamicToolset + + return TemporalDynamicToolset( + toolset, + activity_name_prefix=activity_name_prefix, + activity_config=activity_config, + tool_activity_config=tool_activity_config, + deps_type=deps_type, + run_context_type=run_context_type, + ) + try: from pydantic_ai.mcp import MCPServer diff --git a/pydantic_ai_slim/pydantic_ai/toolsets/_dynamic.py b/pydantic_ai_slim/pydantic_ai/toolsets/_dynamic.py index 9bb622ce23..aa4e3a764a 100644 --- a/pydantic_ai_slim/pydantic_ai/toolsets/_dynamic.py +++ b/pydantic_ai_slim/pydantic_ai/toolsets/_dynamic.py @@ -2,10 +2,9 @@ import inspect from collections.abc import Awaitable, Callable -from dataclasses import dataclass, replace -from typing import Any, TypeAlias +from typing import Any, Generic, TypeAlias -from typing_extensions import Self +from typing_extensions import Self, TypedDict from .._run_context import AgentDepsT, RunContext from .abstract import AbstractToolset, ToolsetTool @@ -17,39 +16,82 @@ """A sync/async function which takes a run context and returns a toolset.""" -@dataclass +class ToolsetRunStep(TypedDict, Generic[AgentDepsT]): + """State for a DynamicToolset for a specific run.""" + + toolset: AbstractToolset[AgentDepsT] | None + run_step: int | None + + class DynamicToolset(AbstractToolset[AgentDepsT]): """A toolset that dynamically builds a toolset using a function that takes the run context. - It should only be used during a single agent run as it stores the generated toolset. - To use it multiple times, copy it using `dataclasses.replace`. + State is isolated per run using `ctx.run_id` as a key, allowing the same instance + to be safely reused across multiple agent runs. """ toolset_func: ToolsetFunc[AgentDepsT] - per_run_step: bool = True - - _toolset: AbstractToolset[AgentDepsT] | None = None - _run_step: int | None = None + per_run_step: bool + _id: str | None + _toolset_runstep: dict[str, ToolsetRunStep[AgentDepsT]] + + def __init__( + self, + toolset_func: ToolsetFunc[AgentDepsT], + *, + per_run_step: bool = True, + id: str | None = None, + ): + """Build a new dynamic toolset. + + Args: + toolset_func: A function that takes the run context and returns a toolset or None. + per_run_step: Whether to re-evaluate the toolset for each run step. Defaults to True. + 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. + """ + self.toolset_func = toolset_func + self.per_run_step = per_run_step + self._id = id + self._toolset_runstep = {} @property def id(self) -> str | None: - return None # pragma: no cover + return self._id + + def copy(self) -> DynamicToolset[AgentDepsT]: + """Create a copy of this toolset for use in a new agent run.""" + return DynamicToolset( + self.toolset_func, + per_run_step=self.per_run_step, + id=self._id, + ) async def __aenter__(self) -> Self: return self async def __aexit__(self, *args: Any) -> bool | None: try: - if self._toolset is not None: - return await self._toolset.__aexit__(*args) + result = None + for run_state in self._toolset_runstep.values(): + if run_state['toolset'] is not None: + result = await run_state['toolset'].__aexit__(*args) finally: - self._toolset = None - self._run_step = None + self._toolset_runstep.clear() + return result async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]: - if self._toolset is None or (self.per_run_step and ctx.run_step != self._run_step): - if self._toolset is not None: - await self._toolset.__aexit__() + run_id = ctx.run_id or '__default__' + + if run_id not in self._toolset_runstep: + self._toolset_runstep[run_id] = {'toolset': None, 'run_step': None} + + run_state = self._toolset_runstep[run_id] + + if run_state['toolset'] is None or (self.per_run_step and ctx.run_step != run_state['run_step']): + if run_state['toolset'] is not None: + await run_state['toolset'].__aexit__() toolset = self.toolset_func(ctx) if inspect.isawaitable(toolset): @@ -58,30 +100,43 @@ async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[ if toolset is not None: await toolset.__aenter__() - self._toolset = toolset - self._run_step = ctx.run_step + run_state['toolset'] = toolset + run_state['run_step'] = ctx.run_step - if self._toolset is None: + if run_state['toolset'] is None: return {} - return await self._toolset.get_tools(ctx) + return await run_state['toolset'].get_tools(ctx) async def call_tool( self, name: str, tool_args: dict[str, Any], ctx: RunContext[AgentDepsT], tool: ToolsetTool[AgentDepsT] ) -> Any: - assert self._toolset is not None - return await self._toolset.call_tool(name, tool_args, ctx, tool) + run_id = ctx.run_id or '__default__' + run_state = self._toolset_runstep.get(run_id) + assert run_state is not None and run_state['toolset'] is not None + return await run_state['toolset'].call_tool(name, tool_args, ctx, tool) def apply(self, visitor: Callable[[AbstractToolset[AgentDepsT]], None]) -> None: - if self._toolset is None: + wrapped_toolsets = [rs['toolset'] for rs in self._toolset_runstep.values() if rs['toolset'] is not None] + if not wrapped_toolsets: super().apply(visitor) else: - self._toolset.apply(visitor) + for toolset in wrapped_toolsets: + toolset.apply(visitor) def visit_and_replace( self, visitor: Callable[[AbstractToolset[AgentDepsT]], AbstractToolset[AgentDepsT]] ) -> AbstractToolset[AgentDepsT]: - if self._toolset is None: + wrapped_items = [(run_id, rs) for run_id, rs in self._toolset_runstep.items() if rs['toolset'] is not None] + if not wrapped_items: return super().visit_and_replace(visitor) else: - return replace(self, _toolset=self._toolset.visit_and_replace(visitor)) + new_copy = self.copy() + for run_id, run_state in wrapped_items: + toolset = run_state['toolset'] + assert toolset is not None + new_copy._toolset_runstep[run_id] = { + 'toolset': toolset.visit_and_replace(visitor), + 'run_step': run_state['run_step'], + } + return new_copy diff --git a/tests/cassettes/test_temporal/test_fastmcp_dynamic_toolset_in_workflow.yaml b/tests/cassettes/test_temporal/test_fastmcp_dynamic_toolset_in_workflow.yaml new file mode 100644 index 0000000000..6096172400 --- /dev/null +++ b/tests/cassettes/test_temporal/test_fastmcp_dynamic_toolset_in_workflow.yaml @@ -0,0 +1,1187 @@ +interactions: +- request: + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '152' + content-type: + - application/json + host: + - mcp.deepwiki.com + method: POST + parsed_body: + id: 0 + jsonrpc: '2.0' + method: initialize + params: + capabilities: {} + clientInfo: + name: mcp + version: 0.1.0 + protocolVersion: '2025-06-18' + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: |+ + event: message + data: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"DeepWiki","version":"0.0.1"}}} + + event: ping + data: ping + + headers: + access-control-allow-headers: + - Content-Type, mcp-session-id, mcp-protocol-version + access-control-allow-methods: + - GET, POST, OPTIONS + access-control-allow-origin: + - '*' + access-control-expose-headers: + - mcp-session-id + access-control-max-age: + - '86400' + alt-svc: + - h3=":443"; ma=86400 + cache-control: + - no-cache + connection: + - keep-alive + content-type: + - text/event-stream + mcp-session-id: + - cdedaeddac989e6130483fada3a2be512ac7ebf53552449423f906d8b5f282e8 + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=lKi9XzYtX66W%2B9XfhoDyMxmKe8bQR16G%2F3hUc3LZx14AmbTBS6dNtTQDK5bDdco1JexrO%2FdFatKVj7Eays5k%2FggAxkS98QMxpe8Wivr%2F3Y7kXwtc1K6z7GFXu96S04eHvOkKVw%3D%3D"}]}' + transfer-encoding: + - chunked + vary: + - accept-encoding + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '54' + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - cdedaeddac989e6130483fada3a2be512ac7ebf53552449423f906d8b5f282e8 + method: POST + parsed_body: + jsonrpc: '2.0' + method: notifications/initialized + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: '' + headers: + access-control-allow-headers: + - Content-Type, mcp-session-id, mcp-protocol-version + access-control-allow-methods: + - GET, POST, OPTIONS + access-control-allow-origin: + - '*' + access-control-expose-headers: + - mcp-session-id + access-control-max-age: + - '86400' + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '0' + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=Dr%2BCTPCKwDsOm7SKZJX829UAswQJM7TGM5a9VDRmhVd%2FN2QIcY7xQNDYylIuQUav2MYv1dWv3qxceJMaywW7kNq93N1OO1xzoctsMCn704yHJLXUhduednBCrVkuQTJMPCTNdw%3D%3D"}]}' + vary: + - accept-encoding + status: + code: 202 + message: Accepted +- request: + body: '' + headers: + accept: + - application/json, text/event-stream, text/event-stream + accept-encoding: + - gzip, deflate + cache-control: + - no-store + connection: + - keep-alive + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - cdedaeddac989e6130483fada3a2be512ac7ebf53552449423f906d8b5f282e8 + method: GET + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed"},"id":null}' + headers: + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '82' + content-type: + - text/plain;charset=UTF-8 + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=7VNPpbENnzJhqksXXBCOI8%2FGxZFJRywD5pjWO7HBa6OPW0VGWqsa4HWlgc0yiiZFcoIrjRkIE1HDAlShfth7EzoROXxiJv9o4QtaQewqpKowR3DexVuyDBfvrjuU4FAcZBpy7g%3D%3D"}]}' + vary: + - accept-encoding + status: + code: 405 + message: Method Not Allowed +- request: + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '46' + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - cdedaeddac989e6130483fada3a2be512ac7ebf53552449423f906d8b5f282e8 + method: POST + parsed_body: + id: 1 + jsonrpc: '2.0' + method: tools/list + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: |+ + event: message + data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"read_wiki_structure","description":"Get a list of documentation topics for a GitHub repository","inputSchema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"name":"read_wiki_contents","description":"View documentation about a GitHub repository","inputSchema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"name":"ask_question","description":"Ask any question about a GitHub repository","inputSchema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"},"question":{"type":"string","description":"The question to ask about the repository"}},"required":["repoName","question"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}]}} + + event: ping + data: ping + + headers: + access-control-allow-headers: + - Content-Type, mcp-session-id, mcp-protocol-version + access-control-allow-methods: + - GET, POST, OPTIONS + access-control-allow-origin: + - '*' + access-control-expose-headers: + - mcp-session-id + access-control-max-age: + - '86400' + alt-svc: + - h3=":443"; ma=86400 + cache-control: + - no-cache + connection: + - keep-alive + content-type: + - text/event-stream + mcp-session-id: + - cdedaeddac989e6130483fada3a2be512ac7ebf53552449423f906d8b5f282e8 + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=cNMxdnon7Ewc9GmFCyuC2Z%2BKv2vnutahwvOaZ%2FSj6q3JcknZyWJJ4jI%2FbdZlmdBmU7PFktnC0jXJzCzxts%2FOV7FvYKVLd8SD9w3j%2FzbVvFzPTDXy8YRHV4VZvNYFGHMJY4ZiEA%3D%3D"}]}' + transfer-encoding: + - chunked + vary: + - accept-encoding + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - cdedaeddac989e6130483fada3a2be512ac7ebf53552449423f906d8b5f282e8 + method: DELETE + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed"},"id":null}' + headers: + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '82' + content-type: + - text/plain;charset=UTF-8 + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=XEB1cbp%2BoMIAhdOnn4HBZHZfR2yoqR3Z%2FDcn7GGpmgTI7GDEUudQsea1sW9%2Bacdy0yAKPYy3I9I4tQttg5BYUOWTohaxBoq3RRWNIgJyG18UU0sigXD0gHE%2FMip3KISnFezmnQ%3D%3D"}]}' + vary: + - accept-encoding + status: + code: 405 + message: Method Not Allowed +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '1285' + content-type: + - application/json + host: + - api.openai.com + method: POST + parsed_body: + messages: + - content: Can you tell me about the pydantic/pydantic-ai repo? Keep it short. + role: user + model: gpt-4o + stream: false + tool_choice: auto + tools: + - function: + description: Get a list of documentation topics for a GitHub repository + name: read_wiki_structure + parameters: + additionalProperties: false + properties: + repoName: + description: 'GitHub repository: owner/repo (e.g. "facebook/react")' + type: string + required: + - repoName + type: object + strict: true + type: function + - function: + description: View documentation about a GitHub repository + name: read_wiki_contents + parameters: + additionalProperties: false + properties: + repoName: + description: 'GitHub repository: owner/repo (e.g. "facebook/react")' + type: string + required: + - repoName + type: object + strict: true + type: function + - function: + description: Ask any question about a GitHub repository + name: ask_question + parameters: + additionalProperties: false + properties: + question: + description: The question to ask about the repository + type: string + repoName: + description: 'GitHub repository: owner/repo (e.g. "facebook/react")' + type: string + required: + - repoName + - question + type: object + strict: true + type: function + uri: https://api.openai.com/v1/chat/completions + response: + headers: + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '1160' + content-type: + - application/json + openai-organization: + - user-grnwlxd1653lxdzp921aoihz + openai-processing-ms: + - '571' + openai-project: + - proj_FYsIItHHgnSPdHBVMzhNBWGa + openai-version: + - '2020-10-01' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + choices: + - finish_reason: tool_calls + index: 0 + logprobs: null + message: + annotations: [] + content: null + refusal: null + role: assistant + tool_calls: + - function: + arguments: '{"repoName":"pydantic/pydantic-ai","question":"What is the pydantic-ai repository about?"}' + name: ask_question + id: call_6PsGSGgsIN4tDkVQjd9ozPOj + type: function + created: 1765405132 + id: chatcmpl-ClMoG5xhjRrpBUS45Xx4D2PaJcFnK + model: gpt-4o-2024-08-06 + object: chat.completion + service_tier: default + system_fingerprint: fp_37d212baff + usage: + completion_tokens: 34 + completion_tokens_details: + accepted_prediction_tokens: 0 + audio_tokens: 0 + reasoning_tokens: 0 + rejected_prediction_tokens: 0 + prompt_tokens: 180 + prompt_tokens_details: + audio_tokens: 0 + cached_tokens: 0 + total_tokens: 214 + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '152' + content-type: + - application/json + host: + - mcp.deepwiki.com + method: POST + parsed_body: + id: 0 + jsonrpc: '2.0' + method: initialize + params: + capabilities: {} + clientInfo: + name: mcp + version: 0.1.0 + protocolVersion: '2025-06-18' + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: |+ + event: message + data: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"DeepWiki","version":"0.0.1"}}} + + event: ping + data: ping + + headers: + access-control-allow-headers: + - Content-Type, mcp-session-id, mcp-protocol-version + access-control-allow-methods: + - GET, POST, OPTIONS + access-control-allow-origin: + - '*' + access-control-expose-headers: + - mcp-session-id + access-control-max-age: + - '86400' + alt-svc: + - h3=":443"; ma=86400 + cache-control: + - no-cache + connection: + - keep-alive + content-type: + - text/event-stream + mcp-session-id: + - 7a71ff290d9b9fb0cb7e2483f972eeca6c1178127bbf1afc8c8f612d4cd6702c + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=XoQV0BNRcUUBxB0ZSWRrCYR21AKCRUpQHFTBuSDXXnp9oRJ%2FYJGL9KXqukpsG1ZsSGOH3R51tXKESx1KEh02V1H7lY7DelWa6KNp%2B6aehqRBECWrOL9exqIA4qq1gO6xQdMlKQ%3D%3D"}]}' + transfer-encoding: + - chunked + vary: + - accept-encoding + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '54' + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - 7a71ff290d9b9fb0cb7e2483f972eeca6c1178127bbf1afc8c8f612d4cd6702c + method: POST + parsed_body: + jsonrpc: '2.0' + method: notifications/initialized + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: '' + headers: + access-control-allow-headers: + - Content-Type, mcp-session-id, mcp-protocol-version + access-control-allow-methods: + - GET, POST, OPTIONS + access-control-allow-origin: + - '*' + access-control-expose-headers: + - mcp-session-id + access-control-max-age: + - '86400' + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '0' + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=fXjkNmxYMvnBv5zvSilSAtuglGxLMpcArzlBXuooT%2BRazO35bd1hQ4CDu18flhDIHpQnZjr0qFS%2Br%2FHh5EDtSuQv0pRYNwTinLQGFSRQe%2FM2tS5NngcBYQfZoXMk9ofYWs2s%2Fg%3D%3D"}]}' + vary: + - accept-encoding + status: + code: 202 + message: Accepted +- request: + body: '' + headers: + accept: + - application/json, text/event-stream, text/event-stream + accept-encoding: + - gzip, deflate + cache-control: + - no-store + connection: + - keep-alive + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - 7a71ff290d9b9fb0cb7e2483f972eeca6c1178127bbf1afc8c8f612d4cd6702c + method: GET + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed"},"id":null}' + headers: + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '82' + content-type: + - text/plain;charset=UTF-8 + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=lHtihfdTRriHMcT0m0378%2FArc6OOFU7%2BaUITTAW5fWFf%2B104KApJE1JZsOYCZVarn6TBYhdoiB2JwbAjxaDWvFGxXzqnq3HsRNbl6AIsFWnhpv7aK8vQn8O6LjymRGcEhZg%3D"}]}' + vary: + - accept-encoding + status: + code: 405 + message: Method Not Allowed +- request: + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '46' + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - 7a71ff290d9b9fb0cb7e2483f972eeca6c1178127bbf1afc8c8f612d4cd6702c + method: POST + parsed_body: + id: 1 + jsonrpc: '2.0' + method: tools/list + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: |+ + event: message + data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"read_wiki_structure","description":"Get a list of documentation topics for a GitHub repository","inputSchema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"name":"read_wiki_contents","description":"View documentation about a GitHub repository","inputSchema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"name":"ask_question","description":"Ask any question about a GitHub repository","inputSchema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"},"question":{"type":"string","description":"The question to ask about the repository"}},"required":["repoName","question"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}]}} + + event: ping + data: ping + + headers: + access-control-allow-headers: + - Content-Type, mcp-session-id, mcp-protocol-version + access-control-allow-methods: + - GET, POST, OPTIONS + access-control-allow-origin: + - '*' + access-control-expose-headers: + - mcp-session-id + access-control-max-age: + - '86400' + alt-svc: + - h3=":443"; ma=86400 + cache-control: + - no-cache + connection: + - keep-alive + content-type: + - text/event-stream + mcp-session-id: + - 7a71ff290d9b9fb0cb7e2483f972eeca6c1178127bbf1afc8c8f612d4cd6702c + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=7OjssFx%2B4VVdCLoMctTsgn36bqLZhooT8Z8BCc2FEVUzQhlV%2FJNvzuKVBfyXHtKw%2BH2U3NO22m6Kftc7vHihJXoQ7RwQlookXRPeAE0GgeqlvXf3T46zG9VyJ%2F%2BqHgj%2FBLU%3D"}]}' + transfer-encoding: + - chunked + vary: + - accept-encoding + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '210' + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - 7a71ff290d9b9fb0cb7e2483f972eeca6c1178127bbf1afc8c8f612d4cd6702c + method: POST + parsed_body: + id: 2 + jsonrpc: '2.0' + method: tools/call + params: + _meta: + progressToken: 2 + arguments: + question: What is the pydantic-ai repository about? + repoName: pydantic/pydantic-ai + name: ask_question + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: |+ + event: ping + data: ping + + event: message + data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"The `pydantic-ai` repository is a Python agent framework designed for building production-grade Generative AI applications using Large Language Models (LLMs) . It aims to provide an ergonomic and type-safe developer experience, similar to Pydantic and FastAPI, for AI agent development .\n\n## Core Purpose and Features\n\nThe framework focuses on simplifying the development of robust and reliable AI applications by offering a structured, type-safe, and extensible environment .\n\nKey features include:\n* **Type-safe Agents**: Agents are defined using `Agent[Deps, Output]` for compile-time validation, leveraging Pydantic for output validation and dependency injection .\n* **Model-agnostic Design**: It supports over 15 LLM providers through a unified `Model` interface, allowing for easy switching between different models and providers .\n* **Structured Outputs**: Automatic Pydantic validation and self-correction ensure structured and reliable outputs from LLMs .\n* **Comprehensive Observability**: Integration with OpenTelemetry and native Logfire provides real-time debugging, performance monitoring, and cost tracking .\n* **Production-ready Tooling**: This includes an evaluation framework (`pydantic-evals`), durable execution capabilities, and various protocol integrations like MCP, A2A, and AG-UI .\n* **Graph Support**: It provides a powerful way to define graphs using type hints for complex applications .\n\n## Framework Architecture\n\nThe framework is structured as a monorepo with multiple coordinated packages .\n\n### Core Packages \n\n* `pydantic-ai`: A full-featured bundle that acts as a convenience wrapper with all common extras pre-installed .\n* `pydantic-ai-slim`: The minimal core package containing the core framework with optional dependencies for specific providers .\n\n### Supporting Packages \n\n* `pydantic-graph`: A graph and state machine library that provides the agent execution graphs .\n* `pydantic-evals`: An evaluation framework for systematic testing and performance evaluation .\n\n## Agent Execution Flow\n\nPydantic AI uses `pydantic-graph` to implement agent execution as a finite state machine with three core nodes . The execution typically flows through `UserPromptNode` → `ModelRequestNode` → `CallToolsNode` .\n\n* `UserPromptNode`: Processes user input and creates the initial `ModelRequest` .\n* `ModelRequestNode`: Calls `model.request()` or `model.request_stream()` and handles retries .\n* `CallToolsNode`: Executes tool functions via `RunContext[Deps]` .\n\nThe `Agent` class serves as the primary orchestrator and provides methods like `run()`, `run_sync()`, and `run_stream()` for different execution scenarios .\n\n## Example Usage\n\nA minimal example demonstrates how to define and run an agent :\n```python\nfrom pydantic_ai import Agent\n\nagent = Agent(\n 'anthropic:claude-sonnet-4-0',\n instructions='Be concise, reply with one sentence.',\n)\n\nresult = agent.run_sync('Where does \"hello world\" come from?')\nprint(result.output)\n```\n \n\nThis example configures an agent with a specific model and instructions, then runs it synchronously with a user prompt .\n\n## Notes\n\nThe `pydantic-ai` repository is actively maintained and considered \"Production/Stable\" . It supports Python versions 3.10 through 3.13 . The documentation is built using MkDocs and includes API references and examples .\n\nWiki pages you might want to explore:\n- [Overview (pydantic/pydantic-ai)](/wiki/pydantic/pydantic-ai#1)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-the-pydanticai-reposit_b07a3d28-6abf-4d61-856c-6c3e07e2fb0c\n"}]}} + + event: ping + data: ping + + headers: + access-control-allow-headers: + - Content-Type, mcp-session-id, mcp-protocol-version + access-control-allow-methods: + - GET, POST, OPTIONS + access-control-allow-origin: + - '*' + access-control-expose-headers: + - mcp-session-id + access-control-max-age: + - '86400' + alt-svc: + - h3=":443"; ma=86400 + cache-control: + - no-cache + connection: + - keep-alive + content-type: + - text/event-stream + mcp-session-id: + - 7a71ff290d9b9fb0cb7e2483f972eeca6c1178127bbf1afc8c8f612d4cd6702c + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=BgpgnpbY7klKcgEuPp%2FiQbQ8dfp9cSGvdyho90uf78ILEyyBK0k8f0S%2FhwKQtwJhPtP0x32tGFXxXxPXOBOYnJI7xyAmgHJN4obbLHO40wbYEHH%2B9FZmJexZl1%2F9hbMh2nyoIQ%3D%3D"}]}' + transfer-encoding: + - chunked + vary: + - accept-encoding + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - 7a71ff290d9b9fb0cb7e2483f972eeca6c1178127bbf1afc8c8f612d4cd6702c + method: DELETE + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed"},"id":null}' + headers: + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '82' + content-type: + - text/plain;charset=UTF-8 + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=HuWRjE8dHD6L4PuaA%2B3PRcO0aeBz8QwQjxvLotxgLtnGPDkMWc9n8N9Gsg0IcDyH95tvdjxLAzlViHw6ecbb%2BkmDFzrO%2Bxt%2FwB5X%2Fp%2BIFPXmip%2BDgxhVF%2B%2FxrP7fKolilV7O%2Bg%3D%3D"}]}' + vary: + - accept-encoding + status: + code: 405 + message: Method Not Allowed +- request: + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '152' + content-type: + - application/json + host: + - mcp.deepwiki.com + method: POST + parsed_body: + id: 0 + jsonrpc: '2.0' + method: initialize + params: + capabilities: {} + clientInfo: + name: mcp + version: 0.1.0 + protocolVersion: '2025-06-18' + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: |+ + event: message + data: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"DeepWiki","version":"0.0.1"}}} + + event: ping + data: ping + + headers: + access-control-allow-headers: + - Content-Type, mcp-session-id, mcp-protocol-version + access-control-allow-methods: + - GET, POST, OPTIONS + access-control-allow-origin: + - '*' + access-control-expose-headers: + - mcp-session-id + access-control-max-age: + - '86400' + alt-svc: + - h3=":443"; ma=86400 + cache-control: + - no-cache + connection: + - keep-alive + content-type: + - text/event-stream + mcp-session-id: + - b50f0ce12e7805c97982cdd37986b3527767b51aa0a703bb373f31834095e6c0 + nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + report-to: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=sv8L%2BV8Em2fmN8b1RsNPYXajgbeHXPyBdTHHmOVSnWjVj%2B1H3ifdMqHTJRl7h%2FGK0QMapKh9K4IS3JIlPQLjo2NLhGPFa0R3hn6y0purkc4XmYs5VLmC8n4ciyI4KDAmKb%2FzvhSnHcsOEZHb80vyFdk8tdpflOA%3D"}],"group":"cf-nel","max_age":604800}' + transfer-encoding: + - chunked + vary: + - Accept-Encoding + status: + code: 200 + message: OK +- request: + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '54' + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - b50f0ce12e7805c97982cdd37986b3527767b51aa0a703bb373f31834095e6c0 + method: POST + parsed_body: + jsonrpc: '2.0' + method: notifications/initialized + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: '' + headers: + access-control-allow-headers: + - Content-Type, mcp-session-id, mcp-protocol-version + access-control-allow-methods: + - GET, POST, OPTIONS + access-control-allow-origin: + - '*' + access-control-expose-headers: + - mcp-session-id + access-control-max-age: + - '86400' + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '0' + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=cSRpCOzcOM93tN0EMWi%2FkFcfSHjRU8dJQB7Is95IKxzN25GR%2FhrJU05oNRU%2BavvTn%2BYTKCfcFtusioWfsysnDsJS8lXp3Qo8fU9O5eokivC554MbQ1PRhoL45gF5dECrvEI%3D"}]}' + vary: + - accept-encoding + status: + code: 202 + message: Accepted +- request: + body: '' + headers: + accept: + - application/json, text/event-stream, text/event-stream + accept-encoding: + - gzip, deflate + cache-control: + - no-store + connection: + - keep-alive + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - b50f0ce12e7805c97982cdd37986b3527767b51aa0a703bb373f31834095e6c0 + method: GET + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed"},"id":null}' + headers: + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '82' + content-type: + - text/plain;charset=UTF-8 + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=sCtJY9Ja%2FEG3JIRQmsl3QxquVQn38MohBBYDC4LhZLZPK%2BwMo8Uf5HyInSC5gRlic1mrjzftFnFp5c5AW2htVsj0NuepL9%2FJPvHIBCopix9aFq0SxjGr52E6WN9uHBbZnEqLzQ%3D%3D"}]}' + vary: + - accept-encoding + status: + code: 405 + message: Method Not Allowed +- request: + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '46' + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - b50f0ce12e7805c97982cdd37986b3527767b51aa0a703bb373f31834095e6c0 + method: POST + parsed_body: + id: 1 + jsonrpc: '2.0' + method: tools/list + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: |+ + event: message + data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"read_wiki_structure","description":"Get a list of documentation topics for a GitHub repository","inputSchema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"name":"read_wiki_contents","description":"View documentation about a GitHub repository","inputSchema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}},{"name":"ask_question","description":"Ask any question about a GitHub repository","inputSchema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"},"question":{"type":"string","description":"The question to ask about the repository"}},"required":["repoName","question"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}}]}} + + event: ping + data: ping + + headers: + access-control-allow-headers: + - Content-Type, mcp-session-id, mcp-protocol-version + access-control-allow-methods: + - GET, POST, OPTIONS + access-control-allow-origin: + - '*' + access-control-expose-headers: + - mcp-session-id + access-control-max-age: + - '86400' + alt-svc: + - h3=":443"; ma=86400 + cache-control: + - no-cache + connection: + - keep-alive + content-type: + - text/event-stream + mcp-session-id: + - b50f0ce12e7805c97982cdd37986b3527767b51aa0a703bb373f31834095e6c0 + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=SXumXC6V%2BxXXW3V%2F3B%2FsraA58yC%2FyptvjYnhiDAhcaWxbeuI4H6iOaAK1OY%2F0KxisjMN1CAldx%2F7jWKDD4II0ajIP7U6XMVToGOSGGcZLlvgFFK7%2F7mEif4HWZojttdE7Cc%3D"}]}' + transfer-encoding: + - chunked + vary: + - accept-encoding + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - application/json, text/event-stream + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-type: + - application/json + host: + - mcp.deepwiki.com + mcp-protocol-version: + - '2025-03-26' + mcp-session-id: + - b50f0ce12e7805c97982cdd37986b3527767b51aa0a703bb373f31834095e6c0 + method: DELETE + uri: https://mcp.deepwiki.com/mcp + response: + body: + string: '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed"},"id":null}' + headers: + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '82' + content-type: + - text/plain;charset=UTF-8 + nel: + - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}' + report-to: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=xr1XKAXxETLz%2BaJjE4Tpg26C7LjR4%2FMO84CVDRUH59VRphgSR%2Bv7CHGXaVCnLGNmQ3h%2BGXdAxIVJoNShngYK7alFPc09ldnLP6taQ%2FtmVmgVCFvM%2BLGFh7LLr%2BrC0pPLXRo%3D"}]}' + vary: + - accept-encoding + status: + code: 405 + message: Method Not Allowed +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '5292' + content-type: + - application/json + cookie: + - __cf_bm=gFcGCCCkMTmVY3dWsQapynsHE4AbWPTskJYoyLQZSZE-1765405133-1.0.1.1-yAqe.j3bQWSajlbEOf6.r..mui2.7WJPPK9mwjVtmqRwxbPdHVMMWQIfoIeSQeRqC8zbRV865Zl_dKmld1P9cTw76JA.EsoTfjHPX_6tANI; + _cfuvid=B_WqbWMRZC6yieiFZ5B7lkgE2jBpODOfCGY8Ry81YPo-1765405133031-0.0.1.1-604800000 + host: + - api.openai.com + method: POST + parsed_body: + messages: + - content: Can you tell me about the pydantic/pydantic-ai repo? Keep it short. + role: user + - content: null + role: assistant + tool_calls: + - function: + arguments: '{"repoName":"pydantic/pydantic-ai","question":"What is the pydantic-ai repository about?"}' + name: ask_question + id: call_6PsGSGgsIN4tDkVQjd9ozPOj + type: function + - content: "The `pydantic-ai` repository is a Python agent framework designed for building production-grade Generative + AI applications using Large Language Models (LLMs) . It aims to provide an ergonomic and type-safe developer experience, + similar to Pydantic and FastAPI, for AI agent development .\n\n## Core Purpose and Features\n\nThe framework focuses + on simplifying the development of robust and reliable AI applications by offering a structured, type-safe, and extensible + environment .\n\nKey features include:\n* **Type-safe Agents**: Agents are defined using `Agent[Deps, Output]` + for compile-time validation, leveraging Pydantic for output validation and dependency injection .\n* **Model-agnostic + Design**: It supports over 15 LLM providers through a unified `Model` interface, allowing for easy switching between + different models and providers .\n* **Structured Outputs**: Automatic Pydantic validation and self-correction + ensure structured and reliable outputs from LLMs .\n* **Comprehensive Observability**: Integration with OpenTelemetry + and native Logfire provides real-time debugging, performance monitoring, and cost tracking .\n* **Production-ready + Tooling**: This includes an evaluation framework (`pydantic-evals`), durable execution capabilities, and various + protocol integrations like MCP, A2A, and AG-UI .\n* **Graph Support**: It provides a powerful way to define + graphs using type hints for complex applications .\n\n## Framework Architecture\n\nThe framework is structured + as a monorepo with multiple coordinated packages .\n\n### Core Packages \n\n* `pydantic-ai`: A full-featured bundle + that acts as a convenience wrapper with all common extras pre-installed .\n* `pydantic-ai-slim`: The minimal core + package containing the core framework with optional dependencies for specific providers .\n\n### Supporting Packages + \n\n* `pydantic-graph`: A graph and state machine library that provides the agent execution graphs .\n* `pydantic-evals`: + An evaluation framework for systematic testing and performance evaluation .\n\n## Agent Execution Flow\n\nPydantic + AI uses `pydantic-graph` to implement agent execution as a finite state machine with three core nodes . The execution + typically flows through `UserPromptNode` → `ModelRequestNode` → `CallToolsNode` .\n\n* `UserPromptNode`: Processes + user input and creates the initial `ModelRequest` .\n* `ModelRequestNode`: Calls `model.request()` or `model.request_stream()` + and handles retries .\n* `CallToolsNode`: Executes tool functions via `RunContext[Deps]` .\n\nThe `Agent` class + serves as the primary orchestrator and provides methods like `run()`, `run_sync()`, and `run_stream()` for different + execution scenarios .\n\n## Example Usage\n\nA minimal example demonstrates how to define and run an agent :\n```python\nfrom + pydantic_ai import Agent\n\nagent = Agent(\n 'anthropic:claude-sonnet-4-0',\n instructions='Be concise, reply + with one sentence.',\n)\n\nresult = agent.run_sync('Where does \"hello world\" come from?')\nprint(result.output)\n```\n + \n\nThis example configures an agent with a specific model and instructions, then runs it synchronously with a user + prompt .\n\n## Notes\n\nThe `pydantic-ai` repository is actively maintained and considered \"Production/Stable\" + . It supports Python versions 3.10 through 3.13 . The documentation is built using MkDocs and includes API references + and examples .\n\nWiki pages you might want to explore:\n- [Overview (pydantic/pydantic-ai)](/wiki/pydantic/pydantic-ai#1)\n\nView + this search on DeepWiki: https://deepwiki.com/search/what-is-the-pydanticai-reposit_b07a3d28-6abf-4d61-856c-6c3e07e2fb0c\n" + role: tool + tool_call_id: call_6PsGSGgsIN4tDkVQjd9ozPOj + model: gpt-4o + stream: false + tool_choice: auto + tools: + - function: + description: Get a list of documentation topics for a GitHub repository + name: read_wiki_structure + parameters: + additionalProperties: false + properties: + repoName: + description: 'GitHub repository: owner/repo (e.g. "facebook/react")' + type: string + required: + - repoName + type: object + strict: true + type: function + - function: + description: View documentation about a GitHub repository + name: read_wiki_contents + parameters: + additionalProperties: false + properties: + repoName: + description: 'GitHub repository: owner/repo (e.g. "facebook/react")' + type: string + required: + - repoName + type: object + strict: true + type: function + - function: + description: Ask any question about a GitHub repository + name: ask_question + parameters: + additionalProperties: false + properties: + question: + description: The question to ask about the repository + type: string + repoName: + description: 'GitHub repository: owner/repo (e.g. "facebook/react")' + type: string + required: + - repoName + - question + type: object + strict: true + type: function + uri: https://api.openai.com/v1/chat/completions + response: + headers: + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + connection: + - keep-alive + content-length: + - '1963' + content-type: + - application/json + openai-organization: + - user-grnwlxd1653lxdzp921aoihz + openai-processing-ms: + - '5352' + openai-project: + - proj_FYsIItHHgnSPdHBVMzhNBWGa + openai-version: + - '2020-10-01' + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + parsed_body: + choices: + - finish_reason: stop + index: 0 + logprobs: null + message: + annotations: [] + content: |- + The `pydantic-ai` repository is a Python framework designed for building production-grade Generative AI applications using Large Language Models (LLMs). It emphasizes a structured, type-safe, and extensible development environment, mirroring the user-friendly experience of Pydantic and FastAPI for AI agent development. + + ### Key Features + - **Type-safe Agents**: Uses `Agent[Deps, Output]` for compile-time validation. + - **Model-agnostic Design**: Supports over 15 LLM providers with a unified interface. + - **Structured Outputs**: Ensures reliable LLM outputs with Pydantic validation. + - **Observability and Tooling**: Includes real-time monitoring, debugging tools, and an evaluation framework. + - **Graph Support**: Allows defining complex execution graphs with type hints. + + The framework operates as a monorepo with core and supporting packages like `pydantic-ai-slim`, `pydantic-graph`, and `pydantic-evals`. Agent execution uses a finite state machine approach, providing robust orchestration and execution mechanisms. + + The repository is actively maintained, supports Python 3.10 to 3.13, and offers comprehensive documentation and examples. + refusal: null + role: assistant + created: 1765405200 + id: chatcmpl-ClMpMlSxMgZD1k7rwYSxuGfkOf700 + model: gpt-4o-2024-08-06 + object: chat.completion + service_tier: default + system_fingerprint: fp_83554c687e + usage: + completion_tokens: 242 + completion_tokens_details: + accepted_prediction_tokens: 0 + audio_tokens: 0 + reasoning_tokens: 0 + rejected_prediction_tokens: 0 + prompt_tokens: 1019 + prompt_tokens_details: + audio_tokens: 0 + cached_tokens: 0 + total_tokens: 1261 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_temporal.py b/tests/test_temporal.py index 98039d9078..e194a1e7df 100644 --- a/tests/test_temporal.py +++ b/tests/test_temporal.py @@ -1104,6 +1104,125 @@ async def test_toolset_without_id(): TemporalAgent(Agent(model=model, name='test_agent', toolsets=[FunctionToolset()])) +# --- DynamicToolset / @agent.toolset tests --- + + +@dataclass +class DynamicToolsetDeps: + user_name: str + + +dynamic_toolset_agent = Agent(TestModel(), name='dynamic_toolset_agent', deps_type=DynamicToolsetDeps) + + +@dynamic_toolset_agent.toolset(id='my_dynamic_tools') +def my_dynamic_toolset(ctx: RunContext[DynamicToolsetDeps]) -> FunctionToolset[DynamicToolsetDeps]: + toolset = FunctionToolset[DynamicToolsetDeps](id='dynamic_weather') + + @toolset.tool + def get_dynamic_weather(location: str) -> str: + """Get the weather for a location.""" + user = ctx.deps.user_name + return f'Weather in {location} for {user}: sunny.' + + return toolset + + +dynamic_toolset_temporal_agent = TemporalAgent( + dynamic_toolset_agent, + activity_config=BASE_ACTIVITY_CONFIG, +) + + +@workflow.defn +class DynamicToolsetAgentWorkflow: + @workflow.run + async def run(self, prompt: str, deps: DynamicToolsetDeps) -> str: + result = await dynamic_toolset_temporal_agent.run(prompt, deps=deps) + return result.output + + +async def test_dynamic_toolset_in_workflow(client: Client): + """Test that @agent.toolset works correctly in a Temporal workflow.""" + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[DynamicToolsetAgentWorkflow], + plugins=[AgentPlugin(dynamic_toolset_temporal_agent)], + ): + output = await client.execute_workflow( + DynamicToolsetAgentWorkflow.run, + args=['Get the weather for London', DynamicToolsetDeps(user_name='Alice')], + id='test_dynamic_toolset_workflow', + task_queue=TASK_QUEUE, + ) + assert output == snapshot('{"get_dynamic_weather":"Weather in a for Alice: sunny."}') + + +async def test_dynamic_toolset_outside_workflow(): + """Test that the dynamic toolset agent works correctly outside of a workflow.""" + result = await dynamic_toolset_temporal_agent.run( + 'Get the weather for Paris', deps=DynamicToolsetDeps(user_name='Bob') + ) + assert result.output == snapshot('{"get_dynamic_weather":"Weather in a for Bob: sunny."}') + + +# --- MCP-based DynamicToolset test --- +# Tests that @agent.toolset with an MCP toolset works with Temporal workflows. +# See https://github.com/pydantic/pydantic-ai/issues/3390 +# Uses FastMCPToolset (HTTP-based) rather than MCPServerStdio (subprocess-based) because +# MCPServerStdio has issues when created dynamically inside Temporal activities. + + +fastmcp_dynamic_toolset_agent = Agent(model, name='fastmcp_dynamic_toolset_agent') + + +@fastmcp_dynamic_toolset_agent.toolset(id='fastmcp_toolset', per_run_step=False) +def my_fastmcp_dynamic_toolset(ctx: RunContext[None]) -> FastMCPToolset: + """Dynamic toolset that returns an MCP toolset. + + This tests MCP lifecycle management (context manager enter/exit) within DynamicToolset + Temporal. + Uses per_run_step=False so the toolset persists across run steps within an activity. + """ + return FastMCPToolset('https://mcp.deepwiki.com/mcp', id='dynamic_deepwiki') + + +fastmcp_dynamic_toolset_temporal_agent = TemporalAgent( + fastmcp_dynamic_toolset_agent, + activity_config=BASE_ACTIVITY_CONFIG, +) + + +@workflow.defn +class FastMCPDynamicToolsetAgentWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + result = await fastmcp_dynamic_toolset_temporal_agent.run(prompt) + return result.output + + +async def test_fastmcp_dynamic_toolset_in_workflow(allow_model_requests: None, client: Client): + """Test that @agent.toolset with FastMCPToolset works in a Temporal workflow. + + This demonstrates MCP lifecycle management (entering/exiting the MCP toolset context manager) + within a DynamicToolset wrapped by TemporalDynamicToolset. + """ + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[FastMCPDynamicToolsetAgentWorkflow], + plugins=[AgentPlugin(fastmcp_dynamic_toolset_temporal_agent)], + ): + output = await client.execute_workflow( + FastMCPDynamicToolsetAgentWorkflow.run, + args=['Can you tell me about the pydantic/pydantic-ai repo? Keep it short.'], + id='test_fastmcp_dynamic_toolset_workflow', + task_queue=TASK_QUEUE, + ) + # The deepwiki MCP server should return info about the pydantic-ai repo + assert 'pydantic' in output.lower() or 'agent' in output.lower() + + async def test_temporal_agent(): assert isinstance(complex_temporal_agent.model, TemporalModel) assert complex_temporal_agent.model.wrapped == complex_agent.model diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index 9a4a344d12..c4edab0974 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -709,7 +709,7 @@ async def test_visit_and_replace(): active_dynamic_toolset = DynamicToolset(toolset_func=lambda ctx: toolset2) await active_dynamic_toolset.get_tools(build_run_context(None)) - assert active_dynamic_toolset._toolset is toolset2 # pyright: ignore[reportPrivateUsage] + assert active_dynamic_toolset._toolset_runstep['__default__']['toolset'] is toolset2 # pyright: ignore[reportPrivateUsage] inactive_dynamic_toolset = DynamicToolset(toolset_func=lambda ctx: FunctionToolset()) @@ -721,19 +721,32 @@ async def test_visit_and_replace(): ] ) visited_toolset = toolset.visit_and_replace(lambda toolset: WrapperToolset(toolset)) - assert visited_toolset == CombinedToolset( - [ - WrapperToolset(WrapperToolset(toolset1)), - DynamicToolset( - toolset_func=active_dynamic_toolset.toolset_func, - per_run_step=active_dynamic_toolset.per_run_step, - _toolset=WrapperToolset(toolset2), - _run_step=active_dynamic_toolset._run_step, # pyright: ignore[reportPrivateUsage] - ), - WrapperToolset(inactive_dynamic_toolset), - ] + + # Check the structure of the visited toolset + assert isinstance(visited_toolset, CombinedToolset) + assert len(visited_toolset.toolsets) == 3 + + # First toolset is doubly wrapped + assert isinstance(visited_toolset.toolsets[0], WrapperToolset) + assert isinstance(visited_toolset.toolsets[0].wrapped, WrapperToolset) + assert visited_toolset.toolsets[0].wrapped.wrapped is toolset1 + + # Second toolset is a DynamicToolset with wrapped inner toolset + visited_dynamic = visited_toolset.toolsets[1] + assert isinstance(visited_dynamic, DynamicToolset) + assert visited_dynamic.toolset_func is active_dynamic_toolset.toolset_func + assert visited_dynamic.per_run_step == active_dynamic_toolset.per_run_step + assert isinstance(visited_dynamic._toolset_runstep['__default__']['toolset'], WrapperToolset) # pyright: ignore[reportPrivateUsage] + assert visited_dynamic._toolset_runstep['__default__']['toolset'].wrapped is toolset2 # pyright: ignore[reportPrivateUsage] + assert ( + visited_dynamic._toolset_runstep['__default__']['run_step'] # pyright: ignore[reportPrivateUsage] + == active_dynamic_toolset._toolset_runstep['__default__']['run_step'] # pyright: ignore[reportPrivateUsage] ) + # Third toolset is the inactive dynamic toolset wrapped + assert isinstance(visited_toolset.toolsets[2], WrapperToolset) + assert visited_toolset.toolsets[2].wrapped is inactive_dynamic_toolset + async def test_dynamic_toolset(): class EnterableToolset(AbstractToolset[None]): @@ -771,14 +784,15 @@ def toolset_factory(ctx: RunContext[None]) -> AbstractToolset[None]: def get_inner_toolset(toolset: DynamicToolset[None] | None) -> EnterableToolset | None: assert toolset is not None - inner_toolset = toolset._toolset # pyright: ignore[reportPrivateUsage] + run_state = toolset._toolset_runstep.get('__default__') # pyright: ignore[reportPrivateUsage] + inner_toolset = run_state['toolset'] if run_state else None assert isinstance(inner_toolset, EnterableToolset) or inner_toolset is None return inner_toolset run_context = build_run_context(None) async with toolset: - assert not toolset._toolset # pyright: ignore[reportPrivateUsage] + assert not toolset._toolset_runstep # pyright: ignore[reportPrivateUsage] # Test that calling get_tools initializes the toolset tools = await toolset.get_tools(run_context) @@ -815,10 +829,62 @@ def no_toolset_func(ctx: RunContext[None]) -> None: assert tools == {} async with toolset: - assert toolset._toolset is None # pyright: ignore[reportPrivateUsage] + run_state = toolset._toolset_runstep.get('__default__') # pyright: ignore[reportPrivateUsage] + assert run_state is None or run_state['toolset'] is None tools = await toolset.get_tools(run_context) assert tools == {} - assert toolset._toolset is None # pyright: ignore[reportPrivateUsage] + run_state = toolset._toolset_runstep.get('__default__') # pyright: ignore[reportPrivateUsage] + assert run_state is not None and run_state['toolset'] is None + + +def test_dynamic_toolset_id(): + """Test that DynamicToolset can have an id set.""" + + def toolset_func(ctx: RunContext[None]) -> FunctionToolset[None]: + return FunctionToolset() # pragma: no cover + + # No id by default + toolset_no_id = DynamicToolset[None](toolset_func=toolset_func) + assert toolset_no_id.id is None + + # Explicit id + toolset_with_id = DynamicToolset[None](toolset_func=toolset_func, id='my_dynamic_toolset') + assert toolset_with_id.id == 'my_dynamic_toolset' + + # copy() preserves id + copied = toolset_with_id.copy() + assert copied.id == 'my_dynamic_toolset' + + +def test_agent_toolset_decorator_id(): + """Test that @agent.toolset decorator requires explicit id or defaults to None.""" + from pydantic_ai import Agent + from pydantic_ai.models.test import TestModel + + agent = Agent(TestModel()) + + @agent.toolset + def my_tools(ctx: RunContext[None]) -> FunctionToolset[None]: + return FunctionToolset() # pragma: no cover + + @agent.toolset(id='custom_id') + def other_tools(ctx: RunContext[None]) -> FunctionToolset[None]: + return FunctionToolset() # pragma: no cover + + # The toolsets are DynamicToolsets with None or explicit ids + toolsets = agent.toolsets + assert len(toolsets) == 3 # FunctionToolset for agent tools + 2 dynamic toolsets + + # First is the agent's own FunctionToolset + assert isinstance(toolsets[0], FunctionToolset) + + # Second toolset without explicit id should have None + assert isinstance(toolsets[1], DynamicToolset) + assert toolsets[1].id is None + + # Third toolset should have explicit id + assert isinstance(toolsets[2], DynamicToolset) + assert toolsets[2].id == 'custom_id'