refactor: replace spawnHermes with HermesAcpClient

Remove spawnHermes, spawnHermesChat, spawnHermesResume, parseSessionId,
and buildResultFromSession. runHermes and continueHermes now use
HermesAcpClient for structured JSON-RPC communication.

Session ID comes directly from ACP protocol, eliminating #380 race
condition. Agent output collected via streaming chunks instead of
session file loading.

Phase 2 of RFC #398
Fixes #380
This commit is contained in:
2026-05-22 12:18:14 +00:00
parent 766ec7ddc2
commit 96584e481f
2 changed files with 23 additions and 102 deletions
+22 -101
View File
@@ -1,4 +1,3 @@
import { spawn } from "node:child_process";
import type { Store } from "@uncaged/json-cas"; import type { Store } from "@uncaged/json-cas";
import { import {
@@ -8,14 +7,8 @@ import {
createAgent, createAgent,
} from "@uncaged/workflow-agent-kit"; } from "@uncaged/workflow-agent-kit";
import { import { HermesAcpClient } from "./acp-client.js";
loadHermesSession, import { storeHermesRawOutput } from "./session-detail.js";
parseSessionIdFromStdout,
storeHermesSessionDetail,
} from "./session-detail.js";
const HERMES_COMMAND = "hermes";
const HERMES_MAX_TURNS = 90;
function buildHistorySummary(steps: AgentContext["steps"]): string { function buildHistorySummary(steps: AgentContext["steps"]): string {
if (steps.length === 0) { if (steps.length === 0) {
@@ -52,106 +45,34 @@ export function buildHermesPrompt(ctx: AgentContext): string {
return parts.join("\n"); return parts.join("\n");
} }
function spawnHermes(args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(HERMES_COMMAND, args, {
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", (cause) => {
const message = cause instanceof Error ? cause.message : String(cause);
reject(new Error(`hermes spawn failed: ${message}`));
});
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
reject(new Error(`hermes exited with code ${code ?? "null"}${detail}`));
});
});
}
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
return spawnHermes([
"chat",
"-q",
prompt,
"--yolo",
"--max-turns",
String(HERMES_MAX_TURNS),
"--quiet",
]);
}
function spawnHermesResume(
sessionId: string,
message: string,
): Promise<{ stdout: string; stderr: string }> {
return spawnHermes([
"chat",
"--resume",
sessionId,
"-q",
message,
"--yolo",
"--max-turns",
String(HERMES_MAX_TURNS),
"--quiet",
]);
}
function parseSessionId(stdout: string, stderr: string): string {
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
if (sessionId === null) {
throw new Error(
"Failed to parse session_id from hermes output.\n" +
`stderr (first 200 chars): ${stderr.slice(0, 200)}\n` +
`stdout (first 200 chars): ${stdout.slice(0, 200)}`,
);
}
return sessionId;
}
async function buildResultFromSession(sessionId: string, store: Store): Promise<AgentRunResult> {
const session = await loadHermesSession(sessionId);
if (session === null) {
throw new Error(`Failed to load hermes session file for session_id: ${sessionId}`);
}
const { detailHash, output } = await storeHermesSessionDetail(store, session);
return { output, detailHash, sessionId };
}
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> { async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
const fullPrompt = buildHermesPrompt(ctx); const fullPrompt = buildHermesPrompt(ctx);
const { stdout, stderr } = await spawnHermesChat(fullPrompt); const client = new HermesAcpClient();
const sessionId = parseSessionId(stdout, stderr); try {
return buildResultFromSession(sessionId, ctx.store); await client.connect(process.cwd());
const { text, sessionId } = await client.prompt(fullPrompt);
const detailHash = await storeHermesRawOutput(ctx.store, text);
return { output: text, detailHash, sessionId };
} finally {
await client.close();
}
} }
async function continueHermes( async function continueHermes(
sessionId: string, _sessionId: string,
message: string, message: string,
store: Store, store: Store,
): Promise<AgentRunResult> { ): Promise<AgentRunResult> {
const { stdout, stderr } = await spawnHermesResume(sessionId, message); // ACP does not support resuming an external session; start a new session with the message as prompt
// Resume may return a new session_id const client = new HermesAcpClient();
const newSessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout); try {
const resolvedId = newSessionId ?? sessionId; await client.connect(process.cwd());
return buildResultFromSession(resolvedId, store); const { text, sessionId } = await client.prompt(message);
const detailHash = await storeHermesRawOutput(store, text);
return { output: text, detailHash, sessionId };
} finally {
await client.close();
}
} }
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */ /** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
+1 -1
View File
@@ -1,2 +1,2 @@
export { buildHermesPrompt, createHermesAgent } from "./hermes.js";
export { HermesAcpClient } from "./acp-client.js"; export { HermesAcpClient } from "./acp-client.js";
export { buildHermesPrompt, createHermesAgent } from "./hermes.js";