545a703227 feat: Adds Dynamic Workflows (#273)
* feat(dynamic-workflow): orchestrate sub-agents from a model-written script

Turn-by-turn sub-agent delegation makes every composition step (chain, vote,
loop) a separate model turn and pushes each intermediate result back through the
orchestrator's context. DynamicWorkflow is Code Mode with sub-agents as the
callables: the model writes one Monty-sandboxed script that composes the
sub-agents with ordinary control flow, and only the final result returns to the
model. Extracts the shared Monty execution loop into _monty_exec, reused by
code_mode.

The pydantic-ai bump the capability needs (defer_loading, ctx.enqueue) lands
separately, per the repo policy on dependency changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(deps): bump pydantic-ai 1.95.1 -> 1.105.0

DynamicWorkflow needs defer_loading (>=1.97) and ctx.enqueue (1.105) for
on-demand loading and cache-stable runtime sub-agent reveal. The lock exempts
the pydantic-ai family from its exclude-newer cutoff to pull the newer release.
Collateral: xfail a code_mode ToolSearch test that relied on the old
discovered-tool tracking, and regenerate the managed_prompt snapshot for the
new tool-definition fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(examples): tidy example docs and keep them fully linted

Drop the blanket 'examples/**' pydocstyle ignore — it masked a single missing
docstring. Add the one-line docstring to fork_and_resume's main() instead, so
examples stay under the same docstring rules as library code. Also drop the
redundant "Runs / needs a key" column from the examples README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(dynamic-workflow): render catalog via core FunctionSignature, reject keyword names

Addresses PR #273 review.

Render the sub-agent catalog through pydantic_ai's FunctionSignature (the same
renderer code_mode uses) instead of a hand-rolled f-string. This is the real
dedup with code_mode and fixes two latent bugs: the signature now forces
keyword-only `task` (`async def name(*, task: str)`) to match dispatch, which
reads kwargs['task']; and a description containing a newline or quote now
renders as a safe docstring block instead of breaking the f-string.

Reject sub-agent names that are Python keywords. `'class'.isidentifier()` is
True, so a keyword name passed validation but the model could never call it
(`await class(...)` is a syntax error) — a silently-uncallable agent. Validate
non-keyword identifiers at construction, via a shared in-file helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(dynamic-workflow): harden public API ahead of release

Adversarial review of the dynamic-workflow surface surfaced contracts
that would be painful to change once released; fix them while cheap:

- Correct the "usage_limits bounds the tree" claim — the parent's limit
  is never forwarded to sub-agents. Add `sub_agent_usage_limits` so a
  per-sub-agent limit plus `max_agent_calls` give a real tree-wide token
  ceiling, and document what `forward_usage` actually does.
- Own `WorkflowResourceLimits` instead of leaking pydantic_monty's
  `ResourceLimits` onto the public field; add an explicit `'unlimited'`
  sentinel and merge a partial dict onto the backstop (no silent drop).
- Narrow `agents` to `list` so the append-to-reveal contract is enforced
  by the type rather than explained in prose.
- Drop the `description` -> `agent.name` fallback that rendered a useless
  self-referential docstring to the model.
- Teach the model-facing tool description what the README already knew:
  keyword-only `task`, no `return_exceptions`/abort-on-raise, dict (not
  attribute) access for structured output, and the print envelope.

Also refresh the runnable examples (audit + migrate) and keep the README
and tests in sync. lint/typecheck clean, 100% branch coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* removing time limit from monty

* docs(examples): consolidate into one verified DynamicWorkflow showcase

Replace the overlapping audit and migrate examples with a single example
that exercises the full capability range — parallel fan-out, read/write
confinement, an adversarial review, a feedback loop, typed fan-in, and a
Logfire trace — so a reader sees what the capability is for without
wading through two near-identical fan-out demos.

Fixes issues an adversarial review surfaced: the retry loop now threads
reviewer issues back to the migrator (so it can actually converge), the
synthesizer's typed report survives to the caller via the orchestrator's
output_type, and several claims (budget ceiling, confinement, run
command portability) are corrected to match the code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(deps): raise pydantic-ai-slim floor to 1.101.0 for RunContext.enqueue

The dynamic_workflow reveal path calls ctx.enqueue to announce a
sub-agent added mid-run, but RunContext.enqueue did not exist until
pydantic-ai 1.101.0 (verified: absent in 1.100.0, present in 1.101.0).
The declared floor was still >=1.95.1, so the test-floor job
(--resolution lowest-direct) and any install at the floor would resolve
a RunContext without enqueue and raise AttributeError when revealing an
agent instead of announcing it.

Bump the runtime dep, the logfire extra, and the dev group to >=1.101.0,
and update the README requirement line to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(dynamic-workflow): move capability under experimental

DynamicWorkflow ships as experimental so its API can change without a
deprecation period, like the compaction menu. Importing it now emits a
HarnessExperimentalWarning, silenceable category-wide.

_monty_exec stays at the package root: it is shared with code_mode, which
is stable, so it must not move into experimental.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(dynamic-workflow): correct stdlib import guidance for the sandbox

