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
150 lines (149 loc) • 6.73 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/.
/**
* Gemini CLI result extractor (P2, plan §"The adapter contract" step 3 /
* §"Structured-output normalization", tier "native-json").
*
* Normalizes one raw {@link AgentRunResult} from a headless `gemini` run into
* `{ text, sessionId? }` — the {@link AgentResultExtraction} seam. The
* engine's shared schema-validation / retry-until-valid loop runs *after*
* this; the extractor only strips transport framing.
*
* Gemini's stdout takes one of three shapes depending on flags/version
* (`--output-format json` per the capability matrix), all handled here:
*
* 1. **Single JSON document** — the documented `--output-format json`
* envelope, e.g. `{"response":"<final answer>","stats":{...}}` (an
* `error` variant carries `{error:{type,message,code}}` and no usable
* response — that degrades to the raw-stdout fallback so the engine's
* failure handling sees the whole envelope). Pretty-printed multi-line
* JSON is included (whole-stdout parse is attempted first).
* 2. **JSONL event stream** (`--output-format stream-json`) — one JSON
* object per line; the LAST text-bearing event wins, the first
* session-id-bearing event supplies the session id. Non-JSON banner
* lines ("Loaded cached credentials.", update notices) are skipped.
* 3. **Plain text** — no JSON anywhere; trimmed stdout passes through
* verbatim (the engine's embedded-JSON parsing still runs downstream for
* schema units).
*
* Key names are matched tolerantly (`response` first — Gemini's documented
* envelope key — then `result`/`text`/`output`/`content`/`message`;
* snake_case and camelCase session-id variants) so point-release renames in
* the CLI's envelope degrade to the plain-text fallback instead of
* hard-failing — version churn stays contained in this one file, per the
* adapter contract. The session id feeds `--resume <id>` opportunistically;
* `workflow_run_units` remains the durable source of truth.
*
* Registered: `geminiResultExtractor` is `GeminiHarness.resultExtractor`
* (`./index.ts`); the engine resolves it via `getHarness()` in
* `workflows/exec/native-executor.ts` to normalize agent-unit stdout before
* schema validation.
*/
import { isRecord, tryParseJson } from "../../../core/common.js";
/**
* Keys that may carry the final answer, in precedence order. `response` is
* Gemini's documented `--output-format json` envelope key.
*/
const TEXT_KEYS = ["response", "result", "text", "output", "content", "message"];
/** Keys that may carry the harness-native session id, in precedence order. */
const SESSION_KEYS = ["session_id", "sessionId", "conversation_id", "conversationId", "chat_id", "chatId"];
/**
* Coerce a candidate value into text. Handles the shapes Gemini nests
* answers in: bare strings, `{content|text}` wrappers (assistant message
* objects), and arrays of `{type:"text",text}` content blocks (Gemini API
* `parts`-style lists). Depth-bounded: JSON.parse output is acyclic, but
* nesting is capped anyway so a pathological envelope cannot recurse
* unboundedly.
*/
function textOf(value, depth = 0) {
if (depth > 4)
return undefined;
if (typeof value === "string")
return value;
if (Array.isArray(value)) {
const parts = [];
for (const block of value) {
const t = textOf(block, depth + 1);
if (t)
parts.push(t);
}
return parts.length > 0 ? parts.join("\n") : undefined;
}
if (isRecord(value)) {
return textOf(value.content ?? value.text, depth + 1);
}
return undefined;
}
/** Extract the final-answer text from one parsed JSON value, if any. */
function extractText(value) {
if (typeof value === "string")
return value;
if (!isRecord(value))
return undefined;
for (const key of TEXT_KEYS) {
if (!(key in value))
continue;
const text = textOf(value[key]);
if (text !== undefined)
return text;
}
return undefined;
}
/** Extract a harness-native session id from one parsed JSON value, if any. */
function extractSessionId(value) {
if (!isRecord(value))
return undefined;
for (const key of SESSION_KEYS) {
const candidate = value[key];
if (typeof candidate === "string" && candidate.length > 0)
return candidate;
}
return undefined;
}
/**
* Normalize a raw gemini run result into `{ text, sessionId? }`.
* See the module doc for the three stdout shapes handled.
*/
export const geminiResultExtractor = (result) => {
// The raw result's own sessionId (SDK-style paths) is the fallback; an id
// found in the output stream wins because it is fresher.
const fallbackSessionId = result.sessionId;
const raw = result.stdout.trim();
// Shape 1 — whole stdout is one JSON document (spawn may have pre-parsed
// it when the profile requested parseOutput: "json").
const whole = result.parsed !== undefined ? result.parsed : raw.length > 0 ? tryParseJson(raw) : undefined;
if (whole !== undefined) {
const sessionId = extractSessionId(whole) ?? fallbackSessionId;
// Unknown envelope (no recognized text key — includes the error-only
// variant): fall back to raw stdout so downstream embedded-JSON parsing
// and failure handling still have the full material to work with.
const text = extractText(whole) ?? raw;
return { text, ...(sessionId ? { sessionId } : {}) };
}
// Shape 2 — JSONL event stream: last text-bearing event wins; first
// session-id-bearing event supplies the id. Non-JSON lines are banner
// noise and are skipped.
let lastText;
let streamSessionId;
let sawJsonLine = false;
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (trimmed.length === 0)
continue;
const event = tryParseJson(trimmed);
if (event === undefined)
continue;
sawJsonLine = true;
streamSessionId ??= extractSessionId(event);
const text = extractText(event);
if (text !== undefined)
lastText = text;
}
if (sawJsonLine) {
const sessionId = streamSessionId ?? fallbackSessionId;
return { text: lastText ?? raw, ...(sessionId ? { sessionId } : {}) };
}
// Shape 3 — plain text passthrough.
return { text: raw, ...(fallbackSessionId ? { sessionId: fallbackSessionId } : {}) };
};