diff --git a/packages/uwf-agent-hermes/__tests__/session-detail.test.ts b/packages/uwf-agent-hermes/__tests__/session-detail.test.ts new file mode 100644 index 0000000..311b098 --- /dev/null +++ b/packages/uwf-agent-hermes/__tests__/session-detail.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas"; + +import { + computeDurationMs, + extractLastAssistantContent, + messageToTurnPayload, + parseSessionIdFromStdout, + storeHermesSessionDetail, +} from "../src/session-detail.js"; +import type { HermesSessionJson, HermesSessionMessage } from "../src/types.js"; + +describe("parseSessionIdFromStdout", () => { + test("reads session_id from the last non-empty line", () => { + const stdout = "Done.\n\nsession_id: 20260518_223724_45ab80\n"; + expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80"); + }); + + test("returns null when trailing line is not session_id", () => { + expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull(); + }); +}); + +describe("messageToTurnPayload", () => { + test("maps assistant tool_calls to toolCalls", () => { + const msg: HermesSessionMessage = { + role: "assistant", + content: "", + reasoning: null, + tool_calls: [{ function: { name: "read_file", arguments: '{"path":"x"}' } }], + }; + const turn = messageToTurnPayload(msg, 0); + expect(turn).toEqual({ + index: 0, + role: "assistant", + content: "", + toolCalls: [{ name: "read_file", args: '{"path":"x"}' }], + reasoning: null, + }); + }); + + test("skips user messages", () => { + const msg: HermesSessionMessage = { + role: "user", + content: "hi", + reasoning: null, + tool_calls: null, + }; + expect(messageToTurnPayload(msg, 0)).toBeNull(); + }); +}); + +describe("extractLastAssistantContent", () => { + test("returns the last non-empty assistant content", () => { + const messages: HermesSessionMessage[] = [ + { role: "assistant", content: "first", reasoning: null, tool_calls: null }, + { role: "tool", content: "tool output", reasoning: null, tool_calls: null }, + { role: "assistant", content: "", reasoning: null, tool_calls: null }, + { role: "assistant", content: "final answer", reasoning: null, tool_calls: null }, + ]; + expect(extractLastAssistantContent(messages)).toBe("final answer"); + }); +}); + +describe("computeDurationMs", () => { + test("computes elapsed time from session_start", () => { + const now = Date.parse("2026-05-18T13:32:59.028640Z"); + const duration = computeDurationMs("2026-05-18T13:31:59.028640Z", now); + expect(duration).toBe(60_000); + }); +}); + +describe("storeHermesSessionDetail", () => { + test("stores hermes-detail root with cas_ref turns walkable", async () => { + const session: HermesSessionJson = { + session_id: "20260518_133159_6a84e8", + model: "claude-opus-4.6", + session_start: "2026-05-18T13:31:59.028640", + messages: [ + { role: "user", content: "task", reasoning: null, tool_calls: null }, + { + role: "assistant", + content: "", + reasoning: "thinking", + tool_calls: [{ function: { name: "terminal", arguments: "{}" } }], + }, + { role: "tool", content: "ok", reasoning: null, tool_calls: null }, + { role: "assistant", content: "done", reasoning: null, tool_calls: null }, + ], + }; + + const store = createMemoryStore(); + const now = Date.parse("2026-05-18T13:32:59.028640"); + const { detailHash, output } = await storeHermesSessionDetail(store, session, now); + + expect(output).toBe("done"); + + const detailNode = store.get(detailHash); + expect(detailNode).not.toBeNull(); + if (detailNode === null) { + return; + } + expect(validate(store, detailNode)).toBe(true); + expect(detailNode.payload).toMatchObject({ + sessionId: "20260518_133159_6a84e8", + model: "claude-opus-4.6", + duration: 60_000, + turnCount: 3, + }); + + const turnRefs = refs(store, detailNode); + expect(turnRefs).toHaveLength(3); + + const visited: string[] = []; + walk(store, detailHash, (hash) => visited.push(hash)); + expect(visited).toContain(detailHash); + for (const turnHash of turnRefs) { + expect(visited).toContain(turnHash); + } + }); +}); diff --git a/packages/uwf-agent-hermes/src/hermes.ts b/packages/uwf-agent-hermes/src/hermes.ts index 3aab684..aa093b6 100644 --- a/packages/uwf-agent-hermes/src/hermes.ts +++ b/packages/uwf-agent-hermes/src/hermes.ts @@ -1,6 +1,5 @@ import { spawn } from "node:child_process"; -import { bootstrap, type JSONSchema, putSchema } from "@uncaged/json-cas"; import { type AgentContext, type AgentRunResult, @@ -9,19 +8,16 @@ import { resolveStorageRoot, } from "@uncaged/uwf-agent-kit"; +import { + loadHermesSession, + parseSessionIdFromStdout, + storeHermesRawOutput, + storeHermesSessionDetail, +} from "./session-detail.js"; + const HERMES_COMMAND = "hermes"; const HERMES_MAX_TURNS = 90; -const HERMES_RAW_OUTPUT_SCHEMA: JSONSchema = { - title: "hermes-raw-output", - type: "object", - required: ["text"], - properties: { - text: { type: "string" }, - }, - additionalProperties: false, -}; - function buildHistorySummary(history: AgentContext["history"]): string { if (history.length === 0) { return ""; @@ -93,18 +89,22 @@ function spawnHermesChat(prompt: string): Promise { }); } -async function storeHermesRawOutput(rawOutput: string): Promise { - const storageRoot = resolveStorageRoot(); - const { store } = await createAgentStore(storageRoot); - await bootstrap(store); - const schemaHash = await putSchema(store, HERMES_RAW_OUTPUT_SCHEMA); - return store.put(schemaHash, { text: rawOutput }); -} - async function runHermes(ctx: AgentContext): Promise { const fullPrompt = buildHermesPrompt(ctx); const rawOutput = await spawnHermesChat(fullPrompt); - const detailHash = await storeHermesRawOutput(rawOutput); + const storageRoot = resolveStorageRoot(); + const { store } = await createAgentStore(storageRoot); + + const sessionId = parseSessionIdFromStdout(rawOutput); + if (sessionId !== null) { + const session = await loadHermesSession(sessionId); + if (session !== null) { + const { detailHash, output } = await storeHermesSessionDetail(store, session); + return { output, detailHash }; + } + } + + const detailHash = await storeHermesRawOutput(store, rawOutput); return { output: rawOutput, detailHash }; } diff --git a/packages/uwf-agent-hermes/src/schemas.ts b/packages/uwf-agent-hermes/src/schemas.ts new file mode 100644 index 0000000..6d62183 --- /dev/null +++ b/packages/uwf-agent-hermes/src/schemas.ts @@ -0,0 +1,57 @@ +import type { JSONSchema } from "@uncaged/json-cas"; + +const HERMES_TOOL_CALL_SCHEMA: JSONSchema = { + type: "object", + required: ["name", "args"], + properties: { + name: { type: "string" }, + args: { type: "string" }, + }, + additionalProperties: false, +}; + +export const HERMES_TURN_SCHEMA: JSONSchema = { + title: "hermes-turn", + type: "object", + required: ["index", "role", "content"], + properties: { + index: { type: "integer" }, + role: { type: "string", enum: ["assistant", "tool"] }, + content: { type: "string" }, + toolCalls: { + anyOf: [{ type: "array", items: HERMES_TOOL_CALL_SCHEMA }, { type: "null" }], + }, + reasoning: { + anyOf: [{ type: "string" }, { type: "null" }], + }, + }, + additionalProperties: false, +}; + +export const HERMES_DETAIL_SCHEMA: JSONSchema = { + title: "hermes-detail", + type: "object", + required: ["sessionId", "model", "duration", "turnCount", "turns"], + properties: { + sessionId: { type: "string" }, + model: { type: "string" }, + duration: { type: "integer" }, + turnCount: { type: "integer" }, + turns: { + type: "array", + items: { type: "string", format: "cas_ref" }, + }, + }, + additionalProperties: false, +}; + +/** Fallback detail when Hermes session file is unavailable. */ +export const HERMES_RAW_OUTPUT_SCHEMA: JSONSchema = { + title: "hermes-raw-output", + type: "object", + required: ["text"], + properties: { + text: { type: "string" }, + }, + additionalProperties: false, +}; diff --git a/packages/uwf-agent-hermes/src/session-detail.ts b/packages/uwf-agent-hermes/src/session-detail.ts new file mode 100644 index 0000000..3e29f6d --- /dev/null +++ b/packages/uwf-agent-hermes/src/session-detail.ts @@ -0,0 +1,228 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { bootstrap, putSchema, type Store } from "@uncaged/json-cas"; + +import { HERMES_DETAIL_SCHEMA, HERMES_RAW_OUTPUT_SCHEMA, HERMES_TURN_SCHEMA } from "./schemas.js"; +import type { + HermesDetailPayload, + HermesSessionJson, + HermesSessionMessage, + HermesToolCall, + HermesTurnPayload, + HermesTurnRole, +} from "./types.js"; + +const SESSION_ID_LINE = /^session_id:\s*(\S+)\s*$/i; + +export function getHermesSessionsDir(): string { + return join(homedir(), ".hermes", "sessions"); +} + +export function getHermesSessionPath(sessionId: string): string { + return join(getHermesSessionsDir(), `session_${sessionId}.json`); +} + +/** Parse `session_id: …` from the last non-empty line of Hermes stdout. */ +export function parseSessionIdFromStdout(stdout: string): string | null { + const lines = stdout.split(/\r?\n/).map((line) => line.trim()); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (line === undefined || line === "") { + continue; + } + const match = SESSION_ID_LINE.exec(line); + if (match?.[1] !== undefined) { + return match[1]; + } + break; + } + return null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseToolCalls(raw: unknown): HermesSessionMessage["tool_calls"] { + if (!Array.isArray(raw) || raw.length === 0) { + return null; + } + const calls: NonNullable = []; + for (const entry of raw) { + if (!isRecord(entry)) { + continue; + } + const fn = entry.function; + if (!isRecord(fn)) { + continue; + } + const name = fn.name; + const args = fn.arguments; + if (typeof name !== "string" || typeof args !== "string") { + continue; + } + calls.push({ function: { name, arguments: args } }); + } + return calls.length > 0 ? calls : null; +} + +function normalizeMessage(raw: unknown): HermesSessionMessage | null { + if (!isRecord(raw)) { + return null; + } + const role = raw.role; + if (role !== "assistant" && role !== "tool" && role !== "user") { + return null; + } + const content = typeof raw.content === "string" ? raw.content : raw.content === null ? null : ""; + const reasoning = + typeof raw.reasoning === "string" + ? raw.reasoning + : raw.reasoning === null || raw.reasoning === undefined + ? null + : null; + const tool_calls = parseToolCalls(raw.tool_calls); + return { role, content, reasoning, tool_calls }; +} + +function parseSessionJson(raw: unknown): HermesSessionJson | null { + if (!isRecord(raw)) { + return null; + } + const session_id = raw.session_id; + const model = raw.model; + const session_start = raw.session_start; + const messagesRaw = raw.messages; + if ( + typeof session_id !== "string" || + typeof model !== "string" || + typeof session_start !== "string" || + !Array.isArray(messagesRaw) + ) { + return null; + } + const messages: HermesSessionMessage[] = []; + for (const entry of messagesRaw) { + const msg = normalizeMessage(entry); + if (msg !== null) { + messages.push(msg); + } + } + return { session_id, model, session_start, messages }; +} + +export async function loadHermesSession(sessionId: string): Promise { + const path = getHermesSessionPath(sessionId); + try { + const text = await readFile(path, "utf8"); + const raw = JSON.parse(text) as unknown; + return parseSessionJson(raw); + } catch { + return null; + } +} + +export function computeDurationMs(sessionStart: string, nowMs: number = Date.now()): number { + const startMs = Date.parse(sessionStart); + if (Number.isNaN(startMs)) { + return 0; + } + return Math.max(0, nowMs - startMs); +} + +function mapSessionToolCalls( + toolCalls: HermesSessionMessage["tool_calls"], +): HermesToolCall[] | null { + if (toolCalls === null || toolCalls.length === 0) { + return null; + } + return toolCalls.map((call) => ({ + name: call.function.name, + args: call.function.arguments, + })); +} + +export function messageToTurnPayload( + message: HermesSessionMessage, + index: number, +): HermesTurnPayload | null { + if (message.role !== "assistant" && message.role !== "tool") { + return null; + } + const role = message.role as HermesTurnRole; + return { + index, + role, + content: message.content ?? "", + toolCalls: mapSessionToolCalls(message.tool_calls), + reasoning: message.reasoning, + }; +} + +/** Last assistant message with non-empty text content (walks backward). */ +export function extractLastAssistantContent(messages: HermesSessionMessage[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg === undefined) { + continue; + } + if (msg.role === "assistant" && msg.content !== null && msg.content.trim() !== "") { + return msg.content; + } + } + return ""; +} + +type HermesSchemaHashes = { + turn: string; + detail: string; + rawOutput: string; +}; + +async function registerHermesSchemas(store: Store): Promise { + await bootstrap(store); + const [turn, detail, rawOutput] = await Promise.all([ + putSchema(store, HERMES_TURN_SCHEMA), + putSchema(store, HERMES_DETAIL_SCHEMA), + putSchema(store, HERMES_RAW_OUTPUT_SCHEMA), + ]); + return { turn, detail, rawOutput }; +} + +export async function storeHermesSessionDetail( + store: Store, + session: HermesSessionJson, + nowMs: number = Date.now(), +): Promise<{ detailHash: string; output: string }> { + const schemas = await registerHermesSchemas(store); + const turnHashes: string[] = []; + let turnIndex = 0; + + for (const message of session.messages) { + const turn = messageToTurnPayload(message, turnIndex); + if (turn === null) { + continue; + } + const hash = await store.put(schemas.turn, turn); + turnHashes.push(hash); + turnIndex += 1; + } + + const detail: HermesDetailPayload = { + sessionId: session.session_id, + model: session.model, + duration: computeDurationMs(session.session_start, nowMs), + turnCount: turnHashes.length, + turns: turnHashes, + }; + const detailHash = await store.put(schemas.detail, detail); + const output = extractLastAssistantContent(session.messages); + return { detailHash, output }; +} + +export async function storeHermesRawOutput(store: Store, rawOutput: string): Promise { + const schemas = await registerHermesSchemas(store); + return store.put(schemas.rawOutput, { text: rawOutput }); +} diff --git a/packages/uwf-agent-hermes/src/types.ts b/packages/uwf-agent-hermes/src/types.ts new file mode 100644 index 0000000..4aa32cf --- /dev/null +++ b/packages/uwf-agent-hermes/src/types.ts @@ -0,0 +1,43 @@ +export type HermesTurnRole = "assistant" | "tool"; + +export type HermesToolCall = { + name: string; + args: string; +}; + +export type HermesTurnPayload = { + index: number; + role: HermesTurnRole; + content: string; + toolCalls: HermesToolCall[] | null; + reasoning: string | null; +}; + +export type HermesDetailPayload = { + sessionId: string; + model: string; + duration: number; + turnCount: number; + turns: string[]; +}; + +export type HermesSessionToolCall = { + function: { + name: string; + arguments: string; + }; +}; + +export type HermesSessionMessage = { + role: string; + content: string | null; + tool_calls: HermesSessionToolCall[] | null; + reasoning: string | null; +}; + +export type HermesSessionJson = { + session_id: string; + model: string; + session_start: string; + messages: HermesSessionMessage[]; +};