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

158 lines (157 loc) • 8.07 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/. import { ConfigError } from "../core/errors.js"; import { DEFAULT_LLM_TIMEOUT_MS } from "../integrations/agent/config.js"; import { buildExecution, resolveExecution } from "../integrations/agent/execution.js"; import { runExecution } from "../integrations/agent/runner-dispatch.js"; import { isContextSizeError, LlmCallError } from "./client.js"; import { isLlmFeatureEnabled, tryLlmFeature, } from "./feature-gate.js"; /** * Classify a thrown LLM error into one of the three buckets. This is the single * home for the `isContextSizeError -> html -> other` ladder that was previously * inlined at every call site. */ export function classifyLlmError(err) { const message = err instanceof Error ? err.message : String(err); if (isContextSizeError(message)) return "context_limit"; if (err instanceof LlmCallError && err.code === "provider_html_error") return "html"; return "other"; } function own(value, key) { return value !== undefined && Object.hasOwn(value, key); } /** * @internal Exact request-to-cascade projection, exported for presence-semantics contracts. * Model work is bounded: with no timeout from the request or the runner, it * gets {@link DEFAULT_LLM_TIMEOUT_MS} on every runner kind. */ export function resolveStructuredCurrent(current, request, runner) { const out = current ? { ...current } : {}; const requestHasInference = own(request, "temperature") || own(request, "maxTokens") || own(request, "enableThinking"); const baseInference = current?.inference && typeof current.inference === "object" && !Array.isArray(current.inference) ? { ...current.inference } : {}; const inference = { ...baseInference }; if (own(request, "temperature") && request?.temperature !== undefined) inference.temperature = request.temperature; if (own(request, "maxTokens") && request?.maxTokens !== undefined) inference.maxTokens = request.maxTokens; if (own(request, "enableThinking") && request?.enableThinking !== undefined) { inference.enableThinking = request.enableThinking; } if (Object.keys(inference).length > 0 || requestHasInference) out.inference = inference; else if (current?.inference === null) out.inference = null; if (own(request, "responseSchema") && request?.responseSchema !== undefined) { out.outputSchema = request.responseSchema; } if (own(request, "timeoutMs")) out.timeout = request?.timeoutMs ?? null; else if (runner && !Object.hasOwn(runner, "timeoutMs") && !own(current, "timeout")) { out.timeout = DEFAULT_LLM_TIMEOUT_MS; } return Object.keys(out).length > 0 ? out : undefined; } function requireTerminalUserMessage(messages) { const terminal = messages.at(-1); if (!terminal || terminal.role !== "user") { throw new TypeError("callStructured messages must end with the terminal user command"); } return { content: terminal.content, conversation: messages.slice(0, -1).map((message) => ({ role: message.role, content: message.content })), }; } function dispatchFailure(result) { const message = result.error ?? result.stderr ?? result.reason ?? "dispatch failed"; const error = result.llmErrorCode ? new LlmCallError(message, result.llmErrorCode) : new Error(message); return Object.assign(error, { reason: result.reason, result }); } /** The runner's failure reason (`timeout`, `aborted`, …) a failed dispatch threw with, whatever its kind. */ export function dispatchFailureReason(err) { return err instanceof Error ? err.reason : undefined; } /** The failed dispatch's own result (an agent's exit code and stderr, say), whatever its kind. */ export function dispatchFailureResult(err) { return err instanceof Error ? err.result : undefined; } export async function callStructured(opts) { const { feature, akmConfig, enabled, messages, request, parse, onError, fallback, onFallback } = opts; // A disabled feature owns a true no-work path: it does not need a runner, // messages, authorization, lowering, or provider state. Some commands keep // their runner optional precisely because a disabled feature must fall back // before execution planning begins. if (akmConfig !== undefined && !isLlmFeatureEnabled(akmConfig, feature, enabled)) { return tryLlmFeature(feature, akmConfig, async () => fallback, fallback, { ...(own(request, "timeoutMs") ? { timeoutMs: request?.timeoutMs } : {}), ...(enabled !== undefined ? { enabled } : {}), onFallback, }); } const runner = opts.runner; if (!runner) throw new TypeError("callStructured requires a resolved runner"); const terminal = requireTerminalUserMessage(messages); // `gateSignal` aborts when the feature gate's timeout fires, so the dispatch stops with it. // Every structured call is unattended model work, so it runs under the model-work tool policy. const prepareInvocation = () => { const prepared = resolveExecution({ content: terminal.content, conversation: terminal.conversation, runner, current: resolveStructuredCurrent(opts.current, request, runner), modelWork: true, }); const lowered = buildExecution(prepared.request, prepared.runner); opts.onNotices?.(lowered.notices); return async (gateSignal) => { const signal = request?.signal && gateSignal ? AbortSignal.any([request.signal, gateSignal]) : (request?.signal ?? gateSignal); const runOptions = { ...request?.runOptions, ...(signal ? { signal } : {}) }; const result = await runExecution(lowered, { ...(request?.chat ? { chat: request.chat } : {}), ...(request?.runSdk ? { runSdk: request.runSdk } : {}), ...(request?.onRetryAttempt ? { onRetryAttempt: request.onRetryAttempt } : {}), ...(Object.keys(runOptions).length > 0 ? { runOptions } : {}), }); if (!result.ok) throw dispatchFailure(result); return parse(result.stdout); }; }; // UNGATED: run the chat+parse directly. Errors propagate — no `onError` // funnel — matching the pre-gate behaviour of direct callers. if (akmConfig === undefined) { return prepareInvocation()(); } // On an enabled path, preparation/lowering happen OUTSIDE tryLlmFeature so // authorization and invalid-config failures remain hard failures instead of // being mistaken for provider fallbacks. const invoke = prepareInvocation(); // GATED: run through `tryLlmFeature`. A throw inside is classified ONCE and // routed to `onError`; `tryLlmFeature` returns `fallback` on disablement/timeout. const outcome = await tryLlmFeature(feature, akmConfig, async (gateSignal) => { try { return { kind: "value", value: await invoke(gateSignal) }; } catch (err) { // Credential materialization remains dispatch-owned, so a missing // required symbolic credential can surface here. Preserve config // failures as hard pre-provider errors instead of sending them through // a leaf's provider/runtime fallback policy. if (err instanceof ConfigError) return { kind: "config-error", error: err }; return { kind: "value", value: onError(classifyLlmError(err), err) }; } }, { kind: "value", value: fallback }, { ...(own(request, "timeoutMs") ? { timeoutMs: request?.timeoutMs } : {}), ...(enabled !== undefined ? { enabled } : {}), onFallback, }); if (outcome.kind === "config-error") throw outcome.error; return outcome.value; }