Skip to content

Conversation

@szeka94
Copy link

@szeka94 szeka94 commented Oct 14, 2025

Implementation Journey: DynamicToolset Support for Temporal Workflows

This PR enables using DynamicToolset with Temporal workflows for dynamic MCP server connections based on runtime context.

Issue:

While the current temporalization process worked great for static mcp-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 own get_tools / call_tool activities.

Solution:

With this approach, we are creating 2 static activities for get_tools and call_tool and 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 on RunContext. 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 - Add id parameter to DynamicToolset for Temporal support

Adds _id field to DynamicToolset and updates @agent.toolset() decorator to accept id parameter (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 implementation

Implements TemporalDynamicToolset wrapper 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 connections

Commit 4: 7755221 - feat: implement complete Temporal workflow example with DynamicToolset

Replaces the simple example with a complete production-ready version (example_dynamic_temporal_full.py) and adds separate worker/client scripts. Demonstrates proper Temporal configuration with PydanticAIPlugin and AgentPlugin.

git checkout 7755221

# Start Temporal server (required for all examples below)
temporal server start-dev

# Self-contained example
uv run python example_dynamic_temporal_full.py

# Or separate worker/client:
# Terminal 1: uv run python example_dynamic_temporal_worker.py
# Terminal 2: uv run python example_dynamic_temporal_client.py

Get Tools Temporal Activity:
image

Call Tool Temporal Activity:
image

szeka94 and others added 4 commits October 12, 2025 15:04
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>
Copy link
Collaborator

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))
Copy link
Collaborator

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]
Copy link
Collaborator

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
Copy link
Collaborator

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.'
Copy link
Collaborator

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,
Copy link
Collaborator

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,
Copy link
Collaborator

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.
Copy link
Collaborator

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
Copy link
Collaborator

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.'
Copy link
Collaborator

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:

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."
)

@github-actions
Copy link

This PR is stale, and will be closed in 3 days if no reply is received.

@github-actions github-actions bot added the Stale label Oct 23, 2025
@DouweM DouweM removed the Stale label Oct 23, 2025
@DouweM
Copy link
Collaborator

DouweM commented Oct 23, 2025

@szeka94 Are you still interested in finishing this up?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants