Skip to content

Conversation

@dsfaccini
Copy link
Collaborator

@dsfaccini dsfaccini commented Dec 9, 2025

Closes #3390

  • adds support for @agent.toolset decorator

assert output == 'Dynamic result received'


# Test with explicit id parameter
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather test the new id arg where we test the @agent.toolset decorator, since this part is not Temporal-specific

pytest.fail('TemporalDynamicToolset not found in toolsets')


async def test_dynamic_toolset_get_tools_outside_workflow():
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should test this by running the same agent we created earlier outside of a workflow, and checking that we still get the correct result

pytest.fail('TemporalDynamicToolset not found')


async def test_dynamic_toolset_call_tool_outside_workflow():
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed

pytest.fail('TemporalDynamicToolset not found')


async def test_dynamic_toolset_tool_not_found_in_activity():
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with pragma: no cover for this UserError like we have here:

try:
tool = (await toolset.get_tools(ctx))[name]
except KeyError as e: # pragma: no cover
raise UserError(
f'Tool {name!r} not found in toolset {self.id!r}. '
'Removing or renaming tools during an agent run is not supported with Temporal.'
) from e

dsfaccini and others added 3 commits December 10, 2025 17:25
Core changes:
- DynamicToolset: convert to plain class with custom __init__, add copy()
- TemporalDynamicToolset: use _call_tool_in_activity shared method
- TemporalFunctionToolset: use _call_tool_in_activity shared method
- temporalize_toolset: move DynamicToolset import to top

Test changes:
- Add test_fastmcp_dynamic_toolset_in_workflow for MCP lifecycle in DynamicToolset
- Add test_dynamic_toolset_id and test_agent_toolset_decorator_id
- Update test_visit_and_replace for DynamicToolset plain class

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>

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 can be set explicitly or it will default to the function name:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please link to the dynamic toolset doc

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's actually better to NOT default to the function name and require id to be set explicitly when Temporal is used, as the ID is used in activity names that need to be stable (i.e. can't change from one release to the next). So it's better for it to be separate field with a clear docstring that the user won't accidentally rename when they're doing a code refactor

class MyDeps:
...

@agent.toolset(id='my_dynamic_tools')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need the example

...
```

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be on the same single line about dynamic toolsets as the stuff above

docs/toolsets.md Outdated

By default, the function will be called again ahead of each agent run step. If you are using the decorator, you can optionally provide a `per_run_step=False` argument to indicate that the toolset only needs to be built once for the entire run.

When using [Temporal durable execution](./durable_execution/temporal.md), the decorator also accepts an `id` parameter to uniquely identify the toolset. If not provided, the function name is used as the ID.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes it seem like it only accepts the parameter when Temporal is used, while of course it always accepts the parameter :)

If we don't use the function name by default as I suggested above, we may not need this line here at all, as this won't be relevant unless they use Temporal, and if they do they'll get an error or read it in the docs

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed the wording about the id

per_run_step: bool
_id: str | None
_toolset: AbstractToolset[AgentDepsT] | None
_run_step: int | None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could solve this "reuse a dynamic toolset across multiple agent runs" issue not by copying it for each run (which is tricky as we see here), but by storing the _toolset and _run_step on a dict keyed by ctx.run_id.

That way, we can keep using the same toolset instance, but state from different runs wouldn't interfere. I think that's worth trying in this PR, as an alternative to the new copy method

# --- DynamicToolset / @agent.toolset tests ---


def get_dynamic_weather(location: str) -> 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 don't need this at the top level; we can register it to the FunctionToolset inline inside my_dynamic_toolset using @toolset.tool

# --- MCP-based DynamicToolset test ---
# Tests that @agent.toolset with an MCP toolset works with Temporal workflows.
# Uses FastMCPToolset (HTTP-based) rather than MCPServerStdio (subprocess-based) because
# MCPServerStdio has issues when created dynamically inside Temporal activities.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's link to the issue

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually work?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unsure what this was related to

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well we say "Uses per_run_step=False so the toolset persists across run steps within an activity.", but I'm not sure that actually works, as I think we regenerate the dynamic toolset each time, and we explicitly document that we don't respect per_run_step=False. And "run steps within an activity" doesn't really make sense


@dynamic_toolset_agent.toolset
def my_dynamic_toolset(ctx: RunContext[None]) -> FunctionToolset[None]:
return FunctionToolset(tools=[get_dynamic_weather], id='dynamic_weather')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use a value from deps inside the function (when we inline it), since the point of DynamicToolset is to build it dynamically. We can do that in the MCP server example as well, if that seems more appropriate

def temporal_activities(self) -> list[Callable[..., Any]]:
return [self.get_tools_activity, self.call_tool_activity]

async def __aenter__(self) -> Self:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This behavior in aenter/aexit may make sense on TemporalWrapperToolset. Then we can also remove the overrides from TemporalMCPServer.

toolset = CombinedToolset(toolsets)

# Copy the dynamic toolsets to ensure each run has its own instances
def copy_dynamic_toolsets(toolset: AbstractToolset[AgentDepsT]) -> AbstractToolset[AgentDepsT]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we use run_id in DynamicToolset, I don't think we need to do this anymore, nor do we need the copy method



@dataclass
class ToolsetRunStep(TypedDict, Generic[AgentDepsT]):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Could this be a dataclass?
  • Can we come up with a more clear name? Something with "state" in it

_run_step: int | None = None
per_run_step: bool
_id: str | None
_toolset_runstep: dict[str, ToolsetRunStep[AgentDepsT]]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe _run_state?


def __eq__(self, other: object) -> bool:
if not isinstance(other, DynamicToolset):
return False
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It reads a little easier to ad isinstance(other, DynamicToolset) and to the next lines

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be a dict instead of a list of tuples?


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the issue we're fixing, I meant let's fix to the issue that's called out in the next lines that we haven't fixed yet!

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well we say "Uses per_run_step=False so the toolset persists across run steps within an activity.", but I'm not sure that actually works, as I think we regenerate the dynamic toolset each time, and we explicitly document that we don't respect per_run_step=False. And "run steps within an activity" doesn't really make sense

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')
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 use our MCPServerStreamableHTTP instead? Or does that also have the issue of MCPServerStdio? In that case, the comment above is incorrect, because it's not just MCPServerStdio that has issues, but all MCPServers

# Different instances with different functions
assert toolset1 != toolset2

# Compare with non-DynamicToolset
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem really worth testing

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.

@agent.toolset decorator (DynamicToolset) does not work with Temporal

2 participants