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