feat: agent session protocol — sessionId in result, continue support, frontmatter retry

Breaking changes:
- AgentRunResult now requires sessionId field
- AgentOptions now requires continue function
- Agent CLI outputs JSON {stepHash, sessionId} instead of plain CAS hash
- Engine parses JSON output (with legacy CAS hash fallback)

New features:
- Frontmatter validation retry: if agent output lacks valid frontmatter,
  engine calls agent.continue() up to 2 times with correction message
- Session tracking: sessionId flows from agent → engine → StepOutput
- Hermes agent: session parse failure is now a hard error (no raw text fallback)
- Hermes agent: supports --resume for continue sessions

Closes #384
This commit is contained in:
2026-05-22 09:13:05 +00:00
parent e62d51d845
commit 7ff90cef4f
6 changed files with 149 additions and 48 deletions
+25 -5
View File
@@ -624,7 +624,12 @@ function resolveAgentConfig(
return agentConfig; return agentConfig;
} }
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef { type SpawnAgentResult = {
stepHash: CasRef;
sessionId: string;
};
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): SpawnAgentResult {
const argv = [...agent.args, threadId, role]; const argv = [...agent.args, threadId, role];
let stdout: string; let stdout: string;
try { try {
@@ -646,10 +651,24 @@ function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRe
} }
const line = stdout.trim().split("\n").pop()?.trim() ?? ""; const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) {
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`); // Try JSON output first (new protocol)
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
const stepHash = parsed.stepHash;
const sessionId = parsed.sessionId;
if (typeof stepHash === "string" && isCasRef(stepHash) && typeof sessionId === "string") {
return { stepHash, sessionId };
}
} catch {
// Not JSON — fall through to legacy CAS hash parsing
} }
return line;
// Legacy: plain CAS hash on stdout
if (!isCasRef(line)) {
fail(`agent stdout is not a valid CAS hash or JSON: ${line || "(empty)"}`);
}
return { stepHash: line, sessionId: "" };
} }
async function archiveThread( async function archiveThread(
@@ -706,7 +725,7 @@ export async function cmdThreadStep(
const agent = resolveAgentConfig(config, workflow, role, agentOverride); const agent = resolveAgentConfig(config, workflow, role, agentOverride);
loadDotenv({ path: getEnvPath(storageRoot) }); loadDotenv({ path: getEnvPath(storageRoot) });
const newHead = spawnAgent(agent, threadId, role); const { stepHash: newHead, sessionId } = spawnAgent(agent, threadId, role);
// Re-create store to pick up nodes written by the agent subprocess // Re-create store to pick up nodes written by the agent subprocess
const uwfAfter = await createUwfStore(storageRoot); const uwfAfter = await createUwfStore(storageRoot);
@@ -737,6 +756,7 @@ export async function cmdThreadStep(
thread: threadId, thread: threadId,
head: newHead, head: newHead,
done, done,
sessionId,
}; };
} }
+66 -24
View File
@@ -1,4 +1,5 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import type { Store } from "@uncaged/json-cas";
import { import {
type AgentContext, type AgentContext,
@@ -10,7 +11,6 @@ import {
import { import {
loadHermesSession, loadHermesSession,
parseSessionIdFromStdout, parseSessionIdFromStdout,
storeHermesRawOutput,
storeHermesSessionDetail, storeHermesSessionDetail,
} from "./session-detail.js"; } from "./session-detail.js";
@@ -52,17 +52,8 @@ export function buildHermesPrompt(ctx: AgentContext): string {
return parts.join("\n"); return parts.join("\n");
} }
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> { function spawnHermes(args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const args = [
"chat",
"-q",
prompt,
"--yolo",
"--max-turns",
String(HERMES_MAX_TURNS),
"--quiet",
];
const child = spawn(HERMES_COMMAND, args, { const child = spawn(HERMES_COMMAND, args, {
env: process.env, env: process.env,
shell: false, shell: false,
@@ -94,23 +85,73 @@ function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: stri
}); });
} }
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 { stdout, stderr } = await spawnHermesChat(fullPrompt);
const { store } = ctx; const sessionId = parseSessionId(stdout, stderr);
return buildResultFromSession(sessionId, ctx.store);
}
// --quiet mode: session_id may be on stdout or stderr async function continueHermes(
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout); sessionId: string,
if (sessionId !== null) { message: string,
const session = await loadHermesSession(sessionId); store: Store,
if (session !== null) { ): Promise<AgentRunResult> {
const { detailHash, output } = await storeHermesSessionDetail(store, session); const { stdout, stderr } = await spawnHermesResume(sessionId, message);
return { output, detailHash }; // Resume may return a new session_id
} const newSessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
} const resolvedId = newSessionId ?? sessionId;
return buildResultFromSession(resolvedId, store);
const detailHash = await storeHermesRawOutput(store, stdout);
return { output: stdout, detailHash };
} }
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */ /** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
@@ -118,5 +159,6 @@ export function createHermesAgent(): () => Promise<void> {
return createAgent({ return createAgent({
name: "hermes", name: "hermes",
run: runHermes, run: runHermes,
continue: continueHermes,
}); });
} }
+7 -1
View File
@@ -12,4 +12,10 @@ export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } from "./frontmatter.js"; export { tryFrontmatterFastPath } from "./frontmatter.js";
export { createAgent } from "./run.js"; export { createAgent } from "./run.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js"; export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js"; export type {
AgentContext,
AgentContinueFn,
AgentOptions,
AgentRunFn,
AgentRunResult,
} from "./types.js";
+42 -18
View File
@@ -6,7 +6,9 @@ import { buildContextWithMeta } from "./context.js";
import { tryFrontmatterFastPath } from "./frontmatter.js"; import { tryFrontmatterFastPath } from "./frontmatter.js";
import type { AgentStore } from "./storage.js"; import type { AgentStore } from "./storage.js";
import { getEnvPath, resolveStorageRoot } from "./storage.js"; import { getEnvPath, resolveStorageRoot } from "./storage.js";
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js"; import type { AgentOptions } from "./types.js";
const MAX_FRONTMATTER_RETRIES = 2;
function fail(message: string): never { function fail(message: string): never {
process.stderr.write(`${message}\n`); process.stderr.write(`${message}\n`);
@@ -65,26 +67,16 @@ async function writeStepNode(options: {
return hash; return hash;
} }
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<AgentRunResult> { async function tryExtractOutput(
return runWithMessage("agent run failed", () => options.run(ctx));
}
async function extractOutput(
rawOutput: string, rawOutput: string,
outputSchema: CasRef, outputSchema: CasRef,
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>, ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
): Promise<CasRef> { ): Promise<CasRef | null> {
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store); const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
if (fastPath !== null) { if (fastPath !== null) {
return fastPath.outputHash; return fastPath.outputHash;
} }
return null;
fail(
"Agent output does not contain valid YAML frontmatter matching the role schema.\n" +
"The agent must output a YAML frontmatter block (--- delimited) as the first thing in its response.\n" +
`Raw output (first 500 chars): ${rawOutput.slice(0, 500)}`,
);
} }
async function persistStep(options: { async function persistStep(options: {
@@ -106,10 +98,18 @@ async function persistStep(options: {
}); });
} }
export type AgentCliOutput = {
stepHash: CasRef;
sessionId: string;
};
/** /**
* Create an agent CLI entrypoint. * Create an agent CLI entrypoint.
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output, * Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
* writes StepNode to CAS, and prints the new node hash to stdout. * writes StepNode to CAS, and prints JSON result to stdout.
*
* If frontmatter extraction fails, retries up to MAX_FRONTMATTER_RETRIES times
* by calling agent.continue() with a correction message.
*/ */
export function createAgent(options: AgentOptions): () => Promise<void> { export function createAgent(options: AgentOptions): () => Promise<void> {
return async function main(): Promise<void> { return async function main(): Promise<void> {
@@ -129,8 +129,31 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema); ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
} }
const agentResult = await runAgent(options, ctx); let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
const outputHash = await extractOutput(agentResult.output, roleDef.frontmatter, ctx);
// Try to extract frontmatter; retry via continue if it fails
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
const correctionMessage =
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
"Please output ONLY the corrected frontmatter block followed by your work.";
agentResult = await runWithMessage("agent continue failed", () =>
options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store),
);
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
}
if (outputHash === null) {
fail(
"Agent output does not contain valid YAML frontmatter matching the role schema " +
`after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
`Raw output (first 500 chars): ${agentResult.output.slice(0, 500)}`,
);
}
const stepHash = await persistStep({ const stepHash = await persistStep({
ctx, ctx,
outputHash, outputHash,
@@ -138,6 +161,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
agentName: agentLabel(options.name), agentName: agentLabel(options.name),
}); });
process.stdout.write(`${stepHash}\n`); const result: AgentCliOutput = { stepHash, sessionId: agentResult.sessionId };
process.stdout.write(`${JSON.stringify(result)}\n`);
}; };
} }
+8
View File
@@ -17,11 +17,19 @@ export type AgentContext = ModeratorContext & {
export type AgentRunResult = { export type AgentRunResult = {
output: string; output: string;
detailHash: string; detailHash: string;
sessionId: string;
}; };
export type AgentContinueFn = (
sessionId: string,
message: string,
store: AgentContext["store"],
) => Promise<AgentRunResult>;
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>; export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AgentOptions = { export type AgentOptions = {
name: string; name: string;
run: AgentRunFn; run: AgentRunFn;
continue: AgentContinueFn;
}; };
+1
View File
@@ -81,6 +81,7 @@ export type StepOutput = {
thread: ThreadId; thread: ThreadId;
head: CasRef; head: CasRef;
done: boolean; done: boolean;
sessionId?: string;
}; };
/** uwf thread steps — single step entry */ /** uwf thread steps — single step entry */