Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1a0a135d4 | |||
| 34e00bebdf | |||
| 33cf23ed01 | |||
| 94c719870f | |||
| 5af2d54e0f | |||
| e01c08dacb | |||
| f9d3d38008 |
+2
-2
@@ -5,8 +5,8 @@
|
||||
"packages/*"
|
||||
],
|
||||
"overrides": {
|
||||
"@uncaged/json-cas": "file:../json-cas/packages/json-cas",
|
||||
"@uncaged/json-cas-workflow": "file:../json-cas/packages/json-cas-workflow"
|
||||
"@uncaged/json-cas": "^0.1.0",
|
||||
"@uncaged/json-cas-workflow": "^0.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bunx tsc --build",
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
cmdJsonCasInit,
|
||||
cmdNodeGet,
|
||||
cmdNodeList,
|
||||
cmdNodeWalk,
|
||||
cmdWorkflowRegister,
|
||||
cmdWorkflowShow,
|
||||
formatNodeWalk,
|
||||
formatWorkflowShow,
|
||||
getJsonCasDir,
|
||||
} from "../src/commands/json-cas/index.js";
|
||||
|
||||
const SIMPLE_WORKFLOW = {
|
||||
name: "test-workflow",
|
||||
description: "A test workflow for CLI tests",
|
||||
roles: {
|
||||
analyst: {
|
||||
description: "Analyses the input",
|
||||
systemPrompt: "You are an analyst.",
|
||||
extractPrompt: "Extract the analysis.",
|
||||
schema: { type: "object", properties: { result: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
moderator: [{ from: "analyst", to: "__end__", when: null }],
|
||||
};
|
||||
|
||||
describe("json-cas CLI commands", () => {
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-json-cas-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("getJsonCasDir returns path under storageRoot", () => {
|
||||
const dir = getJsonCasDir(storageRoot);
|
||||
expect(dir).toBe(join(storageRoot, "json-cas"));
|
||||
});
|
||||
|
||||
test("init bootstraps the store and returns a workflow type hash", async () => {
|
||||
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
|
||||
expect(typeof workflowTypeHash).toBe("string");
|
||||
expect(workflowTypeHash.length).toBe(13);
|
||||
});
|
||||
|
||||
test("init is idempotent", async () => {
|
||||
const hash1 = await cmdJsonCasInit(storageRoot);
|
||||
const hash2 = await cmdJsonCasInit(storageRoot);
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
test("workflow register returns a hash", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const result = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
expect(typeof result.hash).toBe("string");
|
||||
expect(result.hash.length).toBe(13);
|
||||
});
|
||||
|
||||
test("workflow register is idempotent", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const r1 = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const r2 = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
expect(r1.hash).toBe(r2.hash);
|
||||
});
|
||||
|
||||
test("workflow show loads a registered workflow", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const wf = await cmdWorkflowShow(storageRoot, hash);
|
||||
|
||||
expect(wf).not.toBeNull();
|
||||
if (wf === null) return;
|
||||
|
||||
expect(wf.name).toBe("test-workflow");
|
||||
expect(wf.description).toBe("A test workflow for CLI tests");
|
||||
expect(Object.keys(wf.roles)).toContain("analyst");
|
||||
expect(wf.roles.analyst.systemPrompt).toBe("You are an analyst.");
|
||||
expect(wf.moderator).toHaveLength(1);
|
||||
expect(wf.moderator[0].from).toBe("analyst");
|
||||
});
|
||||
|
||||
test("workflow show returns null for unknown hash", async () => {
|
||||
await cmdJsonCasInit(storageRoot);
|
||||
const result = await cmdWorkflowShow(storageRoot, "AAAAAAAAAAAAA");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("formatWorkflowShow produces expected output", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const wf = await cmdWorkflowShow(storageRoot, hash);
|
||||
if (wf === null) throw new Error("workflow not found");
|
||||
|
||||
const output = formatWorkflowShow(hash, wf);
|
||||
expect(output).toContain("test-workflow");
|
||||
expect(output).toContain(hash);
|
||||
expect(output).toContain("analyst");
|
||||
expect(output).toContain("moderator:");
|
||||
});
|
||||
|
||||
test("node list returns hashes after registration", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const hashes = await cmdNodeList(storageRoot);
|
||||
|
||||
expect(hashes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("node list returns empty array for empty store", async () => {
|
||||
const hashes = await cmdNodeList(storageRoot);
|
||||
expect(hashes).toEqual([]);
|
||||
});
|
||||
|
||||
test("node get returns JSON for a known hash", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const json = await cmdNodeGet(storageRoot, hash);
|
||||
|
||||
expect(json).not.toBeNull();
|
||||
if (json === null) return;
|
||||
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
expect(parsed).toMatchObject({ payload: expect.anything() });
|
||||
});
|
||||
|
||||
test("node get returns null for unknown hash", async () => {
|
||||
const result = await cmdNodeGet(storageRoot, "AAAAAAAAAAAAA");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("node walk traverses the workflow DAG", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const entries = await cmdNodeWalk(storageRoot, hash);
|
||||
|
||||
expect(entries).not.toBeNull();
|
||||
if (entries === null) return;
|
||||
|
||||
// should include at least the workflow node, role node, and role-schema node
|
||||
expect(entries.length).toBeGreaterThanOrEqual(3);
|
||||
const hashes = entries.map((e) => e.hash);
|
||||
expect(hashes).toContain(hash);
|
||||
});
|
||||
|
||||
test("node walk returns null for unknown root", async () => {
|
||||
const result = await cmdNodeWalk(storageRoot, "AAAAAAAAAAAAA");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("formatNodeWalk produces output with node hashes", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const entries = await cmdNodeWalk(storageRoot, hash);
|
||||
if (entries === null) throw new Error("walk failed");
|
||||
|
||||
const output = formatNodeWalk(hash, entries);
|
||||
expect(output).toContain(`walk from: ${hash}`);
|
||||
expect(output).toContain(hash);
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,17 @@
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/json-cas": "^0.1.1",
|
||||
"@uncaged/json-cas-fs": "^0.1.1",
|
||||
"@uncaged/json-cas-workflow": "^0.1.1",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-json-def": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { dispatchConnect } from "./commands/connect/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { createJsonCasDispatcher } from "./commands/json-cas/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
@@ -43,6 +44,7 @@ const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
|
||||
const dispatchThread = createThreadDispatcher({ dispatchGroup });
|
||||
const dispatchCas = createCasDispatcher({ dispatchGroup });
|
||||
const dispatchInit = createInitDispatcher({ dispatchGroup });
|
||||
const dispatchJsonCas = createJsonCasDispatcher({ dispatchGroup });
|
||||
|
||||
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
|
||||
if (topic === undefined) {
|
||||
@@ -72,6 +74,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
connect: dispatchConnect,
|
||||
"json-cas": dispatchJsonCas,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CommandGroup } from "./cli-command-types.js";
|
||||
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
|
||||
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
|
||||
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { JSON_CAS_SUBCOMMAND_TABLE } from "./commands/json-cas/index.js";
|
||||
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
|
||||
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
|
||||
|
||||
@@ -52,6 +53,14 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
name: "setup",
|
||||
commands: [...SETUP_USAGE_COMMANDS],
|
||||
},
|
||||
{
|
||||
name: "json-cas",
|
||||
commands: Object.entries(JSON_CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
setup: "Configuration:",
|
||||
"json-cas": "JSON-CAS engine:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { cmdJsonCasInit } from "./init.js";
|
||||
import { cmdNodeGet } from "./node-get.js";
|
||||
import { cmdNodeList } from "./node-list.js";
|
||||
import { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
import { cmdThreadShow, formatThreadShow } from "./thread-show.js";
|
||||
import type { JsonCasDispatchDeps } from "./types.js";
|
||||
import { cmdWorkflowRegister } from "./workflow-register.js";
|
||||
import { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
|
||||
|
||||
// ── node subcommands ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchNodeGet(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas node get requires <hash>");
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdNodeGet(storageRoot, hash);
|
||||
if (result === null) {
|
||||
printCliError(`error: node not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchNodeList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError("error: json-cas node list takes no arguments");
|
||||
return 1;
|
||||
}
|
||||
const hashes = await cmdNodeList(storageRoot);
|
||||
if (hashes.length === 0) {
|
||||
printCliLine("(no nodes)");
|
||||
return 0;
|
||||
}
|
||||
for (const hash of hashes) {
|
||||
printCliLine(hash);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchNodeWalk(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas node walk requires <hash>");
|
||||
return 1;
|
||||
}
|
||||
const entries = await cmdNodeWalk(storageRoot, hash);
|
||||
if (entries === null) {
|
||||
printCliError(`error: node not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatNodeWalk(hash, entries));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const JSON_CAS_NODE_TABLE: Record<string, CommandEntry> = {
|
||||
get: { handler: dispatchNodeGet, args: "<hash>", description: "Get a CAS node as JSON" },
|
||||
list: { handler: dispatchNodeList, args: "", description: "List all hashes in the store" },
|
||||
walk: {
|
||||
handler: dispatchNodeWalk,
|
||||
args: "<hash>",
|
||||
description: "Walk the DAG from a node, show referenced nodes",
|
||||
},
|
||||
};
|
||||
|
||||
// ── workflow subcommands ─────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchWorkflowRegister(
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
): Promise<number> {
|
||||
const file = argv[0];
|
||||
if (file === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas workflow register requires <file.json>");
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdWorkflowRegister(storageRoot, file);
|
||||
printCliLine(`registered workflow: ${result.hash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchWorkflowShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas workflow show requires <hash>");
|
||||
return 1;
|
||||
}
|
||||
const wf = await cmdWorkflowShow(storageRoot, hash);
|
||||
if (wf === null) {
|
||||
printCliError(`error: workflow not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatWorkflowShow(hash, wf));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const JSON_CAS_WORKFLOW_TABLE: Record<string, CommandEntry> = {
|
||||
register: {
|
||||
handler: dispatchWorkflowRegister,
|
||||
args: "<file.json>",
|
||||
description: "Register a workflow definition from a JSON file",
|
||||
},
|
||||
show: {
|
||||
handler: dispatchWorkflowShow,
|
||||
args: "<hash>",
|
||||
description: "Show a workflow by its CAS hash",
|
||||
},
|
||||
};
|
||||
|
||||
// ── thread subcommands ───────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas thread show requires <start-hash>");
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadShow(storageRoot, hash);
|
||||
if (result === null) {
|
||||
printCliError(`error: thread start node not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatThreadShow(result));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const JSON_CAS_THREAD_TABLE: Record<string, CommandEntry> = {
|
||||
show: {
|
||||
handler: dispatchThreadShow,
|
||||
args: "<start-hash>",
|
||||
description: "Walk and display thread steps from a thread-start hash",
|
||||
},
|
||||
};
|
||||
|
||||
// ── top-level json-cas subcommands ───────────────────────────────────────────
|
||||
|
||||
export async function dispatchJsonCasInit(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError("error: json-cas init takes no arguments");
|
||||
return 1;
|
||||
}
|
||||
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
|
||||
const dir = getJsonCasDir(storageRoot);
|
||||
printCliLine(`initialized json-cas store at ${dir}`);
|
||||
printCliLine(`workflow type hash: ${workflowTypeHash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function printSubgroupHelp(
|
||||
groupPath: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
sub: string | undefined,
|
||||
): number {
|
||||
if (sub === undefined || sub === "--help" || sub === "-h") {
|
||||
const lines = [`json-cas ${groupPath} subcommands:\n`];
|
||||
for (const [name, e] of Object.entries(table)) {
|
||||
const args = e.args ? ` ${e.args}` : "";
|
||||
lines.push(` uncaged-workflow json-cas ${groupPath} ${name}${args}`);
|
||||
lines.push(` ${e.description}\n`);
|
||||
}
|
||||
printCliLine(lines.join("\n"));
|
||||
return sub === undefined ? 1 : 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async function dispatchSubgroup(
|
||||
groupPath: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
): Promise<number> {
|
||||
const sub = argv[0];
|
||||
const helpResult = printSubgroupHelp(groupPath, table, sub);
|
||||
if (helpResult >= 0) return helpResult;
|
||||
|
||||
const entry = table[sub as string];
|
||||
if (entry === undefined) {
|
||||
printCliError(`error: unknown json-cas ${groupPath} subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
return entry.handler(storageRoot, argv.slice(1));
|
||||
}
|
||||
|
||||
export const JSON_CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
init: {
|
||||
handler: dispatchJsonCasInit,
|
||||
args: "",
|
||||
description: "Initialize json-cas store and register workflow schemas",
|
||||
},
|
||||
workflow: {
|
||||
handler: (storageRoot, argv) =>
|
||||
dispatchSubgroup("workflow", JSON_CAS_WORKFLOW_TABLE, storageRoot, argv),
|
||||
args: "<register|show>",
|
||||
description: "Manage json-cas workflow definitions",
|
||||
},
|
||||
thread: {
|
||||
handler: (storageRoot, argv) =>
|
||||
dispatchSubgroup("thread", JSON_CAS_THREAD_TABLE, storageRoot, argv),
|
||||
args: "<show>",
|
||||
description: "Inspect json-cas thread execution records",
|
||||
},
|
||||
node: {
|
||||
handler: (storageRoot, argv) =>
|
||||
dispatchSubgroup("node", JSON_CAS_NODE_TABLE, storageRoot, argv),
|
||||
args: "<get|list|walk>",
|
||||
description: "Low-level access to json-cas store nodes",
|
||||
},
|
||||
};
|
||||
|
||||
export function createJsonCasDispatcher(deps: JsonCasDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchJsonCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("json-cas", JSON_CAS_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`error: unknown json-cas subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
createJsonCasDispatcher,
|
||||
dispatchJsonCasInit,
|
||||
dispatchNodeGet,
|
||||
dispatchNodeList,
|
||||
dispatchNodeWalk,
|
||||
dispatchThreadShow,
|
||||
dispatchWorkflowRegister,
|
||||
dispatchWorkflowShow,
|
||||
JSON_CAS_NODE_TABLE,
|
||||
JSON_CAS_SUBCOMMAND_TABLE,
|
||||
JSON_CAS_THREAD_TABLE,
|
||||
JSON_CAS_WORKFLOW_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdJsonCasInit } from "./init.js";
|
||||
export { cmdNodeGet } from "./node-get.js";
|
||||
export { cmdNodeList } from "./node-list.js";
|
||||
export { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
|
||||
export { getJsonCasDir, openStore } from "./store.js";
|
||||
export type { ThreadShowResult, ThreadStep } from "./thread-show.js";
|
||||
export { cmdThreadShow, formatThreadShow } from "./thread-show.js";
|
||||
export type { JsonCasDispatchDeps } from "./types.js";
|
||||
export type { WorkflowRegisterResult } from "./workflow-register.js";
|
||||
export { cmdWorkflowRegister } from "./workflow-register.js";
|
||||
export { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
export async function cmdJsonCasInit(storageRoot: string): Promise<string> {
|
||||
const { typeHashes } = await openStore(storageRoot);
|
||||
return typeHashes.workflow;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
|
||||
export async function cmdNodeGet(storageRoot: string, hash: string): Promise<string | null> {
|
||||
const store = createFsStore(getJsonCasDir(storageRoot));
|
||||
const node = store.get(hash);
|
||||
if (node === null) return null;
|
||||
return JSON.stringify(node, null, 2);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
|
||||
export async function cmdNodeList(storageRoot: string): Promise<string[]> {
|
||||
const store = createFsStore(getJsonCasDir(storageRoot));
|
||||
return store.list();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CasNode, Hash } from "@uncaged/json-cas";
|
||||
import { walk } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
|
||||
export type WalkEntry = {
|
||||
hash: Hash;
|
||||
type: Hash;
|
||||
timestamp: number;
|
||||
payloadPreview: string;
|
||||
};
|
||||
|
||||
export async function cmdNodeWalk(
|
||||
storageRoot: string,
|
||||
rootHash: string,
|
||||
): Promise<WalkEntry[] | null> {
|
||||
const store = createFsStore(getJsonCasDir(storageRoot));
|
||||
|
||||
if (!store.has(rootHash)) return null;
|
||||
|
||||
const entries: WalkEntry[] = [];
|
||||
|
||||
walk(store, rootHash, (hash: Hash, node: CasNode) => {
|
||||
const preview = JSON.stringify(node.payload).slice(0, 100);
|
||||
entries.push({
|
||||
hash,
|
||||
type: node.type,
|
||||
timestamp: node.timestamp,
|
||||
payloadPreview: preview,
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function formatNodeWalk(rootHash: string, entries: WalkEntry[]): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`walk from: ${rootHash}`);
|
||||
lines.push(`nodes: ${entries.length}`);
|
||||
|
||||
for (const entry of entries) {
|
||||
lines.push("");
|
||||
lines.push(` hash: ${entry.hash}`);
|
||||
lines.push(` type: ${entry.type}`);
|
||||
lines.push(` time: ${new Date(entry.timestamp).toISOString()}`);
|
||||
lines.push(` data: ${entry.payloadPreview}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { join } from "node:path";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import { bootstrap } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
|
||||
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
|
||||
|
||||
export function getJsonCasDir(storageRoot: string): string {
|
||||
return join(storageRoot, "json-cas");
|
||||
}
|
||||
|
||||
export type OpenStoreResult = {
|
||||
store: Store;
|
||||
typeHashes: WorkflowSchemaHashes;
|
||||
};
|
||||
|
||||
export async function openStore(storageRoot: string): Promise<OpenStoreResult> {
|
||||
const dir = getJsonCasDir(storageRoot);
|
||||
const store = createFsStore(dir);
|
||||
await bootstrap(store);
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
return { store, typeHashes };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
ContentPayload,
|
||||
ThreadStepPayload,
|
||||
WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
type StepEntry = {
|
||||
hash: Hash;
|
||||
payload: ThreadStepPayload;
|
||||
};
|
||||
|
||||
function collectStepsInOrder(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
startHash: Hash,
|
||||
): StepEntry[] {
|
||||
const stepMap = new Map<Hash, StepEntry>();
|
||||
|
||||
for (const hash of store.list()) {
|
||||
const node = store.get(hash);
|
||||
if (node === null || node.type !== typeHashes.threadStep) continue;
|
||||
const p = node.payload as ThreadStepPayload;
|
||||
if (p.start === startHash) {
|
||||
stepMap.set(hash, { hash, payload: p });
|
||||
}
|
||||
}
|
||||
|
||||
if (stepMap.size === 0) return [];
|
||||
|
||||
// Find the terminal step: the one whose hash is not referenced as `previous` by any other step
|
||||
const previousSet = new Set<Hash>();
|
||||
for (const entry of stepMap.values()) {
|
||||
if (entry.payload.previous !== null) {
|
||||
previousSet.add(entry.payload.previous);
|
||||
}
|
||||
}
|
||||
|
||||
const terminalEntries = [...stepMap.values()].filter((e) => !previousSet.has(e.hash));
|
||||
if (terminalEntries.length === 0) return [...stepMap.values()];
|
||||
|
||||
// Walk backward from terminal to build ordered list
|
||||
const ordered: StepEntry[] = [];
|
||||
let current: StepEntry | undefined = terminalEntries[0];
|
||||
while (current !== undefined) {
|
||||
ordered.unshift(current);
|
||||
const prevHash = current.payload.previous;
|
||||
if (prevHash === null) break;
|
||||
current = stepMap.get(prevHash);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function getContentText(store: Store, contentHash: Hash): string {
|
||||
const node = store.get(contentHash);
|
||||
if (node === null) return "(not found)";
|
||||
const payload = node.payload as ContentPayload;
|
||||
return typeof payload.text === "string" ? payload.text : "(no text)";
|
||||
}
|
||||
|
||||
export type ThreadStep = {
|
||||
hash: Hash;
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
contentPreview: string;
|
||||
};
|
||||
|
||||
export type ThreadShowResult = {
|
||||
startHash: Hash;
|
||||
steps: ThreadStep[];
|
||||
};
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
startHash: string,
|
||||
): Promise<ThreadShowResult | null> {
|
||||
const { store, typeHashes } = await openStore(storageRoot);
|
||||
|
||||
const startNode = store.get(startHash);
|
||||
if (startNode === null) return null;
|
||||
|
||||
const entries = collectStepsInOrder(store, typeHashes, startHash);
|
||||
|
||||
const steps: ThreadStep[] = entries.map((entry) => {
|
||||
const rawText = getContentText(store, entry.payload.content);
|
||||
const preview = rawText.slice(0, 120).replace(/\n/g, " ");
|
||||
return {
|
||||
hash: entry.hash,
|
||||
role: entry.payload.role,
|
||||
meta: entry.payload.meta,
|
||||
contentPreview: preview,
|
||||
};
|
||||
});
|
||||
|
||||
return { startHash, steps };
|
||||
}
|
||||
|
||||
export function formatThreadShow(result: ThreadShowResult): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`thread: ${result.startHash}`);
|
||||
lines.push(`steps: ${result.steps.length}`);
|
||||
|
||||
for (let i = 0; i < result.steps.length; i++) {
|
||||
const step = result.steps[i];
|
||||
lines.push("");
|
||||
lines.push(` [${i + 1}] ${step.role} (${step.hash})`);
|
||||
if (Object.keys(step.meta).length > 0) {
|
||||
lines.push(` meta: ${JSON.stringify(step.meta)}`);
|
||||
}
|
||||
lines.push(` > ${step.contentPreview}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type JsonCasDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { WorkflowInput } from "@uncaged/workflow-json-def";
|
||||
import { registerWorkflow } from "@uncaged/workflow-json-def";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
export type WorkflowRegisterResult = {
|
||||
hash: string;
|
||||
};
|
||||
|
||||
export async function cmdWorkflowRegister(
|
||||
storageRoot: string,
|
||||
filePath: string,
|
||||
): Promise<WorkflowRegisterResult> {
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const workflowDef = JSON.parse(raw) as WorkflowInput;
|
||||
|
||||
const { store, typeHashes } = await openStore(storageRoot);
|
||||
const hash = await registerWorkflow(store, typeHashes, workflowDef);
|
||||
|
||||
return { hash };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { HydratedWorkflow } from "@uncaged/workflow-json-def";
|
||||
import { loadWorkflow } from "@uncaged/workflow-json-def";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
export async function cmdWorkflowShow(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<HydratedWorkflow | null> {
|
||||
const { store, typeHashes } = await openStore(storageRoot);
|
||||
return loadWorkflow(store, typeHashes, hash);
|
||||
}
|
||||
|
||||
export function formatWorkflowShow(hash: string, wf: HydratedWorkflow): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`workflow: ${wf.name}`);
|
||||
lines.push(`hash: ${hash}`);
|
||||
lines.push(`desc: ${wf.description}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("roles:");
|
||||
for (const [name, role] of Object.entries(wf.roles)) {
|
||||
lines.push(` ${name}:`);
|
||||
lines.push(` description: ${role.description}`);
|
||||
lines.push(` systemPrompt: ${role.systemPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
|
||||
lines.push(` extractPrompt: ${role.extractPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
|
||||
}
|
||||
|
||||
if (wf.moderator.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("moderator:");
|
||||
for (const rule of wf.moderator) {
|
||||
const when = rule.when === null ? "(always)" : `when: ${rule.when}`;
|
||||
lines.push(` ${rule.from} → ${rule.to} [${when}]`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/index.js";
|
||||
|
||||
describe("packageDescriptor", () => {
|
||||
test("has the correct package name", () => {
|
||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-cursor");
|
||||
});
|
||||
|
||||
test("has a non-empty version string", () => {
|
||||
expect(typeof packageDescriptor.version).toBe("string");
|
||||
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("capabilities is a non-empty array of strings", () => {
|
||||
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||
for (const cap of packageDescriptor.capabilities) {
|
||||
expect(typeof cap).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("configSchema is an object with type 'object'", () => {
|
||||
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||
});
|
||||
|
||||
test("configSchema requires 'command' and 'timeout'", () => {
|
||||
const required = packageDescriptor.configSchema.required as string[];
|
||||
expect(required).toContain("command");
|
||||
expect(required).toContain("timeout");
|
||||
});
|
||||
|
||||
test("configSchema properties include command, model, timeout, workspace", () => {
|
||||
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||
expect(props).toHaveProperty("command");
|
||||
expect(props).toHaveProperty("model");
|
||||
expect(props).toHaveProperty("timeout");
|
||||
expect(props).toHaveProperty("workspace");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { extractWorkspacePath } from "./extract-workspace.js";
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
import { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { CursorAgentConfig } from "./types.js";
|
||||
export { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
|
||||
|
||||
/**
|
||||
* Static metadata for @uncaged/workflow-agent-cursor.
|
||||
* Config maps to {@link CursorAgentConfig}.
|
||||
*/
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-cursor",
|
||||
version: "0.5.0-alpha.4",
|
||||
capabilities: ["cursor-cli", "workspace-agent"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["command", "timeout"],
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description: "Absolute path to the cursor-agent CLI binary.",
|
||||
},
|
||||
model: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Model identifier passed to cursor-agent --model; null means auto.",
|
||||
},
|
||||
timeout: {
|
||||
type: "number",
|
||||
description: "Timeout in milliseconds; 0 means no limit.",
|
||||
},
|
||||
workspace: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Override workspace path; null resolves from thread context.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/index.js";
|
||||
|
||||
describe("packageDescriptor", () => {
|
||||
test("has the correct package name", () => {
|
||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-hermes");
|
||||
});
|
||||
|
||||
test("has a non-empty version string", () => {
|
||||
expect(typeof packageDescriptor.version).toBe("string");
|
||||
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("capabilities is a non-empty array of strings", () => {
|
||||
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||
for (const cap of packageDescriptor.capabilities) {
|
||||
expect(typeof cap).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("configSchema is an object with type 'object'", () => {
|
||||
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||
});
|
||||
|
||||
test("configSchema requires 'command'", () => {
|
||||
const required = packageDescriptor.configSchema.required as string[];
|
||||
expect(required).toContain("command");
|
||||
});
|
||||
|
||||
test("configSchema properties include command, model, timeout", () => {
|
||||
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||
expect(props).toHaveProperty("command");
|
||||
expect(props).toHaveProperty("model");
|
||||
expect(props).toHaveProperty("timeout");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ const HERMES_DEFAULT_MAX_TURNS = 90;
|
||||
|
||||
type HermesAgentOpt = { prompt: string };
|
||||
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { HermesAgentConfig } from "./types.js";
|
||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
||||
|
||||
/**
|
||||
* Static metadata for @uncaged/workflow-agent-hermes.
|
||||
* Config maps to {@link HermesAgentConfig}.
|
||||
*/
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-hermes",
|
||||
version: "0.5.0-alpha.4",
|
||||
capabilities: ["hermes-cli", "yolo-mode"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["command"],
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description: "Absolute path to the hermes CLI binary.",
|
||||
},
|
||||
model: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Model identifier passed to hermes --model; null uses the CLI default.",
|
||||
},
|
||||
timeout: {
|
||||
anyOf: [{ type: "number" }, { type: "null" }],
|
||||
description: "Timeout in milliseconds; null means no limit.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/index.js";
|
||||
|
||||
describe("packageDescriptor", () => {
|
||||
test("has the correct package name", () => {
|
||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-llm");
|
||||
});
|
||||
|
||||
test("has a non-empty version string", () => {
|
||||
expect(typeof packageDescriptor.version).toBe("string");
|
||||
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("capabilities is a non-empty array of strings", () => {
|
||||
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||
for (const cap of packageDescriptor.capabilities) {
|
||||
expect(typeof cap).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("configSchema is an object with type 'object'", () => {
|
||||
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||
});
|
||||
|
||||
test("configSchema requires baseUrl, apiKey, model", () => {
|
||||
const required = packageDescriptor.configSchema.required as string[];
|
||||
expect(required).toContain("baseUrl");
|
||||
expect(required).toContain("apiKey");
|
||||
expect(required).toContain("model");
|
||||
});
|
||||
|
||||
test("configSchema properties include baseUrl, apiKey, model", () => {
|
||||
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||
expect(props).toHaveProperty("baseUrl");
|
||||
expect(props).toHaveProperty("apiKey");
|
||||
expect(props).toHaveProperty("model");
|
||||
});
|
||||
});
|
||||
@@ -4,3 +4,4 @@ export {
|
||||
type LlmChatError,
|
||||
type LlmMessage,
|
||||
} from "./create-llm-adapter.js";
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
||||
|
||||
/**
|
||||
* Static metadata for @uncaged/workflow-agent-llm.
|
||||
* Config maps to {@link LlmProvider}: baseUrl + apiKey + model.
|
||||
*/
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-llm",
|
||||
version: "0.5.0-alpha.4",
|
||||
capabilities: ["llm-single-turn"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["baseUrl", "apiKey", "model"],
|
||||
properties: {
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
description: "Base URL of the OpenAI-compatible chat completions endpoint.",
|
||||
},
|
||||
apiKey: {
|
||||
type: "string",
|
||||
description: "API key for the provider.",
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
description: "Model identifier passed as the `model` field in the request body.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/index.js";
|
||||
|
||||
describe("packageDescriptor", () => {
|
||||
test("has the correct package name", () => {
|
||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-react");
|
||||
});
|
||||
|
||||
test("has a non-empty version string", () => {
|
||||
expect(typeof packageDescriptor.version).toBe("string");
|
||||
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("capabilities is a non-empty array of strings", () => {
|
||||
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||
for (const cap of packageDescriptor.capabilities) {
|
||||
expect(typeof cap).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("configSchema is an object with type 'object'", () => {
|
||||
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||
});
|
||||
|
||||
test("configSchema requires maxRounds", () => {
|
||||
const required = packageDescriptor.configSchema.required as string[];
|
||||
expect(required).toContain("maxRounds");
|
||||
});
|
||||
|
||||
test("configSchema properties include maxRounds", () => {
|
||||
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||
expect(props).toHaveProperty("maxRounds");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export { createReactAdapter } from "./create-react-adapter.js";
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { ToolEntry, ToolHandler } from "./tools/index.js";
|
||||
export { defaultToolHandler, defaultTools } from "./tools/index.js";
|
||||
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
|
||||
|
||||
/**
|
||||
* Static metadata for @uncaged/workflow-agent-react.
|
||||
*
|
||||
* Config represents the serializable subset of {@link ReactAdapterConfig}.
|
||||
* The `llm` function and `toolHandler` are runtime constructs and are not
|
||||
* stored in the CAS agent node; only `maxRounds` is serializable.
|
||||
*/
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-react",
|
||||
version: "0.5.0-alpha.4",
|
||||
capabilities: ["react-loop", "tool-calling"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["maxRounds"],
|
||||
properties: {
|
||||
maxRounds: {
|
||||
type: "number",
|
||||
description: "Maximum number of LLM ↔ tool-call rounds before the loop is terminated.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,868 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore, type Store, walk } from "@uncaged/json-cas";
|
||||
import {
|
||||
type ContentPayload,
|
||||
registerWorkflowSchemas,
|
||||
type ThreadEndPayload,
|
||||
type ThreadStartPayload,
|
||||
type ThreadStepPayload,
|
||||
type WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
import { registerWorkflow, type WorkflowInput } from "@uncaged/workflow-json-def";
|
||||
|
||||
import {
|
||||
buildJsonCasThreadContext,
|
||||
buildJsonCasThreadSnapshot,
|
||||
readContentText,
|
||||
} from "../src/engine/json-cas-context.js";
|
||||
import { executeJsonCasThread } from "../src/engine/json-cas-engine.js";
|
||||
import type {
|
||||
JsonCasAgentFn,
|
||||
JsonCasEngineIo,
|
||||
JsonCasEngineOptions,
|
||||
} from "../src/engine/json-cas-types.js";
|
||||
|
||||
// ── Test fixtures ─────────────────────────────────────────────────────
|
||||
|
||||
const START = "__start__";
|
||||
const END = "__end__";
|
||||
|
||||
const SIMPLE_WORKFLOW: WorkflowInput = {
|
||||
name: "test-simple",
|
||||
description: "A simple two-role workflow for testing",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Plans the work",
|
||||
systemPrompt: "You are a planner.",
|
||||
extractPrompt: "Extract planner output.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["plan"],
|
||||
properties: { plan: { type: "string" } },
|
||||
},
|
||||
},
|
||||
coder: {
|
||||
description: "Implements the plan",
|
||||
systemPrompt: "You are a coder.",
|
||||
extractPrompt: "Extract coder output.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["code"],
|
||||
properties: { code: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: START, to: "planner", when: null },
|
||||
{ from: "planner", to: "coder", when: null },
|
||||
{ from: "coder", to: END, when: null },
|
||||
],
|
||||
};
|
||||
|
||||
const SINGLE_ROLE_WORKFLOW: WorkflowInput = {
|
||||
name: "test-single",
|
||||
description: "A single-role workflow",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Does all the work",
|
||||
systemPrompt: "You are a worker.",
|
||||
extractPrompt: "Extract worker output.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["result"],
|
||||
properties: { result: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: START, to: "worker", when: null },
|
||||
{ from: "worker", to: END, when: null },
|
||||
],
|
||||
};
|
||||
|
||||
const CONDITIONAL_WORKFLOW: WorkflowInput = {
|
||||
name: "test-conditional",
|
||||
description: "A workflow with JSONata conditions",
|
||||
roles: {
|
||||
checker: {
|
||||
description: "Checks the input",
|
||||
systemPrompt: "You are a checker.",
|
||||
extractPrompt: "Extract checker output.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["status"],
|
||||
properties: { status: { type: "string" } },
|
||||
},
|
||||
},
|
||||
fixer: {
|
||||
description: "Fixes issues",
|
||||
systemPrompt: "You are a fixer.",
|
||||
extractPrompt: "Extract fixer output.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["fix"],
|
||||
properties: { fix: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: START, to: "checker", when: null },
|
||||
{ from: "checker", to: END, when: "steps[-1].meta.status = 'ok'" },
|
||||
{ from: "checker", to: "fixer", when: null },
|
||||
{ from: "fixer", to: "checker", when: null },
|
||||
],
|
||||
};
|
||||
|
||||
function noLogger(): (tag: string, content: string) => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
async function setupStore(): Promise<{
|
||||
store: Store;
|
||||
typeHashes: WorkflowSchemaHashes;
|
||||
}> {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
return { store, typeHashes };
|
||||
}
|
||||
|
||||
async function setupWorkflow(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
workflowDef: WorkflowInput,
|
||||
) {
|
||||
const workflowHash = await registerWorkflow(store, typeHashes, workflowDef);
|
||||
return { workflowHash };
|
||||
}
|
||||
|
||||
function makeOptions(overrides: Partial<JsonCasEngineOptions> = {}): JsonCasEngineOptions {
|
||||
return {
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
signal: new AbortController().signal,
|
||||
agents: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIo(store: Store, typeHashes: WorkflowSchemaHashes, threadId: string): JsonCasEngineIo {
|
||||
return { threadId, store, typeHashes };
|
||||
}
|
||||
|
||||
/**
|
||||
* A mock agent that returns a canned text and meta for each role.
|
||||
*/
|
||||
function createMockAgent(
|
||||
responses: Record<string, { text: string; meta: Record<string, unknown> }>,
|
||||
): JsonCasAgentFn {
|
||||
return async (role, _systemPrompt, _snapshot) => {
|
||||
const resp = responses[role];
|
||||
if (resp === undefined) {
|
||||
throw new Error(`mock agent: no response configured for role "${role}"`);
|
||||
}
|
||||
return { ...resp, react: null };
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("executeJsonCasThread", () => {
|
||||
describe("thread lifecycle", () => {
|
||||
test("simple two-role workflow creates start, two steps, and end nodes", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "I will plan", meta: { plan: "phase-1" } },
|
||||
coder: { text: "I wrote code", meta: { code: "done" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "Build a widget",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD01"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(result.summary).toContain("END");
|
||||
expect(result.rootHash).toBeTruthy();
|
||||
|
||||
const endNode = store.get(result.rootHash);
|
||||
expect(endNode).not.toBeNull();
|
||||
const endPayload = endNode!.payload as ThreadEndPayload;
|
||||
expect(endPayload.returnCode).toBe(0);
|
||||
expect(endPayload.start).toBeTruthy();
|
||||
expect(endPayload.lastStep).toBeTruthy();
|
||||
});
|
||||
|
||||
test("single-role workflow creates correct chain", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
worker: { text: "work done", meta: { result: "success" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "Do the thing",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD02"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
|
||||
const endNode = store.get(result.rootHash);
|
||||
expect(endNode).not.toBeNull();
|
||||
const endPayload = endNode!.payload as ThreadEndPayload;
|
||||
|
||||
const lastStepNode = store.get(endPayload.lastStep);
|
||||
expect(lastStepNode).not.toBeNull();
|
||||
const lastStepPayload = lastStepNode!.payload as ThreadStepPayload;
|
||||
expect(lastStepPayload.role).toBe("worker");
|
||||
expect(lastStepPayload.previous).toBeNull();
|
||||
|
||||
const startNode = store.get(endPayload.start);
|
||||
expect(startNode).not.toBeNull();
|
||||
const startPayload = startNode!.payload as ThreadStartPayload;
|
||||
expect(startPayload.input).toBe("Do the thing");
|
||||
expect(startPayload.depth).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CAS node structure", () => {
|
||||
test("thread-start contains workflow ref, input, depth, agents", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
worker: { text: "ok", meta: { result: "ok" } },
|
||||
});
|
||||
|
||||
const agentHash = await store.put(typeHashes.agent, {
|
||||
package: "test-agent",
|
||||
version: "1.0.0",
|
||||
config: {},
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "Test input",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD03"),
|
||||
options: makeOptions({ agents: { worker: agentHash }, depth: 2 }),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const startPayload = store.get(endPayload.start)!.payload as ThreadStartPayload;
|
||||
|
||||
expect(startPayload.workflow).toBe(workflowHash);
|
||||
expect(startPayload.input).toBe("Test input");
|
||||
expect(startPayload.depth).toBe(2);
|
||||
expect(startPayload.parentThread).toBeNull();
|
||||
expect(startPayload.agents).toEqual({ worker: agentHash });
|
||||
});
|
||||
|
||||
test("thread-start records parentThread when provided", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
worker: { text: "nested", meta: { result: "nested" } },
|
||||
});
|
||||
|
||||
const fakeParent = "FAKEPARENT0001";
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "nested task",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD04"),
|
||||
options: makeOptions({ parentThread: fakeParent, depth: 1 }),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const startPayload = store.get(endPayload.start)!.payload as ThreadStartPayload;
|
||||
expect(startPayload.parentThread).toBe(fakeParent);
|
||||
expect(startPayload.depth).toBe(1);
|
||||
});
|
||||
|
||||
test("each thread-step has content, react, start, and previous refs", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan text", meta: { plan: "p1" } },
|
||||
coder: { text: "code text", meta: { code: "c1" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "go",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD05"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const startHash = endPayload.start;
|
||||
|
||||
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
expect(step2.role).toBe("coder");
|
||||
expect(step2.start).toBe(startHash);
|
||||
expect(step2.previous).not.toBeNull();
|
||||
|
||||
const contentNode2 = store.get(step2.content);
|
||||
expect(contentNode2).not.toBeNull();
|
||||
expect((contentNode2!.payload as ContentPayload).text).toBe("code text");
|
||||
|
||||
const reactNode2 = store.get(step2.react);
|
||||
expect(reactNode2).not.toBeNull();
|
||||
|
||||
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
|
||||
expect(step1.role).toBe("planner");
|
||||
expect(step1.start).toBe(startHash);
|
||||
expect(step1.previous).toBeNull();
|
||||
|
||||
const contentNode1 = store.get(step1.content);
|
||||
expect(contentNode1).not.toBeNull();
|
||||
expect((contentNode1!.payload as ContentPayload).text).toBe("plan text");
|
||||
});
|
||||
|
||||
test("thread-end references start and last step", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan", meta: { plan: "x" } },
|
||||
coder: { text: "code", meta: { code: "x" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "test",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD06"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
expect(endPayload.returnCode).toBe(0);
|
||||
expect(endPayload.summary).toBeTruthy();
|
||||
|
||||
const startNode = store.get(endPayload.start);
|
||||
expect(startNode).not.toBeNull();
|
||||
expect((startNode!.payload as ThreadStartPayload).workflow).toBe(workflowHash);
|
||||
|
||||
const lastStepNode = store.get(endPayload.lastStep);
|
||||
expect(lastStepNode).not.toBeNull();
|
||||
expect((lastStepNode!.payload as ThreadStepPayload).role).toBe("coder");
|
||||
});
|
||||
|
||||
test("content nodes store the agent text verbatim", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const longText = "This is a longer text with\nnewlines\nand special chars: <>&\"'";
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
worker: { text: longText, meta: { result: "done" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "process this",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD07"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const stepPayload = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
const contentPayload = store.get(stepPayload.content)!.payload as ContentPayload;
|
||||
expect(contentPayload.text).toBe(longText);
|
||||
});
|
||||
|
||||
test("meta is stored in thread-step payload", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const complexMeta = {
|
||||
plan: "phase-1",
|
||||
phases: [{ hash: "abc", title: "first" }],
|
||||
nested: { deep: true },
|
||||
};
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan", meta: complexMeta },
|
||||
coder: { text: "code", meta: { code: "done" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "go",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD08"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
|
||||
|
||||
expect(step1.meta).toEqual(complexMeta);
|
||||
expect(step2.meta).toEqual({ code: "done" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("moderator routing", () => {
|
||||
test("conditional moderator routes based on agent meta", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
|
||||
|
||||
let checkerCallCount = 0;
|
||||
const agentFn: JsonCasAgentFn = async (role, _sp, _snap) => {
|
||||
if (role === "checker") {
|
||||
checkerCallCount++;
|
||||
if (checkerCallCount === 1) {
|
||||
return { text: "found issue", meta: { status: "bad" }, react: null };
|
||||
}
|
||||
return { text: "all good now", meta: { status: "ok" }, react: null };
|
||||
}
|
||||
return { text: "fixed it", meta: { fix: "patched" }, react: null };
|
||||
};
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "check and fix",
|
||||
moderatorRules: CONDITIONAL_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD09"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(checkerCallCount).toBe(2);
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const lastStep = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
expect(lastStep.role).toBe("checker");
|
||||
|
||||
const step2 = store.get(lastStep.previous!)!.payload as ThreadStepPayload;
|
||||
expect(step2.role).toBe("fixer");
|
||||
|
||||
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
|
||||
expect(step1.role).toBe("checker");
|
||||
expect(step1.previous).toBeNull();
|
||||
});
|
||||
|
||||
test("immediate END from moderator still produces a valid thread", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
|
||||
const immediateEnd: WorkflowInput = {
|
||||
name: "test-immediate-end",
|
||||
description: "Ends immediately",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Never called",
|
||||
systemPrompt: "N/A",
|
||||
extractPrompt: "N/A",
|
||||
schema: { type: "object" },
|
||||
},
|
||||
},
|
||||
moderator: [{ from: START, to: END, when: null }],
|
||||
};
|
||||
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, immediateEnd);
|
||||
|
||||
const agentFn: JsonCasAgentFn = async (): Promise<never> => {
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "skip",
|
||||
moderatorRules: immediateEnd.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD10"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
|
||||
const endNode = store.get(result.rootHash);
|
||||
expect(endNode).not.toBeNull();
|
||||
const endPayload = endNode!.payload as ThreadEndPayload;
|
||||
expect(endPayload.start).toBeTruthy();
|
||||
expect(endPayload.lastStep).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("abort handling", () => {
|
||||
test("aborted signal produces returnCode 130", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
|
||||
const agentFn: JsonCasAgentFn = async (): Promise<never> => {
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "will abort",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD11"),
|
||||
options: makeOptions({ signal: ac.signal }),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(result.returnCode).toBe(130);
|
||||
expect(result.summary).toContain("abort");
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
expect(endPayload.returnCode).toBe(130);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent receives correct context", () => {
|
||||
test("agent receives role name, system prompt, and accumulated steps", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const { loadWorkflow } = await import("@uncaged/workflow-json-def");
|
||||
const hydrated = loadWorkflow(store, typeHashes, workflowHash);
|
||||
|
||||
const receivedCalls: Array<{
|
||||
role: string;
|
||||
systemPrompt: string;
|
||||
stepCount: number;
|
||||
input: string;
|
||||
}> = [];
|
||||
|
||||
const agentFn: JsonCasAgentFn = async (role, systemPrompt, snapshot) => {
|
||||
receivedCalls.push({
|
||||
role,
|
||||
systemPrompt,
|
||||
stepCount: snapshot.steps.length,
|
||||
input: snapshot.start.input,
|
||||
});
|
||||
return { text: `output for ${role}`, meta: {}, react: null };
|
||||
};
|
||||
|
||||
await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "my prompt",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD12"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: hydrated,
|
||||
});
|
||||
|
||||
expect(receivedCalls.length).toBe(2);
|
||||
|
||||
expect(receivedCalls[0]!.role).toBe("planner");
|
||||
expect(receivedCalls[0]!.systemPrompt).toBe("You are a planner.");
|
||||
expect(receivedCalls[0]!.stepCount).toBe(0);
|
||||
expect(receivedCalls[0]!.input).toBe("my prompt");
|
||||
|
||||
expect(receivedCalls[1]!.role).toBe("coder");
|
||||
expect(receivedCalls[1]!.systemPrompt).toBe("You are a coder.");
|
||||
expect(receivedCalls[1]!.stepCount).toBe(1);
|
||||
});
|
||||
|
||||
test("snapshot accumulates step meta from previous rounds", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
|
||||
|
||||
let round = 0;
|
||||
const snapshots: Array<{
|
||||
role: string;
|
||||
steps: readonly { role: string; meta: Record<string, unknown> }[];
|
||||
}> = [];
|
||||
|
||||
const agentFn: JsonCasAgentFn = async (role, _sp, snapshot) => {
|
||||
snapshots.push({ role, steps: [...snapshot.steps] });
|
||||
round++;
|
||||
if (role === "checker") {
|
||||
return round === 1
|
||||
? { text: "bad", meta: { status: "bad" }, react: null }
|
||||
: { text: "ok", meta: { status: "ok" }, react: null };
|
||||
}
|
||||
return { text: "fixed", meta: { fix: "yes" }, react: null };
|
||||
};
|
||||
|
||||
await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "go",
|
||||
moderatorRules: CONDITIONAL_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD13"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
expect(snapshots.length).toBe(3);
|
||||
|
||||
expect(snapshots[0]!.steps.length).toBe(0);
|
||||
|
||||
expect(snapshots[1]!.steps.length).toBe(1);
|
||||
expect(snapshots[1]!.steps[0]!.role).toBe("checker");
|
||||
expect(snapshots[1]!.steps[0]!.meta).toEqual({ status: "bad" });
|
||||
|
||||
expect(snapshots[2]!.steps.length).toBe(2);
|
||||
expect(snapshots[2]!.steps[0]!.role).toBe("checker");
|
||||
expect(snapshots[2]!.steps[1]!.role).toBe("fixer");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildJsonCasThreadSnapshot", () => {
|
||||
test("builds snapshot from start + step chain", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan text", meta: { plan: "alpha" } },
|
||||
coder: { text: "code text", meta: { code: "beta" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "build it",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD_SNAP"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const startHash = endPayload.start;
|
||||
const lastStepHash = endPayload.lastStep;
|
||||
|
||||
const snapshot = buildJsonCasThreadSnapshot(
|
||||
store,
|
||||
typeHashes,
|
||||
startHash,
|
||||
lastStepHash,
|
||||
"THREAD_SNAP",
|
||||
);
|
||||
|
||||
expect(snapshot.threadId).toBe("THREAD_SNAP");
|
||||
expect(snapshot.start.input).toBe("build it");
|
||||
expect(snapshot.start.workflowHash).toBe(workflowHash);
|
||||
expect(snapshot.steps.length).toBe(2);
|
||||
expect(snapshot.steps[0]!.role).toBe("planner");
|
||||
expect(snapshot.steps[0]!.meta).toEqual({ plan: "alpha" });
|
||||
expect(snapshot.steps[1]!.role).toBe("coder");
|
||||
expect(snapshot.steps[1]!.meta).toEqual({ code: "beta" });
|
||||
});
|
||||
|
||||
test("builds snapshot with null headStepHash (start only)", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const startHash = await store.put(typeHashes.threadStart, {
|
||||
workflow: workflowHash,
|
||||
input: "just started",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: {},
|
||||
});
|
||||
|
||||
const snapshot = buildJsonCasThreadSnapshot(store, typeHashes, startHash, null, "THREAD_SNAP2");
|
||||
|
||||
expect(snapshot.threadId).toBe("THREAD_SNAP2");
|
||||
expect(snapshot.start.input).toBe("just started");
|
||||
expect(snapshot.steps.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildJsonCasThreadContext", () => {
|
||||
test("builds a protocol-compatible ThreadContext", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan text", meta: { plan: "ctx-test" } },
|
||||
coder: { text: "code text", meta: { code: "ctx-done" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "context test",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD_CTX"),
|
||||
options: makeOptions({ depth: 3 }),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const ctx = buildJsonCasThreadContext(store, typeHashes, endPayload.start, endPayload.lastStep);
|
||||
|
||||
expect(ctx.threadId).toBe("");
|
||||
expect(ctx.depth).toBe(3);
|
||||
expect(ctx.bundleHash).toBe(workflowHash);
|
||||
expect(ctx.start.role).toBe("__start__");
|
||||
expect(ctx.start.content).toBe("context test");
|
||||
expect(ctx.steps.length).toBe(2);
|
||||
expect(ctx.steps[0]!.role).toBe("planner");
|
||||
expect(ctx.steps[0]!.meta).toEqual({ plan: "ctx-test" });
|
||||
expect(ctx.steps[1]!.role).toBe("coder");
|
||||
expect(ctx.steps[1]!.meta).toEqual({ code: "ctx-done" });
|
||||
});
|
||||
|
||||
test("context from start-only thread has empty steps", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const startHash = await store.put(typeHashes.threadStart, {
|
||||
workflow: workflowHash,
|
||||
input: "start only",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: {},
|
||||
});
|
||||
|
||||
const ctx = buildJsonCasThreadContext(store, typeHashes, startHash, null);
|
||||
|
||||
expect(ctx.start.content).toBe("start only");
|
||||
expect(ctx.steps.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readContentText", () => {
|
||||
test("reads text from a content node", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const hash = await store.put(typeHashes.content, { text: "hello world" });
|
||||
|
||||
const text = readContentText(store, hash);
|
||||
expect(text).toBe("hello world");
|
||||
});
|
||||
|
||||
test("returns null for missing hash", async () => {
|
||||
const { store } = await setupStore();
|
||||
const text = readContentText(store, "NONEXISTENT0001");
|
||||
expect(text).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CAS graph integrity", () => {
|
||||
test("all nodes are reachable via walk from thread-end", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
planner: { text: "plan", meta: { plan: "x" } },
|
||||
coder: { text: "code", meta: { code: "y" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "walk test",
|
||||
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD_WALK"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
walk(store, result.rootHash, (hash) => {
|
||||
visited.add(hash);
|
||||
});
|
||||
|
||||
expect(visited.has(result.rootHash)).toBe(true);
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
expect(visited.has(endPayload.start)).toBe(true);
|
||||
expect(visited.has(endPayload.lastStep)).toBe(true);
|
||||
|
||||
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
expect(visited.has(step2.content)).toBe(true);
|
||||
expect(visited.has(step2.react)).toBe(true);
|
||||
expect(visited.has(step2.start)).toBe(true);
|
||||
|
||||
if (step2.previous !== null) {
|
||||
expect(visited.has(step2.previous)).toBe(true);
|
||||
const step1 = store.get(step2.previous)!.payload as ThreadStepPayload;
|
||||
expect(visited.has(step1.content)).toBe(true);
|
||||
expect(visited.has(step1.react)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("react session nodes have empty structure when agent returns react: null", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
const agentFn = createMockAgent({
|
||||
worker: { text: "w", meta: { result: "r" } },
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "react check",
|
||||
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||
io: makeIo(store, typeHashes, "THREAD_REACT"),
|
||||
options: makeOptions(),
|
||||
agentFn,
|
||||
logger: noLogger(),
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const stepPayload = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||
const reactNode = store.get(stepPayload.react);
|
||||
|
||||
expect(reactNode).not.toBeNull();
|
||||
const reactPayload = reactNode!.payload as Record<string, unknown>;
|
||||
expect(reactPayload.turns).toEqual([]);
|
||||
expect(reactPayload.totalTokens).toBe(0);
|
||||
expect(reactPayload.durationMs).toBe(0);
|
||||
expect(reactPayload.role).toBe("worker");
|
||||
expect(typeof reactPayload.agent).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore } from "@uncaged/json-cas";
|
||||
import {
|
||||
type ContentPayload,
|
||||
type ReactSessionPayload,
|
||||
type ReactToolCallPayload,
|
||||
type ReactTurnPayload,
|
||||
registerWorkflowSchemas,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
|
||||
import { writeReactSession } from "../src/engine/json-cas-react-recorder.js";
|
||||
import type { ReactTrace } from "../src/engine/json-cas-types.js";
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
async function setupStore() {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
return { store, typeHashes };
|
||||
}
|
||||
|
||||
async function makeFakeAgent(
|
||||
store: Awaited<ReturnType<typeof setupStore>>["store"],
|
||||
typeHashes: Awaited<ReturnType<typeof setupStore>>["typeHashes"],
|
||||
) {
|
||||
return store.put(typeHashes.agent, {
|
||||
package: "test-agent",
|
||||
version: "1.0.0",
|
||||
config: {},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("writeReactSession", () => {
|
||||
describe("empty trace", () => {
|
||||
test("produces a react-session with zero turns", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = { turns: [], totalTokens: 0, durationMs: 0 };
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "worker",
|
||||
trace,
|
||||
});
|
||||
|
||||
const node = store.get(sessionHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as ReactSessionPayload;
|
||||
expect(payload.agent).toBe(agentHash);
|
||||
expect(payload.role).toBe("worker");
|
||||
expect(payload.turns).toEqual([]);
|
||||
expect(payload.totalTokens).toBe(0);
|
||||
expect(payload.durationMs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("single turn, no tool calls", () => {
|
||||
test("produces react-session → react-turn → content nodes", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = {
|
||||
turns: [
|
||||
{
|
||||
input: "What is 2+2?",
|
||||
output: "4",
|
||||
toolCalls: [],
|
||||
tokens: { input: 10, output: 5 },
|
||||
latencyMs: 200,
|
||||
},
|
||||
],
|
||||
totalTokens: 15,
|
||||
durationMs: 200,
|
||||
};
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "solver",
|
||||
trace,
|
||||
});
|
||||
|
||||
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||
expect(session.turns.length).toBe(1);
|
||||
expect(session.totalTokens).toBe(15);
|
||||
expect(session.durationMs).toBe(200);
|
||||
expect(session.role).toBe("solver");
|
||||
|
||||
const turnHash = session.turns[0]!;
|
||||
const turn = store.get(turnHash)!.payload as ReactTurnPayload;
|
||||
expect(turn.toolCalls).toEqual([]);
|
||||
expect(turn.tokens).toEqual({ input: 10, output: 5 });
|
||||
expect(turn.latencyMs).toBe(200);
|
||||
|
||||
const inputContent = store.get(turn.input)!.payload as ContentPayload;
|
||||
expect(inputContent.text).toBe("What is 2+2?");
|
||||
|
||||
const outputContent = store.get(turn.output)!.payload as ContentPayload;
|
||||
expect(outputContent.text).toBe("4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("single turn with tool calls", () => {
|
||||
test("serialises tool calls to react-tool-call → content nodes", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = {
|
||||
turns: [
|
||||
{
|
||||
input: "Search for cats",
|
||||
output: "Found 42 cats",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "search",
|
||||
arguments: '{"query":"cats"}',
|
||||
result: '{"count":42}',
|
||||
durationMs: 80,
|
||||
},
|
||||
],
|
||||
tokens: { input: 20, output: 10 },
|
||||
latencyMs: 350,
|
||||
},
|
||||
],
|
||||
totalTokens: 30,
|
||||
durationMs: 350,
|
||||
};
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "searcher",
|
||||
trace,
|
||||
});
|
||||
|
||||
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||
expect(turn.toolCalls.length).toBe(1);
|
||||
|
||||
const toolCall = store.get(turn.toolCalls[0]!)!.payload as ReactToolCallPayload;
|
||||
expect(toolCall.name).toBe("search");
|
||||
expect(toolCall.durationMs).toBe(80);
|
||||
|
||||
const argsContent = store.get(toolCall.arguments)!.payload as ContentPayload;
|
||||
expect(argsContent.text).toBe('{"query":"cats"}');
|
||||
|
||||
const resultContent = store.get(toolCall.result)!.payload as ContentPayload;
|
||||
expect(resultContent.text).toBe('{"count":42}');
|
||||
});
|
||||
|
||||
test("multiple tool calls in one turn are all recorded", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = {
|
||||
turns: [
|
||||
{
|
||||
input: "Do two things",
|
||||
output: "Done",
|
||||
toolCalls: [
|
||||
{ name: "tool_a", arguments: '{"x":1}', result: '"ok_a"', durationMs: 10 },
|
||||
{ name: "tool_b", arguments: '{"y":2}', result: '"ok_b"', durationMs: 20 },
|
||||
],
|
||||
tokens: { input: 5, output: 3 },
|
||||
latencyMs: 100,
|
||||
},
|
||||
],
|
||||
totalTokens: 8,
|
||||
durationMs: 100,
|
||||
};
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "doer",
|
||||
trace,
|
||||
});
|
||||
|
||||
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||
expect(turn.toolCalls.length).toBe(2);
|
||||
|
||||
const tc0 = store.get(turn.toolCalls[0]!)!.payload as ReactToolCallPayload;
|
||||
expect(tc0.name).toBe("tool_a");
|
||||
|
||||
const tc1 = store.get(turn.toolCalls[1]!)!.payload as ReactToolCallPayload;
|
||||
expect(tc1.name).toBe("tool_b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple turns", () => {
|
||||
test("each turn is stored as a separate react-turn node", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = {
|
||||
turns: [
|
||||
{
|
||||
input: "Round 1 prompt",
|
||||
output: "Round 1 response",
|
||||
toolCalls: [],
|
||||
tokens: { input: 10, output: 8 },
|
||||
latencyMs: 100,
|
||||
},
|
||||
{
|
||||
input: "Round 2 prompt",
|
||||
output: "Round 2 response",
|
||||
toolCalls: [],
|
||||
tokens: { input: 12, output: 6 },
|
||||
latencyMs: 120,
|
||||
},
|
||||
],
|
||||
totalTokens: 36,
|
||||
durationMs: 220,
|
||||
};
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "multi",
|
||||
trace,
|
||||
});
|
||||
|
||||
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||
expect(session.turns.length).toBe(2);
|
||||
expect(session.totalTokens).toBe(36);
|
||||
expect(session.durationMs).toBe(220);
|
||||
|
||||
// Turns must be distinct nodes
|
||||
expect(session.turns[0]).not.toBe(session.turns[1]);
|
||||
|
||||
const turn0 = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||
expect((store.get(turn0.input)!.payload as ContentPayload).text).toBe("Round 1 prompt");
|
||||
expect(turn0.tokens).toEqual({ input: 10, output: 8 });
|
||||
|
||||
const turn1 = store.get(session.turns[1]!)!.payload as ReactTurnPayload;
|
||||
expect((store.get(turn1.input)!.payload as ContentPayload).text).toBe("Round 2 prompt");
|
||||
expect(turn1.tokens).toEqual({ input: 12, output: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("token and duration values", () => {
|
||||
test("token counts and latency are preserved exactly", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = {
|
||||
turns: [
|
||||
{
|
||||
input: "p",
|
||||
output: "r",
|
||||
toolCalls: [],
|
||||
tokens: { input: 9999, output: 1234 },
|
||||
latencyMs: 5678,
|
||||
},
|
||||
],
|
||||
totalTokens: 11233,
|
||||
durationMs: 5678,
|
||||
};
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "counter",
|
||||
trace,
|
||||
});
|
||||
|
||||
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||
expect(session.totalTokens).toBe(11233);
|
||||
expect(session.durationMs).toBe(5678);
|
||||
|
||||
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||
expect(turn.tokens.input).toBe(9999);
|
||||
expect(turn.tokens.output).toBe(1234);
|
||||
expect(turn.latencyMs).toBe(5678);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeReactSession + executeJsonCasThread integration", () => {
|
||||
test("engine stores real react session when agent provides react trace", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { registerWorkflow } = await import("@uncaged/workflow-json-def");
|
||||
const { executeJsonCasThread } = await import("../src/engine/json-cas-engine.js");
|
||||
type JsonCasAgentFn = import("../src/engine/json-cas-types.js").JsonCasAgentFn;
|
||||
|
||||
const workflowHash = await registerWorkflow(store, typeHashes, {
|
||||
name: "react-test",
|
||||
description: "Tests react instrumentation",
|
||||
roles: {
|
||||
solver: {
|
||||
description: "Solves",
|
||||
systemPrompt: "Solve it.",
|
||||
extractPrompt: "Extract.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["answer"],
|
||||
properties: { answer: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: "__start__", to: "solver", when: null },
|
||||
{ from: "solver", to: "__end__", when: null },
|
||||
],
|
||||
});
|
||||
|
||||
const agentFn: JsonCasAgentFn = async () => ({
|
||||
text: "The answer is 42",
|
||||
meta: { answer: "42" },
|
||||
react: {
|
||||
turns: [
|
||||
{
|
||||
input: "Solve it. What is the answer?",
|
||||
output: "The answer is 42",
|
||||
toolCalls: [],
|
||||
tokens: { input: 15, output: 8 },
|
||||
latencyMs: 300,
|
||||
},
|
||||
],
|
||||
totalTokens: 23,
|
||||
durationMs: 300,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "What is the answer?",
|
||||
moderatorRules: [
|
||||
{ from: "__start__", to: "solver", when: null },
|
||||
{ from: "solver", to: "__end__", when: null },
|
||||
],
|
||||
io: { threadId: "REACT_INTEG", store, typeHashes },
|
||||
options: { depth: 0, parentThread: null, signal: new AbortController().signal, agents: {} },
|
||||
agentFn,
|
||||
logger: () => {},
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!
|
||||
.payload as import("@uncaged/json-cas-workflow").ThreadEndPayload;
|
||||
const stepPayload = store.get(endPayload.lastStep)!
|
||||
.payload as import("@uncaged/json-cas-workflow").ThreadStepPayload;
|
||||
const session = store.get(stepPayload.react)!.payload as ReactSessionPayload;
|
||||
|
||||
expect(session.turns.length).toBe(1);
|
||||
expect(session.totalTokens).toBe(23);
|
||||
expect(session.durationMs).toBe(300);
|
||||
expect(session.role).toBe("solver");
|
||||
|
||||
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||
expect(turn.tokens).toEqual({ input: 15, output: 8 });
|
||||
expect(turn.latencyMs).toBe(300);
|
||||
expect((store.get(turn.input)!.payload as ContentPayload).text).toBe(
|
||||
"Solve it. What is the answer?",
|
||||
);
|
||||
});
|
||||
|
||||
test("engine falls back to empty react-session when react is null", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { registerWorkflow } = await import("@uncaged/workflow-json-def");
|
||||
const { executeJsonCasThread } = await import("../src/engine/json-cas-engine.js");
|
||||
type JsonCasAgentFn = import("../src/engine/json-cas-types.js").JsonCasAgentFn;
|
||||
|
||||
const workflowHash = await registerWorkflow(store, typeHashes, {
|
||||
name: "null-react-test",
|
||||
description: "Tests null react fallback",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Works",
|
||||
systemPrompt: "Work.",
|
||||
extractPrompt: "Extract.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["result"],
|
||||
properties: { result: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: "__start__", to: "worker", when: null },
|
||||
{ from: "worker", to: "__end__", when: null },
|
||||
],
|
||||
});
|
||||
|
||||
const agentFn: JsonCasAgentFn = async () => ({
|
||||
text: "done",
|
||||
meta: { result: "done" },
|
||||
react: null,
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "do it",
|
||||
moderatorRules: [
|
||||
{ from: "__start__", to: "worker", when: null },
|
||||
{ from: "worker", to: "__end__", when: null },
|
||||
],
|
||||
io: { threadId: "NULL_REACT", store, typeHashes },
|
||||
options: { depth: 0, parentThread: null, signal: new AbortController().signal, agents: {} },
|
||||
agentFn,
|
||||
logger: () => {},
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!
|
||||
.payload as import("@uncaged/json-cas-workflow").ThreadEndPayload;
|
||||
const stepPayload = store.get(endPayload.lastStep)!
|
||||
.payload as import("@uncaged/json-cas-workflow").ThreadStepPayload;
|
||||
const session = store.get(stepPayload.react)!.payload as ReactSessionPayload;
|
||||
|
||||
expect(session.turns).toEqual([]);
|
||||
expect(session.totalTokens).toBe(0);
|
||||
expect(session.durationMs).toBe(0);
|
||||
expect(session.role).toBe("worker");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,9 @@
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-reactor": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/json-cas": "^0.1.0",
|
||||
"@uncaged/json-cas-workflow": "^0.1.0",
|
||||
"@uncaged/workflow-json-def": "workspace:^",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -7,6 +7,27 @@ export {
|
||||
walkStateFramesNewestFirst,
|
||||
} from "./fork-thread.js";
|
||||
export { garbageCollectCas } from "./gc.js";
|
||||
export {
|
||||
buildJsonCasThreadContext,
|
||||
buildJsonCasThreadSnapshot,
|
||||
readContentText,
|
||||
} from "./json-cas-context.js";
|
||||
export { executeJsonCasThread } from "./json-cas-engine.js";
|
||||
export { writeReactSession } from "./json-cas-react-recorder.js";
|
||||
export type {
|
||||
AgentBindings,
|
||||
JsonCasAgentFn,
|
||||
JsonCasAgentResult,
|
||||
JsonCasEngineIo,
|
||||
JsonCasEngineOptions,
|
||||
JsonCasStartSnapshot,
|
||||
JsonCasStepSnapshot,
|
||||
JsonCasThreadPauseGate,
|
||||
JsonCasThreadSnapshot,
|
||||
ReactToolCallTrace,
|
||||
ReactTrace,
|
||||
ReactTurnTrace,
|
||||
} from "./json-cas-types.js";
|
||||
export { createThreadPauseGate } from "./thread-pause-gate.js";
|
||||
export type { ThreadHistoryEntry, ThreadIndex, ThreadIndexEntry } from "./threads-index.js";
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
ContentPayload,
|
||||
ThreadStartPayload,
|
||||
ThreadStepPayload,
|
||||
WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
import type { ThreadContext } from "@uncaged/workflow-protocol";
|
||||
import { START } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { JsonCasStepSnapshot, JsonCasThreadSnapshot } from "./json-cas-types.js";
|
||||
|
||||
// ── Snapshot builder (lightweight, for agent & moderator) ─────────────
|
||||
|
||||
/**
|
||||
* Walk the thread-step chain backwards via `previous` refs, then reverse
|
||||
* to get chronological order. Returns a {@link JsonCasThreadSnapshot}.
|
||||
*/
|
||||
export function buildJsonCasThreadSnapshot(
|
||||
store: Store,
|
||||
_typeHashes: WorkflowSchemaHashes,
|
||||
startHash: Hash,
|
||||
headStepHash: Hash | null,
|
||||
threadId: string,
|
||||
): JsonCasThreadSnapshot {
|
||||
const startNode = store.get(startHash);
|
||||
if (startNode === null) {
|
||||
throw new Error(`buildJsonCasThreadSnapshot: missing thread-start node at ${startHash}`);
|
||||
}
|
||||
const startPayload = startNode.payload as ThreadStartPayload;
|
||||
|
||||
const steps: JsonCasStepSnapshot[] = [];
|
||||
|
||||
let cursor: Hash | null = headStepHash;
|
||||
while (cursor !== null) {
|
||||
const stepNode = store.get(cursor);
|
||||
if (stepNode === null) {
|
||||
throw new Error(`buildJsonCasThreadSnapshot: missing thread-step node at ${cursor}`);
|
||||
}
|
||||
const stepPayload = stepNode.payload as ThreadStepPayload;
|
||||
steps.push({
|
||||
role: stepPayload.role,
|
||||
meta: stepPayload.meta,
|
||||
contentHash: stepPayload.content,
|
||||
});
|
||||
cursor = stepPayload.previous;
|
||||
}
|
||||
|
||||
steps.reverse();
|
||||
|
||||
return {
|
||||
threadId,
|
||||
start: {
|
||||
input: startPayload.input,
|
||||
depth: startPayload.depth,
|
||||
workflowHash: startPayload.workflow,
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ThreadContext builder (protocol-compatible) ───────────────────────
|
||||
|
||||
/**
|
||||
* Build a full {@link ThreadContext} from a json-cas thread chain.
|
||||
* Reads the thread-start node, walks thread-step backwards, and resolves
|
||||
* content text from each step's content node.
|
||||
*
|
||||
* `bundleHash` is set from the workflow ref in the thread-start payload.
|
||||
* `threadId` is set to `""` — callers should overwrite when known.
|
||||
*/
|
||||
export function buildJsonCasThreadContext(
|
||||
store: Store,
|
||||
_typeHashes: WorkflowSchemaHashes,
|
||||
startHash: Hash,
|
||||
headStepHash: Hash | null,
|
||||
): ThreadContext {
|
||||
const startNode = store.get(startHash);
|
||||
if (startNode === null) {
|
||||
throw new Error(`buildJsonCasThreadContext: missing thread-start node at ${startHash}`);
|
||||
}
|
||||
const startPayload = startNode.payload as ThreadStartPayload;
|
||||
|
||||
const rawSteps: ThreadStepPayload[] = [];
|
||||
let cursor: Hash | null = headStepHash;
|
||||
while (cursor !== null) {
|
||||
const stepNode = store.get(cursor);
|
||||
if (stepNode === null) {
|
||||
throw new Error(`buildJsonCasThreadContext: missing thread-step node at ${cursor}`);
|
||||
}
|
||||
const payload = stepNode.payload as ThreadStepPayload;
|
||||
rawSteps.push(payload);
|
||||
cursor = payload.previous;
|
||||
}
|
||||
rawSteps.reverse();
|
||||
|
||||
const steps = rawSteps.map((sp) => ({
|
||||
role: sp.role,
|
||||
meta: sp.meta,
|
||||
contentHash: sp.content,
|
||||
refs: [] as string[],
|
||||
timestamp: 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
threadId: "",
|
||||
depth: startPayload.depth,
|
||||
bundleHash: startPayload.workflow,
|
||||
start: {
|
||||
role: START,
|
||||
content: startPayload.input,
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: startPayload.parentThread,
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the text payload from a content node.
|
||||
*/
|
||||
export function readContentText(store: Store, contentHash: Hash): string | null {
|
||||
const node = store.get(contentHash);
|
||||
if (node === null) {
|
||||
return null;
|
||||
}
|
||||
const payload = node.payload as ContentPayload;
|
||||
return payload.text;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
ContentPayload,
|
||||
ThreadEndPayload,
|
||||
ThreadStartPayload,
|
||||
ThreadStepPayload,
|
||||
WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
import type { HydratedWorkflow } from "@uncaged/workflow-json-def";
|
||||
import type { ModeratorRule, WorkflowResult } from "@uncaged/workflow-protocol";
|
||||
import { END, evaluateModerator, START } from "@uncaged/workflow-protocol";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
|
||||
import { writeReactSession } from "./json-cas-react-recorder.js";
|
||||
|
||||
import type {
|
||||
AgentBindings,
|
||||
JsonCasAgentFn,
|
||||
JsonCasEngineIo,
|
||||
JsonCasEngineOptions,
|
||||
JsonCasStepSnapshot,
|
||||
JsonCasThreadSnapshot,
|
||||
} from "./json-cas-types.js";
|
||||
|
||||
// ── Helpers: CAS node writers ─────────────────────────────────────────
|
||||
|
||||
async function writeContent(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
text: string,
|
||||
): Promise<Hash> {
|
||||
const payload: ContentPayload = { text };
|
||||
return store.put(typeHashes.content, payload);
|
||||
}
|
||||
|
||||
async function writeEmptyReactSession(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
role: string,
|
||||
agentHash: Hash,
|
||||
): Promise<Hash> {
|
||||
return store.put(typeHashes.reactSession, {
|
||||
agent: agentHash,
|
||||
role,
|
||||
turns: [],
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeThreadStart(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
params: {
|
||||
workflowHash: Hash;
|
||||
input: string;
|
||||
depth: number;
|
||||
parentThread: Hash | null;
|
||||
agents: AgentBindings;
|
||||
},
|
||||
): Promise<Hash> {
|
||||
const payload: ThreadStartPayload = {
|
||||
workflow: params.workflowHash,
|
||||
input: params.input,
|
||||
depth: params.depth,
|
||||
parentThread: params.parentThread,
|
||||
agents: params.agents,
|
||||
};
|
||||
return store.put(typeHashes.threadStart, payload);
|
||||
}
|
||||
|
||||
async function writeThreadStep(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
params: {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
contentHash: Hash;
|
||||
reactHash: Hash;
|
||||
startHash: Hash;
|
||||
previousHash: Hash | null;
|
||||
},
|
||||
): Promise<Hash> {
|
||||
const payload: ThreadStepPayload = {
|
||||
role: params.role,
|
||||
meta: params.meta,
|
||||
content: params.contentHash,
|
||||
react: params.reactHash,
|
||||
start: params.startHash,
|
||||
previous: params.previousHash,
|
||||
};
|
||||
return store.put(typeHashes.threadStep, payload);
|
||||
}
|
||||
|
||||
async function writeThreadEnd(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
params: {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
startHash: Hash;
|
||||
lastStepHash: Hash;
|
||||
},
|
||||
): Promise<Hash> {
|
||||
const payload: ThreadEndPayload = {
|
||||
returnCode: params.returnCode,
|
||||
summary: params.summary,
|
||||
start: params.startHash,
|
||||
lastStep: params.lastStepHash,
|
||||
};
|
||||
return store.put(typeHashes.threadEnd, payload);
|
||||
}
|
||||
|
||||
// ── Placeholder agent ─────────────────────────────────────────────────
|
||||
|
||||
async function ensurePlaceholderAgent(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
): Promise<Hash> {
|
||||
return store.put(typeHashes.agent, {
|
||||
package: "placeholder",
|
||||
version: "0.0.0",
|
||||
config: {},
|
||||
});
|
||||
}
|
||||
|
||||
// ── JSONata moderator adapter ─────────────────────────────────────────
|
||||
|
||||
function snapshotToModeratorContext(
|
||||
snapshot: JsonCasThreadSnapshot,
|
||||
): Parameters<typeof evaluateModerator>[1] {
|
||||
return {
|
||||
threadId: snapshot.threadId,
|
||||
depth: snapshot.start.depth,
|
||||
bundleHash: snapshot.start.workflowHash,
|
||||
start: {
|
||||
role: START,
|
||||
content: snapshot.start.input,
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: null,
|
||||
},
|
||||
steps: snapshot.steps.map((s) => ({
|
||||
role: s.role,
|
||||
meta: s.meta,
|
||||
contentHash: s.contentHash,
|
||||
refs: [],
|
||||
timestamp: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main engine ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a workflow thread using json-cas as the storage layer.
|
||||
*
|
||||
* Drives the moderator→agent loop:
|
||||
* 1. Writes a thread-start node.
|
||||
* 2. On each round: evaluates the moderator, invokes the agent, writes
|
||||
* content + thread-step nodes (react is a placeholder for now).
|
||||
* 3. On END: writes a thread-end node and returns the result.
|
||||
*
|
||||
* The `agentFn` callback is invoked for each role step. It receives the
|
||||
* role name, system prompt, and current thread snapshot, and returns the
|
||||
* agent's text output plus structured meta.
|
||||
*/
|
||||
export async function executeJsonCasThread(params: {
|
||||
workflowHash: Hash;
|
||||
input: string;
|
||||
moderatorRules: readonly ModeratorRule[];
|
||||
io: JsonCasEngineIo;
|
||||
options: JsonCasEngineOptions;
|
||||
agentFn: JsonCasAgentFn;
|
||||
logger: LogFn;
|
||||
/** Hydrated workflow for role system prompts. Null disables prompt forwarding. */
|
||||
workflow: HydratedWorkflow | null;
|
||||
}): Promise<WorkflowResult> {
|
||||
const { io, options, agentFn, logger, moderatorRules, workflow } = params;
|
||||
const { store, typeHashes, threadId } = io;
|
||||
|
||||
const placeholderAgentHash = await ensurePlaceholderAgent(store, typeHashes);
|
||||
|
||||
const startHash = await writeThreadStart(store, typeHashes, {
|
||||
workflowHash: params.workflowHash,
|
||||
input: params.input,
|
||||
depth: options.depth,
|
||||
parentThread: options.parentThread,
|
||||
agents: options.agents,
|
||||
});
|
||||
|
||||
logger("X3RK7QWN", `json-cas thread ${threadId} started`);
|
||||
|
||||
let previousStepHash: Hash | null = null;
|
||||
let headStepHash: Hash | null = null;
|
||||
const stepSnapshots: JsonCasStepSnapshot[] = [];
|
||||
|
||||
while (true) {
|
||||
if (options.signal.aborted) {
|
||||
return abortThread(store, typeHashes, startHash, headStepHash, logger, threadId);
|
||||
}
|
||||
|
||||
const snapshot: JsonCasThreadSnapshot = {
|
||||
threadId,
|
||||
start: {
|
||||
input: params.input,
|
||||
depth: options.depth,
|
||||
workflowHash: params.workflowHash,
|
||||
},
|
||||
steps: stepSnapshots,
|
||||
};
|
||||
|
||||
const modCtx = snapshotToModeratorContext(snapshot);
|
||||
const nextRole = await evaluateModerator(moderatorRules, modCtx);
|
||||
|
||||
if (nextRole === END) {
|
||||
logger("Y5TN8RVK", `json-cas thread ${threadId} moderator returned END`);
|
||||
|
||||
if (headStepHash === null) {
|
||||
const dummyContentHash = await writeContent(store, typeHashes, "no-op");
|
||||
const dummyReactHash = await writeEmptyReactSession(
|
||||
store,
|
||||
typeHashes,
|
||||
END,
|
||||
placeholderAgentHash,
|
||||
);
|
||||
headStepHash = await writeThreadStep(store, typeHashes, {
|
||||
role: END,
|
||||
meta: {},
|
||||
contentHash: dummyContentHash,
|
||||
reactHash: dummyReactHash,
|
||||
startHash,
|
||||
previousHash: null,
|
||||
});
|
||||
}
|
||||
|
||||
const endHash = await writeThreadEnd(store, typeHashes, {
|
||||
returnCode: 0,
|
||||
summary: "completed: moderator returned END",
|
||||
startHash,
|
||||
lastStepHash: headStepHash,
|
||||
});
|
||||
|
||||
return { returnCode: 0, summary: "completed: moderator returned END", rootHash: endHash };
|
||||
}
|
||||
|
||||
const roleSystemPrompt =
|
||||
workflow !== null && workflow.roles[nextRole] !== undefined
|
||||
? workflow.roles[nextRole].systemPrompt
|
||||
: "";
|
||||
|
||||
const agentResult = await agentFn(nextRole, roleSystemPrompt, snapshot);
|
||||
|
||||
const contentHash = await writeContent(store, typeHashes, agentResult.text);
|
||||
|
||||
const agentHash = options.agents[nextRole] ?? placeholderAgentHash;
|
||||
const reactHash =
|
||||
agentResult.react !== null
|
||||
? await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: nextRole,
|
||||
trace: agentResult.react,
|
||||
})
|
||||
: await writeEmptyReactSession(store, typeHashes, nextRole, agentHash);
|
||||
|
||||
const stepHash = await writeThreadStep(store, typeHashes, {
|
||||
role: nextRole,
|
||||
meta: agentResult.meta,
|
||||
contentHash,
|
||||
reactHash,
|
||||
startHash,
|
||||
previousHash: previousStepHash,
|
||||
});
|
||||
|
||||
previousStepHash = stepHash;
|
||||
headStepHash = stepHash;
|
||||
stepSnapshots.push({
|
||||
role: nextRole,
|
||||
meta: agentResult.meta,
|
||||
contentHash,
|
||||
});
|
||||
|
||||
logger("Z7WP4NHK", `json-cas thread ${threadId} wrote role ${nextRole}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function abortThread(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
startHash: Hash,
|
||||
headStepHash: Hash | null,
|
||||
logger: LogFn,
|
||||
threadId: string,
|
||||
): Promise<WorkflowResult> {
|
||||
logger("A8QK3VNR", `json-cas thread ${threadId} aborted`);
|
||||
|
||||
const placeholderAgentHash = await ensurePlaceholderAgent(store, typeHashes);
|
||||
|
||||
let lastStep = headStepHash;
|
||||
if (lastStep === null) {
|
||||
const dummyContentHash = await writeContent(store, typeHashes, "thread aborted");
|
||||
const dummyReactHash = await writeEmptyReactSession(
|
||||
store,
|
||||
typeHashes,
|
||||
END,
|
||||
placeholderAgentHash,
|
||||
);
|
||||
lastStep = await writeThreadStep(store, typeHashes, {
|
||||
role: END,
|
||||
meta: {},
|
||||
contentHash: dummyContentHash,
|
||||
reactHash: dummyReactHash,
|
||||
startHash,
|
||||
previousHash: null,
|
||||
});
|
||||
}
|
||||
|
||||
const endHash = await writeThreadEnd(store, typeHashes, {
|
||||
returnCode: 130,
|
||||
summary: "thread aborted",
|
||||
startHash,
|
||||
lastStepHash: lastStep,
|
||||
});
|
||||
|
||||
return { returnCode: 130, summary: "thread aborted", rootHash: endHash };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
ContentPayload,
|
||||
ReactSessionPayload,
|
||||
ReactToolCallPayload,
|
||||
ReactTurnPayload,
|
||||
WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
|
||||
import type { ReactToolCallTrace, ReactTrace, ReactTurnTrace } from "./json-cas-types.js";
|
||||
|
||||
// ── Node writers ──────────────────────────────────────────────────────
|
||||
|
||||
async function writeContent(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
text: string,
|
||||
): Promise<Hash> {
|
||||
const payload: ContentPayload = { text };
|
||||
return store.put(typeHashes.content, payload);
|
||||
}
|
||||
|
||||
async function writeToolCall(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
toolCall: ReactToolCallTrace,
|
||||
): Promise<Hash> {
|
||||
const [argsHash, resultHash] = await Promise.all([
|
||||
writeContent(store, typeHashes, toolCall.arguments),
|
||||
writeContent(store, typeHashes, toolCall.result),
|
||||
]);
|
||||
const payload: ReactToolCallPayload = {
|
||||
name: toolCall.name,
|
||||
arguments: argsHash,
|
||||
result: resultHash,
|
||||
durationMs: toolCall.durationMs,
|
||||
};
|
||||
return store.put(typeHashes.reactToolCall, payload);
|
||||
}
|
||||
|
||||
async function writeTurn(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
turn: ReactTurnTrace,
|
||||
): Promise<Hash> {
|
||||
const [inputHash, outputHash, toolCallHashes] = await Promise.all([
|
||||
writeContent(store, typeHashes, turn.input),
|
||||
writeContent(store, typeHashes, turn.output),
|
||||
Promise.all(turn.toolCalls.map((tc) => writeToolCall(store, typeHashes, tc))),
|
||||
]);
|
||||
const payload: ReactTurnPayload = {
|
||||
input: inputHash,
|
||||
output: outputHash,
|
||||
toolCalls: toolCallHashes,
|
||||
tokens: turn.tokens,
|
||||
latencyMs: turn.latencyMs,
|
||||
};
|
||||
return store.put(typeHashes.reactTurn, payload);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialise a {@link ReactTrace} captured during an agent run into CAS nodes:
|
||||
*
|
||||
* content (args/result) → react-tool-call
|
||||
* content (input/output) + react-tool-calls → react-turn
|
||||
* react-turns → react-session
|
||||
*
|
||||
* Returns the hash of the written react-session node.
|
||||
*/
|
||||
export async function writeReactSession(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
params: {
|
||||
agentHash: Hash;
|
||||
role: string;
|
||||
trace: ReactTrace;
|
||||
},
|
||||
): Promise<Hash> {
|
||||
const turnHashes = await Promise.all(
|
||||
params.trace.turns.map((turn) => writeTurn(store, typeHashes, turn)),
|
||||
);
|
||||
const payload: ReactSessionPayload = {
|
||||
agent: params.agentHash,
|
||||
role: params.role,
|
||||
turns: turnHashes,
|
||||
totalTokens: params.trace.totalTokens,
|
||||
durationMs: params.trace.durationMs,
|
||||
};
|
||||
return store.put(typeHashes.reactSession, payload);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
|
||||
|
||||
import type { Result } from "@uncaged/workflow-util";
|
||||
|
||||
// ── Engine IO ─────────────────────────────────────────────────────────
|
||||
|
||||
export type JsonCasEngineIo = {
|
||||
threadId: string;
|
||||
store: Store;
|
||||
typeHashes: WorkflowSchemaHashes;
|
||||
};
|
||||
|
||||
// ── Agent binding ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps each role name to a CAS hash referencing an agent node.
|
||||
* Phase 4 uses a simple role→hash mapping; full agent resolution comes later.
|
||||
*/
|
||||
export type AgentBindings = Record<string, Hash>;
|
||||
|
||||
// ── Engine options ────────────────────────────────────────────────────
|
||||
|
||||
export type JsonCasEngineOptions = {
|
||||
depth: number;
|
||||
parentThread: Hash | null;
|
||||
signal: AbortSignal;
|
||||
agents: AgentBindings;
|
||||
};
|
||||
|
||||
// ── React trace (raw data before CAS serialisation) ───────────────────
|
||||
|
||||
export type ReactToolCallTrace = {
|
||||
name: string;
|
||||
/** JSON-serialised arguments */
|
||||
arguments: string;
|
||||
/** JSON-serialised result */
|
||||
result: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type ReactTurnTrace = {
|
||||
/** Full prompt text sent to the LLM */
|
||||
input: string;
|
||||
/** Raw assistant response text */
|
||||
output: string;
|
||||
toolCalls: ReactToolCallTrace[];
|
||||
tokens: { input: number; output: number };
|
||||
latencyMs: number;
|
||||
};
|
||||
|
||||
export type ReactTrace = {
|
||||
turns: ReactTurnTrace[];
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
// ── Agent function result ─────────────────────────────────────────────
|
||||
|
||||
export type JsonCasAgentResult = {
|
||||
text: string;
|
||||
meta: Record<string, unknown>;
|
||||
/**
|
||||
* React trace captured during the agent run.
|
||||
* Null when the agent has no trace to record (e.g. a mock or passthrough).
|
||||
*/
|
||||
react: ReactTrace | null;
|
||||
};
|
||||
|
||||
// ── Agent function (mock-friendly) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Invoked for each role step. Returns the agent's raw text output,
|
||||
* structured meta, and an optional react trace. The engine stores the
|
||||
* text in a content node and the trace in react-* CAS nodes.
|
||||
*/
|
||||
export type JsonCasAgentFn = (
|
||||
role: string,
|
||||
systemPrompt: string,
|
||||
context: JsonCasThreadSnapshot,
|
||||
) => Promise<JsonCasAgentResult>;
|
||||
|
||||
// ── Thread snapshot (read-only view for agents & moderator) ───────────
|
||||
|
||||
export type JsonCasStartSnapshot = {
|
||||
input: string;
|
||||
depth: number;
|
||||
workflowHash: Hash;
|
||||
};
|
||||
|
||||
export type JsonCasStepSnapshot = {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
contentHash: Hash;
|
||||
};
|
||||
|
||||
export type JsonCasThreadSnapshot = {
|
||||
threadId: string;
|
||||
start: JsonCasStartSnapshot;
|
||||
steps: readonly JsonCasStepSnapshot[];
|
||||
};
|
||||
|
||||
// ── Thread pause gate (re-use from existing types) ────────────────────
|
||||
|
||||
export type JsonCasThreadPauseGate = {
|
||||
awaitAfterYield: () => Promise<void>;
|
||||
pause: () => Result<void, string>;
|
||||
resume: () => Result<void, string>;
|
||||
isPaused: () => boolean;
|
||||
};
|
||||
@@ -4,6 +4,18 @@ export {
|
||||
walkStateFramesNewestFirst,
|
||||
} from "./engine/fork-thread.js";
|
||||
export { garbageCollectCas } from "./engine/gc.js";
|
||||
export { buildJsonCasThreadContext, buildJsonCasThreadSnapshot, readContentText } from "./engine/json-cas-context.js";
|
||||
export { executeJsonCasThread } from "./engine/json-cas-engine.js";
|
||||
export type {
|
||||
AgentBindings,
|
||||
JsonCasAgentFn,
|
||||
JsonCasEngineIo,
|
||||
JsonCasEngineOptions,
|
||||
JsonCasStartSnapshot,
|
||||
JsonCasStepSnapshot,
|
||||
JsonCasThreadPauseGate,
|
||||
JsonCasThreadSnapshot,
|
||||
} from "./engine/json-cas-types.js";
|
||||
export type {
|
||||
ThreadHistoryEntry,
|
||||
ThreadIndex,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "path": "../workflow-util" },
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-reactor" },
|
||||
{ "path": "../workflow-register" }
|
||||
{ "path": "../workflow-register" },
|
||||
{ "path": "../workflow-json-def" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { CasNode } from "@uncaged/json-cas";
|
||||
import { createMemoryStore, refs, validate } from "@uncaged/json-cas";
|
||||
import type { ThreadStartPayload } from "@uncaged/json-cas-workflow";
|
||||
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
|
||||
import { putAgentNode } from "../src/index.js";
|
||||
|
||||
// ── Step 6: putAgentNode — CAS agent instance nodes ──────────────────────────
|
||||
|
||||
describe("Step 6: putAgentNode", () => {
|
||||
test("returns a 13-char Crockford Base32 hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const hash = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
{ baseUrl: "https://api.example.com", apiKey: "sk-test", model: "gpt-4o" },
|
||||
);
|
||||
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("stored agent node is present in the store", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const hash = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-cursor",
|
||||
"0.5.0-alpha.4",
|
||||
{
|
||||
command: "/usr/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(store.get(hash)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("agent node payload contains package, version, and config", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const config = { command: "/usr/bin/hermes", model: "claude-3-5-sonnet", timeout: null };
|
||||
const hash = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-hermes",
|
||||
"0.5.0-alpha.4",
|
||||
config,
|
||||
);
|
||||
|
||||
const node = store.get(hash) as CasNode;
|
||||
const payload = node.payload as Record<string, unknown>;
|
||||
expect(payload.package).toBe("@uncaged/workflow-agent-hermes");
|
||||
expect(payload.version).toBe("0.5.0-alpha.4");
|
||||
expect(payload.config).toEqual(config);
|
||||
});
|
||||
|
||||
test("idempotent: same package + version + config returns the same hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const config = { baseUrl: "https://api.example.com", apiKey: "sk-test", model: "gpt-4o" };
|
||||
const hash1 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
config,
|
||||
);
|
||||
const hash2 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
config,
|
||||
);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
test("different configs produce different hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const hash1 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
{
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
},
|
||||
);
|
||||
const hash2 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
{
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o-mini",
|
||||
},
|
||||
);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
test("agent node passes validation against the agent schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const hash = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-react",
|
||||
"0.5.0-alpha.4",
|
||||
{
|
||||
maxRounds: 10,
|
||||
},
|
||||
);
|
||||
|
||||
const node = store.get(hash) as CasNode;
|
||||
expect(validate(store, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("agent node with empty config is valid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const hash = await putAgentNode(store, typeHashes, "placeholder", "0.0.0", {});
|
||||
const node = store.get(hash) as CasNode;
|
||||
expect(validate(store, node)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Step 6: refs from thread-start includes agent refs ────────────────────────
|
||||
|
||||
describe("Step 6: refs() from thread-start extracts agent refs", () => {
|
||||
test("thread-start with agents: refs() returns the agent hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const agentHash1 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
{ baseUrl: "https://api.example.com", apiKey: "sk-1", model: "gpt-4o" },
|
||||
);
|
||||
const agentHash2 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-cursor",
|
||||
"0.5.0-alpha.4",
|
||||
{ command: "/usr/bin/cursor-agent", model: null, timeout: 0, workspace: null },
|
||||
);
|
||||
|
||||
const fakeWorkflowHash = "FAKEWF0000001";
|
||||
const startHash = await store.put(typeHashes.threadStart, {
|
||||
workflow: fakeWorkflowHash,
|
||||
input: "test",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: { planner: agentHash1, coder: agentHash2 },
|
||||
} satisfies ThreadStartPayload);
|
||||
|
||||
const startNode = store.get(startHash) as CasNode;
|
||||
const startRefs = refs(store, startNode);
|
||||
|
||||
expect(startRefs).toContain(agentHash1);
|
||||
expect(startRefs).toContain(agentHash2);
|
||||
});
|
||||
|
||||
test("thread-start with no agents: refs() returns only the workflow ref", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const fakeWorkflowHash = "FAKEWF0000002";
|
||||
const startHash = await store.put(typeHashes.threadStart, {
|
||||
workflow: fakeWorkflowHash,
|
||||
input: "empty agents",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: {},
|
||||
} satisfies ThreadStartPayload);
|
||||
|
||||
const startNode = store.get(startHash) as CasNode;
|
||||
const startRefs = refs(store, startNode);
|
||||
|
||||
expect(startRefs).toContain(fakeWorkflowHash);
|
||||
expect(startRefs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("thread-start with 3 agents: refs() count includes workflow + 3 agents", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const makeAgent = (model: string) =>
|
||||
putAgentNode(store, typeHashes, "@uncaged/workflow-agent-llm", "0.5.0-alpha.4", {
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "sk-x",
|
||||
model,
|
||||
});
|
||||
|
||||
const [a1, a2, a3] = await Promise.all([makeAgent("m1"), makeAgent("m2"), makeAgent("m3")]);
|
||||
const fakeWorkflowHash = "FAKEWF0000003";
|
||||
|
||||
const startHash = await store.put(typeHashes.threadStart, {
|
||||
workflow: fakeWorkflowHash,
|
||||
input: "multi-agent",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: { r1: a1, r2: a2, r3: a3 },
|
||||
} satisfies ThreadStartPayload);
|
||||
|
||||
const startNode = store.get(startHash) as CasNode;
|
||||
const startRefs = refs(store, startNode);
|
||||
|
||||
// 1 workflow ref + 3 agent refs = 4
|
||||
expect(startRefs).toHaveLength(4);
|
||||
expect(startRefs).toContain(fakeWorkflowHash);
|
||||
expect(startRefs).toContain(a1);
|
||||
expect(startRefs).toContain(a2);
|
||||
expect(startRefs).toContain(a3);
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,8 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "file:../../../json-cas/packages/json-cas",
|
||||
"@uncaged/json-cas-workflow": "file:../../../json-cas/packages/json-cas-workflow"
|
||||
"@uncaged/json-cas": "^0.1.0",
|
||||
"@uncaged/json-cas-workflow": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
|
||||
|
||||
/**
|
||||
* Store an agent instance in CAS.
|
||||
*
|
||||
* Writes an `agent` node with `{ package, version, config }` and returns
|
||||
* the content-addressed hash. Idempotent: the same inputs always produce
|
||||
* the same hash.
|
||||
*/
|
||||
export async function putAgentNode(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
pkg: string,
|
||||
version: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Hash> {
|
||||
return store.put(typeHashes.agent, { package: pkg, version, config });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { putAgentNode } from "./agent.js";
|
||||
export {
|
||||
DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
developWorkflow,
|
||||
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
PackageDescriptor,
|
||||
ProviderConfig,
|
||||
ResolvedModel,
|
||||
Result,
|
||||
|
||||
@@ -130,6 +130,26 @@ export type WorkflowConfig = {
|
||||
models: Record<string, string>;
|
||||
};
|
||||
|
||||
// ── Package Descriptor ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Static metadata describing a workflow agent npm package.
|
||||
* Stored alongside the CAS agent node to document what an agent instance is.
|
||||
*/
|
||||
export type PackageDescriptor = {
|
||||
/** The npm package name, e.g. `@uncaged/workflow-agent-cursor`. */
|
||||
name: string;
|
||||
/** Semver version of the package at the time the descriptor was written. */
|
||||
version: string;
|
||||
/** Human-readable capability tags, e.g. `["cursor-cli", "workspace-agent"]`. */
|
||||
capabilities: string[];
|
||||
/**
|
||||
* JSON Schema that describes the serializable config stored in the CAS
|
||||
* agent node's `config` field.
|
||||
*/
|
||||
configSchema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// ── Functions ──────────────────────────────────────────────────────
|
||||
|
||||
/** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */
|
||||
|
||||
@@ -10,6 +10,7 @@ export type {
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
PackageDescriptor,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
|
||||
Reference in New Issue
Block a user