The ACP Automation Server
The one-liner:
@deepseek-ai/dsh-acpimplements an automation-only Agent Client Protocol server overstdin/stdout(JSON-RPC stdio). Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, answer one-shot permission requests by policy (no dialogs), and cancel in-flight work; on disconnect the whole owned session tree is drained + disposed with no orphan agents left behind.
The Agent Client Protocol (ACP) is the interoperability transport for "a programmatic client drives an agent". DSH's ACP server is not a presentation layer, nor a human-interaction layer: it does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Those interactive rendering and human-question features belong to the Web host and client modules. The primary in-repository client is subagent/subagent-acp (which implements the subagent provider interface).
Plugin wiring
apply(ctx, config) opens an AgentSideConnection on stdin/stdout and drives ctx.agents (packages/acp/acp/src/index.ts). Stdout is reserved for protocol frames.
| Config key | Default | Meaning |
|---|---|---|
provider | — | Initial provider route for every created agent |
model | — | Initial model for every created agent |
Both fields are optional in the schema (Schema.object({ provider: Schema.string(), model: Schema.string() })), so another agent/request listener may supply the target; but the runnable ACP composition requires both — in examples/acp-demo they are required.
Protocol contract
| Method | Behavior |
|---|---|
initialize | Negotiates the supported version and advertises baseline-only prompt capabilities (image/audio/embeddedContext all false); no session, editor, terminal, filesystem, or MCP capability is advertised |
authenticate | No-op (the server advertises no authentication methods) |
session/new | Creates a fresh agent with an absolute primary cwd; empty additionalDirectories / mcpServers are accepted, non-empty values reject |
session/prompt | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty / beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle; normal quiescence reports end_turn, explicit ACP cancellation / disposal / a discarded prompt slot reports cancelled |
session/cancel | Cancels only the addressed agent and settles its pending prompt as cancelled; unknown ids are no-ops |
session/update | Emits one agent_message_chunk per non-empty text block in a committed assistant/message; raw deltas and non-message events are omitted |
session/request_permission | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id; clients may answer automatically |
One connection may own several sessions. The bridge keys records by branded session id and checks exact agent identity before routing events or permission requests. Each session has an independent prompt slot, workspace, cancellation path, and disposer.
The initialize response (src/index.ts):
return Promise.resolve({
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
promptCapabilities: { image: false, audio: false, embeddedContext: false },
},
authMethods: [],
})
session/prompt waits for the whole agent to go idle; the settlement logic lives in src/index.ts:
void record.agent.whenIdle().then(() => {
if (record.inflight !== inflight) return
record.inflight = undefined
const end = inflight.endReason
if (end === undefined) {
inflight.resolve('cancelled') // turnless slot
} else {
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
}
})
Provider / model routing
When creating an agent, session/new builds per-agent route options from the plugin config (agentOptions() fills only configured fields):
function agentOptions(config: AcpConfig): { provider?: string; model?: string } {
return {
...config.provider !== undefined ? { provider: config.provider } : {},
...config.model !== undefined ? { model: config.model } : {},
}
}
Agents read model-facing rows from the host plane (no preset composition; a deployment that configures a roster must join one here first). The actual provider/model adapters come from the leaves of the ACP composition (e.g. the DeepSeek adapter mounted by examples/acp-agent/cordis.yml) — dsh-acp only forwards provider/model to ctx.agents.create; routing is decided by your composition.
Disconnect → drain + dispose, no orphans
Client disconnect and Cordis disposal share the same memoized teardown (quiesce(), src/index.ts). The order:
- Sets
closedand rejects new sessions and prompts; - Settles all pending prompts (as
cancelled) and cancels the bridge-owned top-level agents' work; - First drains the continuable subagent descendants under exactly these owned agents (child-first, via the subagent seam's
drainContinuableDescendants) — otherwise a descendant might still hold a runtime whose owner was already released, while other frontends sharing the same Context remain live; - Then disposes all top-level agent handles in parallel, awaiting every result before reporting any failure.
const subagents = ctx.get('subagents') as ContinuableDrain | undefined
if (subagents !== undefined) {
await subagents.drainContinuableDescendants(records.map(record => record.agent))
}
const disposals = await Promise.allSettled(records.map(record => record.dispose()))
Other frontends sharing that Context retain their own continuable forests and admission. So an ACP-only plugin reload leaves no orphan agent. Lifetime is connection-owned — one connection releases all its sessions; per-session close is not implemented.
Output trade-off: committed text only
The ACP session/update loop emits only committed assistant text: each non-empty text block in an assistant/message event becomes an agent_message_chunk, images render as an [image attachment …] text placeholder, and reasoning/tool activity/plans/retry markers stay in the session log for observability through other interfaces (ctx.on('session/event', …) in src/index.ts). This is a deliberate trade of token-by-token latency for a clean automation result — uncommitted provider chunks and retry attempts can never leak partial text.
turnEndToStopReason (codec.ts) maps turn endings to ACP's terminal vocabulary: completed → end_turn, max-tokens → max_tokens, interrupted → cancelled, and other ordinary quiescence → end_turn. ACP requires every prompt response to carry a stopReason, but the bridge does not claim a prompt-specific turn outcome; token-limit turn endings settle as end_turn, while a model error on the correlated turn rejects the prompt immediately.
Permissions: policy-driven allow / reject, no dialogs
Approvals flow through the approval/request event → request_permission. The bridge offers one-shot options for requests carrying a callId (tool call id) and never infers a durable grant from an unknown client response (src/index.ts):
return conn.requestPermission({
sessionId: record.agent.session.id,
toolCall: { toolCallId: request.callId },
options: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
],
}).then(({ outcome }) => {
if (outcome.outcome === 'cancelled') return 'cancelled'
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
})
Running and the composable demo
The repo ships an examples/acp-demo app (dsh-acp-demo bin): the default agent spine + client-created agents through @deepseek-ai/dsh-acp + JSONL persistence + semantic checkpointing behind one newline JSON-RPC stdio bin. It mounts no commands, user interaction, session navigation, configuration pickers, or stdout logger.
pnpm --dir /path/to/deepseek-harness run demo:acp # boot the repo's automation-server composition
dsh-acp-demo -c ./cordis.yml # or explicitly load your own composition
A session/new cwd must be an absolute path; non-empty additionalDirectories and non-empty mcpServers both reject — only one workspace, baseline prompts, and fresh sessions are supported. Resource links flatten to textual references rather than being fetched.
Verify / try it
Use any ACP client to push frames into dsh-acp-demo's stdin. Here is a minimal orchestration (initialize → session/new → session/prompt → receive update → answer a permission → cancel):
# 1) Start the server (diagnostics to stderr; stdout is the ACP wire)
pnpm --dir /path/to/deepseek-harness run demo:acp
# 2) Handshake: stdout shows one line of JSON per frame
--> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
<-- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":...,"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}}}} # baseline only
# 3) Create a session — no non-empty directories/MCP at the same time, and cwd must be absolute
printf '%s\n' \
'{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"'"$PWD"'","additionalDirectories":[],"mcpServers":[]}}'
# The reply carries a sessionId; then session/prompt sends text and session/cancel stops it.
Assertions you can test:
session/newwith a non-absolute cwd →invalidParams; non-empty additional directories / MCP →invalidParams.- A second
session/promptwhile one is in flight →"a prompt is already in flight for this session". - Sending an image/audio/embedded block →
"only text and resource_link prompt content is supported". - Dropping the connection (close stdin / Ctrl-D) → the bridge drains + disposes every agent owned by that connection to quiescence, and the process exits with no orphans.
To wire "APC in a subprocess" as a subagent provider, see the
acpprovider in Subagents and Parallelism.
Known limits
- Fresh sessions only: load / list / resume / delete / fork are unsupported.
- Baseline prompts + one workspace: images/audio/embedded resources/non-empty additional directories/MCP servers reject; resource links flatten to textual references.
- Committed answers only: live progress, reasoning, tool activity, plans, titles, and usage stay off the wire.
- Connection-owned lifetime: one connection releases all its sessions; per-session close is not implemented.
- JSONL persistence is fixed; sibling plugins can corrupt stdout (the app cannot prevent another entry from writing non-protocol bytes).
Next steps
- To see ACP used as a client in the subagent scenario, see Subagents and Parallelism.
- The full property and limitation references live in the source:
packages/acp/acp/README{.zh}.mdandpackages/examples/acp-demo/README{.zh}.md.