-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Example/dynamic temporal toolset #3165
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
This example demonstrates the intended usage of DynamicToolset with TemporalAgent for dynamic MCP server connections. Currently fails because DynamicToolset.id returns None (by design) and at TemporalAgent.__init__ time, the DynamicToolset behaves as a "leaf" toolset (since _toolset is None). The visitor function requires all leaf toolsets to have an ID, which causes the error. Includes explanation of: - Why the error occurs (DynamicToolset has no ID, behaves as leaf at init time) - The solution path (implement TemporalDynamicToolset wrapper with static activities) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This is the first step towards supporting DynamicToolset with Temporal workflows. Changes: - Add `_id` field to `DynamicToolset` dataclass - Update `DynamicToolset.id` property to return `self._id` instead of `None` - Add `id` parameter to `@agent.toolset` decorator - Default ID to function name if not explicitly provided - Update example to demonstrate usage Following the same pattern as `FunctionToolset` and `MCPServer`, which both require an explicit ID for use in durable execution environments. Next steps: - Implement `TemporalDynamicToolset` wrapper with static activities - Update `temporalize_toolset()` to handle `DynamicToolset` instances 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Enables DynamicToolset to work with Temporal by providing static activities (get_tools, call_tool) that are registered at worker start time, while the actual toolset selection happens dynamically inside activities where I/O is allowed. The implementation properly manages async context lifecycle to avoid violations when entering/exiting toolsets. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Transform the placeholder example into a fully functional demonstration that: - Connects to a local Temporal server with proper error handling - Implements a complete workflow using DynamicToolset with MCP servers - Shows proper setup with Worker, Client, and workflow execution - Includes comprehensive documentation and setup instructions - Demonstrates I/O operations within the dynamic toolset function This example now provides a working reference for users wanting to use DynamicToolset with Temporal workflows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please turn this into a minimal test in tests/test_temporal.py.
| def toolset_decorator(func_: ToolsetFunc[AgentDepsT]) -> ToolsetFunc[AgentDepsT]: | ||
| self._dynamic_toolsets.append(DynamicToolset(func_, per_run_step=per_run_step)) | ||
| toolset_id = id if id is not None else func_.__name__ | ||
| self._dynamic_toolsets.append(DynamicToolset(func_, per_run_step=per_run_step, _id=toolset_id)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we pass as id?
| _CallDeferred, # pyright: ignore[reportPrivateUsage] | ||
| _CallToolResult, # pyright: ignore[reportPrivateUsage] | ||
| _ModelRetry, # pyright: ignore[reportPrivateUsage] | ||
| _ToolReturn, # pyright: ignore[reportPrivateUsage] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we move these to _toolset.py where both toolsets can access them?
| from pydantic_ai import ToolsetTool | ||
| from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry, UserError | ||
| from pydantic_ai.tools import AgentDepsT, RunContext, ToolDefinition | ||
| from pydantic_ai.toolsets._dynamic import DynamicToolset |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The durable execution implementations should use only public parts of Pydantic AI, so if we need this here, that means we should make it public, by renaming the file and exporting it from pydantic_ai.__init__ a few places.
| if tool_config is False: | ||
| raise UserError( | ||
| f'Temporal activity config for dynamic toolset tool {tool_name!r} has been explicitly set to `False` (activity disabled), ' | ||
| 'but dynamic toolsets require I/O to instantiate their tools and so cannot be run outside of an activity.' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this actually a hard requirement? I'd rather not have this error.
| tool_activity_config=tool_activity_config, | ||
| deps_type=deps_type, | ||
| run_context_type=run_context_type, | ||
| toolset_id=toolset.id, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does this need to be passed at all? TemporalWrapperToolset already inherits the id from its wrapped toolset
| tool_activity_config: dict[str, ActivityConfig | Literal[False]], | ||
| deps_type: type[AgentDepsT], | ||
| run_context_type: type[TemporalRunContext[AgentDepsT]] = TemporalRunContext[AgentDepsT], | ||
| toolset_id: str, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We shouldn't need this attr or field, see other comment
|
|
||
| async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]: | ||
| if not workflow.in_workflow(): | ||
| # Don't use super().get_tools() because DynamicToolset has lifecycle issues. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We really should be using super instead of reimplementing everything. What does lifecycle issues mean?
| from ._run_context import TemporalRunContext | ||
| from ._toolset import TemporalWrapperToolset | ||
|
|
||
| # Export these for use by other temporal toolset implementations |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We shouldn't need to do this
|
|
||
| raise UserError( | ||
| 'DynamicToolset must have an `id` to be used with Temporal. ' | ||
| 'Use @agent.toolset(id="my_id") to provide one.' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We already have something similar in TemporalAgent:
pydantic-ai/pydantic_ai_slim/pydantic_ai/durable_exec/temporal/_agent.py
Lines 167 to 171 in afccc1b
| id = toolset.id | |
| if id is None: | |
| raise UserError( | |
| "Toolsets that are 'leaves' (i.e. those that implement their own tool listing and calling) need to have a unique `id` in order to be used with Temporal. The ID will be used to identify the toolset's activities within the workflow." | |
| ) |
|
This PR is stale, and will be closed in 3 days if no reply is received. |
|
@szeka94 Are you still interested in finishing this up? |
Implementation Journey: DynamicToolset Support for Temporal Workflows
This PR enables using
DynamicToolsetwith Temporal workflows for dynamic MCP server connections based on runtime context.Issue:
While the current
temporalizationprocess worked great for staticmcp-servers, it didn't address my need for dynamic MCP server connections (mcp-connections where the connections are only known at run-time). This was due to the fact that temporal needs to know all the activities when the worker starts, while each toolset has created it's ownget_tools/call_toolactivities.Solution:
With this approach, we are creating 2 static activities for
get_toolsandcall_tooland we expose the dynamically created toolsets when these activities get called.Commits:
Commit 1:
2632586- Add example showing DynamicToolset with TemporalAgent (currently fails)Demonstrates the desired API pattern using
@agent.toolset(id="...")to return different MCP servers based onRunContext. Intentionally fails to show the problem we're solving.git checkout 2632586 uv run python example_dynamic_temporal.py # Expected: Error "Toolsets that are 'leaves' need to have a unique id"Commit 2:
215d3f0- Addidparameter to DynamicToolset for Temporal supportAdds
_idfield toDynamicToolsetand updates@agent.toolset()decorator to acceptidparameter (defaults to function name). Enables Temporal activity registration but example still fails due to missing wrapper implementation.git checkout 215d3f0 uv run python example_dynamic_temporal.py # Expected: Error "RuntimeError: Attempted to exit cancel scope in a different task"Commit 3:
ea11ee3- Add TemporalDynamicToolset implementationImplements
TemporalDynamicToolsetwrapper with static activities (get_tools,call_tool) registered at worker start time. Activities internally call the dynamic function where I/O is allowed, properly managing async context lifecycle.git checkout ea11ee3 uv run python example_dynamic_temporal.py # Expected: Success! Workflow executes with dynamic MCP connectionsCommit 4:
7755221- feat: implement complete Temporal workflow example with DynamicToolsetReplaces the simple example with a complete production-ready version (
example_dynamic_temporal_full.py) and adds separate worker/client scripts. Demonstrates proper Temporal configuration withPydanticAIPluginandAgentPlugin.Get Tools Temporal Activity:

Call Tool Temporal Activity:
