diff --git a/packages/cli-workflow/__tests__/thread-cli.test.ts b/packages/cli-workflow/__tests__/thread-cli.test.ts index 5e36107..84fd465 100644 --- a/packages/cli-workflow/__tests__/thread-cli.test.ts +++ b/packages/cli-workflow/__tests__/thread-cli.test.ts @@ -187,6 +187,14 @@ describe("cli thread commands", () => { } expect(shown.value.includes('"threadId"')).toBe(true); + const parsed = JSON.parse(shown.value) as Record; + expect(parsed.parentState).toBeNull(); + const parsedSteps = parsed.steps as Array>; + for (const step of parsedSteps) { + expect(step).toHaveProperty("childThread"); + expect(step.childThread).toBeNull(); + } + const removed = await cmdThreadRemove(storageRoot, threadId); expect(removed.ok).toBe(true); diff --git a/packages/cli-workflow/src/commands/thread/show.ts b/packages/cli-workflow/src/commands/thread/show.ts index fdc08a1..b03ae24 100644 --- a/packages/cli-workflow/src/commands/thread/show.ts +++ b/packages/cli-workflow/src/commands/thread/show.ts @@ -1,4 +1,4 @@ -import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas"; +import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas"; import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute"; import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { END } from "@uncaged/workflow-runtime"; @@ -6,6 +6,21 @@ import { getGlobalCasDir } from "@uncaged/workflow-util"; import { resolveThreadRecord } from "../../thread-scan.js"; +async function readParentStateFromStartNode( + cas: { get(hash: string): Promise }, + startHash: string, +): Promise { + const yamlText = await cas.get(startHash); + if (yamlText === null) { + return null; + } + const parsed = parseCasThreadNode(yamlText); + if (parsed === null || parsed.kind !== "start") { + return null; + } + return parsed.node.payload.parentState; +} + export async function cmdThreadShow( storageRoot: string, threadId: string, @@ -19,7 +34,15 @@ export async function cmdThreadShow( const frames = await walkStateFramesNewestFirst(cas, resolved.head); const chronological = [...frames].reverse(); - const steps: Array<{ role: string; hash: string; timestamp: number; content: string }> = []; + const parentState = await readParentStateFromStartNode(cas, resolved.start); + + const steps: Array<{ + role: string; + hash: string; + timestamp: number; + content: string; + childThread: string | null; + }> = []; for (const fr of chronological) { if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) { continue; @@ -33,6 +56,7 @@ export async function cmdThreadShow( payloadText !== null ? payloadText : `(content not in CAS; contentHash=${fr.payload.content})`, + childThread: fr.payload.childThread, }); } @@ -41,6 +65,7 @@ export async function cmdThreadShow( bundleHash: resolved.bundleHash, head: resolved.head, start: resolved.start, + parentState, source: resolved.source, steps, }; diff --git a/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts b/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts index 8303efb..0a804b8 100644 --- a/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts +++ b/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts @@ -10,6 +10,7 @@ function makeCtx(userContent: string): AgentContext { content: userContent, meta: {}, timestamp: 1, + parentState: null, }, depth: 0, bundleHash: "TESTHASH00001", diff --git a/packages/workflow-execute/src/engine/engine.ts b/packages/workflow-execute/src/engine/engine.ts index b395f25..7fd9d52 100644 --- a/packages/workflow-execute/src/engine/engine.ts +++ b/packages/workflow-execute/src/engine/engine.ts @@ -499,6 +499,7 @@ export async function executeThread( content: input.prompt, meta: {}, timestamp: nowMs, + parentState: options.parentStateHash, }, steps: input.steps.map((out, i) => ({ role: out.role, diff --git a/packages/workflow-protocol/__tests__/moderator-table.test.ts b/packages/workflow-protocol/__tests__/moderator-table.test.ts index 6d66a51..4b44542 100644 --- a/packages/workflow-protocol/__tests__/moderator-table.test.ts +++ b/packages/workflow-protocol/__tests__/moderator-table.test.ts @@ -27,6 +27,7 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext content: "test", meta: {}, timestamp: Date.now(), + parentState: null, } as StartStep, steps, }; diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index ed8988c..b7d002e 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -62,6 +62,7 @@ export type StartStep = { content: string; meta: Record; timestamp: number; + parentState: string | null; }; export type RoleStep = { diff --git a/packages/workflow-runtime/src/build-context.ts b/packages/workflow-runtime/src/build-context.ts index 874c4a5..38c46fb 100644 --- a/packages/workflow-runtime/src/build-context.ts +++ b/packages/workflow-runtime/src/build-context.ts @@ -60,6 +60,7 @@ async function threadFromStartHead( content: prompt, meta: {}, timestamp: 0, + parentState: p.parentState, }, steps: [], }; @@ -120,6 +121,7 @@ async function threadFromStateHead( content: prompt, meta: {}, timestamp: firstTs, + parentState: sp.parentState, }, steps, }; diff --git a/packages/workflow-template-develop/__tests__/develop-template.test.ts b/packages/workflow-template-develop/__tests__/develop-template.test.ts index a626d33..0013ea2 100644 --- a/packages/workflow-template-develop/__tests__/develop-template.test.ts +++ b/packages/workflow-template-develop/__tests__/develop-template.test.ts @@ -22,6 +22,7 @@ function makeStart(): ModeratorContext["start"] { content: "Implement the feature", meta: {}, timestamp: 0, + parentState: null, }; } diff --git a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts index c7137f2..6d2d1a1 100644 --- a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts +++ b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts @@ -107,6 +107,7 @@ function makeStart(): ModeratorContext["start"] { content: "Fix the flaky login test", meta: {}, timestamp: 0, + parentState: null, }; } @@ -188,6 +189,7 @@ function makeThread(prompt: string) { content: prompt, meta: {}, timestamp: Date.now(), + parentState: null, }, steps: [], }; diff --git a/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts b/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts index 3601015..5b4e641 100644 --- a/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts +++ b/packages/workflow-util-agent/__tests__/build-agent-prompt.test.ts @@ -3,12 +3,13 @@ import { type AgentContext, START } from "@uncaged/workflow-runtime"; import { buildAgentPrompt } from "../src/index.js"; -function startTask(content: string): AgentContext["start"] { +function startTask(content: string, parentState: string | null = null): AgentContext["start"] { return { role: START, content, meta: {}, timestamp: 1, + parentState, }; } @@ -95,6 +96,35 @@ describe("buildAgentPrompt", () => { expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR"); }); + test("parentState null omits Parent Context section", async () => { + const ctx: AgentContext = { + start: startTask("top-level task"), + depth: 0, + bundleHash: "TESTHASH00001", + steps: [], + threadId: "01TEST000000000000000000TR", + currentRole: { name: START, systemPrompt: "You are an agent." }, + }; + const text = await buildAgentPrompt(ctx); + expect(text).not.toContain("## Parent Context"); + }); + + test("parentState non-null includes Parent Context section with hash", async () => { + const parentHash = "01PARENTSTATE0000000000001"; + const ctx: AgentContext = { + start: startTask("child task", parentHash), + depth: 1, + bundleHash: "TESTHASH00001", + steps: [], + threadId: "01TEST000000000000000000TR", + currentRole: { name: START, systemPrompt: "You are an agent." }, + }; + const text = await buildAgentPrompt(ctx); + expect(text).toContain("## Parent Context"); + expect(text).toContain(parentHash); + expect(text).toContain(`uncaged-workflow cas get ${parentHash}`); + }); + test("middle steps show meta summary only and latest shows hash", async () => { const ha = "01HASHA00000000000000000001"; const hb = "01HASHB00000000000000000001"; diff --git a/packages/workflow-util-agent/src/build-agent-prompt.ts b/packages/workflow-util-agent/src/build-agent-prompt.ts index 2345ee1..2851f26 100644 --- a/packages/workflow-util-agent/src/build-agent-prompt.ts +++ b/packages/workflow-util-agent/src/build-agent-prompt.ts @@ -5,6 +5,19 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise { const lines: string[] = []; lines.push(ctx.currentRole.systemPrompt); lines.push(""); + + if (ctx.start.parentState !== null) { + lines.push("## Parent Context"); + lines.push( + "This workflow was spawned by a parent workflow. The parent's state at spawn time is available at hash: " + + ctx.start.parentState, + ); + lines.push( + `Use \`uncaged-workflow cas get ${ctx.start.parentState}\` to inspect the parent's context and trace back through its steps.`, + ); + lines.push(""); + } + lines.push("## Task"); lines.push(ctx.start.content);