Files
deepseek-harness/docs/rfc/010-acp-agent-client-protocol.md
2026-06-16 14:55:37 +08:00

17 KiB

RFC 010: Agent Client Protocol (ACP) support — drive the coding agent from external editors

Status: proposed

Problem

The coding agent is reachable only through the readline stdio-chat plugin: it reads lines from stdin, calls agent.send(), and prints agent/stream-chunk to stdout. There is no structured protocol, so the agent cannot be embedded in an editor — no streaming render, no tool-call display, no permission UI, no resumable sessions.

Editors are converging on the Agent Client Protocol (ACP), which Zed and others speak: JSON-RPC 2.0 over newline-delimited stdio, modeled on the Language Server Protocol. An editor boots the agent as a subprocess and exchanges initialize / session/new / session/prompt, rendering streamed session/update notifications and session/request_permission prompts. The goal is for the agent to be a drop-in ACP server — implement the protocol once and run in any ACP client, with no per-editor glue.

This RFC has a hard prerequisite on RFC 009: it assumes durable session persistence (the SessionPersistence service and the async AgentLoop.resume seam) is implemented, so resuming a session via session/load is in scope. None of those APIs exist yet — AgentLoop currently exposes only the synchronous create — so 010 must land after, or in the same change as, 009, and pins to 009's resume(agentId, resumeSessionId) contract. RFC 009 persists every SessionEvent verbatim (including assistant/chunk), so a loaded session has the stream chunks needed to replay turns to the client.

Proposal

A new plugin package @deepseek-ai/dsh-acp — a client-driver / UI plugin, the structured analogue of stdio-chat. It is NOT a change to the loop and NOT an ADR 0009 interface/implementation/consumer capability split; it consumes the existing agent/* event taxonomy and the tools/execute waterfall.

It depends on the official @agentclientprotocol/sdk (the AgentSideConnection class) — Apache-2.0, actively versioned. The SDK declares a zod peer dependency and imports zod/v4 at runtime, so packages/acp must declare zod itself (per the workspace dependency constraints). This is the renamed successor to @zed-industries/agent-client-protocol, which is now deprecated on npm.

The mapping between ACP and existing harness seams — each row names the seam and any required extension:

ACP (client ⇄ agent) Harness seam Notes
initialize static handler negotiate protocolVersion (echo the supported version, else error); advertise text-only promptCapabilities and loadSession: true; report agent name/version
session/new {cwd, mcpServers, additionalDirectories}{sessionId} the dsh-agent create factory (see Dependency note + Plan) the seam must accept { sessionId, meta } so the ACP-generated sessionId becomes the live/persisted session id and the validated cwd is attached as the SessionHeader (today AgentLoop.create(id) hardcodes ${id}-session and takes no metadata); reject a 2nd session (single-session MVP, see RFC 011); cwd validated (require absolute) with "launch the server in the workspace root" documented until the workdir seam exists; mcpServers ignored (no mcpCapabilities advertised); non-empty additionalDirectories rejected for the MVP (the bridge cannot yet widen bash/tool filesystem scope, so silently ignoring them would desync the client's filesystem-scope UI)
session/load {sessionId, cwd, mcpServers, additionalDirectories} the dsh-agent resume factory (RFC 009 + Dependency note) load { meta, events }, seed the session, re-derive history via deriveMessages(), replay prior turns to the client as session/update per the ACP load contract; additionalDirectories rejected as in session/new
session/prompt {prompt} agent.send() (idle) text blocks → TextBlock; reject image/audio per advertised capabilities; one in-flight prompt per session
resolve session/prompt{stopReason} agent/turn-end (extended, see Plan) map the harness kebab TurnEndReason to the ACP snake_case StopReason wire enum: completedend_turn, max-tokensmax_tokens, aborted(cancel)→cancelled, plus refusal/max_turn_requests when applicable; honor the batch-into-one-turn and send-not-synchronously-running settle semantics
session/update: agent_message_chunk agent/stream-chunk text-delta only do NOT also emit on block-end(TextBlock) — it carries the fully-assembled block and would duplicate the streamed text
session/update: agent_thought_chunk agent/stream-chunk reasoning-delta
session/update: tool_call (pending→in_progress) session/event tool/call demux via a Session→sessionId map; kind inferred from the tool name
session/update: tool_call_update (completed/failed) session/event tool/result a throwing tools/execute yields NO tool/result → fail the pending tool UI from agent/error/turn-end
session/request_permission {sessionId, toolCall, options} prepended tools/execute listener no-op unless exec.agent is ACP-owned; await the outcome; selected/allow_*next(); reject_*/cancelled → veto ToolExecutionResult{isError}
session/cancel (notification) agent.abort(reason) settle the in-flight prompt as cancelled; resolve any pending permission as cancelled exactly once

