feat(acp): ACP bridge — drive the coding agent from an editor over JSON-RPC stdio
Implements the RFC 010 MVP: a new `@deepseek-ai/dsh-acp` package bridges the harness agent to the Agent Client Protocol (JSON-RPC 2.0 over newline-delimited stdio), so Zed and other ACP editors can drive the coding agent — streaming render, tool-call display, and resumable sessions via `session/load`. - packages/acp: AgentSideConnection wiring; initialize/newSession/loadSession/ prompt/cancel; a total TurnEndReason→StopReason codec; settle-once with a fallback chain (agent/turn-end → logged turn/end → idle); single-session guard; cwd-must-equal-launch-dir validation; load replays from the persisted event log (assistant/chunk→agent_message_chunk, tool/call/result→tool_call*). - agent: add Agent.whenIdle() quiescence signal to the interface; LoopAgent implements it (resolves on the first running→idle/disposed transition). The bridge awaits it on disposal so teardown reaches quiescence, not just abort. - examples: extract the shared provider/tool core into examples/base.yml; coding-agent nest-includes it; new examples/acp-agent serves the agent over ACP with JSONL persistence and no stdout logger (stdout is the protocol). - Permission gate deferred (TODO(rfc010-permission-gate)): tools run with the executor's full authority; only the Agent→sessionId ownership seam is laid down. Cancel is best-effort for a not-yet-started queued turn (TODO(rfc010-cancel-prestep)). RFC 010 stays `proposed`. - Docs: package README + Zed snippet; client-driver cookbook section; root and packages layout/commands; RFC 010 implementation-status note. 48 bridge tests + whenIdle coverage; 100% per-file coverage; e2e boots the example as a subprocess and verifies a written file on disk (key-gated, with a no-key stdout-purity check).
This commit is contained in:
@@ -119,6 +119,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told
|
||||
- `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle
|
||||
- `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see ADR 0017).
|
||||
- `abort(reason)` — aborts the in-flight step via `AbortSignal`
|
||||
- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `abort()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent.
|
||||
- `session`, `status`, `options`
|
||||
|
||||
**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred.
|
||||
|
||||
@@ -53,6 +53,33 @@ export function apply(ctx: Context) {
|
||||
}
|
||||
```
|
||||
|
||||
## A client-driver plugin (external protocol bridge)
|
||||
|
||||
A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.abort()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and on disposal reach quiescence (`await agent.whenIdle()` after `abort()`), not just request it.
|
||||
|
||||
`packages/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = 'my-protocol-bridge'
|
||||
export const inject = ['agents', 'sessions', 'sessionPersistence']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// Stream every logged assistant text/reasoning delta out to the client.
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const chunk = event.data.chunk
|
||||
if (chunk.type === 'text-delta') {
|
||||
// sendToClient({ kind: 'message_chunk', text: chunk.text })
|
||||
}
|
||||
}
|
||||
})
|
||||
// Inbound "prompt": create/resume an agent and feed it; settle on turn end.
|
||||
// Disposal awaits quiescence: agent.abort() then await agent.whenIdle().
|
||||
}
|
||||
```
|
||||
|
||||
## Runnable wirings
|
||||
|
||||
Two complete examples load their plugin trees from `cordis.yml` with HMR: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `yarn demo:echo`) and [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing, `yarn demo:coding`).
|
||||
Three complete examples load their plugin trees from `cordis.yml` with HMR: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `yarn demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing, `yarn demo:coding`), and [`examples/acp-agent`](../../examples/acp-agent) (the same coding agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `yarn demo:acp`). The two real demos share their provider/tool core via [`examples/base.yml`](../../examples/base.yml).
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: proposed
|
||||
|
||||
> **Implementation status (MVP landed):** steps 1, 2, 3, 4, 6, 7, 8 are implemented in `packages/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. One further best-effort limitation is tracked as `TODO(rfc010-cancel-prestep)`: `session/cancel` aborts a running step and settles the RPC as `cancelled`, but a turn still queued (not yet started) when the cancel arrives may execute before the abort takes effect, pending a loop-level pre-step cancel.
|
||||
|
||||
## 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.
|
||||
|
||||
Reference in New Issue
Block a user