UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

241 lines (240 loc) • 9.88 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Agent CLI spawn wrapper (v1 spec §12.2). * * Single helper that owns: * • Process spawn (Bun's subprocess API). * • Captured vs interactive stdio. * • Hard timeout (per-call override or profile default). * • Structured failure reasons — `timeout`, `spawn_failed`, * `non_zero_exit`, `parse_error`. * * NEVER imports an LLM SDK. Agents are reachable only via shell-out; * this is a pre-emptive guarantee against the #222 invariant. */ import { parseEmbeddedJsonResponse } from "../../core/parse.js"; import { collectAllowlistedEnv } from "../../core/spawn-env.js"; import { runManagedSubprocess, streamCaptureFailure, } from "../../core/subprocess.js"; import { getCommandBuilder } from "./builders.js"; import { DEFAULT_AGENT_TIMEOUT_MS } from "./config.js"; /** * Build the child env. Starts empty and copies through: * • Every name in `profile.envPassthrough` (via the shared * {@link collectAllowlistedEnv}, which also supplements PATH for * scheduler contexts where the inherited PATH is stripped). * • Every entry in `profile.env`. * • Every entry in `options.env` (highest precedence). */ function buildChildEnv(profile, options) { const env = collectAllowlistedEnv(profile.envPassthrough, options.envSource ?? process.env); if (profile.env) { for (const [k, v] of Object.entries(profile.env)) env[k] = v; } if (options.env) { for (const [k, v] of Object.entries(options.env)) env[k] = v; } return env; } /** * This path's phrasing of the SHARED incomplete-capture verdict * ({@link streamCaptureFailure} in `core/subprocess.ts`). The classification * lives in the primitive so the agent path and the workflow `exec` path cannot * drift apart on what "the capture did not complete" means; only the sentence * naming the profile is local. Message text is unchanged from the inlined copy. */ function streamFailureMessage(profileName, stdout, stderr) { const failures = streamCaptureFailure(stdout, stderr); return failures === undefined ? undefined : `agent CLI "${profileName}" output capture failed: ${failures}`; } /** * Spawn the agent CLI described by `profile` with `prompt` (forwarded as * the last positional arg by default) and return a structured result. * * The `prompt` argument is appended to `profile.args` (and `options.args`) * unless it is `undefined`. Pass `prompt = ""` to forward an explicit * empty positional, or pass extra args via `options.args`. * * Failure modes (see {@link AgentFailureReason}): * * • `spawn_failed` — the spawn call threw synchronously. * • `timeout` — exceeded the resolved timeout. * • `non_zero_exit` — child exited with a non-zero code. * • `parse_error` — `parseOutput === "json"` and stdout was not JSON. * * `ok === true` requires exit code 0 and (if `parseOutput === "json"`) * a successful `JSON.parse`. */ export async function runAgent(profile, prompt, options = {}) { const stdioMode = options.stdio ?? profile.stdio; // null = explicitly disabled (no kill timer). undefined = runtime default. const timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : DEFAULT_AGENT_TIMEOUT_MS; const parseOutput = options.parseOutput ?? profile.parseOutput; const setTimeoutImpl = options.setTimeoutFn ?? setTimeout; const clearTimeoutImpl = options.clearTimeoutFn ?? clearTimeout; // Observability seam — ids/status only, best-effort (see RunAgentOptions.onEvent). const emitSpawnEvent = (type, data) => { if (!options.onEvent) return; try { options.onEvent({ type, data }); } catch { // Observability must never break the dispatch. } }; // Build argv via the platform-specific builder when dispatch params are // provided; otherwise use the direct positional-prompt form. let builtArgv; let builtEnv; if (options.dispatch !== undefined) { const builder = getCommandBuilder(profile.platform ?? profile.name, options.builderRegistry); const built = builder.build(profile, options.dispatch); builtArgv = built.argv; builtEnv = built.env; } else { const positionalArgs = [...profile.args, ...(options.args ?? [])]; if (prompt !== undefined) positionalArgs.push(prompt); builtArgv = [profile.bin, ...positionalArgs]; } // Extra args (e.g. forwarded CLI positionals) are appended after the builder output. const finalArgv = [...builtArgv, ...(options.dispatch ? (options.args ?? []) : [])]; const env = { ...buildChildEnv(profile, options), ...(builtEnv ?? {}) }; const start = Date.now(); // Cooperative cancel: refuse to spawn at all when the caller's signal is // already aborted (e.g. the run's budget was exhausted before this unit). if (options.signal?.aborted) { return { ok: false, exitCode: null, stdout: "", stderr: "", durationMs: 0, reason: "aborted", error: `agent CLI "${profile.name}" not started: caller signal already aborted`, }; } // Spawn/timeout/abort/capture are owned by the managed-subprocess primitive // (src/core/subprocess.ts): process-GROUP spawn (detached in captured mode), // the SIGTERM→SIGKILL kill ladder on timeout/abort, bounded output capture, // and the optional stdin payload. runAgent keeps its agent-specific surface — // the onEvent observability seam, the failure taxonomy, and JSON parsing. // // `onSpawn` fires spawn_start once the child is live (never for a pre-spawn // abort or a synchronous spawn failure), and captures the proc so spawn_exit // can carry the same pid. let spawnedProc; const result = await runManagedSubprocess(finalArgv, { capture: stdioMode === "captured", env, ...(options.cwd ? { cwd: options.cwd } : {}), ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), timeoutMs, ...(options.signal ? { signal: options.signal } : {}), ...(options.spawn ? { spawnFn: options.spawn } : {}), setTimeoutFn: setTimeoutImpl, clearTimeoutFn: clearTimeoutImpl, onSpawn: (proc) => { spawnedProc = proc; emitSpawnEvent("spawn_start", { profile: profile.name, ...(typeof proc.pid === "number" ? { pid: proc.pid } : {}), }); }, }); const pidField = spawnedProc && typeof spawnedProc.pid === "number" ? { pid: spawnedProc.pid } : {}; const durationMs = Date.now() - start; // Spawn/exit failure. A synchronous spawn throw never reached a live child // (no spawn_start fired, so no spawn_exit either); a rejected proc.exited did // (spawn_start already fired, so it emits a spawn_exit with status spawn_failed). if (result.spawnError) { if (spawnedProc) { emitSpawnEvent("spawn_exit", { profile: profile.name, ...pidField, exitCode: null, status: "spawn_failed" }); } return { ok: false, exitCode: null, stdout: "", stderr: "", durationMs, reason: "spawn_failed", error: result.spawnError.message, }; } const { exitCode, timedOut, aborted, stdout, stderr } = result; emitSpawnEvent("spawn_exit", { profile: profile.name, ...pidField, exitCode, status: aborted ? "aborted" : timedOut ? "timeout" : exitCode !== 0 ? "non_zero_exit" : "ok", }); if (aborted) { return { ok: false, exitCode, stdout, stderr, durationMs, reason: "aborted", error: `agent CLI "${profile.name}" aborted by caller signal`, }; } if (timedOut) { return { ok: false, exitCode, stdout, stderr, durationMs, reason: "timeout", error: `agent CLI "${profile.name}" timed out after ${timeoutMs ?? 0}ms`, }; } const captureFailure = streamFailureMessage(profile.name, result.stdoutRead, result.stderrRead); if (captureFailure) { return { ok: false, exitCode, stdout, stderr, durationMs, reason: "spawn_failed", error: captureFailure, }; } if (exitCode !== 0) { return { ok: false, exitCode, stdout, stderr, durationMs, reason: "non_zero_exit", error: `agent CLI "${profile.name}" exited with code ${exitCode}`, }; } if (parseOutput === "json" && stdioMode === "captured") { // Strip <think> blocks and code fences, then parse with embedded-JSON // fallback for local LLMs that emit prose around the payload. Handles // both top-level `{…}` and `[…]` structures. const parsed = parseEmbeddedJsonResponse(stdout); if (parsed === undefined) { return { ok: false, exitCode, stdout, stderr, durationMs, reason: "parse_error", error: "no JSON structure found in agent output", }; } return { ok: true, exitCode, stdout, stderr, durationMs, parsed }; } return { ok: true, exitCode, stdout, stderr, durationMs }; }