feat(capabilities): support dynamic capabilities via callables in capabilities list#5252
Merged
feat(capabilities): support dynamic capabilities via callables in capabilities list#5252
Conversation
…abilities list Mirrors the dynamic-toolset pattern: a function that takes a RunContext and returns a capability can be passed alongside static capabilities to Agent(capabilities=[...]) or agent.run(capabilities=[...]). The factory is invoked once per run via for_run; the resolved capability's instructions, model settings, toolset, builtin tools, and hooks all flow through normally.
Contributor
Docs Preview
|
The toolset and constructor-wrapping tests previously inspected state without invoking the agent, leaving the tool body and factory body uncovered. Run the agent end-to-end so the realistic call paths execute.
…at top level Mirrors `pydantic_ai.AgentToolset` and `pydantic_ai.ToolsetFunc`. Also drops a redundant private-state assertion in the per-run test.
DouweM
commented
May 1, 2026
Drops the misleading `noqa: UP007` comment — `X | Y` works at runtime in 3.10+ as long as the right-hand side is a real expression, which it is here. `TypeAlias` annotation keeps pyright recognizing the alias.
DouweM
added a commit
that referenced
this pull request
May 1, 2026
Two integration points after #5252's `DynamicCapability` (callable-as-capability) landed on main: 1. **`deserialize_run_context` now sets `root_capability`** on the rebuilt `RunContext` inside an activity. Without it, `process_event_stream` falls back to the raw stream and outer capabilities like `ProcessEventStream` never see live events from inside the activity. Read it off `agent.root_capability` (the bound copy stored on the `TemporalDurability` instance, which is set on the run context's `__dict__` right above). 2. **`_validate_per_run_capabilities` skips its strict class-set check** when the bound tree contains a `DynamicCapability`. The factory result replaces the `DynamicCapability` itself in the run-time tree, so the runtime-class check would falsely reject any class produced by the factory. End-to-end support for dynamic-capability toolsets in durable execution is tracked in #5253; until that lands the relaxed check is the conservative choice (factory results without registered activities still fail loudly when the activity tries to dispatch — this is just to stop the construction-time guard from rejecting them outright).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Checklist
Summary
Adds support for dynamic capabilities — a function that takes a
RunContextand returns a capability — passed alongside static capabilities in theAgent(capabilities=[...])oragent.run(capabilities=[...])argument. Mirrors the existing dynamic-toolset pattern (Agent(toolsets=[fn])).The factory is invoked once per run via
for_run. The resolved capability's instructions, model settings, toolset, builtin tools, and hooks all flow through normally — no bespoke handling at every step of the agent loop, sincefor_runresolution + the existingCombinedCapability.for_runrecursion already handles the swap.Motivating use case: per-user skills loaded from a database (the Jack Collins champion call) — the agent receives a user_id via
deps, the dynamic capability factory looks up the user's skills, and contributes their per-user instructions for the run. Same pattern also fits multi-tenant agents, feature-flagged capabilities, A/B-tested capabilities, and admin-vs-regular scoping.What's added
pydantic_ai.capabilities.DynamicCapability(capability_func)— wraps aCapabilityFuncso it can sit alongside static capabilities in the agent's tree. Exported frompydantic_ai.capabilities.pydantic_ai.capabilities.CapabilityFunc— sync/async type alias(RunContext) -> AbstractCapability | None.pydantic_ai.capabilities.AgentCapability = Union[AbstractCapability, CapabilityFunc]— item type for thecapabilities=lists. Bare callables get auto-wrapped inDynamicCapability.capabilities=parameters (Agent ctor + run/iter overloads inAgent,AbstractAgent,WrapperAgent,TemporalAgent,DBOSAgent,PrefectAgent) widened fromSequence[AbstractCapability]toSequence[AgentCapability].Durable execution
A dynamic capability whose resolved capability contributes only instructions, model settings, builtin tools, hooks, or
prepare_tools/get_wrapper_toolset(i.e. noget_toolset()) works seamlessly with Temporal/DBOS/Prefect today — the factory runs in the workflow alongside the rest of the agent loop. Covers the Jack-Collins skills-from-DB pattern.Dynamic capabilities that contribute their own toolset via
get_toolset()are not yet supported with durable execution: the toolset is only known at run time and bypasses the durable wrapper's construction-time toolset registration. Documented indocs/capabilities.md. Tracked in a follow-on issue (will reference once created), to land on top of #4977 (TemporalDurability/DBOSDurability/PrefectDurabilitycapabilities) and the planned capabilityid/get_descriptionPR.A prototype Temporal interop landed earlier in this PR's history and was reverted after the design discussion concluded the work belongs inside
TemporalDurability.for_agent()(the new capability-based path), not on the legacy wrapper-agent path. The issue captures the design and code that's ready to port.Test plan
tests/test_capabilities.pycovering: factory called per-run withRunContext; sync vs async factories; factory returningNone(no contribution); contributing instructions/toolset/hooks/builtin_tools dynamically; called once per run not per step; returning aCombinedCapability; passed viaagent.run(capabilities=[...]); composition with static capabilities; per-run isolation across concurrent runs; constructor wraps bare callables.docs/capabilities.mdruns as part oftests/test_examples.py.