Compare commits

...

3 Commits

Author SHA1 Message Date
xiaoju 74cea09ac0 fix: bundle validator accepts Identifier init and wildcard @uncaged/workflow-* imports
- bindingInitializerIsCallable: accept Identifier (e.g. var run = wf)
- import allowlist: startsWith('@uncaged/workflow') instead of exact match list

小橘 🍊(NEKO Team)
2026-05-12 02:33:28 +00:00
xiaoju 98122b446d 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)
2026-05-12 02:23:15 +00:00
xiaoju 4a31cf9d63 fix: workflowAsAgent error paths return AgentFnResult instead of plain string
Nit from PR #202 review — all error returns now use { output, childThread: null }
for type consistency with the success path.

小橘 🍊(NEKO Team)
2026-05-12 02:15:06 +00:00
13 changed files with 96 additions and 16 deletions
@@ -187,6 +187,14 @@ describe("cli thread commands", () => {
}
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);
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 { 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<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(
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,
};
@@ -10,6 +10,7 @@ function makeCtx(userContent: string): AgentContext {
content: userContent,
meta: {},
timestamp: 1,
parentState: null,
},
depth: 0,
bundleHash: "TESTHASH00001",
@@ -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,
@@ -59,23 +59,23 @@ export function workflowAsAgent(
const registryResult = await readWorkflowRegistry(storageRoot);
if (!registryResult.ok) {
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
return { output: `ERROR: failed to read workflow registry: ${registryResult.error.message}`, childThread: null };
}
const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
if (nextDepth > maxDepth) {
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
return { output: `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`, childThread: null };
}
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
if (entry === null) {
return `ERROR: workflow "${workflowName}" not found in registry`;
return { output: `ERROR: workflow "${workflowName}" not found in registry`, childThread: null };
}
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
if (!bundleExportsResult.ok) {
return `ERROR: ${bundleExportsResult.error}`;
return { output: `ERROR: ${bundleExportsResult.error}`, childThread: null };
}
const input = {
@@ -121,7 +121,7 @@ export function workflowAsAgent(
return { output: summary, childThread: result.rootHash };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return `ERROR: ${message}`;
return { output: `ERROR: ${message}`, childThread: null };
}
};
}
@@ -27,6 +27,7 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta>
content: "test",
meta: {},
timestamp: Date.now(),
parentState: null,
} as StartStep,
steps,
};
+1
View File
@@ -62,6 +62,7 @@ export type StartStep = {
content: string;
meta: Record<string, never>;
timestamp: number;
parentState: string | null;
};
export type RoleStep<M extends RoleMeta> = {
@@ -37,13 +37,7 @@ function isAllowedImportSpecifier(spec: string): boolean {
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
return false;
}
if (
spec === "@uncaged/workflow" ||
spec === "@uncaged/workflow-runtime" ||
spec === "@uncaged/workflow-protocol" ||
spec === "@uncaged/workflow-cas" ||
spec === "@uncaged/workflow-util"
) {
if (spec.startsWith("@uncaged/workflow")) {
return true;
}
return isBuiltin(spec);
@@ -114,7 +108,8 @@ function bindingInitializerIsCallable(init: Node): boolean {
return (
init.type === "FunctionExpression" ||
init.type === "ArrowFunctionExpression" ||
init.type === "CallExpression"
init.type === "CallExpression" ||
init.type === "Identifier"
);
}
@@ -60,6 +60,7 @@ async function threadFromStartHead<M extends RoleMeta>(
content: prompt,
meta: {},
timestamp: 0,
parentState: p.parentState,
},
steps: [],
};
@@ -120,6 +121,7 @@ async function threadFromStateHead<M extends RoleMeta>(
content: prompt,
meta: {},
timestamp: firstTs,
parentState: sp.parentState,
},
steps,
};
@@ -22,6 +22,7 @@ function makeStart(): ModeratorContext<DevelopMeta>["start"] {
content: "Implement the feature",
meta: {},
timestamp: 0,
parentState: null,
};
}
@@ -107,6 +107,7 @@ function makeStart(): ModeratorContext<SolveIssueMeta>["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: [],
};
@@ -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";
@@ -5,6 +5,19 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
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);