The tool description and README presented `asyncio, math, json, re, typing`
as the complete set of importable modules. In Monty, `datetime`/`os`/`sys`
also import, but they are inert in DynamicWorkflow (no os/path hooks are
registered, so their calls resolve to NameError). Reword to a curated,
non-exhaustive list so the model-facing text is accurate without steering
toward the stubbed dead ends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dynamic-workflow): harden sandbox boundary and reveal/config validation

Adversarial review of the run_workflow path surfaced several silent-failure
and crash modes, all reachable from model-authored scripts or host config:

- A model script awaiting the same sub-agent call twice in one asyncio.gather
  made the Monty VM raise pyo3_runtime.PanicException (a BaseException), which
  escaped both Monty error handlers and tore down the whole agent run. Catch
  it by name and convert to a retry; non-panic BaseExceptions still propagate.
- Sub-agent calls silently dropped extra keyword args and accepted a non-string
  task (a dict/list was smeared into message parts), running on wrong input
  with no signal. Reject both at dispatch, before the budget is touched.
- A runtime reveal whose name was invalid or collided with an existing agent
  was silently dropped, keeping the old agent while the host believed it had
  swapped one in. Distinguish idempotent re-reveal (object identity) from a
  real conflict, warn once on the latter, never swap silently.
- max_agent_calls < 1 produced a dead-on-arrival tool with a misleading
  "conclude with results gathered" message; a typo'd resource_limits key was
  silently ignored, disabling the only guard against a pure-CPU loop. Both now
  raise UserError at construction.

Also pins the headline guarantee with a concurrent fan-out test asserting
max_agent_calls admits exactly the budget under asyncio.gather (previously only
covered sequentially). _toolset.py stays at 100% line+branch coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: await cancelled dispatch tasks and survive bad mid-run reveals

Cancelling a Monty execution previously fired task.cancel() and returned
immediately, leaving in-flight tool calls and sub-agent runs unwinding in
the background — they could mutate the shared usage accumulator after the
tool call had already reported its result, and teardown errors were never
retrieved. The executor now awaits the cancelled tasks so nothing outlives
the call that spawned it. This changes code_mode's cancellation timing too
(flagged in the PR description): teardown now waits for in-flight calls to
unwind instead of abandoning them.

Also: create the sequential dispatch coroutine after the pending-task
barrier so cancellation at the barrier cannot leak a never-awaited
coroutine, and make `for_run` index the agent list leniently — an invalid
WorkflowAgent appended mid-run (which `_reveal_pending` deliberately
tolerates) no longer hard-fails every subsequent run via `replace`'s
re-validation.

A new test pins that host-raised exceptions cannot be caught inside the
sandbox: the budget-exhausted terminal result relies on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(dynamic-workflow): document experimental status and actual return shapes

The README never said the import warns or how to silence it, omitted the
print()/None result shaping the tool actually performs, and the API block
hid `description` and the WorkflowResourceLimits type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(dynamic-workflow): cover deps forwarding, message isolation, and shared-counter limits

An adversarial tests-vs-spec pass found three documented behaviors with no
assertion behind them: parent deps reaching sub-agents, sub-agents never
seeing the parent conversation, and sub_agent_usage_limits checked against
the shared counter under forward_usage=True. Dropping any of them would
have passed the suite. Also pins the documented backstop values and the
served tool's max_retries, asserts the reveal announcement names the actual
tool, and fixes two comments that described a duration backstop / a
dataclasses.replace that don't exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(dynamic-workflow): record the core usage_limits TOCTOU race behind max_agent_calls

The exact host-side counter exists because core's limit enforcement races
under concurrent fan-out and RunContext can't forward the parent's limits;
pin that context (and the upstream TODO) where the discipline lives so it
isn't lost once the PR ships.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(examples): replace the generated example with a feature-led README

Review feedback on #273: the example will be rewritten by hand, and the
README should lead with the Anthropic dynamic-workflows post and the
feature itself rather than narrating one example run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(dynamic-workflow): add a dedicated install extra

Installing DynamicWorkflow's sandbox dependency via the code-mode extra
conflated two capabilities; each now names its own extra (both resolve
to pydantic-monty).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(code_mode): convert sandbox VM panics to retries

A model-written script that awaits the same call twice in one
asyncio.gather panics the Monty VM; pyo3 surfaces that as a
BaseException that nothing caught, tearing down the whole agent run.
DynamicWorkflow already guarded this; the guard now lives in
_monty_exec and code_mode applies it too, dropping its REPL since the
aborted VM's accumulated state can't be trusted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(dynamic-workflow): remove duplication that could drift

Single-source the long-form field docs on the capability (the toolset
docstrings now point there), derive the resource-limit key set from the
TypedDict instead of a parallel frozenset, and freeze the rendered tool
description itself rather than a catalog dict re-rendered every step --
making the cache-stability claim structural. Drops the for_run_step
override that duplicated the base default, plus redundant tests and a
stateless test fixture. No behavior or public API change; coverage
stays at 100%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: narrow the shared executor seam and drop dead type unions

