mirror of
https://github.com/pydantic/pydantic-ai-harness.git
synced 2026-07-21 02:45:34 +00:00
* feat: add ACP capability to serve agents to editors Editors and TUIs that speak the Agent Client Protocol (Zed and others) can drive an external coding agent, but plugging a Pydantic AI agent into one previously meant implementing the ACP server side by hand. run_acp_stdio serves any Agent over stdio: streamed text and thinking, file edits rendered as diffs, human-in-the-loop tool approval mapped to deferred-approval tools, per-workspace sessions via a session_config hook, model switching, cancellation, and optional session persistence. FileSystem.get_toolset/Shell.get_toolset now return their concrete toolset types so the ACP presenter can recognize their tool calls by name and annotate them with kinds, locations, and diffs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: skip ACP tests at collection when the acp extra is not installed The slim CI jobs sync without extras, so `agent-client-protocol` is absent and the ACP test modules failed at import during collection. Ignore them via conftest when `acp` can't be found; `test_packaging.py` stays collected since package metadata holds on base installs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(acp): enable unstable routing and bound streamed updates by byte size session/set_model and session/close are advertised at initialize, but the ACP SDK router rejects them as unstable unless run_agent is given use_unstable_protocol=True -- so the model picker and session-close affordance returned method_not_found over a real connection. Enable the flag. Streamed text was chunked by character count, but the SDK serializes with ensure_ascii=True, so a non-ASCII code point expands up to 12 bytes (a surrogate pair) inside the JSON string. A single agent_message_chunk of emoji/CJK could exceed the client's 64 KiB read buffer and drop the connection. Chunk by escaped byte length instead. Both gaps were invisible to the suite: the close/set_model tests called the adapter directly (bypassing the router) and the large-output test used only ASCII. Add a through-the-router stdio test and a non-ASCII chunking test as guards. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(acp): per-turn usage, read-resilient terminal cleanup, persistence + native-toolset tests Address review follow-ups on the ACP capability: - Report per-turn token usage on `PromptResponse.usage`, summed across approval passes (UNSTABLE ACP field; clients that don't support it ignore it). - Suppress client errors during the shielded terminal kill/release so a failing cleanup call can't mask the in-flight CancelledError (the spec requires the turn to end with a `cancelled` stop reason). - Cover the persistence guarantees that previously had no store-active test: a cancelled turn commits nothing, an approval-resume turn persists each update once (no duplicate tool-call start), and `StoredSession` round-trips through Pydantic across the full `SessionUpdate` union. - Add a through-the-wire stdio test that the editor-native fs/terminal toolsets route to the client when mounted per session. - Document the dynamic-`ApprovalRequired` partial-side-effect nuance, per-turn usage, and that session persistence composes with per-run step durability. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(acp): editor-native reads for read-only fs clients, writes delegated locally A client that advertises filesystem reads but not writes previously got no editor-native filesystem at all (acp_filesystem returned None, so callers fell back to a fully-local toolset and lost the editor's live view, e.g. unsaved buffers). It now returns a combined toolset: reads route through the editor, and writes are delegated to the local FileSystem capability rooted at the session cwd (reusing its path sandboxing rather than reimplementing writes). This is 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 the writes land on the agent's disk. Documented as such at the helper and README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(acp): point _all_known_model_names at the now-merged known_model_names() pydantic-ai#5803 added `pydantic_ai.models.known_model_names()`, the public replacement for the `KnownModelName.__value__` introspection. It isn't in a released `pydantic-ai-slim` yet, so the swap (and the floor bump it requires) waits for that release; update the breadcrumb so it's actioned then. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(acp): replay user turns on session/load, advertise MCP capabilities Two spec-conformance gaps from PR review: - The transcript only ever recorded agent-direction updates, so a reopened session replayed a one-sided conversation. ACP requires session/load to replay the entire conversation, user turns included. The prompt's content blocks are now seeded into the turn's update list -- recorded for replay but never sent live (the client renders its own prompt; the prompt-turn spec sends no user echo) -- riding the existing commit path so a cancelled turn still rolls its user message back. - initialize left mcp_capabilities at the default (http/sse false), and the spec forbids clients from sending HTTP/SSE MCP servers that are not advertised -- making the documented session_config -> mcp_servers path unreachable for conforming clients over those transports (stdio is not gated). Advertising is now an explicit opt-in mirroring prompt_capabilities, since only the embedder knows which transports their session_config connects; setting it without a session_config fails at construction rather than inviting servers that session/new would reject. Also adds the guard test from review follow-up: _all_known_model_names fails loudly if pyai ever recomposes the KnownModelName alias. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(acp): make the collection guard coverable in a single environment The acp-extra collection guard was an `if` statement whose true arm only runs on slim installs, so local `make testcov` (always all-extras) could never reach 100% -- only CI's combined slim+all-extras matrix could. A conditional expression has no statement arc for branch coverage to miss, so the documented local gate works again without excluding a line CI genuinely covers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(acp): record why _all_known_model_names avoids the unreleased public API The breadcrumb read as if known_model_names() were already usable; make the deliberate avoidance explicit. The public enumerator is merged upstream but not in any released pydantic-ai-slim, and the test-floor CI job runs against the floor release, so calling it would break there. Move the rationale from the docstring to a code comment (docstring stays contract-only) and spell out the swap-and-delete trigger: a release that ships it plus a floor bump. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(acp): add spec-conformance suite driven over a real in-memory wire The existing tests invoke adapter methods directly, below the SDK's JSON-RPC router and serialization -- a boundary that cannot see two bug classes that already bit this adapter: a method the router gates before the adapter runs (the use_unstable_protocol reachability bug) and a frame whose serialized bytes overrun the client buffer (the ensure_ascii chunking bug). `tests/acp/_wire.py` closes that gap without a subprocess: a real ClientSideConnection talks to acp.run_agent across a socket.socketpair in one event loop, so every request crosses the router and codec and every update arrives as bytes the client re-parses, with asyncio's default 64 KiB reader limit standing in for the stdio buffer. `tests/acp/test_conformance.py` is organized by spec clause, not by adapter method, each with an oracle built from the spec/input rather than the adapter's own output: - version negotiation echoes the requested version (not a literal) - capabilities are advertised iff supported (load_session, mcp, auth_methods, session list/fork/resume, modes, config options) -- read back off the wire - unstable methods route through with the flag and are method_not_found without it (the load-bearing reason run_acp_stdio enables it) - error codes distinguish method_not_found from invalid_params - session/load replays the entire conversation including user turns - large non-ASCII output reassembles intact within the client's read buffer CONFORMANCE.md records the clause-to-test matrix, the open gaps (none a known bug), the two verified deviations, and the N/A ledger, derived from a spec-page sweep with adversarial verification of every candidate finding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(acp): stop surfacing unconsumed additionalDirectories The adapter accepted the client's additionalDirectories and forwarded them to the session_config AcpSession, but never advertised sessionCapabilities.additionalDirectories -- so a conformant client never sent them, and nothing downstream consumed them (the FileSystem capability is single-root). The ACP capability is also still UNSTABLE. Drop the dead surface: remove the field from AcpSession and stop forwarding it. The acp.Agent base signature still carries the parameter, so the session methods accept and ignore it. Supporting it properly (multi-root filesystem + advertisement) is recorded as a deferred feature. Also trims CONFORMANCE.md to a public supported-capability summary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(acp): keep inline image bytes and let run_acp_stdio advertise MCP An ACP image block always carries inline `data`; `uri` is only an optional source reference, so preferring the URL dropped the bytes the client actually sent in favour of a link the model may be unable to fetch. Prefer the inline data, matching the reference ACP agents. `run_acp_stdio`/`run_acp_stdio_sync` forwarded every adapter option except `mcp_capabilities`, so advertising MCP transports was only reachable by constructing `PydanticAIACPAgent` by hand. Forward it for parity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(acp): reliable approval scope, mapped stop reasons, safe session reload "Always allow"/"always reject" keyed the scope on the raw tool arguments, which a streaming model (and OpenAI by default) delivers as a JSON string; re-ordered keys then produced a different key and silently re-prompted for a call already decided. Canonicalize via `args_as_dict()` so the same logical call shares one remembered decision. Completed turns always reported `end_turn`, hiding `max_tokens`/`refusal` from the client. Map the model's finish reason to the ACP stop reason. `session/load` overwrote a still-open session without cancelling its in-flight turn, leaking a task that could later persist stale state over the restored transcript. Tear it down first, sharing the close_session teardown path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(acp): correct the API reference and capability notes The README API block omitted `session_store`/`models` (and now `mcp_capabilities`), the limitations list contradicted the documented MCP-via-`session_config` support, and the overwrite-diff note misdescribed what the code emits. Also drop a dangling internal-doc reference from a source comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(acp): record the unhandled SessionStore failure contract A durable SessionStore can fail on save or return a corrupt payload on load, but the adapter currently propagates those exceptions rather than handling them. Document the gap on the Protocol where store implementers will see it, so the behaviour is a known boundary rather than a surprise -- without adding handling we have no consumer for yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(acp): correct the SessionStore failure note to match observed behaviour The previous note assumed store exceptions surface "raw". Verified against the SDK (connection.py `_run_request`) and over a real in-memory wire with a raising stub store: they are converted to JSON-RPC errors before reaching the client -- a `pydantic.ValidationError` to `invalid_params` (-32602), anything else to a generic internal error (-32603). Record what the client actually receives rather than what was guessed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(acp): handle SessionStore failures instead of leaking them `session_store` is a public, documented extension point, so a durable store that fails on save or returns a corrupt payload on load is reachable today -- not hypothetical. A failed save previously errored the very turn the user had already watched stream to completion; a corrupt load leaked raw pydantic/IO detail to the client. Make save failures non-fatal: log and swallow them so the turn (or session) that already committed in memory still succeeds, and the next save catches the store up. Make load failures fail `session/load` with a clear internal error rather than a leaked exception, since a session that cannot be read cannot be reopened. Verified over the in-memory wire with a failing stub store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(acp): close out in-flight tool calls when a turn is cancelled A tool call announced as pending/in_progress was never driven to a terminal status when its turn was cancelled (via session/cancel or a dismissed permission dialog), so a client kept rendering it as running after the turn ended cancelled. On the cancel unwind, fail any tool call still lacking a terminal result -- sent live (a cancelled turn never commits its transcript) and shielded so the asyncio cancellation cannot abort the send. Verified over the in-memory wire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(acp): reject prompts queued behind a session that closed or reloaded A prompt waiting on the session's turn lock held a reference to the old SessionState; close_session/load_session only cancel the *active* turn, so the queued prompt would run against the discarded state - invisible to session/cancel - and persist its orphaned history over the closed (or just restored) session. Re-checking liveness after acquiring the lock turns that zombie turn into the invalid_params the client already gets for a prompt sent after close. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(acp): never swallow a handler's own teardown cancellation prompt() and _cancel_active_turn awaited the turn task bare, so the CancelledError from connection teardown was indistinguishable from the turn's own cancellation and got converted into a response (or, via a dismissed permission dialog, replaced by the internal _TurnCancelled). The SDK cancels each handler task exactly once on shutdown and its sender is already closed by then, so a handler that survives that one cancel and tries to answer hangs Connection.close() forever. Awaiting through asyncio.shield keeps the two cancellation sources apart: the turn's end is read off the (done) task, while the handler's own cancellation propagates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(acp): don't report rollback when a cancel lands in the post-commit save The store save after a turn commits was the one suspension point left between commit and return: a session/cancel delivered there escaped the turn task and the prompt answered 'cancelled' (the rollback signal, no user_message_id) while the session's in-memory history and transcript already contained the whole turn - the next prompt would build on history the client believed was discarded. The InMemorySessionStore never suspends, which is why the existing cancel-persistence test could not see this; any real (file/db) store can. A cancel that arrives after commit has simply lost the race, so the interrupted save is treated like the write failures _persist already swallows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(acp): don't leak a terminal when run_command is cancelled mid-create The terminal/create call sat before the cleanup try-block, so a cancellation landing there unwound without a kill or release - but the request may already be on the wire (the SDK sender flushes queued payloads even when the awaiting future is cancelled), leaving the command running client-side with nobody holding its id. Running the create as a shielded task lets the cleanup await the response late, learn the id, and kill/release as usual. Folding the late-id case into one kill path and one release path also keeps the existing guarantee that a failing kill never skips the release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(acp): end limit-hit turns with max_turn_requests, close out tool calls on errors pydantic-ai's default UsageLimits(request_limit=50) means a long tool loop routinely raises UsageLimitExceeded - which escaped the turn as a JSON-RPC internal_error, the exact condition ACP defines the max_turn_requests stop reason for (token limits map to max_tokens). The raising run's partial messages are not retrievable, so such a turn rolls back like a cancellation and says so (no user_message_id, no usage). Turns failing with any other exception now also close out their announced tool calls before the error reaches the client, matching the cancellation path, so the client does not render them as running next to the turn's failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(acp): resolve workspace-relative paths before they reach fs/read|write ACP requires absolute paths on fs/read_text_file and fs/write_text_file, but models routinely emit workspace-relative ones - the local FileSystem tools document relative paths and share these tools' names, and the presenter layer already absolutizes for exactly that reason. The client-backed toolset forwarded them raw, producing non-conformant requests the client may reject. acp_filesystem now hands the toolset the session cwd so relative paths resolve before the wire; absolute paths and directly-constructed cwd-less toolsets are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(acp): true up the conformance claims, comments, and doc links The stop-reason note predated the finish-reason mapping and said the opposite of what the code (and its tests) do; the prompt-capabilities bullet read as inbound enforcement when the spec puts that restriction on the client; the stdio MCP gap (the spec's unconditional MUST) was undisclosed; and two code comments claimed validation/rejection the SDK router does not actually perform - the adapter's own raises are the load-bearing behavior. Doc links now use the canonical pydantic.dev paths both old URLs redirect to. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(acp): shield the terminal create in place instead of via a side task The ensure_future + asyncio.shield shape from the previous commit hit Python 3.12+'s shield behavior: once the outer await is cancelled, a late failure of the inner create is reported to the loop exception handler even when the cleanup retrieves it, which spams production logs and fails under pytest-anyio. An anyio shield around the create await itself is simpler and leans on the same anyio-mediated cancellation the kill/release cleanup already depends on: the cancellation defers to the next await, by which point the terminal id is known and the existing cleanup kills and releases as usual. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(acp): small type and surface cleanups from review - ToolCallPermission.args was typed object although the only construction site passes args_as_dict(); Mapping[str, object] lets a custom policy scope by an argument without isinstance gymnastics. - default_permission_scope is referenced by the permission_policy docs as the fallback to compose with, so it must be importable: exported. - McpServer names the per-server union once instead of spelling it in two places; McpServers gains the TypeAlias marker its sibling had. - _model_state folds the None case so both call sites drop a repeated conditional; the load_session store guard's no-cover pragma was wrong (the SDK router routes session/load regardless of the advertisement, and test_persistence already executes the branch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(acp): pin claimed behavior the suite asserted too weakly or not at all - The unsupported-method test asserted only RequestError despite name and CONFORMANCE.md promising method_not_found; codes now pinned (-32601, and -32602 for the unknown-session prompt). - The model-override test was tautological: both models answered successfully either way. It now asserts the override's distinct canned output (mutation-checked: dropping the per-run override fails it). - Usage summation across approval passes had no test; an approval turn's output tokens must exceed the resume pass alone (mutation-checked against 'usage = result.usage()'). - New protocol-contract coverage: cancel racing an unanswered permission dialog (turn ends cancelled, pending call driven to failed), double cancel idempotency, image block arriving in model history as decoded BinaryContent, the denial message reaching the failed update's raw_output, chunk_text's exact-budget boundary, and a set_model save failure being swallowed like every other persist failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(acp): survive raw cancellation during terminal create; answer cancelled after a post-commit cancel An adversarial re-review of this branch's own fixes caught a regression: the anyio shield around terminal/create only blocks anyio-mediated cancellation, but the adapter and pydantic-ai deliver raw task.cancel() (turn.cancel, cancel_and_drain), which pierces it - reintroducing the leaked-terminal window end to end. The create now runs as its own task awaited via asyncio.wait: a raw cancel hits the waiter, not the create, and unlike asyncio.shield (3.12+) a late create failure is not reported to the loop exception handler when the cleanup retrieves it. The leak scenario is pinned at both the toolset level and through the real adapter/agent path. Also from the re-review: a cancel that lands after the turn committed (inside the store save) now answers 'cancelled' as the spec requires - while keeping the committed signals (user_message_id, usage, history) - and the one-tick teardown-vs-turn ambiguity that 3.10 cannot resolve is documented at both await sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(acp): record the post-commit-cancel semantics in CONFORMANCE Also drops an unbounded poll loop in the permission-dialog race test for a single deterministic tick (prompt sets active_turn before its first suspension point), which was the one partial branch left repo-wide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(acp): let the turn build its own response and drop the commit-flag tuple _run_turn returned (usage, stop_reason, committed) so prompt() could reassemble a PromptResponse it had all the pieces for; building the response in the turn removes that protocol and the committed flag. Recording turn.updates is now unconditional (only the commit gate matters), and the presenter-fields/acp_filesystem branches collapse to single construction paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(acp): appease codespell (unparseable -> unparsable) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Support Pydantic AI 2.x in ACP * feat(acp): forward usage limits into agent runs Let hosts bound each ACP run segment with Pydantic AI usage ceilings while preserving the default run behavior when no explicit limits are configured. * feat(acp): resolve selected session models Let embedders map advertised ACP model ids to concrete Pydantic AI models and re-surface a resident session's current model state. * feat(acp): expose read-only session history Hosts can gauge context with the same estimate_token_count path the compaction tiers use by reading committed resident-session history. The returned list is a shallow snapshot, and callers must treat the shared ModelMessage objects as read-only. * Track ACP 0.11 session config models * chore(acp): fix coverage, trim redundancy, clarify module names Get the ACP PR to green CI and simpler to review, without changing behavior. - Coverage: the only uncovered line was a dead `return None` branch in the `_model_option` test helper (source was already 100%). Assert the option is present instead, dropping the untested branch and its now-redundant guards. - Remove CONFORMANCE.md: its supported/not-supported matrix duplicated the package README's feature sections and "Cancellation and limitations". Point the two references at the README. - Dedup the test ACP clients: RecordingClient, FakeClient, and WireClient each re-spelled the same ~13-method "unused capability" stub block to satisfy the SDK Client interface. Extract it once into RecordingClientBase; each client now subclasses it and overrides only what it exercises. Interface drift is one edit. - Rename two opaque modules for clarity (files only; public symbols unchanged): _present.py -> _presentation.py (matches its ToolCallPresentation exports) and _native.py -> _client_toolsets.py (client-routed filesystem/shell toolsets), with tests/acp/test_native.py -> test_client_toolsets.py to mirror the source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(acp): move under experimental and shrink the public surface New capabilities start under pydantic_ai_harness.experimental, so ACP follows: importing warns HarnessExperimentalWarning and the API may change without deprecation. __all__ drops the typing-only aliases (25 to 18 names); default_permission_scope and McpServer stay because policies compose with the default scope and AcpSession.mcp_servers is typed by McpServer. Tests move to tests/experimental/acp to mirror the source tree; the experimental warning test lives in the SDK-gated test_acp.py so slim (no-extras) installs stay green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(acp): expose model_resolver and usage_limits on the stdio entry points PydanticAIACPAgent already accepted both, so wanting a per-run token ceiling or host-defined model ids forced users off run_acp_stdio onto the class and a hand-rolled acp.run_agent call, where forgetting use_unstable_protocol=True silently loses session/close. Forward the two params so the one-liner covers every adapter option. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: David SF <david.sanchez@pydantic.dev>
952 lines
49 KiB
Python
952 lines
49 KiB
Python
"""Adapt a Pydantic AI agent to the Agent Client Protocol (ACP) agent interface.
|
|
|
|
ACP (https://agentclientprotocol.com) lets a code editor or terminal UI (the *client*)
|
|
drive a coding agent (the *server*) over stdio JSON-RPC. This module exposes a Pydantic AI
|
|
[`Agent`][pydantic_ai.Agent] as such a server: it streams the agent's output to the client
|
|
as `session/update` notifications and bridges ACP permission requests to Pydantic AI's
|
|
human-in-the-loop tool approval.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
from collections.abc import Callable, Hashable, Sequence
|
|
from dataclasses import dataclass, field
|
|
from inspect import isawaitable
|
|
from typing import Generic, Literal
|
|
from uuid import uuid4
|
|
|
|
import acp
|
|
import anyio
|
|
from acp import schema
|
|
from acp.interfaces import Client
|
|
from pydantic_ai import DeferredToolRequests, DeferredToolResults, ToolDenied, UsageLimitExceeded
|
|
from pydantic_ai.agent import AbstractAgent
|
|
from pydantic_ai.messages import (
|
|
AgentStreamEvent,
|
|
FinishReason,
|
|
FunctionToolCallEvent,
|
|
FunctionToolResultEvent,
|
|
ModelMessage,
|
|
PartDeltaEvent,
|
|
PartStartEvent,
|
|
RetryPromptPart,
|
|
TextPart,
|
|
TextPartDelta,
|
|
ThinkingPart,
|
|
ThinkingPartDelta,
|
|
ToolCallPart,
|
|
UserContent,
|
|
)
|
|
from pydantic_ai.models import KnownModelName, Model, known_model_names
|
|
from pydantic_ai.output import OutputDataT
|
|
from pydantic_ai.run import AgentRunResultEvent
|
|
from pydantic_ai.tools import AgentDepsT
|
|
from pydantic_ai.toolsets import AbstractToolset, FunctionToolset
|
|
from pydantic_ai.usage import RunUsage, UsageLimits
|
|
|
|
from pydantic_ai_harness.experimental.acp._content import PromptContentBlock, prompt_blocks_to_user_content
|
|
from pydantic_ai_harness.experimental.acp._permission import (
|
|
PermissionPolicy,
|
|
ToolCallPermission,
|
|
default_permission_scope,
|
|
)
|
|
from pydantic_ai_harness.experimental.acp._presentation import (
|
|
ToolCallContent,
|
|
ToolCallPresentation,
|
|
ToolCallPresenter,
|
|
absolutize,
|
|
default_coding_presenter,
|
|
)
|
|
from pydantic_ai_harness.experimental.acp._serialize import bounded_jsonable, chunk_text, jsonable
|
|
from pydantic_ai_harness.experimental.acp._session import (
|
|
AcpSession,
|
|
AcpSessionConfig,
|
|
McpServers,
|
|
SessionConfigFunc,
|
|
SessionState,
|
|
SessionUpdate,
|
|
)
|
|
from pydantic_ai_harness.experimental.acp._store import SessionStore, StoredSession
|
|
|
|
# Version advertised to the client when the caller does not supply one.
|
|
DEFAULT_VERSION = '0.1.0'
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
_MODEL_CONFIG_ID = 'model'
|
|
|
|
|
|
def _all_known_model_names() -> tuple[str, ...]:
|
|
"""Every model name Pydantic AI exposes through the public `known_model_names()` API."""
|
|
return known_model_names()
|
|
|
|
|
|
def _finish_reason_to_stop_reason(finish_reason: FinishReason | None) -> schema.StopReason:
|
|
"""Map the model's terminal `finish_reason` to the ACP stop reason for a completed turn.
|
|
|
|
Only `length` and `content_filter` carry a distinct ACP meaning; every other reason (a normal
|
|
stop, a final tool call, an unknown/missing reason) is reported as a plain `end_turn`.
|
|
Cancellation is handled by the caller and never reaches here.
|
|
"""
|
|
if finish_reason == 'length':
|
|
return 'max_tokens'
|
|
if finish_reason == 'content_filter':
|
|
return 'refusal'
|
|
return 'end_turn'
|
|
|
|
|
|
def _usage_limit_stop_reason(exc: UsageLimitExceeded) -> schema.StopReason:
|
|
"""Map a run's exceeded usage limit to the ACP stop reason ending the turn.
|
|
|
|
Token-based limits report `max_tokens`; the request/tool-call count limits report
|
|
`max_turn_requests`. The exception carries no structured detail, so this reads its message; an
|
|
unrecognized wording falls back to `max_turn_requests` (the limit pydantic-ai's default
|
|
configuration can hit).
|
|
"""
|
|
return 'max_tokens' if 'tokens_limit' in str(exc) else 'max_turn_requests'
|
|
|
|
|
|
def _to_acp_usage(usage: RunUsage) -> schema.Usage:
|
|
"""Map a Pydantic AI `RunUsage` to ACP's per-turn `Usage` (an UNSTABLE field on `PromptResponse`)."""
|
|
return schema.Usage(
|
|
input_tokens=usage.input_tokens,
|
|
output_tokens=usage.output_tokens,
|
|
total_tokens=usage.total_tokens,
|
|
cached_read_tokens=usage.cache_read_tokens,
|
|
cached_write_tokens=usage.cache_write_tokens,
|
|
)
|
|
|
|
|
|
class _TurnCancelled(Exception):
|
|
"""Raised internally to unwind a turn the client cancelled mid-flight (e.g. via a permission dialog)."""
|
|
|
|
|
|
@dataclass(kw_only=True)
|
|
class _TurnState:
|
|
"""Per-turn state shared by the stream handlers, created once per turn in `_run_turn`."""
|
|
|
|
conn: Client
|
|
session_id: str
|
|
cwd: str
|
|
approval_names: frozenset[str]
|
|
# Tool-call ids accumulated across the turn's approval-resume passes: `started` so a call
|
|
# paused for approval is announced only once, `denied` so a rejected call's result is failed,
|
|
# `resulted` so a cancelled turn can fail only the calls still left without a terminal status.
|
|
started: set[str] = field(default_factory=set[str])
|
|
denied: set[str] = field(default_factory=set[str])
|
|
resulted: set[str] = field(default_factory=set[str])
|
|
# The `session/update`s sent this turn; appended to the session transcript only on commit.
|
|
updates: list[SessionUpdate] = field(default_factory=list[SessionUpdate])
|
|
|
|
|
|
@dataclass(frozen=True, kw_only=True)
|
|
class _ToolCallFields:
|
|
"""The ACP tool-call fields derived from the presenter, shared by the start and permission paths."""
|
|
|
|
title: str
|
|
kind: schema.ToolKind | None
|
|
content: list[ToolCallContent] | None
|
|
locations: list[schema.ToolCallLocation] | None
|
|
|
|
|
|
class PydanticAIACPAgent(acp.Agent, Generic[AgentDepsT, OutputDataT]):
|
|
"""An ACP agent backed by a Pydantic AI [`Agent`][pydantic_ai.Agent].
|
|
|
|
Each ACP session is an independent conversation, keyed by session ID. Pass the instance to
|
|
[`run_acp_stdio`][pydantic_ai_harness.experimental.acp.run_acp_stdio] (or the lower-level `acp.run_agent`)
|
|
to serve it over stdio. When calling `acp.run_agent` yourself, pass `use_unstable_protocol=True`
|
|
as `run_acp_stdio` does, or the SDK router rejects `session/close` (still UNSTABLE in the SDK)
|
|
with `method_not_found`.
|
|
|
|
A tool that [requires approval](https://pydantic.dev/docs/ai/tools-toolsets/deferred-tools/)
|
|
pauses the run and asks the client via `session/request_permission`; "always allow"/"always
|
|
reject" decisions are remembered for the rest of the session. Per-session workspace setup
|
|
(the client's `cwd` and MCP servers) is surfaced through the optional `session_config` factory.
|
|
|
|
`session/close` cancels any in-flight turn and discards the session. `session/load` (with a
|
|
`session_store`) and model selection via `session/set_config_option` (with `models`) are
|
|
supported when configured; fork, resume, and modes are advertised as unsupported. As with any
|
|
`output_type` override, this adapter is incompatible with agents that define output validators.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
agent: AbstractAgent[AgentDepsT, OutputDataT],
|
|
*,
|
|
deps: AgentDepsT = None,
|
|
name: str | None = None,
|
|
version: str = DEFAULT_VERSION,
|
|
session_config: SessionConfigFunc[AgentDepsT] | None = None,
|
|
permission_policy: PermissionPolicy | None = None,
|
|
prompt_capabilities: schema.PromptCapabilities | None = None,
|
|
mcp_capabilities: schema.McpCapabilities | None = None,
|
|
tool_presenter: ToolCallPresenter | None = None,
|
|
session_store: SessionStore | None = None,
|
|
models: Sequence[KnownModelName | str] | Literal['all'] | None = None,
|
|
model_resolver: Callable[[str], Model | str] | None = None,
|
|
usage_limits: UsageLimits | None = None,
|
|
) -> None:
|
|
"""Build the adapter.
|
|
|
|
Args:
|
|
agent: The Pydantic AI agent to expose. Its model, tools, and instructions are used as-is.
|
|
deps: Dependencies passed to every agent run, equivalent to `Agent.run(..., deps=deps)`.
|
|
Used for sessions that `session_config` does not configure (or when it is not given).
|
|
name: Name advertised to the client. Defaults to the agent's name, then `'pydantic-ai-agent'`.
|
|
version: Version advertised to the client.
|
|
session_config: Optional factory called once per session with the client's
|
|
[`AcpSession`][pydantic_ai_harness.experimental.acp.AcpSession] setup (its `cwd`, MCP servers,
|
|
and capabilities). It returns an
|
|
[`AcpSessionConfig`][pydantic_ai_harness.experimental.acp.AcpSessionConfig] whose `deps` and
|
|
`toolsets` are applied to every run in that session. May be sync or async.
|
|
permission_policy: Optional function deciding the scope under which an "always
|
|
allow"/"always reject" decision is remembered. Defaults to the exact call (tool
|
|
name plus arguments).
|
|
prompt_capabilities: Prompt content the agent advertises support for (image, audio,
|
|
embedded context). Defaults to text-only; enable what the backing model handles,
|
|
e.g. `schema.PromptCapabilities(image=True, embedded_context=True)`.
|
|
mcp_capabilities: MCP transports the agent advertises support for, e.g.
|
|
`schema.McpCapabilities(http=True, sse=True)`. A spec-following client only sends
|
|
HTTP/SSE MCP servers when these are advertised (stdio servers are not gated), so
|
|
set this when the `session_config` connects them. Defaults to neither; requires a
|
|
`session_config`, since without one MCP servers are rejected at `session/new`.
|
|
tool_presenter: Maps a tool call to its rich ACP presentation (`kind`, file
|
|
`locations`, and diff `content`). Defaults to
|
|
[`default_coding_presenter`][pydantic_ai_harness.experimental.acp.default_coding_presenter],
|
|
which recognizes the `FileSystem`/`Shell` tools by name (a custom tool sharing a
|
|
name is also matched). Pass your own presenter (optionally chained ahead of the
|
|
default with [`chain_presenters`][pydantic_ai_harness.experimental.acp.chain_presenters]), or
|
|
`lambda _call: None` to disable rich rendering.
|
|
session_store: Optional [`SessionStore`][pydantic_ai_harness.experimental.acp.SessionStore] enabling
|
|
`session/load`: each committed turn is persisted (model history plus client
|
|
transcript) and a stored session can be reopened. Defaults to `None`, advertising
|
|
`session/load` as unsupported.
|
|
models: Optional models the client may switch between with the stable ACP session
|
|
config option `model`. The first is each session's default. A selection applies as
|
|
a per-run override (the shared agent is never mutated) and is persisted with the
|
|
session. Pass `'all'` to offer every model Pydantic AI knows (its default is then
|
|
the first known model, so the user should pick one). Defaults to `None`,
|
|
advertising no model config option.
|
|
model_resolver: Optional hook mapping an advertised model id to the `Model` (or model
|
|
string) used for that run, applied just before each run's per-run override. Lets a
|
|
host advertise ids Pydantic AI's `infer_model` does not understand (e.g. OAuth/
|
|
subscription models) and supply a pre-built `Model` for them. Returning the id
|
|
unchanged falls back to `infer_model`. Defaults to identity.
|
|
usage_limits: Optional per-run ceilings (`request_limit`, `tool_calls_limit`, token
|
|
limits) applied to every agent run, equivalent to `Agent.run(..., usage_limits=...)`.
|
|
A turn that resumes after a tool approval starts a fresh run, so the limits bound
|
|
each approval-to-approval segment, not the whole turn. An exceeded limit ends the
|
|
turn with a `max_tokens`/`max_turn_requests` stop reason, never a request error.
|
|
Defaults to Pydantic AI's own defaults (50 requests per run).
|
|
"""
|
|
self._agent = agent
|
|
self._deps = deps
|
|
self._name = name or agent.name or 'pydantic-ai-agent'
|
|
self._version = version
|
|
self._session_config = session_config
|
|
self._permission_policy = permission_policy or default_permission_scope
|
|
# Text-only by default, so a client is not invited to send image/audio/resource blocks
|
|
# the backing model may not accept.
|
|
self._prompt_capabilities = prompt_capabilities or schema.PromptCapabilities()
|
|
if mcp_capabilities is not None and session_config is None:
|
|
# Advertising HTTP/SSE MCP support would invite server definitions that
|
|
# `_build_config` then rejects; fail at construction instead of per session.
|
|
raise ValueError('mcp_capabilities requires a session_config that connects the MCP servers')
|
|
self._mcp_capabilities = mcp_capabilities or schema.McpCapabilities()
|
|
self._tool_presenter = tool_presenter or default_coding_presenter
|
|
self._session_store = session_store
|
|
self._models: tuple[str, ...] = _all_known_model_names() if models == 'all' else tuple(models) if models else ()
|
|
self._model_resolver = model_resolver
|
|
self._usage_limits = usage_limits
|
|
self._client_capabilities: schema.ClientCapabilities | None = None
|
|
self._conn: Client | None = None
|
|
# All live sessions, keyed by session id. Each owns its own turn lock (the dispatcher
|
|
# delivers requests concurrently), so turns within a session are serialized.
|
|
self._sessions: dict[str, SessionState[AgentDepsT]] = {}
|
|
|
|
def on_connect(self, conn: Client) -> None:
|
|
"""Capture the outbound connection used to stream updates and request permission."""
|
|
self._conn = conn
|
|
|
|
async def initialize(
|
|
self,
|
|
protocol_version: int,
|
|
client_capabilities: schema.ClientCapabilities | None = None,
|
|
client_info: schema.Implementation | None = None,
|
|
**kwargs: object,
|
|
) -> schema.InitializeResponse:
|
|
"""Negotiate the protocol version and advertise the agent's capabilities."""
|
|
self._client_capabilities = client_capabilities
|
|
# Echo a supported client version; otherwise answer with the latest version we speak so
|
|
# an out-of-range client does not negotiate an unsupported one.
|
|
if 1 <= protocol_version <= acp.PROTOCOL_VERSION:
|
|
negotiated_version = protocol_version
|
|
else:
|
|
negotiated_version = acp.PROTOCOL_VERSION
|
|
return schema.InitializeResponse(
|
|
protocol_version=negotiated_version,
|
|
agent_capabilities=schema.AgentCapabilities(
|
|
load_session=self._session_store is not None,
|
|
prompt_capabilities=self._prompt_capabilities,
|
|
mcp_capabilities=self._mcp_capabilities,
|
|
session_capabilities=schema.SessionCapabilities(close=schema.SessionCloseCapabilities()),
|
|
),
|
|
agent_info=schema.Implementation(name=self._name, version=self._version),
|
|
)
|
|
|
|
async def new_session(
|
|
self,
|
|
cwd: str,
|
|
additional_directories: list[str] | None = None,
|
|
mcp_servers: McpServers = None,
|
|
**kwargs: object,
|
|
) -> schema.NewSessionResponse:
|
|
"""Start a new session with an empty conversation history.
|
|
|
|
The session's `cwd` is the workspace the client opened: root the agent's tools at it via
|
|
`session_config` (see the package README) so file edits and tool-call locations line up
|
|
with that workspace. A request with MCP servers but no `session_config` is rejected (see
|
|
`_build_config`).
|
|
"""
|
|
# The spec requires `cwd` to be absolute, a MUST on the client; neither the SDK router
|
|
# nor this adapter re-validates it. `additional_directories` is
|
|
# part of the `acp.Agent` interface but not consumed: the capability is not advertised, so
|
|
# a conformant client never sends extra roots, and they are not surfaced to `session_config`.
|
|
session_id = uuid4().hex
|
|
config = await self._build_config(session_id, cwd, mcp_servers)
|
|
# The first configured model is the session default; `None` (no models) uses the agent's own.
|
|
default_model = self._models[0] if self._models else None
|
|
state = SessionState(session_id=session_id, config=config, cwd=cwd, model=default_model)
|
|
self._sessions[session_id] = state
|
|
# Persist the empty session so the client can reopen it even before its first turn.
|
|
await self._persist(state)
|
|
return schema.NewSessionResponse(session_id=session_id, config_options=self._model_config_options(state.model))
|
|
|
|
async def load_session(
|
|
self,
|
|
cwd: str,
|
|
session_id: str,
|
|
mcp_servers: McpServers = None,
|
|
additional_directories: list[str] | None = None,
|
|
**kwargs: object,
|
|
) -> schema.LoadSessionResponse | None:
|
|
"""Reopen a stored session: restore its model history and replay its transcript to the client.
|
|
|
|
Only called when a `session_store` was given (`session/load` is otherwise advertised as
|
|
unsupported). The run configuration is rebuilt from `cwd`/`mcp_servers` via
|
|
`session_config`, as in `new_session`.
|
|
|
|
Raises:
|
|
acp.RequestError: if no session with `session_id` is stored, or the stored session
|
|
cannot be read.
|
|
"""
|
|
if self._session_store is None:
|
|
# Advertised off, but the SDK router routes `session/load` regardless; a client
|
|
# calling it anyway gets the rejection the advertisement implies.
|
|
raise acp.RequestError.method_not_found('session/load')
|
|
if self._conn is None: # pragma: no cover - on_connect always runs before load_session
|
|
raise RuntimeError('load_session called before on_connect()')
|
|
try:
|
|
stored = await self._session_store.load(session_id)
|
|
except Exception as exc:
|
|
# A read or deserialization failure is a server-side durability problem, not a bad
|
|
# client request: surface a clear, purpose-built error rather than leaking the store's
|
|
# low-level exception (a corrupt payload would otherwise reach the client as raw
|
|
# pydantic validation detail).
|
|
raise acp.RequestError.internal_error(
|
|
{'session_id': session_id, 'reason': 'stored session could not be read'}
|
|
) from exc
|
|
if stored is None:
|
|
raise acp.RequestError.invalid_params(
|
|
{'session_id': session_id, 'reason': 'no stored session with this id'}
|
|
)
|
|
# A client may load a session id that is still open (a reconnecting editor, or a double
|
|
# load). Tear down any live turn first so the orphaned turn cannot later persist its stale
|
|
# state over the transcript and history we are about to restore.
|
|
prior = self._sessions.pop(session_id, None)
|
|
if prior is not None:
|
|
await self._cancel_active_turn(prior)
|
|
config = await self._build_config(session_id, cwd, mcp_servers)
|
|
state = SessionState(
|
|
session_id=session_id,
|
|
config=config,
|
|
cwd=cwd,
|
|
history=list(stored.messages),
|
|
transcript=list(stored.updates),
|
|
model=stored.model,
|
|
)
|
|
self._sessions[session_id] = state
|
|
for update in stored.updates:
|
|
await self._conn.session_update(session_id=session_id, update=update)
|
|
return schema.LoadSessionResponse(config_options=self._model_config_options(state.model))
|
|
|
|
async def _persist(self, state: SessionState[AgentDepsT]) -> None:
|
|
"""Save a session's committed state (history, transcript, selected model), if a store is configured.
|
|
|
|
A store failure is logged and swallowed rather than failing the operation that triggered the
|
|
save: the turn (or session) has already streamed and committed in memory, so a durable-write
|
|
error must not surface as a failure for work the user already saw succeed. The session stays
|
|
usable and the next successful save catches the store back up.
|
|
"""
|
|
if self._session_store is None:
|
|
return
|
|
try:
|
|
await self._session_store.save(
|
|
state.session_id,
|
|
StoredSession(messages=list(state.history), updates=list(state.transcript), model=state.model),
|
|
)
|
|
except Exception:
|
|
_logger.exception('failed to persist ACP session %s; durable state is now behind', state.session_id)
|
|
|
|
def _model_option(self, current_model_id: str | None) -> schema.SessionConfigOptionSelect | None:
|
|
"""The model config option advertised to the client, or `None` when none is configured."""
|
|
if current_model_id is None or not self._models:
|
|
return None
|
|
return schema.SessionConfigOptionSelect(
|
|
id=_MODEL_CONFIG_ID,
|
|
name='Model',
|
|
type='select',
|
|
current_value=current_model_id,
|
|
options=[schema.SessionConfigSelectOption(value=model, name=model) for model in self._models],
|
|
)
|
|
|
|
def _model_config_options(
|
|
self, current_model_id: str | None
|
|
) -> list[schema.SessionConfigOptionSelect | schema.SessionConfigOptionBoolean] | None:
|
|
"""The full session config option list, or `None` when model switching is not configured."""
|
|
option = self._model_option(current_model_id)
|
|
return None if option is None else [option]
|
|
|
|
def session_history(self, session_id: str) -> list[ModelMessage] | None:
|
|
"""A snapshot of a resident session's committed model history (what the next turn will send).
|
|
|
|
Returns a shallow copy: the list is the caller's to keep, but the `ModelMessage` objects are
|
|
shared with the live session -- treat them as read-only. `None` when the session is not
|
|
resident in this adapter.
|
|
"""
|
|
state = self._sessions.get(session_id)
|
|
return list(state.history) if state is not None else None
|
|
|
|
def session_model_option(self, session_id: str) -> schema.SessionConfigOptionSelect | None:
|
|
"""The model config option for a resident session -- its option set + current selection.
|
|
|
|
The public counterpart to the `config_options` entry `new_session`/`load_session` return,
|
|
for callers that re-surface model selection on a later interaction (e.g. an attach/resume
|
|
reply) without re-creating or reloading the session. `None` when the session is unknown to
|
|
this adapter or no switch-set was configured.
|
|
"""
|
|
state = self._sessions.get(session_id)
|
|
return self._model_option(state.model) if state is not None else None
|
|
|
|
async def _build_config(self, session_id: str, cwd: str, mcp_servers: McpServers) -> AcpSessionConfig[AgentDepsT]:
|
|
"""Derive a session's run configuration, calling the `session_config` factory if present.
|
|
|
|
Raises:
|
|
acp.RequestError: if the client provides MCP servers but no `session_config` is
|
|
installed to connect them.
|
|
"""
|
|
if self._session_config is None:
|
|
if mcp_servers:
|
|
raise acp.RequestError.invalid_params(
|
|
{
|
|
'reason': 'this agent does not connect MCP servers; provide a session_config '
|
|
'that turns mcp_servers into toolsets to use them',
|
|
'mcp_server_count': len(mcp_servers),
|
|
}
|
|
)
|
|
return AcpSessionConfig(deps=self._deps)
|
|
if self._conn is None: # pragma: no cover - on_connect always runs before session setup
|
|
raise RuntimeError('_build_config called before on_connect()')
|
|
session = AcpSession(
|
|
cwd=cwd,
|
|
mcp_servers=mcp_servers or [],
|
|
client_capabilities=self._client_capabilities,
|
|
client=self._conn,
|
|
session_id=session_id,
|
|
)
|
|
result = self._session_config(session)
|
|
return await result if isawaitable(result) else result
|
|
|
|
async def prompt(
|
|
self,
|
|
session_id: str,
|
|
prompt: list[PromptContentBlock],
|
|
**kwargs: object,
|
|
) -> schema.PromptResponse:
|
|
"""Run one user turn, streaming the agent's output to the client.
|
|
|
|
Turns for a session are serialized. The turn runs in a cancellable task so a concurrent
|
|
`session/cancel` (or a cancelled permission dialog) stops it promptly with a `cancelled`
|
|
stop reason; a cancelled turn's prompt and partial output are not committed to the
|
|
session history.
|
|
"""
|
|
if self._conn is None: # pragma: no cover - on_connect always runs first in practice
|
|
raise RuntimeError('prompt() called before on_connect()')
|
|
state = self._sessions.get(session_id)
|
|
if state is None:
|
|
raise acp.RequestError.invalid_params({'session_id': session_id})
|
|
|
|
user_content = prompt_blocks_to_user_content(prompt)
|
|
async with state.lock:
|
|
if self._sessions.get(session_id) is not state:
|
|
# The session was closed or reloaded while this prompt waited its turn on the
|
|
# lock. Running now would resurrect the discarded state: `cancel` could no longer
|
|
# reach the turn, and its commit would persist the orphaned history over the
|
|
# live session.
|
|
raise acp.RequestError.invalid_params(
|
|
{'session_id': session_id, 'reason': 'session was closed while the prompt was queued'}
|
|
)
|
|
state.cancel_requested = False
|
|
turn = asyncio.ensure_future(self._run_turn(state, prompt, user_content))
|
|
state.active_turn = turn
|
|
try:
|
|
# Shielded so this coroutine's *own* cancellation (connection teardown) surfaces
|
|
# here instead of propagating into the turn: with a bare `await turn` the two are
|
|
# indistinguishable, and answering a request whose handler the connection already
|
|
# cancelled would deadlock its shutdown (the response future is never resolved).
|
|
return await asyncio.shield(turn)
|
|
except _TurnCancelled:
|
|
return schema.PromptResponse(stop_reason='cancelled')
|
|
except asyncio.CancelledError:
|
|
# `turn.done()` cannot fully prove the cancellation came from the turn: in the one
|
|
# tick between the turn completing and this coroutine resuming, a teardown cancel
|
|
# aimed at *this* handler is indistinguishable on 3.10 (3.11's `Task.cancelling()`
|
|
# would settle it). Accepted residual; every wider window is covered below.
|
|
if turn.done() and state.cancel_requested:
|
|
return schema.PromptResponse(stop_reason='cancelled')
|
|
if state.cancel_requested:
|
|
# `cancel()` already delivered cancellation to the turn. Do not inject a second
|
|
# one while Pydantic AI's stream context is closing, or its internal event stream
|
|
# can be interrupted before its receive side closes.
|
|
with contextlib.suppress(asyncio.CancelledError, _TurnCancelled):
|
|
await asyncio.shield(turn)
|
|
raise
|
|
# The prompt coroutine itself was cancelled (e.g. connection teardown): stop the
|
|
# child turn before propagating, so it is not left running on a closing connection.
|
|
turn.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError, _TurnCancelled):
|
|
await turn
|
|
raise
|
|
finally:
|
|
state.active_turn = None
|
|
state.cancel_requested = False
|
|
|
|
async def cancel(self, session_id: str, **kwargs: object) -> None:
|
|
"""Cancel the in-flight turn for a session, if any."""
|
|
state = self._sessions.get(session_id)
|
|
if state is None:
|
|
return
|
|
state.cancel_requested = True
|
|
if state.active_turn is not None and not state.active_turn.done():
|
|
state.active_turn.cancel()
|
|
|
|
async def close_session(self, session_id: str, **kwargs: object) -> schema.CloseSessionResponse | None:
|
|
"""Cancel any in-flight turn and discard all state for the session.
|
|
|
|
Closing an unknown (or already-closed) session is a no-op.
|
|
"""
|
|
state = self._sessions.pop(session_id, None)
|
|
if state is not None:
|
|
await self._cancel_active_turn(state)
|
|
return schema.CloseSessionResponse()
|
|
|
|
async def _cancel_active_turn(self, state: SessionState[AgentDepsT]) -> None:
|
|
"""End a session's in-flight turn (if any) with a `cancelled` stop reason and await its unwind.
|
|
|
|
Awaiting the turn keeps the caller from racing the turn's teardown -- the prompt has fully
|
|
returned `cancelled` and released the session's resources before this returns.
|
|
"""
|
|
turn = state.active_turn
|
|
if turn is not None and not turn.done():
|
|
state.cancel_requested = True
|
|
turn.cancel()
|
|
try:
|
|
# Shielded for the same reason as in `prompt`: only the turn's own unwind may be
|
|
# swallowed here, never this coroutine's cancellation (see the comment there).
|
|
await asyncio.shield(turn)
|
|
except (asyncio.CancelledError, _TurnCancelled):
|
|
# As in `prompt`, `turn.done()` is a heuristic with a one-tick residual: a
|
|
# teardown cancel landing between the turn completing and this resuming is
|
|
# swallowed here on 3.10. Accepted.
|
|
if not turn.done():
|
|
raise
|
|
|
|
async def _run_turn(
|
|
self,
|
|
state: SessionState[AgentDepsT],
|
|
prompt: list[PromptContentBlock],
|
|
user_content: list[UserContent],
|
|
) -> schema.PromptResponse:
|
|
"""Drive the agent (resuming across tool-approval pauses), stream updates, and build the response.
|
|
|
|
The response carries the ACP stop reason mapped from the final model response, plus the
|
|
turn's total token usage (summed across every run pass, one per approval pause). A turn
|
|
ended by a usage limit rolls back like a cancellation, so its response omits usage while
|
|
still answering with the limit's `max_tokens`/`max_turn_requests` stop reason.
|
|
"""
|
|
conn = self._conn
|
|
assert conn is not None
|
|
history = state.history
|
|
config = state.config
|
|
usage = RunUsage()
|
|
stop_reason: schema.StopReason = 'end_turn'
|
|
deferred_results: DeferredToolResults | None = None
|
|
run_input: list[UserContent] | None = user_content
|
|
output_type = [self._agent.output_type, DeferredToolRequests]
|
|
turn = _TurnState(
|
|
conn=conn, session_id=state.session_id, cwd=state.cwd, approval_names=self._approval_tool_names(config)
|
|
)
|
|
# Recorded for replay only, never sent live: the client renders its own prompt during
|
|
# the turn, but `session/load` must rebuild the user side of the conversation too.
|
|
# Going through `turn.updates` keeps the cancel semantics: a rolled-back turn does not
|
|
# persist its user message either.
|
|
turn.updates.extend(acp.update_user_message(block) for block in prompt)
|
|
|
|
try:
|
|
while True:
|
|
result = None
|
|
async with self._agent.run_stream_events(
|
|
run_input,
|
|
message_history=history,
|
|
deferred_tool_results=deferred_results,
|
|
output_type=output_type,
|
|
deps=config.deps,
|
|
toolsets=config.toolsets,
|
|
# Per-run override for the client's model config choice; `None` uses the
|
|
# agent's own model, never mutating the shared agent. A `model_resolver` (if
|
|
# given) maps the advertised id to a pre-built `Model` for ids `infer_model`
|
|
# can't parse.
|
|
model=self._resolve_run_model(state.model),
|
|
usage_limits=self._usage_limits,
|
|
) as stream:
|
|
event: AgentStreamEvent | AgentRunResultEvent[object]
|
|
async for event in stream:
|
|
if isinstance(event, AgentRunResultEvent):
|
|
result = event.result
|
|
else:
|
|
await self._emit_event(turn, event)
|
|
|
|
assert result is not None, 'run_stream_events always yields a final result event'
|
|
history = result.all_messages()
|
|
usage += result.usage # pydantic-ai 2.0: `usage` is a property, not a method
|
|
output = result.output
|
|
if isinstance(output, DeferredToolRequests):
|
|
if not output.approvals and not output.calls: # pragma: no cover
|
|
break # defensive: the run never yields an empty DeferredToolRequests
|
|
deferred_results = await self._resolve_approvals(turn, state, output)
|
|
run_input = None
|
|
continue
|
|
if output is not None and not isinstance(output, str):
|
|
# Structured output isn't streamed as text parts, so deliver it as a final message.
|
|
# (`str` output already streamed via text deltas; `None` has nothing to show.)
|
|
await self._emit_text(turn, json.dumps(jsonable(output)), thought=False)
|
|
stop_reason = _finish_reason_to_stop_reason(result.response.finish_reason)
|
|
break
|
|
except (asyncio.CancelledError, _TurnCancelled):
|
|
# The turn is ending without finishing its tool calls; close out any still shown as
|
|
# pending/in_progress so the client stops rendering them as running. Shielded because an
|
|
# asyncio cancellation would otherwise abort the sends, and live-only: a cancelled turn
|
|
# never commits its transcript.
|
|
with anyio.CancelScope(shield=True):
|
|
await self._fail_outstanding_tool_calls(turn)
|
|
raise
|
|
except UsageLimitExceeded as exc:
|
|
# ACP models a turn ending at a limit as a normal stop reason, not a request error.
|
|
# The raising run's partial messages are not retrievable, so nothing is committed --
|
|
# the turn rolls back to the prior state, like a cancellation, and the response must
|
|
# not report uncommitted usage.
|
|
await self._fail_outstanding_tool_calls(turn)
|
|
return schema.PromptResponse(stop_reason=_usage_limit_stop_reason(exc))
|
|
except Exception:
|
|
# The turn is failing with the error the client receives as the prompt's response;
|
|
# close out its announced tool calls so they are not left rendering as running.
|
|
await self._fail_outstanding_tool_calls(turn)
|
|
raise
|
|
|
|
state.history = history
|
|
# Commit and persist this turn's updates alongside the history; a cancelled turn never
|
|
# reaches here, so only committed turns are persisted.
|
|
if self._session_store is not None:
|
|
state.transcript.extend(turn.updates)
|
|
try:
|
|
await self._persist(state)
|
|
except asyncio.CancelledError:
|
|
# The turn is already committed: a cancel landing inside the store's save came
|
|
# too late to roll anything back. The spec still requires the prompt to answer
|
|
# `cancelled`, so that stop reason is reported alongside committed usage. The
|
|
# interrupted save is the same benign failure `_persist` swallows; the next
|
|
# successful save catches the store up.
|
|
_logger.warning(
|
|
'persisting ACP session %s was cancelled; durable state is now behind', state.session_id
|
|
)
|
|
stop_reason = 'cancelled'
|
|
return schema.PromptResponse(stop_reason=stop_reason, usage=_to_acp_usage(usage))
|
|
|
|
async def _fail_outstanding_tool_calls(self, turn: _TurnState) -> None:
|
|
"""Drive every announced-but-unfinished tool call to a terminal `failed` status.
|
|
|
|
Called when a turn is cancelled, so a client does not keep rendering a `pending`/`in_progress`
|
|
tool call as running. Sent directly (not recorded for the transcript) and best-effort: the
|
|
connection may already be going away.
|
|
"""
|
|
for tool_call_id in turn.started - turn.resulted:
|
|
with contextlib.suppress(Exception):
|
|
await turn.conn.session_update(
|
|
session_id=turn.session_id,
|
|
update=acp.update_tool_call(tool_call_id=tool_call_id, status='failed'),
|
|
)
|
|
|
|
async def _send_update(self, turn: _TurnState, update: SessionUpdate) -> None:
|
|
"""Send one `session/update` to the client, recording it for the transcript."""
|
|
turn.updates.append(update)
|
|
await turn.conn.session_update(session_id=turn.session_id, update=update)
|
|
|
|
async def _emit_text(self, turn: _TurnState, text: str, *, thought: bool) -> None:
|
|
"""Stream text to the client as one or more chunked `session/update` notifications."""
|
|
for chunk in chunk_text(text):
|
|
update = acp.update_agent_thought_text(chunk) if thought else acp.update_agent_message_text(chunk)
|
|
await self._send_update(turn, update)
|
|
|
|
def _tool_call_fields(
|
|
self, call: ToolCallPart, cwd: str, *, default_kind: schema.ToolKind | None
|
|
) -> _ToolCallFields:
|
|
"""Derive the ACP tool-call fields shared by the start and permission paths via the presenter.
|
|
|
|
Falls back to the tool name for the title, and to `default_kind` when the presenter
|
|
offers no `kind`.
|
|
"""
|
|
presentation = self._tool_presenter(call)
|
|
presentation = absolutize(presentation, cwd) if presentation is not None else ToolCallPresentation()
|
|
return _ToolCallFields(
|
|
title=presentation.title or call.tool_name,
|
|
kind=presentation.kind or default_kind,
|
|
content=list(presentation.content) or None,
|
|
locations=list(presentation.locations) or None,
|
|
)
|
|
|
|
async def _emit_event(self, turn: _TurnState, event: AgentStreamEvent) -> None:
|
|
"""Translate a single Pydantic AI stream event into an ACP `session/update`."""
|
|
if isinstance(event, PartStartEvent):
|
|
part = event.part
|
|
if isinstance(part, TextPart) and part.content:
|
|
await self._emit_text(turn, part.content, thought=False)
|
|
elif isinstance(part, ThinkingPart) and part.content:
|
|
await self._emit_text(turn, part.content, thought=True)
|
|
elif isinstance(event, PartDeltaEvent):
|
|
delta = event.delta
|
|
if isinstance(delta, TextPartDelta) and delta.content_delta:
|
|
await self._emit_text(turn, delta.content_delta, thought=False)
|
|
elif isinstance(delta, ThinkingPartDelta) and delta.content_delta:
|
|
await self._emit_text(turn, delta.content_delta, thought=True)
|
|
elif isinstance(event, FunctionToolCallEvent):
|
|
call = event.part
|
|
if call.tool_call_id in turn.started:
|
|
return # the model re-emits the call event when resuming after approval
|
|
turn.started.add(call.tool_call_id)
|
|
fields = self._tool_call_fields(call, turn.cwd, default_kind=None)
|
|
# A call awaiting approval is not running yet: it starts `pending` and
|
|
# `_resolve_approvals` promotes it once approved. Any other call is already executing.
|
|
status: schema.ToolCallStatus = 'pending' if call.tool_name in turn.approval_names else 'in_progress'
|
|
await self._send_update(
|
|
turn,
|
|
acp.start_tool_call(
|
|
tool_call_id=call.tool_call_id,
|
|
title=fields.title,
|
|
kind=fields.kind,
|
|
status=status,
|
|
content=fields.content,
|
|
locations=fields.locations,
|
|
raw_input=bounded_jsonable(call.args),
|
|
),
|
|
)
|
|
elif isinstance(event, FunctionToolResultEvent):
|
|
part = event.part
|
|
if isinstance(part, RetryPromptPart):
|
|
status, raw_output = 'failed', part.model_response()
|
|
elif part.tool_call_id in turn.denied:
|
|
status, raw_output = 'failed', part.content
|
|
else:
|
|
status, raw_output = 'completed', part.content
|
|
turn.resulted.add(part.tool_call_id)
|
|
await self._send_update(
|
|
turn,
|
|
acp.update_tool_call(
|
|
tool_call_id=part.tool_call_id,
|
|
status=status,
|
|
raw_output=bounded_jsonable(raw_output),
|
|
),
|
|
)
|
|
|
|
async def _resolve_approvals(
|
|
self, turn: _TurnState, state: SessionState[AgentDepsT], requests: DeferredToolRequests
|
|
) -> DeferredToolResults:
|
|
"""Ask the client to approve each pending tool call and collect the decisions.
|
|
|
|
Records rejected call IDs in `turn.denied` so the eventual result event is reported as failed.
|
|
|
|
Raises:
|
|
_TurnCancelled: if the client cancels a permission request.
|
|
acp.RequestError: if the agent requests external tool execution, which is unsupported.
|
|
"""
|
|
if requests.calls:
|
|
names = sorted({call.tool_name for call in requests.calls})
|
|
raise acp.RequestError.internal_error(
|
|
{'reason': 'external tool execution is not supported by the ACP adapter', 'tools': names}
|
|
)
|
|
|
|
results = DeferredToolResults()
|
|
for call in requests.approvals:
|
|
# `args_as_dict()` canonicalizes the call's arguments to a dict: a model may deliver them
|
|
# as a JSON string (the OpenAI default, and how streamed calls accumulate), and a raw
|
|
# string would make the scope key sensitive to key order, defeating a remembered "always"
|
|
# decision for what is the same logical call.
|
|
scope = self._permission_policy(
|
|
ToolCallPermission(tool_name=call.tool_name, tool_call_id=call.tool_call_id, args=call.args_as_dict())
|
|
)
|
|
if scope in state.always_allow:
|
|
results.approvals[call.tool_call_id] = True
|
|
await self._mark_running(turn, call.tool_call_id)
|
|
continue
|
|
if scope in state.always_reject:
|
|
results.approvals[call.tool_call_id] = ToolDenied('Rejected by the client.')
|
|
turn.denied.add(call.tool_call_id)
|
|
continue
|
|
# Only an approved call is promoted to `in_progress`; a rejected one stays `pending`
|
|
# until its `failed` result, never shown as running.
|
|
decision = await self._request_permission(turn, state, call, scope)
|
|
results.approvals[call.tool_call_id] = decision
|
|
if isinstance(decision, ToolDenied):
|
|
turn.denied.add(call.tool_call_id)
|
|
else:
|
|
await self._mark_running(turn, call.tool_call_id)
|
|
return results
|
|
|
|
async def _mark_running(self, turn: _TurnState, tool_call_id: str) -> None:
|
|
"""Promote an approved tool call from `pending` to `in_progress`, just before it executes."""
|
|
await self._send_update(turn, acp.update_tool_call(tool_call_id=tool_call_id, status='in_progress'))
|
|
|
|
def _approval_tool_names(self, config: AcpSessionConfig[AgentDepsT]) -> frozenset[str]:
|
|
"""Names of the tools that pause for the client's approval, announced `pending` in `_emit_event`.
|
|
|
|
Only `FunctionToolset`-held tools expose `requires_approval` without a live run context;
|
|
tools from other toolset types are treated as not requiring approval.
|
|
"""
|
|
toolsets: list[AbstractToolset[AgentDepsT]] = [*self._agent.toolsets, *(config.toolsets or [])]
|
|
names: set[str] = set()
|
|
for toolset in toolsets:
|
|
if isinstance(toolset, FunctionToolset):
|
|
names.update(name for name, tool in toolset.tools.items() if tool.requires_approval)
|
|
return frozenset(names)
|
|
|
|
async def _request_permission(
|
|
self, turn: _TurnState, state: SessionState[AgentDepsT], call: ToolCallPart, scope: Hashable
|
|
) -> bool | ToolDenied:
|
|
"""Ask the client to approve a single tool call, remembering "always" decisions by `scope`."""
|
|
fields = self._tool_call_fields(call, turn.cwd, default_kind='execute')
|
|
response = await turn.conn.request_permission(
|
|
session_id=turn.session_id,
|
|
tool_call=schema.ToolCallUpdate(
|
|
tool_call_id=call.tool_call_id,
|
|
title=fields.title,
|
|
kind=fields.kind,
|
|
status='pending',
|
|
content=fields.content,
|
|
locations=fields.locations,
|
|
raw_input=bounded_jsonable(call.args),
|
|
),
|
|
options=[
|
|
schema.PermissionOption(kind='allow_once', name='Allow', option_id='allow_once'),
|
|
schema.PermissionOption(kind='allow_always', name='Always allow', option_id='allow_always'),
|
|
schema.PermissionOption(kind='reject_once', name='Reject', option_id='reject_once'),
|
|
schema.PermissionOption(kind='reject_always', name='Always reject', option_id='reject_always'),
|
|
],
|
|
)
|
|
outcome = response.outcome
|
|
if isinstance(outcome, schema.DeniedOutcome):
|
|
# ACP signals a cancelled turn via a "cancelled" permission outcome.
|
|
raise _TurnCancelled
|
|
if outcome.option_id == 'allow_always':
|
|
state.always_allow.add(scope)
|
|
elif outcome.option_id == 'reject_always':
|
|
state.always_reject.add(scope)
|
|
if outcome.option_id in ('allow_once', 'allow_always'):
|
|
return True
|
|
return ToolDenied('Rejected by the client.')
|
|
|
|
# --- Optional ACP methods not supported by this adapter ------------------------------
|
|
# The capabilities for these are advertised as off in `initialize`, but the SDK router still
|
|
# routes the methods here (`acp.Agent` is a Protocol whose inherited stub bodies would answer
|
|
# success-`null`), so these raises are what a client calling them anyway actually receives.
|
|
|
|
async def authenticate(self, method_id: str, **kwargs: object) -> schema.AuthenticateResponse | None:
|
|
"""No authentication is required, so this is a no-op."""
|
|
return None
|
|
|
|
async def list_sessions(
|
|
self,
|
|
cwd: str | None = None,
|
|
cursor: str | None = None,
|
|
**kwargs: object,
|
|
) -> schema.ListSessionsResponse:
|
|
raise acp.RequestError.method_not_found('session/list')
|
|
|
|
def _resolve_run_model(self, model_id: str | None) -> Model | str | None:
|
|
"""Map an advertised model id to the per-run override, applying `model_resolver` if set.
|
|
|
|
`None` (no advertised models) is passed through so the run uses the agent's own model.
|
|
"""
|
|
if model_id is None or self._model_resolver is None:
|
|
return model_id
|
|
return self._model_resolver(model_id)
|
|
|
|
async def set_session_mode(
|
|
self, session_id: str, mode_id: str, **kwargs: object
|
|
) -> schema.SetSessionModeResponse | None:
|
|
raise acp.RequestError.method_not_found('session/set_mode')
|
|
|
|
async def set_config_option(
|
|
self, config_id: str, session_id: str, value: str | bool, **kwargs: object
|
|
) -> schema.SetSessionConfigOptionResponse | None:
|
|
"""Switch the session's model through ACP's stable session config option surface."""
|
|
state = self._sessions.get(session_id)
|
|
if state is None:
|
|
raise acp.RequestError.invalid_params({'session_id': session_id})
|
|
if config_id != _MODEL_CONFIG_ID:
|
|
raise acp.RequestError.invalid_params({'config_id': config_id, 'reason': 'unknown config option'})
|
|
if not isinstance(value, str) or value not in self._models:
|
|
raise acp.RequestError.invalid_params({'model_id': value, 'reason': 'not an advertised model'})
|
|
state.model = value
|
|
await self._persist(state)
|
|
options = self._model_config_options(state.model)
|
|
assert options is not None
|
|
return schema.SetSessionConfigOptionResponse(config_options=options)
|
|
|
|
async def fork_session(
|
|
self,
|
|
session_id: str,
|
|
cwd: str,
|
|
additional_directories: list[str] | None = None,
|
|
mcp_servers: McpServers = None,
|
|
**kwargs: object,
|
|
) -> schema.ForkSessionResponse:
|
|
raise acp.RequestError.method_not_found('session/fork')
|
|
|
|
async def resume_session(
|
|
self,
|
|
session_id: str,
|
|
cwd: str,
|
|
additional_directories: list[str] | None = None,
|
|
mcp_servers: McpServers = None,
|
|
**kwargs: object,
|
|
) -> schema.ResumeSessionResponse:
|
|
raise acp.RequestError.method_not_found('session/resume')
|
|
|
|
async def ext_method(self, method: str, params: dict[str, object]) -> dict[str, object]:
|
|
raise acp.RequestError.method_not_found(method)
|
|
|
|
async def ext_notification(self, method: str, params: dict[str, object]) -> None:
|
|
return None
|