ACP (Agent Client Protocol) model source — issue #96, ADR 0031, proposal
openspec/changes/add-acp-model-source.
Connects to a running agent (devin acp, Gemini CLI, Zed's agents, ...) over
a child process's stdin/stdout, speaking JSON-RPC 2.0, one object per line —
the SAME framing MCP local stdio already uses (SPEC §2), so this module
reuses Toolnexus.Mcp.Transport.Stdio to spawn the child rather than
hand-rolling a second port-spawning path.
A warm session (ADR 0031 — "the warm session is the feature"): the
process is spawned once via connect/2, initialize and session/new
happen once, and every subsequent turn is one session/prompt against that
same session. Concretely:
- Only
agent_message_chunksession/updatenotifications are accumulated into the reply —agent_thought_chunkand tool-call narration are dropped, or they wrap prose around structured output and break JSON parsing outright. - Responses to OUR calls and
session/updatenotifications interleave on the same stream, so replies are demultiplexed by JSON-RPC id. session/request_permissionis answered INLINE, from the read loop, with the firstallow-kind option, the instant it arrives — never exposed to the caller and never awaited. An unanswered permission request hangs the turn forever (even in bypass mode); answering immediately is what this module is FOR.- Turns are serialised: one ACP session is one conversation, so a second
concurrent
generate/1call is queued rather than interleaved into the same transcript. - The child process's lifetime is independent of any one turn — a failed
or errored turn does not kill the session — and
close/1is idempotent. - Every turn sends the FULL assembled request (every message, every
turn — matching every other toolnexus model source, which is
stateless by default) PLUS an explicit supersedes marker built by this
library (not left to the caller), naming the current turn's content —
the mitigation
spikes/acp/SPIKE.mdgate 1 proved against a stateful session that otherwise answers a near-duplicate, stale prompt.
generate/1 returns the exact (map() -> map()) shape
Toolnexus.Client.create_in_process/1 and Toolnexus.Agents.Runtime's
:in_process option already accept, so the tool-calling loop, skills, MCP
and sub-agents are completely unmodified — ACP is a model source, not a new
tool source and not a new client. See spikes/acp/SPIKE.md for the
reference Go client this ports, and docs/adr/0031-acp-a-warm-session-is-the-feature.md.
Summary
Functions
Returns a specification to start this module under a supervisor.
Close the session. Idempotent — safe to call more than once.
Spawn command (a list, e.g. ["devin", "acp"]) and complete the ACP
handshake: initialize then session/new.
Build a semantic generate function — (map() -> map()) — backed by this
warm ACP session, in the exact shape Toolnexus.Client.create_in_process/1
and Toolnexus.Agents.Runtime's :in_process option accept.
Send one turn's FULL prompt text and wait for the accumulated
agent_message_chunk reply. Turns on the same session are serialised.
Exposed as the lower-level primitive generate/1 builds on; most callers
should use generate/1.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec close(pid()) :: :ok
Close the session. Idempotent — safe to call more than once.
Spawn command (a list, e.g. ["devin", "acp"]) and complete the ACP
handshake: initialize then session/new.
Options:
:cwd— absolute working directory advertised to the agent. Defaults toFile.cwd!(). A realdevin acprejectssession/newwith-32602 Invalid paramson a relative or missingcwd.:mcp_servers— themcpServersarray onsession/new(default[]). Realdevin acprequires the key to be present, even empty.:environment/:env— extra env vars for the child.:timeout— per-RPC-call timeout in ms (default 30000), coveringinitializeandsession/newduring connect, and eachsession/promptreply.
Returns {:ok, pid} or {:error, reason}.
Build a semantic generate function — (map() -> map()) — backed by this
warm ACP session, in the exact shape Toolnexus.Client.create_in_process/1
and Toolnexus.Agents.Runtime's :in_process option accept.
{:ok, acp} = Toolnexus.Acp.connect(["devin", "acp"])
client = Toolnexus.Client.create_in_process(model: "devin", generate: Toolnexus.Acp.generate(acp))Raises on an ACP-level failure (a closed session, a timed-out call, an RPC
error) — the same failure shape any other generate function raising
surfaces to the caller.
Send one turn's FULL prompt text and wait for the accumulated
agent_message_chunk reply. Turns on the same session are serialised.
Exposed as the lower-level primitive generate/1 builds on; most callers
should use generate/1.