feat(agent): orchestrate external coding agents over ACP (#204) (#360)

* feat(agent): orchestrate external coding agents over ACP (#204)

Add an AgentBackend seam so an Agent can run on either the LLM AgentRunner
or an external agent. First backend: an ACP client that drives a coding CLI
(e.g. Claude Code via its ACP adapter) as a first-class team member — same
task DAG, shared memory, cascade, and token budget as LLM agents.

- Extract AgentBackend ({ run, stream }) that AgentRunner already implements;
  Agent.getBackend() branches on AgentConfig.backend.
- AcpBackend spawns the agent over stdio JSON-RPC and maps session updates to
  a RunResult (text, tool calls, usage); handles permissions and abort.
- ACP SDK as a lazy optional peer; new /acp subpath export.
- docs/external-agents.md, a hybrid-team example, and tests (in-process fake
  agent + subprocess integration).

Experimental: ACP is young; token accounting maps ACP's single context
figure and client-side fs proxying is not implemented yet.

* fix(agent): harden ACP backend after review (#204)

Follow-up fixes on the ACP external-agent backend (PR #360):

- Token accounting: usage_update.used is a cumulative context-size gauge, not a
  per-turn delta. Record each turn's increment so summing across a reused session
  telescopes to the latest figure instead of double-counting.
- systemPrompt: ACP has no system-prompt field, so prepend the agent's systemPrompt
  to the session's first turn (once). It was previously dropped and only fed
  coordinator routing.
- Abort: when the signal is already aborted before a turn starts, return empty
  output without prompting (no in-flight turn to cancel).
- Startup: clear the cached start() promise on failure so a later run retries
  instead of re-throwing the same rejection forever.
- Permissions: auto-approve now prefers least-privilege allow_once over a
  session-wide allow_always.

Docs (external-agents.md, CLAUDE.md) updated to match. ACP tests 10 to 15;
lint + full suite (1135) green.
This commit is contained in:
Jack Chen authored and GitHub committed 2026-07-10 23:26:40 +08:00
1 parent f0375eebea
commit ffb2905017
22 files changed
+1465 -34

No files matched your search

+4 -2
View File
@@ -46,7 +46,7 @@ The framework's key feature. The coordinator receives goal + roster → emits a
|-------|-------|----------------|
| Orchestrator | `orchestrator/orchestrator.ts`, `scheduler.ts` | Top-level API, task decomposition, retry/backoff, coordinator |
| Team | `team/team.ts`, `messaging.ts` | Agent roster, MessageBus (point-to-point + broadcast), SharedMemory binding |
| Agent | `agent/agent.ts`, `runner.ts`, `pool.ts`, `structured-output.ts`, `loop-detector.ts` | Lifecycle (idle→running→completed/error), conversation loop, concurrency pool + per-agent mutex, structured-output validation, loop detection |
| Agent | `agent/agent.ts`, `runner.ts`, `pool.ts`, `structured-output.ts`, `loop-detector.ts`, `acp-backend.ts` | Lifecycle (idle→running→completed/error), conversation loop, concurrency pool + per-agent mutex, structured-output validation, loop detection; `AgentBackend` seam so an `Agent` runs on either an LLM `AgentRunner` or an external ACP subprocess |
| Task | `task/queue.ts`, `task.ts` | Dependency-aware queue, auto-unblock on completion, cascade failure to dependents |
| Tool | `tool/framework.ts`, `executor.ts`, `mcp.ts`, `text-tool-extractor.ts`, `built-in/` | `defineTool()` + Zod, ToolRegistry, parallel batch exec, MCP bridge, local-model text tool-call fallback, filesystem sandbox |
| LLM | `llm/adapter.ts` + 12 per-provider files + `openai-common.ts` + `reasoning-fallback.ts` | `LLMAdapter` (`chat` + `stream`); lazy `createAdapter()` factory; `baseURL` for OpenAI-compatible servers; cross-provider reasoning round-tripping |
@@ -55,7 +55,7 @@ The framework's key feature. The coordinator receives goal + roster → emits a
| CLI | `cli/oma.ts` | Shell/CI entry; built to `dist/cli/oma.js`, exposed as the `oma` npm bin |
| Utils | `utils/*.ts` | Semaphore, token accounting, keyword helpers, trace plumbing, secret/PII redaction |
| Types / Errors | `types.ts`, `errors.ts` | All interfaces in one file (avoids circular deps); shared error types |
| Exports | `index.ts`, `mcp.ts`, `ai-sdk.ts` | Root + `/mcp` + `/ai-sdk` subpaths so optional peers don't break the main import |
| Exports | `index.ts`, `mcp.ts`, `ai-sdk.ts`, `acp.ts` | Root + `/mcp` + `/ai-sdk` + `/acp` subpaths so optional peers don't break the main import |
### Non-obvious invariants
@@ -67,6 +67,7 @@ Behavior that isn't visible from any single file and will cause bugs if missed:
- **Filesystem tools are sandboxed, `bash` is not** — `file_read/file_write/file_edit/grep/glob` resolve every path (symlinks included) within `AgentConfig.cwd` / `OrchestratorConfig.defaultCwd`, defaulting to `<cwd>/.agent-workspace`. `null` disables the sandbox; `process.cwd()` widens it. → [docs/tool-configuration.md](docs/tool-configuration.md)
- **Reasoning is dropped unless opted in** — provider-native `ReasoningBlock`s the target adapter can't echo are silently dropped unless `AgentConfig.preserveReasoningAsText` is on (then converted to inline `<thinking>` text). `<thinking>` text is never parsed back into a signed block. → [docs/context-management.md](docs/context-management.md)
- **Local-model tool-call fallback** — `text-tool-extractor.ts` only runs when the server emits no native `tool_calls` (Ollama/vLLM/LM Studio); native calls always win.
- **External (ACP) agents run their own loop** — `AgentConfig.backend = { kind: 'acp', … }` swaps the LLM `AgentRunner` for an `AcpBackend` (spawns a coding CLI over ACP). The runner's tool loop / sandbox / context strategy don't apply; OMA is the ACP *client*, the subprocess does its own file IO in `cwd` (no `fs/*` proxying yet), permission prompts default to auto-approve, `systemPrompt` is prepended to the session's first turn (ACP has no system-prompt field), and ACP's cumulative context-token figure is recorded as a per-turn delta into `tokenUsage.input_tokens` (no `usage_update` ⇒ not budget-gated). Everything downstream (`pool`/`scheduler`/`queue`/memory/budget) is backend-agnostic. → [docs/external-agents.md](docs/external-agents.md)
- **Secrets are auto-redacted** from traces, bash output, and dashboard payloads (`utils/redaction.ts`).
### Subsystem docs
@@ -82,6 +83,7 @@ Detailed behavior is documented in `docs/` — the single source of truth, so up
| Checkpoint/resume over `MemoryStore` | `memory/checkpoint.ts`, `orchestrator/orchestrator.ts` | [checkpoint.md](docs/checkpoint.md) |
| Tracing, progress events, dashboard | `utils/trace.ts`, `dashboard/` | [observability.md](docs/observability.md) |
| CLI usage + JSON schemas | `cli/oma.ts` | [cli.md](docs/cli.md) |
| External coding agents over ACP | `agent/acp-backend.ts` | [external-agents.md](docs/external-agents.md) |
### Adding an LLM Adapter
+181
View File
@@ -0,0 +1,181 @@
# External Agents (ACP)
OMA can orchestrate **external agents that run as local processes** alongside its
LLM-backed agents. The first supported kind is a coding agent driven over the
[Agent Client Protocol (ACP)](https://agentclientprotocol.com) — a JSON-RPC-over-stdio
standard implemented by Gemini CLI, Claude Code, Codex, and others.
An ACP agent is a first-class team member: it sits in the same task DAG, writes to
the same shared memory, cascades failure to its dependents, and counts against the
same token budget as any LLM agent. The motivating shape is a **hybrid team** — an
LLM planner decomposes the goal, an external coding agent writes the code, an LLM
reviewer audits the diff — all in one `runTeam` / `runTasks` call.
## Quick start
Declare an agent with `backend` instead of a model. Everything else about the team
is unchanged:
```typescript
import { OpenMultiAgent } from '@open-multi-agent/core'
const oma = new OpenMultiAgent({ defaultModel: 'claude-sonnet-4-6', defaultProvider: 'anthropic' })
const team = oma.createTeam('hybrid-dev', {
name: 'hybrid-dev',
agents: [
{ name: 'planner', systemPrompt: 'Break the task into a short plan. Do not write code.' },
{
name: 'coder',
systemPrompt: 'Writes and edits code by running an external coding CLI.',
backend: {
kind: 'acp',
command: 'npx',
args: ['-y', '@agentclientprotocol/claude-agent-acp'], // Claude Code over ACP
cwd: process.cwd(),
},
},
{ name: 'reviewer', systemPrompt: 'Review the change and summarize risks. Do not edit files.' },
],
sharedMemory: true,
})
const result = await oma.runTeam(team, 'Add a slugify() utility with tests, then review it.')
```
The coordinator routes the coding work to `coder` based on its roster description; the
ACP subprocess does the file edits; `reviewer` then reads the result from shared memory.
A runnable version is at
[`examples/integrations/external-agent-acp.ts`](../packages/core/examples/integrations/external-agent-acp.ts).
## Installation
ACP support requires the optional peer dependency, loaded lazily so it never affects
consumers that don't use it:
```bash
npm install @agentclientprotocol/sdk
```
You also need an ACP-speaking agent. Set the backend's `command` / `args` to launch
it — any ACP agent works. Common choices:
| Agent | `command` / `args` | Notes |
|-------|--------------------|-------|
| **Claude Code** | `npx -y @agentclientprotocol/claude-agent-acp` | Official [Claude Agent SDK adapter](https://github.com/agentclientprotocol/claude-agent-acp) (Claude Code has no native ACP). Auth via `ANTHROPIC_API_KEY`. |
| Gemini CLI | `gemini --acp` | Native ACP. Note: Google is reportedly retiring the free-tier Gemini CLI (and its `--experimental-acp` flag) — verify availability before relying on it. |
| Codex | `codex-acp` (or `codex --experimental-acp`) | Experimental ACP support. |
The example below uses Claude Code, which fits OMA's Anthropic-centric default and
needs only one key (`ANTHROPIC_API_KEY`) for the whole team.
## Configuration
`AgentConfig.backend` takes an `AgentBackendConfig` (a discriminated union; only
`kind: 'acp'` exists today):
| Field | Type | Default | Meaning |
|-------|------|---------|---------|
| `kind` | `'acp'` | — | Backend discriminant. |
| `command` | `string` | — | Executable to spawn (`'npx'`, `'gemini'`, …). |
| `args` | `string[]` | `[]` | Arguments passed to `command`. |
| `env` | `Record<string,string>` | — | Extra env vars, merged over `process.env`. |
| `cwd` | `string` | `process.cwd()` | Directory the agent reads and edits. |
| `permission` | `'auto-approve' \| 'reject' \| fn` | `'auto-approve'` | How to answer permission prompts (below). |
When `backend` is set, the LLM-specific fields (`model`, `provider`, `adapter`,
sampling, `tools`, context strategy) do not apply — the external agent runs its own
loop, and `model` becomes optional. The agent's `systemPrompt` is the exception: it
still shapes the external agent because OMA — lacking any ACP system-prompt field —
prepends it to the agent's first prompt (once per session), on top of seeding the
coordinator's routing as it does for every agent.
### Permissions
ACP agents ask the client to approve sensitive tool calls (editing a file, running a
command). Because OMA runs agents autonomously inside a DAG, the default is
`'auto-approve'` (it picks the least-privilege `allow_once` when offered, otherwise
`allow_always`). Tighten it as needed:
```typescript
backend: {
kind: 'acp',
command: 'npx',
args: ['-y', '@agentclientprotocol/claude-agent-acp'],
// Reject everything…
permission: 'reject',
// …or decide per request.
permission: (req) => req.kind !== 'delete' && !req.title.includes('rm -rf'),
}
```
The callback receives a minimal, SDK-agnostic `{ title, kind, optionKinds }` and returns
`true` to approve / `false` to reject.
> **Security.** Unlike OMA's filesystem-tool sandbox, an ACP agent accesses `cwd`
> directly — it is a local subprocess with your permissions. Scope `cwd` to a project
> you trust the agent with, and use `permission` to gate destructive actions.
## How it works
OMA takes the ACP **client** role. On the first run for an agent it spawns the
subprocess, frames its stdio as newline-delimited JSON-RPC, `initialize`s, and opens a
`session/new` in `cwd`. Each `agent.run(prompt)` then sends one `session/prompt` turn
and drains `session/update` notifications into a normal agent result:
| ACP update / stop | Maps to |
|-------------------|---------|
| `agent_message_chunk` (text) | streamed `text` deltas + the result's `output` |
| `tool_call` / `tool_call_update` | entries in `result.toolCalls` |
| `usage_update` (`used`) | `result.tokenUsage` (see caveat) |
| stop `end_turn` | success |
| stop `max_tokens` / `max_turn_requests` | success with `budgetExceeded` (stopped early) |
| stop `refusal` | task failure (cascades to dependents) |
| stop `cancelled` | returns partial output (from an abort) |
The seam is the `AgentBackend` interface (`run` + `stream`) that `AgentRunner` already
implements — so the pool, scheduler, task queue, shared memory, and budget accounting
treat an ACP agent exactly like an LLM agent, with no special cases.
### Token accounting caveat
ACP reports a single **context-token** figure (`usage_update.used` — "tokens
currently in context"), not an input/output split, and it is *cumulative* across a
session, not a per-turn delta. Because OMA reuses one session across an agent's
turns, it records each turn's usage as the **increment** since the previous reading
and stores it as `tokenUsage.input_tokens` (with `output_tokens: 0`) — so summing
across turns telescopes to the latest figure instead of double-counting. That total
aggregates into the run and honors `maxTokenBudget`. An agent that emits no
`usage_update` reports `{0, 0}` and is therefore **not** budget-gated — size the
budget on LLM agents, or bound the ACP agent with its own `--max-*` flags.
## Programmatic API
Most users only touch `backend`. To construct a backend directly, import from the
`@open-multi-agent/core/acp` subpath (this is where the optional peer is loaded):
```typescript
import { createAcpBackend } from '@open-multi-agent/core/acp'
const backend = createAcpBackend({ command: 'npx', args: ['-y', '@agentclientprotocol/claude-agent-acp'] })
const result = await backend.run([{ role: 'user', content: [{ type: 'text', text: 'refactor foo.ts' }] }])
await backend.dispose() // close the connection and kill the subprocess
```
## v1 scope
What this release does **not** do yet (open an issue with a real use case to pull any
of these forward):
- **Client role only.** OMA drives external agents; it does not expose OMA agents *as*
an ACP agent to editors.
- **No `fs/*` proxying.** The agent does its own filesystem access within `cwd`; OMA
does not yet proxy ACP file operations through its sandbox. Agents that require the
client to serve files are not supported.
- **ACP only.** Bespoke per-CLI adapters and non-coding business-system CLIs are out of
scope. ACP already covers the major coding agents.
- **No cost-based budgets.** Budgeting is token-based; `usage_update.cost` is ignored.
- **Subprocess lifetime.** An orchestrated ACP agent's subprocess lives until the
process exits (there is no per-agent disposal hook in `runTeam` / `runTasks`).
Use the programmatic API + `dispose()` when you need explicit teardown.
+15
View File
@@ -15,6 +15,16 @@
"node": ">=18.0.0"
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.2.1.tgz",
"integrity": "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==",
"dev": true,
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.6.tgz",
@@ -6467,6 +6477,7 @@
"oma": "dist/cli/oma.js"
},
"devDependencies": {
"@agentclientprotocol/sdk": "^1.2.1",
"@ai-sdk/openai": "^4.0.0",
"@ai-sdk/provider": "^4.0.0",
"@aws-sdk/client-bedrock-runtime": "^3.1040.0",
@@ -6483,12 +6494,16 @@
"node": ">=18.0.0"
},
"peerDependencies": {
"@agentclientprotocol/sdk": "^1.2.0",
"@aws-sdk/client-bedrock-runtime": "^3.700.0",
"@google/genai": "^1.48.0",
"@modelcontextprotocol/sdk": "^1.18.0",
"ai": "^5.0.0 || ^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"@agentclientprotocol/sdk": {
"optional": true
},
"@aws-sdk/client-bedrock-runtime": {
"optional": true
},
@@ -14,6 +14,8 @@ contributed them. Current:
jobs — N Open Design landing-page variants generated in parallel via
`runTasks()`, each variant driven `create_project → start_run → poll get_run`
to a terminal status by deterministic code.
- `external-agent-acp.ts`: external coding agents (Gemini CLI / Claude Code) as
team members via the `acp` backend. See [docs/external-agents.md](../../../../docs/external-agents.md).
- `trace-observability.ts`: the `onTrace` API.
- `with-vercel-ai-sdk/`: Next.js + AI SDK + `runTeam()`.
- `express-customer-support/`: Express REST API + `runTasks()`. Contributed
@@ -0,0 +1,95 @@
/**
* External coding agents over ACP (Agent Client Protocol)
*
* A hybrid team: LLM-backed agents plan and review, while an *external* coding
* agent (a local CLI driven over ACP) writes the code — all in one OMA task DAG
* with shared memory, cascade-on-failure, and unified token accounting. The
* coordinator decomposes the goal and routes the coding work to the ACP agent
* because of its roster description; the LLM reviewer then audits what it wrote.
*
* The ACP agent is declared with `backend: { kind: 'acp', ... }` instead of a
* model. OMA spawns it, exchanges JSON-RPC over stdio, and turns each prompt
* turn into a normal agent result — so it behaves like any other team member.
*
* Run:
* npx tsx packages/core/examples/integrations/external-agent-acp.ts
*
* Prerequisites:
* - ANTHROPIC_API_KEY one key for the whole team: the LLM
* planner + reviewer AND the Claude Code coder
* - @agentclientprotocol/sdk installed (the optional ACP peer)
* - Node.js with `npx` on PATH. The coder runs Claude Code over ACP via the
* official adapter `npx @agentclientprotocol/claude-agent-acp` (auto-downloaded
* on first run). Swap `command`/`args` below for any other ACP agent — e.g.
* Gemini CLI (`gemini --acp`) or Codex (`codex-acp`). See docs/external-agents.md.
*/
import { OpenMultiAgent } from '../../src/index.js'
if (!process.env.ANTHROPIC_API_KEY?.trim()) {
console.error('Missing ANTHROPIC_API_KEY: needed for the planner/reviewer and the Claude Code coder.')
process.exit(1)
}
// The directory the coding agent is allowed to read and edit. Point this at a
// scratch project you trust the external agent with.
const projectDir = process.env.OMA_ACP_PROJECT_DIR ?? process.cwd()
const oma = new OpenMultiAgent({
defaultModel: 'claude-sonnet-4-6',
defaultProvider: 'anthropic',
maxConcurrency: 3,
})
const team = oma.createTeam('hybrid-dev', {
name: 'hybrid-dev',
agents: [
{
name: 'planner',
systemPrompt:
'You break a software task into a short, concrete implementation plan. ' +
'You do not write code yourself.',
},
{
// External coding agent — no model; runs Claude Code as a local subprocess
// over ACP, via the official Claude Agent SDK adapter.
name: 'coder',
systemPrompt:
'Writes and edits code in the project by running Claude Code. ' +
'Assign all file-editing and code-writing work here.',
backend: {
kind: 'acp',
command: 'npx',
args: ['-y', '@agentclientprotocol/claude-agent-acp'],
cwd: projectDir,
// OMA runs autonomously, so tool prompts are auto-approved within `cwd`.
// Use 'reject' or a function to gate specific actions.
permission: 'auto-approve',
},
},
{
name: 'reviewer',
systemPrompt:
'You review a code change for correctness and clarity and summarize the diff, ' +
'calling out risks. You do not edit files.',
},
],
sharedMemory: true,
})
const result = await oma.runTeam(
team,
'Add a `slugify(text)` utility (lowercase, spaces and punctuation to single hyphens) ' +
'with a couple of unit tests, then review the change.',
{
onProgress: (event) => {
if (event.type === 'task_complete') {
console.log(`✓ ${event.agent} finished task ${event.task}`)
}
},
},
)
console.log('\n=== Final output ===\n')
console.log(result.agentResults.get('coordinator')?.output ?? '(no synthesis)')
console.log('\nTotal tokens:', result.totalTokenUsage)
+9
View File
@@ -21,6 +21,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./acp": {
"types": "./dist/acp.d.ts",
"import": "./dist/acp.js"
},
"./mcp": {
"types": "./dist/mcp.d.ts",
"import": "./dist/mcp.js"
@@ -76,12 +80,16 @@
"zod": "^3.23.0"
},
"peerDependencies": {
"@agentclientprotocol/sdk": "^1.2.0",
"@aws-sdk/client-bedrock-runtime": "^3.700.0",
"@google/genai": "^1.48.0",
"@modelcontextprotocol/sdk": "^1.18.0",
"ai": "^5.0.0 || ^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"@agentclientprotocol/sdk": {
"optional": true
},
"@aws-sdk/client-bedrock-runtime": {
"optional": true
},
@@ -96,6 +104,7 @@
}
},
"devDependencies": {
"@agentclientprotocol/sdk": "^1.2.1",
"@ai-sdk/openai": "^4.0.0",
"@ai-sdk/provider": "^4.0.0",
"@aws-sdk/client-bedrock-runtime": "^3.1040.0",
+24
View File
@@ -0,0 +1,24 @@
/**
* @fileoverview `@open-multi-agent/core/acp` — orchestrate external coding agents
* (Gemini CLI, Claude Code, Codex, …) over the Agent Client Protocol (ACP).
*
* Most users never import this module: set {@link AgentBackendConfig} on an
* agent's `backend` field and OMA loads the backend for you. Import from here only
* to construct a backend directly (advanced / programmatic use):
*
* ```ts
* import { createAcpBackend } from '@open-multi-agent/core/acp'
* const backend = createAcpBackend({ command: 'npx', args: ['-y', '@agentclientprotocol/claude-agent-acp'] })
* ```
*
* Requires the optional peer dependency `@agentclientprotocol/sdk`.
*/
export { AcpBackend, createAcpBackend } from './agent/acp-backend.js'
export type { AcpBackendOptions, AcpConnection } from './agent/acp-backend.js'
export type { AgentBackend } from './agent/runner.js'
export type {
AgentBackendConfig,
AcpPermissionPolicy,
AcpPermissionRequest,
} from './types.js'
+501
View File
@@ -0,0 +1,501 @@
/**
* @fileoverview {@link AgentBackend} that drives an external coding agent over the
* Agent Client Protocol (ACP).
*
* OMA takes the ACP **client** role: it spawns the agent (a local CLI such as
* Gemini CLI or Claude Code) as a subprocess, exchanges JSON-RPC over stdio, and
* turns one `session/prompt` turn into a {@link RunResult}. This lets an ACP agent
* sit in the same task DAG as LLM agents — with shared memory, cascade-on-failure,
* and token budget — because it satisfies the same {@link AgentBackend} contract.
*
* Requires the optional peer dependency `@agentclientprotocol/sdk`, loaded lazily
* so the three-runtime-dependency promise holds for consumers that never use ACP.
* Reached only through `AgentConfig.backend` (see {@link Agent}) or the
* `@open-multi-agent/core/acp` subpath — never imported by the package root.
*
* v1 scope: OMA is the client only; the agent performs its own filesystem access
* within `cwd` (client-side `fs/*` proxying is intentionally not implemented yet).
*/
import { spawn } from 'node:child_process'
import { resolve as resolvePath } from 'node:path'
import { Readable, Writable } from 'node:stream'
import type {
ActiveSession,
CancelNotification,
ClientApp,
ClientConnection,
ClientContext,
PermissionOption,
RequestPermissionRequest,
RequestPermissionResponse,
StopReason,
} from '@agentclientprotocol/sdk'
import type {
AcpPermissionPolicy,
AcpPermissionRequest,
ContentBlock,
LLMMessage,
StreamEvent,
TokenUsage,
ToolCallRecord,
} from '../types.js'
import type { AgentBackend, RunOptions, RunResult } from './runner.js'
/** The `@agentclientprotocol/sdk` module shape, resolved lazily. */
type AcpSdkModule = typeof import('@agentclientprotocol/sdk')
const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 }
/** A live ACP client connection paired with its teardown. */
export interface AcpConnection {
readonly connection: ClientConnection
dispose(): Promise<void> | void
}
/** Options for {@link createAcpBackend} / {@link AcpBackend}. */
export interface AcpBackendOptions {
/** Executable to spawn (e.g. `'npx'`, `'gemini'`, `'codex-acp'`). */
readonly command: string
/** Arguments passed to `command` (e.g. `['-y', '@agentclientprotocol/claude-agent-acp']`). */
readonly args?: readonly string[]
/** Extra environment variables for the subprocess, merged over `process.env`. */
readonly env?: Readonly<Record<string, string>>
/** Working directory the agent reads and edits. Defaults to `process.cwd()`. */
readonly cwd?: string
/** How to answer the agent's permission prompts. Defaults to `'auto-approve'`. */
readonly permission?: AcpPermissionPolicy
/**
* The agent's role / system prompt. ACP has no session-level system-prompt
* field, so this is prepended to the *first* `session/prompt` turn (once per
* session) to convey the agent's role to the external CLI. Empty/whitespace is
* ignored.
*/
readonly systemPrompt?: string
/** Agent name, used as the ACP client identity and in error messages. */
readonly agentName?: string
/** Model/label, forwarded only for diagnostics. */
readonly model?: string
/** Advisory per-call timeout hint (unused today; reserved). */
readonly callTimeoutMs?: number
/**
* Test seam. Given the configured {@link ClientApp}, return an open connection
* instead of spawning `command` — e.g. `app.connect(fakeAgentApp)` for an
* in-process fake agent. Defaults to spawning a subprocess and framing its
* stdio as newline-delimited JSON-RPC.
*/
readonly connect?: (app: ClientApp, sdk: AcpSdkModule) => AcpConnection | Promise<AcpConnection>
}
/** Construct an ACP-backed {@link AgentBackend}. The subprocess is spawned lazily on first run. */
export function createAcpBackend(options: AcpBackendOptions): AgentBackend {
return new AcpBackend(options)
}
/**
* {@link AgentBackend} implementation backed by an ACP subprocess.
*
* The subprocess + session are created on the first `run`/`stream` and reused for
* subsequent calls (preserving conversation context). Call {@link dispose} to
* close the connection and kill the subprocess.
*/
export class AcpBackend implements AgentBackend {
private readonly options: AcpBackendOptions
private sdk: AcpSdkModule | null = null
private conn: AcpConnection | null = null
private ctx: ClientContext | null = null
private session: ActiveSession | null = null
private starting: Promise<void> | null = null
/**
* Cumulative context-token reading from the last `usage_update`. ACP reports a
* running total ("tokens currently in context"), not a per-turn delta, so each
* turn's usage is reported as the increment over this value (see {@link stream}).
*/
private lastUsedTokens = 0
/** Role prompt to prepend to the first turn of the session; cleared once sent. */
private systemPromptPending: string | undefined
constructor(options: AcpBackendOptions) {
this.options = options
this.systemPromptPending = options.systemPrompt?.trim() || undefined
}
/** Run one prompt turn to completion and return the aggregated {@link RunResult}. */
async run(messages: LLMMessage[], options: RunOptions = {}): Promise<RunResult> {
let result: RunResult = {
messages: [],
output: '',
toolCalls: [],
tokenUsage: ZERO_USAGE,
turns: 0,
}
for await (const event of this.stream(messages, options)) {
if (event.type === 'done') {
result = event.data as RunResult
} else if (event.type === 'error') {
throw event.data
}
}
return result
}
/** Run one prompt turn, yielding `text` deltas then a terminal `done`/`error` event. */
async *stream(messages: LLMMessage[], options: RunOptions = {}): AsyncGenerator<StreamEvent> {
const label = this.options.agentName ?? 'agent'
const userText = lastUserText(messages)
if (!userText) {
yield { type: 'error', data: new Error(`ACP backend "${label}" received an empty prompt.`) }
return
}
let session: ActiveSession
let ctx: ClientContext
try {
const started = await this.ensureSession()
session = started.session
ctx = started.ctx
} catch (err) {
yield { type: 'error', data: toError(err) }
return
}
const abort = options.abortSignal
const toolCalls: ToolCallRecord[] = []
// Tool metadata seen on the initial `tool_call` event, finalized on the
// completing `tool_call_update` (which may omit the title/input).
const toolMeta = new Map<string, { start: number; title?: string; input?: Record<string, unknown> }>()
const assistantText: string[] = []
// The latest cumulative context reading seen this turn; undefined ⇒ the
// agent sent no usage_update, so this turn reports zero usage.
let usedThisTurn: number | undefined
// Wire abort → session/cancel; a well-behaved agent replies with a
// `cancelled` stop, which ends the drain loop below.
const onAbort = (): void => {
void ctx
.notify('session/cancel', { sessionId: session.sessionId } satisfies CancelNotification)
.catch(() => {})
}
// If the caller already aborted before we start a turn, don't prompt at all —
// there is no in-flight turn to cancel. Return empty output, mirroring a turn
// that was cancelled immediately.
if (abort?.aborted) {
yield {
type: 'done',
data: { messages: [], output: '', toolCalls: [], tokenUsage: ZERO_USAGE, turns: 0 },
}
return
}
abort?.addEventListener('abort', onAbort, { once: true })
try {
// ACP has no session-level system prompt, so convey the agent's role by
// prepending it to the first prompt of the session (once). Later turns
// reuse the same session, which already carries it.
const promptText = this.systemPromptPending
? `${this.systemPromptPending}\n\n${userText}`
: userText
this.systemPromptPending = undefined
// Fire the prompt; its completion is also queued as a `stop` message for
// nextUpdate(). Attach a no-op catch now, then propagate after draining.
const promptPromise = session.prompt(promptText)
promptPromise.catch(() => {})
let stopReason: StopReason = 'end_turn'
while (true) {
const msg = await session.nextUpdate()
if (msg.kind === 'stop') {
stopReason = msg.stopReason
break
}
const update = msg.update
switch (update.sessionUpdate) {
case 'agent_message_chunk': {
if (update.content.type === 'text') {
assistantText.push(update.content.text)
yield { type: 'text', data: update.content.text }
}
break
}
case 'tool_call': {
toolMeta.set(update.toolCallId, {
start: Date.now(),
title: update.title ?? undefined,
input: asRecord(update.rawInput),
})
break
}
case 'tool_call_update': {
if (update.status === 'completed' || update.status === 'failed') {
const meta = toolMeta.get(update.toolCallId)
toolCalls.push({
toolName: update.title ?? meta?.title ?? update.toolCallId,
input: update.rawInput !== undefined ? asRecord(update.rawInput) : meta?.input ?? {},
output: toolOutputText(update.rawOutput, update.content),
duration: Math.max(0, Date.now() - (meta?.start ?? Date.now())),
})
}
break
}
case 'usage_update': {
// ACP reports a single *cumulative* context-token figure ("tokens
// currently in context"), not an input/output split or a per-turn
// delta. Keep the latest reading; it is converted to an incremental
// figure below (see docs/external-agents.md).
usedThisTurn = update.used
break
}
default:
break
}
}
// Surface a late prompt rejection (e.g. transport failure at turn end).
await promptPromise
const output = assistantText.join('')
// A refusal is a task failure — dependents should not proceed on it.
if (stopReason === 'refusal') {
yield {
type: 'error',
data: new Error(`ACP agent "${label}" refused the task${output ? `: ${output}` : '.'}`),
}
return
}
const assistantMsg = output
? ({ role: 'assistant', content: [{ type: 'text', text: output }] } satisfies LLMMessage)
: undefined
if (assistantMsg) options.onMessage?.(assistantMsg)
// Convert ACP's cumulative context reading into this turn's incremental
// usage, so summing token usage across a reused session telescopes to the
// final figure instead of double-counting. A turn with no usage_update
// reports 0 and leaves the running total untouched.
let inputTokens = 0
if (usedThisTurn !== undefined) {
inputTokens = Math.max(0, usedThisTurn - this.lastUsedTokens)
this.lastUsedTokens = usedThisTurn
}
// `max_tokens` / `max_turn_requests` mean the agent stopped before finishing;
// reuse the "hit a limit, may be incomplete" signal so the task soft-fails.
const budgetExceeded = stopReason === 'max_tokens' || stopReason === 'max_turn_requests'
const result: RunResult = {
messages: assistantMsg ? [assistantMsg] : [],
output,
toolCalls,
tokenUsage: { input_tokens: inputTokens, output_tokens: 0 },
turns: 1,
...(budgetExceeded ? { budgetExceeded: true } : {}),
}
yield { type: 'done', data: result }
} catch (err) {
yield { type: 'error', data: toError(err) }
} finally {
if (abort) abort.removeEventListener('abort', onAbort)
}
}
/** Close the ACP connection and kill the subprocess. Safe to call more than once. */
async dispose(): Promise<void> {
try {
this.session?.dispose()
} catch {
// best-effort
}
if (this.conn) {
await this.conn.dispose()
}
this.session = null
this.ctx = null
this.conn = null
this.starting = null
// Reset per-session accounting: a fresh session restarts its cumulative
// token reading from zero and re-prepends the role prompt on its first turn.
this.lastUsedTokens = 0
this.systemPromptPending = this.options.systemPrompt?.trim() || undefined
}
// -------------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------------
private async ensureSession(): Promise<{ session: ActiveSession; ctx: ClientContext }> {
if (this.session && this.ctx) {
return { session: this.session, ctx: this.ctx }
}
if (!this.starting) {
this.starting = this.start()
}
try {
await this.starting
} catch (err) {
// Clear the cached promise so a later run can retry, instead of re-throwing
// this same rejection forever (e.g. after a transient spawn failure).
this.starting = null
throw err
}
return { session: this.session!, ctx: this.ctx! }
}
private async start(): Promise<void> {
const sdk = this.sdk ?? (this.sdk = await loadAcpSdk())
const app = sdk
.client({ name: this.options.agentName ?? 'open-multi-agent' })
.onRequest('session/request_permission', ({ params }) => this.resolvePermission(params))
const connector = this.options.connect ?? defaultSpawnConnector(this.options)
this.conn = await connector(app, sdk)
this.ctx = this.conn.connection.agent
await this.ctx.request('initialize', {
protocolVersion: sdk.PROTOCOL_VERSION,
clientCapabilities: {},
})
this.session = await this.ctx.buildSession(resolveCwd(this.options.cwd)).start()
}
private async resolvePermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const options = params.options
// Prefer the least-privilege option: a one-time `allow_once` / `reject_once`
// over a session-wide `allow_always` / `reject_always`, so a single auto
// decision doesn't silently broaden scope for the rest of the session.
const allow = pick(options, 'allow_once') ?? pick(options, 'allow_always')
const reject = pick(options, 'reject_once') ?? pick(options, 'reject_always')
const policy = this.options.permission ?? 'auto-approve'
let approve: boolean
if (policy === 'auto-approve') {
approve = true
} else if (policy === 'reject') {
approve = false
} else {
const request: AcpPermissionRequest = {
title: params.toolCall.title ?? '',
kind: params.toolCall.kind ?? undefined,
optionKinds: options.map((o) => o.kind),
}
approve = await policy(request)
}
if (approve && allow) {
return { outcome: { outcome: 'selected', optionId: allow.optionId } }
}
if (!approve && reject) {
return { outcome: { outcome: 'selected', optionId: reject.optionId } }
}
return { outcome: { outcome: 'cancelled' } }
}
}
// ---------------------------------------------------------------------------
// Module helpers
// ---------------------------------------------------------------------------
async function loadAcpSdk(): Promise<AcpSdkModule> {
try {
return await import('@agentclientprotocol/sdk')
} catch (err) {
throw new Error(
`The 'acp' agent backend requires the optional peer dependency ` +
`'@agentclientprotocol/sdk'. Install it with: npm install @agentclientprotocol/sdk\n` +
`Original error: ${err instanceof Error ? err.message : String(err)}`,
)
}
}
/** Default connector: spawn the CLI and frame its stdio as newline-delimited JSON-RPC. */
function defaultSpawnConnector(
options: AcpBackendOptions,
): (app: ClientApp, sdk: AcpSdkModule) => AcpConnection {
return (app, sdk) => {
const child = spawn(options.command, [...(options.args ?? [])], {
cwd: resolveCwd(options.cwd),
env: { ...process.env, ...options.env },
stdio: ['pipe', 'pipe', 'inherit'],
})
if (!child.stdin || !child.stdout) {
child.kill()
throw new Error(`Failed to open stdio pipes for ACP agent command "${options.command}".`)
}
const stream = sdk.ndJsonStream(
Writable.toWeb(child.stdin) as unknown as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout) as unknown as ReadableStream<Uint8Array>,
)
const connection = app.connect(stream)
// If the process can't start (e.g. command not found), close the connection
// so pending requests reject instead of hanging.
child.once('error', () => {
try {
connection.close()
} catch {
// best-effort
}
})
return {
connection,
dispose: () => {
try {
connection.close()
} catch {
// best-effort
}
if (!child.killed) child.kill()
},
}
}
}
function pick(options: readonly PermissionOption[], kind: string): PermissionOption | undefined {
return options.find((o) => o.kind === kind)
}
/** Extract the text of the most recent user message (the new turn for the ACP session). */
function lastUserText(messages: LLMMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== 'user') continue
return message.content
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
.map((b) => b.text)
.join('\n')
.trim()
}
return ''
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function toolOutputText(rawOutput: unknown, content: unknown): string {
if (typeof rawOutput === 'string') return rawOutput
if (rawOutput != null) return safeStringify(rawOutput)
if (Array.isArray(content) && content.length > 0) return safeStringify(content)
return ''
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function resolveCwd(cwd?: string): string {
return resolvePath(cwd ?? process.cwd())
}
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err))
}
+64 -21
View File
@@ -37,6 +37,7 @@
*/
import type {
AgentBackendConfig,
AgentConfig,
AgentState,
AgentRunResult,
@@ -52,7 +53,7 @@ import type { ToolDefinition as FrameworkToolDefinition, ToolRegistry } from '..
import type { ToolExecutor } from '../tool/executor.js'
import { defaultWorkspaceDir } from '../tool/built-in/path-safety.js'
import { createAdapter } from '../llm/adapter.js'
import { AgentRunner, type RunnerOptions, type RunOptions, type RunResult } from './runner.js'
import { AgentRunner, type AgentBackend, type RunnerOptions, type RunOptions, type RunResult } from './runner.js'
import {
buildStructuredOutputInstruction,
extractJSON,
@@ -84,7 +85,7 @@ export class Agent {
readonly name: string
readonly config: AgentConfig
private runner: AgentRunner | null = null
private backend: AgentBackend | null = null
private state: AgentState
private readonly _toolRegistry: ToolRegistry
private readonly _toolExecutor: ToolExecutor
@@ -106,11 +107,12 @@ export class Agent {
this.name = config.name
// `model` is optional on AgentConfig so orchestrated agents can inherit
// `OrchestratorConfig.defaultModel`. A standalone Agent has no orchestrator
// to inherit from, so it must declare a model explicitly.
if (config.model === undefined) {
// to inherit from, so it must declare a model explicitly — unless it runs on
// an external `backend` (e.g. an ACP coding CLI), which needs no LLM model.
if (config.model === undefined && config.backend === undefined) {
throw new Error(
`Agent "${config.name}" has no model. Set 'model' in its config, or run it ` +
`through OpenMultiAgent to inherit 'defaultModel'.`,
`Agent "${config.name}" has no model. Set 'model' in its config, or a 'backend', ` +
`or run it through OpenMultiAgent to inherit 'defaultModel'.`,
)
}
this.config = config
@@ -129,14 +131,24 @@ export class Agent {
// -------------------------------------------------------------------------
/**
* Lazily create the {@link AgentRunner}.
* Lazily create the {@link AgentBackend} that executes this agent's runs.
*
* The adapter is created asynchronously (it may lazy-import provider SDKs),
* so we defer construction until the first `run` / `prompt` / `stream` call.
* Defaults to an {@link AgentRunner} (the LLM conversation loop). When
* {@link AgentConfig.backend} is set, delegates to an external backend (e.g. an
* ACP coding CLI) instead. Construction is async because it may lazy-import a
* provider SDK or the optional ACP peer, so we defer it to the first
* `run` / `prompt` / `stream` call.
*/
private async getRunner(): Promise<AgentRunner> {
if (this.runner !== null) {
return this.runner
private async getBackend(): Promise<AgentBackend> {
if (this.backend !== null) {
return this.backend
}
// External backend: it runs its own agentic loop, so none of the
// LLM-specific configuration below applies.
if (this.config.backend) {
this.backend = await this.createExternalBackend(this.config.backend)
return this.backend
}
const provider = this.config.provider ?? 'anthropic'
@@ -185,14 +197,45 @@ export class Agent {
compressReasoningText: this.config.compressReasoningText,
}
this.runner = new AgentRunner(
this.backend = new AgentRunner(
adapter,
this._toolRegistry,
this._toolExecutor,
runnerOptions,
)
return this.runner
return this.backend
}
/**
* Build a non-LLM {@link AgentBackend} from {@link AgentConfig.backend}.
*
* The backend module is loaded via dynamic `import()` so its optional peer SDK
* (e.g. `@agentclientprotocol/sdk`) is only resolved when a backend is used.
*/
private async createExternalBackend(backend: AgentBackendConfig): Promise<AgentBackend> {
switch (backend.kind) {
case 'acp': {
const { createAcpBackend } = await import('./acp-backend.js')
return createAcpBackend({
command: backend.command,
args: backend.args,
env: backend.env,
cwd: backend.cwd,
permission: backend.permission,
// ACP has no system-prompt field; the backend prepends this to the
// agent's first turn so its configured role reaches the external CLI.
systemPrompt: this.config.systemPrompt,
agentName: this.name,
model: this.config.model,
callTimeoutMs: this.config.callTimeoutMs,
})
}
default:
throw new Error(
`Agent "${this.name}": unknown backend kind "${(backend as { kind: string }).kind}".`,
)
}
}
// -------------------------------------------------------------------------
@@ -333,7 +376,7 @@ export class Agent {
this.applyHookContext(messages, modified, hookCtx.prompt)
}
const runner = await this.getRunner()
const backend = await this.getBackend()
const internalOnMessage = (msg: LLMMessage) => {
this.state.messages.push(msg)
callerOptions?.onMessage?.(msg)
@@ -365,7 +408,7 @@ export class Agent {
}
effectiveTraceOptions = runOptions
const result = await runner.run(messages, runOptions)
const result = await backend.run(messages, runOptions)
this.state.tokenUsage = addUsage(this.state.tokenUsage, result.tokenUsage)
if (result.budgetExceeded) {
@@ -383,7 +426,7 @@ export class Agent {
let validated = await this.validateStructuredOutput(
messages,
result,
runner,
backend,
runOptions,
)
// --- afterRun hook ---
@@ -456,7 +499,7 @@ export class Agent {
private async validateStructuredOutput(
originalMessages: LLMMessage[],
result: RunResult,
runner: AgentRunner,
backend: AgentBackend,
runOptions: RunOptions,
): Promise<AgentRunResult> {
const schema = this.config.outputSchema!
@@ -497,7 +540,7 @@ export class Agent {
errorFeedbackMessage,
]
const retryResult = await runner.run(retryMessages, runOptions)
const retryResult = await backend.run(retryMessages, runOptions)
this.state.tokenUsage = addUsage(this.state.tokenUsage, retryResult.tokenUsage)
const mergedTokenUsage = addUsage(result.tokenUsage, retryResult.tokenUsage)
@@ -552,7 +595,7 @@ export class Agent {
this.applyHookContext(messages, modified, hookCtx.prompt)
}
const runner = await this.getRunner()
const backend = await this.getBackend()
// Fresh timeout per stream call, same as executeRun.
const timeoutSignal = this.config.timeoutMs !== undefined && this.config.timeoutMs > 0
? AbortSignal.timeout(this.config.timeoutMs)
@@ -575,7 +618,7 @@ export class Agent {
}
effectiveTraceOptions = runOptions
for await (const event of runner.stream(messages, runOptions)) {
for await (const event of backend.stream(messages, runOptions)) {
if (event.type === 'done') {
const result = event.data as RunResult
this.state.tokenUsage = addUsage(this.state.tokenUsage, result.tokenUsage)
+20 -1
View File
@@ -218,6 +218,25 @@ export interface RunResult {
readonly budgetExceeded?: boolean
}
/**
* The execution seam behind every {@link Agent}: given a conversation, produce a
* {@link RunResult} (and a matching {@link StreamEvent} stream). {@link AgentRunner}
* is the LLM implementation; alternative backends (e.g. an external coding agent
* over ACP — see `@open-multi-agent/core/acp`) implement the same contract so an
* `Agent` can drive either without the orchestrator, pool, or team knowing which.
*
* The contract mirrors {@link AgentRunner.run} / {@link AgentRunner.stream}:
* `stream()` yields incremental events and MUST end with a single
* `{ type: 'done', data: RunResult }` (or `{ type: 'error', data }` on failure);
* `run()` returns that same aggregated {@link RunResult}.
*/
export interface AgentBackend {
/** Run the conversation to completion and return the aggregated result. */
run(messages: LLMMessage[], options?: RunOptions): Promise<RunResult>
/** Run the conversation and yield {@link StreamEvent}s, ending with `done`. */
stream(messages: LLMMessage[], options?: RunOptions): AsyncIterable<StreamEvent>
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
@@ -377,7 +396,7 @@ function loopWarningText(kind: 'tool_repetition' | 'text_repetition'): string {
* console.log(result.output)
* ```
*/
export class AgentRunner {
export class AgentRunner implements AgentBackend {
private readonly maxTurns: number
private summarizeCache: {
oldSignature: string
+6
View File
@@ -65,6 +65,9 @@ export { renderTeamRunDashboard } from './dashboard/render-team-run-dashboard.js
// ---------------------------------------------------------------------------
export { Agent } from './agent/agent.js'
// The execution seam behind every Agent. `AgentRunner` (the LLM loop) implements
// it; the ACP backend (`@open-multi-agent/core/acp`) is an alternative impl.
export type { AgentBackend, RunOptions, RunResult } from './agent/runner.js'
export { LoopDetector } from './agent/loop-detector.js'
export { buildStructuredOutputInstruction, extractJSON, validateOutput } from './agent/structured-output.js'
export { AgentPool, Semaphore } from './agent/pool.js'
@@ -167,6 +170,9 @@ export type {
AgentConfig,
AgentState,
AgentRunResult,
AgentBackendConfig,
AcpPermissionPolicy,
AcpPermissionRequest,
BeforeRunHookContext,
ToolCallRecord,
LoopDetectionConfig,
+66
View File
@@ -400,6 +400,62 @@ export interface BeforeRunHookContext {
readonly agent: AgentConfig
}
/**
* A minimal, SDK-agnostic view of an ACP `session/request_permission` prompt,
* passed to a {@link AgentBackendConfig.permission} callback so callers can decide
* without importing `@agentclientprotocol/sdk`.
*/
export interface AcpPermissionRequest {
/** Human-readable title of the pending tool call (e.g. `"Edit src/app.ts"`). */
readonly title: string
/** ACP tool kind when the agent provides one (e.g. `'edit'`, `'execute'`, `'read'`). */
readonly kind?: string
/** The option kinds the agent offered (e.g. `'allow_once'`, `'reject_always'`). */
readonly optionKinds: readonly string[]
}
/**
* How an ACP-backed agent answers a permission prompt. OMA runs agents
* autonomously inside a task DAG, so the default is `'auto-approve'`.
* - `'auto-approve'` — select an `allow_*` option, preferring the least-privilege
* `allow_once` over a session-wide `allow_always` (falls back to cancel).
* - `'reject'` — select a `reject_*` option, preferring `reject_once` (falls back to cancel).
* - function — decide per request; `true` approves, `false` rejects.
*/
export type AcpPermissionPolicy =
| 'auto-approve'
| 'reject'
| ((request: AcpPermissionRequest) => boolean | Promise<boolean>)
/**
* Configuration for running an external agent over the Agent Client Protocol
* (ACP) as an OMA team member — see {@link AgentConfig.backend}. The agent is a
* local subprocess (a coding CLI such as Gemini CLI or Claude Code) that runs its
* own agentic loop, while OMA drives it, collects its output and token usage, and
* schedules it in the task DAG alongside LLM agents.
*
* A discriminated union keyed by {@link kind}; only `'acp'` exists today.
* Requires the optional peer `@agentclientprotocol/sdk`.
*/
export interface AgentBackendConfig {
/** Backend discriminant. Only `'acp'` is supported today. */
readonly kind: 'acp'
/** Executable to spawn (e.g. `'npx'`, `'gemini'`, `'codex-acp'`). */
readonly command: string
/** Arguments passed to `command` (e.g. `['-y', '@agentclientprotocol/claude-agent-acp']`). */
readonly args?: readonly string[]
/** Extra environment variables for the subprocess, merged over `process.env`. */
readonly env?: Readonly<Record<string, string>>
/**
* Working directory the agent reads and edits. Defaults to `process.cwd()`.
* Unlike OMA's filesystem-tool sandbox, an ACP agent accesses this directory
* directly — scope it to a project you trust the external agent with.
*/
readonly cwd?: string
/** How to answer the agent's permission prompts. Defaults to `'auto-approve'`. */
readonly permission?: AcpPermissionPolicy
}
/** Static configuration for a single agent. */
export interface AgentConfig {
readonly name: string
@@ -419,6 +475,16 @@ export interface AgentConfig {
* and `region` are ignored for LLM calls.
*/
readonly adapter?: LLMAdapter
/**
* Run this agent on an external {@link AgentBackendConfig} (e.g. a coding CLI
* over the Agent Client Protocol) instead of an LLM adapter. When set, the
* LLM-specific fields (`model`, `provider`, `adapter`, sampling, tools, context
* strategy) do not apply — the external agent runs its own loop — but the agent
* still participates in the task DAG, shared memory, cascade-on-failure, and
* token budget like any other team member. Requires the optional peer
* `@agentclientprotocol/sdk`; import the backend from `@open-multi-agent/core/acp`.
*/
readonly backend?: AgentBackendConfig
readonly provider?: SupportedProvider
/**
* Custom base URL for OpenAI-compatible APIs (Ollama, vLLM, LM Studio, etc.).
+429
View File
@@ -0,0 +1,429 @@
/**
* Tests for the ACP agent backend (`src/agent/acp-backend.ts`).
*
* The unit tests wire the backend to an in-process fake ACP *agent* built with
* the SDK's `agent()` helper (via the backend's `connect` test seam), so no
* subprocess or real coding CLI is needed. One integration test spawns a real
* subprocess (`fixtures/fake-acp-agent.mjs`) through the public `Agent` API to
* exercise the full spawn → stdio JSON-RPC → RunResult path.
*/
import { describe, it, expect } from 'vitest'
import { fileURLToPath } from 'node:url'
import { agent, PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import type { AgentApp, AgentRequestHandlersByMethod } from '@agentclientprotocol/sdk'
import { AcpBackend, type AcpBackendOptions } from '../src/agent/acp-backend.js'
import { Agent } from '../src/agent/agent.js'
import { ToolRegistry } from '../src/tool/framework.js'
import { ToolExecutor } from '../src/tool/executor.js'
import type { AcpPermissionPolicy, LLMMessage } from '../src/types.js'
type PromptHandler = AgentRequestHandlersByMethod['session/prompt']
function userMsg(text: string): LLMMessage {
return { role: 'user', content: [{ type: 'text', text }] }
}
/** Extract the text an ACP agent received in a `session/prompt` turn. */
function promptText(prompt: unknown): string {
const blocks = (Array.isArray(prompt) ? prompt : [prompt]) as Array<{ type?: string; text?: string }>
return blocks.find((b) => b?.type === 'text')?.text ?? ''
}
/** Build a fake ACP agent whose prompt turn is driven by `promptHandler`. */
function fakeAgent(promptHandler: PromptHandler): AgentApp {
return agent({ name: 'fake' })
.onRequest('initialize', () => ({ protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} }))
.onRequest('session/new', () => ({ sessionId: 'test-session' }))
.onRequest('session/prompt', promptHandler)
}
/** An {@link AcpBackend} connected in-process to `fake` (no subprocess). */
function backendFor(
fake: AgentApp,
permission?: AcpPermissionPolicy,
extra?: Partial<AcpBackendOptions>,
): AcpBackend {
return new AcpBackend({
command: 'unused',
agentName: 'fake',
...(permission ? { permission } : {}),
...extra,
connect: (clientApp) => {
const connection = clientApp.connect(fake)
return {
connection,
dispose: () => {
try {
connection.close()
} catch {
// best-effort
}
},
}
},
})
}
describe('AcpBackend (in-process fake agent)', () => {
it('maps agent_message_chunk text and usage_update into a RunResult', async () => {
const backend = backendFor(
fakeAgent(async ({ params, client }) => {
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello ' } },
})
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'world' } },
})
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'usage_update', used: 1234, size: 200000 },
})
return { stopReason: 'end_turn' }
}),
)
try {
const result = await backend.run([userMsg('hi')])
expect(result.output).toBe('Hello world')
expect(result.tokenUsage).toEqual({ input_tokens: 1234, output_tokens: 0 })
expect(result.turns).toBe(1)
expect(result.budgetExceeded).toBeUndefined()
expect(result.messages).toHaveLength(1)
expect(result.messages[0]?.role).toBe('assistant')
} finally {
await backend.dispose()
}
})
it('streams text deltas and ends with a done event carrying the RunResult', async () => {
const backend = backendFor(
fakeAgent(async ({ params, client }) => {
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'a' } },
})
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'b' } },
})
return { stopReason: 'end_turn' }
}),
)
try {
const deltas: string[] = []
let done = false
for await (const event of backend.stream([userMsg('hi')])) {
if (event.type === 'text') deltas.push(event.data as string)
else if (event.type === 'done') done = true
}
expect(deltas).toEqual(['a', 'b'])
expect(done).toBe(true)
} finally {
await backend.dispose()
}
})
it('records tool calls, carrying the title from tool_call to its completion', async () => {
const backend = backendFor(
fakeAgent(async ({ params, client }) => {
await client.notify('session/update', {
sessionId: params.sessionId,
update: {
sessionUpdate: 'tool_call',
toolCallId: 't1',
title: 'Edit file',
kind: 'edit',
status: 'pending',
rawInput: { path: 'a.ts' },
},
})
await client.notify('session/update', {
sessionId: params.sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 't1',
status: 'completed',
rawOutput: 'patched',
},
})
return { stopReason: 'end_turn' }
}),
)
try {
const result = await backend.run([userMsg('edit a.ts')])
expect(result.toolCalls).toHaveLength(1)
expect(result.toolCalls[0]).toMatchObject({
toolName: 'Edit file',
input: { path: 'a.ts' },
output: 'patched',
})
} finally {
await backend.dispose()
}
})
it('auto-approves permission requests by selecting an allow option', async () => {
const backend = backendFor(fakeAgent(reportPermissionChoice))
try {
const result = await backend.run([userMsg('go')])
expect(result.output).toBe('allow')
} finally {
await backend.dispose()
}
})
it('auto-approve prefers the least-privilege allow_once over allow_always', async () => {
const backend = backendFor(
fakeAgent(async ({ params, client }) => {
const response = await client.request('session/request_permission', {
sessionId: params.sessionId,
toolCall: { toolCallId: 't1', title: 'Edit', kind: 'edit', status: 'pending' },
options: [
// `allow_always` is listed first on purpose.
{ optionId: 'always', name: 'Always', kind: 'allow_always' },
{ optionId: 'once', name: 'Once', kind: 'allow_once' },
],
})
const chosen =
response.outcome.outcome === 'selected' ? response.outcome.optionId : 'cancelled'
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: chosen } },
})
return { stopReason: 'end_turn' }
}),
)
try {
const result = await backend.run([userMsg('go')])
// Auto-approve must pick `allow_once`, not the first (`allow_always`) option.
expect(result.output).toBe('once')
} finally {
await backend.dispose()
}
})
it("rejects permission requests under the 'reject' policy", async () => {
const backend = backendFor(fakeAgent(reportPermissionChoice), 'reject')
try {
const result = await backend.run([userMsg('go')])
expect(result.output).toBe('reject')
} finally {
await backend.dispose()
}
})
it('honors a custom permission function', async () => {
const seen: string[] = []
const policy: AcpPermissionPolicy = (req) => {
seen.push(req.title)
return false
}
const backend = backendFor(fakeAgent(reportPermissionChoice), policy)
try {
const result = await backend.run([userMsg('go')])
expect(result.output).toBe('reject')
expect(seen).toEqual(['Run cmd'])
} finally {
await backend.dispose()
}
})
it('throws when the agent refuses the turn', async () => {
const backend = backendFor(fakeAgent(async () => ({ stopReason: 'refusal' })))
try {
await expect(backend.run([userMsg('x')])).rejects.toThrow(/refused/i)
} finally {
await backend.dispose()
}
})
it('flags budgetExceeded when the agent stops on max_tokens', async () => {
const backend = backendFor(
fakeAgent(async ({ params, client }) => {
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'partial' } },
})
return { stopReason: 'max_tokens' }
}),
)
try {
const result = await backend.run([userMsg('x')])
expect(result.budgetExceeded).toBe(true)
expect(result.output).toBe('partial')
} finally {
await backend.dispose()
}
})
it('errors on an empty prompt', async () => {
const backend = backendFor(fakeAgent(async () => ({ stopReason: 'end_turn' })))
try {
await expect(backend.run([])).rejects.toThrow(/empty prompt/i)
} finally {
await backend.dispose()
}
})
it('reports token usage as a per-turn delta across a reused session', async () => {
// `usage_update.used` is cumulative context occupancy, so a reused session
// reports a growing total. The backend must record each turn's increment,
// not the running total, or summing across turns double-counts.
const readings = [1000, 2500, 3000]
let turn = 0
const backend = backendFor(
fakeAgent(async ({ params, client }) => {
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'usage_update', used: readings[turn++]!, size: 200000 },
})
return { stopReason: 'end_turn' }
}),
)
try {
const first = await backend.run([userMsg('turn 1')])
const second = await backend.run([userMsg('turn 2')])
const third = await backend.run([userMsg('turn 3')])
expect(first.tokenUsage.input_tokens).toBe(1000)
expect(second.tokenUsage.input_tokens).toBe(1500) // 2500 - 1000
expect(third.tokenUsage.input_tokens).toBe(500) // 3000 - 2500
// The sum telescopes to the latest cumulative reading (3000), not 6500.
const total =
first.tokenUsage.input_tokens +
second.tokenUsage.input_tokens +
third.tokenUsage.input_tokens
expect(total).toBe(3000)
} finally {
await backend.dispose()
}
})
it('prepends systemPrompt to the first turn only, then reuses the session', async () => {
const seen: string[] = []
const backend = backendFor(
fakeAgent(async ({ params, client }) => {
seen.push(promptText(params.prompt))
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } },
})
return { stopReason: 'end_turn' }
}),
undefined,
{ systemPrompt: 'You are a coder.' },
)
try {
await backend.run([userMsg('do X')])
await backend.run([userMsg('do Y')])
expect(seen[0]).toBe('You are a coder.\n\ndo X')
expect(seen[1]).toBe('do Y')
} finally {
await backend.dispose()
}
})
it('does not start a turn when the abort signal is already aborted', async () => {
let prompted = false
const backend = backendFor(
fakeAgent(async ({ params, client }) => {
prompted = true
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } },
})
return { stopReason: 'end_turn' }
}),
)
try {
const result = await backend.run([userMsg('go')], { abortSignal: AbortSignal.abort() })
expect(result.output).toBe('')
expect(prompted).toBe(false)
} finally {
await backend.dispose()
}
})
it('retries session startup after a failed start instead of caching the rejection', async () => {
let attempts = 0
const fake = fakeAgent(async ({ params, client }) => {
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } },
})
return { stopReason: 'end_turn' }
})
const backend = new AcpBackend({
command: 'unused',
agentName: 'fake',
connect: (clientApp) => {
attempts++
if (attempts === 1) throw new Error('spawn failed')
const connection = clientApp.connect(fake)
return {
connection,
dispose: () => {
try {
connection.close()
} catch {
// best-effort
}
},
}
},
})
try {
await expect(backend.run([userMsg('go')])).rejects.toThrow(/spawn failed/)
const result = await backend.run([userMsg('go')])
expect(result.output).toBe('ok')
expect(attempts).toBe(2)
} finally {
await backend.dispose()
}
})
})
describe('AcpBackend via Agent (subprocess integration)', () => {
it('runs a real ACP agent subprocess through Agent.run', async () => {
const fixture = fileURLToPath(new URL('./fixtures/fake-acp-agent.mjs', import.meta.url))
const registry = new ToolRegistry()
const executor = new ToolExecutor(registry)
const agentInstance = new Agent(
{ name: 'coder', backend: { kind: 'acp', command: process.execPath, args: [fixture] } },
registry,
executor,
)
try {
const result = await agentInstance.run('build the thing')
expect(result.success).toBe(true)
expect(result.output).toBe('done: build the thing')
expect(result.tokenUsage.input_tokens).toBe(42)
} finally {
// Reach into the Agent's cached backend to tear the subprocess down.
await (agentInstance as unknown as { backend?: AcpBackend }).backend?.dispose()
}
})
})
/**
* Prompt handler that asks the client to approve a tool call, then reports the
* selected option id (or `cancelled`) back as its assistant message.
*/
const reportPermissionChoice: PromptHandler = async ({ params, client }) => {
const response = await client.request('session/request_permission', {
sessionId: params.sessionId,
toolCall: { toolCallId: 't1', title: 'Run cmd', kind: 'execute', status: 'pending' },
options: [
{ optionId: 'allow', name: 'Allow', kind: 'allow_once' },
{ optionId: 'reject', name: 'Reject', kind: 'reject_once' },
],
})
const chosen =
response.outcome.outcome === 'selected' ? response.outcome.optionId : 'cancelled'
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: chosen } },
})
return { stopReason: 'end_turn' }
}
+2 -2
View File
@@ -50,7 +50,7 @@ function buildMockAgent(config: AgentConfig, responseText: string) {
temperature: config.temperature,
agentName: config.name,
})
;(agent as any).runner = runner
;(agent as any).backend = runner
return { agent, calls }
}
@@ -330,7 +330,7 @@ describe('Agent hooks — beforeRun / afterRun', () => {
model: config.model,
agentName: config.name,
})
;(agent as any).runner = runner
;(agent as any).backend = runner
// Directly call run which creates a single text-only user message.
// To test mixed content, we need to go through the private executeRun.
+1 -1
View File
@@ -48,7 +48,7 @@ function buildMockAgent(config: AgentConfig, responseText: string): Agent {
temperature: config.temperature,
agentName: config.name,
})
;(agent as any).runner = runner
;(agent as any).backend = runner
return agent
}
+39
View File
@@ -0,0 +1,39 @@
// Minimal ACP *agent* over stdio, used by the acp-backend integration test.
// Echoes the incoming prompt text back as an assistant message, reports a fixed
// token usage, then ends the turn. Hermetic: no network, no real model.
import { agent, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { Readable, Writable } from 'node:stream'
function firstText(prompt) {
const blocks = Array.isArray(prompt) ? prompt : [prompt]
const block = blocks.find((b) => b && b.type === 'text')
return block ? block.text : ''
}
const app = agent({ name: 'fake-acp-agent' })
.onRequest('initialize', () => ({
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: {},
}))
.onRequest('session/new', () => ({ sessionId: 'test-session' }))
.onRequest('session/prompt', async ({ params, client }) => {
await client.notify('session/update', {
sessionId: params.sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: `done: ${firstText(params.prompt)}` },
},
})
await client.notify('session/update', {
sessionId: params.sessionId,
update: { sessionUpdate: 'usage_update', used: 42, size: 1000 },
})
return { stopReason: 'end_turn' }
})
app.connect(
ndJsonStream(
Writable.toWeb(process.stdout),
Readable.toWeb(process.stdin),
),
)
@@ -49,7 +49,7 @@ function buildMockAgent(config: AgentConfig, responses: string[]): Agent {
temperature: config.temperature,
agentName: config.name,
})
;(agent as any).runner = runner
;(agent as any).backend = runner
return agent
}
+1 -1
View File
@@ -57,7 +57,7 @@ function buildMockAgent(config: AgentConfig, responseText: string) {
maxTurns: config.maxTurns,
agentName: config.name,
})
;(agent as any).runner = runner
;(agent as any).backend = runner
return { agent, calls }
}
@@ -55,7 +55,7 @@ function buildMockAgent(config: AgentConfig, responses: string[]): Agent {
temperature: config.temperature,
agentName: config.name,
})
;(agent as any).runner = runner
;(agent as any).backend = runner
return agent
}
@@ -33,7 +33,7 @@ function buildAgentWithAdapter(config: AgentConfig, adapter: LLMAdapter) {
maxTurns: config.maxTurns,
agentName: config.name,
})
;(agent as any).runner = runner
;(agent as any).backend = runner
return agent
}
@@ -196,7 +196,7 @@ function buildMockAgentWithAdapter(config: AgentConfig, adapter: LLMAdapter): Ag
const executor = new ToolExecutor(registry)
const agent = new Agent(config, registry, executor)
// Inject a pre-built runner so `getRunner()` returns it without calling createAdapter.
// Inject a pre-built runner so `getBackend()` returns it without calling createAdapter.
const runner = new AgentRunner(adapter, registry, executor, {
model: config.model,
systemPrompt: config.systemPrompt,
@@ -205,7 +205,7 @@ function buildMockAgentWithAdapter(config: AgentConfig, adapter: LLMAdapter): Ag
temperature: config.temperature,
agentName: config.name,
})
;(agent as any).runner = runner
;(agent as any).backend = runner
return agent
}
+1 -1
View File
@@ -78,7 +78,7 @@ function buildMockAgent(
temperature: config.temperature,
agentName: config.name,
})
;(agent as any).runner = runner
;(agent as any).backend = runner
return agent
}