Files
David SFandGitHub 3ba9e2f9a5 docs: capability pages for the unified docs site + README/doc parity gate (#329)
* docs: publish capability docs to the unified site + add README/doc parity gate

Every capability shipped only a README (kept for GitHub/PyPI). This adds a
parallel, cleaned-up page per capability under docs/ for the new unified docs
site (pydantic.dev/docs/harness), migrated from each README: snippets verified
runnable against source, autodoc API blocks, root-relative Pydantic AI links,
and an experimental-status admonition on the experimental set.

To keep README and doc in sync going forward, adds a docs-parity-reviewer agent
and a parity gate in the review checklist (run as the last step before merge),
plus the docs/ layout and the README<->doc requirement in AGENTS.md and the
capability-authoring guide.

* docs: fix README<->doc<->source inconsistencies across capabilities

A parity audit against source found drift, mostly in the capability READMEs
(staler than the migrated docs). All fixes verified against source:

- Correctness: the "approval/deferred tools are excluded from the sandbox" claim
  (code_mode README + doc) was false -- those tools are sandboxed like any
  other; corrected in both. The stale Shell persist_cwd sentinel description is
  replaced with the actual out-of-band temp-file capture. filesystem protected
  default `.git/` -> `.git/*` (the bare form never matched).
- Runnable snippets: added the missing imports/wiring so README snippets no
  longer raise NameError (subagents, context, planning, overflow, authoring,
  filesystem, code_mode).
- Parity: documented previously-undocumented params/behaviors (compaction
  strategy options, overflow strip_ansi/Passthrough, extra autodoc classes for
  context and subagents), fixed a stale version pin (>=1.95.1 -> >=2.1.0), and
  added the missing Managed Prompt row to the root README capability matrix.
- Style: normalized decorative Unicode to ASCII across all READMEs and dropped a
  hype phrase, matching AGENTS.md writing style and the docs.

* docs: add nav.json to drive the unified-docs harness sidebar

The unified docs mount the harness docs under /docs/ai/harness (fed live from
this repo via the pydantic-ai 'Pydantic AI Harness' section). This nav.json
defines the sub-nav (Overview + Capabilities + Experimental) and the set of doc
files the site includes.

* docs: migrate "What goes where?" explainer into harness overview

Adds the core-vs-harness boundary section (anchor #what-goes-where) to the
canonical harness overview, so the pydantic-ai docs that link to it can point
here after the duplicated in-repo stub is removed.

* docs: address CodeRabbit review -- runnable snippets, accuracy, multi-class autodoc

* docs: flatten harness nav and align with graduated capabilities

Following the experimental-graduation refactor (#347), restructure the
unified-docs harness pages:

- Flatten docs/ (drop capabilities/ and experimental/ subdirs); the sidebar
  is now Overview + one flat list per Douwe's request.
- Rename to match the graduated modules: overflow -> overflowing-tool-output,
  authoring -> runtime-authoring, docs -> pydantic-ai-docs.
- Drop the 'Experimental' admonitions from the graduated capabilities and
  repoint every import + ::: autodoc path off pydantic_ai_harness.experimental.
- Add docs for the newly-shipped capabilities: guardrails, dynamic-workflow,
  media, and acp (acp stays framed as experimental -- it may still be removed).
- Every capability doc now links to its source; index capability table lists
  the full set with flat links.

* docs: apply team-sync authoring rules + enforce them in CI

From the 2026-07-10 docs review on #329:

- Purpose-first leads: drop hook names (before_model_request,
  after_tool_execute) from the opening paragraphs of compaction and
  overflowing-tool-output (doc + README); mechanism moves lower.
- Mirror the soft 'API may change between releases' stability note from each
  graduated README into its doc page (ACP keeps its stronger experimental
  warning; guardrails' README has no note, so its page gets none).
- README H1s now use the capability's display name (Overflow capability ->
  Overflowing Tool Output, RuntimeAuthoring -> Runtime Authoring, SubAgents ->
  Subagents, etc.).
- Extend tests/test_docs_parity.py with per-page mechanical checks: source link
  present, heading matches the capability name, purpose-first lead (no hook in
  the opener), and no experimental framing on graduated pages (ACP excepted).
- Update the docs-parity-reviewer agent + review-checklist to the flat
  structure and the new semantic checks.

* docs: add the stability note to guardrails (parity with sibling capabilities)

guardrails was the one graduated capability whose README and doc page lacked
the shared 'API may change between releases' note. Add it to both.

* fix: restore uv.lock to match pyproject (bad text-merge dropped 8 lines)

Merging origin/main did a git text-merge of the generated uv.lock, leaving it
inconsistent with pyproject.toml -- every CI job failed at 'uv sync --locked'.
pyproject.toml is identical to main here, so the correct lock is main's.

* docs: address CodeRabbit review on #329

Findings that failed to post inline (GitHub error) but were real:
- context/README.md, planning/README.md: two nested examples still imported
  from pydantic_ai_harness.experimental.* -- repoint to the graduated modules.
- guardrails/README.md: replace em dashes with '--' (repo style) and add the
  source-module link.
- docs/media.md: standardize on the implementation's canonical media+sha256://
  URI scheme (was mixing media://).
- tests/test_docs_parity.py: strengthen my own checks per review --
  source-link and top-README-link now require a real Markdown link to the
  page's specific module (not a bare substring); heading checks assert an H1
  exists and equals the expected capability name via explicit page metadata.

* fix: restore uv.lock [options.exclude-newer-package] block

The lock lost its [options.exclude-newer-package] manifest (pydantic-ai-slim
= false, ...) -- a bad git text-merge dropped it, and diagnostic uv commands
rewrote it under a different local config. Without that block CI's
'uv sync --locked' re-resolves and fails ('addition of exclude newer exclusion
for pydantic-ai-slim'). Restore origin/main's exact lock.

* fix: restore uv.lock [options.exclude-newer-package] block

A pre-commit hook was rewriting uv.lock under the local uv config, stripping
the [options.exclude-newer-package] manifest (pydantic-ai-slim = false, ...).
Without it CI's 'uv sync --locked' re-resolves and fails. Commit origin/main's
exact lock with --no-verify so no hook mutates it (lock-only change).

* test: cover the docs-parity helper edge cases (100% coverage)

The strengthened helpers added defensive branches (missing frontmatter close,
fenced code before the lead, missing/forbidden/ClassName H1, lead running to
EOF) that no real doc exercises. Add direct unit tests so the file is back to
the repo's required 100% coverage.

* docs: link every capability README to its source module + enforce it

CodeRabbit re-flagged planning/README.md for a missing source link. Only
guardrails had one, so add the source-module link to all 15 remaining
capability READMEs (matching the doc pages) and add a parity test so the
requirement is mechanical and cannot silently regress.

* docs(agents): drop stale folder tree; fix flat docs path + guard names

AGENTS.md's File-structure ASCII tree and capability-authoring's doc paths
still showed docs/capabilities// docs/experimental/ (flattened in this PR) and
the old /docs/harness URL. Delete the tree rather than redraw it -- the layout
is discoverable by listing the repo; keep only the non-obvious conventions
(flat docs/, the README<->doc parity requirement). Also fix the Vocabulary
guard examples (InputGuard/OutputGuard, not the nonexistent InputGuardrail/
CostGuard).

* test: statically validate doc snippets exist and parse

Every Python snippet in the capability READMEs and docs/*.md pages is now
checked for the two failures a reader hits immediately: it does not parse
(syntax), or it imports a pydantic_ai_harness symbol that does not exist (stale
module path or renamed name -- the class of bug behind the experimental.* import
drift). Static only: no model/network execution, so it needs no mocking. The
four illustrative API-signature blocks opt out with a {test="skip"} fence
(read by pytest-examples, stripped-safe for the unified-docs render).

* test: don't fail doc-snippet check on a missing optional extra

The static check imported capability modules to resolve their symbols, but in
the slim CI job (no extras) importing e.g. pydantic_ai_harness.experimental.acp
raises ModuleNotFoundError for the absent third-party 'acp' package -- the
harness module exists, its extra just isn't installed. Distinguish a genuinely
missing harness module (fail) from a missing extra (skip) by the ImportError's
module name.
2026-07-13 11:00:12 -05:00
..

ACP (Agent Client Protocol)

Warning

Experimental. This capability lives under pydantic_ai_harness.experimental and may change or be removed in any release, without a deprecation period. Import it from the experimental path -- there is no top-level export:

from pydantic_ai_harness.experimental.acp import run_acp_stdio_sync

Importing any experimental capability emits a HarnessExperimentalWarning. Silence all harness experimental warnings with a single filter (no per-capability lines needed):

import warnings
from pydantic_ai_harness.experimental import HarnessExperimentalWarning

warnings.filterwarnings('ignore', category=HarnessExperimentalWarning)

Expose a Pydantic AI agent to editors and terminal UIs over the Agent Client Protocol.

Source

The problem

Editors like Zed speak ACP: a stdio JSON-RPC protocol that lets a TUI or editor drive an external coding agent -- streaming its text, rendering its file edits as diffs, and prompting the user to approve sensitive tool calls. To plug a Pydantic AI agent into one of these editors you would otherwise have to implement the ACP server side yourself.

The solution

run_acp_stdio serves any Pydantic AI Agent as an ACP agent over stdin/stdout. The editor launches your script as a subprocess and talks to it; the adapter translates between ACP and the agent's run loop:

ACP needs The adapter provides
Streamed assistant text and reasoning Agent text/thinking deltas, chunked under the wire limit
Rich tool calls (kind, file locations, diffs) A presenter that recognizes FileSystem/Shell tool calls
Human-in-the-loop tool approval Maps ACP permission requests to Pydantic AI's deferred-approval tools
Per-workspace sessions A session_config hook to root tools at the client's working directory
Cancellation, multi-turn history, session close Handled per session

Installation

uv add "pydantic-ai-harness[acp]"

This pulls in the agent-client-protocol SDK. The rest of the harness does not depend on it -- only pydantic_ai_harness.experimental.acp does.

Quick start

Write a script that builds your agent and serves it:

# my_acp_agent.py
from pydantic_ai import Agent
from pydantic_ai_harness.experimental.acp import run_acp_stdio_sync


def build_agent() -> Agent[None, str]:
    return Agent('anthropic:claude-sonnet-4-6', instructions='You are a coding assistant.')


if __name__ == '__main__':
    run_acp_stdio_sync(build_agent())

run_acp_stdio_sync blocks for the lifetime of the connection -- it is the main() of an agent the editor launches. Inside an existing event loop, use the async run_acp_stdio instead.

Connecting from an editor

ACP clients launch the agent as a subprocess. In Zed, register it as an external agent in settings.json:

{
  "agent_servers": {
    "My Pydantic AI Agent": {
      "type": "custom",
      "command": "python",
      "args": ["/absolute/path/to/my_acp_agent.py"],
      "env": { "ANTHROPIC_API_KEY": "..." }
    }
  }
}

Any ACP-compatible client works the same way -- point it at python my_acp_agent.py. Refer to your editor's external-agent documentation for the exact config location.

The provider environment must be available to the launched subprocess. GUI editors and SDK-based test wrappers may not source your interactive shell startup files, and the ACP Python SDK's spawn_agent_process helper starts from a trimmed default environment unless you pass env explicitly. If a real-model agent exits before initialize or fails provider auth, first verify that the command's process can see variables such as ANTHROPIC_API_KEY.

Rooting tools at the workspace

A coding agent should read and write files in the workspace the editor opened, not wherever the subprocess started. ACP gives each session a working directory (cwd); a session_config factory turns that into per-session tools:

from pydantic_ai import Agent
from pydantic_ai_harness.experimental.acp import AcpSession, AcpSessionConfig, run_acp_stdio_sync
from pydantic_ai_harness.filesystem import FileSystem
from pydantic_ai_harness.shell import Shell

agent = Agent('anthropic:claude-sonnet-4-6')


def session_config(session: AcpSession) -> AcpSessionConfig[None]:
    # Root file and shell tools at the workspace the client opened.
    return AcpSessionConfig(
        deps=None,
        toolsets=[
            FileSystem[None](root_dir=session.cwd).get_toolset(),
            Shell[None](cwd=session.cwd).get_toolset(),
        ],
    )


if __name__ == '__main__':
    run_acp_stdio_sync(agent, session_config=session_config)

The factory runs once per session with the client's [AcpSession][pydantic_ai_harness.experimental.acp.AcpSession] setup (its cwd, mcp_servers, and capabilities) and returns an [AcpSessionConfig][pydantic_ai_harness.experimental.acp.AcpSessionConfig] whose deps and toolsets apply to every run in that session. This is correct across multiple concurrent sessions in one process, where a single static FileSystem could not be.

Editor-native filesystem and shell (optional)

The local FileSystem and Shell above operate on the agent process's own disk and subprocesses. An editor's source of truth is different: it has unsaved buffers, the file layout it considers the workspace, and -- for a remote or containerized editor -- the machine the code actually lives on. When the client advertises support, [acp_filesystem][pydantic_ai_harness.experimental.acp.acp_filesystem] and [acp_terminal][pydantic_ai_harness.experimental.acp.acp_terminal] give the agent read_file/write_file/run_command tools that route through the client, so it acts where the user is:

from pydantic_ai_harness.experimental.acp import AcpSession, AcpSessionConfig, acp_filesystem, acp_terminal
from pydantic_ai_harness.filesystem import FileSystem
from pydantic_ai_harness.shell import Shell


def session_config(session: AcpSession) -> AcpSessionConfig[None]:
    # Use the editor's filesystem/terminal when offered; otherwise fall back to local.
    fs = acp_filesystem(session) or FileSystem[None](root_dir=session.cwd).get_toolset()
    shell = acp_terminal(session) or Shell[None](cwd=session.cwd).get_toolset()
    return AcpSessionConfig(deps=None, toolsets=[fs, shell])

Each helper returns None when the client did not advertise the capability, so the or falls back to local and the agent works either way. The tool names match the local FileSystem/Shell, so rich rendering (next section) is identical. acp_terminal runs the command in the editor's environment and returns its captured output (see Limitations).

If a client advertises filesystem reads but not writes, acp_filesystem keeps editor-native reads and sends writes to the local FileSystem rooted at session.cwd -- coherent only when the agent shares the workspace disk with the editor (same machine, or an agent inside the editor's container); for a remote editor those writes land on the agent's disk, not the editor's.

Tool approval

Mark a tool to require approval and ACP relays the decision to the client, which shows the user an approve/reject prompt:

@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str:
    ...

The lifecycle the client sees is pending (awaiting approval) → in_progress (granted, running) → completed/failed, so an unapproved action is never shown as already running. "Always allow"/"always reject" decisions are remembered for the session, scoped by default to the exact call (tool name plus arguments) so approving one call never silently approves a different one. Pass permission_policy to widen or narrow that scope.

Rich tool rendering

By default the adapter recognizes the harness FileSystem and Shell tool calls by name and annotates them with an ACP kind (read/edit/search/execute), the file locations they touch, and an inline diff for edits -- so the editor renders click-to-file links and diff views instead of opaque JSON. Pass tool_presenter to add rendering for your own tools (optionally with chain_presenters ahead of the default), or lambda _call: None to disable it.

MCP servers

An ACP client may offer MCP servers during session setup. This adapter does not connect them itself; a session_config is the place to turn session.mcp_servers into Pydantic AI toolsets. If a client sends MCP servers and no session_config is installed to consume them, the session request is rejected (rather than silently ignoring them) so the mismatch is visible. The spec expects every agent to accept stdio MCP servers, so an agent meant for arbitrary editors should install a session_config that connects them (for example with pydantic_ai.mcp.MCPServerStdio).

A spec-following client only sends HTTP/SSE MCP servers when the agent advertises support for those transports during initialize (stdio servers are not capability-gated). When your session_config connects them, say so:

PydanticAIACPAgent(
    agent,
    session_config=connect_mcp_servers,
    mcp_capabilities=schema.McpCapabilities(http=True, sse=True),
)

Prompt content types

The agent advertises which prompt content it accepts. The default is text only, so a client is not invited to send blocks a text model cannot handle. Enable the kinds your model supports:

from acp import schema

run_acp_stdio_sync(agent, prompt_capabilities=schema.PromptCapabilities(image=True, embedded_context=True))

Session persistence

Pass a session_store to let a client reopen a past conversation with session/load. Each committed turn is persisted as two parts -- the model's message history and the client-visible transcript (the user's messages plus everything streamed back) -- and reopening restores the history into the agent and replays the transcript to the client, so its UI is rebuilt as the user last saw it. Without a store, session/load is advertised as unsupported.

from pydantic_ai_harness.experimental.acp import InMemorySessionStore

run_acp_stdio_sync(agent, session_store=InMemorySessionStore())

InMemorySessionStore keeps sessions for the lifetime of the process. Implement the SessionStore protocol (save/load a StoredSession) over a file or database to make them survive a restart -- the stored values are Pydantic models, so they serialize with Pydantic.

Session persistence is for reopening a conversation; it is orthogonal to per-run durability. To also make individual turns crash-resilient (or resume a long sub-agent run), add a step-durability capability to the agent -- each ACP turn is one agent run, so the two layers compose with no glue.

Model selection

Pass models to advertise a stable ACP session config option named model (using Pydantic AI model names). The first is each session's default. A selection is applied as a per-run override -- the shared agent is never mutated -- and is persisted with the session when a session_store is set.

run_acp_stdio_sync(agent, models=['anthropic:claude-sonnet-4-6', 'anthropic:claude-opus-4-8', 'openai:gpt-4o'])

A model id is any string a Pydantic AI model accepts, so newer models not yet in KnownModelName work too. Pass models='all' to offer every model Pydantic AI knows (its default is then the first known model, so curate the list when you want a specific default). Without models, no model config option is advertised.

To advertise ids Pydantic AI's infer_model does not understand (for example OAuth or subscription models), pass model_resolver to map the selected id to a prebuilt Model; returning the id unchanged falls back to infer_model.

Token usage

Each completed turn reports its token counts (input/output/total, plus cached tokens) on the ACP PromptResponse, summed across any approval pauses. This is an UNSTABLE ACP field, so clients that don't support it simply ignore it.

Cancellation and limitations

  • Cancellation. session/cancel and session/close cancel the in-flight turn; close waits for it to unwind before returning. Cooperative async tools stop promptly. A synchronous tool already running in a worker thread cannot be force-stopped, so its side effects may complete after the turn reports cancelled -- prefer async tools for cancellation-sensitive work.
  • Approval detection. Tools that require approval are recognized when they live in a FunctionToolset (which the harness FileSystem/Shell and @agent.tool both use). A tool whose approval requirement is decided dynamically per call (by raising ApprovalRequired from its body) starts as in_progress, and any side effects it ran before raising have already happened by the time the client is asked -- use an ApprovalRequiredToolset (which gates before the tool body runs) for actions that must not partially execute before approval.
  • Overwrite diffs. write_file renders an overwrite as if creating a new file (no prior contents), so the diff understates what it replaced.
  • Live terminal panes. acp_terminal returns a command's captured output; it does not embed a live terminal pane in the tool call, which would need the terminal id at call-start, before the command runs.
  • Images. Prompt image blocks are off by default and must be enabled via prompt_capabilities with a model that accepts them (see Prompt content types). The harness FileSystem.read_file is text-only, so the agent cannot open image files from the workspace itself.
  • Slash commands. The adapter does not yet advertise any commands (available_commands), so no slash commands appear in the client. Planned.
  • MCP servers. Client-offered MCP servers are surfaced to your session_config to turn into toolsets (advertise the transports with mcp_capabilities); the adapter does not auto-connect them. Resource metadata is not yet wired end-to-end.

API

run_acp_stdio(            # async; serve until the client disconnects
    agent,
    *,
    deps=None,
    name=None,            # advertised name; defaults to the agent's name
    version='0.1.0',
    session_config=None,  # per-session deps/toolsets from the client's setup
    permission_policy=None,   # scope of remembered "always" approval decisions
    prompt_capabilities=None, # defaults to text-only
    mcp_capabilities=None,    # MCP transports to advertise; needs a session_config to connect them
    tool_presenter=None,      # defaults to the FileSystem/Shell presenter
    session_store=None,       # enables session/load by persisting each session
    models=None,              # models offered as the `model` config option ('all' for every known model)
    model_resolver=None,      # maps an advertised model id to the Model used for the run
    usage_limits=None,        # per-run request/token ceilings
)

run_acp_stdio_sync(...)   # synchronous wrapper, same arguments

PydanticAIACPAgent(agent, *, ...)  # the ACP agent object, to embed in a custom server

Further reading