Name sanitization is a code_mode concern, so the mapping moves into its
dispatch closure and MontyExecutor stops knowing about it. The empty
pending_call_ids guard fell through to an identical resume call, so it
goes. Concrete return types on get_toolset/for_run let tests drop four
isinstance asserts that existed only to narrow types pyright can now
see directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: fix a comment stating a false invariant; unshadow a closure param

_by_name is mutated during a run (reveals fold into it each step); the
stable-registry invariant only holds while a script executes, which is
what the executor relies on. The dispatch closure's name param shadowed
call_tool's own, a future-edit hazard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(dynamic-workflow): restructure README for faster comprehension

Lead with a minimal runnable snippet and state the context-saving idea once
instead of repeating it across Why/What/blockquote. Move Installation above the
full example. Turn the sub_agent_usage_limits paragraph into a forward_usage
True-vs-False table so the N*T hard ceiling and the best-effort concurrency
caveat are scannable. No technical claims changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dynamic-workflow): address CodeRabbit review

- Fix broken relative link to Code Mode README: the capability lives two
  levels deep under experimental/, so `../code_mode/` resolved to a
  non-existent experimental/code_mode/. Use `../../code_mode/`.
- Add missing `-> None` return types to all four test_warnings methods (the
  bot flagged one; the whole file was missing them).
- Narrow `_workflow_result`'s unconstrained passthrough from `Any` to `object`.

Left the other flagged `Any`s in place: the `call` override and `dispatch`
must match `AbstractToolset.call_tool` / `_monty_exec.DispatchFn`, and
`AbstractAgent[..., Any]` carries a heterogeneous sub-agent output type that
`object` would reject on assignment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dynamic-workflow): harden error surfaces, render output schemas, add reveal() API

Findings from an end-to-end adversarial review (PR #273 branch), fixes
authored with codex exec:

- Serialize sub-agent output inside the dispatch error wrapper: a
  non-serializable output leaked host class paths into the retry text
  and failed identically on every retry.
- Render each sub-agent's output schema in the catalog and reveal
  announcements (TypedDict-style, with cross-catalog type-name conflict
  handling). The model previously saw `-> Any` and had to be told the
  fields in prose.
- Add DynamicWorkflow.reveal() as the supported runtime reveal API;
  list append remains the underlying mechanism. An aliased-list-only
  contract cannot survive the planned durable resume.
- Return terminal {'error': ...} results for permanent conditions
  (nesting refusal, budget exhaustion) instead of ModelRetry, which
  burned retries into UnexpectedModelBehavior. The budget result now
  carries last_error and completed.
- Salvage completed sub-agent results into retry messages and the
  budget-terminal dict so a retry can reuse them instead of re-spending
  the budget; the sandbox-panic path does the same.
- Pass through model-safe exception messages (UsageLimitExceeded) so
  the model can react; keep the opaque form for everything else.
- State the call budget and sub-agent statelessness in the tool
  description; warn instead of failing when a reveal has no pending
  message queue to announce into.
- Docs: correct the N*T "hard ceiling" claim (core checks token limits
  after a response arrives), fix the examples README script (it
  subscripted str outputs), pin model-facing text with snapshots.

The old "budget error cannot mask another failure" comment was
disproven under asyncio.gather; the true invariant is documented.

Refs: https://www.anthropic.com/engineering/code-execution-with-mcp,
https://code.claude.com/docs/en/workflows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(dynamic-workflow): align tests with pydantic-ai v2 deps typing

Merging main raised the pydantic-ai-slim floor to >=2.1, whose Agent
`deps_type` default became `object` (was `NoneType`). Constructing an
`Agent(..., capabilities=[Cap[None](...)])` then fails overload matching:
the capabilities pin `AgentDepsT=None` while the default supplies `object`.
Switch the deps typevar to `object` throughout, matching the convention the
code_mode and subagents tests already use.

Also make test_max_agent_calls_exact_under_concurrent_fan_out order-
independent. The completed list was asserted in exact order, but which three
of eight concurrent calls win the budget and the order they finish are both
scheduler-dependent (3.14 admits them in a different order than 3.13). Assert
the invariant that matters: exactly the budget ran, each once, well-formed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: stop tracking .agents/scheduled_tasks.lock

This is a machine-local runtime lock for Claude Code's scheduled-task
runner (pid/session mutex), not project content. It was committed by
accident. Remove it from the tree and gitignore it alongside the other
.agents machine-local files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(dynamic-workflow): accept raw agents, make reveal() the strict mutation API

Pydantic AI 2.x agents carry their own name and description, so wrapping
every entry in WorkflowAgent was boilerplate: agents=[reviewer, summarizer]
now works, following the OpenAI-handoffs union pattern where a raw agent
means "use the agent's own metadata" and the wrapper is a per-use-site
override. WorkflowAgent gains the agent.description fallback and positional
agent that the sibling SubAgent already had; the divergence had no rationale.

Making reveal() the only post-construction channel (eager validation,
UserError at the call site, internal catalog list) removes the reason the
toolset needed lenient re-validation: the strict/lenient _rebuild duality,
_reveal_warned, and the warn-and-skip paths are deleted, and an entry that
reaches the toolset invalid now fails fast as a contract violation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(dynamic-workflow): rewrite README as a progressive tutorial

The reference-style README front-loaded precise semantics before a reader
had a mental model to hang them on. Reshape it into a learning arc -- smallest
working example first, one concept per section, dense tables tucked into
admonition boxes -- so the same facts land in the order a reader needs them.
All technical claims are carried over unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(dynamic-workflow): use raw-agent shorthand in the examples README

The migration example wrapped each sub-agent in WorkflowAgent solely to
supply a catalog description, which read as pre-refactor mandatory wrapping
and contradicted the prose above it ("documented by its description"). An
agent's description is a property of the agent, not of one workflow, and the
field has other consumers (agent spec, OTel gen_ai.agent.description), so its
natural home is the Agent. Move the descriptions onto the agents and pass
them raw; WorkflowAgent stays documented in the capability README's override
section for the cases that need it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(dynamic-workflow): drop internal decisions log from the PR

DYNAMIC_WORKFLOW_DECISIONS.md was an internal working-notes log (branch
codenames, v1/maybe-v2 planning, open problems). It doesn't belong in the
shipped repo and duplicates the capability README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: fix pydantic-ai-slim version pin and tighten DynamicWorkflow docstrings

Correct the required version in README.md to `pydantic-ai-slim>=2.1.0` (matches
pyproject.toml; the previous `>=1.101.0` was wrong), and trim the verbose
DynamicWorkflow capability/field docstrings to say the same thing more concisely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dynamic-workflow): promote out of experimental

