feat: Phase 3 — agent observability for Merkle call stack
- StartStep gains parentState: string | null (from StartNodePayload) - buildAgentPrompt injects Parent Context section when parentState is set - CLI thread show outputs parentState (top-level) and childThread (per step) - 2 new prompt tests + thread show assertion updates Refs #197, #194 小橘 🍊(NEKO Team)
This commit is contained in:
@@ -187,6 +187,14 @@ describe("cli thread commands", () => {
|
|||||||
}
|
}
|
||||||
expect(shown.value.includes('"threadId"')).toBe(true);
|
expect(shown.value.includes('"threadId"')).toBe(true);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(shown.value) as Record<string, unknown>;
|
||||||
|
expect(parsed.parentState).toBeNull();
|
||||||
|
const parsedSteps = parsed.steps as Array<Record<string, unknown>>;
|
||||||
|
for (const step of parsedSteps) {
|
||||||
|
expect(step).toHaveProperty("childThread");
|
||||||
|
expect(step.childThread).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||||
expect(removed.ok).toBe(true);
|
expect(removed.ok).toBe(true);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
import { END } from "@uncaged/workflow-runtime";
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
@@ -6,6 +6,21 @@ import { getGlobalCasDir } from "@uncaged/workflow-util";
|
|||||||
|
|
||||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||||
|
|
||||||
|
async function readParentStateFromStartNode(
|
||||||
|
cas: { get(hash: string): Promise<string | null> },
|
||||||
|
startHash: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
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(
|
export async function cmdThreadShow(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
@@ -19,7 +34,15 @@ export async function cmdThreadShow(
|
|||||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||||
const chronological = [...frames].reverse();
|
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) {
|
for (const fr of chronological) {
|
||||||
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
|
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
|
||||||
continue;
|
continue;
|
||||||
@@ -33,6 +56,7 @@ export async function cmdThreadShow(
|
|||||||
payloadText !== null
|
payloadText !== null
|
||||||
? payloadText
|
? payloadText
|
||||||
: `(content not in CAS; contentHash=${fr.payload.content})`,
|
: `(content not in CAS; contentHash=${fr.payload.content})`,
|
||||||
|
childThread: fr.payload.childThread,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +65,7 @@ export async function cmdThreadShow(
|
|||||||
bundleHash: resolved.bundleHash,
|
bundleHash: resolved.bundleHash,
|
||||||
head: resolved.head,
|
head: resolved.head,
|
||||||
start: resolved.start,
|
start: resolved.start,
|
||||||
|
parentState,
|
||||||
source: resolved.source,
|
source: resolved.source,
|
||||||
steps,
|
steps,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ function makeCtx(userContent: string): AgentContext {
|
|||||||
content: userContent,
|
content: userContent,
|
||||||
meta: {},
|
meta: {},
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
parentState: null,
|
||||||
},
|
},
|
||||||
depth: 0,
|
depth: 0,
|
||||||
bundleHash: "TESTHASH00001",
|
bundleHash: "TESTHASH00001",
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ export async function executeThread(
|
|||||||
content: input.prompt,
|
content: input.prompt,
|
||||||
meta: {},
|
meta: {},
|
||||||
timestamp: nowMs,
|
timestamp: nowMs,
|
||||||
|
parentState: options.parentStateHash,
|
||||||
},
|
},
|
||||||
steps: input.steps.map((out, i) => ({
|
steps: input.steps.map((out, i) => ({
|
||||||
role: out.role,
|
role: out.role,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta>
|
|||||||
content: "test",
|
content: "test",
|
||||||
meta: {},
|
meta: {},
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
parentState: null,
|
||||||
} as StartStep,
|
} as StartStep,
|
||||||
steps,
|
steps,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export type StartStep = {
|
|||||||
content: string;
|
content: string;
|
||||||
meta: Record<string, never>;
|
meta: Record<string, never>;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
parentState: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoleStep<M extends RoleMeta> = {
|
export type RoleStep<M extends RoleMeta> = {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ async function threadFromStartHead<M extends RoleMeta>(
|
|||||||
content: prompt,
|
content: prompt,
|
||||||
meta: {},
|
meta: {},
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
parentState: p.parentState,
|
||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
};
|
};
|
||||||
@@ -120,6 +121,7 @@ async function threadFromStateHead<M extends RoleMeta>(
|
|||||||
content: prompt,
|
content: prompt,
|
||||||
meta: {},
|
meta: {},
|
||||||
timestamp: firstTs,
|
timestamp: firstTs,
|
||||||
|
parentState: sp.parentState,
|
||||||
},
|
},
|
||||||
steps,
|
steps,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function makeStart(): ModeratorContext<DevelopMeta>["start"] {
|
|||||||
content: "Implement the feature",
|
content: "Implement the feature",
|
||||||
meta: {},
|
meta: {},
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
parentState: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ function makeStart(): ModeratorContext<SolveIssueMeta>["start"] {
|
|||||||
content: "Fix the flaky login test",
|
content: "Fix the flaky login test",
|
||||||
meta: {},
|
meta: {},
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
parentState: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +189,7 @@ function makeThread(prompt: string) {
|
|||||||
content: prompt,
|
content: prompt,
|
||||||
meta: {},
|
meta: {},
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
parentState: null,
|
||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
|||||||
|
|
||||||
import { buildAgentPrompt } from "../src/index.js";
|
import { buildAgentPrompt } from "../src/index.js";
|
||||||
|
|
||||||
function startTask(content: string): AgentContext["start"] {
|
function startTask(content: string, parentState: string | null = null): AgentContext["start"] {
|
||||||
return {
|
return {
|
||||||
role: START,
|
role: START,
|
||||||
content,
|
content,
|
||||||
meta: {},
|
meta: {},
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
parentState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +96,35 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
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 () => {
|
test("middle steps show meta summary only and latest shows hash", async () => {
|
||||||
const ha = "01HASHA00000000000000000001";
|
const ha = "01HASHA00000000000000000001";
|
||||||
const hb = "01HASHB00000000000000000001";
|
const hb = "01HASHB00000000000000000001";
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
|||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(ctx.currentRole.systemPrompt);
|
lines.push(ctx.currentRole.systemPrompt);
|
||||||
lines.push("");
|
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("## Task");
|
||||||
lines.push(ctx.start.content);
|
lines.push(ctx.start.content);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user