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