Toolnexus.Acp (toolnexus v0.20.0)

Copy Markdown View Source

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_chunk session/update notifications are accumulated into the reply — agent_thought_chunk and tool-call narration are dropped, or they wrap prose around structured output and break JSON parsing outright.
  • Responses to OUR calls and session/update notifications interleave on the same stream, so replies are demultiplexed by JSON-RPC id.
  • session/request_permission is answered INLINE, from the read loop, with the first allow-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/1 call 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/1 is 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.md gate 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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

close(pid)

@spec close(pid()) :: :ok

Close the session. Idempotent — safe to call more than once.

connect(command, opts \\ [])

@spec connect(
  [String.t()],
  keyword()
) :: {:ok, pid()} | {:error, term()}

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 to File.cwd!(). A real devin acp rejects session/new with -32602 Invalid params on a relative or missing cwd.
  • :mcp_servers — the mcpServers array on session/new (default []). Real devin acp requires the key to be present, even empty.
  • :environment / :env — extra env vars for the child.
  • :timeout — per-RPC-call timeout in ms (default 30000), covering initialize and session/new during connect, and each session/prompt reply.

Returns {:ok, pid} or {:error, reason}.

generate(pid)

@spec generate(pid()) :: (map() -> map())

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.

prompt(pid, text, timeout \\ :infinity)

@spec prompt(pid(), String.t(), timeout()) :: {:ok, String.t()} | {:error, term()}

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.