Merge pull request 'feat: builtin agent session resume via deterministic message reconstruction' (#427) from feat/426-builtin-session-resume into main

This commit is contained in:
2026-05-23 09:39:32 +00:00
12 changed files with 305 additions and 52 deletions
@@ -594,6 +594,7 @@ function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorConte
output: expandOutput(uwf, step.output), output: expandOutput(uwf, step.output),
detail: step.detail, detail: step.detail,
agent: step.agent, agent: step.agent,
edgePrompt: step.edgePrompt ?? "",
})); }));
return { start: chain.start, steps }; return { start: chain.start, steps };
} }
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-agent-kit"; 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> = {}): AgentContext { function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
return { return {
@@ -11,11 +11,13 @@ function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
store: {} as AgentContext["store"], store: {} as AgentContext["store"],
workflow: { workflow: {
name: "test", name: "test",
description: "test workflow",
roles: { roles: {
developer: { developer: {
description: "Developer role",
goal: "Ship the fix", goal: "Ship the fix",
capabilities: ["file-edit"], capabilities: ["file-edit"],
procedure: ["Edit files"], procedure: "Edit files",
output: "A patch", output: "A patch",
frontmatter: "schema-hash", frontmatter: "schema-hash",
}, },
@@ -32,24 +34,30 @@ function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
}; };
} }
describe("buildBuiltinPrompt", () => { describe("buildBuiltinMessages", () => {
test("system includes output format and role goal", () => { test("system includes output format and role goal", () => {
const { system } = buildBuiltinPrompt(minimalContext()); const messages = buildBuiltinMessages(minimalContext());
expect(system).toContain("status: done"); const system = messages[0];
expect(system).toContain("## Goal"); expect(system?.role).toBe("system");
expect(system).toContain("Ship the fix"); 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", () => { test("first visit produces system + single user message with edge prompt", () => {
const { user } = buildBuiltinPrompt(minimalContext()); const messages = buildBuiltinMessages(minimalContext());
expect(user).toContain("## Task"); expect(messages).toHaveLength(2);
expect(user).toContain("Fix the bug"); expect(messages[1]?.role).toBe("user");
expect(user).toContain("## Current Step Instruction"); if (messages[1]?.role === "user") {
expect(user).toContain("Implement the fix"); 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", () => { test("first visit with prior steps includes inter-step summary in final user message", () => {
const { user } = buildBuiltinPrompt( const messages = buildBuiltinMessages(
minimalContext({ minimalContext({
steps: [ steps: [
{ {
@@ -57,11 +65,172 @@ describe("buildBuiltinPrompt", () => {
output: { plan: "step 1" }, output: { plan: "step 1" },
agent: "uwf-builtin", agent: "uwf-builtin",
detail: "detail-hash", detail: "detail-hash",
edgePrompt: "Create a plan.",
}, },
], ],
}), }),
); );
expect(user).toContain("## Previous Steps"); expect(messages).toHaveLength(2);
expect(user).toContain("planner"); 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');
}
}); });
}); });
+2 -6
View File
@@ -12,7 +12,7 @@ import { generateUlid } from "@uncaged/workflow-util";
import { storeBuiltinDetail } from "./detail.js"; import { storeBuiltinDetail } from "./detail.js";
import type { ChatMessage } from "./llm/index.js"; import type { ChatMessage } from "./llm/index.js";
import { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.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"; import type { BuiltinSessionState } from "./types.js";
const sessions = new Map<string, BuiltinSessionState>(); const sessions = new Map<string, BuiltinSessionState>();
@@ -69,11 +69,7 @@ async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
const provider = resolveModel(config, config.defaultModel); const provider = resolveModel(config, config.defaultModel);
const sessionId = generateUlid(Date.now()); const sessionId = generateUlid(Date.now());
const promptParts = buildBuiltinPrompt(ctx); const messages = buildBuiltinMessages(ctx);
const messages: ChatMessage[] = [
{ role: "system", content: promptParts.system },
{ role: "user", content: promptParts.user },
];
const session: BuiltinSessionState = { const session: BuiltinSessionState = {
sessionId, sessionId,
+1 -1
View File
@@ -3,7 +3,7 @@ export { extractFinalAssistantText, storeBuiltinDetail } from "./detail.js";
export type { ChatMessage, LlmAssistantResponse, LlmToolCall } from "./llm/index.js"; export type { ChatMessage, LlmAssistantResponse, LlmToolCall } from "./llm/index.js";
export { chatCompletionWithTools } from "./llm/index.js"; export { chatCompletionWithTools } from "./llm/index.js";
export { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.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 type { BuiltinTool, ToolContext } from "./tools/index.js";
export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js"; export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js";
export type { export type {
+73 -24
View File
@@ -1,31 +1,56 @@
import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-agent-kit"; import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-agent-kit";
function buildHistorySummary(steps: AgentContext["steps"]): string { import type { ChatMessage } from "./llm/index.js";
if (steps.length === 0) {
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 ""; return "";
} }
const lines: string[] = ["## Previous Steps"]; const lines: string[] = ["## What Happened Since Your Last Turn"];
for (let i = 0; i < steps.length; i++) { for (let i = fromIndex; i < toIndex; i++) {
const step = steps[i]; const step = steps[i];
if (step === undefined) { if (step === undefined) {
continue; continue;
} }
lines.push(""); lines.push("");
lines.push(`### Step ${i + 1}: ${step.role}`); lines.push(formatStep(step, i + 1));
lines.push(`Output: ${JSON.stringify(step.output)}`);
lines.push(`Agent: ${step.agent}`);
} }
return lines.join("\n"); return lines.join("\n");
} }
export type BuiltinPromptParts = { function buildUserTurnContent(edgePrompt: string, summary: string): string {
system: string; const parts: string[] = [];
user: 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 roleDef = ctx.workflow.roles[ctx.role];
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : ""; const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
const systemParts: string[] = []; const systemParts: string[] = [];
@@ -34,17 +59,41 @@ export function buildBuiltinPrompt(ctx: AgentContext): BuiltinPromptParts {
} }
systemParts.push(rolePrompt); systemParts.push(rolePrompt);
const userParts: string[] = ["## Task", ctx.start.prompt]; const messages: ChatMessage[] = [{ role: "system", content: systemParts.join("\n") }];
if (ctx.edgePrompt !== "") {
userParts.push("", "## Current Step Instruction", ctx.edgePrompt); const roleVisitIndices: number[] = [];
} for (let i = 0; i < ctx.steps.length; i++) {
const historyBlock = buildHistorySummary(ctx.steps); const step = ctx.steps[i];
if (historyBlock !== "") { if (step !== undefined && step.role === ctx.role) {
userParts.push("", historyBlock); roleVisitIndices.push(i);
}
} }
return { let prevVisitIndex = -1;
system: systemParts.join("\n"), for (const visitIndex of roleVisitIndices) {
user: userParts.join("\n"), 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;
} }
@@ -41,7 +41,15 @@ describe("buildClaudeCodePrompt", () => {
test("includes previous steps as history summary", () => { test("includes previous steps as history summary", () => {
const ctx = makeCtx({ 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); const result = buildClaudeCodePrompt(ctx);
expect(result).toContain("## Previous Steps"); expect(result).toContain("## Previous Steps");
@@ -49,8 +49,20 @@ describe("buildHermesPrompt", () => {
isFirstVisit: false, isFirstVisit: false,
edgePrompt: "The reviewer rejected your work. Fix the issues.", edgePrompt: "The reviewer rejected your work. Fix the issues.",
steps: [ 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( const result = buildHermesPrompt(
makeCtx({ makeCtx({
isFirstVisit: true, 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.", edgePrompt: "Retry with a fresh approach.",
}), }),
); );
@@ -7,6 +7,7 @@ const reviewerStep: StepContext = {
output: { approved: false, comments: "Missing tests" }, output: { approved: false, comments: "Missing tests" },
detail: "2MXBG6PN4A8JR", detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes", agent: "uwf-hermes",
edgePrompt: "Review the developer's work.",
}; };
const developerStep: StepContext = { const developerStep: StepContext = {
@@ -14,6 +15,7 @@ const developerStep: StepContext = {
output: { filesChanged: ["src/app.ts"], summary: "Initial fix" }, output: { filesChanged: ["src/app.ts"], summary: "Initial fix" },
detail: "1VPBG9SM5E7WK", detail: "1VPBG9SM5E7WK",
agent: "uwf-hermes", agent: "uwf-hermes",
edgePrompt: "Implement the fix.",
}; };
describe("buildContinuationPrompt", () => { describe("buildContinuationPrompt", () => {
@@ -26,6 +28,7 @@ describe("buildContinuationPrompt", () => {
output: { plan: "revise approach" }, output: { plan: "revise approach" },
detail: "7BQST3VW9F2MA", detail: "7BQST3VW9F2MA",
agent: "uwf-hermes", agent: "uwf-hermes",
edgePrompt: "Revise the plan.",
}, },
]; ];
@@ -102,6 +102,7 @@ async function buildHistory(
output: expandOutput(store, step.output), output: expandOutput(store, step.output),
detail: step.detail, detail: step.detail,
agent: step.agent, agent: step.agent,
edgePrompt: step.edgePrompt ?? "",
}); });
} }
return history; return history;
+3
View File
@@ -50,6 +50,7 @@ async function writeStepNode(options: {
outputHash: CasRef; outputHash: CasRef;
detailHash: CasRef; detailHash: CasRef;
agentName: string; agentName: string;
edgePrompt: string;
}): Promise<CasRef> { }): Promise<CasRef> {
const payload: StepNodePayload = { const payload: StepNodePayload = {
start: options.startHash, start: options.startHash,
@@ -58,6 +59,7 @@ async function writeStepNode(options: {
output: options.outputHash, output: options.outputHash,
detail: options.detailHash, detail: options.detailHash,
agent: options.agentName, agent: options.agentName,
edgePrompt: options.edgePrompt,
}; };
const hash = await options.store.put(options.schemas.stepNode, payload); const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash); const node = options.store.get(hash);
@@ -95,6 +97,7 @@ async function persistStep(options: {
outputHash: options.outputHash, outputHash: options.outputHash,
detailHash: options.detailHash, detailHash: options.detailHash,
agentName: options.agentName, agentName: options.agentName,
edgePrompt: options.ctx.edgePrompt,
}); });
} }
@@ -85,6 +85,7 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
output: { type: "string", format: "cas_ref" }, output: { type: "string", format: "cas_ref" },
detail: { type: "string", format: "cas_ref" }, detail: { type: "string", format: "cas_ref" },
agent: { type: "string" }, agent: { type: "string" },
edgePrompt: { type: "string" },
}, },
additionalProperties: false, additionalProperties: false,
}; };
+2
View File
@@ -12,6 +12,8 @@ export type StepRecord = {
output: CasRef; output: CasRef;
detail: CasRef; detail: CasRef;
agent: string; agent: string;
/** Moderator edge prompt that led to this step. Missing in legacy nodes → "". */
edgePrompt: string;
}; };
// ── 4.2 Workflow 定义 ─────────────────────────────────────────────── // ── 4.2 Workflow 定义 ───────────────────────────────────────────────