From eb027e70f42c834fc724beb14febb3b904dc7a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Sun, 24 May 2026 13:30:03 +0000 Subject: [PATCH] fix: include step content in continuation prompt (closes #466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `content: string | null` to RoleStep type - Resolve contentHash → text for the last step when building ThreadContext - Update buildAgentPrompt to include tag with step content - Add 16k content quota with truncation - Update tests --- .../workflow-execute/src/engine/engine.ts | 26 +++++-- .../workflow-protocol/src/types.ts | 1 + .../workflow-runtime/src/build-context.ts | 6 +- .../workflow-runtime/src/create-workflow.ts | 1 + .../__tests__/build-agent-prompt.test.ts | 75 +++++++++++++++++-- .../src/build-agent-prompt.ts | 64 ++++++++++++++++ 6 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 packages/workflow-util-agent/src/build-agent-prompt.ts diff --git a/legacy-packages/workflow-execute/src/engine/engine.ts b/legacy-packages/workflow-execute/src/engine/engine.ts index eea1036..fbd9b49 100644 --- a/legacy-packages/workflow-execute/src/engine/engine.ts +++ b/legacy-packages/workflow-execute/src/engine/engine.ts @@ -531,13 +531,25 @@ export async function executeThread( timestamp: nowMs, parentState: options.parentStateHash, }, - steps: input.steps.map((out, i) => ({ - role: out.role, - contentHash: out.contentHash, - meta: out.meta, - refs: out.refs, - timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i, - })), + steps: await Promise.all( + input.steps.map(async (out, i) => { + // Resolve content for the last step (most relevant for the next agent). + // Earlier steps only carry meta summaries to avoid bloating the prompt. + const isLast = i === input.steps.length - 1; + let content: string | null = null; + if (isLast) { + content = await getContentMerklePayload(io.cas, out.contentHash); + } + return { + role: out.role, + contentHash: out.contentHash, + content, + meta: out.meta, + refs: out.refs, + timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i, + }; + }), + ), }; const runtime: WorkflowRuntime = { diff --git a/legacy-packages/workflow-protocol/src/types.ts b/legacy-packages/workflow-protocol/src/types.ts index 5214b46..a54c4b0 100644 --- a/legacy-packages/workflow-protocol/src/types.ts +++ b/legacy-packages/workflow-protocol/src/types.ts @@ -71,6 +71,7 @@ export type RoleStep = { role: K; meta: M[K]; contentHash: string; + content: string | null; refs: string[]; timestamp: number; }; diff --git a/legacy-packages/workflow-runtime/src/build-context.ts b/legacy-packages/workflow-runtime/src/build-context.ts index 38c46fb..b5105bd 100644 --- a/legacy-packages/workflow-runtime/src/build-context.ts +++ b/legacy-packages/workflow-runtime/src/build-context.ts @@ -71,7 +71,8 @@ async function buildRoleStepsFromStates( cas: CasStore, ): Promise[]> { const steps: RoleStep[] = []; - for (const st of chronologicalStates) { + for (let idx = 0; idx < chronologicalStates.length; idx++) { + const st = chronologicalStates[idx]; if (st.payload.role === END) { continue; } @@ -79,10 +80,13 @@ async function buildRoleStepsFromStates( if (contentParsed === null || contentParsed.kind !== "content") { throw new Error(`buildThreadContext: expected content node at ${st.payload.content}`); } + // Resolve full text content for the last step only + const isLast = idx === chronologicalStates.length - 1; steps.push({ role: st.payload.role, meta: st.payload.meta, contentHash: st.payload.content, + content: isLast ? contentParsed.node.payload : null, refs: [...contentParsed.node.refs], timestamp: st.payload.timestamp, } as RoleStep); diff --git a/legacy-packages/workflow-runtime/src/create-workflow.ts b/legacy-packages/workflow-runtime/src/create-workflow.ts index 0a688ea..89ab7a8 100644 --- a/legacy-packages/workflow-runtime/src/create-workflow.ts +++ b/legacy-packages/workflow-runtime/src/create-workflow.ts @@ -88,6 +88,7 @@ async function advanceOneRound( const step = { role: next, contentHash, + content: contentPayload, meta, refs, timestamp: Date.now(), diff --git a/legacy-packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts b/legacy-packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts index 5b4e641..8f48446 100644 --- a/legacy-packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts +++ b/legacy-packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts @@ -30,7 +30,7 @@ describe("buildAgentPrompt", () => { expect(text).not.toContain("## Tools"); }); - test("single step shows hash and meta, and includes tools", async () => { + test("single step shows meta and content, and includes tools", async () => { const onlyHash = "01HASHSINGLESTEP0000000001"; const ctx: AgentContext = { start: startTask("user task"), @@ -42,6 +42,7 @@ describe("buildAgentPrompt", () => { { role: "coder", contentHash: onlyHash, + content: "Here is my implementation of the feature.", meta: { files: ["a.ts"] }, refs: [onlyHash], timestamp: 2, @@ -52,13 +53,39 @@ describe("buildAgentPrompt", () => { expect(text).toContain("## Task"); expect(text).toContain("user task"); expect(text).toContain("## Step: coder"); - expect(text).toContain(`ContentHash: ${onlyHash}`); expect(text).toContain('Meta: {"files":["a.ts"]}'); + expect(text).toContain(""); + expect(text).toContain("Here is my implementation of the feature."); + expect(text).toContain(""); expect(text).toContain("## Tools"); expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR"); }); - test("two or more steps: previous steps are meta-only; latest step includes hash", async () => { + test("single step with null content omits output tag", async () => { + const onlyHash = "01HASHSINGLESTEP0000000001"; + const ctx: AgentContext = { + start: startTask("user task"), + depth: 0, + bundleHash: "TESTHASH00001", + threadId: "01TEST000000000000000000TR", + currentRole: { name: "coder", systemPrompt: "Be helpful." }, + steps: [ + { + role: "coder", + contentHash: onlyHash, + content: null, + meta: { files: ["a.ts"] }, + refs: [onlyHash], + timestamp: 2, + }, + ], + }; + const text = await buildAgentPrompt(ctx); + expect(text).not.toContain(""); + expect(text).toContain('Meta: {"files":["a.ts"]}'); + }); + + test("two or more steps: previous steps are meta-only; latest step includes content", async () => { const plannerHash = "01HASHPLANNER0000000000001"; const coderHash = "01HASHCODER0000000000000001"; const ctx: AgentContext = { @@ -71,6 +98,7 @@ describe("buildAgentPrompt", () => { { role: "planner", contentHash: plannerHash, + content: null, meta: { plan: "short" }, refs: [plannerHash], timestamp: 2, @@ -78,6 +106,7 @@ describe("buildAgentPrompt", () => { { role: "coder", contentHash: coderHash, + content: "I reviewed the code and found 4 lint issues:\n1. Missing semicolon on line 42\n2. Unused import on line 3", meta: { done: true }, refs: [coderHash], timestamp: 3, @@ -90,10 +119,11 @@ describe("buildAgentPrompt", () => { expect(text).toContain("### Step 1: planner"); expect(text).toContain('Summary: {"plan":"short"}'); expect(text).toContain("## Latest Step: coder"); - expect(text).toContain(`ContentHash: ${coderHash}`); expect(text).toContain('Meta: {"done":true}'); + expect(text).toContain(""); + expect(text).toContain("I reviewed the code and found 4 lint issues:"); + expect(text).toContain(""); expect(text).toContain("## Tools"); - expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR"); }); test("parentState null omits Parent Context section", async () => { @@ -125,7 +155,7 @@ describe("buildAgentPrompt", () => { expect(text).toContain(`uncaged-workflow cas get ${parentHash}`); }); - test("middle steps show meta summary only and latest shows hash", async () => { + test("middle steps show meta summary only and latest shows content", async () => { const ha = "01HASHA00000000000000000001"; const hb = "01HASHB00000000000000000001"; const hc = "01HASHC00000000000000000001"; @@ -139,6 +169,7 @@ describe("buildAgentPrompt", () => { { role: "a", contentHash: ha, + content: null, meta: { n: 1 }, refs: [ha], timestamp: 2, @@ -146,6 +177,7 @@ describe("buildAgentPrompt", () => { { role: "b", contentHash: hb, + content: null, meta: { n: 2 }, refs: [hb], timestamp: 3, @@ -153,6 +185,7 @@ describe("buildAgentPrompt", () => { { role: "c", contentHash: hc, + content: "Final output from role c", meta: { n: 3 }, refs: [hc], timestamp: 4, @@ -162,7 +195,35 @@ describe("buildAgentPrompt", () => { const text = await buildAgentPrompt(ctx); expect(text).toContain('Summary: {"n":1}'); expect(text).toContain('Summary: {"n":2}'); - expect(text).toContain(`ContentHash: ${hc}`); expect(text).toContain("## Latest Step: c"); + expect(text).toContain(""); + expect(text).toContain("Final output from role c"); + expect(text).toContain(""); + }); + + test("content is truncated when exceeding quota", async () => { + const longContent = "x".repeat(20_000); + const hash = "01HASHLONG000000000000000001"; + const ctx: AgentContext = { + start: startTask("task"), + depth: 0, + bundleHash: "TESTHASH00001", + threadId: "01TEST000000000000000000TR", + currentRole: { name: "r", systemPrompt: "S" }, + steps: [ + { + role: "r", + contentHash: hash, + content: longContent, + meta: {}, + refs: [], + timestamp: 2, + }, + ], + }; + const text = await buildAgentPrompt(ctx); + expect(text).toContain(""); + expect(text).toContain("... (truncated)"); + expect(text.length).toBeLessThan(20_000); }); }); diff --git a/packages/workflow-util-agent/src/build-agent-prompt.ts b/packages/workflow-util-agent/src/build-agent-prompt.ts new file mode 100644 index 0000000..a132f09 --- /dev/null +++ b/packages/workflow-util-agent/src/build-agent-prompt.ts @@ -0,0 +1,64 @@ +import type { AgentContext } from "@uncaged/workflow-runtime"; + +/** Max characters of step content to include in the prompt. */ +const CONTENT_QUOTA = 16_000; + +/** Builds the full agent prompt: system instructions plus summarized thread history. */ +export async function buildAgentPrompt(ctx: AgentContext): Promise { + const lines: string[] = []; + lines.push(ctx.currentRole.systemPrompt); + lines.push(""); + lines.push("## Task"); + lines.push(ctx.start.content); + + const { steps } = ctx; + if (steps.length === 0) { + return lines.join("\n"); + } + + if (steps.length === 1) { + const s = steps[0]; + lines.push(""); + lines.push(`## Step: ${s.role}`); + lines.push(""); + lines.push(`Meta: ${JSON.stringify(s.meta)}`); + appendContent(lines, s.content); + } else { + lines.push(""); + lines.push("## Previous Steps"); + for (let i = 0; i < steps.length - 1; i++) { + const s = steps[i]; + lines.push(""); + lines.push(`### Step ${i + 1}: ${s.role}`); + lines.push(`Summary: ${JSON.stringify(s.meta)}`); + } + const last = steps[steps.length - 1]; + lines.push(""); + lines.push(`## Latest Step: ${last.role}`); + lines.push(""); + lines.push(`Meta: ${JSON.stringify(last.meta)}`); + appendContent(lines, last.content); + } + + lines.push(""); + lines.push("## Tools"); + lines.push( + `Use \`uncaged-workflow thread ${ctx.threadId}\` to read full details of any previous step.`, + ); + + return lines.join("\n"); +} + +function appendContent(lines: string[], content: string | null | undefined): void { + if (content === null || content === undefined || content.trim() === "") { + return; + } + const truncated = + content.length > CONTENT_QUOTA + ? `${content.slice(0, CONTENT_QUOTA)}\n... (truncated)` + : content; + lines.push(""); + lines.push(""); + lines.push(truncated); + lines.push(""); +}