Compare commits

...

6 Commits

Author SHA1 Message Date
xiaoju cf9e2cd3d6 fix: parse session_id from stderr (hermes --quiet writes it there)
hermes --quiet outputs session_id to stderr and AI response to stdout.
The agent was only parsing stdout, so session_id was never found and
detail always fell back to raw output.

Now checks stderr first, then stdout as fallback.
2026-05-18 17:05:54 +00:00
xiaomo 7a99c1a9d6 Merge pull request 'fix: hermes agent empty detail — parse session_id from any line' (#347) from fix/342-parse-session-id into main 2026-05-18 16:58:24 +00:00
xiaoju 546237db85 fix: parseSessionIdFromStdout scans all lines, not just last
--quiet mode outputs session_id on the first line, not the last.
The old code only checked the last non-empty line and broke immediately
if it didn't match, causing session detail to always be empty.

Fixes the empty detail bug when hermes agent runs in quiet mode.
2026-05-18 16:57:24 +00:00
xiaomo 1ed7e32067 Merge pull request 'simplify: thread fork only takes step-hash' (#346) from fix/342-fork-simplify into main 2026-05-18 16:43:33 +00:00
xiaoju bd5e5a435b simplify: thread fork only takes step-hash
Remove thread-id argument — CAS node is self-contained, no need to
specify which thread it belongs to. Just verify the hash is a valid
StartNode or StepNode.

Refs #342
2026-05-18 16:38:55 +00:00
xiaomo 67e689ff1a Merge pull request 'feat: thread steps + thread fork' (#345) from feat/342-thread-steps-fork into main 2026-05-18 16:34:55 +00:00
6 changed files with 25 additions and 47 deletions
+3 -4
View File
@@ -161,12 +161,11 @@ thread
thread
.command("fork")
.description("Fork a thread from a specific step")
.argument("<thread-id>", "Thread ULID")
.argument("<step-hash>", "CAS hash of the step to fork from")
.action((threadId: string, stepHash: string) => {
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
.action((stepHash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadFork(storageRoot, threadId, stepHash);
const result = await cmdThreadFork(storageRoot, stepHash);
writeOutput(result);
});
});
+5 -26
View File
@@ -521,35 +521,15 @@ export async function cmdThreadSteps(
export async function cmdThreadFork(
storageRoot: string,
threadId: ThreadId,
stepHash: CasRef,
): Promise<ThreadForkOutput> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
// Verify stepHash belongs to this thread by walking the chain
let found = false;
let cur: CasRef | null = headHash;
while (cur !== null) {
if (cur === stepHash) {
found = true;
break;
}
const node = uwf.store.get(cur);
if (node === null) break;
if (node.type === uwf.schemas.startNode) {
// startHash check
if (cur === stepHash) {
found = true;
}
break;
}
const payload = node.payload as StepNodePayload;
cur = payload.prev;
const node = uwf.store.get(stepHash);
if (node === null) {
fail(`CAS node not found: ${stepHash}`);
}
if (!found) {
fail(`step ${stepHash} not found in thread ${threadId}`);
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
fail(`node ${stepHash} is not a StartNode or StepNode`);
}
const newThreadId = generateUlid(Date.now()) as ThreadId;
@@ -560,7 +540,6 @@ export async function cmdThreadFork(
return {
thread: newThreadId,
forkedFrom: {
thread: threadId,
step: stepHash,
},
};
@@ -16,7 +16,12 @@ describe("parseSessionIdFromStdout", () => {
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
});
test("returns null when trailing line is not session_id", () => {
test("reads session_id from the first line (quiet mode)", () => {
const stdout = "session_id: 20260518_165315_3467a1\nHello world\n";
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_165315_3467a1");
});
test("returns null when no session_id line present", () => {
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
});
});
+7 -6
View File
@@ -43,7 +43,7 @@ export function buildHermesPrompt(ctx: AgentContext): string {
return parts.join("\n");
}
function spawnHermesChat(prompt: string): Promise<string> {
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const args = [
"chat",
@@ -76,7 +76,7 @@ function spawnHermesChat(prompt: string): Promise<string> {
child.on("close", (code) => {
if (code === 0) {
resolve(stdout);
resolve({ stdout, stderr });
return;
}
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
@@ -87,10 +87,11 @@ function spawnHermesChat(prompt: string): Promise<string> {
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
const fullPrompt = buildHermesPrompt(ctx);
const rawOutput = await spawnHermesChat(fullPrompt);
const { stdout, stderr } = await spawnHermesChat(fullPrompt);
const { store } = ctx;
const sessionId = parseSessionIdFromStdout(rawOutput);
// --quiet mode: session_id may be on stdout or stderr
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
if (sessionId !== null) {
const session = await loadHermesSession(sessionId);
if (session !== null) {
@@ -99,8 +100,8 @@ async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
}
}
const detailHash = await storeHermesRawOutput(store, rawOutput);
return { output: rawOutput, detailHash };
const detailHash = await storeHermesRawOutput(store, stdout);
return { output: stdout, detailHash };
}
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
@@ -24,19 +24,14 @@ export function getHermesSessionPath(sessionId: string): string {
return join(getHermesSessionsDir(), `session_${sessionId}.json`);
}
/** Parse `session_id: …` from the last non-empty line of Hermes stdout. */
/** Parse `session_id: …` from any 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);
const lines = stdout.split(/\r?\n/);
for (const line of lines) {
const match = SESSION_ID_LINE.exec(line.trim());
if (match?.[1] !== undefined) {
return match[1];
}
break;
}
return null;
}
-1
View File
@@ -109,7 +109,6 @@ export type ThreadStepsOutput = {
export type ThreadForkOutput = {
thread: ThreadId;
forkedFrom: {
thread: ThreadId;
step: CasRef;
};
};