/** * ACP agent process manager. * * Wraps a child process running an ACP-compliant agent, managing its lifecycle * and providing typed methods for the ACP client-side protocol operations. */ import { type ChildProcess, spawn } from "node:child_process"; import { Readable, Writable } from "node:stream"; import type { Agent, AuthMethod, AuthMethodEnvVar, Client, InitializeResponse, NewSessionResponse, PromptResponse, } from "@agentclientprotocol/sdk"; import * as acp from "@agentclientprotocol/sdk"; import { getLogger } from "../util/logger.js"; import { AcpAuthRequiredError, isAcpAuthRequired } from "./auth-required.js"; import type { AcpAgentConfig } from "./types.js"; const log = getLogger("acp"); /** * Rough byte cap for retained stderr. Oldest lines are evicted once the sum of * retained line lengths exceeds this, so the stderr ring (read via stderrSince) * stays bounded. */ const STDERR_RETENTION_BYTES = 4096; function isEnvVarMethod( method: AuthMethod, ): method is AuthMethodEnvVar & { type: "env_var" } { return "type" in method && method.type === "env_var"; } /** * Factory function type for creating ACP client handlers. * PR 5 will provide the real VellumAcpClientHandler implementation. */ export type AcpClientFactory = (agent: Agent) => Client; /** * Manages an ACP agent child process and its protocol connection. */ export class AcpAgentProcess { private proc: ChildProcess | null = null; private connection: acp.ClientSideConnection | null = null; private initializeResponse: InitializeResponse | null = null; /** * Merged env captured at spawn() so auth satisfiability checks match the * env the child process actually received, even if process.env changes * afterwards. */ private spawnedEnv: NodeJS.ProcessEnv | null = null; /** * Ring of the most recent stderr lines, bounded to ~STDERR_RETENTION_BYTES. * Read once on the failure path to surface the real adapter error. Each * entry carries a monotonic `seq` so a caller can scope reads to lines * produced after a checkpoint (see markStderr/stderrSince). */ private stderrRing: { seq: number; text: string }[] = []; private stderrRingBytes = 0; /** * Cumulative count of stderr lines ever retained, including ones later * evicted. Assigned as each line's `seq`; markStderr() snapshots it. */ private stderrSeq = 0; constructor( public readonly agentId: string, private readonly config: AcpAgentConfig, private readonly clientFactory: AcpClientFactory, ) {} /** * Spawns the agent command as a child process and sets up the ACP connection. */ spawn(cwd: string): void { log.info( { agentId: this.agentId, command: this.config.command, cwd }, "Spawning ACP agent process", ); this.spawnedEnv = { ...process.env, ...this.config.env }; this.proc = spawn(this.config.command, this.config.args, { cwd, stdio: ["pipe", "pipe", "pipe"], env: this.spawnedEnv, }); const stream = acp.ndJsonStream( Writable.toWeb(this.proc.stdin!) as WritableStream, Readable.toWeb( this.proc.stdout!, ) as unknown as ReadableStream, ); this.connection = new acp.ClientSideConnection( (agent) => this.clientFactory(agent), stream, ); // Capture stderr so agent crash details appear in logs this.proc.stderr?.on("data", (chunk: Buffer) => { const text = chunk.toString().trim(); if (text) { log.error({ agentId: this.agentId, stderr: text }, "ACP agent stderr"); this.retainStderr(text); } }); // Handle process exit this.proc.on("exit", (code) => { this.handleProcessExit(code); }); this.proc.on("error", (err) => { log.error( { agentId: this.agentId, error: err.message }, "ACP agent process error", ); }); } /** * Appends a stderr line to the ring, evicting oldest lines once the retained * total exceeds STDERR_RETENTION_BYTES. Always keeps at least the newest line * so a single oversized line is not fully dropped. */ private retainStderr(text: string): void { // Cap a single oversized line: the "keep newest" rule below never evicts it, // so an untruncated multi-MB chunk would blow the ring budget and make the // failure-path stderr scan (deriveFailureError) super-linear on huge input. // Keep the TAIL: the adapter's error JSON / last line sits at the end, and // deriveFailureError reads from the end, so dropping the head is lossless. const line = text.length > STDERR_RETENTION_BYTES ? text.slice(-STDERR_RETENTION_BYTES) : text; this.stderrSeq += 1; this.stderrRing.push({ seq: this.stderrSeq, text: line }); this.stderrRingBytes += Buffer.byteLength(line); while ( this.stderrRingBytes > STDERR_RETENTION_BYTES && this.stderrRing.length > 1 ) { const evicted = this.stderrRing.shift()!; this.stderrRingBytes -= Buffer.byteLength(evicted.text); } } /** * Snapshots the current cumulative stderr line count. Pair with * stderrSince() to read only stderr produced after this checkpoint, so a * prompt's failure derives from its own stderr rather than lines retained * from startup, resume, or an earlier (possibly cancelled) prompt. */ markStderr(): number { return this.stderrSeq; } /** * Returns retained stderr lines produced after `mark` (a markStderr() * checkpoint), joined by newlines. Best-effort: lines pushed after the mark * but since evicted are simply absent; returns "" when nothing newer remains. */ stderrSince(mark: number): string { return this.stderrRing .filter((e) => e.seq > mark) .map((e) => e.text) .join("\n"); } /** * Initializes the ACP connection by negotiating protocol version and capabilities. */ async initialize(): Promise { const connection = this.requireConnection(); log.info({ agentId: this.agentId }, "Initializing ACP connection"); const response = await connection.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: "vellum", version: "1.0.0" }, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true, }, }); this.initializeResponse = response; return response; } /** * Whether the agent advertised support for `session/load` at initialize. * Returns false before initialize() resolves. */ get supportsLoadSession(): boolean { return this.initializeResponse?.agentCapabilities?.loadSession === true; } /** * Whether the agent advertised support for `session/resume` at initialize. * Returns false before initialize() resolves. */ get supportsSessionResume(): boolean { return ( this.initializeResponse?.agentCapabilities?.sessionCapabilities?.resume != null ); } /** * Authentication methods the agent advertised at initialize. * Returns an empty array before initialize() resolves. */ private get authMethods(): AuthMethod[] { return this.initializeResponse?.authMethods ?? []; } /** * Selects the first advertised env_var auth method whose required variables * are all present (non-empty) in the env the agent process was spawned with. * * Terminal-type and agent-driven (untyped) methods are never selected: * auto-triggering an interactive login would hang the headless daemon. */ private selectEnvVarAuthMethod(): AuthMethod | undefined { const env = this.spawnedEnv; if (!env) { return undefined; } return this.authMethods.find((method) => { if (!isEnvVarMethod(method)) { return false; } // `vars` is required by the SDK type, but agent responses aren't // runtime-validated — tolerate an out-of-spec agent omitting it so the // caller gets the friendly auth error instead of a TypeError. const requiredVars = (method.vars ?? []).filter((v) => !v.optional); if (requiredVars.length === 0) { return false; } return requiredVars.every((v) => { const value = env[v.name]; return typeof value === "string" && value.length > 0; }); }); } /** * Returns the live connection, throwing the standard not-spawned error if * the agent was never spawned or its process has since exited. */ private requireConnection(): acp.ClientSideConnection { if (!this.connection) { throw new Error(`ACP agent "${this.agentId}" is not spawned`); } return this.connection; } /** * Runs an operation, and if the agent rejects with the ACP auth-required * error, authenticates via a satisfiable env_var auth method and retries * the operation exactly once. */ private async withAuthRetry(op: () => Promise): Promise { try { return await op(); } catch (err) { if (!isAcpAuthRequired(err)) { throw err; } // The agent may have exited between the auth_required rejection and // this retry path; fail with the standard not-spawned error. const connection = this.requireConnection(); const method = this.selectEnvVarAuthMethod(); if (!method) { // Typed so the session manager can tell an auth failure from a crash // and surface a re-authentication path. The remediation text is split // by what the agent advertises: env-var and `credentials prompt` // advice only helps when an env_var method exists to satisfy, and for // terminal-login-only adapters it sends the user chasing a CLI // workaround for a credential the app repairs on its own. throw new AcpAuthRequiredError( this.agentId, this.hasEnvVarAuthMethod() ? `ACP agent "${this.agentId}" requires authentication. ` + `Advertised methods: ${this.describeAuthMethods()}. ` + "Set the required env var under acp.agents..env in config.json, " + "or collect it securely via 'assistant credentials prompt --service acp --field --label \"