feat: replace edgePrompt null check with isFirstVisit (Phase 2)

- Add isFirstVisit: boolean to AgentContext
- Compute from steps history: !steps.some(s => s.role === role)
- hermes.ts: use isFirstVisit for first-entry vs re-entry logic
- buildInitialPrompt: always append edgePrompt as Moderator Instruction
- edgePrompt is never blanked — always the real moderator instruction
- New tests for first-visit, re-entry, and fallback scenarios

Refs #405, #407, #404
This commit is contained in:
2026-05-23 04:54:11 +00:00
parent 2a6bce4918
commit 4b45f4e6d1
5 changed files with 92 additions and 4 deletions
@@ -7,6 +7,7 @@ function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
return { return {
threadId: "01JTEST0000000000000000000" as ThreadId, threadId: "01JTEST0000000000000000000" as ThreadId,
edgePrompt: "Proceed with the assigned role.", edgePrompt: "Proceed with the assigned role.",
isFirstVisit: true,
workflow: { workflow: {
roles: { roles: {
developer: { developer: {
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-agent-kit";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { buildHermesPrompt } from "../src/hermes.js";
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
return {
threadId: "01JTEST0000000000000000000" as ThreadId,
edgePrompt: "Proceed with the assigned role.",
isFirstVisit: true,
workflow: {
roles: {
developer: {
description: "TDD implementation per test spec",
goal: "Write code",
capabilities: ["coding"],
procedure: "1. Read spec\n2. Write code",
output: "List files changed",
frontmatter: "",
},
},
conditions: {},
graph: {},
},
role: "developer",
start: { prompt: "Fix the bug", workflowHash: "abc123", threadId: "t1" },
steps: [],
store: {} as AgentContext["store"],
outputFormatInstruction: "Use YAML frontmatter",
...overrides,
};
}
describe("buildHermesPrompt", () => {
test("first visit uses full role prompt and includes moderator instruction", () => {
const result = buildHermesPrompt(
makeCtx({ edgePrompt: "Focus on the failing test.", isFirstVisit: true }),
);
expect(result).toMatch(/^Use YAML frontmatter/);
expect(result).toContain("Write code");
expect(result).toContain("## Task\nFix the bug");
expect(result).toContain("## Moderator Instruction");
expect(result).toContain("Focus on the failing test.");
});
test("re-entry uses continuation prompt with edge instruction", () => {
const ctx = makeCtx({
isFirstVisit: false,
edgePrompt: "The reviewer rejected your work. Fix the issues.",
steps: [
{ role: "developer", output: { summary: "Initial fix" }, agent: "uwf-hermes" },
{ role: "reviewer", output: { approved: false }, agent: "uwf-hermes" },
],
});
const result = buildHermesPrompt(ctx);
expect(result).not.toContain("## Task");
expect(result).toContain("## What Happened Since Your Last Turn");
expect(result).toContain("## Moderator Instruction");
expect(result).toContain("The reviewer rejected your work.");
});
test("forced first visit via isFirstVisit uses initial prompt even when role appears in history", () => {
const result = buildHermesPrompt(
makeCtx({
isFirstVisit: true,
steps: [{ role: "developer", output: { done: true }, agent: "uwf-hermes" }],
edgePrompt: "Retry with a fresh approach.",
}),
);
expect(result).toContain("## Task");
expect(result).toContain("Retry with a fresh approach.");
expect(result).not.toContain("## What Happened Since Your Last Turn");
});
});
+4 -3
View File
@@ -45,12 +45,13 @@ function buildInitialPrompt(ctx: AgentContext): string {
if (historyBlock !== "") { if (historyBlock !== "") {
parts.push("", historyBlock); parts.push("", historyBlock);
} }
parts.push("", "## Moderator Instruction", "", ctx.edgePrompt);
return parts.join("\n"); return parts.join("\n");
} }
/** Assemble system prompt, task, and prior step outputs for Hermes. */ /** Assemble system prompt, task, and prior step outputs for Hermes. */
export function buildHermesPrompt(ctx: AgentContext): string { export function buildHermesPrompt(ctx: AgentContext): string {
if (ctx.edgePrompt !== "") { if (!ctx.isFirstVisit) {
const parts: string[] = []; const parts: string[] = [];
if (ctx.outputFormatInstruction !== "") { if (ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, ""); parts.push(ctx.outputFormatInstruction, "");
@@ -86,7 +87,7 @@ async function prepareSession(
ctx: AgentContext, ctx: AgentContext,
cwd: string, cwd: string,
): Promise<PromptAttempt> { ): Promise<PromptAttempt> {
if (ctx.edgePrompt === "" || isResumeDisabled()) { if (ctx.isFirstVisit || isResumeDisabled()) {
await client.connect(cwd); await client.connect(cwd);
return { useContinuation: false, resumed: false }; return { useContinuation: false, resumed: false };
} }
@@ -127,7 +128,7 @@ export function createHermesAgent(): () => Promise<void> {
}); });
async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise<AgentRunResult> { async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise<AgentRunResult> {
const effectiveCtx = useContinuation ? ctx : { ...ctx, edgePrompt: "" }; const effectiveCtx = useContinuation ? ctx : { ...ctx, isFirstVisit: true };
const fullPrompt = buildHermesPrompt(effectiveCtx); const fullPrompt = buildHermesPrompt(effectiveCtx);
const { text, sessionId, messages } = await client.prompt(fullPrompt); const { text, sessionId, messages } = await client.prompt(fullPrompt);
const { detailHash } = await storePromptResult(ctx.store, sessionId, messages); const { detailHash } = await storePromptResult(ctx.store, sessionId, messages);
@@ -142,6 +142,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 = readEdgePrompt(); const edgePrompt = readEdgePrompt();
const isFirstVisit = !steps.some((s) => s.role === role);
return { return {
threadId, threadId,
@@ -152,6 +153,7 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
store, store,
outputFormatInstruction: "", outputFormatInstruction: "",
edgePrompt, edgePrompt,
isFirstVisit,
}; };
} }
@@ -189,6 +191,7 @@ export async function buildContextWithMeta(
const steps = await buildHistory(store, chain.stepsNewestFirst); const steps = await buildHistory(store, chain.stepsNewestFirst);
const edgePrompt = readEdgePrompt(); const edgePrompt = readEdgePrompt();
const isFirstVisit = !steps.some((s) => s.role === role);
return { return {
threadId, threadId,
@@ -199,6 +202,7 @@ export async function buildContextWithMeta(
store, store,
outputFormatInstruction: "", outputFormatInstruction: "",
edgePrompt, edgePrompt,
isFirstVisit,
meta: { storageRoot, store, schemas, headHash, chain }, meta: { storageRoot, store, schemas, headHash, chain },
}; };
} }
+5 -1
View File
@@ -14,9 +14,13 @@ export type AgentContext = ModeratorContext & {
outputFormatInstruction: string; outputFormatInstruction: string;
/** /**
* Edge prompt from the graph transition that led to this role (UWF_EDGE_PROMPT). * Edge prompt from the graph transition that led to this role (UWF_EDGE_PROMPT).
* Phase 2 will use visit history to choose full role definition vs continuation. * Always the real moderator instruction for this step.
*/ */
edgePrompt: string; edgePrompt: string;
/**
* True when the current role has not appeared in steps history before this invocation.
*/
isFirstVisit: boolean;
}; };
export type AgentRunResult = { export type AgentRunResult = {