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 is contained in:
@@ -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>");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user