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

136 lines (135 loc) • 6.88 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/. /** * OpenHands CLI result extractor (P2, plan §"The adapter contract" step 3 / * §"Structured-output normalization", tier "native-json"). * * Normalizes one raw {@link AgentRunResult} from a headless * `openhands --headless --json` 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. * * With `--json` OpenHands emits ONE JSON event per stdout line — its * action/observation event stream, e.g.: * * {"id":0,"source":"user","action":"message","args":{"content":"<task>"},"message":"<task>"} * {"id":1,"source":"agent","action":"run","args":{"command":"ls"},"message":"Running command: ls"} * {"id":2,"source":"agent","observation":"run","content":"README.md","message":"Command `ls` executed."} * {"id":3,"source":"agent","action":"message","args":{"content":"<answer>"},"message":"<answer>"} * {"id":4,"source":"agent","action":"finish","args":{"final_thought":"<answer>","outputs":{}},"message":"..."} * * Extraction rules ("parse JSONL final message" per the capability matrix): * - text: the LAST agent-sourced *message-bearing* event wins. Only two * event kinds carry the agent's answer: `action:"message"` * (`args.content`, falling back to the top-level `message` mirror) and * `action:"finish"` (`args.final_thought`/`args.thought`, falling back to * `message`). User echoes, observations, and tool actions (`run`, `edit`, * …, whose `message` is progress noise like "Running command: ls") never * contribute. * - sessionId: the FIRST id-bearing event supplies it (`session_id`, * `sessionId`, `sid`, `conversation_id`), falling back to any sessionId * the spawn layer already attached. Stored opportunistically on the unit * row; per the matrix OpenHands resume is workspace-state, so akm never * depends on it (plan §"Session, MCP, and identity across harnesses"). * * Also handled, so version churn stays contained in this one file (per the * adapter contract): * - a single whole-stdout JSON document (or a spawn-layer `result.parsed` * value) is interpreted with the same rules; * - plain text (no `--json`, or an unrecognized envelope) passes through * trimmed — the engine's embedded-JSON parsing still runs downstream for * schema units; * - non-JSON banner lines interleaved in the stream are skipped. * * Registered: `openhandsResultExtractor` is `OpenhandsHarness.resultExtractor` * (`./index.ts`); the engine resolves it via `getHarness()` in * `workflows/exec/native-executor.ts` to normalize agent-unit stdout before * schema validation. */ import { asNonEmptyString, isRecord, tryParseJson } from "../../../core/common.js"; /** Keys that may carry the harness-native session id, in precedence order. */ const SESSION_KEYS = ["session_id", "sessionId", "sid", "conversation_id"]; /** * Extract the agent's answer text from one parsed OpenHands event, if the * event is one of the two message-bearing kinds (see module doc). Everything * else — user echoes, observations, tool actions — yields undefined. */ function extractAgentText(event) { // Only agent-authored events count; a missing `source` is tolerated for // finish/message actions from older serializations, but an explicit // non-agent source (user echo, environment) never contributes. if (event.source !== undefined && event.source !== "agent") return undefined; if (typeof event.action !== "string") return undefined; const args = isRecord(event.args) ? event.args : undefined; if (event.action === "message") { return asNonEmptyString(args?.content) ?? asNonEmptyString(event.message); } if (event.action === "finish") { return asNonEmptyString(args?.final_thought) ?? asNonEmptyString(args?.thought) ?? asNonEmptyString(event.message); } return undefined; } /** Extract a harness-native session id from one parsed JSON value, if any. */ function extractSessionId(event) { for (const key of SESSION_KEYS) { const candidate = asNonEmptyString(event[key]); if (candidate) return candidate; } return undefined; } /** * Normalize a raw openhands run result into `{ text, sessionId? }`. * See the module doc for the stdout shapes handled. */ export const openhandsResultExtractor = (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"). A multi-event JSONL // stream never parses as a single document, so this cannot shadow shape 2. const whole = result.parsed !== undefined ? result.parsed : raw.length > 0 ? tryParseJson(raw) : undefined; if (whole !== undefined) { if (typeof whole === "string") { return { text: whole, ...(fallbackSessionId ? { sessionId: fallbackSessionId } : {}) }; } if (isRecord(whole)) { const sessionId = extractSessionId(whole) ?? fallbackSessionId; // Unknown envelope (no agent message): fall back to raw stdout so // downstream embedded-JSON parsing still has material to work with. const text = extractAgentText(whole) ?? raw; return { text, ...(sessionId ? { sessionId } : {}) }; } } // Shape 2 — JSONL event stream (`--json`): last message-bearing agent event // wins; first id-bearing event supplies the session id. Non-JSON banner // lines 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 || !isRecord(event)) continue; sawJsonLine = true; streamSessionId ??= extractSessionId(event); const text = extractAgentText(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 } : {}) }; };