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
JavaScript
// 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 };
}