The permission gate is the first real consumer of the tools/execute veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in docs/architecture.md). It is a single global listener registered with prepend: true so it runs before any other tool wrapper. ToolExecution.agent is optional and the Agent interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a WeakMap<Agent, sessionId> and the gate no-ops (calls next() immediately) for any exec.agent it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues session/request_permission, and stores the pending resolver on that session's record so the outcome — or a session/cancel/connection-close — settles it exactly once.

Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via ctx.effect/ctx.on; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, agent.abort(), and wait for the agent to settle. The disposal-settle signal must come from the dsh-agent interface, not the loop: agent.done exists only on the concrete LoopAgent, so the bridge instead observes agent/status reaching idle/disposed (or the RFC lifts a quiescence promise onto the Agent interface). Every listener contains its send() exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn.

Dependency note (architecture rule). docs/architecture.md states "plugins depend on interface packages, never on dsh-agent-loop." Creating and resuming agents is currently only on the concrete AgentLoop (ctx.agentLoop), so this RFC proposes adding an abstract create/resume factory to the dsh-agent interface (registry-level create({ sessionId, meta }) / resume(...)), implemented by the loop, so dsh-acp injects only agents (the interface) and the dependency rule holds. The alternative — injecting the concrete agentLoop and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback.

Plan

  1. Package scaffold packages/acp/ per the cookbook; add @agentclientprotocol/sdk and zod. Add the abstract create/resume factory to dsh-agent (the interface) so the bridge can inject: ['agents', 'sessions', 'tools', 'sessionPersistence'] without depending on the concrete loop; sessionPersistence is required because session/load advertises loadSession: true. (Fallback only if the factory is judged not worth it: inject agentLoop directly and record the architecture-rule exception in docs/architecture.md.)
  2. Connection plus initialize/session/new: wire AgentSideConnection to stdin/stdout; protocolVersion negotiation; the single-session guard; create the live session through the new { sessionId, meta } factory seam (so the ACP sessionId and validated cwd become the session's id and header); the sessionId↔agent and Session↔sessionId maps.
  3. Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend TurnEndReasonMap in the proper places: (a) declaration-merge a max-tokens variant in the owning package (packages/session/src/types.ts, alongside completed|aborted|error|disposed) — add max-tokens because FinishReasonMap produces it (DeepSeek maps lengthmax-tokens); do not add refusal, since no current adapter produces it (unknown DeepSeek finish reasons collapse to error), but leave a comment in TurnEndReasonMap noting refusal should be added when an adapter first emits it (FinishReasonMap is merge-extensible); (b) make agent-loop's loop.ts populate the reason from the model finish chunk — assembler.finish lives inside runStep, so runStep must return it up to runTurn, and the rule is "the last step's finish reason wins, but any max-tokens in the turn surfaces as max-tokens"; (c) no consumer exhaustively switches over TurnEndReason today (the invariants plugin switches on SessionEventType, and deriveMessages ignores turn/end), so adding max-tokens is a non-breaking extension — but recheck before landing; (d) update docs/architecture.md (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (dsh-session, dsh-agent, dsh-agent-loop) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract.
  4. Prompt-turn streaming plus load: translate agent/stream-chunk and session/event into session/update; resolve session/prompt on settle, mapping the harness TurnEndReason to the ACP StopReason wire enum (completedend_turn, max-tokensmax_tokens, abortedcancelled) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknown stopReason. Concrete correlation, since the loop batches queued messages into one turn and send() does not synchronously flip to running: install listeners before send(); gate on an observed agent/turn-start (confirms work was accepted) then resolve on the next agent/turn-end; reject an empty/whitespace prompt up front rather than calling send() (no turn would ever start, so the RPC would hang). Implement session/load on RFC 009's resume seam.
  5. Permission gate: a single tools/execute listener registered with prepend: true, owning a WeakMap<Agent, sessionId> of bridge-created agents; no-op (next()) for unowned/no-agent calls; for owned calls → session/request_permission → allow (next()) / veto; settle the stored resolver exactly once on outcome, cancel, or connection close.
  6. Example wiring (extract a shared base). @cordisjs/plugin-include is itself a plugin entry that resets ctx.baseUrl and loads a path, so a child cordis.yml can nest-include a shared base; the extraction is safe because every dependent plugin declares inject (loader groups initialize via Promise.all, so YAML order is NOT the dependency mechanism — never rely on it). Extract the provider/tool core (llm, sessions, system-prompt, tools, agents, invariants, llm-deepseek, bash-local, tool-bash) into examples/base.yml; have both coding-agent and a new examples/acp-agent/ include it and add their own UI plugin plus logger. Keep agent-loop per-example (NOT in the base): AgentLoop creates its configured agents in its constructor, and the two examples disagree — coding-agent needs a pre-created main (its stdio-chat calls ctx.agents.get('main')), while acp-agent must pre-create none (ACP session/new creates agents). So coding-agent declares agent-loop with agents: [{ id: main, … }] and acp-agent with agents: []. acp-agent loads dsh-session-persistence-jsonl (from RFC 009 — required for session/load), omits the stdout logger (see Risks), and adds pnpm run demo:acp plus the Zed agent_servers snippet.
  7. Tests (the repo cares a lot here): a property-based test for the protocol shape (precedent: RFC 001 / ADR 0013) — fuzz arbitrary harness event sequences and assert ACP-stream invariants (never a tool_call_update before its tool_call; exactly one session/prompt resolution per prompt; monotonic, well-formed ordering; stopReason in the legal set); codec unit tests over an in-memory Duplex pair (drive AgentSideConnection without a subprocess; assert exact frames for initialize, session/new, a full prompt turn); the mandatory HMR-safety test (dispose the fiber; assert the connection closed, all ctx.on listeners gone, any in-flight request_permission settled); failure-path tests (connection closes mid-stream; closes with a permission pending; a notification send() rejects but the turn survives; finish{kind:'error'|'aborted'}; a tools/execute throw with no tool/result; a second session/new rejected; a session/prompt while one is in flight; an empty prompt rejected without hanging; a session/load re-derives identical history and replays it); and an e2e (*.e2e.ts, self-skips without DEEPSEEK_API_KEY) that boots examples/acp-agent, connects a ClientSideConnection, sends a real prompt, owns and disposes the harness in afterEach, and verifies the world (files on disk), not the agent's self-report.
  8. Docs: module/JSDoc plus a package README; extend the extension cookbook with the client-driver pattern. Flip Status to implemented on landing; write an ADR only if a decision proves durable, contested, and surprising (candidates: the tools/execute permission-ownership rule, the npm-dependency choice) — not auto-required.

Deferred (each names its owning future work):

  • Multiplexing concurrent sessions → RFC 011.
  • cwd honoring. There is no current path from session/new.cwd to the bash workdir (AgentLoop.create takes only AgentOptions; tool-bash forwards only an explicit args.workdir; LocalBashExecutor.resolve defaults to its own config or process.cwd()). The MVP validates cwd (require absolute) and requires the server to be launched in the workspace root, erroring on a mismatch rather than silently running tools in the wrong directory; honoring an arbitrary cwd later means extending the agent-creation seam to carry a workdir.
  • Client terminal/* proxying (a live editor terminal) and fs/* (editor-rendered diffs) — a future BashExecutor over the ADR 0009 bash seam, gated on clientCapabilities.terminal.
  • Image/audio prompts (blocked on the DeepSeek adapter, which skips image blocks today), modes, auth, available_commands/slash-commands, plan, and usage_update.

Risks

stdout is the protocol — guaranteed by config, not by monkey-patching. The console logger writes through console.log to stdout, so any stdout UI/logger plugin corrupts JSON-RPC. The guarantee is config-only: the acp-agent example loads no stdout plugin (no console logger, no stdio-chat) and, if logging is wanted, uses a stderr exporter. A defensive process-wide process.stdout.write/console.log hijack inside dsh-acp is explicitly rejected — it lives outside Cordis' effect-scoped, HMR-friendly plugin model, races the connection's own stdout handoff, and fights the logger. A test asserts the example emits only framed JSON-RPC on stdout.

New third-party runtime dependency plus protocol drift: @agentclientprotocol/sdk is young (0.25.x, recently renamed) and evolving. Pin the version and isolate churn to the one bridge package. This is not a vendoring-policy violation — ADR 0001 vendors the framework; genuine third-party deps already live on npm (@earendil-works/pi-ai).

Turn-settle and prompt-correlation hazards: honor "queued messages batch into one turn" and "send() does not synchronously flip to running" (see stdio-chat.ts and the defensive-patterns section of docs/architecture.md); gate resolution on an observed running→idle transition and handle the empty-prompt / no-work branch so an RPC can't hang.

Permission-await and disposal hangs: a pending request_permission whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence (observe the interface-level settle signal — agent/status reaching idle/disposed, since agent.done is LoopAgent-only), not orphan awaits on a closed pipe.

The 100% per-file coverage gate (repo policy) makes a branch-heavy protocol bridge real work. Accepted deliberately, surfaced so it isn't a surprise at PR time.

ACP protocol-shape details (exact method names, session/update variants, permission option kinds, stop reasons) are taken from the ACP spec and the @agentclientprotocol/sdk types; they are not independently verifiable until the dependency is added, so the implementation pins the SDK version and conforms to its types rather than to this RFC's prose where they differ.