fix: include step content in continuation prompt (closes #466)

- Add `content: string | null` to RoleStep type
- Resolve contentHash → text for the last step when building ThreadContext
- Update buildAgentPrompt to include <output> tag with step content
- Add 16k content quota with truncation
- Update tests
This commit was merged in pull request #472.
This commit is contained in:
2026-05-24 13:30:03 +00:00
parent 8fbbbce07e
commit eb027e70f4
6 changed files with 158 additions and 15 deletions
@@ -531,13 +531,25 @@ export async function executeThread(
timestamp: nowMs, timestamp: nowMs,
parentState: options.parentStateHash, parentState: options.parentStateHash,
}, },
steps: input.steps.map((out, i) => ({ steps: await Promise.all(
role: out.role, input.steps.map(async (out, i) => {
contentHash: out.contentHash, // Resolve content for the last step (most relevant for the next agent).
meta: out.meta, // Earlier steps only carry meta summaries to avoid bloating the prompt.
refs: out.refs, const isLast = i === input.steps.length - 1;
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i, 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 = { const runtime: WorkflowRuntime = {
@@ -71,6 +71,7 @@ export type RoleStep<M extends RoleMeta> = {
role: K; role: K;
meta: M[K]; meta: M[K];
contentHash: string; contentHash: string;
content: string | null;
refs: string[]; refs: string[];
timestamp: number; timestamp: number;
}; };
@@ -71,7 +71,8 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
cas: CasStore, cas: CasStore,
): Promise<RoleStep<M>[]> { ): Promise<RoleStep<M>[]> {
const steps: RoleStep<M>[] = []; const steps: RoleStep<M>[] = [];
for (const st of chronologicalStates) { for (let idx = 0; idx < chronologicalStates.length; idx++) {
const st = chronologicalStates[idx];
if (st.payload.role === END) { if (st.payload.role === END) {
continue; continue;
} }
@@ -79,10 +80,13 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
if (contentParsed === null || contentParsed.kind !== "content") { if (contentParsed === null || contentParsed.kind !== "content") {
throw new Error(`buildThreadContext: expected content node at ${st.payload.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({ steps.push({
role: st.payload.role, role: st.payload.role,
meta: st.payload.meta, meta: st.payload.meta,
contentHash: st.payload.content, contentHash: st.payload.content,
content: isLast ? contentParsed.node.payload : null,
refs: [...contentParsed.node.refs], refs: [...contentParsed.node.refs],
timestamp: st.payload.timestamp, timestamp: st.payload.timestamp,
} as RoleStep<M>); } as RoleStep<M>);
@@ -88,6 +88,7 @@ async function advanceOneRound<M extends RoleMeta>(
const step = { const step = {
role: next, role: next,
contentHash, contentHash,
content: contentPayload,
meta, meta,
refs, refs,
timestamp: Date.now(), timestamp: Date.now(),
@@ -30,7 +30,7 @@ describe("buildAgentPrompt", () => {
expect(text).not.toContain("## Tools"); 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 onlyHash = "01HASHSINGLESTEP0000000001";
const ctx: AgentContext = { const ctx: AgentContext = {
start: startTask("user task"), start: startTask("user task"),
@@ -42,6 +42,7 @@ describe("buildAgentPrompt", () => {
{ {
role: "coder", role: "coder",
contentHash: onlyHash, contentHash: onlyHash,
content: "Here is my implementation of the feature.",
meta: { files: ["a.ts"] }, meta: { files: ["a.ts"] },
refs: [onlyHash], refs: [onlyHash],
timestamp: 2, timestamp: 2,
@@ -52,13 +53,39 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("## Task"); expect(text).toContain("## Task");
expect(text).toContain("user task"); expect(text).toContain("user task");
expect(text).toContain("## Step: coder"); expect(text).toContain("## Step: coder");
expect(text).toContain(`ContentHash: ${onlyHash}`);
expect(text).toContain('Meta: {"files":["a.ts"]}'); expect(text).toContain('Meta: {"files":["a.ts"]}');
expect(text).toContain("<output>");
expect(text).toContain("Here is my implementation of the feature.");
expect(text).toContain("</output>");
expect(text).toContain("## Tools"); expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR"); 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("<output>");
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 plannerHash = "01HASHPLANNER0000000000001";
const coderHash = "01HASHCODER0000000000000001"; const coderHash = "01HASHCODER0000000000000001";
const ctx: AgentContext = { const ctx: AgentContext = {
@@ -71,6 +98,7 @@ describe("buildAgentPrompt", () => {
{ {
role: "planner", role: "planner",
contentHash: plannerHash, contentHash: plannerHash,
content: null,
meta: { plan: "short" }, meta: { plan: "short" },
refs: [plannerHash], refs: [plannerHash],
timestamp: 2, timestamp: 2,
@@ -78,6 +106,7 @@ describe("buildAgentPrompt", () => {
{ {
role: "coder", role: "coder",
contentHash: coderHash, 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 }, meta: { done: true },
refs: [coderHash], refs: [coderHash],
timestamp: 3, timestamp: 3,
@@ -90,10 +119,11 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("### Step 1: planner"); expect(text).toContain("### Step 1: planner");
expect(text).toContain('Summary: {"plan":"short"}'); expect(text).toContain('Summary: {"plan":"short"}');
expect(text).toContain("## Latest Step: coder"); expect(text).toContain("## Latest Step: coder");
expect(text).toContain(`ContentHash: ${coderHash}`);
expect(text).toContain('Meta: {"done":true}'); expect(text).toContain('Meta: {"done":true}');
expect(text).toContain("<output>");
expect(text).toContain("I reviewed the code and found 4 lint issues:");
expect(text).toContain("</output>");
expect(text).toContain("## Tools"); expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
}); });
test("parentState null omits Parent Context section", async () => { test("parentState null omits Parent Context section", async () => {
@@ -125,7 +155,7 @@ describe("buildAgentPrompt", () => {
expect(text).toContain(`uncaged-workflow cas get ${parentHash}`); 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 ha = "01HASHA00000000000000000001";
const hb = "01HASHB00000000000000000001"; const hb = "01HASHB00000000000000000001";
const hc = "01HASHC00000000000000000001"; const hc = "01HASHC00000000000000000001";
@@ -139,6 +169,7 @@ describe("buildAgentPrompt", () => {
{ {
role: "a", role: "a",
contentHash: ha, contentHash: ha,
content: null,
meta: { n: 1 }, meta: { n: 1 },
refs: [ha], refs: [ha],
timestamp: 2, timestamp: 2,
@@ -146,6 +177,7 @@ describe("buildAgentPrompt", () => {
{ {
role: "b", role: "b",
contentHash: hb, contentHash: hb,
content: null,
meta: { n: 2 }, meta: { n: 2 },
refs: [hb], refs: [hb],
timestamp: 3, timestamp: 3,
@@ -153,6 +185,7 @@ describe("buildAgentPrompt", () => {
{ {
role: "c", role: "c",
contentHash: hc, contentHash: hc,
content: "Final output from role c",
meta: { n: 3 }, meta: { n: 3 },
refs: [hc], refs: [hc],
timestamp: 4, timestamp: 4,
@@ -162,7 +195,35 @@ describe("buildAgentPrompt", () => {
const text = await buildAgentPrompt(ctx); const text = await buildAgentPrompt(ctx);
expect(text).toContain('Summary: {"n":1}'); expect(text).toContain('Summary: {"n":1}');
expect(text).toContain('Summary: {"n":2}'); expect(text).toContain('Summary: {"n":2}');
expect(text).toContain(`ContentHash: ${hc}`);
expect(text).toContain("## Latest Step: c"); expect(text).toContain("## Latest Step: c");
expect(text).toContain("<output>");
expect(text).toContain("Final output from role c");
expect(text).toContain("</output>");
});
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("<output>");
expect(text).toContain("... (truncated)");
expect(text.length).toBeLessThan(20_000);
}); });
}); });
@@ -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<string> {
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("<output>");
lines.push(truncated);
lines.push("</output>");
}