docs: add RFCs 009-011 (session persistence + ACP support)

Three proposal documents, numbered in dependency order:

- RFC 009: an abstract, append-only, event-based SessionPersistence
  service over the existing SessionEvent log (no parallel persisted
  type), a JSONL impl, a SessionMeta header seam, and an async
  AgentLoop.resume path. Design informed by Codex/Claude Code/
  opencode/pi. Core design point; unblocks resume + ACP session/load.
- RFC 010: ACP (Agent Client Protocol) support as a dsh-acp
  client-driver plugin on @agentclientprotocol/sdk, mapping ACP onto
  the agent/* events and the tools/execute permission seam. Builds on
  009 for session/load; single active session.
- RFC 011: multiplex concurrent ACP sessions over one connection
  (bridge-layer change; downstream of 010).
This commit is contained in:
Tianyi Cui
2026-06-14 21:14:51 +08:00
parent 87608f3c32
commit a44f7f3486
4 changed files with 165 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
# 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](../adr/0009-capability-seams.md) 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) — zero runtime dependencies, Apache-2.0, actively versioned. 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}` → `{sessionId}` | `ctx.agentLoop.create` | agent generates `sessionId`; 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) |
| `session/load {sessionId, cwd, mcpServers}` | RFC 009's async `AgentLoop.resume(agentId, sessionId)` | 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 |
| `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) | turn-end carries the real reason including `max-tokens`; 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](../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 await `agent.done`. 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.
## Plan
1. Package scaffold `packages/acp/` per [the cookbook](../cookbook/adding-a-package.md); add `@agentclientprotocol/sdk`; `inject: ['agents', 'agentLoop', 'sessions', 'tools', 'sessionPersistence']` (the last is required because `session/load` advertises `loadSession: true`).
2. Connection plus `initialize`/`session/new`: wire `AgentSideConnection` to stdin/stdout; protocolVersion negotiation; the single-session guard; 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 `length` → `max-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](../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. 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` with the authoritative `stopReason`; 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 `AgentLoop.resume`.
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 `yarn 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](../adr/0013-property-based-testing.md)) — 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](../cookbook/extension-cookbook.md) 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](../adr/0009-capability-seams.md) 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](../adr/0001-vendor-cordis-as-source.md) 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](../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 (await `agent.done`), 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.