DynamicWorkflow graduates to a released top-level capability after a
hardening round driven by live model runs and adversarial review:

- Live-exercised with a real Opus orchestrator across ten scenarios
  (fan-out, structured output, budget exhaustion, error salvage,
  runtime reveal, defer_loading, nesting refusal, open-ended research).
  The one model-facing trip point found -- reading the sub-agent call
  budget as per-script rather than per-run -- is fixed in the tool
  description.
- Reveals on a deferred capability no longer leak: get_tools holds
  reveal folding and announcements until the capability is loaded.
  Ownership is resolved by identity through the run's capability
  registry, unwrapping wrapper chains, because ids cannot identify the
  owner (an id-less capability registers under a generated key, and a
  wrapper registers in place of what it wraps).
- Public annotations erase sub-agent outputs as `object`, not `Any`.
- README drops the experimental banner, documents the per-run budget
  and keyword-only construction, and loses a stray editing artifact
  that had been committed at the end of the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(dynamic-workflow): let sub-agents inherit the run model

* test(dynamic-workflow): exclude never-run parent model fn from coverage

The parent model callback in test_inherit_model_off_keeps_sub_agent_bound_model
is asserted never to run, so its body is unreachable and broke fail-under=100.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dynamic-workflow): merge into CodeMode when both are on one agent

With both capabilities registered, CodeMode used to fold run_workflow into
the run_code sandbox as a plain function taking a code string, so the model
had to write a script containing a second script as a string literal, run
in a nested sandbox, with no return schema. The two now compose instead:
when a CodeMode capability is present, DynamicWorkflow exposes each
sub-agent as its own (task) tool carrying the agent's output schema, and
CodeMode renders them as typed async functions inside run_code, callable
alongside the agent's regular tools in one script. Budget, usage
forwarding, and the nesting guard moved into a shared call path so both
modes enforce the same rules; the sub-agent guidance run_workflow's
description carried moves into instructions in merged mode. No new public
API: the merge is automatic.

Standalone run_workflow scripts are now statically type-checked against
the sub-agent signatures before execution (every call is a fresh sandbox,
so the check is always sound); a misspelled function or positional task
costs a retry but no sub-agent budget. The runtime kwarg guards remain,
because Any-typed values (e.g. json.loads results) evade the checker, and
the tests now exercise them through exactly that route instead of reaching
into private helpers.

