Add the ACP subagent backend: out-of-process delegation (PR3)
The first OUT-OF-PROCESS subagent backend, proving the seam generalizes past the in-process backends. @deepseek-ai/dsh-subagent-acp runs each child agent in a spawned subprocess, driven over the Agent Client Protocol as the CLIENT — the direction-inverted twin of the dsh-acp server bridge. Point the configured command at the acp-agent example and the harness talks to its own process. - Fresh process per run: start spawns, runs one ACP session (initialize → newSession → prompt), dispose kills the subprocess and awaits its exit. - Minimal client stub: advertises no fs/terminal; accumulates agent_message_chunk text as the result output; auto-answers session/request_permission by a configured policy (reject default / allow). No start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter); ignores request.parent; injects only `subagents`. - StopReason mapping (end_turn→completed, cancelled→aborted, …); result resolves error/aborted on a child failure, never rejects (seam contract). - Security: credential-shaped ambient env vars are scrubbed; the child's own key is forwarded only via explicit config.env. A spawn-level error (ENOENT) is captured and raced against the ACP drive so a bad command settles error rather than crashing the parent. Testing designed at every tier: keyless integration drives a scripted mock ACP server subprocess (cancellation incl. the pre-newSession race and a torn-pipe-after-cancel, permission auto-answer, non-message updates, spawn failure, HMR, export shape) at 100% coverage; a with-key e2e drives the REAL acp-agent example process (PONG + real file write, verified on disk) — the harness driving itself. Snapshot coverage of an ACP child is deferred as TODO(acp-subagent-replay) (each child is its own process with its own replay). Stayed on @agentclientprotocol/sdk 0.25.1: the proposed 0.28.x bump only deprecates the stable ClientSideConnection/AgentSideConnection API this layer uses (33 sites incl. the server bridge), turning no-deprecated red across code this PR shouldn't rewrite — that fluent-API migration is its own follow-up. The backend needs nothing 0.28.x adds. This completes the subagent seam stack (PR1 interface → PR2 in-process → PR2.5 snapshot infra → PR3 ACP); the seam RFC moves to implemented/, amended.
This commit is contained in:
90
packages/subagent/subagent-acp/src/index.ts
Normal file
90
packages/subagent/subagent-acp/src/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The out-of-process ACP subagent backend: registers a {@link SubagentProvider}
|
||||
* on `ctx.subagents` that runs each child agent in a SPAWNED SUBPROCESS, driven
|
||||
* over the Agent Client Protocol (ACP) as the client. The parent process is the
|
||||
* ACP client; the child is any ACP agent (point the configured command at the
|
||||
* `acp-agent` example to "talk to our own process").
|
||||
*
|
||||
* Unlike the in-process backends (`-spawn`/`-fork`), the child does NOT share
|
||||
* this cordis context — it is a separate process with its own session, model
|
||||
* client, and tools. So this backend injects only `subagents` (no `agents`),
|
||||
* advertises NO start-time capabilities (an out-of-process child cannot enforce
|
||||
* the parent's depth/tool-filter), and ignores `request.parent`.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
|
||||
* export (the cordis Loader's `unwrapExports` does `exports.default ?? exports`,
|
||||
* so a stray default would drop the namespace — see docs/postmortem/0001).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-acp
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { type AcpRunSpec, type PermissionPolicy, startAcpRun } from './run.ts'
|
||||
|
||||
export const name = 'subagent-acp'
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: how to spawn and drive the child ACP agent process. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `acp`). */
|
||||
providerName: string
|
||||
/** The executable to spawn for each run (the child ACP agent). */
|
||||
command: string
|
||||
/** Arguments passed to {@link command}. */
|
||||
args: string[]
|
||||
/**
|
||||
* Working directory for the child process and its ACP session. Defaults to
|
||||
* the parent process's cwd when omitted.
|
||||
*/
|
||||
cwd?: string
|
||||
/**
|
||||
* How to auto-answer the child's `session/request_permission` prompts:
|
||||
* `reject` (default — decline every prompt) or `allow` (approve via the first
|
||||
* allow-shaped option). The first cut surfaces no prompt to a human.
|
||||
*/
|
||||
permission: PermissionPolicy
|
||||
/**
|
||||
* Extra environment variables for the child process — e.g. the child
|
||||
* harness's own `DEEPSEEK_API_KEY`. Forwarded on top of a credential-scrubbed
|
||||
* copy of the parent env, so an explicit key here reaches the child while
|
||||
* ambient secrets do not leak implicitly.
|
||||
*/
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
providerName: z.string().default('acp'),
|
||||
command: z.string().required(),
|
||||
args: z.array(z.string()).default([]),
|
||||
cwd: z.string(),
|
||||
permission: z.union(['allow', 'reject'] as const).default('reject'),
|
||||
env: z.dict(z.string()).default({}),
|
||||
})
|
||||
|
||||
/**
|
||||
* The ACP provider. Advertises NO start-time capabilities: an out-of-process
|
||||
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
|
||||
* a request needing any of them before `start` runs).
|
||||
*/
|
||||
class AcpProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
|
||||
|
||||
constructor(readonly name: string, private readonly config: Config) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
const spec: AcpRunSpec = {
|
||||
command: this.config.command,
|
||||
args: this.config.args,
|
||||
cwd: this.config.cwd ?? process.cwd(),
|
||||
permission: this.config.permission,
|
||||
env: this.config.env,
|
||||
}
|
||||
return startAcpRun(request, spec)
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new AcpProvider(config.providerName, config))
|
||||
}
|
||||
Reference in New Issue
Block a user