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:
@@ -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,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";
|
||||||
|
|||||||
Reference in New Issue
Block a user