Also: budget-sharing across multiple run_workflow calls in one run gets a
test (the tool description's headline claim was untested), the examples/
README folds into the capability README, and the unrelated .gitignore line
is dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: explain DynamicWorkflow in the main README

The main README showed CodeMode end to end but only gave DynamicWorkflow a
matrix row, so a reader had no way to see the point of the capability
without opening its package README. Add a compact section after the Quick
start: the turn-by-turn delegation problem, the two-line setup, the script
the model writes, and how it composes with CodeMode, budgets, and deferred
loading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(dynamic-workflow): move under experimental

New capabilities start under pydantic_ai_harness.experimental per repo
convention, and the planned extensions (structured sub-agent inputs,
durable workflows) touch the sub-agent call contract, so the API needs
room to move before names go stable. This reverses the earlier top-level
promotion on top of everything built since: importing the capability now
emits HarnessExperimentalWarning and there is no top-level export.

The README additionally explains the experimental status, positions
DynamicWorkflow against SubAgents (one-delegation-per-turn vs scripted
choreography), and documents the merged-mode tool-name collision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(dynamic-workflow): separate from CodeMode instead of merging

The CodeMode merge coupled two independent capabilities, and when their
composition did not line up -- a restrictive CodeMode `tool_selector`, or
running out of the sub-agent budget mid-script -- it degraded in ways the
merge could not recover cleanly. Drop the merge: DynamicWorkflow always
exposes its `run_workflow` tool and no longer inspects the run's
capabilities.

`run_workflow` is itself a code-execution sandbox, so CodeMode must not
fold it into `run_code` -- that would make the model pass a script as a
string argument to a function inside another script. CodeMode now keeps
any tool carrying `code_arg_name` metadata native; `run_workflow` already
declares it, so with both capabilities the model sees `run_code` and
`run_workflow` as two independent tools. The rule only affects
code-execution tools, so CodeMode is unchanged for every other tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(dynamic-workflow): correct max_duration_secs semantics and refresh README

The docs claimed `max_duration_secs` counts wall-clock, including time awaiting
sub-agents fanned out with `asyncio.gather`, and justified having no default cap
on that basis. Empirically that is false across every monty the capability
supports: the timer is a per-bytecode-step check, so it measures in-sandbox
execution time and excludes time the script spends suspended on the host awaiting
sub-agents (sequential or gathered). Correct the claim in `_toolset.py`,
`_capability.py`, and the README, and describe what the cap is actually for: a
pure-CPU `while True` runaway, the one thing the sub-agent budgets cannot catch.

Add a regression test that pins the corrected behavior so the stale claim cannot
silently return: slow sub-agents under a small cap still complete.

Rewrite the README prose in a plainer voice with no em-dashes, and cite Jarred
Sumner's Bun Zig-to-Rust rewrite as a real-world example of the same pattern (at
Claude Code's session scale, not this specific API).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:53:02 +05:30
2026-07-09 16:53:02 +05:30
2026-03-21 00:30:54 -05:00
2026-04-13 18:33:40 +05:30
2026-03-20 15:33:12 -05:00
2026-07-09 16:53:02 +05:30
2026-07-09 16:53:02 +05:30

Pydantic AI Harness

CI PyPI versions license

The batteries for your Pydantic AI agent.


Pydantic AI's capabilities and hooks API is how you give an agent its harness -- bundles of tools, lifecycle hooks, instructions, and model settings that extend what the agent can do without any framework changes.

Pydantic AI Harness is the official capability library for Pydantic AI, maintained by the Pydantic AI team. Pydantic AI core ships capabilities that require model or framework support, and capabilities fundamental to every agent -- web search, tool search, thinking. Everything else lives here: standalone building blocks you pick and choose to turn your agent into a coding agent, a research assistant, or anything else. This is also where new capabilities start -- as they stabilize and prove themselves broadly essential, they can graduate into core.

The capability matrix tracks where we are. Tell us what to prioritize.

Contents: Installation · Quick start · DynamicWorkflow · Capability matrix · An ecosystem agent · Help us prioritize · Build your own · Contributing · Version policy · Pydantic AI references · License

Installation

uv add pydantic-ai-harness

Extras for specific capabilities:

uv add "pydantic-ai-harness[codemode]"          # CodeMode (adds the Monty sandbox)
uv add "pydantic-ai-harness[dynamic-workflow]"  # DynamicWorkflow (adds the Monty sandbox)
uv add "pydantic-ai-harness[logfire]"           # ManagedPrompt (Logfire-managed prompts)
uv add "pydantic-ai-harness[acp]"               # ACP (serve an agent to editors over the Agent Client Protocol)

The code-mode extra is also supported as an alias.

Requires Python 3.10+ and pydantic-ai-slim>=2.1.0.

Quick start

uv add "pydantic-ai-slim[anthropic,mcp,duckduckgo,logfire]" "pydantic-ai-harness[code-mode]"
import logfire
from pydantic_ai import Agent
from pydantic_ai.capabilities import MCP, WebSearch
from pydantic_ai_harness import CodeMode

# See https://ai.pydantic.dev/logfire/ for setup details.
logfire.configure()
logfire.instrument_pydantic_ai()

agent = Agent(
    'anthropic:claude-opus-4-7',
    capabilities=[
        # Wraps every tool into a single run_code tool, sandboxed by Monty
        # (https://github.com/pydantic/monty -- pulled in by the [code-mode] extra).
        # The model writes Python that calls multiple tools with loops, conditionals,
        # asyncio.gather, and local filtering -- one model round-trip for N tool calls.
        CodeMode(),
        # Connect to any MCP server -- here, the open-source Hacker News server
        # (https://github.com/cyanheads/hn-mcp-server). native=False forces the
        # local MCP toolset so CodeMode can wrap the tools; without it,
        # providers that natively support MCP server connectors execute the tools
        # server-side and bypass the sandbox.
        MCP('https://hn.caseyjhand.com/mcp', native=False),
        # Provider-adaptive web search; native=False routes through the local
        # DuckDuckGo fallback (the [duckduckgo] extra above) so CodeMode can batch
        # web searches alongside the HN calls in a single run_code.
        WebSearch(native=False),
    ],
)

result = agent.run_sync(
    "Across the top, best, and 'show HN' Hacker News feeds, find the most-discussed "
    "story with at least 100 points. Pull its comment thread, its submitter's profile, "
    "and any web coverage. Summarize what you find in one paragraph."
)
print(result.output)
"""
The most-discussed HN story across top/best/show clearing 100 points is "Vibe coding
and agentic engineering are getting closer than I'd like" by Simon Willison (748 points,
853 comments, on the Best feed), submitted by long-time HNer e12e. The piece argues
that the two modes Willison once kept mentally separate -- throwaway "vibe coding" and
disciplined "agentic engineering" -- are blurring, since agents like Claude Code now
reliably handle non-trivial tasks like "build a JSON API endpoint that runs a SQL query"
with tests and docs on the first pass. The HN thread is unusually substantive, with
commenters debating whether LLMs created or merely *exposed* sloppy engineering
practices and warning of a "normalization of deviance" as engineers stop reviewing diffs.
"""

Logfire trace from the Quick start run

See this run as a public Logfire trace → Each run_code span fans out into the tool calls the model issued from inside the sandbox -- it's the easiest way to understand what code mode actually did.

Orchestrating sub-agents: DynamicWorkflow

CodeMode gives the model one script for its tools. DynamicWorkflow does the same for sub-agents. Without it, an orchestrator delegates one tool call at a time: call a sub-agent, wait, read the result into context, think, call the next one. Ten delegations cost ten model round-trips, and every intermediate result flows through the orchestrator's context whether it needed to see it or not.

With it, the model writes one Python script in which each sub-agent is an async function, and the whole tree runs in a single tool call:

from pydantic_ai import Agent
from pydantic_ai_harness.experimental.dynamic_workflow import DynamicWorkflow

reviewer = Agent('anthropic:claude-sonnet-4-6', name='reviewer', description='Reviews code for bugs.')
summarizer = Agent('anthropic:claude-sonnet-4-6', name='summarizer', description='Summarizes findings.')

orchestrator = Agent(
    'anthropic:claude-opus-4-7',
    capabilities=[DynamicWorkflow(agents=[reviewer, summarizer])],
)

The script the model writes looks like this -- fan out, chain, and only the last line's value returns to its context:

import asyncio

reports = await asyncio.gather(
    reviewer(task="Review auth.py for bugs:\n<file contents>"),
    reviewer(task="Review parser.py for bugs:\n<file contents>"),
)
await summarizer(task="Summarize these findings:\n" + "\n\n".join(reports))

It composes with the rest of the harness:

  • Budgets: max_agent_calls is an exact, host-enforced ceiling on sub-agent runs (it holds even under concurrent fan-out), and by default the whole tree's token spend lands on the parent run's usage.
  • On-demand: defer_loading=True keeps the catalog out of the prompt until the model loads the capability, and reveal() adds a sub-agent mid-run without disturbing the prompt cache.

DynamicWorkflow ships under experimental while planned extensions (structured sub-agent inputs, durable workflows) settle the call contract; importing it emits a HarnessExperimentalWarning.

Full tutorial →

Capability matrix

We studied leading coding agents, agent frameworks, and Claw-style assistants to map every capability area that matters for production agents. Each one is tracked as an issue in this repo.

Vote on whatever is linked in the Status column -- PRs if we're actively building it, issues if it's planned -- to help us decide what to work on next.

Category Capability Description Status Community alternatives
Tools & execution Code mode Sandboxed Python execution via Monty -- one run_code call replaces N tool calls Docs
Tool search Progressive tool discovery for large tool sets Pydantic AI
File system Read, write, edit, search files with path traversal prevention Docs pydantic-ai-backend (vstormco)
Shell Execute commands with allowlists, denylists, and timeouts Docs pydantic-ai-backend (vstormco)
Repo context injection Auto-load CLAUDE.md/AGENTS.md and repo structure Docs (experimental) pydantic-deep (vstormco)
Docs lookup On-demand read_pyai_docs tool for Pydantic AI docs Docs (experimental)
Verification loop Run tests after edits, auto-fix failures 🚧 PR #169
Editor integration ACP Serve an agent to editors (Zed, etc.) over the Agent Client Protocol -- streamed text, diff-rendered edits, tool approval Docs (experimental)
Prompt management Managed prompt Back an agent's instructions with a Logfire-managed prompt, editable without shipping code Docs
Context management Sliding window Trim conversation history to stay within token limits Docs (experimental) summarization-pydantic-ai (vstormco)
Context compaction LLM-powered summarization of older messages Docs (experimental) summarization-pydantic-ai (vstormco)
Limit warnings Warn agent before hitting context/iteration limits Docs (experimental) summarization-pydantic-ai (vstormco)
Tool output management Truncate, summarize, or spill large tool outputs Docs (experimental)
System reminders Inject periodic reminders to counteract instruction drift 🚧 PR #181
Memory & persistence Memory Persistent key-value memory across sessions 🚧 PR #179 pydantic-deep (vstormco)
Session persistence Save and restore full conversation state Docs (experimental)
Checkpointing Snapshot, resume (continue_run), and fork (fork_run) a run Docs (experimental) pydantic-deep (vstormco)
Media externalization Offload large BinaryContent to content-addressed stores (building blocks) Docs (experimental)
Agent orchestration Sub-agents Delegate subtasks to specialized child agents Docs (experimental) subagents-pydantic-ai (vstormco)
Dynamic workflow Orchestrate sub-agents from a model-written Python script -- fan-out, chaining, voting in one tool call Docs (experimental)
Skills Progressive tool loading -- search, activate, deactivate 🚧 PR #183 pydantic-ai-skills (DougTrajano), pydantic-deep (vstormco)
Planning Break complex tasks into structured plans before execution Docs (experimental)
Runtime authoring Let an agent author, validate, and load real capabilities at runtime Docs (experimental)
Task tracking Track tasks, subtasks, and dependencies 📝 #65 pydantic-ai-todo (vstormco)
Teams Multi-agent teams with shared state and message bus 📝 #195 pydantic-deep (vstormco)
Safety & guardrails Input guardrails Validate user input before the agent run starts 🚧 PR #182 pydantic-ai-shields (vstormco)
Output guardrails Validate model output after the run completes 🚧 PR #182 pydantic-ai-shields (vstormco)
Cost/token budgets Enforce token and cost limits per run 🚧 PR #182 pydantic-ai-shields (vstormco)
Tool access control Block tools or require approval before execution 🚧 PR #182 pydantic-ai-shields (vstormco)
Async guardrails Run validation concurrently with model requests 🚧 PR #182 pydantic-ai-shields (vstormco)
Secret masking Detect and redact secrets in agent I/O 🚧 PR #172 pydantic-ai-shields (vstormco)
Approval workflows Require human approval for sensitive operations 🚧 PR #173 Pydantic AI (builtin)
Tool budget Limit total tool calls or cost per run 🚧 PR #168
Reliability Stuck loop detection Detect and break out of repetitive agent loops 🚧 PR #186
Tool error recovery Retry failed tool calls with backoff and budget 🚧 PR #171
Tool orphan repair Fix orphaned tool calls in conversation history 🚧 PR #184
Reasoning Adaptive reasoning Adjust thinking effort based on task complexity 🚧 PR #174
Current time Inject current date/time into system prompt 🚧 PR #170

Packages by vstorm-co are endorsed by the Pydantic AI team. We're working with them to upstream some of their implementations into this repo.

An ecosystem agent

The Quick start above is deliberately small. Here's the other end of the spectrum -- an agent wired up with capabilities drawn from across the Pydantic AI ecosystem: this repo, core pydantic-ai, and the community packages we vouch for in the matrix above.

import logfire
from pydantic_ai import Agent
from pydantic_ai.capabilities import MCP, Thinking, ToolSearch, WebSearch
from pydantic_ai_harness import CodeMode

# Community packages, alphabetical:
from pydantic_ai_backends import ConsoleCapability
from pydantic_ai_shields import CostTracking, InputGuard, SecretRedaction, ToolGuard
from pydantic_ai_skills import SkillsCapability
from pydantic_ai_summarization import ContextManagerCapability
from pydantic_ai_todo import TodoCapability
from pydantic_deep import MemoryCapability, StuckLoopDetection
from subagents_pydantic_ai import SubAgentCapability, SubAgentConfig

# See https://ai.pydantic.dev/logfire/ for setup details.
logfire.configure()
logfire.instrument_pydantic_ai()

agent = Agent(
    'anthropic:claude-opus-4-7',
    capabilities=[
        # --- Tool execution & discovery ---
        # Wraps every tool into a single run_code, sandboxed by Monty.
        CodeMode(),

        # Progressive tool discovery for large tool sets; discovered tools fold into run_code.
        ToolSearch(),

        # --- Reasoning ---
        # Provider-adaptive thinking; uses native extended thinking on supporting models.
        Thinking(effort='xhigh'),

        # --- Context management ---
        # Sliding window + LLM compaction. By @vstorm-co:
        # https://github.com/vstorm-co/summarization-pydantic-ai
        # Pydantic AI also ships `AnthropicCompaction` and `OpenAICompaction` for
        # provider-native compaction.
        ContextManagerCapability(max_tokens=180_000),

        # --- Tools ---
        # Connect to any MCP server -- here, the open-source Hacker News server
        # (https://github.com/cyanheads/hn-mcp-server).
        MCP('https://hn.caseyjhand.com/mcp'),

        # Provider-adaptive web search; falls back to a local DuckDuckGo implementation.
        WebSearch(),

        # Filesystem + shell. By @vstorm-co: https://github.com/vstorm-co/pydantic-ai-backend
        ConsoleCapability(),

        # --- Memory & persistence ---
        # Persistent ./MEMORY.md per agent name. By @vstorm-co:
        # https://github.com/vstorm-co/pydantic-deepagents
        MemoryCapability(agent_name='harness-example'),

        # --- Orchestration ---
        # Agent skills (Anthropic's spec) by @DougTrajano:
        # https://github.com/DougTrajano/pydantic-ai-skills
        # @vstorm-co's pydantic-deep also offers skills loading; the two have different
        # spec footprints (Doug's is closer to programmatic skills).
        SkillsCapability(directories=['./skills']),

        # Spawn sub-agents with their own toolsets and instructions. By @vstorm-co:
        # https://github.com/vstorm-co/subagents-pydantic-ai
        SubAgentCapability(subagents=[
            SubAgentConfig(
                name='researcher',
                description='Deep research on a topic',
                instructions='You are a thorough research assistant.',
            ),
        ]),

        # Track tasks and subtasks; in-memory by default, AsyncPostgresStorage available.
        # By @vstorm-co: https://github.com/vstorm-co/pydantic-ai-todo
        TodoCapability(enable_subtasks=True),

        # --- Safety & reliability ---
        # The next four are by @vstorm-co: https://github.com/vstorm-co/pydantic-ai-shields
        # Per-run cost cap with a callback hook.
        CostTracking(budget_usd=5.0),

        # Reject prompts that look like prompt-injection attempts.
        InputGuard(guard=lambda p: 'ignore previous instructions' not in p.lower()),

        # Block or require approval per tool name.
        ToolGuard(blocked=['rm'], require_approval=['write_file']),

        # Detect API keys/tokens in tool I/O and redact before they reach the model.
        SecretRedaction(),

        # Bail out if the agent gets stuck calling the same tools in a loop.
        # By @vstorm-co: https://github.com/vstorm-co/pydantic-deepagents
        StuckLoopDetection(),
    ],
)

This snippet is illustrative, not literally copy-pasteable: a few capabilities have setup requirements (a ./skills directory, a Postgres database for TodoCapability's persistent storage), and the community packages move independently of this one. The capability matrix tracks each one's status. As the harness ships first-party versions, the imports above will collapse onto fewer packages -- but the example will keep working, since the API surface is the same.

Help us prioritize

Vote on whatever is linked in the Status column above. If there's a PR, vote on the PR -- it means we're actively building it. If there's only an issue, vote on the issue.

Want something that's not on the list? Open a capability request.

Build your own

Capabilities are the primary extension point for Pydantic AI. Any of the existing capabilities in this repo can serve as a reference for building your own.

Publishing as a standalone package? Use the pydantic-ai-<name> naming convention. See Publishing capability packages.

Contributing

We welcome capability contributions. Here's how:

  1. Start with an issue. Open a capability request describing the behavior you want. This lets us discuss the approach and priority before code is written -- we can close an approach without closing the problem.
  2. Then open a PR. Once the issue exists, you're welcome to open a PR with an implementation. Link the issue in your PR. We review based on community interest -- upvotes on both the issue and PR count.
  3. Don't chase green CI. Get the approach working, then let us know. We'll take it from there -- we may push to your branch, rewrite, or open a follow-up PR. You'll be credited as the original author. (See the Pydantic AI contributing guide.)

Note

: PRs that modify pyproject.toml or uv.lock from non-team members are auto-closed by CI to prevent supply chain risk. If you need a new dependency, open an issue.

Development

make install   # install dependencies
make format    # ruff format
make lint      # ruff check
make typecheck # pyright strict
make test      # pytest
make testcov   # pytest with 100% branch coverage

Version policy

Pydantic AI Harness uses 0.x versioning to signal that APIs are still stabilizing. During 0.x:

  • Minor releases (0.1 → 0.2) may include breaking changes -- renamed parameters, changed defaults, restructured APIs. As the library grows, especially as capabilities gain provider-native support (starting as a local implementation, then auto-switching to the provider's built-in API when available), we may need to reshape APIs we couldn't fully anticipate in the initial design.
  • Patch releases (0.1.0 → 0.1.1) will not intentionally break existing behavior.
  • All breaking changes are documented in release notes with migration guidance.
  • Where practical, we'll keep the previous behavior available under a deprecated name or configuration option before removing it.

This is why Pydantic AI Harness is a separate package from Pydantic AI, which has a stricter version policy. As the core capabilities stabilize, we'll move toward 1.0 with stability guarantees to match.

Pydantic AI references

  • Capabilities -- what capabilities are, built-in capabilities, building your own
  • Hooks -- lifecycle hooks reference, ordering, error handling
  • Extensibility -- publishing packages, third-party ecosystem
  • Toolsets -- building tools for capabilities
  • API reference -- full API docs

Part of the Pydantic Stack

The Pydantic Stack is everything you need to ship production-grade AI agents:

License

MIT -- see LICENSE.

S
Description
No description provided
Readme MIT
8 MiB
Languages
Python 99.9%