Files
deepseek-harness/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md
Tianyi Cui b84d4828a8 refactor(events): remove the agent/stream-chunk mirror of assistant/chunk
The loop recorded every model token delta as a durable `assistant/chunk`
session event AND emitted an identical live `agent/stream-chunk` Cordis event
one line later. Same StreamChunk, same turn/step; the emit added only the live
Agent handle, which the sole consumer discarded. This is the boundary-mirror
duplication the event-domain work removed for turn/step boundaries, applied to
the token stream — a follow-up the boundary RFC explicitly deferred.

The premise is settled: chunk persistence is authoritative (the proposal to
stop persisting chunks was rejected — replay/snapshots depend on it), so
`assistant/chunk` on `session/event` is the load-bearing token stream and
`agent/stream-chunk` is pure redundancy.

- Remove the `agent/stream-chunk` declaration + emit; drop the now-unused
  StreamChunk import from dsh-agent's types.
- Migrate `dsh-ui-stdio` (the only live consumer; ACP already reads
  assistant/chunk off session/event) to render assistant/chunk in its existing
  session/event listener. Consolidating to one listener also makes the
  inReasoning dim-SGR flag deterministic across chunk/boundary events (they no
  longer race across two listeners).
- Repoint the agent-loop tests (cancel/loop) and ui-stdio tests to the
  session/event assistant/chunk feed.
- New RFC (implemented/simplification/2026-07-02-remove-stream-chunk-mirror);
  amend the boundary RFC's retained-list entry to cross-link; update
  architecture, cookbook, event-domain-semantics, the ACP proposal, and the
  regenerated cordis catalog.

Snapshot goldens unchanged (ACP never used the mirror), confirming no
editor-facing transcript change.
2026-07-02 23:42:16 +08:00

20 KiB

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

Status: proposed

Implementation status (MVP landed): steps 1, 2, 3, 4, 6, 7, 8 are implemented in packages/ui/acp + examples/acp-agent. Step 5 (the session/request_permission permission gate) is deferred — the bridge ships a pass-through (tools run with the executor's full authority) marked TODO(rfc010-permission-gate), and lays down only the WeakMap<Agent, sessionId> ownership seam the gate will build on. Status stays proposed until the gate lands. session/cancel is the queue-aware agent.cancel(): it aborts a running step, clears queued + steering work, and drops a turn that is about to start, so a queued-but-not-yet-started prompt never runs and a later prompt cannot be batched into the cancelled turn. Per-session cwd is now honored (lifting the original "launch the server in the workspace root" restriction — see § Deferred): session/new accepts any absolute cwd, and session/load requires the request cwd to match the persisted session cwd so the editor and bash executor agree on the workspace.

Problem

The coding agent is reachable only through the readline stdio-chat plugin: it reads lines from stdin, calls agent.send(), and prints the assistant token stream (session/event assistant/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 session persistence: 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 ACP must land after, or in the same change as, session persistence, and pins to its resume(agentId, resumeSessionId) contract. Session persistence 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 capability seams interface/implementation/consumer capability split; it consumes the existing agent/* event taxonomy and the tools/pre-execute/tools/post-execute waterfalls.

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/ui/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 ACP multi-session); cwd validated (require absolute) — any absolute cwd is honored: it becomes the session's SessionHeader.cwd and the default bash workdir (per-session cwd, see § Deferred → RESOLVED), so the server need not launch in the workspace; non-empty mcpServers and additionalDirectories are rejected for the MVP because silently ignoring requested servers/roots would desync the client's tool and filesystem-scope UI
session/load {sessionId, cwd, mcpServers, additionalDirectories} the dsh-agent resume factory (session persistence + 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; mcpServers and 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} the turn/end session/event (its reason) 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 session/event assistant/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 session/event assistant/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.cancel(reason) the queue-aware cancel (abort running step, clear queued + steering, drop an about-to-start turn); 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 must reach quiescence, not just request it — close the connection, settle/reject pending permissions, and dispose each owned agent through its AgentHandle.dispose() (which stops the loop, awaits its exit, and unregisters). Owner teardown goes through that handle seam, not the loop's concrete agent.done (which exists only on ReactLoopAgent); a non-owner that merely wants to observe the current work settling without tearing the agent down awaits the interface-level agent.whenIdle(). 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/ui/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/core/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 session/event (the assistant/chunk token stream plus boundaries and tool activity) 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 the session/event listener before send(); capture the prompt's owning turn from its turn/start record, then resolve on that turn's turn/end (with agent/status idle/disposed as a fallback); 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 the session-persistence 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 session persistence — 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: property-based testing) — 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; record a decision in this RFC only if it 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 → ACP multi-session.
  • cwd honoring. RESOLVED. Originally there was no path from session/new.cwd to the bash workdir (tool-bash forwarded only an explicit args.workdir; LocalBashExecutor.resolve defaulted to its own config or process.cwd()), so the MVP validated cwd (require absolute) AND required the server to launch in the workspace root, erroring on a mismatch. This is now lifted: the validated cwd is stored as SessionHeader.cwd, and dsh-tool-bash defaults the bash workdir to the calling agent's session.header.cwd (an explicit model workdir still wins; a relative one resolves against it). Any absolute cwd is honored — the server need not launch in the workspace, and N sessions can each target a different directory. Widening scope beyond the single cwd (additionalDirectories) remains deferred.
  • Client terminal/* proxying (a live editor terminal) and fs/* (editor-rendered diffs) — a future BashExecutor over the capability seams 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 — vendoring Cordis as source 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 — tear each owned agent down through AgentHandle.dispose() (which stops the loop and awaits its exit), rather than orphaning 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.