Skip to content

feat(capabilities): support dynamic capabilities via callables in capabilities list#5252

Merged
DouweM merged 7 commits intomainfrom
dynamic-capability
May 1, 2026
Merged

feat(capabilities): support dynamic capabilities via callables in capabilities list#5252
DouweM merged 7 commits intomainfrom
dynamic-capability

Conversation

@DouweM
Copy link
Copy Markdown
Collaborator

@DouweM DouweM commented Apr 29, 2026

  • Closes #

Checklist

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • No breaking changes in accordance with the version policy.
  • PR title is fit for the release changelog.

Summary

Adds support for dynamic capabilities — a function that takes a RunContext and returns a capability — passed alongside static capabilities in the Agent(capabilities=[...]) or agent.run(capabilities=[...]) argument. Mirrors the existing dynamic-toolset pattern (Agent(toolsets=[fn])).

from pydantic_ai import Agent, RunContext
from pydantic_ai.capabilities import AbstractCapability


@dataclass
class Skill(AbstractCapability[str]):
    """Per-user skill loaded from a database at run time."""

    name: str

    def get_instructions(self) -> str:
        return f'You can use the {self.name} skill.'


SKILLS = {
    'alice': Skill(name='refunds'),
    'bob': Skill(name='lookup'),
}


def user_skill(ctx: RunContext[str]) -> AbstractCapability[str] | None:
    return SKILLS.get(ctx.deps)


agent = Agent('openai:gpt-5.2', deps_type=str, capabilities=[user_skill])

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, since for_run resolution + the existing CombinedCapability.for_run recursion 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 a CapabilityFunc so it can sit alongside static capabilities in the agent's tree. Exported from pydantic_ai.capabilities.
  • pydantic_ai.capabilities.CapabilityFunc — sync/async type alias (RunContext) -> AbstractCapability | None.
  • pydantic_ai.capabilities.AgentCapability = Union[AbstractCapability, CapabilityFunc] — item type for the capabilities= lists. Bare callables get auto-wrapped in DynamicCapability.
  • All capabilities= parameters (Agent ctor + run/iter overloads in Agent, AbstractAgent, WrapperAgent, TemporalAgent, DBOSAgent, PrefectAgent) widened from Sequence[AbstractCapability] to Sequence[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. no get_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 in docs/capabilities.md. Tracked in a follow-on issue (will reference once created), to land on top of #4977 (TemporalDurability/DBOSDurability/PrefectDurability capabilities) and the planned capability id / get_description PR.

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

  • 12 new tests in tests/test_capabilities.py covering: factory called per-run with RunContext; sync vs async factories; factory returning None (no contribution); contributing instructions/toolset/hooks/builtin_tools dynamically; called once per run not per step; returning a CombinedCapability; passed via agent.run(capabilities=[...]); composition with static capabilities; per-run isolation across concurrent runs; constructor wraps bare callables.
  • Doc example in docs/capabilities.md runs as part of tests/test_examples.py.
  • Full agent / capabilities / toolsets / temporal / dbos / prefect / examples test suites pass (1734+ tests).
  • No type-check regressions (pyright clean across changed files).

…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.
@github-actions github-actions Bot added size: M Medium PR (101-500 weighted lines) feature New feature request, or PR implementing a feature (enhancement) labels Apr 29, 2026
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

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.
Comment thread tests/test_capabilities.py Outdated
Comment thread tests/test_capabilities.py Outdated
Comment thread pydantic_ai_slim/pydantic_ai/capabilities/__init__.py Outdated
Comment thread tests/test_capabilities.py Outdated
…at top level

Mirrors `pydantic_ai.AgentToolset` and `pydantic_ai.ToolsetFunc`.
Also drops a redundant private-state assertion in the per-run test.
Comment thread pydantic_ai_slim/pydantic_ai/capabilities/__init__.py Outdated
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 DouweM merged commit cad9569 into main May 1, 2026
53 checks passed
@DouweM DouweM deleted the dynamic-capability branch May 1, 2026 03:07
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature request, or PR implementing a feature (enhancement) size: M Medium PR (101-500 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant