Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ playwright-report/

# tanstack sandbox projected state
.tanstack-projected-*
spikes/out/
75 changes: 62 additions & 13 deletions bun.lock

Large diffs are not rendered by default.

232 changes: 232 additions & 0 deletions docs/adr/0013-agents-over-acp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# 0013. Agents over ACP — one adapter for opencode and Claude Code

## Status

Proposed, 2026-10-02. Based on the spike in finding 13 (`docs/findings/13-acp-agents.md`),
which ran a two-step workflow through the real runtime on both agents. The approach comes from
the sibling project canvas, where it runs in production use (canvas findings 01, 04, 06, 12).

Supersedes ADR 0007 (per-run free port). Amends ADR 0012: §3 (workspace preparation) loses its
only job and §6 (sandbox) is overtaken. §1, §2, §4 and §5 stand. Closes #39 and #40 once
implemented.

## Context

Factory drives exactly one agent, opencode, through `@tanstack/ai-opencode`: `chat()` boots
`opencode serve` inside a `localProcessSandbox` on a free host port, and the adapter talks to it
over HTTP. ADR 0012 made the adapter a seam so a second agent would be "a normal amount of work",
but the opencode adapter has costs that a second adapter would not fix:

- **#24's permission deadlock** is patched by writing `opencode.json` into every run tree, so
the workspace carries an agent-specific file.
- **ADR 0007's port race** is patched with a bind-then-close probe.
- **Cancel goes through a long chain**: abort → HTTP `session.abort()` → generator `finally` →
process kill. `agent-step.ts` needs a paragraph and `abortableIterable` to get through it.
- **The model is a provider-qualified constant** (`DEFAULT_MODEL`) with no config level (#40).
- **`permissionMode` is read by nobody** (#39).

The Agent Client Protocol (ACP) is the open, stdio JSON-RPC protocol coding agents speak to
editors. opencode speaks it natively (`opencode acp`). Claude Code speaks it through Anthropic's
`@agentclientprotocol/claude-agent-acp`. canvas drives both through one generic client.
Finding 13 showed that the same client, behind factory's existing `AgentAdapter` interface:

- runs both agents end to end;
- selects the model per step;
- gets structured output on the first parse;
- answers permission asks without writing a file;
- cancels in 2–3 ms with no process left behind.

The finding also surfaced three things this ADR has to decide:

- **Model ids belong to the agent.** opencode's big-pickle is `opencode/big-pickle`; Claude's
are `sonnet`, `opus`, `claude-sonnet-5`, …
- **An agent left on its own default runs on the host's configuration.** Claude started on the
`haiku` from `~/.claude/settings.json`.
- **Usage means different things per agent.** opencode reports the last message, Claude the
whole turn. Both report context in use, and Claude reports cost.

## Decision

### 1. One ACP adapter replaces the opencode adapter

`runtime/acp-adapter.ts` speaks ACP with `@agentclientprotocol/sdk` directly, as canvas's
`agents.ts` does. It does **not** use `chat()` with `@tanstack/ai-acp`'s `acpCompatible`: that
adapter discards `session/new`'s config options and cannot call `session/set_config_option`,
which is the only way to choose a model in either agent (canvas finding 04). Updates become
AG-UI chunks through `@tanstack/ai-acp`'s `translateAcpStream`, so ADR 0003 §2 and ADR 0012 §1
hold unchanged: chunks stay opaque AG-UI, and the SPA keeps folding them with `StreamProcessor`.

The adapter is generic. An agent is a **definition**, and every agent-specific detail lives in
it:

- the launch command;
- the environment;
- the `_meta` sent with `session/new`;
- how host settings are excluded (§3).

Factory ships two definitions, `claude` and `opencode`. A third ACP agent is a third definition.
`@tanstack/ai-opencode`, `@tanstack/ai-sandbox` and `-local-process` are removed, together with
the opencode adapter. The opencode adapter is not kept as an alternative: one implementation,
per ADR 0012 §6's own rule.

### 2. Agent and model are separate fields, and factory always sends the model

The authoring surface gains `agent` next to `model`, at every level of the existing precedence
chain:

```ts
ctx.agent("implement", prompt, { agent: "claude", model: "sonnet" }); // call
defineWorkflow("x", { agent: { agent: "opencode", model: "opencode/big-pickle" }, … });
defineSchedule({ …, agent: { agent: "claude" } }); // schedule
defineConfig({ agent: { default: "claude", models: { claude: "sonnet", opencode: "opencode/big-pickle" } } });
```

**Resolution:**

- **Agent:** the most specific level that names one, else `config.agent.default`.
- **Model:** the most specific level that names one, counting only the level that chose the
agent and the levels more specific than it. If none of those names a model, it is
`config.agent.models[agent]`.

In short, a model never carries across a change of agent. A workflow pinned to `sonnet` and
called once with `{ agent: "opencode" }` runs on opencode's configured default model, not on an
invalid `sonnet`.

**Factory always sends a model.** It never leaves a session on the agent's own default, because
that default comes from the host's settings (finding 13 §1). For the same reason it is not
enough to switch the host's settings off (§3): claude-agent-acp reads the user's model setting
regardless. `DEFAULT_MODEL` is deleted. `factory init` writes `agent.default` and
`agent.models`. An unresolvable model fails the run at start.

**A model the agent does not offer fails the step before the prompt is sent.** The error names
the agent and lists some of the models it does offer (finding 13 §1). Model ids are passed
verbatim; factory keeps no catalogue of them.

`AgentStepStarted` records `agent` beside `model`. The field is optional: logs written before
this change have no `agent`.

### 3. Runs ignore the host's agent settings by default

A run reads the **project's** agent configuration and not the operator's: the repository's
`CLAUDE.md` / `AGENTS.md`, `.claude/settings.json` and `opencode.json` apply, while `~/.claude`
and `~/.config/opencode` do not. A run should not depend on whose machine it runs on.

Credentials are not settings: the host's Claude login and opencode's provider auth stay
available.

This is configurable per agent in `factory.config.ts`:

```ts
agent: { hostSettings: { claude: "ignore" | "include", opencode: "ignore" | "include" } }
```

`"ignore"` is the default. `"include"` is the opt-in for an operator who wants their own skills,
plugins or providers in runs. The settings themselves stay in the agents' own files; factory
does not copy them into its config.

**Per agent:**

- **Claude:** `_meta.claudeCode.options.settingSources: ["project", "local"]` (the default is
`["user", "project", "local"]`). Known residue: claude-agent-acp's own settings reader still
applies the user's `model`, `permissions.defaultMode` and `availableModels` (finding 13
addendum). §2 overrides the model and §4 answers every ask. Only an `availableModels`
allowlist can still narrow what a run may pick, and §2's error message makes that visible.
- **opencode:** the global config directory must be hidden from the opencode process.
`XDG_CONFIG_HOME` pointed at an empty directory works: the global plugins and the custom
`omniroute` provider disappear, while the auth-based `opencode` and `opencode-go` providers
remain. But the variable is inherited by every tool the agent runs, which would hide
`~/.config/gh` (gh's auth) and git's XDG config too. **The exact mechanism is left to the
implementation ticket**: an opencode-specific variable if one exists, otherwise a directory
that leaves those tools' configs in place.
- **Both:** the agent's environment is scrubbed of the parent's `CLAUDE_*` / `CLAUDECODE`
variables. A daemon started from inside a Claude Code session otherwise passes its
effort, subagent model and session ids down to the agents it runs.

### 4. Headless permission is the client's answer

The adapter answers every ACP `session/request_permission` with an allow option, preferring
`allow_always` over `allow_once`. This is #24's policy ("a run never waits on a question nobody
can answer") moved from a file opencode reads into a callback that covers every agent. The
following go away:

- `lib/sandbox-config.ts` (`writeHeadlessPermissions`);
- the `opencode.json` write;
- the `.git/info/exclude` entry;
- `AgentAdapter.prepareWorkspace` and `StartRunOptions.prepareWorkspace`.

Finding 13 confirmed that Claude's "allow always" leaves the tree untouched.

`permissionMode` (#39) is **removed** from `AgentCallOptions`. The ACP `mode` options differ per
agent (Claude: `default / acceptEdits / plan / auto / bypassPermissions`; opencode:
`build / plan`), and with every ask answered the remaining difference is behavioural, such as a
read-only plan mode. When a workflow needs that, it can arrive as an explicit `mode` passthrough
validated like the model. It is not added now.

### 5. Context and cost get their own fields

`AgentStepFinished` gains two optional fields, filled from ACP `usage_update` (the last one of the
step):

- `context: { used, size }` — the context in use and the window size, as the agent measured them;
- `cost: { amount, currency }` — what the agent says the step cost.

opencode reports `cost` as `0 USD` for a free model; it is recorded as reported.

`usage` stays the four token components from `PromptResponse.usage`. Its meaning is now "as the
agent reports it": the last message on opencode, the whole turn on Claude. The step's `agent`
(§2) says which, and its doc comment says so. Nothing should bill off `usage`; `cost` exists for
that. `agentStepContextTokens` gives way to `context.used` where present, and stays as the
fallback for logs written before this change.

`usage_update` is not an AG-UI event, so the adapter forwards it as a `CUSTOM` chunk (`acp.usage`)
carrying a new signal, `usage`, with `context` and `cost`. This is the ADR 0012 §2 rule working
as intended: the union grows because factory now has fields for these. The chunk also lets the
SPA show context filling up live.

### 6. One process per step, over stdio

Each `ctx.agent` call spawns the agent with `cwd` set to the run's tree and holds one session for
one prompt. The process ends with the step.

- **Startup:** about 1.5–2 s per step (finding 13 §6), accepted.
- **No session pool:** canvas keeps sessions open because people return to them; a step never
does.
- **Cancel:** sends ACP `session/cancel`, then kills the process after a 2 s grace and in
`finally`. Both agents settle the turn as `cancelled` in milliseconds.
- **Port race:** gone (ADR 0007), since stdio needs no port.
- **Sandbox:** the agent runs on the host in the tree, exactly as under `localProcessSandbox`.
A future container sandbox wraps the command (`docker exec -i …`), which stdio makes simpler
than published ports.

### 7. Structured output stays prompt-injected

The adapter appends `appendOutputSchemaInstruction` and parses the last assistant message, then
emits the `structuredOutput` signal. The runtime's tier-2 re-parse (ADR 0002 §3) is unchanged.
An MCP `submit_output` tool would give schema-validated output from both agents (canvas finding
06 shows the per-session MCP server). It is deferred until tier 1 is seen failing in practice.

## Consequences

- **Workflows can use Claude Code.** Choosing the agent is a field, and a project can set its
defaults in config. That was the point of the change.
- **The replay corpus must be re-recorded.** The `test/corpus` traces are `@tanstack/ai-opencode`
chunks (`opencode.session-id`, `sandbox.file`, opencode tool names), and the replay adapter
reads them with `extractOpencodeSignal`. New corpora come from the ACP adapter for both agents.
Old run logs stay readable, because chunks are opaque.
- **Transcripts show coarser tool names**: ACP tool kinds (`read`, `edit`, `execute`) instead of
opencode's own. The tool's title (`Edit math.ts`) is in the ACP update but does not reach
`TOOL_CALL_START`; the SPA may want to read it from the chunk's raw event.
- **`agent-step.ts` gets simpler.** The abort-chain commentary and the teardown grace were about
`@tanstack/ai`'s chat engine sitting on an aborted opencode stream. `abortableIterable` stays
until the replay and fake adapters are checked against the simpler contract.
- **New dependencies:** `@agentclientprotocol/sdk`, `@agentclientprotocol/claude-agent-acp`
(bundles the Claude Agent SDK) and `@tanstack/ai-acp`. `@tanstack/ai` moves to 0.64.
- **Claude runs need a Claude login (or API key) on the daemon's host**, and opencode runs need
`opencode` on `PATH`. The daemon checks the configured agents at start and names what is
missing, rather than failing the first step.
- **AGENTS.md's model advice changes**: the preferred test model is `opencode/big-pickle`.
- **Untested until the implementation:**
- an opencode ask outside the project (`external_directory`), now answered by the callback;
- an agent crashing mid-turn (the exit race is wired, never triggered);
- `RUN_ERROR` from an agent;
- whether Claude's `settingSources` also keeps the user's skills and `CLAUDE.md` out.
Loading
Loading