Merge pull request 'feat: edge prompt + session resume (#402)' (#403) from feat/402-edge-prompt-session-resume into main
This commit is contained in:
@@ -62,14 +62,19 @@ graph:
|
|||||||
$START:
|
$START:
|
||||||
- role: "planner"
|
- role: "planner"
|
||||||
condition: null
|
condition: null
|
||||||
|
prompt: null
|
||||||
planner:
|
planner:
|
||||||
- role: "developer"
|
- role: "developer"
|
||||||
condition: null
|
condition: null
|
||||||
|
prompt: null
|
||||||
developer:
|
developer:
|
||||||
- role: "reviewer"
|
- role: "reviewer"
|
||||||
condition: null
|
condition: null
|
||||||
|
prompt: null
|
||||||
reviewer:
|
reviewer:
|
||||||
- role: "developer"
|
- role: "developer"
|
||||||
condition: "notApproved"
|
condition: "notApproved"
|
||||||
|
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
|
||||||
- role: "$END"
|
- role: "$END"
|
||||||
condition: null
|
condition: null
|
||||||
|
prompt: null
|
||||||
|
|||||||
@@ -624,13 +624,17 @@ function resolveAgentConfig(
|
|||||||
return agentConfig;
|
return agentConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string, edgePrompt: string | null): CasRef {
|
||||||
const argv = [...agent.args, threadId, role];
|
const argv = [...agent.args, threadId, role];
|
||||||
|
const env = { ...process.env };
|
||||||
|
if (edgePrompt !== null) {
|
||||||
|
env.UWF_EDGE_PROMPT = edgePrompt;
|
||||||
|
}
|
||||||
let stdout: string;
|
let stdout: string;
|
||||||
try {
|
try {
|
||||||
stdout = execFileSync(agent.command, argv, {
|
stdout = execFileSync(agent.command, argv, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
env: process.env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -712,7 +716,7 @@ async function cmdThreadStepOnce(
|
|||||||
fail(nextResult.error.message);
|
fail(nextResult.error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextResult.value === END_ROLE) {
|
if (nextResult.value.role === END_ROLE) {
|
||||||
await archiveThread(storageRoot, threadId, workflowHash, headHash);
|
await archiveThread(storageRoot, threadId, workflowHash, headHash);
|
||||||
return {
|
return {
|
||||||
workflow: workflowHash,
|
workflow: workflowHash,
|
||||||
@@ -722,12 +726,13 @@ async function cmdThreadStepOnce(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = nextResult.value;
|
const role = nextResult.value.role;
|
||||||
|
const edgePrompt = nextResult.value.prompt;
|
||||||
const config = await loadWorkflowConfig(storageRoot);
|
const config = await loadWorkflowConfig(storageRoot);
|
||||||
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 newHead = spawnAgent(agent, threadId, role, edgePrompt);
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -748,7 +753,7 @@ async function cmdThreadStepOnce(
|
|||||||
fail(afterResult.error.message);
|
fail(afterResult.error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const done = afterResult.value === END_ROLE;
|
const done = afterResult.value.role === END_ROLE;
|
||||||
if (done) {
|
if (done) {
|
||||||
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Tra
|
|||||||
result[node] = transitions.map((t) => ({
|
result[node] = transitions.map((t) => ({
|
||||||
role: t.role,
|
role: t.role,
|
||||||
condition: t.condition ?? null,
|
condition: t.condition ?? null,
|
||||||
|
prompt: t.prompt ?? null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.4.0",
|
"@uncaged/json-cas": "^0.4.0",
|
||||||
"@uncaged/workflow-agent-kit": "workspace:^"
|
"@uncaged/workflow-agent-kit": "workspace:^",
|
||||||
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
|
"@uncaged/workflow-util": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
|||||||
@@ -46,53 +46,8 @@ export class HermesAcpClient {
|
|||||||
|
|
||||||
/** Spawn hermes acp, initialize, create session */
|
/** Spawn hermes acp, initialize, create session */
|
||||||
async connect(cwd: string): Promise<string> {
|
async connect(cwd: string): Promise<string> {
|
||||||
const child = spawn(HERMES_COMMAND, ["acp"], {
|
await this.ensureProcess();
|
||||||
env: process.env,
|
await this.initialize();
|
||||||
shell: false,
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.process = child;
|
|
||||||
|
|
||||||
child.stderr?.on("data", (chunk: Buffer) => {
|
|
||||||
this.stderrBuffer += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on("error", (cause) => {
|
|
||||||
const message = cause instanceof Error ? cause.message : String(cause);
|
|
||||||
this.rejectAll(new Error(`hermes acp spawn failed: ${message}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on("close", (code) => {
|
|
||||||
if (code !== 0 && this.pending.size > 0) {
|
|
||||||
const detail = this.stderrBuffer.trim() !== "" ? ` stderr=${this.stderrBuffer.trim()}` : "";
|
|
||||||
this.rejectAll(
|
|
||||||
new Error(`hermes acp exited unexpectedly with code ${code ?? "null"}${detail}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (child.stdout === null) {
|
|
||||||
throw new Error("hermes acp process stdout is not available");
|
|
||||||
}
|
|
||||||
const rl = createInterface({ input: child.stdout });
|
|
||||||
rl.on("line", (line) => {
|
|
||||||
this.handleLine(line.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
const initResponse = await this.sendRequest("initialize", {
|
|
||||||
protocolVersion: PROTOCOL_VERSION,
|
|
||||||
clientInfo: { name: "uwf", version: "0.1.0" },
|
|
||||||
capabilities: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
if ((initResponse as { error?: unknown }).error !== undefined) {
|
|
||||||
throw new Error(
|
|
||||||
`initialize failed: ${JSON.stringify((initResponse as { error: unknown }).error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendNotification("initialized");
|
|
||||||
|
|
||||||
const sessionResponse = (await this.sendRequest("session/new", {
|
const sessionResponse = (await this.sendRequest("session/new", {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -108,6 +63,27 @@ export class HermesAcpClient {
|
|||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Spawn hermes acp, initialize, resume an existing session */
|
||||||
|
async resume(sessionId: string, cwd: string): Promise<string> {
|
||||||
|
await this.ensureProcess();
|
||||||
|
await this.initialize();
|
||||||
|
|
||||||
|
const response = await this.sendRequest("session/resume", {
|
||||||
|
cwd,
|
||||||
|
sessionId,
|
||||||
|
mcpServers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((response as { error?: unknown }).error !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`session/resume failed: ${JSON.stringify((response as { error: unknown }).error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
/** Send prompt and collect full response text + structured messages. */
|
/** Send prompt and collect full response text + structured messages. */
|
||||||
async prompt(text: string): Promise<AcpPromptResult> {
|
async prompt(text: string): Promise<AcpPromptResult> {
|
||||||
if (this.sessionId === null) {
|
if (this.sessionId === null) {
|
||||||
@@ -358,4 +334,60 @@ export class HermesAcpClient {
|
|||||||
}
|
}
|
||||||
this.pending.clear();
|
this.pending.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureProcess(): Promise<void> {
|
||||||
|
if (this.process !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(HERMES_COMMAND, ["acp"], {
|
||||||
|
env: process.env,
|
||||||
|
shell: false,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process = child;
|
||||||
|
|
||||||
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
this.stderrBuffer += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (cause) => {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
this.rejectAll(new Error(`hermes acp spawn failed: ${message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0 && this.pending.size > 0) {
|
||||||
|
const detail = this.stderrBuffer.trim() !== "" ? ` stderr=${this.stderrBuffer.trim()}` : "";
|
||||||
|
this.rejectAll(
|
||||||
|
new Error(`hermes acp exited unexpectedly with code ${code ?? "null"}${detail}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (child.stdout === null) {
|
||||||
|
throw new Error("hermes acp process stdout is not available");
|
||||||
|
}
|
||||||
|
const rl = createInterface({ input: child.stdout });
|
||||||
|
rl.on("line", (line) => {
|
||||||
|
this.handleLine(line.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initialize(): Promise<void> {
|
||||||
|
const initResponse = await this.sendRequest("initialize", {
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
clientInfo: { name: "uwf", version: "0.1.0" },
|
||||||
|
capabilities: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((initResponse as { error?: unknown }).error !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`initialize failed: ${JSON.stringify((initResponse as { error: unknown }).error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendNotification("initialized");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import type { Store } from "@uncaged/json-cas";
|
import type { Store } from "@uncaged/json-cas";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AgentContext,
|
type AgentContext,
|
||||||
type AgentRunResult,
|
type AgentRunResult,
|
||||||
|
buildContinuationPrompt,
|
||||||
buildRolePrompt,
|
buildRolePrompt,
|
||||||
createAgent,
|
createAgent,
|
||||||
} from "@uncaged/workflow-agent-kit";
|
} from "@uncaged/workflow-agent-kit";
|
||||||
|
import { createLogger } from "@uncaged/workflow-util";
|
||||||
|
|
||||||
import { HermesAcpClient } from "./acp-client.js";
|
import { HermesAcpClient } from "./acp-client.js";
|
||||||
|
import { getCachedSessionId, isResumeDisabled, setCachedSessionId } from "./session-cache.js";
|
||||||
import { storeHermesSessionDetail } from "./session-detail.js";
|
import { storeHermesSessionDetail } from "./session-detail.js";
|
||||||
|
|
||||||
|
const log = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||||
if (steps.length === 0) {
|
if (steps.length === 0) {
|
||||||
return "";
|
return "";
|
||||||
@@ -29,12 +33,11 @@ function buildHistorySummary(steps: AgentContext["steps"]): string {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
function buildInitialPrompt(ctx: AgentContext): string {
|
||||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
|
||||||
const roleDef = ctx.workflow.roles[ctx.role];
|
const roleDef = ctx.workflow.roles[ctx.role];
|
||||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (ctx.outputFormatInstruction !== undefined && ctx.outputFormatInstruction !== "") {
|
if (ctx.outputFormatInstruction !== "") {
|
||||||
parts.push(ctx.outputFormatInstruction, "");
|
parts.push(ctx.outputFormatInstruction, "");
|
||||||
}
|
}
|
||||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||||
@@ -45,6 +48,69 @@ export function buildHermesPrompt(ctx: AgentContext): string {
|
|||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
||||||
|
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||||
|
if (ctx.edgePrompt !== null) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (ctx.outputFormatInstruction !== "") {
|
||||||
|
parts.push(ctx.outputFormatInstruction, "");
|
||||||
|
}
|
||||||
|
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildInitialPrompt(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storePromptResult(
|
||||||
|
store: Store,
|
||||||
|
sessionId: string,
|
||||||
|
messages: Awaited<ReturnType<HermesAcpClient["prompt"]>>["messages"],
|
||||||
|
): Promise<{ detailHash: string }> {
|
||||||
|
const session = {
|
||||||
|
session_id: sessionId,
|
||||||
|
model: "",
|
||||||
|
session_start: new Date().toISOString(),
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
return storeHermesSessionDetail(store, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptAttempt = {
|
||||||
|
useContinuation: boolean;
|
||||||
|
resumed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function prepareSession(
|
||||||
|
client: HermesAcpClient,
|
||||||
|
ctx: AgentContext,
|
||||||
|
cwd: string,
|
||||||
|
): Promise<PromptAttempt> {
|
||||||
|
if (ctx.edgePrompt === null || isResumeDisabled()) {
|
||||||
|
await client.connect(cwd);
|
||||||
|
return { useContinuation: false, resumed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role);
|
||||||
|
if (cachedSessionId === null) {
|
||||||
|
log("6RWK3N8Q", `no cached session for ${ctx.threadId}:${ctx.role}, starting new session`);
|
||||||
|
await client.connect(cwd);
|
||||||
|
return { useContinuation: false, resumed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.resume(cachedSessionId, cwd);
|
||||||
|
log("9MHT4V2P", `resumed hermes session ${cachedSessionId} for ${ctx.threadId}:${ctx.role}`);
|
||||||
|
return { useContinuation: true, resumed: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
log("3XPN7K4W", `session resume failed, falling back to new session: ${message}`);
|
||||||
|
await client.close();
|
||||||
|
await client.connect(cwd);
|
||||||
|
return { useContinuation: false, resumed: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode.
|
* Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode.
|
||||||
*
|
*
|
||||||
@@ -60,15 +126,38 @@ export function createHermesAgent(): () => Promise<void> {
|
|||||||
void client.close();
|
void client.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise<AgentRunResult> {
|
||||||
const fullPrompt = buildHermesPrompt(ctx);
|
const effectiveCtx = useContinuation ? ctx : { ...ctx, edgePrompt: null as string | null };
|
||||||
await client.connect(process.cwd());
|
const fullPrompt = buildHermesPrompt(effectiveCtx);
|
||||||
const { text, sessionId, messages } = await client.prompt(fullPrompt);
|
const { text, sessionId, messages } = await client.prompt(fullPrompt);
|
||||||
const session = { session_id: sessionId, model: "", session_start: new Date().toISOString(), messages };
|
const { detailHash } = await storePromptResult(ctx.store, sessionId, messages);
|
||||||
const { detailHash } = await storeHermesSessionDetail(ctx.store, session);
|
|
||||||
|
if (!isResumeDisabled()) {
|
||||||
|
await setCachedSessionId(ctx.threadId, ctx.role, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
return { output: text, detailHash, sessionId };
|
return { output: text, detailHash, sessionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const attempt = await prepareSession(client, ctx, cwd);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await runPrompt(ctx, attempt.useContinuation);
|
||||||
|
} catch (error) {
|
||||||
|
if (!attempt.resumed) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
log("8FQW2R6N", `continuation prompt failed, retrying with initial prompt: ${message}`);
|
||||||
|
await client.close();
|
||||||
|
await client.connect(cwd);
|
||||||
|
return runPrompt(ctx, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function continueHermes(
|
async function continueHermes(
|
||||||
_sessionId: string,
|
_sessionId: string,
|
||||||
message: string,
|
message: string,
|
||||||
@@ -77,8 +166,7 @@ export function createHermesAgent(): () => Promise<void> {
|
|||||||
// Client is already connected from runHermes — same ACP session,
|
// Client is already connected from runHermes — same ACP session,
|
||||||
// so the agent sees the full conversation history (crucial for retries).
|
// so the agent sees the full conversation history (crucial for retries).
|
||||||
const { text, sessionId, messages } = await client.prompt(message);
|
const { text, sessionId, messages } = await client.prompt(message);
|
||||||
const session = { session_id: sessionId, model: "", session_start: new Date().toISOString(), messages };
|
const { detailHash } = await storePromptResult(store, sessionId, messages);
|
||||||
const { detailHash } = await storeHermesSessionDetail(store, session);
|
|
||||||
return { output: text, detailHash, sessionId };
|
return { output: text, detailHash, sessionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
import { resolveStorageRoot } from "@uncaged/workflow-agent-kit";
|
||||||
|
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
type HermesSessionCache = Record<string, string>;
|
||||||
|
|
||||||
|
function getCachePath(): string {
|
||||||
|
return join(resolveStorageRoot(), "cache", "hermes-sessions.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(threadId: ThreadId, role: string): string {
|
||||||
|
return `${threadId}:${role}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCache(): Promise<HermesSessionCache> {
|
||||||
|
const path = getCachePath();
|
||||||
|
try {
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const raw = JSON.parse(text) as unknown;
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const cache: HermesSessionCache = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (typeof value === "string" && value !== "") {
|
||||||
|
cache[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCache(cache: HermesSessionCache): Promise<void> {
|
||||||
|
const path = getCachePath();
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isResumeDisabled(): boolean {
|
||||||
|
const flag = process.env.UWF_NO_RESUME;
|
||||||
|
return flag !== undefined && flag !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
|
||||||
|
const cache = await readCache();
|
||||||
|
const sessionId = cache[cacheKey(threadId, role)];
|
||||||
|
return sessionId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCachedSessionId(
|
||||||
|
threadId: ThreadId,
|
||||||
|
role: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const cache = await readCache();
|
||||||
|
cache[cacheKey(threadId, role)] = sessionId;
|
||||||
|
await writeCache(cache);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { StepContext } from "@uncaged/workflow-protocol";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { buildContinuationPrompt } from "../src/build-continuation-prompt.js";
|
||||||
|
|
||||||
|
const reviewerStep: StepContext = {
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false, comments: "Missing tests" },
|
||||||
|
detail: "2MXBG6PN4A8JR",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
};
|
||||||
|
|
||||||
|
const developerStep: StepContext = {
|
||||||
|
role: "developer",
|
||||||
|
output: { filesChanged: ["src/app.ts"], summary: "Initial fix" },
|
||||||
|
detail: "1VPBG9SM5E7WK",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("buildContinuationPrompt", () => {
|
||||||
|
test("includes steps after the last matching role and the edge prompt", () => {
|
||||||
|
const steps: StepContext[] = [
|
||||||
|
developerStep,
|
||||||
|
reviewerStep,
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "revise approach" },
|
||||||
|
detail: "7BQST3VW9F2MA",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(
|
||||||
|
steps,
|
||||||
|
"developer",
|
||||||
|
"The reviewer rejected your implementation. Read their feedback and fix the issues.",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 2: reviewer");
|
||||||
|
expect(result).toContain("Missing tests");
|
||||||
|
expect(result).toContain("### Step 3: planner");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("The reviewer rejected your implementation.");
|
||||||
|
expect(result).not.toContain("Initial fix");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses all steps when the role has not run before", () => {
|
||||||
|
const result = buildContinuationPrompt(
|
||||||
|
[developerStep, reviewerStep],
|
||||||
|
"planner",
|
||||||
|
"Continue from the reviewer feedback.",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain("### Step 1: developer");
|
||||||
|
expect(result).toContain("### Step 2: reviewer");
|
||||||
|
expect(result).toContain("Continue from the reviewer feedback.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still includes moderator instruction when there are no intervening steps", () => {
|
||||||
|
const result = buildContinuationPrompt(
|
||||||
|
[developerStep],
|
||||||
|
"developer",
|
||||||
|
"Please revise your work.",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("Please revise your work.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { StepContext } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
function formatStep(step: StepContext, stepNumber: number): string {
|
||||||
|
return [
|
||||||
|
`### Step ${stepNumber}: ${step.role}`,
|
||||||
|
`Output: ${JSON.stringify(step.output)}`,
|
||||||
|
`Agent: ${step.agent}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastRoleIndex(steps: StepContext[], role: string): number {
|
||||||
|
for (let i = steps.length - 1; i >= 0; i--) {
|
||||||
|
const step = steps[i];
|
||||||
|
if (step !== undefined && step.role === role) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a continuation prompt for a role re-entry.
|
||||||
|
*
|
||||||
|
* Finds the most recent step for `role`, collects everything after it as context,
|
||||||
|
* and appends the moderator edge prompt as the instruction.
|
||||||
|
*/
|
||||||
|
export function buildContinuationPrompt(
|
||||||
|
steps: StepContext[],
|
||||||
|
role: string,
|
||||||
|
edgePrompt: string,
|
||||||
|
): string {
|
||||||
|
const lastIndex = findLastRoleIndex(steps, role);
|
||||||
|
const sinceSteps = lastIndex >= 0 ? steps.slice(lastIndex + 1) : steps;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (sinceSteps.length > 0) {
|
||||||
|
parts.push("## What Happened Since Your Last Turn");
|
||||||
|
const baseStepNumber = lastIndex >= 0 ? lastIndex + 2 : 1;
|
||||||
|
for (let i = 0; i < sinceSteps.length; i++) {
|
||||||
|
const step = sinceSteps[i];
|
||||||
|
if (step === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parts.push("");
|
||||||
|
parts.push(formatStep(step, baseStepNumber + i));
|
||||||
|
}
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push("## Moderator Instruction", "", edgePrompt);
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
@@ -133,6 +133,7 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
|
|||||||
}
|
}
|
||||||
|
|
||||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||||
|
const edgePrompt = process.env.UWF_EDGE_PROMPT ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
threadId,
|
threadId,
|
||||||
@@ -142,6 +143,7 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
|
|||||||
workflow,
|
workflow,
|
||||||
store,
|
store,
|
||||||
outputFormatInstruction: "",
|
outputFormatInstruction: "",
|
||||||
|
edgePrompt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +180,7 @@ export async function buildContextWithMeta(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||||
|
const edgePrompt = process.env.UWF_EDGE_PROMPT ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
threadId,
|
threadId,
|
||||||
@@ -187,6 +190,7 @@ export async function buildContextWithMeta(
|
|||||||
workflow,
|
workflow,
|
||||||
store,
|
store,
|
||||||
outputFormatInstruction: "",
|
outputFormatInstruction: "",
|
||||||
|
edgePrompt,
|
||||||
meta: { storageRoot, store, schemas, headHash, chain },
|
meta: { storageRoot, store, schemas, headHash, chain },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { buildContinuationPrompt } from "./build-continuation-prompt.js";
|
||||||
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||||
export { buildRolePrompt } from "./build-role-prompt.js";
|
export { buildRolePrompt } from "./build-role-prompt.js";
|
||||||
export type { BuildContextMeta } from "./context.js";
|
export type { BuildContextMeta } from "./context.js";
|
||||||
@@ -11,7 +12,7 @@ export {
|
|||||||
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
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, resolveStorageRoot } from "./storage.js";
|
||||||
export type {
|
export type {
|
||||||
AgentContext,
|
AgentContext,
|
||||||
AgentContinueFn,
|
AgentContinueFn,
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export type AgentContext = ModeratorContext & {
|
|||||||
* role's output schema. Populated by `createAgent` at run time.
|
* role's output schema. Populated by `createAgent` at run time.
|
||||||
*/
|
*/
|
||||||
outputFormatInstruction: string;
|
outputFormatInstruction: string;
|
||||||
|
/**
|
||||||
|
* Edge prompt from the graph transition that led to this role.
|
||||||
|
* null on first entry (use full role definition), non-null on re-entry
|
||||||
|
* (use as continuation instruction from moderator).
|
||||||
|
*/
|
||||||
|
edgePrompt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentRunResult = {
|
export type AgentRunResult = {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
|
|||||||
describe("evaluate", () => {
|
describe("evaluate", () => {
|
||||||
test("$START → first role (fallback)", async () => {
|
test("$START → first role (fallback)", async () => {
|
||||||
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
||||||
expect(result).toEqual({ ok: true, value: "planner" });
|
expect(result).toEqual({ ok: true, value: { role: "planner", prompt: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("condition match (rejected → developer)", async () => {
|
test("condition match (rejected → developer)", async () => {
|
||||||
@@ -82,7 +82,7 @@ describe("evaluate", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
expect(result).toEqual({ ok: true, value: "developer" });
|
expect(result).toEqual({ ok: true, value: { role: "developer", prompt: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fallback when condition does not match → $END", async () => {
|
test("fallback when condition does not match → $END", async () => {
|
||||||
@@ -95,7 +95,7 @@ describe("evaluate", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
expect(result).toEqual({ ok: true, value: "$END" });
|
expect(result).toEqual({ ok: true, value: { role: "$END", prompt: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing role in graph → error", async () => {
|
test("missing role in graph → error", async () => {
|
||||||
@@ -124,7 +124,7 @@ describe("evaluate", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
expect(result).toEqual({ ok: true, value: "developer" });
|
expect(result).toEqual({ ok: true, value: { role: "developer", prompt: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("$last returns most recent matching role's frontmatter", async () => {
|
test("$last returns most recent matching role's frontmatter", async () => {
|
||||||
@@ -165,7 +165,7 @@ describe("evaluate", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const result = await evaluate(workflow, context);
|
const result = await evaluate(workflow, context);
|
||||||
expect(result).toEqual({ ok: true, value: "$END" });
|
expect(result).toEqual({ ok: true, value: { role: "$END", prompt: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("$first returns earliest matching role's frontmatter", async () => {
|
test("$first returns earliest matching role's frontmatter", async () => {
|
||||||
@@ -206,7 +206,7 @@ describe("evaluate", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const result = await evaluate(workflow, context);
|
const result = await evaluate(workflow, context);
|
||||||
expect(result).toEqual({ ok: true, value: "$END" });
|
expect(result).toEqual({ ok: true, value: { role: "$END", prompt: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("$last returns undefined for unmatched role", async () => {
|
test("$last returns undefined for unmatched role", async () => {
|
||||||
@@ -236,6 +236,6 @@ describe("evaluate", () => {
|
|||||||
]);
|
]);
|
||||||
const result = await evaluate(workflow, context);
|
const result = await evaluate(workflow, context);
|
||||||
// no reviewer step → $exists returns false → fallback to developer
|
// no reviewer step → $exists returns false → fallback to developer
|
||||||
expect(result).toEqual({ ok: true, value: "developer" });
|
expect(result).toEqual({ ok: true, value: { role: "developer", prompt: null } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
import jsonata from "jsonata";
|
import jsonata from "jsonata";
|
||||||
|
|
||||||
import type { Result } from "./types.js";
|
import type { EvaluateResult, Result } from "./types.js";
|
||||||
|
|
||||||
const START_ROLE = "$START";
|
const START_ROLE = "$START";
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ function currentRole(context: ModeratorContext): string {
|
|||||||
export async function evaluate(
|
export async function evaluate(
|
||||||
workflow: WorkflowPayload,
|
workflow: WorkflowPayload,
|
||||||
context: ModeratorContext,
|
context: ModeratorContext,
|
||||||
): Promise<Result<string, Error>> {
|
): Promise<Result<EvaluateResult, Error>> {
|
||||||
const role = currentRole(context);
|
const role = currentRole(context);
|
||||||
const transitions = workflow.graph[role];
|
const transitions = workflow.graph[role];
|
||||||
if (transitions === undefined) {
|
if (transitions === undefined) {
|
||||||
@@ -90,7 +90,7 @@ export async function evaluate(
|
|||||||
|
|
||||||
for (const transition of transitions) {
|
for (const transition of transitions) {
|
||||||
if (transition.condition === null) {
|
if (transition.condition === null) {
|
||||||
return { ok: true, value: transition.role };
|
return { ok: true, value: { role: transition.role, prompt: transition.prompt ?? null } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const conditionDef = workflow.conditions[transition.condition];
|
const conditionDef = workflow.conditions[transition.condition];
|
||||||
@@ -106,7 +106,7 @@ export async function evaluate(
|
|||||||
return evalResult;
|
return evalResult;
|
||||||
}
|
}
|
||||||
if (isTruthy(evalResult.value)) {
|
if (isTruthy(evalResult.value)) {
|
||||||
return { ok: true, value: transition.role };
|
return { ok: true, value: { role: transition.role, prompt: transition.prompt ?? null } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { evaluate } from "./evaluate.js";
|
export { evaluate } from "./evaluate.js";
|
||||||
|
export type { EvaluateResult } from "./types.js";
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
|
|
||||||
|
/** The result of moderator evaluation — which role to go to, and the edge prompt (if any). */
|
||||||
|
export type EvaluateResult = {
|
||||||
|
role: string;
|
||||||
|
prompt: string | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type RoleDefinition = {
|
|||||||
export type Transition = {
|
export type Transition = {
|
||||||
role: string;
|
role: string;
|
||||||
condition: string | null;
|
condition: string | null;
|
||||||
|
prompt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConditionDefinition = {
|
export type ConditionDefinition = {
|
||||||
|
|||||||
Reference in New Issue
Block a user