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

276 lines (275 loc) • 13 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/. /** * Run a built execution: the one place credentials are read. A direct LLM * goes to `chatCompletion`, an agent engine to `runAgent` (the harness builder * turns the request into argv), an SDK engine to `runOpencodeSdk`. Every * credential and passthrough value that could reach the child is redacted * from the result. */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { assertNever } from "../../core/assert.js"; import { UsageError } from "../../core/errors.js"; import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose, redactSensitiveText, redactSensitiveValue, } from "../../core/redaction.js"; import { MODEL_WORK_POLICY_ID } from "../../execution/source.js"; import { chatCompletion, LlmCallError } from "../../llm/client.js"; import { emitLlmUsage } from "../../llm/usage-telemetry.js"; import { getHarness } from "../harnesses/index.js"; import { closeServer as disposeOpencodeSdkServers, runOpencodeSdk } from "../harnesses/opencode-sdk/sdk-runner.js"; import { modelFromArgs } from "./builder-shared.js"; import { lookupApiKeyFileValue, lookupApiKeySecretRefValue, lookupCredentialFromEnv, resolveEngine, } from "./engine-resolution.js"; import { materializeLlmRunnerConnection, materializeSdkFallbackConnection } from "./runner.js"; import { runAgent } from "./spawn.js"; const OPERATIONAL_OPTIONS = [ "stdio", "parseOutput", "signal", "envSource", "spawn", "setTimeoutFn", "clearTimeoutFn", "onEvent", ]; /** Every value that can reach one dispatch and must never be echoed back: credentials and secret-looking env. */ export function collectDispatchSensitiveValues(runner, opts, envSource = opts.envSource ?? process.env) { const values = []; if (runner.kind === "llm") { values.push(runner.connection.apiKey, lookupCredentialFromEnv(runner.credential, envSource), runner.apiKeyFile ? lookupApiKeyFileValue(runner.apiKeyFile) : undefined, runner.apiKeySecretRef ? lookupApiKeySecretRefValue(runner.apiKeySecretRef) : undefined); } else { if (runner.kind === "sdk") { values.push(runner.fallbackConnection?.apiKey, lookupCredentialFromEnv(runner.fallbackCredential, envSource), runner.fallbackApiKeyFile ? lookupApiKeyFileValue(runner.fallbackApiKeyFile) : undefined, runner.fallbackApiKeySecretRef ? lookupApiKeySecretRefValue(runner.fallbackApiKeySecretRef) : undefined); } values.push(...Object.values(runner.profile.env ?? {})); for (const name of runner.profile.envPassthrough) { if (!isEnvPassthroughValueSafeToExpose(name, envSource[name])) values.push(envSource[name]); } } for (const [name, value] of Object.entries(opts.env ?? {})) { if (!isEnvPassthroughValueSafeToExpose(name, value)) values.push(value); } return collectSensitiveValues(values); } function redactResult(result, sensitiveValues) { return { ...result, stdout: redactSensitiveText(result.stdout, sensitiveValues), stderr: redactSensitiveText(result.stderr, sensitiveValues), ...(result.error !== undefined ? { error: redactSensitiveText(result.error, sensitiveValues) } : {}), ...(result.parsed !== undefined ? { parsed: redactSensitiveValue(result.parsed, sensitiveValues) } : {}), }; } function llmFailureReason(error) { if (!(error instanceof LlmCallError)) return "spawn_failed"; switch (error.code) { case "aborted": return "aborted"; case "timeout": return "timeout"; case "rate_limited": return "llm_rate_limit"; case "parse_error": case "provider_html_error": return "parse_error"; default: return "spawn_failed"; } } const USAGE_ERROR_CODES = { timeout: "timeout", aborted: "aborted", parse_error: "parse_error", llm_rate_limit: "rate_limited", }; /** * The model an agent or SDK dispatch ran: the request's, else the one an agent * CLI's own `args` select, which its command carries when the request names * none. An SDK server never sees `args`, so an SDK engine with no model named * has none to report: opencode picks it. */ function dispatchedModel({ request, runner }) { return request.model?.resolved ?? (runner.kind === "agent" ? modelFromArgs(runner.profile.args) : undefined); } /** * One usage record for an agent or SDK dispatch, through the same sink and * ambient `withLlmStage` attribution as the LLM transport's per-HTTP-attempt * records: the model it ran, and tokens when the runner reported them. */ function recordDispatchUsage(execution, result) { const { inputTokens, outputTokens, reasoningTokens } = result.usage ?? {}; const reported = [inputTokens, outputTokens, reasoningTokens].filter((count) => count !== undefined); const model = dispatchedModel(execution); emitLlmUsage({ outcome: result.ok ? "success" : "error", modelSource: "configured", ...(model ? { model } : {}), durationMs: result.durationMs, ...(inputTokens !== undefined ? { promptTokens: inputTokens } : {}), ...(outputTokens !== undefined ? { completionTokens: outputTokens } : {}), ...(reasoningTokens !== undefined ? { reasoningTokens } : {}), ...(reported.length > 0 ? { totalTokens: reported.reduce((sum, count) => sum + count, 0) } : {}), ...(result.ok ? {} : { errorCode: (result.reason && USAGE_ERROR_CODES[result.reason]) ?? "unknown_error" }), }); } async function dispatchRunner(runner, prompt, opts, seams, llm) { const envSource = opts.envSource ?? process.env; const secrets = collectDispatchSensitiveValues(runner, opts, envSource); let result; switch (runner.kind) { case "llm": { if (!llm) throw new Error("an llm runner dispatches only a built execution's chat messages"); const connection = materializeLlmRunnerConnection(runner, envSource); if (connection.apiKey) secrets.push(connection.apiKey); result = await llm(connection, opts); break; } case "agent": result = await (seams.runAgent ?? runAgent)(runner.profile, prompt, opts); break; case "sdk": { const fallbackConnection = materializeSdkFallbackConnection(runner, envSource); if (fallbackConnection?.apiKey) secrets.push(fallbackConnection.apiKey); result = await (seams.runSdk ?? runOpencodeSdk)(runner.profile, prompt, opts, fallbackConnection); break; } default: return assertNever(runner); } return redactResult(result, collectSensitiveValues(secrets)); } /** * The scratch working directory for one model-work dispatch on an agent or SDK * engine, so the edit the model-work tool policy grants never reaches the * stash. */ function createModelWorkDirectory() { return fs.mkdtempSync(path.join(os.tmpdir(), "akm-model-work-")); } /** * Run a built execution. Credentials are read here, once per call, and never * returned. Model work on an agent or SDK engine runs in a scratch working * directory that akm creates for the dispatch and removes after it, and must * end with an answer: an agent that stops with none (opencode at its step * limit, for one) has failed with `parse_error`. */ export async function runExecution(execution, options = {}) { const scratch = execution.runner.kind !== "llm" && execution.request.authorization.policy?.id === MODEL_WORK_POLICY_ID ? createModelWorkDirectory() : undefined; try { return await runBuiltExecution(execution, options, scratch); } finally { if (scratch) fs.rmSync(scratch, { recursive: true, force: true }); } } /** A successful reply's text, unwrapped from its harness's framing (claude's `--output-format json` result envelope, for one). */ export function unwrapHarnessReply(runner, result) { const extractor = runner.kind === "agent" ? getHarness(runner.profile.platform ?? runner.profile.name)?.resultExtractor : undefined; return extractor ? extractor(result) : { text: result.stdout }; } /** A model-work reply's answer: its harness's framing stripped, and no answer is a `parse_error`. */ function modelWorkAnswer(runner, result) { const extracted = unwrapHarnessReply(runner, result); const answer = { ...result, stdout: extracted.text, ...(extracted.sessionId ? { sessionId: extracted.sessionId } : {}), }; if (extracted.text.trim() !== "") return answer; return { ...answer, ok: false, reason: "parse_error", error: `Engine "${runner.engine}" returned no answer.` }; } /** `scratch` is the model-work working directory, set only for model work on an agent or SDK engine. */ async function runBuiltExecution(execution, options, scratch) { const opts = { ...execution.options, ...(scratch ? { cwd: scratch } : {}) }; const operational = options.runOptions ?? {}; for (const key of OPERATIONAL_OPTIONS) { if (operational[key] !== undefined) opts[key] = operational[key]; } if (options.eventSource !== undefined) { opts.env = { ...execution.options.env, AKM_EVENT_SOURCE: options.eventSource }; } const chat = options.chat ?? chatCompletion; const llm = async (connection, callOpts) => { const started = Date.now(); try { const stdout = await chat(connection, [...(execution.messages ?? [])], { ...execution.chatOptions, ...(Object.hasOwn(callOpts, "timeoutMs") ? { timeoutMs: callOpts.timeoutMs } : {}), ...(callOpts.signal ? { signal: callOpts.signal } : {}), ...(options.onRetryAttempt ? { onRetryAttempt: options.onRetryAttempt } : {}), }); return { ok: true, exitCode: 0, stdout, stderr: "", durationMs: Date.now() - started }; } catch (error) { // Returned rather than thrown so the redaction below covers provider bodies. return { ok: false, exitCode: null, stdout: "", stderr: "", durationMs: Date.now() - started, error: error instanceof Error ? error.message : String(error), reason: llmFailureReason(error), ...(error instanceof LlmCallError ? { llmErrorCode: error.code } : {}), }; } }; let result = await dispatchRunner(execution.runner, execution.prompt, opts, options, llm); if (scratch !== undefined && result.ok) result = modelWorkAnswer(execution.runner, result); // The LLM transport records each HTTP attempt itself. if (execution.runner.kind !== "llm") recordDispatchUsage(execution, result); return result; } /** `akm agent`: launch an agent engine interactively, with no prompt of its own. */ export async function executeInteractiveAgentInvocation(input, seams = {}) { const runner = resolveEngine(input.engine, input.config); if (runner.kind === "llm") { throw new UsageError(`Engine "${input.engine}" is an LLM engine; akm agent requires an agent engine.`, "INVALID_FLAG_VALUE"); } const opts = { stdio: runner.kind === "sdk" ? runner.profile.stdio : "interactive", parseOutput: "text", ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : runner.timeoutMs !== undefined ? { timeoutMs: runner.timeoutMs } : {}), ...(input.cwd ? { cwd: input.cwd } : runner.profile.workspace ? { cwd: runner.profile.workspace } : {}), }; return { engine: input.engine, result: await dispatchRunner(runner, "", opts, seams) }; } /** * Close the `opencode serve` children the SDK runner keeps for reuse. Its own * teardown hangs off `process.once("exit")`, which never fires while a child * holds the event loop open, so the CLI and workflow engine call this in * `finally` blocks. A no-op when no SDK server was started. */ export async function disposeDispatchResources() { await disposeOpencodeSdkServers(); } /** * Fail fast, before an operation makes any durable change, when `runner`'s * required credential is missing. Reads it and keeps nothing: every dispatch * reads it again. */ export function assertRunnerCredentials(runner, envSource) { if (runner.kind === "llm") materializeLlmRunnerConnection(runner, envSource); if (runner.kind === "sdk") materializeSdkFallbackConnection(runner, envSource); }