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
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/.
/**
* 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 } : {}) };
};