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

179 lines (178 loc) • 8.61 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/. /** * GitHub Copilot CLI result extractor (P2, plan §"The adapter contract" * step 3 / §"Structured-output normalization", tier "native-json"). * * Normalizes one raw {@link AgentRunResult} from a headless `copilot` 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. * * Copilot'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** — a result envelope, e.g. * `{"type":"result","session_id":"…","result":"<final answer>"}`. * Pretty-printed multi-line JSON is included (whole-stdout parse is * attempted first). Only objects carrying a transport marker (a `type` * discriminator or a session-id field) are unwrapped; a bare JSON answer * with no marker (a schema unit's `{"result":"ok"}`) is passed through * raw so the engine's schema validator sees the whole object. * 2. **JSONL event stream** — one JSON object per line * (`session.start` / assistant `message` / `result` events); the LAST * text-bearing event wins, the first session-id-bearing event supplies * the session id. Non-JSON banner lines 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 (`result`/`response`/`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. * * Registered: `copilotResultExtractor` is `CopilotHarness.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. */ const TEXT_KEYS = ["result", "response", "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", "thread_id", "threadId"]; /** * Coerce a candidate value into text. Handles the shapes Copilot's envelope * nests answers in: bare strings, `{content|text}` wrappers (assistant * message objects), and arrays of `{type:"text",text}` content blocks. * 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; } /** * `type` discriminator values Copilot's `--output-format json` envelopes are * documented to use (plus the `session.*` event family). Deliberately a * CLOSED set: a schema unit's own answer may legitimately be a discriminated * union with a `type` field (`{"type":"success","output":"data"}`), and * treating ANY string `type` as a transport marker would unwrap — and * corrupt — such answers. An unrecognized type degrades to raw pass-through, * which the schema validator handles (whole object) and free-text tolerates. */ const ENVELOPE_TYPES = new Set(["result", "response", "message", "assistant", "error"]); /** * Does this parsed object look like one of Copilot's transport ENVELOPES (a * `result`/event document) rather than a bare JSON answer the model produced? * * Copilot's `--output-format json` documents always carry a transport marker: * a known `type` discriminator (`"result"`, `"session.start"`, `"message"`, …) * and, in every documented result envelope, a session-id field. A bare * structured answer a schema unit was asked for — e.g. `{"result":"ok"}` or * `{"type":"success","output":"data"}` — carries neither a known discriminator * nor (normally) a session-id key. Unwrapping such an answer would hand the * schema validator only the field value (`"ok"`) and reject the run despite * valid JSON (PR #714 review), so we only unwrap objects that are genuinely * envelopes and pass everything else through raw. */ function isTransportEnvelope(value) { const type = value.type; if (typeof type === "string" && (ENVELOPE_TYPES.has(type) || type.startsWith("session."))) return true; return extractSessionId(value) !== undefined; } /** * Normalize a raw copilot run result into `{ text, sessionId? }`. * See the module doc for the three stdout shapes handled. */ export const copilotResultExtractor = (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; // A bare JSON OBJECT with no transport marker is a legitimate structured // answer (a schema unit's `{"result":"ok"}`), NOT an envelope — pass its // raw JSON through so the engine's schema validator receives the whole // object instead of an unwrapped field value (PR #714 review). Only // genuine Copilot envelopes are unwrapped; non-object JSON (a top-level // string/array/primitive) keeps the prior extraction. Either way, an // unknown envelope with no recognized text key falls back to raw stdout. const text = isRecord(whole) && !isTransportEnvelope(whole) ? raw : (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 } : {}) }; };