From 080792a6c0f64b891f6d37f5ac9aab819bcd31a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Sat, 23 May 2026 17:34:49 +0800 Subject: [PATCH] feat: builtin agent session resume via deterministic message reconstruction (#426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StepRecord adds edgePrompt field (backward compat: defaults to "") - StepNode CAS schema includes edgePrompt - writeStepNode persists ctx.edgePrompt - buildHistory exposes edgePrompt in StepContext - buildBuiltinMessages reconstructs multi-turn moderator↔agent conversation: system = role prompt + output format (stable prefix) per prior visit: user (edgePrompt + inter-step summary) + assistant (output) current: user (edgePrompt + recent summary) - Zero extra persistence — pure function of CAS chain - Stable prefix for LLM prompt cache hits - 10 builtin tests pass, all other package tests pass --- packages/cli-workflow/src/commands/thread.ts | 1 + .../__tests__/prompt.test.ts | 203 ++++++++++++++++-- packages/workflow-agent-builtin/src/agent.ts | 8 +- packages/workflow-agent-builtin/src/index.ts | 2 +- packages/workflow-agent-builtin/src/prompt.ts | 97 ++++++--- .../__tests__/claude-code.test.ts | 10 +- .../__tests__/hermes-prompt.test.ts | 26 ++- .../build-continuation-prompt.test.ts | 3 + packages/workflow-agent-kit/src/context.ts | 1 + packages/workflow-agent-kit/src/run.ts | 3 + packages/workflow-protocol/src/schemas.ts | 1 + packages/workflow-protocol/src/types.ts | 2 + 12 files changed, 305 insertions(+), 52 deletions(-) diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index 46c4589..6e535b2 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -594,6 +594,7 @@ function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorConte output: expandOutput(uwf, step.output), detail: step.detail, agent: step.agent, + edgePrompt: step.edgePrompt ?? "", })); return { start: chain.start, steps }; } diff --git a/packages/workflow-agent-builtin/__tests__/prompt.test.ts b/packages/workflow-agent-builtin/__tests__/prompt.test.ts index 58fedf0..9bba61b 100644 --- a/packages/workflow-agent-builtin/__tests__/prompt.test.ts +++ b/packages/workflow-agent-builtin/__tests__/prompt.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { AgentContext } from "@uncaged/workflow-agent-kit"; -import { buildBuiltinPrompt } from "../src/prompt.js"; +import { buildBuiltinMessages } from "../src/prompt.js"; function minimalContext(overrides: Partial = {}): AgentContext { return { @@ -11,11 +11,13 @@ function minimalContext(overrides: Partial = {}): AgentContext { store: {} as AgentContext["store"], workflow: { name: "test", + description: "test workflow", roles: { developer: { + description: "Developer role", goal: "Ship the fix", capabilities: ["file-edit"], - procedure: ["Edit files"], + procedure: "Edit files", output: "A patch", frontmatter: "schema-hash", }, @@ -32,24 +34,30 @@ function minimalContext(overrides: Partial = {}): AgentContext { }; } -describe("buildBuiltinPrompt", () => { +describe("buildBuiltinMessages", () => { test("system includes output format and role goal", () => { - const { system } = buildBuiltinPrompt(minimalContext()); - expect(system).toContain("status: done"); - expect(system).toContain("## Goal"); - expect(system).toContain("Ship the fix"); + const messages = buildBuiltinMessages(minimalContext()); + const system = messages[0]; + expect(system?.role).toBe("system"); + if (system?.role === "system") { + expect(system.content).toContain("status: done"); + expect(system.content).toContain("## Goal"); + expect(system.content).toContain("Ship the fix"); + } }); - test("user includes task and edge prompt", () => { - const { user } = buildBuiltinPrompt(minimalContext()); - expect(user).toContain("## Task"); - expect(user).toContain("Fix the bug"); - expect(user).toContain("## Current Step Instruction"); - expect(user).toContain("Implement the fix"); + test("first visit produces system + single user message with edge prompt", () => { + const messages = buildBuiltinMessages(minimalContext()); + expect(messages).toHaveLength(2); + expect(messages[1]?.role).toBe("user"); + if (messages[1]?.role === "user") { + expect(messages[1].content).toContain("Implement the fix"); + expect(messages[1].content).not.toContain("## What Happened Since Your Last Turn"); + } }); - test("user includes history when steps exist", () => { - const { user } = buildBuiltinPrompt( + test("first visit with prior steps includes inter-step summary in final user message", () => { + const messages = buildBuiltinMessages( minimalContext({ steps: [ { @@ -57,11 +65,172 @@ describe("buildBuiltinPrompt", () => { output: { plan: "step 1" }, agent: "uwf-builtin", detail: "detail-hash", + edgePrompt: "Create a plan.", }, ], }), ); - expect(user).toContain("## Previous Steps"); - expect(user).toContain("planner"); + expect(messages).toHaveLength(2); + const finalUser = messages[1]; + if (finalUser?.role === "user") { + expect(finalUser.content).toContain("Implement the fix"); + expect(finalUser.content).toContain("## What Happened Since Your Last Turn"); + expect(finalUser.content).toContain("planner"); + } + }); + + test("re-entry reconstructs prior user/assistant turns plus current user message", () => { + const messages = buildBuiltinMessages( + minimalContext({ + isFirstVisit: false, + edgePrompt: "Fix the reviewer's feedback.", + steps: [ + { + role: "developer", + output: { summary: "Initial fix" }, + agent: "uwf-builtin", + detail: "detail-1", + edgePrompt: "Implement the fix.", + }, + { + role: "reviewer", + output: { approved: false, comments: "Missing tests" }, + agent: "uwf-builtin", + detail: "detail-2", + edgePrompt: "Review the implementation.", + }, + ], + }), + ); + + expect(messages).toHaveLength(4); + expect(messages[0]?.role).toBe("system"); + expect(messages[1]?.role).toBe("user"); + expect(messages[2]?.role).toBe("assistant"); + expect(messages[3]?.role).toBe("user"); + + if (messages[1]?.role === "user") { + expect(messages[1].content).toBe("Implement the fix."); + } + if (messages[2]?.role === "assistant") { + expect(messages[2].content).toBe(JSON.stringify({ summary: "Initial fix" })); + } + if (messages[3]?.role === "user") { + expect(messages[3].content).toContain("Fix the reviewer's feedback."); + expect(messages[3].content).toContain("## What Happened Since Your Last Turn"); + expect(messages[3].content).toContain("reviewer"); + expect(messages[3].content).toContain("Missing tests"); + } + }); + + test("prefix is stable across re-entry for LLM cache hits", () => { + const firstVisitMessages = buildBuiltinMessages( + minimalContext({ + edgePrompt: "Implement the fix.", + steps: [], + }), + ); + + const reEntryMessages = buildBuiltinMessages( + minimalContext({ + isFirstVisit: false, + edgePrompt: "Fix the reviewer's feedback.", + steps: [ + { + role: "developer", + output: { summary: "Initial fix" }, + agent: "uwf-builtin", + detail: "detail-1", + edgePrompt: "Implement the fix.", + }, + { + role: "reviewer", + output: { approved: false }, + agent: "uwf-builtin", + detail: "detail-2", + edgePrompt: "Review the code.", + }, + ], + }), + ); + + expect(reEntryMessages[0]).toEqual(firstVisitMessages[0]); + expect(reEntryMessages[1]).toEqual(firstVisitMessages[1]); + expect(reEntryMessages[2]?.role).toBe("assistant"); + if (reEntryMessages[2]?.role === "assistant") { + expect(reEntryMessages[2].content).toBe(JSON.stringify({ summary: "Initial fix" })); + } + expect(reEntryMessages[3]?.role).toBe("user"); + if (reEntryMessages[3]?.role === "user") { + expect(reEntryMessages[3].content).toContain("Fix the reviewer's feedback."); + } + }); + + test("multiple prior visits emit one user/assistant pair per visit", () => { + const messages = buildBuiltinMessages( + minimalContext({ + isFirstVisit: false, + edgePrompt: "Third round fix.", + steps: [ + { + role: "developer", + output: { round: 1 }, + agent: "uwf-builtin", + detail: "d1", + edgePrompt: "First attempt.", + }, + { + role: "reviewer", + output: { approved: false }, + agent: "uwf-builtin", + detail: "d2", + edgePrompt: "Review round 1.", + }, + { + role: "developer", + output: { round: 2 }, + agent: "uwf-builtin", + detail: "d3", + edgePrompt: "Second attempt.", + }, + { + role: "reviewer", + output: { approved: false }, + agent: "uwf-builtin", + detail: "d4", + edgePrompt: "Review round 2.", + }, + ], + }), + ); + + expect(messages).toHaveLength(6); + expect(messages.map((m) => m.role)).toEqual([ + "system", + "user", + "assistant", + "user", + "assistant", + "user", + ]); + + if (messages[1]?.role === "user") { + expect(messages[1].content).toBe("First attempt."); + } + if (messages[2]?.role === "assistant") { + expect(messages[2].content).toBe(JSON.stringify({ round: 1 })); + } + if (messages[3]?.role === "user") { + expect(messages[3].content).toContain("Second attempt."); + expect(messages[3].content).toContain("reviewer"); + } + if (messages[4]?.role === "assistant") { + expect(messages[4].content).toBe(JSON.stringify({ round: 2 })); + } + if (messages[5]?.role === "user") { + expect(messages[5].content).toContain("Third round fix."); + expect(messages[5].content).toContain("### Step 4: reviewer"); + expect(messages[5].content).toContain('"approved":false'); + } }); }); diff --git a/packages/workflow-agent-builtin/src/agent.ts b/packages/workflow-agent-builtin/src/agent.ts index c56dcc8..118fee7 100644 --- a/packages/workflow-agent-builtin/src/agent.ts +++ b/packages/workflow-agent-builtin/src/agent.ts @@ -12,7 +12,7 @@ import { generateUlid } from "@uncaged/workflow-util"; import { storeBuiltinDetail } from "./detail.js"; import type { ChatMessage } from "./llm/index.js"; import { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js"; -import { buildBuiltinPrompt } from "./prompt.js"; +import { buildBuiltinMessages } from "./prompt.js"; import type { BuiltinSessionState } from "./types.js"; const sessions = new Map(); @@ -69,11 +69,7 @@ async function runBuiltin(ctx: AgentContext): Promise { const provider = resolveModel(config, config.defaultModel); const sessionId = generateUlid(Date.now()); - const promptParts = buildBuiltinPrompt(ctx); - const messages: ChatMessage[] = [ - { role: "system", content: promptParts.system }, - { role: "user", content: promptParts.user }, - ]; + const messages = buildBuiltinMessages(ctx); const session: BuiltinSessionState = { sessionId, diff --git a/packages/workflow-agent-builtin/src/index.ts b/packages/workflow-agent-builtin/src/index.ts index 4bfe337..f5ad799 100644 --- a/packages/workflow-agent-builtin/src/index.ts +++ b/packages/workflow-agent-builtin/src/index.ts @@ -3,7 +3,7 @@ export { extractFinalAssistantText, storeBuiltinDetail } from "./detail.js"; export type { ChatMessage, LlmAssistantResponse, LlmToolCall } from "./llm/index.js"; export { chatCompletionWithTools } from "./llm/index.js"; export { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js"; -export { buildBuiltinPrompt } from "./prompt.js"; +export { buildBuiltinMessages } from "./prompt.js"; export type { BuiltinTool, ToolContext } from "./tools/index.js"; export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js"; export type { diff --git a/packages/workflow-agent-builtin/src/prompt.ts b/packages/workflow-agent-builtin/src/prompt.ts index 72e2ea9..94f7379 100644 --- a/packages/workflow-agent-builtin/src/prompt.ts +++ b/packages/workflow-agent-builtin/src/prompt.ts @@ -1,31 +1,56 @@ import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-agent-kit"; -function buildHistorySummary(steps: AgentContext["steps"]): string { - if (steps.length === 0) { +import type { ChatMessage } from "./llm/index.js"; + +type StepContext = AgentContext["steps"][number]; + +function formatStep(step: StepContext, stepNumber: number): string { + return [ + `### Step ${stepNumber}: ${step.role}`, + `Output: ${JSON.stringify(step.output)}`, + `Agent: ${step.agent}`, + ].join("\n"); +} + +function buildStepsSummary(steps: StepContext[], fromIndex: number, toIndex: number): string { + if (fromIndex >= toIndex) { return ""; } - const lines: string[] = ["## Previous Steps"]; - for (let i = 0; i < steps.length; i++) { + const lines: string[] = ["## What Happened Since Your Last Turn"]; + for (let i = fromIndex; i < toIndex; i++) { const step = steps[i]; if (step === undefined) { continue; } lines.push(""); - lines.push(`### Step ${i + 1}: ${step.role}`); - lines.push(`Output: ${JSON.stringify(step.output)}`); - lines.push(`Agent: ${step.agent}`); + lines.push(formatStep(step, i + 1)); } return lines.join("\n"); } -export type BuiltinPromptParts = { - system: string; - user: string; -}; +function buildUserTurnContent(edgePrompt: string, summary: string): string { + const parts: string[] = []; + if (edgePrompt !== "") { + parts.push(edgePrompt); + } + if (summary !== "") { + if (parts.length > 0) { + parts.push(""); + } + parts.push(summary); + } + return parts.join("\n"); +} -/** Assemble system prompt (role + format) and user prompt (task + edge + history). */ -export function buildBuiltinPrompt(ctx: AgentContext): BuiltinPromptParts { +/** + * Reconstruct multi-turn chat messages from thread history for cache-friendly session resume. + * + * - system: role prompt + output format (stable prefix) + * - For each prior visit of this role: user (edgePrompt + inter-step summary) + assistant (output JSON) + * - Final user: current edgePrompt + summary since last visit of this role + */ +export function buildBuiltinMessages(ctx: AgentContext): ChatMessage[] { const roleDef = ctx.workflow.roles[ctx.role]; const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : ""; const systemParts: string[] = []; @@ -34,17 +59,41 @@ export function buildBuiltinPrompt(ctx: AgentContext): BuiltinPromptParts { } systemParts.push(rolePrompt); - const userParts: string[] = ["## Task", ctx.start.prompt]; - if (ctx.edgePrompt !== "") { - userParts.push("", "## Current Step Instruction", ctx.edgePrompt); - } - const historyBlock = buildHistorySummary(ctx.steps); - if (historyBlock !== "") { - userParts.push("", historyBlock); + const messages: ChatMessage[] = [{ role: "system", content: systemParts.join("\n") }]; + + const roleVisitIndices: number[] = []; + for (let i = 0; i < ctx.steps.length; i++) { + const step = ctx.steps[i]; + if (step !== undefined && step.role === ctx.role) { + roleVisitIndices.push(i); + } } - return { - system: systemParts.join("\n"), - user: userParts.join("\n"), - }; + let prevVisitIndex = -1; + for (const visitIndex of roleVisitIndices) { + const visitStep = ctx.steps[visitIndex]; + if (visitStep === undefined) { + continue; + } + + const summary = buildStepsSummary(ctx.steps, prevVisitIndex + 1, visitIndex); + messages.push({ + role: "user", + content: buildUserTurnContent(visitStep.edgePrompt, summary), + }); + messages.push({ + role: "assistant", + content: JSON.stringify(visitStep.output), + tool_calls: null, + }); + prevVisitIndex = visitIndex; + } + + const finalSummary = buildStepsSummary(ctx.steps, prevVisitIndex + 1, ctx.steps.length); + messages.push({ + role: "user", + content: buildUserTurnContent(ctx.edgePrompt, finalSummary), + }); + + return messages; } 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 6fffa6c..1b16939 100644 --- a/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts +++ b/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts @@ -41,7 +41,15 @@ describe("buildClaudeCodePrompt", () => { test("includes previous steps as history summary", () => { const ctx = makeCtx({ - steps: [{ role: "planner", output: '{"plan":"do X"}', agent: "hermes" }], + steps: [ + { + role: "planner", + output: '{"plan":"do X"}', + agent: "hermes", + detail: "detail-1", + edgePrompt: "Create a plan.", + }, + ], }); const result = buildClaudeCodePrompt(ctx); expect(result).toContain("## Previous Steps"); diff --git a/packages/workflow-agent-hermes/__tests__/hermes-prompt.test.ts b/packages/workflow-agent-hermes/__tests__/hermes-prompt.test.ts index 6bc1d9a..fbe6f72 100644 --- a/packages/workflow-agent-hermes/__tests__/hermes-prompt.test.ts +++ b/packages/workflow-agent-hermes/__tests__/hermes-prompt.test.ts @@ -49,8 +49,20 @@ describe("buildHermesPrompt", () => { 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" }, + { + role: "developer", + output: { summary: "Initial fix" }, + agent: "uwf-hermes", + detail: "detail-1", + edgePrompt: "Implement the fix.", + }, + { + role: "reviewer", + output: { approved: false }, + agent: "uwf-hermes", + detail: "detail-2", + edgePrompt: "Review the code.", + }, ], }); @@ -66,7 +78,15 @@ describe("buildHermesPrompt", () => { const result = buildHermesPrompt( makeCtx({ isFirstVisit: true, - steps: [{ role: "developer", output: { done: true }, agent: "uwf-hermes" }], + steps: [ + { + role: "developer", + output: { done: true }, + agent: "uwf-hermes", + detail: "detail-1", + edgePrompt: "First attempt.", + }, + ], edgePrompt: "Retry with a fresh approach.", }), ); diff --git a/packages/workflow-agent-kit/__tests__/build-continuation-prompt.test.ts b/packages/workflow-agent-kit/__tests__/build-continuation-prompt.test.ts index 8f58483..8b52cc3 100644 --- a/packages/workflow-agent-kit/__tests__/build-continuation-prompt.test.ts +++ b/packages/workflow-agent-kit/__tests__/build-continuation-prompt.test.ts @@ -7,6 +7,7 @@ const reviewerStep: StepContext = { output: { approved: false, comments: "Missing tests" }, detail: "2MXBG6PN4A8JR", agent: "uwf-hermes", + edgePrompt: "Review the developer's work.", }; const developerStep: StepContext = { @@ -14,6 +15,7 @@ const developerStep: StepContext = { output: { filesChanged: ["src/app.ts"], summary: "Initial fix" }, detail: "1VPBG9SM5E7WK", agent: "uwf-hermes", + edgePrompt: "Implement the fix.", }; describe("buildContinuationPrompt", () => { @@ -26,6 +28,7 @@ describe("buildContinuationPrompt", () => { output: { plan: "revise approach" }, detail: "7BQST3VW9F2MA", agent: "uwf-hermes", + edgePrompt: "Revise the plan.", }, ]; diff --git a/packages/workflow-agent-kit/src/context.ts b/packages/workflow-agent-kit/src/context.ts index e9f4050..4c1647b 100644 --- a/packages/workflow-agent-kit/src/context.ts +++ b/packages/workflow-agent-kit/src/context.ts @@ -102,6 +102,7 @@ async function buildHistory( output: expandOutput(store, step.output), detail: step.detail, agent: step.agent, + edgePrompt: step.edgePrompt ?? "", }); } return history; diff --git a/packages/workflow-agent-kit/src/run.ts b/packages/workflow-agent-kit/src/run.ts index 73f7e58..552a6da 100644 --- a/packages/workflow-agent-kit/src/run.ts +++ b/packages/workflow-agent-kit/src/run.ts @@ -50,6 +50,7 @@ async function writeStepNode(options: { outputHash: CasRef; detailHash: CasRef; agentName: string; + edgePrompt: string; }): Promise { const payload: StepNodePayload = { start: options.startHash, @@ -58,6 +59,7 @@ async function writeStepNode(options: { output: options.outputHash, detail: options.detailHash, agent: options.agentName, + edgePrompt: options.edgePrompt, }; const hash = await options.store.put(options.schemas.stepNode, payload); const node = options.store.get(hash); @@ -95,6 +97,7 @@ async function persistStep(options: { outputHash: options.outputHash, detailHash: options.detailHash, agentName: options.agentName, + edgePrompt: options.ctx.edgePrompt, }); } diff --git a/packages/workflow-protocol/src/schemas.ts b/packages/workflow-protocol/src/schemas.ts index 09ed1aa..d0b36b3 100644 --- a/packages/workflow-protocol/src/schemas.ts +++ b/packages/workflow-protocol/src/schemas.ts @@ -85,6 +85,7 @@ export const STEP_NODE_SCHEMA: JSONSchema = { output: { type: "string", format: "cas_ref" }, detail: { type: "string", format: "cas_ref" }, agent: { type: "string" }, + edgePrompt: { type: "string" }, }, additionalProperties: false, }; diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index 1dcd822..fe256f4 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -12,6 +12,8 @@ export type StepRecord = { output: CasRef; detail: CasRef; agent: string; + /** Moderator edge prompt that led to this step. Missing in legacy nodes → "". */ + edgePrompt: string; }; // ── 4.2 Workflow 定义 ───────────────────────────────────────────────