Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a685583bd | |||
| 7f64541c5b | |||
| 43a6600378 | |||
| 220c9c5224 | |||
| cae59b589e | |||
| b5cc0db17e | |||
| 6196e0974a | |||
| 410e9e6d9b | |||
| 84de74721d | |||
| 4403532f35 |
@@ -29,6 +29,7 @@ const greeter: RoleDefinition<Roles["greeter"]> = {
|
||||
extractPrompt: "Extract the greeting string produced for the user.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
const extract = createExtract({
|
||||
@@ -48,4 +49,5 @@ export const run = createWorkflow<Roles>(
|
||||
agent: async (ctx) => `Hello, ${ctx.start.content}`,
|
||||
},
|
||||
extract,
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -16,6 +16,9 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
`;
|
||||
|
||||
describe("cli workflow commands", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -41,11 +44,13 @@ describe("cli workflow commands", () => {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}import fs from "node:fs";
|
||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input) {
|
||||
export const run = async function* (input, options) {
|
||||
fs.existsSync(".");
|
||||
yield { role: "noop", content: input.prompt, meta: { done: true } };
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
`,
|
||||
@@ -112,8 +117,8 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
const bundlePath = join(storageRoot, "solo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`export const run = async function* (input) {
|
||||
yield { role: "x", content: input.prompt, meta: {} };
|
||||
`export const run = async function* () {
|
||||
yield { role: "x", contentHash: "STUBHASH00000000000000001", meta: {}, refs: [] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
}
|
||||
`,
|
||||
@@ -141,8 +146,11 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
},
|
||||
},
|
||||
};
|
||||
export const run = async function* (input) {
|
||||
yield { role: "greeter", content: input.prompt, meta: { greeting: "hi" } };
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
};
|
||||
`,
|
||||
@@ -180,8 +188,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -209,8 +219,10 @@ export const run = async function* (input) {
|
||||
const dtsPath = join(bundleDir, "types.d.ts");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -240,8 +252,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -261,13 +275,17 @@ export const run = async function* (input) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v1", meta: {} };
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v2", meta: {} };
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
`;
|
||||
@@ -299,13 +317,17 @@ export const run = async function* (input) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v1", meta: {} };
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v2", meta: {} };
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
`;
|
||||
@@ -347,8 +369,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -358,8 +382,10 @@ export const run = async function* (input) {
|
||||
expect(add1.ok).toBe(true);
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "y", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
`,
|
||||
@@ -409,8 +435,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -424,8 +452,10 @@ export const run = async function* (input) {
|
||||
const hash1 = add1.value.hash;
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "y", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdFork } from "../src/cmd-fork.js";
|
||||
import { cmdRun } from "../src/cmd-run.js";
|
||||
@@ -9,7 +10,9 @@ import { pathExists } from "../src/fs-utils.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `export const descriptor = {
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "fork-cli",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
@@ -17,20 +20,21 @@ const threeRoleBundleSource = `export const descriptor = {
|
||||
reviewer: { description: "reviewer", schema: {} },
|
||||
},
|
||||
};
|
||||
export const run = async function* (input) {
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
yield { role: "planner", content: "p1", meta: { k: "planner" } };
|
||||
const h = await putContentMerkleNode(cas, "p1");
|
||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
yield { role: "coder", content: "c1", meta: { k: "coder" } };
|
||||
const h = await putContentMerkleNode(cas, "c1");
|
||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
yield {
|
||||
role: "reviewer",
|
||||
content: "rev-" + String(input.steps.length),
|
||||
meta: { k: "reviewer" },
|
||||
};
|
||||
const body = "rev-" + String(input.steps.length);
|
||||
const h = await putContentMerkleNode(cas, body);
|
||||
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||
}
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
@@ -132,7 +136,8 @@ describe("cli fork", () => {
|
||||
|
||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
||||
expect(last.role).toBe("reviewer");
|
||||
expect(last.content).toBe("rev-1");
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
expect(await getContentMerklePayload(cas, String(last.contentHash))).toBe("rev-1");
|
||||
});
|
||||
|
||||
test("fork without --from-role retries last role", async () => {
|
||||
@@ -179,11 +184,12 @@ describe("cli fork", () => {
|
||||
|
||||
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(replayCoder.role).toBe("coder");
|
||||
expect(replayCoder.content).toBe("c1");
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
expect(await getContentMerklePayload(cas, String(replayCoder.contentHash))).toBe("c1");
|
||||
|
||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
||||
expect(last.role).toBe("reviewer");
|
||||
expect(last.content).toBe("rev-2");
|
||||
expect(await getContentMerklePayload(cas, String(last.contentHash))).toBe("rev-2");
|
||||
});
|
||||
|
||||
test("fork rejects unknown role with available names", async () => {
|
||||
|
||||
@@ -4,31 +4,43 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow";
|
||||
import {
|
||||
createCasStore,
|
||||
garbageCollectCas,
|
||||
getGlobalCasDir,
|
||||
putContentMerkleNode,
|
||||
} from "@uncaged/workflow";
|
||||
import { cmdThreadRemove } from "../src/cmd-thread.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
/** Minimal valid `.data.jsonl` with one role step referencing `activeHash` in `refs`. */
|
||||
function makeDataJsonl(threadId: string, bundleHash: string, activeHash: string): string {
|
||||
return [
|
||||
async function writeDemoDataJsonl(params: {
|
||||
path: string;
|
||||
threadId: string;
|
||||
bundleHash: string;
|
||||
cas: ReturnType<typeof createCasStore>;
|
||||
activeHash: string;
|
||||
}): Promise<void> {
|
||||
const bodyHash = await putContentMerkleNode(params.cas, "p");
|
||||
const text = [
|
||||
JSON.stringify({
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
threadId,
|
||||
hash: params.bundleHash,
|
||||
threadId: params.threadId,
|
||||
parameters: { prompt: "hi", options: { maxRounds: 5 } },
|
||||
timestamp: 100,
|
||||
}),
|
||||
JSON.stringify({
|
||||
role: "planner",
|
||||
content: "p",
|
||||
contentHash: bodyHash,
|
||||
meta: {},
|
||||
refs: [activeHash],
|
||||
refs: [params.activeHash, bodyHash],
|
||||
timestamp: 101,
|
||||
}),
|
||||
"",
|
||||
].join("\n");
|
||||
await writeFile(params.path, text, "utf8");
|
||||
}
|
||||
|
||||
describe("gc cli and garbageCollectCas", () => {
|
||||
@@ -60,11 +72,13 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
const activeHash = await cas.put("active-blob");
|
||||
const orphanHash = await cas.put("orphan-blob");
|
||||
|
||||
await writeFile(
|
||||
join(logsDir, `${threadId}.data.jsonl`),
|
||||
makeDataJsonl(threadId, bundleHash, activeHash),
|
||||
"utf8",
|
||||
);
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
cas,
|
||||
activeHash,
|
||||
});
|
||||
|
||||
const gc = await garbageCollectCas(storageRoot);
|
||||
expect(gc.ok).toBe(true);
|
||||
@@ -72,7 +86,7 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
return;
|
||||
}
|
||||
expect(gc.value.scannedThreads).toBe(1);
|
||||
expect(gc.value.activeRefs).toBe(1);
|
||||
expect(gc.value.activeRefs).toBe(2);
|
||||
expect(gc.value.deletedEntries).toBe(1);
|
||||
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
||||
|
||||
@@ -106,16 +120,18 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
const activeHash = await cas.put("keep-me");
|
||||
await cas.put("drop-me");
|
||||
|
||||
await writeFile(
|
||||
join(logsDir, `${threadId}.data.jsonl`),
|
||||
makeDataJsonl(threadId, bundleHash, activeHash),
|
||||
"utf8",
|
||||
);
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
cas,
|
||||
activeHash,
|
||||
});
|
||||
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const proc = spawnSync(process.execPath, [cliEntryPath, "gc"], { env, encoding: "utf8" });
|
||||
expect(proc.status).toBe(0);
|
||||
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 1 active refs, deleted 1 entries");
|
||||
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 active refs, deleted 1 entries");
|
||||
});
|
||||
|
||||
test("thread rm triggers gc so unreferenced CAS is removed", async () => {
|
||||
@@ -126,11 +142,13 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const activeHash = await cas.put("pinned-by-ref");
|
||||
await writeFile(
|
||||
join(logsDir, `${threadId}.data.jsonl`),
|
||||
makeDataJsonl(threadId, bundleHash, activeHash),
|
||||
"utf8",
|
||||
);
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
cas,
|
||||
activeHash,
|
||||
});
|
||||
|
||||
const orphanHash = await cas.put("orphan-after-rm");
|
||||
const orphanPath = join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`);
|
||||
|
||||
@@ -17,6 +17,9 @@ import { cmdThreads } from "../src/cmd-threads.js";
|
||||
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
description: "thread-cli",
|
||||
roles: {
|
||||
@@ -31,18 +34,26 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
`;
|
||||
|
||||
const fastBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
@@ -50,27 +61,38 @@ export const run = async function* (input) {
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
yield { role: "first", content: "f", meta: {} };
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "f");
|
||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
yield { role: "second", content: "s", meta: {} };
|
||||
h = await putContentMerkleNode(cas, "s");
|
||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
yield { role: "only", content: "x", meta: {} };
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
@@ -192,7 +192,7 @@ export async function cmdAdd(
|
||||
return validated;
|
||||
}
|
||||
|
||||
const extracted = await extractBundleExports(resolvedPath);
|
||||
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
|
||||
if (!extracted.ok) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
@@ -65,8 +65,9 @@ export async function cmdFork(
|
||||
const newThreadId = generateUlid(Date.now());
|
||||
const stepsOnWire = plan.value.historicalSteps.map((s) => ({
|
||||
role: s.role,
|
||||
content: s.content,
|
||||
contentHash: s.contentHash,
|
||||
meta: s.meta,
|
||||
refs: s.refs,
|
||||
timestamp: s.timestamp,
|
||||
}));
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export async function cmdRollback(
|
||||
}
|
||||
|
||||
const nextRegistry = {
|
||||
config: reg.value.config,
|
||||
workflows: { ...reg.value.workflows, [name]: rolled.value },
|
||||
};
|
||||
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
||||
|
||||
@@ -54,7 +54,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
"From the thread context, determine the absolute filesystem path where the project/repository is located.",
|
||||
extractCtx,
|
||||
);
|
||||
const fullPrompt = buildAgentPrompt(ctx);
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
const args = [
|
||||
"-p",
|
||||
fullPrompt,
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx) => {
|
||||
const fullPrompt = buildAgentPrompt(ctx);
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { START, type ThreadContext } from "@uncaged/workflow";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
||||
|
||||
const casDir = mkdtempSync(join(tmpdir(), "wf-llm-adapter-cas-"));
|
||||
const testCas = createCasStore(casDir);
|
||||
|
||||
function makeCtx(userContent: string): ThreadContext {
|
||||
return {
|
||||
start: {
|
||||
@@ -15,6 +21,7 @@ function makeCtx(userContent: string): ThreadContext {
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
||||
cas: testCas,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,4 +39,5 @@ export const coderRole: RoleDefinition<CoderMeta> = {
|
||||
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: (meta) => [meta.completedPhase],
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -21,15 +21,16 @@ export const committerMetaSchema = z.discriminatedUnion("status", [
|
||||
|
||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||
|
||||
const COMMITTER_SYSTEM = `You are the git committer. Create a branch, commit the changes, and push.
|
||||
const COMMITTER_SYSTEM = `You are the git committer. Create a branch and commit the changes.
|
||||
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
|
||||
Do not attempt to fix failures yourself.`;
|
||||
|
||||
export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||
description: "Creates branch, commits, and pushes when review passes.",
|
||||
description: "Creates a branch and commits changes.",
|
||||
systemPrompt: COMMITTER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
||||
schema: committerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -50,4 +50,5 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -48,4 +48,5 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
|
||||
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
|
||||
schema: preparerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -22,4 +22,5 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
|
||||
schema: reviewerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-role-tester",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type TesterMeta, testerMetaSchema, testerRole } from "./tester.js";
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const testerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("passed"),
|
||||
details: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("failed"),
|
||||
details: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type TesterMeta = z.infer<typeof testerMetaSchema>;
|
||||
|
||||
const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.`;
|
||||
|
||||
export const testerRole: RoleDefinition<TesterMeta> = {
|
||||
description: "Runs test, build, and lint commands and reports pass or fail with details.",
|
||||
systemPrompt: TESTER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract the verification result: passed with summary details, or failed with details of what broke.",
|
||||
schema: testerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleStep,
|
||||
START,
|
||||
validateWorkflowDescriptor,
|
||||
} from "@uncaged/workflow";
|
||||
|
||||
import type { CommitterMeta } from "@uncaged/workflow-role-committer";
|
||||
import type { PlannerMeta } from "@uncaged/workflow-role-planner";
|
||||
|
||||
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
||||
import { developModerator } from "../src/index.js";
|
||||
import type { DevelopMeta } from "../src/roles.js";
|
||||
|
||||
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
||||
{
|
||||
hash: "4KNMR2PX",
|
||||
title: "Do the work",
|
||||
},
|
||||
];
|
||||
|
||||
function makeStart(maxRounds: number): ModeratorContext<DevelopMeta>["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content: "Implement the feature",
|
||||
meta: { maxRounds },
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
maxRounds: number,
|
||||
steps: ModeratorContext<DevelopMeta>["steps"],
|
||||
): ModeratorContext<DevelopMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
start: makeStart(maxRounds),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "planner",
|
||||
contentHash: "STUBHASHPLANNER001",
|
||||
meta: { phases },
|
||||
refs: phases.map((p) => p.hash),
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "coder",
|
||||
contentHash: "STUBHASHCODER00001",
|
||||
meta: { completedPhase, filesChanged: ["a.ts"], summary: "implemented" },
|
||||
refs: [completedPhase],
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function reviewerStep(approved: boolean): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "reviewer",
|
||||
contentHash: "STUBHASHREVIEWER01",
|
||||
meta: approved
|
||||
? { status: "approved" as const }
|
||||
: { status: "rejected" as const, issues: ["needs fix"] },
|
||||
refs: [],
|
||||
timestamp: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function testerStep(passed: boolean): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "tester",
|
||||
contentHash: "STUBHASHTESTER01",
|
||||
meta: passed
|
||||
? { status: "passed" as const, details: "all checks passed" }
|
||||
: { status: "failed" as const, details: "lint failed" },
|
||||
refs: [],
|
||||
timestamp: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function committerStep(meta: CommitterMeta): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "committer",
|
||||
contentHash: "STUBHASHCOMMITTER1",
|
||||
meta,
|
||||
refs: [],
|
||||
timestamp: 5,
|
||||
};
|
||||
}
|
||||
|
||||
describe("developModerator", () => {
|
||||
test("routes initial → planner → coder → reviewer → tester → committer → END", () => {
|
||||
expect(developModerator(makeCtx(20, []))).toBe("planner");
|
||||
expect(developModerator(makeCtx(20, [plannerStep()]))).toBe("coder");
|
||||
expect(developModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer");
|
||||
expect(developModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
|
||||
"tester",
|
||||
);
|
||||
expect(
|
||||
developModerator(
|
||||
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), testerStep(true)]),
|
||||
),
|
||||
).toBe("committer");
|
||||
expect(
|
||||
developModerator(
|
||||
makeCtx(20, [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(true),
|
||||
committerStep({ status: "committed", branch: "feat/x", commitSha: "abc1234" }),
|
||||
]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
|
||||
test("reviewer rejects → coder retry when budget allows", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(20, steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("reviewer rejects → END when max rounds exhausted", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(4, steps))).toBe(END);
|
||||
});
|
||||
|
||||
test("tester failed → coder retry when budget allows", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(20, steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("tester failed → END when max rounds exhausted", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(5, steps))).toBe(END);
|
||||
});
|
||||
|
||||
test("multiple planner phases → coder until all complete, then reviewer", () => {
|
||||
const phases: PlannerMeta["phases"] = [
|
||||
{ hash: "AA000001", title: "first phase" },
|
||||
{ hash: "AA000002", title: "second phase" },
|
||||
];
|
||||
expect(developModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
|
||||
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe(
|
||||
"coder",
|
||||
);
|
||||
expect(
|
||||
developModerator(
|
||||
makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
|
||||
),
|
||||
).toBe("reviewer");
|
||||
});
|
||||
|
||||
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
|
||||
const phases: PlannerMeta["phases"] = [
|
||||
{ hash: "BB000001", title: "setup branch" },
|
||||
{ hash: "BB000002", title: "write tests" },
|
||||
{ hash: "BB000003", title: "verify" },
|
||||
{ hash: "BB000004", title: "polish" },
|
||||
];
|
||||
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
|
||||
"reviewer",
|
||||
);
|
||||
});
|
||||
|
||||
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
|
||||
const phases: PlannerMeta["phases"] = [
|
||||
{ hash: "CC000001", title: "first phase" },
|
||||
{ hash: "CC000002", title: "second phase" },
|
||||
];
|
||||
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
|
||||
"coder",
|
||||
);
|
||||
});
|
||||
|
||||
test("incomplete phases → END when max rounds exhausted", () => {
|
||||
const phases: PlannerMeta["phases"] = [
|
||||
{ hash: "DD000001", title: "first phase" },
|
||||
{ hash: "DD000002", title: "second phase" },
|
||||
];
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(phases),
|
||||
coderStep("DD000001"),
|
||||
];
|
||||
expect(developModerator(makeCtx(3, steps))).toBe(END);
|
||||
});
|
||||
|
||||
test("committer → END for any committer meta status", () => {
|
||||
const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" });
|
||||
const recoverable = committerStep({
|
||||
status: "recoverable",
|
||||
error: "merge conflict",
|
||||
logRef: null,
|
||||
});
|
||||
const unrecoverable = committerStep({
|
||||
status: "unrecoverable",
|
||||
error: "repo missing",
|
||||
logRef: "log1",
|
||||
});
|
||||
const base: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(true),
|
||||
];
|
||||
expect(developModerator(makeCtx(20, [...base, committed]))).toBe(END);
|
||||
expect(developModerator(makeCtx(20, [...base, recoverable]))).toBe(END);
|
||||
expect(developModerator(makeCtx(20, [...base, unrecoverable]))).toBe(END);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDevelopDescriptor", () => {
|
||||
test("lists all roles with schemas that validate", () => {
|
||||
const descriptor = buildDevelopDescriptor();
|
||||
const validated = validateWorkflowDescriptor(descriptor);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
expect(Object.keys(validated.value.roles).sort()).toEqual([
|
||||
"coder",
|
||||
"committer",
|
||||
"planner",
|
||||
"reviewer",
|
||||
"tester",
|
||||
]);
|
||||
for (const key of ["planner", "coder", "reviewer", "tester", "committer"] as const) {
|
||||
const role = validated.value.roles[key];
|
||||
expect(role).toBeDefined();
|
||||
expect(typeof role.schema).toBe("object");
|
||||
expect(role.schema).not.toBeNull();
|
||||
expect(Array.isArray(role.schema)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-develop",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-role-coder": "workspace:*",
|
||||
"@uncaged/workflow-role-committer": "workspace:*",
|
||||
"@uncaged/workflow-role-planner": "workspace:*",
|
||||
"@uncaged/workflow-role-reviewer": "workspace:*",
|
||||
"@uncaged/workflow-role-tester": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow";
|
||||
|
||||
import { developModerator } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js";
|
||||
|
||||
export function buildDevelopDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: developRoles,
|
||||
moderator: developModerator,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
type AgentBinding,
|
||||
createWorkflow,
|
||||
type ExtractFn,
|
||||
type LlmProvider,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
} from "@uncaged/workflow";
|
||||
|
||||
import { developModerator } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||
|
||||
export {
|
||||
type CoderMeta,
|
||||
coderMetaSchema,
|
||||
coderRole,
|
||||
} from "@uncaged/workflow-role-coder";
|
||||
export {
|
||||
type CommitterMeta,
|
||||
committerMetaSchema,
|
||||
committerRole,
|
||||
} from "@uncaged/workflow-role-committer";
|
||||
export {
|
||||
type PlannerMeta,
|
||||
phaseSchema,
|
||||
plannerMetaSchema,
|
||||
plannerRole,
|
||||
} from "@uncaged/workflow-role-planner";
|
||||
export {
|
||||
type ReviewerMeta,
|
||||
reviewerMetaSchema,
|
||||
reviewerRole,
|
||||
} from "@uncaged/workflow-role-reviewer";
|
||||
export {
|
||||
type TesterMeta,
|
||||
testerMetaSchema,
|
||||
testerRole,
|
||||
} from "@uncaged/workflow-role-tester";
|
||||
export { buildDevelopDescriptor } from "./descriptor.js";
|
||||
export { developModerator } from "./moderator.js";
|
||||
export {
|
||||
DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
type DevelopMeta,
|
||||
type DevelopRoles,
|
||||
developRoles,
|
||||
} from "./roles.js";
|
||||
|
||||
export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: developRoles,
|
||||
moderator: developModerator,
|
||||
};
|
||||
|
||||
export function createDevelopRun(
|
||||
binding: AgentBinding,
|
||||
extract: ExtractFn,
|
||||
llmProvider: LlmProvider | null,
|
||||
): WorkflowFn {
|
||||
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
|
||||
import { END } from "@uncaged/workflow";
|
||||
|
||||
import type { DevelopMeta } from "./roles.js";
|
||||
|
||||
function coderFinishedAllPlannedPhases(
|
||||
phases: ReadonlyArray<{ hash: string }>,
|
||||
coderCompletedPhases: ReadonlyArray<string>,
|
||||
): boolean {
|
||||
if (phases.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const plannedHashes = new Set(phases.map((p) => p.hash));
|
||||
const lastHash = phases[phases.length - 1].hash;
|
||||
const explicit = new Set(coderCompletedPhases.filter((h) => plannedHashes.has(h)));
|
||||
if (phases.every((p) => explicit.has(p.hash))) {
|
||||
return true;
|
||||
}
|
||||
if (coderCompletedPhases.some((h) => h === lastHash)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function nextAfterCoder(
|
||||
ctx: ModeratorContext<DevelopMeta>,
|
||||
maxRounds: number,
|
||||
): (keyof DevelopMeta & string) | typeof END {
|
||||
const plannerStep = ctx.steps.find((s) => s.role === "planner");
|
||||
if (plannerStep === undefined) {
|
||||
return "reviewer";
|
||||
}
|
||||
const phases = plannerStep.meta.phases;
|
||||
const coderCompletedPhases = ctx.steps
|
||||
.filter((s) => s.role === "coder")
|
||||
.map((s) => s.meta.completedPhase);
|
||||
const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
|
||||
if (allDone) {
|
||||
return "reviewer";
|
||||
}
|
||||
if (ctx.steps.length < maxRounds - 1) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
|
||||
export const developModerator: Moderator<DevelopMeta> = (ctx) => {
|
||||
const maxRounds = ctx.start.meta.maxRounds;
|
||||
|
||||
if (ctx.steps.length === 0) {
|
||||
return "planner";
|
||||
}
|
||||
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
|
||||
if (last.role === "planner") {
|
||||
return "coder";
|
||||
}
|
||||
|
||||
if (last.role === "coder") {
|
||||
return nextAfterCoder(ctx, maxRounds);
|
||||
}
|
||||
|
||||
if (last.role === "reviewer") {
|
||||
if (last.meta.status === "approved") {
|
||||
return "tester";
|
||||
}
|
||||
if (ctx.steps.length < maxRounds - 1) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
|
||||
if (last.role === "tester") {
|
||||
if (last.meta.status === "passed") {
|
||||
return "committer";
|
||||
}
|
||||
if (ctx.steps.length < maxRounds - 1) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
|
||||
if (last.role === "committer") {
|
||||
return END;
|
||||
}
|
||||
|
||||
return END;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder";
|
||||
import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer";
|
||||
import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner";
|
||||
import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
|
||||
import { type TesterMeta, testerRole } from "@uncaged/workflow-role-tester";
|
||||
|
||||
export const DEVELOP_WORKFLOW_DESCRIPTION =
|
||||
"Plan phases, implement incrementally, review, verify with tests/build/lint, and commit (planner → coder [repeat per phase] → reviewer → tester → committer).";
|
||||
|
||||
export type DevelopMeta = {
|
||||
planner: PlannerMeta;
|
||||
coder: CoderMeta;
|
||||
reviewer: ReviewerMeta;
|
||||
tester: TesterMeta;
|
||||
committer: CommitterMeta;
|
||||
};
|
||||
|
||||
export type DevelopRoles = {
|
||||
[K in keyof DevelopMeta]: RoleDefinition<DevelopMeta[K]>;
|
||||
};
|
||||
|
||||
export const developRoles: DevelopRoles = {
|
||||
planner: plannerRole,
|
||||
coder: coderRole,
|
||||
reviewer: reviewerRole,
|
||||
tester: testerRole,
|
||||
committer: committerRole,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow" },
|
||||
{ "path": "../workflow-role-coder" },
|
||||
{ "path": "../workflow-role-committer" },
|
||||
{ "path": "../workflow-role-planner" },
|
||||
{ "path": "../workflow-role-reviewer" },
|
||||
{ "path": "../workflow-role-tester" }
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createCasStore,
|
||||
createExtract,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
@@ -113,7 +117,7 @@ function makeCtx(
|
||||
function preparerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "preparer",
|
||||
content: "prepared",
|
||||
contentHash: "STUBHASHPREPARER01",
|
||||
meta: {
|
||||
repoPath: "/home/user/repos/test",
|
||||
defaultBranch: "main",
|
||||
@@ -133,7 +137,7 @@ function preparerStep(): RoleStep<SolveIssueMeta> {
|
||||
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "planner",
|
||||
content: "plan",
|
||||
contentHash: "STUBHASHPLANNER001",
|
||||
meta: { phases },
|
||||
refs: phases.map((p) => p.hash),
|
||||
timestamp: 1,
|
||||
@@ -143,7 +147,7 @@ function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<S
|
||||
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "coder",
|
||||
content: "code",
|
||||
contentHash: "STUBHASHCODER00001",
|
||||
meta: { completedPhase, filesChanged: ["a.ts"], summary: "fixed" },
|
||||
refs: [completedPhase],
|
||||
timestamp: 2,
|
||||
@@ -153,7 +157,7 @@ function coderStep(completedPhase = "4KNMR2PX"): RoleStep<SolveIssueMeta> {
|
||||
function reviewerStep(approved: boolean): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "reviewer",
|
||||
content: "rev",
|
||||
contentHash: "STUBHASHREVIEWER01",
|
||||
meta: approved
|
||||
? { status: "approved" as const }
|
||||
: { status: "rejected" as const, issues: ["needs fix"] },
|
||||
@@ -165,7 +169,7 @@ function reviewerStep(approved: boolean): RoleStep<SolveIssueMeta> {
|
||||
function committerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "committer",
|
||||
content: "commit",
|
||||
contentHash: "STUBHASHCOMMITTER1",
|
||||
meta: { status: "committed", branch: "feat/issue-1", commitSha: "abc1234" },
|
||||
refs: [],
|
||||
timestamp: 4,
|
||||
@@ -281,10 +285,15 @@ describe("solveIssueModerator", () => {
|
||||
|
||||
describe("createSolveIssueRun", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
let casDir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
if (casDir !== undefined) {
|
||||
await rm(casDir, { recursive: true, force: true }).catch(() => {});
|
||||
casDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test("structured extraction yields preparer then planner meta from mocked chat completions", async () => {
|
||||
@@ -301,10 +310,13 @@ describe("createSolveIssueRun", () => {
|
||||
};
|
||||
restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META, EXPECT_PLANNER_META]);
|
||||
|
||||
const run = createSolveIssueRun({ agent: async () => "" }, stubExtract);
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const run = createSolveIssueRun({ agent: async () => "" }, stubExtract, null);
|
||||
const gen = run(
|
||||
{ prompt: "task", steps: [] },
|
||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0 },
|
||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
||||
);
|
||||
const first = await gen.next();
|
||||
expect(first.done).toBe(false);
|
||||
@@ -336,6 +348,9 @@ describe("createSolveIssueRun", () => {
|
||||
EXPECT_CODER_META,
|
||||
]);
|
||||
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const calls: string[] = [];
|
||||
const run = createSolveIssueRun(
|
||||
{
|
||||
@@ -359,10 +374,11 @@ describe("createSolveIssueRun", () => {
|
||||
},
|
||||
},
|
||||
stubExtract,
|
||||
null,
|
||||
);
|
||||
const gen = run(
|
||||
{ prompt: "task", steps: [] },
|
||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0 },
|
||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
||||
);
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["preparer"]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AgentBinding,
|
||||
createWorkflow,
|
||||
type ExtractFn,
|
||||
type LlmProvider,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
} from "@uncaged/workflow";
|
||||
@@ -50,6 +51,10 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
|
||||
moderator: solveIssueModerator,
|
||||
};
|
||||
|
||||
export function createSolveIssueRun(binding: AgentBinding, extract: ExtractFn): WorkflowFn {
|
||||
return createWorkflow(solveIssueWorkflowDefinition, binding, extract);
|
||||
export function createSolveIssueRun(
|
||||
binding: AgentBinding,
|
||||
extract: ExtractFn,
|
||||
llmProvider: LlmProvider | null,
|
||||
): WorkflowFn {
|
||||
return createWorkflow(solveIssueWorkflowDefinition, binding, extract, llmProvider);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { START, type ThreadContext } from "@uncaged/workflow";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, putContentMerkleNode, START, type ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
import { buildAgentPrompt } from "../src/index.js";
|
||||
|
||||
@@ -13,38 +16,53 @@ function startTask(content: string): ThreadContext["start"] {
|
||||
}
|
||||
|
||||
describe("buildAgentPrompt", () => {
|
||||
test("includes system prompt and full task; omits tools when there are no steps", () => {
|
||||
let casRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
casRoot = await mkdtemp(join(tmpdir(), "wf-build-prompt-cas-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(casRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("includes system prompt and full task; omits tools when there are no steps", async () => {
|
||||
const cas = createCasStore(casRoot);
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("fix the bug"),
|
||||
depth: 0,
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: START, systemPrompt: "You are an agent." },
|
||||
cas,
|
||||
};
|
||||
const text = buildAgentPrompt(ctx);
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
expect(text).toContain("You are an agent.");
|
||||
expect(text).toContain("## Task");
|
||||
expect(text).toContain("fix the bug");
|
||||
expect(text).not.toContain("## Tools");
|
||||
});
|
||||
|
||||
test("single step shows full content and meta, and includes tools", () => {
|
||||
test("single step shows full content and meta, and includes tools", async () => {
|
||||
const cas = createCasStore(casRoot);
|
||||
const onlyHash = await putContentMerkleNode(cas, "only step full body");
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("user task"),
|
||||
depth: 0,
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
||||
cas,
|
||||
steps: [
|
||||
{
|
||||
role: "coder",
|
||||
content: "only step full body",
|
||||
contentHash: onlyHash,
|
||||
meta: { files: ["a.ts"] },
|
||||
refs: [],
|
||||
refs: [onlyHash],
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = buildAgentPrompt(ctx);
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
expect(text).toContain("## Task");
|
||||
expect(text).toContain("user task");
|
||||
expect(text).toContain("## Step: coder");
|
||||
@@ -54,30 +72,34 @@ describe("buildAgentPrompt", () => {
|
||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||
});
|
||||
|
||||
test("two or more steps: previous steps are meta-only; latest step is full", () => {
|
||||
test("two or more steps: previous steps are meta-only; latest step is full", async () => {
|
||||
const cas = createCasStore(casRoot);
|
||||
const plannerHash = await putContentMerkleNode(cas, "PLANNER_SECRET_FULL_TEXT");
|
||||
const coderHash = await putContentMerkleNode(cas, "last step full content");
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("first message full: task content here"),
|
||||
depth: 0,
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "coder", systemPrompt: "System." },
|
||||
cas,
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
content: "PLANNER_SECRET_FULL_TEXT",
|
||||
contentHash: plannerHash,
|
||||
meta: { plan: "short" },
|
||||
refs: [],
|
||||
refs: [plannerHash],
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
role: "coder",
|
||||
content: "last step full content",
|
||||
contentHash: coderHash,
|
||||
meta: { done: true },
|
||||
refs: [],
|
||||
refs: [coderHash],
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = buildAgentPrompt(ctx);
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
expect(text).toContain("first message full: task content here");
|
||||
expect(text).toContain("## Previous Steps");
|
||||
expect(text).toContain("### Step 1: planner");
|
||||
@@ -90,37 +112,42 @@ describe("buildAgentPrompt", () => {
|
||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||
});
|
||||
|
||||
test("middle steps show meta summary only, not full content", () => {
|
||||
test("middle steps show meta summary only, not full content", async () => {
|
||||
const cas = createCasStore(casRoot);
|
||||
const ha = await putContentMerkleNode(cas, "HIDDEN_A");
|
||||
const hb = await putContentMerkleNode(cas, "HIDDEN_B_MIDDLE");
|
||||
const hc = await putContentMerkleNode(cas, "VISIBLE_LAST");
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("start"),
|
||||
depth: 0,
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "c", systemPrompt: "S" },
|
||||
cas,
|
||||
steps: [
|
||||
{
|
||||
role: "a",
|
||||
content: "HIDDEN_A",
|
||||
contentHash: ha,
|
||||
meta: { n: 1 },
|
||||
refs: [],
|
||||
refs: [ha],
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
role: "b",
|
||||
content: "HIDDEN_B_MIDDLE",
|
||||
contentHash: hb,
|
||||
meta: { n: 2 },
|
||||
refs: [],
|
||||
refs: [hb],
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
role: "c",
|
||||
content: "VISIBLE_LAST",
|
||||
contentHash: hc,
|
||||
meta: { n: 3 },
|
||||
refs: [],
|
||||
refs: [hc],
|
||||
timestamp: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = buildAgentPrompt(ctx);
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
expect(text).not.toContain("HIDDEN_A");
|
||||
expect(text).not.toContain("HIDDEN_B_MIDDLE");
|
||||
expect(text).toContain('Summary: {"n":1}');
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { AgentContext } from "@uncaged/workflow";
|
||||
import { getContentMerklePayload } from "@uncaged/workflow";
|
||||
|
||||
async function resolveStepText(ctx: AgentContext, contentHash: string): Promise<string> {
|
||||
const text = await getContentMerklePayload(ctx.cas, contentHash);
|
||||
if (text === null) {
|
||||
throw new Error(`buildAgentPrompt: missing CAS blob for ${contentHash}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Builds the full agent prompt: system instructions plus summarized thread history. */
|
||||
export function buildAgentPrompt(ctx: AgentContext): string {
|
||||
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push("");
|
||||
@@ -15,10 +24,11 @@ export function buildAgentPrompt(ctx: AgentContext): string {
|
||||
|
||||
if (steps.length === 1) {
|
||||
const s = steps[0];
|
||||
const body = await resolveStepText(ctx, s.contentHash);
|
||||
lines.push("");
|
||||
lines.push(`## Step: ${s.role}`);
|
||||
lines.push("");
|
||||
lines.push(s.content);
|
||||
lines.push(body);
|
||||
lines.push("");
|
||||
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
|
||||
} else {
|
||||
@@ -31,10 +41,11 @@ export function buildAgentPrompt(ctx: AgentContext): string {
|
||||
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
|
||||
}
|
||||
const last = steps[steps.length - 1];
|
||||
const lastBody = await resolveStepText(ctx, last.contentHash);
|
||||
lines.push("");
|
||||
lines.push(`## Latest Step: ${last.role}`);
|
||||
lines.push("");
|
||||
lines.push(last.content);
|
||||
lines.push(lastBody);
|
||||
lines.push("");
|
||||
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("buildDescriptor", () => {
|
||||
extractPrompt: "Extract title and count from the analysis.",
|
||||
schema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
|
||||
@@ -26,6 +26,19 @@ export const run = async function* (input) {
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("allows static import of @uncaged/workflow", () => {
|
||||
const source = `${minimalDescriptor}import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
return { returnCode: 0, summary: h };
|
||||
};
|
||||
`;
|
||||
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects wrong filename suffix", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.js",
|
||||
|
||||
@@ -4,11 +4,18 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { createWorkflow } from "../src/create-workflow.js";
|
||||
import { executeThread } from "../src/engine.js";
|
||||
import { createExtract } from "../src/extract-fn.js";
|
||||
import { createLogger } from "../src/logger.js";
|
||||
import { END } from "../src/types.js";
|
||||
import {
|
||||
createContentMerkleNode,
|
||||
getContentMerklePayload,
|
||||
parseMerkleNode,
|
||||
serializeMerkleNode,
|
||||
} from "../src/merkle.js";
|
||||
import { END, type LlmProvider } from "../src/types.js";
|
||||
|
||||
const plannerMetaSchema = z.object({
|
||||
plan: z.string(),
|
||||
@@ -90,6 +97,7 @@ const demoWorkflow = createWorkflow<DemoMeta>(
|
||||
extractPrompt: "Extract plan text and affected files list.",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
coder: {
|
||||
description: "Demo coder",
|
||||
@@ -97,6 +105,7 @@ const demoWorkflow = createWorkflow<DemoMeta>(
|
||||
extractPrompt: "Extract the code diff summary.",
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => {
|
||||
@@ -117,6 +126,7 @@ const demoWorkflow = createWorkflow<DemoMeta>(
|
||||
},
|
||||
},
|
||||
demoExtract,
|
||||
null,
|
||||
);
|
||||
|
||||
describe("executeThread", () => {
|
||||
@@ -140,6 +150,7 @@ describe("executeThread", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
@@ -156,11 +167,24 @@ describe("executeThread", () => {
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
expect(result.rootHash.length).toBeGreaterThan(0);
|
||||
|
||||
const rootYaml = await cas.get(result.rootHash);
|
||||
expect(rootYaml).not.toBeNull();
|
||||
const rootNode = parseMerkleNode(rootYaml ?? "");
|
||||
expect(rootNode.type).toBe("thread");
|
||||
const rootPayload = rootNode.payload as Record<string, unknown>;
|
||||
expect(rootPayload.workflow).toBe("demo-flow");
|
||||
expect(rootPayload.threadId).toBe(threadId);
|
||||
const rootResult = rootPayload.result as Record<string, unknown>;
|
||||
expect(rootResult.returnCode).toBe(0);
|
||||
expect(rootNode.children.length).toBe(2);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
@@ -184,14 +208,29 @@ describe("executeThread", () => {
|
||||
|
||||
const role1 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(role1.role).toBe("planner");
|
||||
expect(role1.content).toBe("plan-body");
|
||||
expect(typeof role1.contentHash).toBe("string");
|
||||
expect(await getContentMerklePayload(cas, String(role1.contentHash))).toBe("plan-body");
|
||||
expect(role1.meta).toEqual({ plan: "do-it", files: ["a.ts"] });
|
||||
expect(role1.refs).toEqual([]);
|
||||
expect(role1.refs).toEqual([role1.contentHash]);
|
||||
expect(typeof role1.timestamp).toBe("number");
|
||||
|
||||
const role2 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(role2.role).toBe("coder");
|
||||
expect(role2.refs).toEqual([]);
|
||||
expect(role2.refs).toEqual([role2.contentHash]);
|
||||
|
||||
const step1Yaml = await cas.get(rootNode.children[0] ?? "");
|
||||
const step2Yaml = await cas.get(rootNode.children[1] ?? "");
|
||||
expect(step1Yaml).not.toBeNull();
|
||||
expect(step2Yaml).not.toBeNull();
|
||||
const step1Node = parseMerkleNode(step1Yaml ?? "");
|
||||
const step2Node = parseMerkleNode(step2Yaml ?? "");
|
||||
expect(step1Node.type).toBe("step");
|
||||
expect(step2Node.type).toBe("step");
|
||||
expect(step1Node.children).toEqual([String(role1.contentHash)]);
|
||||
expect(step2Node.children).toEqual([String(role2.contentHash)]);
|
||||
const step1Payload = step1Node.payload as Record<string, unknown>;
|
||||
expect(step1Payload.role).toBe("planner");
|
||||
expect(step1Payload.meta).toEqual({ plan: "do-it", files: ["a.ts"] });
|
||||
|
||||
const infoText = await readFile(infoPath, "utf8");
|
||||
const infoLines = infoText
|
||||
@@ -219,11 +258,14 @@ describe("executeThread", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body")));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const histTs = 9_000_000;
|
||||
const mergedPlannerRefs = ["CAS111AAAAAAA", plannerHash];
|
||||
const result = await executeThread(
|
||||
demoWorkflow,
|
||||
"demo-flow",
|
||||
@@ -232,9 +274,9 @@ describe("executeThread", () => {
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
content: "plan-body",
|
||||
contentHash: plannerHash,
|
||||
meta: { plan: "do-it", files: ["a.ts"] },
|
||||
refs: ["CAS111AAAAAAA"],
|
||||
refs: mergedPlannerRefs,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -247,18 +289,23 @@ describe("executeThread", () => {
|
||||
prefilledDiskSteps: [
|
||||
{
|
||||
role: "planner",
|
||||
content: "plan-body",
|
||||
contentHash: plannerHash,
|
||||
meta: { plan: "do-it", files: ["a.ts"] },
|
||||
refs: ["CAS111AAAAAAA"],
|
||||
refs: mergedPlannerRefs,
|
||||
timestamp: histTs,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
|
||||
const rootYaml = await cas.get(result.rootHash);
|
||||
const rootNode = parseMerkleNode(rootYaml ?? "");
|
||||
expect(rootNode.children.length).toBe(2);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
@@ -273,11 +320,11 @@ describe("executeThread", () => {
|
||||
const role0 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(role0.role).toBe("planner");
|
||||
expect(role0.timestamp).toBe(histTs);
|
||||
expect(role0.refs).toEqual(["CAS111AAAAAAA"]);
|
||||
expect(role0.refs).toEqual(mergedPlannerRefs);
|
||||
|
||||
const role1 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(role1.role).toBe("coder");
|
||||
expect(role1.content).toBe("code-body");
|
||||
expect(await getContentMerklePayload(cas, String(role1.contentHash))).toBe("code-body");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -291,6 +338,7 @@ describe("executeThread", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
@@ -307,11 +355,17 @@ describe("executeThread", () => {
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
|
||||
const rootYaml = await cas.get(result.rootHash);
|
||||
const rootNode = parseMerkleNode(rootYaml ?? "");
|
||||
expect(rootNode.type).toBe("thread");
|
||||
expect(rootNode.children.length).toBe(0);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
@@ -323,4 +377,240 @@ describe("executeThread", () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Merkle DAG: root → step nodes → content for full thread traversal", async () => {
|
||||
restoreFetch = installMockChatCompletions([
|
||||
{ plan: "do-it", files: ["a.ts"] },
|
||||
{ diff: "+ok" },
|
||||
]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-dag-"));
|
||||
try {
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
demoWorkflow,
|
||||
"demo-flow",
|
||||
{ prompt: "DAG test", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(3);
|
||||
|
||||
const rolePlanner = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
const roleCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
|
||||
const threadYaml = await cas.get(result.rootHash);
|
||||
expect(threadYaml).not.toBeNull();
|
||||
const threadNode = parseMerkleNode(threadYaml ?? "");
|
||||
expect(threadNode.type).toBe("thread");
|
||||
|
||||
const bodies: string[] = [];
|
||||
for (const stepHash of threadNode.children) {
|
||||
const stepYaml = await cas.get(stepHash);
|
||||
expect(stepYaml).not.toBeNull();
|
||||
const stepNode = parseMerkleNode(stepYaml ?? "");
|
||||
expect(stepNode.type).toBe("step");
|
||||
expect(stepNode.children.length).toBe(1);
|
||||
const contentHash = stepNode.children[0];
|
||||
expect(contentHash).toBeDefined();
|
||||
const body = await getContentMerklePayload(cas, contentHash ?? "");
|
||||
expect(body).not.toBeNull();
|
||||
bodies.push(body ?? "");
|
||||
}
|
||||
|
||||
expect(bodies.sort()).toEqual(["code-body", "plan-body"].sort());
|
||||
expect(rolePlanner.role).toBe("planner");
|
||||
expect(roleCoder.role).toBe("coder");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("extractMode react traverses CAS DAG via cas_get during extraction", async () => {
|
||||
const dagMetaSchema = z.object({ leafPayload: z.string() });
|
||||
type DagDemoMeta = { walker: z.infer<typeof dagMetaSchema> };
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
let fetchRound = 0;
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-react-"));
|
||||
try {
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const leafYaml = serializeMerkleNode(createContentMerkleNode("needle-from-leaf"));
|
||||
const leafHash = await cas.put(leafYaml);
|
||||
const rootYaml = serializeMerkleNode({
|
||||
type: "thread",
|
||||
payload: {
|
||||
workflow: "dag-demo",
|
||||
threadId: "01DAG00000000000000000001",
|
||||
result: { returnCode: 0, summary: "" },
|
||||
},
|
||||
children: [leafHash],
|
||||
});
|
||||
const dagRootHash = await cas.put(rootYaml);
|
||||
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
fetchRound += 1;
|
||||
if (fetchRound === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: dagRootHash }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (fetchRound === 2) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c2",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: leafHash }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c3",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: JSON.stringify({ leafPayload: "needle-from-leaf" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
},
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const llm: LlmProvider = { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" };
|
||||
const extractFn = createExtract(llm);
|
||||
|
||||
const dagWorkflow = createWorkflow<DagDemoMeta>(
|
||||
{
|
||||
roles: {
|
||||
walker: {
|
||||
description: "DAG walker",
|
||||
systemPrompt: "Output only the root CAS hash.",
|
||||
extractPrompt:
|
||||
"Set leafPayload to the string payload of the content Merkle node under the root.",
|
||||
schema: dagMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "react",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
||||
},
|
||||
{ agent: async () => dagRootHash },
|
||||
extractFn,
|
||||
llm,
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
dagWorkflow,
|
||||
"dag-demo",
|
||||
{ prompt: "traverse", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(fetchRound).toBe(3);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
const roleRec = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(roleRec.role).toBe("walker");
|
||||
expect(roleRec.meta).toEqual({ leafPayload: "needle-from-leaf" });
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getExtractProvider } from "../src/extract-provider.js";
|
||||
|
||||
describe("getExtractProvider", () => {
|
||||
test("returns provider when config.extract is present", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-"));
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "workflow.yaml"),
|
||||
`config:
|
||||
maxDepth: 3
|
||||
extract:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
model: qwen-plus
|
||||
apiKey: literal-key
|
||||
workflows: {}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const r = await getExtractProvider(root);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
expect(r.value.model).toBe("qwen-plus");
|
||||
expect(r.value.apiKey).toBe("literal-key");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("errs when registry has no config section", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-missing-"));
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(join(root, "workflow.yaml"), "workflows: {}\n", "utf8");
|
||||
const r = await getExtractProvider(root);
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.error).toContain("no global config");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves apiKey from env at registry read time", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-env-"));
|
||||
const prev = process.env.WF_GET_EXTRACT_PROVIDER_KEY;
|
||||
process.env.WF_GET_EXTRACT_PROVIDER_KEY = "resolved-secret";
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "workflow.yaml"),
|
||||
`config:
|
||||
maxDepth: 1
|
||||
extract:
|
||||
baseUrl: https://example.com
|
||||
model: m
|
||||
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
|
||||
workflows: {}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const r = await getExtractProvider(root);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.apiKey).toBe("resolved-secret");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.WF_GET_EXTRACT_PROVIDER_KEY;
|
||||
} else {
|
||||
process.env.WF_GET_EXTRACT_PROVIDER_KEY = prev;
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
} from "../src/fork-thread.js";
|
||||
|
||||
const sampleDataJsonl = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||
{"role":"planner","content":"p","meta":{},"timestamp":101}
|
||||
{"role":"coder","content":"c","meta":{},"timestamp":102}
|
||||
{"role":"reviewer","content":"r","meta":{},"timestamp":103}
|
||||
{"role":"planner","contentHash":"HP0000000000000000000001","meta":{},"refs":[],"timestamp":101}
|
||||
{"role":"coder","contentHash":"HP0000000000000000000002","meta":{},"refs":[],"timestamp":102}
|
||||
{"role":"reviewer","contentHash":"HP0000000000000000000003","meta":{},"refs":[],"timestamp":103}
|
||||
`;
|
||||
|
||||
describe("fork-thread", () => {
|
||||
@@ -89,7 +89,7 @@ describe("fork-thread", () => {
|
||||
|
||||
test("parseThreadDataJsonl reads explicit depth from start record", () => {
|
||||
const text = `{"name":"demo","hash":"H","threadId":"01ZZZZZZZZZZZZZZZZZZZZZZ","parameters":{"prompt":"p","options":{"maxRounds":3,"depth":2}},"timestamp":1}
|
||||
{"role":"planner","content":"x","meta":{},"timestamp":2}
|
||||
{"role":"planner","contentHash":"HP0000000000000000000099","meta":{},"refs":[],"timestamp":2}
|
||||
`;
|
||||
const r = parseThreadDataJsonl(text);
|
||||
expect(r.ok).toBe(true);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, parseMerkleNode, serializeMerkleNode } from "../src/merkle.js";
|
||||
|
||||
describe("merkle", () => {
|
||||
test("content node roundtrips through YAML", () => {
|
||||
const node = createContentMerkleNode("hello\nworld");
|
||||
const yaml = serializeMerkleNode(node);
|
||||
const back = parseMerkleNode(yaml);
|
||||
expect(back).toEqual(node);
|
||||
});
|
||||
|
||||
test("step node with object payload roundtrips", () => {
|
||||
const node = {
|
||||
type: "step" as const,
|
||||
payload: { role: "planner", foo: 1 },
|
||||
children: ["ABC123", "DEF456"],
|
||||
};
|
||||
const yaml = serializeMerkleNode(node);
|
||||
const back = parseMerkleNode(yaml);
|
||||
expect(back.type).toBe("step");
|
||||
expect(back.payload).toEqual({ role: "planner", foo: 1 });
|
||||
expect(back.children).toEqual(["ABC123", "DEF456"]);
|
||||
});
|
||||
|
||||
test("parse rejects invalid YAML root", () => {
|
||||
expect(() => parseMerkleNode("[]")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/merkle.js";
|
||||
import { reactExtract } from "../src/react-extract.js";
|
||||
import type { LlmProvider } from "../src/types.js";
|
||||
|
||||
const metaSchema = z.object({ seen: z.string() });
|
||||
|
||||
const provider: LlmProvider = {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "test",
|
||||
model: "test",
|
||||
};
|
||||
|
||||
describe("reactExtract", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("cas_get rounds then extract tool yields validated meta", async () => {
|
||||
const casDir = await mkdtemp(join(tmpdir(), "react-extract-"));
|
||||
const cas = createCasStore(casDir);
|
||||
try {
|
||||
const blob = serializeMerkleNode(createContentMerkleNode("needle"));
|
||||
const h = await cas.put(blob);
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
let round = 0;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
round += 1;
|
||||
if (round === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "t1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: h }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "t2",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: JSON.stringify({ seen: "needle" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
},
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const text = `## Agent Output\n${h}\n## Extraction Instruction\nExtract seen from CAS.`;
|
||||
const result = await reactExtract({
|
||||
text,
|
||||
schema: metaSchema,
|
||||
provider,
|
||||
cas,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value).toEqual({ seen: "needle" });
|
||||
expect(round).toBe(2);
|
||||
} finally {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stops after max tool rounds when model keeps calling cas_get", async () => {
|
||||
const casDir = await mkdtemp(join(tmpdir(), "react-extract-max-"));
|
||||
const cas = createCasStore(casDir);
|
||||
try {
|
||||
const blob = serializeMerkleNode(createContentMerkleNode("x"));
|
||||
const h = await cas.put(blob);
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
let round = 0;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
round += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: `loop-${round}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: h }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
},
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await reactExtract({
|
||||
text: "## Agent Output\nnoop\n## Extraction Instruction\nExtract seen.",
|
||||
schema: metaSchema,
|
||||
provider,
|
||||
cas,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.error).toBe("max_react_rounds_exceeded");
|
||||
expect(round).toBe(10);
|
||||
} finally {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("passthrough JSON assistant message without tool calls", async () => {
|
||||
const casDir = await mkdtemp(join(tmpdir(), "react-extract-pass-"));
|
||||
const cas = createCasStore(casDir);
|
||||
try {
|
||||
const origFetch = globalThis.fetch;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '{"seen":"direct"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await reactExtract({
|
||||
text: "## Agent Output\nok\n## Extraction Instruction\nExtract.",
|
||||
schema: metaSchema,
|
||||
provider,
|
||||
cas,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value).toEqual({ seen: "direct" });
|
||||
} finally {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { createWorkflow } from "../src/create-workflow.js";
|
||||
import { executeThread } from "../src/engine.js";
|
||||
import { createExtract } from "../src/extract-fn.js";
|
||||
@@ -90,6 +91,7 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
||||
extractPrompt: "Extract phases with CAS hashes.",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END),
|
||||
@@ -98,6 +100,7 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
||||
agent: async () => "plan-output",
|
||||
},
|
||||
refsDemoExtract,
|
||||
null,
|
||||
);
|
||||
|
||||
describe("RoleStep refs tracking", () => {
|
||||
@@ -110,8 +113,8 @@ describe("RoleStep refs tracking", () => {
|
||||
|
||||
test("parseThreadDataJsonl reads refs and defaults missing refs to []", () => {
|
||||
const text = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||
{"role":"planner","content":"p","meta":{},"refs":["H111AAAAAAAAA","H222AAAAAAAAA"],"timestamp":101}
|
||||
{"role":"coder","content":"c","meta":{},"timestamp":102}
|
||||
{"role":"planner","contentHash":"HPAYLOAD111111","meta":{},"refs":["H111AAAAAAAAA","H222AAAAAAAAA"],"timestamp":101}
|
||||
{"role":"coder","contentHash":"HPAYLOAD222222","meta":{},"timestamp":102}
|
||||
`;
|
||||
const r = parseThreadDataJsonl(text);
|
||||
expect(r.ok).toBe(true);
|
||||
@@ -139,6 +142,7 @@ describe("RoleStep refs tracking", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
@@ -155,11 +159,13 @@ describe("RoleStep refs tracking", () => {
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
expect(result.rootHash.length).toBeGreaterThan(0);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
@@ -170,7 +176,12 @@ describe("RoleStep refs tracking", () => {
|
||||
|
||||
const role1 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(role1.role).toBe("planner");
|
||||
expect(role1.refs).toEqual(["C9NMV6V2TQT81", "C9NMV6V2TQT82"]);
|
||||
const refs = role1.refs as string[];
|
||||
expect(refs).toContain("C9NMV6V2TQT81");
|
||||
expect(refs).toContain("C9NMV6V2TQT82");
|
||||
expect(typeof role1.contentHash).toBe("string");
|
||||
expect(refs).toContain(String(role1.contentHash));
|
||||
expect(refs.length).toBe(3);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -178,8 +189,8 @@ describe("RoleStep refs tracking", () => {
|
||||
|
||||
test("buildForkPlan carries refs on historical steps", () => {
|
||||
const text = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||
{"role":"planner","content":"p","meta":{},"refs":["KEEPREFAAAAAA"],"timestamp":101}
|
||||
{"role":"coder","content":"c","meta":{},"refs":["CODERHASHAAAA"],"timestamp":102}
|
||||
{"role":"planner","contentHash":"HP111111111111","meta":{},"refs":["KEEPREFAAAAAA"],"timestamp":101}
|
||||
{"role":"coder","contentHash":"HP222222222222","meta":{},"refs":["CODERHASHAAAA"],"timestamp":102}
|
||||
`;
|
||||
const plan = buildForkPlan(text, null);
|
||||
expect(plan.ok).toBe(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
parseWorkflowRegistryYaml,
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
rollbackWorkflowToHistoryHash,
|
||||
@@ -21,6 +22,7 @@ describe("workflow registry", () => {
|
||||
if (!empty.ok) {
|
||||
return;
|
||||
}
|
||||
expect(empty.value.config).toBeNull();
|
||||
|
||||
const r1 = registerWorkflowVersion(empty.value, "solve-issue", "AAAAAAAAAAAAA", 100);
|
||||
const w1 = await writeWorkflowRegistry(dir, r1);
|
||||
@@ -68,7 +70,7 @@ describe("workflow registry", () => {
|
||||
});
|
||||
|
||||
test("rollbackWorkflowToHistoryHash swaps head with a prior version", () => {
|
||||
let reg = registerWorkflowVersion({ workflows: {} }, "solve-issue", "H1", 100);
|
||||
let reg = registerWorkflowVersion({ config: null, workflows: {} }, "solve-issue", "H1", 100);
|
||||
reg = registerWorkflowVersion(reg, "solve-issue", "H2", 200);
|
||||
reg = registerWorkflowVersion(reg, "solve-issue", "H3", 300);
|
||||
const entry = reg.workflows["solve-issue"];
|
||||
@@ -99,6 +101,85 @@ describe("workflow registry", () => {
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("parses config section and literal apiKey", () => {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 3
|
||||
extract:
|
||||
baseUrl: https://example.com/v1
|
||||
model: qwen-plus
|
||||
apiKey: secret-key
|
||||
workflows:
|
||||
solve-issue:
|
||||
hash: SPVR4BDMSGC1W
|
||||
timestamp: 1
|
||||
history: []
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config).not.toBeNull();
|
||||
if (r.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config.maxDepth).toBe(3);
|
||||
expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1");
|
||||
expect(r.value.config.extract.model).toBe("qwen-plus");
|
||||
expect(r.value.config.extract.apiKey).toBe("secret-key");
|
||||
});
|
||||
|
||||
test("parses config apiKey env: prefix from process.env", () => {
|
||||
const prev = process.env.WF_REGISTRY_TEST_API_KEY;
|
||||
process.env.WF_REGISTRY_TEST_API_KEY = "from-env";
|
||||
try {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 1
|
||||
extract:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
model: qwen-plus
|
||||
apiKey: env:WF_REGISTRY_TEST_API_KEY
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config?.extract.apiKey).toBe("from-env");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.WF_REGISTRY_TEST_API_KEY;
|
||||
} else {
|
||||
process.env.WF_REGISTRY_TEST_API_KEY = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("parse errors when env: apiKey variable is unset", () => {
|
||||
const prev = process.env.WF_REGISTRY_TEST_API_KEY_UNSET;
|
||||
delete process.env.WF_REGISTRY_TEST_API_KEY_UNSET;
|
||||
try {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 1
|
||||
extract:
|
||||
baseUrl: https://example.com
|
||||
model: m
|
||||
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(false);
|
||||
} finally {
|
||||
if (prev !== undefined) {
|
||||
process.env.WF_REGISTRY_TEST_API_KEY_UNSET = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("parse errors on invalid shape", async () => {
|
||||
const dir = join(tmpdir(), `wf-reg3-${process.pid}-${Date.now()}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("RFC-001 thread JSONL shapes", () => {
|
||||
|
||||
const roleRecord = {
|
||||
role: "planner",
|
||||
content: "Plan: modify auth middleware...",
|
||||
contentHash: "CPHASH000000000000000001",
|
||||
meta: { plan: "...", files: ["src/auth.ts"] },
|
||||
refs: [] as string[],
|
||||
timestamp: 1714963201000,
|
||||
@@ -28,7 +28,7 @@ describe("RFC-001 thread JSONL shapes", () => {
|
||||
["hash", "name", "parameters", "threadId", "timestamp"].sort(),
|
||||
);
|
||||
expect(Object.keys(roleRecord).sort()).toEqual(
|
||||
["content", "meta", "refs", "role", "timestamp"].sort(),
|
||||
["contentHash", "meta", "refs", "role", "timestamp"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,22 +5,29 @@ import { createConnection } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/merkle.js";
|
||||
import { getWorkerHostScriptPath } from "../src/worker-entry-path.js";
|
||||
|
||||
const bundleSource = `export const descriptor = {
|
||||
const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "worker-test",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
coder: { description: "coder", schema: {} },
|
||||
},
|
||||
};
|
||||
export const run = async function* (input) {
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
yield { role: "planner", content: "p", meta: { plan: input.prompt } };
|
||||
const h = await putContentMerkleNode(cas, "p");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
yield { role: "coder", content: "c", meta: { diff: "y" } };
|
||||
const h = await putContentMerkleNode(cas, "c");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
}
|
||||
return { returnCode: 0, summary: "completed: moderator returned END" };
|
||||
};
|
||||
@@ -102,7 +109,7 @@ describe("worker process", () => {
|
||||
threadId,
|
||||
workflowName: "demo-flow",
|
||||
prompt: "hello",
|
||||
options: { maxRounds: 5 },
|
||||
options: { maxRounds: 5, depth: 0 },
|
||||
});
|
||||
|
||||
const exitCode: number = await new Promise((resolve) => {
|
||||
@@ -143,6 +150,11 @@ describe("worker process", () => {
|
||||
|
||||
const port = await readReadyPort(child);
|
||||
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const plannerReplayHash = await cas.put(
|
||||
serializeMerkleNode(createContentMerkleNode("p-old")),
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const srcId = "01SRCMMMMMMMMMMMMMMMMMMMM";
|
||||
await sendJson(port, {
|
||||
@@ -150,12 +162,13 @@ describe("worker process", () => {
|
||||
threadId,
|
||||
workflowName: "demo-flow",
|
||||
prompt: "hello",
|
||||
options: { maxRounds: 5 },
|
||||
options: { maxRounds: 5, depth: 0 },
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
content: "p-old",
|
||||
contentHash: plannerReplayHash,
|
||||
meta: { plan: "z" },
|
||||
refs: [plannerReplayHash],
|
||||
timestamp: 555,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -4,11 +4,13 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { createWorkflow } from "../src/create-workflow.js";
|
||||
import { executeThread } from "../src/engine.js";
|
||||
import { createExtract } from "../src/extract-fn.js";
|
||||
import { hashWorkflowBundleBytes } from "../src/hash.js";
|
||||
import { createLogger } from "../src/logger.js";
|
||||
import { getContentMerklePayload, parseMerkleNode } from "../src/merkle.js";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
@@ -80,7 +82,9 @@ const parentExtract = createExtract({
|
||||
model: "test",
|
||||
});
|
||||
|
||||
const childBundleSource = `export const descriptor = {
|
||||
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "child-integration",
|
||||
roles: {
|
||||
agent: {
|
||||
@@ -89,8 +93,10 @@ const childBundleSource = `export const descriptor = {
|
||||
},
|
||||
},
|
||||
};
|
||||
export async function* run(input) {
|
||||
yield { role: "agent", content: "child-body", meta: {}, refs: [] };
|
||||
export async function* run(input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "child-body");
|
||||
yield { role: "agent", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "child-done:" + input.prompt };
|
||||
}
|
||||
`;
|
||||
@@ -136,12 +142,14 @@ describe("workflowAsAgent integration", () => {
|
||||
extractPrompt: "extract done flag",
|
||||
schema: callerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
|
||||
},
|
||||
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) },
|
||||
parentExtract,
|
||||
null,
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
@@ -149,6 +157,7 @@ describe("workflowAsAgent integration", () => {
|
||||
const dataPath = join(root, "logs", parentHash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", parentHash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", parentHash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
@@ -165,11 +174,12 @@ describe("workflowAsAgent integration", () => {
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
|
||||
const parentText = await readFile(dataPath, "utf8");
|
||||
const parentLines = parentText
|
||||
@@ -179,7 +189,16 @@ describe("workflowAsAgent integration", () => {
|
||||
expect(parentLines.length).toBe(2);
|
||||
const callerLine = JSON.parse(parentLines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(callerLine.role).toBe("caller");
|
||||
expect(callerLine.content).toBe("child-done:from-parent");
|
||||
const childRootHash = await getContentMerklePayload(cas, String(callerLine.contentHash));
|
||||
expect(childRootHash).not.toBeNull();
|
||||
const childThreadYaml = await cas.get(childRootHash ?? "");
|
||||
expect(childThreadYaml).not.toBeNull();
|
||||
const childThreadNode = parseMerkleNode(childThreadYaml ?? "");
|
||||
expect(childThreadNode.type).toBe("thread");
|
||||
const childPayload = childThreadNode.payload as Record<string, unknown>;
|
||||
expect(childPayload.workflow).toBe("child-wf");
|
||||
const childResult = childPayload.result as Record<string, unknown>;
|
||||
expect(childResult.summary).toBe("child-done:from-parent");
|
||||
|
||||
const childDir = join(root, "logs", childHash);
|
||||
const childFiles = await readdir(childDir);
|
||||
|
||||
@@ -3,7 +3,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { hashWorkflowBundleBytes } from "../src/hash.js";
|
||||
import { parseMerkleNode } from "../src/merkle.js";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
@@ -12,7 +14,12 @@ import {
|
||||
import { type AgentContext, START } from "../src/types.js";
|
||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||
|
||||
function makeAgentCtx(params: { depth: number; prompt: string; maxRounds: number }): AgentContext {
|
||||
function makeAgentCtx(params: {
|
||||
storageRoot: string;
|
||||
depth: number;
|
||||
prompt: string;
|
||||
maxRounds: number;
|
||||
}): AgentContext {
|
||||
const ts = Date.now();
|
||||
return {
|
||||
threadId: "01PARENT000000000000000001AA",
|
||||
@@ -28,10 +35,13 @@ function makeAgentCtx(params: { depth: number; prompt: string; maxRounds: number
|
||||
name: "caller",
|
||||
systemPrompt: "caller",
|
||||
},
|
||||
cas: createCasStore(join(params.storageRoot, "agent-ctx-cas")),
|
||||
};
|
||||
}
|
||||
|
||||
const childBundleSource = `export const descriptor = {
|
||||
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "child-test",
|
||||
roles: {
|
||||
agent: {
|
||||
@@ -40,8 +50,10 @@ const childBundleSource = `export const descriptor = {
|
||||
},
|
||||
},
|
||||
};
|
||||
export async function* run(input) {
|
||||
yield { role: "agent", content: "child-body", meta: {}, refs: [] };
|
||||
export async function* run(input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "child-body");
|
||||
yield { role: "agent", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "child-done:" + input.prompt };
|
||||
}
|
||||
`;
|
||||
@@ -68,7 +80,9 @@ describe("workflowAsAgent", () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-missing-"));
|
||||
try {
|
||||
const agent = workflowAsAgent("missing-wf", { storageRoot: root });
|
||||
const out = await agent(makeAgentCtx({ depth: 0, prompt: "x", maxRounds: 5 }));
|
||||
const out = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 0, prompt: "x", maxRounds: 5 }),
|
||||
);
|
||||
expect(out).toContain("not found in registry");
|
||||
expect(out).toContain("missing-wf");
|
||||
} finally {
|
||||
@@ -76,13 +90,24 @@ describe("workflowAsAgent", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("runs registered workflow and returns child summary string", async () => {
|
||||
test("runs registered workflow and returns child thread root CAS hash", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-"));
|
||||
try {
|
||||
await installChildWorkflow(root);
|
||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||
const out = await agent(makeAgentCtx({ depth: 0, prompt: "hello-parent", maxRounds: 5 }));
|
||||
expect(out).toBe("child-done:hello-parent");
|
||||
const out = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 0, prompt: "hello-parent", maxRounds: 5 }),
|
||||
);
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const threadYaml = await cas.get(out);
|
||||
expect(threadYaml).not.toBeNull();
|
||||
const node = parseMerkleNode(threadYaml ?? "");
|
||||
expect(node.type).toBe("thread");
|
||||
const payload = node.payload as Record<string, unknown>;
|
||||
expect(payload.workflow).toBe("child-wf");
|
||||
const resultObj = payload.result as Record<string, unknown>;
|
||||
expect(resultObj.summary).toBe("child-done:hello-parent");
|
||||
expect(node.children.length).toBe(1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -92,8 +117,50 @@ describe("workflowAsAgent", () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-depth-"));
|
||||
try {
|
||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||
const out = await agent(makeAgentCtx({ depth: 3, prompt: "x", maxRounds: 5 }));
|
||||
const out = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 3, prompt: "x", maxRounds: 5 }),
|
||||
);
|
||||
expect(out).toContain("depth limit");
|
||||
expect(out).toContain("max 3");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uses registry config maxDepth when set", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-maxdepth-cfg-"));
|
||||
try {
|
||||
await installChildWorkflow(root);
|
||||
const reg = await readWorkflowRegistry(root);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok) {
|
||||
return;
|
||||
}
|
||||
const withCfg = {
|
||||
...reg.value,
|
||||
config: {
|
||||
maxDepth: 2,
|
||||
extract: {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
model: "m",
|
||||
apiKey: "k",
|
||||
},
|
||||
},
|
||||
};
|
||||
const wr = await writeWorkflowRegistry(root, withCfg);
|
||||
expect(wr.ok).toBe(true);
|
||||
|
||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||
const okOut = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 1, prompt: "nest-once", maxRounds: 5 }),
|
||||
);
|
||||
expect(okOut).not.toContain("depth limit");
|
||||
|
||||
const badOut = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 2, prompt: "x", maxRounds: 5 }),
|
||||
);
|
||||
expect(badOut).toContain("depth limit");
|
||||
expect(badOut).toContain("max 2");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* Dynamic-import a workflow bundle path (see {@link extractBundleExports} — symlink must exist first).
|
||||
*/
|
||||
export async function importWorkflowBundleModule(bundlePath: string): Promise<unknown> {
|
||||
return import(pathToFileURL(bundlePath).href);
|
||||
}
|
||||
@@ -41,9 +41,12 @@ function isAllowedImportSpecifier(spec: string): boolean {
|
||||
if (spec.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (spec.startsWith(".") || spec.startsWith("/")) {
|
||||
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
||||
return false;
|
||||
}
|
||||
if (spec === "@uncaged/workflow") {
|
||||
return true;
|
||||
}
|
||||
return isBuiltin(spec);
|
||||
}
|
||||
|
||||
@@ -297,7 +300,7 @@ function validateImportDeclaration(node: ImportDeclaration): string | null {
|
||||
return "only static string import specifiers are allowed";
|
||||
}
|
||||
if (!isAllowedImportSpecifier(spec)) {
|
||||
return `disallowed import specifier "${spec}" (only Node built-ins are allowed)`;
|
||||
return `disallowed import specifier "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -312,7 +315,7 @@ function validateExportSource(
|
||||
return staticMessage;
|
||||
}
|
||||
if (!isAllowedImportSpecifier(spec)) {
|
||||
return `${disallowedPrefix} "${spec}" (only Node built-ins are allowed)`;
|
||||
return `${disallowedPrefix} "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -365,7 +368,7 @@ function bundleConstraintViolationForNode(node: Node): string | null {
|
||||
|
||||
/**
|
||||
* Validate RFC-001 bundle rules: single-file ESM shape, named exports `run` + `descriptor`,
|
||||
* no default export, no dynamic `import()`, static imports restricted to Node builtins.
|
||||
* no default export, no dynamic `import()`, static imports restricted to Node builtins plus `@uncaged/workflow`.
|
||||
*/
|
||||
export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Result<void, string> {
|
||||
if (!endsWithEsmJs(input.filePath)) {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { ExtractFn } from "./extract-fn.js";
|
||||
import type { CasStore } from "./cas.js";
|
||||
import { buildExtractUserContent, type ExtractFn } from "./extract-fn.js";
|
||||
import { putContentMerkleNode } from "./merkle.js";
|
||||
import { reactExtract } from "./react-extract.js";
|
||||
import { mergeRefsWithContentHash } from "./refs-field.js";
|
||||
import {
|
||||
type AgentBinding,
|
||||
type AgentContext,
|
||||
END,
|
||||
type ExtractContext,
|
||||
type LlmProvider,
|
||||
type ModeratorContext,
|
||||
type RoleDefinition,
|
||||
type RoleMeta,
|
||||
@@ -11,10 +16,10 @@ import {
|
||||
type RoleStep,
|
||||
START,
|
||||
type ThreadInput,
|
||||
type WorkflowCompletion,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
type WorkflowFnOptions,
|
||||
type WorkflowResult,
|
||||
} from "./types.js";
|
||||
|
||||
function isRoleNext<M extends RoleMeta>(
|
||||
@@ -34,19 +39,56 @@ function resolveExtractedRefs(
|
||||
return extractRefsFn(meta as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async function resolveRoleMeta<M extends RoleMeta>(
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx: ExtractContext<M>,
|
||||
extract: ExtractFn,
|
||||
llmProvider: LlmProvider | null,
|
||||
cas: CasStore,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (roleDef.extractMode === "react") {
|
||||
if (llmProvider === null) {
|
||||
throw new Error(
|
||||
'createWorkflow: llmProvider is required when a role uses extractMode "react"',
|
||||
);
|
||||
}
|
||||
const text = await buildExtractUserContent(
|
||||
extractCtx as unknown as ExtractContext,
|
||||
roleDef.extractPrompt,
|
||||
);
|
||||
const reactResult = await reactExtract({
|
||||
text,
|
||||
schema: roleDef.schema,
|
||||
provider: llmProvider,
|
||||
cas,
|
||||
});
|
||||
if (!reactResult.ok) {
|
||||
throw new Error(`react extract failed: ${reactResult.error}`);
|
||||
}
|
||||
return reactResult.value as Record<string, unknown>;
|
||||
}
|
||||
return (await extract(
|
||||
roleDef.schema,
|
||||
roleDef.extractPrompt,
|
||||
extractCtx as unknown as ExtractContext,
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds pure role definitions + moderator to runtime agents and structured extraction.
|
||||
* Assign with `export const run = createWorkflow(def, binding, extract)`.
|
||||
* Assign with `export const run = createWorkflow(def, binding, extract, llmProvider)`.
|
||||
* Pass the same {@link LlmProvider} as {@link createExtract} when any role uses `extractMode: "react"`.
|
||||
*/
|
||||
export function createWorkflow<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
binding: AgentBinding,
|
||||
extract: ExtractFn,
|
||||
llmProvider: LlmProvider | null,
|
||||
): WorkflowFn {
|
||||
return async function* workflowLoop(
|
||||
input: ThreadInput,
|
||||
options: WorkflowFnOptions,
|
||||
): AsyncGenerator<RoleOutput, WorkflowResult> {
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const nowMs = Date.now();
|
||||
const start: ModeratorContext<M>["start"] = {
|
||||
role: START,
|
||||
@@ -58,7 +100,7 @@ export function createWorkflow<M extends RoleMeta>(
|
||||
const baseTs = Date.now();
|
||||
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
content: out.content,
|
||||
contentHash: out.contentHash,
|
||||
meta: out.meta,
|
||||
refs: out.refs,
|
||||
timestamp: baseTs + i,
|
||||
@@ -93,6 +135,7 @@ export function createWorkflow<M extends RoleMeta>(
|
||||
const agentCtx: AgentContext<M> = {
|
||||
...modCtx,
|
||||
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
|
||||
cas: options.cas,
|
||||
};
|
||||
|
||||
const agent = binding.overrides?.[next] ?? binding.agent;
|
||||
@@ -104,27 +147,36 @@ export function createWorkflow<M extends RoleMeta>(
|
||||
agentContent: raw,
|
||||
};
|
||||
|
||||
const meta = await extract(
|
||||
roleDef.schema,
|
||||
roleDef.extractPrompt,
|
||||
extractCtx as unknown as ExtractContext,
|
||||
const meta = await resolveRoleMeta(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx,
|
||||
extract,
|
||||
llmProvider,
|
||||
options.cas,
|
||||
);
|
||||
|
||||
const refs = resolveExtractedRefs(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
meta,
|
||||
const contentHash = await putContentMerkleNode(options.cas, raw);
|
||||
|
||||
const refs = mergeRefsWithContentHash(
|
||||
resolveExtractedRefs(roleDef as unknown as RoleDefinition<Record<string, unknown>>, meta),
|
||||
contentHash,
|
||||
);
|
||||
|
||||
const ts = Date.now();
|
||||
const step = {
|
||||
role: next,
|
||||
content: raw,
|
||||
contentHash,
|
||||
meta,
|
||||
refs,
|
||||
timestamp: ts,
|
||||
} as RoleStep<M>;
|
||||
|
||||
yield { role: step.role, content: step.content, meta: step.meta, refs: step.refs };
|
||||
yield {
|
||||
role: step.role,
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: step.refs,
|
||||
};
|
||||
|
||||
steps = [...steps, step];
|
||||
}
|
||||
|
||||
+127
-16
@@ -1,21 +1,30 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import type { CasStore } from "./cas.js";
|
||||
import type { LogFn } from "./logger.js";
|
||||
import { getContentMerklePayload, putStepMerkleNode, putThreadMerkleNode } from "./merkle.js";
|
||||
import { normalizeRefsField } from "./refs-field.js";
|
||||
import type { ThreadInput, WorkflowFn, WorkflowFnOptions, WorkflowResult } from "./types.js";
|
||||
import type {
|
||||
ThreadInput,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowFnOptions,
|
||||
WorkflowResult,
|
||||
} from "./types.js";
|
||||
|
||||
export type ExecuteThreadIo = {
|
||||
threadId: string;
|
||||
hash: string;
|
||||
dataJsonlPath: string;
|
||||
infoJsonlPath: string;
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
/** One persisted role line in `.data.jsonl` (engine adds these for fork replay before running the generator). */
|
||||
export type PrefilledDiskStep = {
|
||||
role: string;
|
||||
content: string;
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[];
|
||||
timestamp: number;
|
||||
@@ -42,51 +51,123 @@ async function appendDataLine(path: string, record: unknown): Promise<void> {
|
||||
await appendFile(path, line, "utf8");
|
||||
}
|
||||
|
||||
async function finalizeThreadResult(params: {
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
threadId: string;
|
||||
stepMerkleHashes: readonly string[];
|
||||
completion: WorkflowCompletion;
|
||||
}): Promise<WorkflowResult> {
|
||||
const rootHash = await putThreadMerkleNode(
|
||||
params.cas,
|
||||
{
|
||||
workflow: params.workflowName,
|
||||
threadId: params.threadId,
|
||||
result: {
|
||||
returnCode: params.completion.returnCode,
|
||||
summary: params.completion.summary,
|
||||
},
|
||||
},
|
||||
params.stepMerkleHashes,
|
||||
);
|
||||
return {
|
||||
returnCode: params.completion.returnCode,
|
||||
summary: params.completion.summary,
|
||||
rootHash,
|
||||
};
|
||||
}
|
||||
|
||||
async function driveWorkflowGenerator(params: {
|
||||
fn: WorkflowFn;
|
||||
workflowName: string;
|
||||
input: ThreadInput;
|
||||
bundleOptions: WorkflowFnOptions;
|
||||
executeOptions: ExecuteThreadOptions;
|
||||
dataJsonlPath: string;
|
||||
threadId: string;
|
||||
logger: LogFn;
|
||||
cas: CasStore;
|
||||
stepMerkleHashes: string[];
|
||||
}): Promise<WorkflowResult> {
|
||||
const { fn, input, bundleOptions, executeOptions, dataJsonlPath, threadId, logger } = params;
|
||||
const {
|
||||
fn,
|
||||
workflowName,
|
||||
input,
|
||||
bundleOptions,
|
||||
executeOptions,
|
||||
dataJsonlPath,
|
||||
threadId,
|
||||
logger,
|
||||
cas,
|
||||
stepMerkleHashes,
|
||||
} = params;
|
||||
const gen = fn(input, bundleOptions);
|
||||
let written = 0;
|
||||
|
||||
while (true) {
|
||||
if (executeOptions.signal.aborted) {
|
||||
logger("V8JX4NP2", `thread ${threadId} aborted`);
|
||||
return { returnCode: 130, summary: "thread aborted" };
|
||||
return await finalizeThreadResult({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
});
|
||||
}
|
||||
|
||||
if (written >= executeOptions.maxRounds) {
|
||||
logger("R3CW7YBQ", `thread ${threadId} stopped at maxRounds=${executeOptions.maxRounds}`);
|
||||
return {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${executeOptions.maxRounds})`,
|
||||
};
|
||||
return await finalizeThreadResult({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion: {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${executeOptions.maxRounds})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const iterResult = await gen.next();
|
||||
|
||||
if (iterResult.done) {
|
||||
logger("F3HN8QKP", `thread ${threadId} generator finished`);
|
||||
return iterResult.value;
|
||||
const completion = iterResult.value;
|
||||
return await finalizeThreadResult({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion,
|
||||
});
|
||||
}
|
||||
|
||||
written++;
|
||||
const step = iterResult.value;
|
||||
const resolved = await getContentMerklePayload(cas, step.contentHash);
|
||||
if (resolved === null) {
|
||||
throw new Error(
|
||||
`role step ${step.role}: CAS blob missing for contentHash ${step.contentHash}`,
|
||||
);
|
||||
}
|
||||
const ts = Date.now();
|
||||
await appendDataLine(dataJsonlPath, {
|
||||
role: step.role,
|
||||
content: step.content,
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: normalizeRefsField(step.refs),
|
||||
timestamp: ts,
|
||||
});
|
||||
|
||||
const stepNodeHash = await putStepMerkleNode(
|
||||
cas,
|
||||
{ role: step.role, meta: step.meta },
|
||||
step.contentHash,
|
||||
);
|
||||
stepMerkleHashes.push(stepNodeHash);
|
||||
|
||||
logger("N7BW4YHQ", `thread ${threadId} wrote role ${step.role}`);
|
||||
|
||||
await Promise.race([
|
||||
@@ -102,7 +183,13 @@ async function driveWorkflowGenerator(params: {
|
||||
|
||||
if (executeOptions.signal.aborted) {
|
||||
logger("V8JX4NP4", `thread ${threadId} aborted`);
|
||||
return { returnCode: 130, summary: "thread aborted" };
|
||||
return await finalizeThreadResult({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,39 +238,63 @@ export async function executeThread(
|
||||
|
||||
logger("T9HQ2KHM", `thread ${io.threadId} started for workflow ${workflowName}`);
|
||||
|
||||
const stepMerkleHashes: string[] = [];
|
||||
|
||||
if (prefilled !== null) {
|
||||
for (const row of prefilled) {
|
||||
const prefilledPayload = await getContentMerklePayload(io.cas, row.contentHash);
|
||||
if (prefilledPayload === null) {
|
||||
throw new Error(
|
||||
`prefilled step ${row.role}: CAS blob missing for contentHash ${row.contentHash}`,
|
||||
);
|
||||
}
|
||||
await appendDataLine(io.dataJsonlPath, {
|
||||
role: row.role,
|
||||
content: row.content,
|
||||
contentHash: row.contentHash,
|
||||
meta: row.meta,
|
||||
refs: normalizeRefsField(row.refs),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
const stepNodeHash = await putStepMerkleNode(
|
||||
io.cas,
|
||||
{ role: row.role, meta: row.meta },
|
||||
row.contentHash,
|
||||
);
|
||||
stepMerkleHashes.push(stepNodeHash);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.maxRounds <= 0) {
|
||||
logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`);
|
||||
return {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||
};
|
||||
return await finalizeThreadResult({
|
||||
cas: io.cas,
|
||||
workflowName,
|
||||
threadId: io.threadId,
|
||||
stepMerkleHashes,
|
||||
completion: {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const bundleOptions: WorkflowFnOptions = {
|
||||
threadId: io.threadId,
|
||||
maxRounds: options.maxRounds,
|
||||
depth: options.depth,
|
||||
cas: io.cas,
|
||||
};
|
||||
|
||||
return await driveWorkflowGenerator({
|
||||
fn,
|
||||
workflowName,
|
||||
input,
|
||||
bundleOptions,
|
||||
executeOptions: options,
|
||||
dataJsonlPath: io.dataJsonlPath,
|
||||
threadId: io.threadId,
|
||||
logger,
|
||||
cas: io.cas,
|
||||
stepMerkleHashes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mkdir, readlink, symlink, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** This module lives in `@uncaged/workflow/src`; parent dir is the package root. */
|
||||
function installedWorkflowPackageDir(): string {
|
||||
return fileURLToPath(new URL("..", import.meta.url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures `<storageRoot>/node_modules/@uncaged/workflow` points at the installed `@uncaged/workflow`
|
||||
* package so workflow bundles loaded from `<storageRoot>/bundles/*.esm.js` can resolve `import "@uncaged/workflow"`.
|
||||
*/
|
||||
export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise<void> {
|
||||
const target = installedWorkflowPackageDir();
|
||||
const linkDir = path.join(storageRoot, "node_modules", "@uncaged");
|
||||
const linkPath = path.join(linkDir, "workflow");
|
||||
await mkdir(linkDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const existing = await readlink(linkPath);
|
||||
const normalizedExisting = path.resolve(linkDir, existing);
|
||||
if (normalizedExisting === target) {
|
||||
return;
|
||||
}
|
||||
await unlink(linkPath);
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code !== "ENOENT" && errObj.code !== "EINVAL") {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const linkType = process.platform === "win32" ? "junction" : "dir";
|
||||
await symlink(target, linkPath, linkType);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import type { WorkflowFn } from "./types.js";
|
||||
import type { WorkflowDescriptor } from "./workflow-descriptor.js";
|
||||
@@ -10,14 +10,23 @@ export type ExtractedBundleExports = {
|
||||
descriptor: WorkflowDescriptor;
|
||||
};
|
||||
|
||||
export type ExtractBundleExportsOptions = {
|
||||
/** When set, ensures `node_modules/@uncaged/workflow` exists under this root before import. */
|
||||
storageRoot: string | null;
|
||||
};
|
||||
|
||||
/** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */
|
||||
export async function extractBundleExports(
|
||||
bundlePath: string,
|
||||
options: ExtractBundleExportsOptions = { storageRoot: null },
|
||||
): Promise<Result<ExtractedBundleExports, string>> {
|
||||
let modUnknown: unknown;
|
||||
try {
|
||||
if (options.storageRoot !== null) {
|
||||
await ensureUncagedWorkflowSymlink(options.storageRoot);
|
||||
}
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
modUnknown = await import(pathToFileURL(bundlePath).href);
|
||||
modUnknown = await importWorkflowBundleModule(bundlePath);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return err(`failed to import bundle: ${message}`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import { llmExtractWithRetry } from "./llm-extract.js";
|
||||
import { getContentMerklePayload } from "./merkle.js";
|
||||
import type { ExtractContext, LlmProvider } from "./types.js";
|
||||
|
||||
export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
@@ -9,6 +10,40 @@ export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
ctx: ExtractContext,
|
||||
) => Promise<T>;
|
||||
|
||||
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
|
||||
export async function buildExtractUserContent(
|
||||
ctx: ExtractContext,
|
||||
prompt: string,
|
||||
): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
lines.push(`## Role: ${ctx.currentRole.name}`);
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push("");
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
lines.push("");
|
||||
if (ctx.steps.length > 0) {
|
||||
lines.push("## Thread History");
|
||||
for (const step of ctx.steps) {
|
||||
const body = await getContentMerklePayload(ctx.cas, step.contentHash);
|
||||
if (body === null) {
|
||||
throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`);
|
||||
}
|
||||
lines.push(`### ${step.role}`);
|
||||
lines.push(body);
|
||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
lines.push("## Agent Output");
|
||||
lines.push(ctx.agentContent);
|
||||
lines.push("");
|
||||
lines.push("## Extraction Instruction");
|
||||
lines.push(prompt);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ExtractFn backed by an LLM provider.
|
||||
* Builds prompt text from {@link ExtractContext} plus `prompt` and calls structured extraction.
|
||||
@@ -19,29 +54,7 @@ export function createExtract(provider: LlmProvider): ExtractFn {
|
||||
prompt: string,
|
||||
ctx: ExtractContext,
|
||||
): Promise<T> => {
|
||||
const lines: string[] = [];
|
||||
lines.push(`## Role: ${ctx.currentRole.name}`);
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push("");
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
lines.push("");
|
||||
if (ctx.steps.length > 0) {
|
||||
lines.push("## Thread History");
|
||||
for (const step of ctx.steps) {
|
||||
lines.push(`### ${step.role}`);
|
||||
lines.push(step.content);
|
||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
lines.push("## Agent Output");
|
||||
lines.push(ctx.agentContent);
|
||||
lines.push("");
|
||||
lines.push("## Extraction Instruction");
|
||||
lines.push(prompt);
|
||||
|
||||
const text = lines.join("\n");
|
||||
const text = await buildExtractUserContent(ctx, prompt);
|
||||
const result = await llmExtractWithRetry({ text, schema, provider });
|
||||
if (!result.ok) {
|
||||
throw new Error(`extract failed: ${JSON.stringify(result.error)}`);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readWorkflowRegistry } from "./registry.js";
|
||||
import type { WorkflowConfig } from "./registry-types.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import { getDefaultWorkflowStorageRoot } from "./storage-root.js";
|
||||
import type { LlmProvider } from "./types.js";
|
||||
|
||||
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
|
||||
|
||||
export function getWorkflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
|
||||
if (config === null) {
|
||||
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
|
||||
}
|
||||
return config.maxDepth;
|
||||
}
|
||||
|
||||
/** Loads `config.extract` from workflow.yaml (apiKey already resolved at registry parse time). */
|
||||
export async function getExtractProvider(
|
||||
storageRoot: string | undefined,
|
||||
): Promise<Result<LlmProvider, string>> {
|
||||
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
|
||||
const regResult = await readWorkflowRegistry(root);
|
||||
if (!regResult.ok) {
|
||||
return err(regResult.error.message);
|
||||
}
|
||||
const cfg = regResult.value.config;
|
||||
if (cfg === null) {
|
||||
return err("workflow registry has no global config section");
|
||||
}
|
||||
const ex = cfg.extract;
|
||||
return ok({
|
||||
baseUrl: ex.baseUrl,
|
||||
apiKey: ex.apiKey,
|
||||
model: ex.model,
|
||||
});
|
||||
}
|
||||
@@ -19,14 +19,14 @@ function parseRoleLine(
|
||||
lineIndex: number,
|
||||
): Result<ForkHistoricalStep, string> {
|
||||
const role = obj.role;
|
||||
const content = obj.content;
|
||||
const contentHash = obj.contentHash;
|
||||
const meta = obj.meta;
|
||||
const timestamp = obj.timestamp;
|
||||
if (typeof role !== "string") {
|
||||
return err(`invalid role record at line ${lineIndex}: missing role`);
|
||||
}
|
||||
if (typeof content !== "string") {
|
||||
return err(`invalid role record at line ${lineIndex}: missing content`);
|
||||
if (typeof contentHash !== "string") {
|
||||
return err(`invalid role record at line ${lineIndex}: missing contentHash`);
|
||||
}
|
||||
if (meta === null || typeof meta !== "object") {
|
||||
return err(`invalid role record at line ${lineIndex}: missing meta`);
|
||||
@@ -36,7 +36,7 @@ function parseRoleLine(
|
||||
}
|
||||
return ok({
|
||||
role,
|
||||
content,
|
||||
contentHash,
|
||||
meta: meta as Record<string, unknown>,
|
||||
refs: normalizeRefsField(obj.refs),
|
||||
timestamp,
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
} from "./engine.js";
|
||||
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
|
||||
export { createExtract, type ExtractFn } from "./extract-fn.js";
|
||||
export { getExtractProvider } from "./extract-provider.js";
|
||||
export {
|
||||
buildForkPlan,
|
||||
type ForkHistoricalStep,
|
||||
@@ -41,6 +42,21 @@ export {
|
||||
type LoggerSink,
|
||||
} from "./logger.js";
|
||||
export {
|
||||
createContentMerkleNode,
|
||||
getContentMerklePayload,
|
||||
type MerkleNode,
|
||||
type MerkleNodeType,
|
||||
parseMerkleNode,
|
||||
putContentMerkleNode,
|
||||
putStepMerkleNode,
|
||||
putThreadMerkleNode,
|
||||
type StepMerklePayload,
|
||||
serializeMerkleNode,
|
||||
type ThreadMerklePayload,
|
||||
} from "./merkle.js";
|
||||
export { type ReactExtractArgs, reactExtract } from "./react-extract.js";
|
||||
export {
|
||||
type ExtractProviderConfig,
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
parseWorkflowRegistryYaml,
|
||||
@@ -49,6 +65,7 @@ export {
|
||||
rollbackWorkflowToHistoryHash,
|
||||
stringifyWorkflowRegistryYaml,
|
||||
unregisterWorkflow,
|
||||
type WorkflowConfig,
|
||||
type WorkflowHistoryEntry,
|
||||
type WorkflowRegistryEntry,
|
||||
type WorkflowRegistryFile,
|
||||
@@ -64,6 +81,7 @@ export {
|
||||
type AgentFn,
|
||||
END,
|
||||
type ExtractContext,
|
||||
type ExtractMode,
|
||||
type LlmProvider,
|
||||
type Moderator,
|
||||
type ModeratorContext,
|
||||
@@ -75,6 +93,7 @@ export {
|
||||
type StartStep,
|
||||
type ThreadContext,
|
||||
type ThreadInput,
|
||||
type WorkflowCompletion,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
type WorkflowFnOptions,
|
||||
|
||||
@@ -47,6 +47,21 @@ function readToolDescription(parametersSchema: Record<string, unknown>): string
|
||||
return "Extract structured data from the input text.";
|
||||
}
|
||||
|
||||
/** Builds OpenAI function-tool metadata from a Zod meta schema (same naming rules as single-shot extract). */
|
||||
export function extractFunctionToolFromZodSchema(schema: z.ZodType<unknown>): {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
} {
|
||||
const rawJsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
|
||||
const parameters = stripJsonSchemaMeta(rawJsonSchema);
|
||||
return {
|
||||
name: readToolName(parameters),
|
||||
description: readToolDescription(parameters),
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
function readToolArgumentsJson(parsed: unknown, previewSource: string): Result<string, LlmError> {
|
||||
if (!isRecord(parsed)) {
|
||||
return err({ kind: "invalid_response_json", message: "Top-level JSON is not an object" });
|
||||
@@ -124,10 +139,7 @@ export function llmErrorToCause(error: LlmError): Error {
|
||||
async function performLlmExtract<T>(
|
||||
options: LlmExtractArgs<T> & { userContent: string },
|
||||
): Promise<Result<T, LlmError>> {
|
||||
const rawJsonSchema = z.toJSONSchema(options.schema) as Record<string, unknown>;
|
||||
const parameters = stripJsonSchemaMeta(rawJsonSchema);
|
||||
const toolName = readToolName(parameters);
|
||||
const toolDescription = readToolDescription(parameters);
|
||||
const extractTool = extractFunctionToolFromZodSchema(options.schema);
|
||||
|
||||
const body = {
|
||||
model: options.provider.model,
|
||||
@@ -142,13 +154,13 @@ async function performLlmExtract<T>(
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: toolName,
|
||||
description: toolDescription,
|
||||
parameters,
|
||||
name: extractTool.name,
|
||||
description: extractTool.description,
|
||||
parameters: extractTool.parameters,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "function" as const, function: { name: toolName } },
|
||||
tool_choice: { type: "function" as const, function: { name: extractTool.name } },
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import type { CasStore } from "./cas.js";
|
||||
|
||||
export type MerkleNodeType = "content" | "step" | "thread";
|
||||
|
||||
export type MerkleNode = {
|
||||
type: MerkleNodeType;
|
||||
payload: string | Record<string, unknown>;
|
||||
children: string[];
|
||||
};
|
||||
|
||||
export function serializeMerkleNode(node: MerkleNode): string {
|
||||
return stringify(
|
||||
{ type: node.type, payload: node.payload, children: node.children },
|
||||
{ indent: 2 },
|
||||
);
|
||||
}
|
||||
|
||||
export function parseMerkleNode(yamlText: string): MerkleNode {
|
||||
const raw = parse(yamlText) as unknown;
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
throw new Error("merkle: YAML root must be an object");
|
||||
}
|
||||
const rec = raw as Record<string, unknown>;
|
||||
const type = rec.type;
|
||||
const payload = rec.payload;
|
||||
const children = rec.children;
|
||||
if (type !== "content" && type !== "step" && type !== "thread") {
|
||||
throw new Error("merkle: invalid or missing type");
|
||||
}
|
||||
if (typeof payload !== "string" && (payload === null || typeof payload !== "object")) {
|
||||
throw new Error("merkle: payload must be a string or object");
|
||||
}
|
||||
if (!Array.isArray(children)) {
|
||||
throw new Error("merkle: children must be an array");
|
||||
}
|
||||
const childHashes: string[] = [];
|
||||
for (const c of children) {
|
||||
if (typeof c !== "string") {
|
||||
throw new Error("merkle: child hash must be a string");
|
||||
}
|
||||
childHashes.push(c);
|
||||
}
|
||||
return {
|
||||
type,
|
||||
payload: typeof payload === "string" ? payload : (payload as Record<string, unknown>),
|
||||
children: childHashes,
|
||||
};
|
||||
}
|
||||
|
||||
export function createContentMerkleNode(payload: string): MerkleNode {
|
||||
return { type: "content", payload, children: [] };
|
||||
}
|
||||
|
||||
export type StepMerklePayload = {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ThreadMerklePayload = {
|
||||
workflow: string;
|
||||
threadId: string;
|
||||
result: {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Serializes a step Merkle node (role + meta + content child) and stores it in CAS. */
|
||||
export async function putStepMerkleNode(
|
||||
store: CasStore,
|
||||
payload: StepMerklePayload,
|
||||
contentHash: string,
|
||||
): Promise<string> {
|
||||
const node: MerkleNode = {
|
||||
type: "step",
|
||||
payload: { role: payload.role, meta: payload.meta },
|
||||
children: [contentHash],
|
||||
};
|
||||
return store.put(serializeMerkleNode(node));
|
||||
}
|
||||
|
||||
/** Serializes the thread root Merkle node and stores it in CAS. */
|
||||
export async function putThreadMerkleNode(
|
||||
store: CasStore,
|
||||
payload: ThreadMerklePayload,
|
||||
stepHashes: readonly string[],
|
||||
): Promise<string> {
|
||||
const node: MerkleNode = {
|
||||
type: "thread",
|
||||
payload: {
|
||||
workflow: payload.workflow,
|
||||
threadId: payload.threadId,
|
||||
result: payload.result,
|
||||
},
|
||||
children: [...stepHashes],
|
||||
};
|
||||
return store.put(serializeMerkleNode(node));
|
||||
}
|
||||
|
||||
/** Serializes a content Merkle node and stores it in CAS; returns its hash. */
|
||||
export async function putContentMerkleNode(store: CasStore, content: string): Promise<string> {
|
||||
const yamlText = serializeMerkleNode(createContentMerkleNode(content));
|
||||
return store.put(yamlText);
|
||||
}
|
||||
|
||||
/** Loads a CAS blob and returns the payload string for a `content` Merkle node. */
|
||||
export async function getContentMerklePayload(
|
||||
store: CasStore,
|
||||
hash: string,
|
||||
): Promise<string | null> {
|
||||
const yamlText = await store.get(hash);
|
||||
if (yamlText === null) {
|
||||
return null;
|
||||
}
|
||||
const node = parseMerkleNode(yamlText);
|
||||
if (node.type !== "content" || typeof node.payload !== "string") {
|
||||
return null;
|
||||
}
|
||||
return node.payload;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import type { CasStore } from "./cas.js";
|
||||
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import type { LlmProvider } from "./types.js";
|
||||
|
||||
export type ReactExtractArgs<T extends Record<string, unknown>> = {
|
||||
text: string;
|
||||
schema: z.ZodType<T>;
|
||||
provider: LlmProvider;
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
const MAX_REACT_ROUNDS = 10;
|
||||
|
||||
const CAS_GET_TOOL_DEFINITION = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "cas_get",
|
||||
description:
|
||||
"Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and children fields.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", description: "The CAS hash to retrieve" },
|
||||
},
|
||||
required: ["hash"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function chatCompletionsUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function tryParseJsonContent(content: string): unknown | null {
|
||||
const trimmed = content.trim();
|
||||
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
|
||||
const payload = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
|
||||
try {
|
||||
return JSON.parse(payload) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ToolCall = {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
};
|
||||
|
||||
type ChatMessage =
|
||||
| { role: "system"; content: string }
|
||||
| { role: "user"; content: string }
|
||||
| {
|
||||
role: "assistant";
|
||||
content: string | null;
|
||||
tool_calls: ToolCall[];
|
||||
}
|
||||
| { role: "tool"; tool_call_id: string; content: string };
|
||||
|
||||
type AssistantTurn<T> =
|
||||
| { kind: "plain_json"; value: T }
|
||||
| { kind: "tool_calls"; calls: ToolCall[]; assistantContent: string | null };
|
||||
|
||||
function firstAssistantMessage(responseText: string): Result<Record<string, unknown>, string> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(responseText) as unknown;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err(`invalid_response_json:${message}`);
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
return err("invalid_response_top_level");
|
||||
}
|
||||
const choices = parsed.choices;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
return err("no_choices_in_response");
|
||||
}
|
||||
const firstChoice = choices[0];
|
||||
if (!isRecord(firstChoice)) {
|
||||
return err("invalid_choice");
|
||||
}
|
||||
const messageObj = firstChoice.message;
|
||||
if (!isRecord(messageObj)) {
|
||||
return err("invalid_message");
|
||||
}
|
||||
return ok(messageObj);
|
||||
}
|
||||
|
||||
function normalizeToolCalls(toolCallsRaw: unknown[]): Result<ToolCall[], string> {
|
||||
const toolCalls: ToolCall[] = [];
|
||||
for (const tc of toolCallsRaw) {
|
||||
if (!isRecord(tc)) {
|
||||
return err("invalid_tool_call");
|
||||
}
|
||||
const id = tc.id;
|
||||
const tcType = tc.type;
|
||||
const fn = tc.function;
|
||||
if (typeof id !== "string" || tcType !== "function" || !isRecord(fn)) {
|
||||
return err("invalid_tool_call_shape");
|
||||
}
|
||||
const name = fn.name;
|
||||
const argumentsStr = fn.arguments;
|
||||
if (typeof name !== "string" || typeof argumentsStr !== "string") {
|
||||
return err("invalid_tool_call_function");
|
||||
}
|
||||
toolCalls.push({ id, type: "function", function: { name, arguments: argumentsStr } });
|
||||
}
|
||||
return ok(toolCalls);
|
||||
}
|
||||
|
||||
function classifyAssistantTurn<T extends Record<string, unknown>>(
|
||||
messageObj: Record<string, unknown>,
|
||||
schema: z.ZodType<T>,
|
||||
): Result<AssistantTurn<T>, string> {
|
||||
const toolCallsRaw = messageObj.tool_calls;
|
||||
if (!Array.isArray(toolCallsRaw) || toolCallsRaw.length === 0) {
|
||||
const content = messageObj.content;
|
||||
if (typeof content !== "string") {
|
||||
return err("no_tool_calls_and_no_string_content");
|
||||
}
|
||||
const jsonParsed = tryParseJsonContent(content);
|
||||
if (jsonParsed === null) {
|
||||
return err("no_tool_calls_and_content_not_json");
|
||||
}
|
||||
const validated = schema.safeParse(jsonParsed);
|
||||
if (!validated.success) {
|
||||
return err(`schema_validation_failed:${validated.error.message}`);
|
||||
}
|
||||
return ok({ kind: "plain_json", value: validated.data });
|
||||
}
|
||||
const callsResult = normalizeToolCalls(toolCallsRaw);
|
||||
if (!callsResult.ok) {
|
||||
return err(callsResult.error);
|
||||
}
|
||||
const assistantContent = messageObj.content;
|
||||
return ok({
|
||||
kind: "tool_calls",
|
||||
calls: callsResult.value,
|
||||
assistantContent: typeof assistantContent === "string" ? assistantContent : null,
|
||||
});
|
||||
}
|
||||
|
||||
async function appendCasGetToolResult(
|
||||
tc: ToolCall,
|
||||
cas: CasStore,
|
||||
messages: ChatMessage[],
|
||||
): Promise<Result<null, string>> {
|
||||
let hash: string;
|
||||
try {
|
||||
const ta = JSON.parse(tc.function.arguments) as unknown;
|
||||
if (!isRecord(ta) || typeof ta.hash !== "string") {
|
||||
return err("cas_get_invalid_arguments");
|
||||
}
|
||||
hash = ta.hash;
|
||||
} catch {
|
||||
return err("cas_get_arguments_not_json");
|
||||
}
|
||||
const blob = await cas.get(hash);
|
||||
const toolContent = blob === null ? "null" : blob;
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: toolContent,
|
||||
});
|
||||
return ok(null);
|
||||
}
|
||||
|
||||
async function appendExtractToolResult<T extends Record<string, unknown>>(
|
||||
tc: ToolCall,
|
||||
schema: z.ZodType<T>,
|
||||
messages: ChatMessage[],
|
||||
): Promise<Result<T, string>> {
|
||||
let parsedArgs: unknown;
|
||||
try {
|
||||
parsedArgs = JSON.parse(tc.function.arguments) as unknown;
|
||||
} catch {
|
||||
return err("extract_tool_arguments_not_json");
|
||||
}
|
||||
const validated = schema.safeParse(parsedArgs);
|
||||
if (!validated.success) {
|
||||
return err(`schema_validation_failed:${validated.error.message}`);
|
||||
}
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: '{"ok":true}',
|
||||
});
|
||||
return ok(validated.data);
|
||||
}
|
||||
|
||||
async function appendToolResults<T extends Record<string, unknown>>(
|
||||
toolCalls: ToolCall[],
|
||||
extractToolName: string,
|
||||
schema: z.ZodType<T>,
|
||||
cas: CasStore,
|
||||
messages: ChatMessage[],
|
||||
): Promise<Result<T | null, string>> {
|
||||
let extracted: T | null = null;
|
||||
for (const tc of toolCalls) {
|
||||
if (tc.function.name === "cas_get") {
|
||||
const casRes = await appendCasGetToolResult(tc, cas, messages);
|
||||
if (!casRes.ok) {
|
||||
return casRes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (tc.function.name === extractToolName) {
|
||||
const exRes = await appendExtractToolResult(tc, schema, messages);
|
||||
if (!exRes.ok) {
|
||||
return exRes;
|
||||
}
|
||||
extracted = exRes.value;
|
||||
continue;
|
||||
}
|
||||
return err(`unknown_tool:${tc.function.name}`);
|
||||
}
|
||||
return ok(extracted);
|
||||
}
|
||||
|
||||
async function postChatCompletion(
|
||||
provider: LlmProvider,
|
||||
messages: ChatMessage[],
|
||||
tools: readonly Record<string, unknown>[],
|
||||
): Promise<Result<string, string>> {
|
||||
try {
|
||||
const response = await fetch(chatCompletionsUrl(provider.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
}),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
return err(`http_error:${String(response.status)}:${responseText.slice(0, 4000)}`);
|
||||
}
|
||||
return ok(responseText);
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err(`network_error:${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-turn ReAct extraction with `cas_get` plus a schema-shaped extract tool (OpenAI-compatible).
|
||||
* Final meta comes from a successful extract tool call or from plain JSON in the assistant message.
|
||||
*/
|
||||
export async function reactExtract<T extends Record<string, unknown>>(
|
||||
args: ReactExtractArgs<T>,
|
||||
): Promise<Result<T, string>> {
|
||||
const extractTool = extractFunctionToolFromZodSchema(args.schema);
|
||||
const tools = [
|
||||
CAS_GET_TOOL_DEFINITION,
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: extractTool.name,
|
||||
description: extractTool.description,
|
||||
parameters: extractTool.parameters,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const systemContent = `You extract structured metadata from the agent output below. Use cas_get to read Merkle DAG nodes from CAS (YAML: type, payload, children) when the agent output references hashes you must traverse. When you have the complete structured object, call the ${extractTool.name} tool with JSON arguments matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`;
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemContent },
|
||||
{ role: "user", content: args.text },
|
||||
];
|
||||
|
||||
for (let round = 0; round < MAX_REACT_ROUNDS; round++) {
|
||||
const bodyResult = await postChatCompletion(args.provider, messages, tools);
|
||||
if (!bodyResult.ok) {
|
||||
return bodyResult;
|
||||
}
|
||||
|
||||
const msgResult = firstAssistantMessage(bodyResult.value);
|
||||
if (!msgResult.ok) {
|
||||
return msgResult;
|
||||
}
|
||||
|
||||
const classified = classifyAssistantTurn(msgResult.value, args.schema);
|
||||
if (!classified.ok) {
|
||||
return classified;
|
||||
}
|
||||
|
||||
const turn = classified.value;
|
||||
if (turn.kind === "plain_json") {
|
||||
return ok(turn.value);
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: turn.assistantContent,
|
||||
tool_calls: turn.calls,
|
||||
});
|
||||
|
||||
const toolsRound = await appendToolResults(
|
||||
turn.calls,
|
||||
extractTool.name,
|
||||
args.schema,
|
||||
args.cas,
|
||||
messages,
|
||||
);
|
||||
if (!toolsRound.ok) {
|
||||
return toolsRound;
|
||||
}
|
||||
if (toolsRound.value !== null) {
|
||||
return ok(toolsRound.value);
|
||||
}
|
||||
}
|
||||
|
||||
return err("max_react_rounds_exceeded");
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
/** Append `contentHash` to `refs` when not already present (dedupe by first occurrence order). */
|
||||
export function mergeRefsWithContentHash(refs: string[], contentHash: string): string[] {
|
||||
const out = [...refs];
|
||||
if (!out.includes(contentHash)) {
|
||||
out.push(contentHash);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Normalize `refs` from persisted JSONL or IPC payloads (missing or invalid → []). */
|
||||
export function normalizeRefsField(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
|
||||
@@ -1,10 +1,68 @@
|
||||
import type {
|
||||
ExtractProviderConfig,
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
WorkflowRegistryEntry,
|
||||
WorkflowRegistryFile,
|
||||
} from "./registry-types.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
|
||||
function resolveRegistryApiKey(raw: string): Result<string, Error> {
|
||||
if (raw.startsWith("env:")) {
|
||||
const name = raw.slice("env:".length);
|
||||
if (name === "") {
|
||||
return err(new Error('config.extract.apiKey "env:" reference must name a variable'));
|
||||
}
|
||||
const value = process.env[name];
|
||||
if (value === undefined) {
|
||||
return err(new Error(`config.extract.apiKey: environment variable "${name}" is not set`));
|
||||
}
|
||||
return ok(value);
|
||||
}
|
||||
return ok(raw);
|
||||
}
|
||||
|
||||
function normalizeExtractProviderConfig(raw: unknown): Result<ExtractProviderConfig, Error> {
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return err(new Error('registry config must contain an "extract" mapping'));
|
||||
}
|
||||
const e = raw as Record<string, unknown>;
|
||||
const baseUrl = e.baseUrl;
|
||||
const model = e.model;
|
||||
const apiKeyRaw = e.apiKey;
|
||||
if (typeof baseUrl !== "string" || baseUrl === "") {
|
||||
return err(new Error("config.extract.baseUrl must be a non-empty string"));
|
||||
}
|
||||
if (typeof model !== "string" || model === "") {
|
||||
return err(new Error("config.extract.model must be a non-empty string"));
|
||||
}
|
||||
if (typeof apiKeyRaw !== "string" || apiKeyRaw === "") {
|
||||
return err(new Error("config.extract.apiKey must be a non-empty string"));
|
||||
}
|
||||
const apiKeyResult = resolveRegistryApiKey(apiKeyRaw);
|
||||
if (!apiKeyResult.ok) {
|
||||
return apiKeyResult;
|
||||
}
|
||||
return ok({ baseUrl, model, apiKey: apiKeyResult.value });
|
||||
}
|
||||
|
||||
function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> {
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return err(new Error('registry "config" must be a mapping'));
|
||||
}
|
||||
const c = raw as Record<string, unknown>;
|
||||
const maxDepth = c.maxDepth;
|
||||
const extractRaw = c.extract;
|
||||
if (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 0) {
|
||||
return err(new Error("config.maxDepth must be a non-negative integer"));
|
||||
}
|
||||
const extractResult = normalizeExtractProviderConfig(extractRaw);
|
||||
if (!extractResult.ok) {
|
||||
return extractResult;
|
||||
}
|
||||
return ok({ maxDepth, extract: extractResult.value });
|
||||
}
|
||||
|
||||
export function normalizeWorkflowHistoryEntry(
|
||||
workflowName: string,
|
||||
index: number,
|
||||
@@ -61,6 +119,15 @@ export function normalizeWorkflowRegistryRoot(raw: unknown): Result<WorkflowRegi
|
||||
return err(new Error("registry root must be a mapping"));
|
||||
}
|
||||
const root = raw as Record<string, unknown>;
|
||||
const configRaw = root.config;
|
||||
let config: WorkflowConfig | null = null;
|
||||
if (configRaw !== undefined && configRaw !== null) {
|
||||
const configResult = normalizeWorkflowConfig(configRaw);
|
||||
if (!configResult.ok) {
|
||||
return configResult;
|
||||
}
|
||||
config = configResult.value;
|
||||
}
|
||||
const workflowsRaw = root.workflows;
|
||||
if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") {
|
||||
return err(new Error('registry must contain a "workflows" mapping'));
|
||||
@@ -73,5 +140,5 @@ export function normalizeWorkflowRegistryRoot(raw: unknown): Result<WorkflowRegi
|
||||
}
|
||||
workflows[name] = entryResult.value;
|
||||
}
|
||||
return ok({ workflows });
|
||||
return ok({ config, workflows });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,19 @@ export type WorkflowRegistryEntry = {
|
||||
history: WorkflowHistoryEntry[];
|
||||
};
|
||||
|
||||
/** LLM provider settings under `config.extract` in workflow.yaml (apiKey resolved after parse). */
|
||||
export type ExtractProviderConfig = {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = {
|
||||
maxDepth: number;
|
||||
extract: ExtractProviderConfig;
|
||||
};
|
||||
|
||||
export type WorkflowRegistryFile = {
|
||||
config: WorkflowConfig | null;
|
||||
workflows: Record<string, WorkflowRegistryEntry>;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
|
||||
export type {
|
||||
ExtractProviderConfig,
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
WorkflowRegistryEntry,
|
||||
WorkflowRegistryFile,
|
||||
@@ -22,7 +24,7 @@ export function workflowRegistryPath(storageRoot: string): string {
|
||||
}
|
||||
|
||||
function emptyRegistry(): WorkflowRegistryFile {
|
||||
return { workflows: {} };
|
||||
return { config: null, workflows: {} };
|
||||
}
|
||||
|
||||
export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistryFile, Error> {
|
||||
@@ -103,6 +105,7 @@ export function registerWorkflowVersion(
|
||||
: [{ hash: prev.hash, timestamp: prev.timestamp }, ...baseHistory];
|
||||
const next: WorkflowRegistryEntry = { hash, timestamp, history };
|
||||
return {
|
||||
config: registry.config,
|
||||
workflows: { ...registry.workflows, [name]: next },
|
||||
};
|
||||
}
|
||||
@@ -150,5 +153,5 @@ export function unregisterWorkflow(
|
||||
return err(new Error(`workflow not registered: ${name}`));
|
||||
}
|
||||
const { [name]: _removed, ...rest } = registry.workflows;
|
||||
return ok({ workflows: rest });
|
||||
return ok({ config: registry.config, workflows: rest });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import type { CasStore } from "./cas.js";
|
||||
|
||||
/** Sentinel values for automaton control flow. */
|
||||
export const START = "__start__" as const;
|
||||
export const END = "__end__" as const;
|
||||
@@ -14,21 +16,30 @@ export type LlmProvider = {
|
||||
model: string;
|
||||
};
|
||||
|
||||
/** How the engine runs meta extraction for a role after the agent phase. */
|
||||
export type ExtractMode = "single" | "react";
|
||||
|
||||
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
|
||||
export type RoleOutput = {
|
||||
role: string;
|
||||
content: string;
|
||||
/** CAS hash of the serialized Merkle content node for this step's body text. */
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
/** CAS hashes produced or consumed by this step (for GC traceability). */
|
||||
refs: string[];
|
||||
};
|
||||
|
||||
/** What the workflow AsyncGenerator returns when done. */
|
||||
export type WorkflowResult = {
|
||||
/** Generator completion value from a workflow bundle (`run` export). Root hash is added by the engine. */
|
||||
export type WorkflowCompletion = {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
/** Final thread outcome from {@link executeThread}, including Merkle thread root CAS hash. */
|
||||
export type WorkflowResult = WorkflowCompletion & {
|
||||
rootHash: string;
|
||||
};
|
||||
|
||||
/** Input to a workflow — prompt plus optional historical steps for fork/resume. */
|
||||
export type ThreadInput = {
|
||||
prompt: string;
|
||||
@@ -41,13 +52,15 @@ export type WorkflowFnOptions = {
|
||||
maxRounds: number;
|
||||
/** Nesting depth for workflow-as-agent chains; root threads use `0`. */
|
||||
depth: number;
|
||||
/** Global CAS store for Merkle content blobs (role step bodies). */
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
|
||||
export type WorkflowFn = (
|
||||
input: ThreadInput,
|
||||
options: WorkflowFnOptions,
|
||||
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||
|
||||
/** Engine start frame: initial prompt + thread identity. */
|
||||
export type StartStep = {
|
||||
@@ -62,7 +75,7 @@ export type RoleStep<M extends RoleMeta> = {
|
||||
[K in keyof M & string]: {
|
||||
role: K;
|
||||
meta: M[K];
|
||||
content: string;
|
||||
contentHash: string;
|
||||
refs: string[];
|
||||
timestamp: number;
|
||||
};
|
||||
@@ -83,6 +96,7 @@ export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> &
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
/** Phase 3: Extractor runs — has agent output; the extraction instruction is a separate argument to the extract function. */
|
||||
@@ -110,6 +124,7 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
schema: z.ZodType<Meta>;
|
||||
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
|
||||
extractRefs: ((meta: Meta) => string[]) | null;
|
||||
extractMode: ExtractMode;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||
import { createCasStore } from "./cas.js";
|
||||
import type { PrefilledDiskStep } from "./engine.js";
|
||||
import { type ExecuteThreadIo, executeThread } from "./engine.js";
|
||||
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { normalizeRefsField } from "./refs-field.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import { getGlobalCasDir } from "./storage-root.js";
|
||||
import { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js";
|
||||
import type { RoleOutput, WorkflowFn } from "./types.js";
|
||||
|
||||
@@ -48,9 +51,9 @@ type ThreadHandle = {
|
||||
|
||||
function parseRoleOutputRecord(obj: Record<string, unknown>): RoleOutput | null {
|
||||
const role = obj.role;
|
||||
const content = obj.content;
|
||||
const contentHash = obj.contentHash;
|
||||
const meta = obj.meta;
|
||||
if (typeof role !== "string" || typeof content !== "string") {
|
||||
if (typeof role !== "string" || typeof contentHash !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (meta === null || typeof meta !== "object") {
|
||||
@@ -58,7 +61,7 @@ function parseRoleOutputRecord(obj: Record<string, unknown>): RoleOutput | null
|
||||
}
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
contentHash,
|
||||
meta: meta as Record<string, unknown>,
|
||||
refs: normalizeRefsField(obj.refs),
|
||||
};
|
||||
@@ -300,8 +303,9 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureUncagedWorkflowSymlink(storageRoot);
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const modUnknown: unknown = await import(pathToFileURL(bundlePath).href);
|
||||
const modUnknown: unknown = await importWorkflowBundleModule(bundlePath);
|
||||
const modRec = modUnknown as Record<string, unknown>;
|
||||
const runExport = modRec.run;
|
||||
if (!isWorkflowFnLike(runExport)) {
|
||||
@@ -315,6 +319,8 @@ async function main(): Promise<void> {
|
||||
let activeThreads = 0;
|
||||
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
const workerCtlPath = join(storageRoot, "workers", `${hash}.json`);
|
||||
|
||||
function cancelShutdownTimer(): void {
|
||||
@@ -363,6 +369,7 @@ async function main(): Promise<void> {
|
||||
hash,
|
||||
dataJsonlPath,
|
||||
infoJsonlPath,
|
||||
cas,
|
||||
};
|
||||
|
||||
const existing = threads.get(threadId);
|
||||
@@ -389,7 +396,7 @@ async function main(): Promise<void> {
|
||||
const ts = cmd.stepTimestamps?.[i];
|
||||
return {
|
||||
role: step.role,
|
||||
content: step.content,
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: normalizeRefsField(step.refs),
|
||||
timestamp: typeof ts === "number" && ts > 0 ? ts : baseTs + i,
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createCasStore } from "./cas.js";
|
||||
import { type ExecuteThreadIo, executeThread } from "./engine.js";
|
||||
import { extractBundleExports } from "./extract-bundle-exports.js";
|
||||
import { getWorkflowAsAgentMaxDepth } from "./extract-provider.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry.js";
|
||||
import { getDefaultWorkflowStorageRoot } from "./storage-root.js";
|
||||
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||
import type { AgentContext, AgentFn, ThreadInput } from "./types.js";
|
||||
import { generateUlid } from "./ulid.js";
|
||||
|
||||
/** Maximum `WorkflowFnOptions.depth` allowed for a child spawned via `workflowAsAgent`. */
|
||||
const WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
|
||||
|
||||
export type WorkflowAsAgentOptions = {
|
||||
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
|
||||
storageRoot: string | null;
|
||||
@@ -33,9 +32,6 @@ export function workflowAsAgent(
|
||||
): AgentFn {
|
||||
return async (ctx: AgentContext): Promise<string> => {
|
||||
const nextDepth = ctx.depth + 1;
|
||||
if (nextDepth > WORKFLOW_AS_AGENT_MAX_DEPTH) {
|
||||
return `ERROR: workflow-as-agent depth limit exceeded (max ${WORKFLOW_AS_AGENT_MAX_DEPTH})`;
|
||||
}
|
||||
|
||||
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
|
||||
|
||||
@@ -44,13 +40,18 @@ export function workflowAsAgent(
|
||||
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
|
||||
}
|
||||
|
||||
const maxDepth = getWorkflowAsAgentMaxDepth(registryResult.value.config);
|
||||
if (nextDepth > maxDepth) {
|
||||
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
|
||||
}
|
||||
|
||||
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
|
||||
if (entry === null) {
|
||||
return `ERROR: workflow "${workflowName}" not found in registry`;
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath);
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
|
||||
if (!bundleExportsResult.ok) {
|
||||
return `ERROR: ${bundleExportsResult.error}`;
|
||||
}
|
||||
@@ -69,6 +70,7 @@ export function workflowAsAgent(
|
||||
hash: entry.hash,
|
||||
dataJsonlPath,
|
||||
infoJsonlPath,
|
||||
cas: createCasStore(getGlobalCasDir(storageRoot)),
|
||||
};
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
@@ -90,7 +92,7 @@ export function workflowAsAgent(
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
return result.summary;
|
||||
return result.rootHash;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return `ERROR: ${message}`;
|
||||
|
||||
+3
-1
@@ -23,10 +23,12 @@
|
||||
{ "path": "packages/workflow-role-coder" },
|
||||
{ "path": "packages/workflow-role-planner" },
|
||||
{ "path": "packages/workflow-role-reviewer" },
|
||||
{ "path": "packages/workflow-role-tester" },
|
||||
{ "path": "packages/workflow-agent-cursor" },
|
||||
{ "path": "packages/workflow-agent-hermes" },
|
||||
{ "path": "packages/workflow-util-agent" },
|
||||
{ "path": "packages/cli-workflow" },
|
||||
{ "path": "packages/workflow-template-solve-issue" }
|
||||
{ "path": "packages/workflow-template-solve-issue" },
|
||||
{ "path": "packages/workflow-template-develop" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user