diff --git a/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts b/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts index 952252c..6fffa6c 100644 --- a/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts +++ b/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts @@ -7,6 +7,7 @@ function makeCtx(overrides: Partial = {}): AgentContext { return { threadId: "01JTEST0000000000000000000" as ThreadId, edgePrompt: "Proceed with the assigned role.", + isFirstVisit: true, workflow: { roles: { developer: { diff --git a/packages/workflow-agent-hermes/__tests__/hermes-prompt.test.ts b/packages/workflow-agent-hermes/__tests__/hermes-prompt.test.ts new file mode 100644 index 0000000..6bc1d9a --- /dev/null +++ b/packages/workflow-agent-hermes/__tests__/hermes-prompt.test.ts @@ -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 { + 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"); + }); +}); diff --git a/packages/workflow-agent-hermes/src/hermes.ts b/packages/workflow-agent-hermes/src/hermes.ts index f65b777..69ccd56 100644 --- a/packages/workflow-agent-hermes/src/hermes.ts +++ b/packages/workflow-agent-hermes/src/hermes.ts @@ -45,12 +45,13 @@ function buildInitialPrompt(ctx: AgentContext): string { if (historyBlock !== "") { parts.push("", historyBlock); } + parts.push("", "## Moderator Instruction", "", ctx.edgePrompt); return parts.join("\n"); } /** Assemble system prompt, task, and prior step outputs for Hermes. */ export function buildHermesPrompt(ctx: AgentContext): string { - if (ctx.edgePrompt !== "") { + if (!ctx.isFirstVisit) { const parts: string[] = []; if (ctx.outputFormatInstruction !== "") { parts.push(ctx.outputFormatInstruction, ""); @@ -86,7 +87,7 @@ async function prepareSession( ctx: AgentContext, cwd: string, ): Promise { - if (ctx.edgePrompt === "" || isResumeDisabled()) { + if (ctx.isFirstVisit || isResumeDisabled()) { await client.connect(cwd); return { useContinuation: false, resumed: false }; } @@ -127,7 +128,7 @@ export function createHermesAgent(): () => Promise { }); async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise { - const effectiveCtx = useContinuation ? ctx : { ...ctx, edgePrompt: "" }; + const effectiveCtx = useContinuation ? ctx : { ...ctx, isFirstVisit: true }; const fullPrompt = buildHermesPrompt(effectiveCtx); const { text, sessionId, messages } = await client.prompt(fullPrompt); const { detailHash } = await storePromptResult(ctx.store, sessionId, messages); diff --git a/packages/workflow-agent-kit/src/context.ts b/packages/workflow-agent-kit/src/context.ts index 2ea1a02..e9f4050 100644 --- a/packages/workflow-agent-kit/src/context.ts +++ b/packages/workflow-agent-kit/src/context.ts @@ -142,6 +142,7 @@ export async function buildContext(threadId: ThreadId, role: string): Promise s.role === role); return { threadId, @@ -152,6 +153,7 @@ export async function buildContext(threadId: ThreadId, role: string): Promise s.role === role); return { threadId, @@ -199,6 +202,7 @@ export async function buildContextWithMeta( store, outputFormatInstruction: "", edgePrompt, + isFirstVisit, meta: { storageRoot, store, schemas, headHash, chain }, }; } diff --git a/packages/workflow-agent-kit/src/types.ts b/packages/workflow-agent-kit/src/types.ts index c68a79f..9a25918 100644 --- a/packages/workflow-agent-kit/src/types.ts +++ b/packages/workflow-agent-kit/src/types.ts @@ -14,9 +14,13 @@ export type AgentContext = ModeratorContext & { outputFormatInstruction: string; /** * 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; + /** + * True when the current role has not appeared in steps history before this invocation. + */ + isFirstVisit: boolean; }; export type AgentRunResult = {