diff --git a/bun.lock b/bun.lock index b039631..9e7a281 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,13 @@ "cborg": "^4.2.3", }, }, + "packages/json-cas-workflow": { + "name": "@uncaged/json-cas-workflow", + "version": "0.1.0", + "dependencies": { + "@uncaged/json-cas": "workspace:^", + }, + }, }, "packages": { "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], @@ -66,6 +73,8 @@ "@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@workspace:packages/json-cas-fs"], + "@uncaged/json-cas-workflow": ["@uncaged/json-cas-workflow@workspace:packages/json-cas-workflow"], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 168eb5e..be04a81 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -2,7 +2,7 @@ import { mkdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; -import type { CasNode, Hash, JSONSchema, Store } from "@uncaged/json-cas"; +import type { Hash, JSONSchema, Store } from "@uncaged/json-cas"; import { bootstrap, computeHash, @@ -51,8 +51,8 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } { const { flags, positional } = parseArgs(process.argv.slice(2)); -const storePath = typeof flags["store"] === "string" ? flags["store"] : ".cas"; -const compact = flags["json"] === true; +const storePath = typeof flags.store === "string" ? flags.store : ".cas"; +const compact = flags.json === true; // ---- Helpers ---- @@ -120,8 +120,8 @@ async function cmdSchemaList(): Promise { if (node !== null && node.type === metaHash) { const schema = node.payload as JSONSchema; const name = - (schema["title"] as string | undefined) ?? - (schema["description"] as string | undefined) ?? + (schema.title as string | undefined) ?? + (schema.description as string | undefined) ?? "(unnamed)"; console.log(`${hash} ${name}`); } @@ -197,7 +197,7 @@ async function cmdWalk(args: string[]): Promise { const hash = args[0]; if (!hash) die("Usage: json-cas walk [--format tree]"); const store = openStore(); - const format = flags["format"]; + const format = flags.format; if (format === "tree") { const childMap = new Map(); @@ -247,7 +247,7 @@ async function cmdCat(args: string[]): Promise { const store = openStore(); const node = store.get(hash); if (node === null) die(`Node not found: ${hash}`); - if (flags["payload"] === true) { + if (flags.payload === true) { out(node.payload); } else { out(node); diff --git a/packages/json-cas-workflow/package.json b/packages/json-cas-workflow/package.json new file mode 100644 index 0000000..4d3d4f1 --- /dev/null +++ b/packages/json-cas-workflow/package.json @@ -0,0 +1,15 @@ +{ + "name": "@uncaged/json-cas-workflow", + "version": "0.1.0", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test" + }, + "dependencies": { + "@uncaged/json-cas": "workspace:^" + } +} diff --git a/packages/json-cas-workflow/src/index.test.ts b/packages/json-cas-workflow/src/index.test.ts new file mode 100644 index 0000000..abd3801 --- /dev/null +++ b/packages/json-cas-workflow/src/index.test.ts @@ -0,0 +1,646 @@ +import { describe, expect, test } from "bun:test"; +import type { CasNode } from "@uncaged/json-cas"; +import { + createMemoryStore, + getSchema, + refs, + validate, + walk, +} from "@uncaged/json-cas"; +import type { WorkflowSchemaHashes } from "./schemas.js"; +import { registerWorkflowSchemas } from "./schemas.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Step 1: registerWorkflowSchemas() — registers all 11 schemas +// ───────────────────────────────────────────────────────────────────────────── +describe("registerWorkflowSchemas", () => { + test("returns an object with all 11 schema hashes", async () => { + const store = createMemoryStore(); + const hashes = await registerWorkflowSchemas(store); + + const keys: (keyof WorkflowSchemaHashes)[] = [ + "agent", + "roleSchema", + "role", + "workflow", + "threadStart", + "threadStep", + "threadEnd", + "content", + "reactSession", + "reactTurn", + "reactToolCall", + ]; + expect(Object.keys(hashes)).toHaveLength(11); + for (const key of keys) { + expect(hashes[key]).toBeDefined(); + } + }); + + test("all hashes are valid 13-char Crockford Base32 strings", async () => { + const store = createMemoryStore(); + const hashes = await registerWorkflowSchemas(store); + + for (const hash of Object.values(hashes)) { + expect(hash).toHaveLength(13); + expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } + }); + + test("all 11 hashes are distinct", async () => { + const store = createMemoryStore(); + const hashes = await registerWorkflowSchemas(store); + + const values = Object.values(hashes); + const unique = new Set(values); + expect(unique.size).toBe(11); + }); + + test("is idempotent: repeated calls return the same hashes", async () => { + const store = createMemoryStore(); + const first = await registerWorkflowSchemas(store); + const second = await registerWorkflowSchemas(store); + + for (const key of Object.keys(first) as (keyof WorkflowSchemaHashes)[]) { + expect(first[key]).toBe(second[key]); + } + }); + + test("schemas are stored in the store (getSchema returns non-null)", async () => { + const store = createMemoryStore(); + const hashes = await registerWorkflowSchemas(store); + + for (const hash of Object.values(hashes)) { + expect(getSchema(store, hash)).not.toBeNull(); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Step 2: getSchema() — schema round-trip for each of the 11 types +// ───────────────────────────────────────────────────────────────────────────── +describe("getSchema round-trip", () => { + test("agent schema has the expected properties", async () => { + const store = createMemoryStore(); + const { agent } = await registerWorkflowSchemas(store); + const schema = getSchema(store, agent); + + expect(schema).not.toBeNull(); + expect(schema?.type).toBe("object"); + const props = schema?.properties as Record; + expect(props).toHaveProperty("package"); + expect(props).toHaveProperty("version"); + expect(props).toHaveProperty("config"); + }); + + test("role schema references cas_ref for the schema field", async () => { + const store = createMemoryStore(); + const { role } = await registerWorkflowSchemas(store); + const schema = getSchema(store, role); + + expect(schema).not.toBeNull(); + const props = schema?.properties as Record; + expect(props.schema?.format).toBe("cas_ref"); + }); + + test("thread-step schema has six required fields", async () => { + const store = createMemoryStore(); + const { threadStep } = await registerWorkflowSchemas(store); + const schema = getSchema(store, threadStep); + + expect(schema?.required).toHaveLength(6); + }); + + test("react-turn schema has nested tokens object", async () => { + const store = createMemoryStore(); + const { reactTurn } = await registerWorkflowSchemas(store); + const schema = getSchema(store, reactTurn); + + const props = schema?.properties as Record< + string, + { type: string; properties?: unknown } + >; + expect(props.tokens?.type).toBe("object"); + expect(props.tokens?.properties).toBeDefined(); + }); + + test("workflow schema has roles with additionalProperties cas_ref", async () => { + const store = createMemoryStore(); + const { workflow } = await registerWorkflowSchemas(store); + const schema = getSchema(store, workflow); + + const props = schema?.properties as Record< + string, + { additionalProperties?: { format?: string } } + >; + expect(props.roles?.additionalProperties?.format).toBe("cas_ref"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Step 3: validate() — correct payloads pass for all 11 schema types +// ───────────────────────────────────────────────────────────────────────────── +describe("validate – valid payloads", () => { + const HASH = "AAAAAAAAAAAAA"; + + test("agent payload is valid", async () => { + const store = createMemoryStore(); + const { agent } = await registerWorkflowSchemas(store); + const h = await store.put(agent, { + package: "gpt-4o", + version: "2024-11", + config: { temperature: 0.7 }, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("role-schema payload is valid (any object)", async () => { + const store = createMemoryStore(); + const { roleSchema } = await registerWorkflowSchemas(store); + const h = await store.put(roleSchema, { + type: "object", + properties: { answer: { type: "string" } }, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("role payload is valid", async () => { + const store = createMemoryStore(); + const { role } = await registerWorkflowSchemas(store); + const h = await store.put(role, { + name: "analyst", + description: "Analyses data", + systemPrompt: "You are an analyst.", + extractPrompt: "Extract the findings.", + schema: HASH, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("workflow payload is valid", async () => { + const store = createMemoryStore(); + const { workflow } = await registerWorkflowSchemas(store); + const h = await store.put(workflow, { + name: "research", + description: "Research workflow", + roles: { analyst: HASH }, + moderator: [{ from: "analyst", to: "analyst", when: null }], + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("thread-start payload is valid (null parentThread)", async () => { + const store = createMemoryStore(); + const { threadStart } = await registerWorkflowSchemas(store); + const h = await store.put(threadStart, { + workflow: HASH, + input: "hello", + depth: 0, + parentThread: null, + agents: { main: HASH }, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("thread-start payload is valid (non-null parentThread)", async () => { + const store = createMemoryStore(); + const { threadStart } = await registerWorkflowSchemas(store); + const h = await store.put(threadStart, { + workflow: HASH, + input: "nested", + depth: 1, + parentThread: HASH, + agents: {}, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("thread-step payload is valid (null previous)", async () => { + const store = createMemoryStore(); + const { threadStep } = await registerWorkflowSchemas(store); + const h = await store.put(threadStep, { + role: "analyst", + meta: { attempt: 1 }, + content: HASH, + react: HASH, + start: HASH, + previous: null, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("thread-step payload is valid (non-null previous)", async () => { + const store = createMemoryStore(); + const { threadStep } = await registerWorkflowSchemas(store); + const h = await store.put(threadStep, { + role: "analyst", + meta: {}, + content: HASH, + react: HASH, + start: HASH, + previous: HASH, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("thread-end payload is valid", async () => { + const store = createMemoryStore(); + const { threadEnd } = await registerWorkflowSchemas(store); + const h = await store.put(threadEnd, { + returnCode: 0, + summary: "Done", + start: HASH, + lastStep: HASH, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("content payload is valid", async () => { + const store = createMemoryStore(); + const { content } = await registerWorkflowSchemas(store); + const h = await store.put(content, { text: "Hello, world!" }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("react-session payload is valid (empty turns)", async () => { + const store = createMemoryStore(); + const { reactSession } = await registerWorkflowSchemas(store); + const h = await store.put(reactSession, { + agent: HASH, + role: "analyst", + turns: [], + totalTokens: 0, + durationMs: 42, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("react-session payload is valid (multiple turns)", async () => { + const store = createMemoryStore(); + const { reactSession } = await registerWorkflowSchemas(store); + const h = await store.put(reactSession, { + agent: HASH, + role: "analyst", + turns: [HASH, HASH], + totalTokens: 300, + durationMs: 1500, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("react-turn payload is valid", async () => { + const store = createMemoryStore(); + const { reactTurn } = await registerWorkflowSchemas(store); + const h = await store.put(reactTurn, { + input: HASH, + output: HASH, + toolCalls: [HASH], + tokens: { input: 100, output: 50 }, + latencyMs: 800, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); + + test("react-tool-call payload is valid", async () => { + const store = createMemoryStore(); + const { reactToolCall } = await registerWorkflowSchemas(store); + const h = await store.put(reactToolCall, { + name: "search", + arguments: HASH, + result: HASH, + durationMs: 200, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Step 4: validate() — invalid payloads fail for representative types +// ───────────────────────────────────────────────────────────────────────────── +describe("validate – invalid payloads", () => { + test("agent: missing required field fails", async () => { + const store = createMemoryStore(); + const { agent } = await registerWorkflowSchemas(store); + const h = await store.put(agent, { package: "gpt-4o", version: "1" }); + expect(validate(store, store.get(h) as CasNode)).toBe(false); + }); + + test("agent: wrong type for config fails", async () => { + const store = createMemoryStore(); + const { agent } = await registerWorkflowSchemas(store); + const h = await store.put(agent, { + package: "gpt-4o", + version: "1", + config: "not-an-object", + }); + expect(validate(store, store.get(h) as CasNode)).toBe(false); + }); + + test("role: missing systemPrompt fails", async () => { + const store = createMemoryStore(); + const { role } = await registerWorkflowSchemas(store); + const h = await store.put(role, { + name: "analyst", + description: "d", + extractPrompt: "e", + schema: "AAAAAAAAAAAAA", + }); + expect(validate(store, store.get(h) as CasNode)).toBe(false); + }); + + test("thread-start: missing depth fails", async () => { + const store = createMemoryStore(); + const { threadStart } = await registerWorkflowSchemas(store); + const h = await store.put(threadStart, { + workflow: "AAAAAAAAAAAAA", + input: "hi", + parentThread: null, + agents: {}, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(false); + }); + + test("thread-end: returnCode as string fails", async () => { + const store = createMemoryStore(); + const { threadEnd } = await registerWorkflowSchemas(store); + const h = await store.put(threadEnd, { + returnCode: "ok", + summary: "Done", + start: "AAAAAAAAAAAAA", + lastStep: "AAAAAAAAAAAAA", + }); + expect(validate(store, store.get(h) as CasNode)).toBe(false); + }); + + test("content: missing text fails", async () => { + const store = createMemoryStore(); + const { content } = await registerWorkflowSchemas(store); + const h = await store.put(content, {}); + expect(validate(store, store.get(h) as CasNode)).toBe(false); + }); + + test("react-turn: tokens.input as string fails", async () => { + const store = createMemoryStore(); + const { reactTurn } = await registerWorkflowSchemas(store); + const h = await store.put(reactTurn, { + input: "AAAAAAAAAAAAA", + output: "AAAAAAAAAAAAA", + toolCalls: [], + tokens: { input: "many", output: 50 }, + latencyMs: 100, + }); + expect(validate(store, store.get(h) as CasNode)).toBe(false); + }); + + test("react-tool-call: missing durationMs fails", async () => { + const store = createMemoryStore(); + const { reactToolCall } = await registerWorkflowSchemas(store); + const h = await store.put(reactToolCall, { + name: "tool", + arguments: "AAAAAAAAAAAAA", + result: "AAAAAAAAAAAAA", + }); + expect(validate(store, store.get(h) as CasNode)).toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Step 5: refs() — extracts direct cas_ref fields from node payloads +// ───────────────────────────────────────────────────────────────────────────── +describe("refs – cas_ref extraction", () => { + const HASH_A = "AAAAAAAAAAAAA"; + const HASH_B = "BBBBBBBBBBBBB"; + + test("content node has no cas_ref fields → empty array", async () => { + const store = createMemoryStore(); + const { content } = await registerWorkflowSchemas(store); + const h = await store.put(content, { text: "hello" }); + const node = store.get(h) as CasNode; + expect(refs(store, node)).toEqual([]); + }); + + test("role node: refs() returns the schema cas_ref", async () => { + const store = createMemoryStore(); + const { role } = await registerWorkflowSchemas(store); + const h = await store.put(role, { + name: "r", + description: "d", + systemPrompt: "s", + extractPrompt: "e", + schema: HASH_A, + }); + const node = store.get(h) as CasNode; + expect(refs(store, node)).toContain(HASH_A); + }); + + test("thread-end: refs() returns start and lastStep", async () => { + const store = createMemoryStore(); + const { threadEnd } = await registerWorkflowSchemas(store); + const h = await store.put(threadEnd, { + returnCode: 0, + summary: "done", + start: HASH_A, + lastStep: HASH_B, + }); + const node = store.get(h) as CasNode; + const result = refs(store, node); + expect(result).toContain(HASH_A); + expect(result).toContain(HASH_B); + expect(result).toHaveLength(2); + }); + + test("react-tool-call: refs() returns arguments and result", async () => { + const store = createMemoryStore(); + const { reactToolCall } = await registerWorkflowSchemas(store); + const h = await store.put(reactToolCall, { + name: "search", + arguments: HASH_A, + result: HASH_B, + durationMs: 100, + }); + const node = store.get(h) as CasNode; + const result = refs(store, node); + expect(result).toContain(HASH_A); + expect(result).toContain(HASH_B); + expect(result).toHaveLength(2); + }); + + test("thread-step: refs() returns content, react, and start (previous null is skipped)", async () => { + const store = createMemoryStore(); + const { threadStep } = await registerWorkflowSchemas(store); + const h = await store.put(threadStep, { + role: "r", + meta: {}, + content: HASH_A, + react: HASH_B, + start: HASH_A, + previous: null, + }); + const node = store.get(h) as CasNode; + const result = refs(store, node); + expect(result).toContain(HASH_A); + expect(result).toContain(HASH_B); + }); + + test("thread-step: refs() includes previous when non-null", async () => { + const store = createMemoryStore(); + const { threadStep } = await registerWorkflowSchemas(store); + const HASH_C = "CCCCCCCCCCCCC"; + const h = await store.put(threadStep, { + role: "r", + meta: {}, + content: HASH_A, + react: HASH_B, + start: HASH_A, + previous: HASH_C, + }); + const node = store.get(h) as CasNode; + const result = refs(store, node); + expect(result).toContain(HASH_C); + }); + + test("react-session: refs() returns the agent cas_ref", async () => { + const store = createMemoryStore(); + const { reactSession } = await registerWorkflowSchemas(store); + const h = await store.put(reactSession, { + agent: HASH_A, + role: "r", + turns: [], + totalTokens: 0, + durationMs: 0, + }); + const node = store.get(h) as CasNode; + expect(refs(store, node)).toContain(HASH_A); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Step 6: walk() — BFS traversal through linked workflow nodes +// ───────────────────────────────────────────────────────────────────────────── +describe("walk – cross-schema traversal", () => { + test("walk visits content node linked from thread-end", async () => { + const store = createMemoryStore(); + const { threadEnd, content } = await registerWorkflowSchemas(store); + + const contentHash = await store.put(content, { text: "summary text" }); + const endHash = await store.put(threadEnd, { + returnCode: 0, + summary: "done", + start: contentHash, + lastStep: contentHash, + }); + + const visited = new Set(); + walk(store, endHash, (h) => visited.add(h)); + + expect(visited.has(endHash)).toBe(true); + expect(visited.has(contentHash)).toBe(true); + }); + + test("walk through role → (schema stored in store)", async () => { + const store = createMemoryStore(); + const { role, roleSchema } = await registerWorkflowSchemas(store); + + const schemaDocHash = await store.put(roleSchema, { + type: "object", + properties: { answer: { type: "string" } }, + }); + const roleHash = await store.put(role, { + name: "analyst", + description: "d", + systemPrompt: "s", + extractPrompt: "e", + schema: schemaDocHash, + }); + + const visited = new Set(); + walk(store, roleHash, (h) => visited.add(h)); + + expect(visited.has(roleHash)).toBe(true); + expect(visited.has(schemaDocHash)).toBe(true); + }); + + test("walk handles diamond: two thread-end nodes sharing the same start", async () => { + const store = createMemoryStore(); + const { threadEnd, content } = await registerWorkflowSchemas(store); + + const sharedStart = await store.put(content, { text: "start" }); + const step1 = await store.put(content, { text: "step1" }); + const step2 = await store.put(content, { text: "step2" }); + + const end1 = await store.put(threadEnd, { + returnCode: 0, + summary: "path A", + start: sharedStart, + lastStep: step1, + }); + const end2 = await store.put(threadEnd, { + returnCode: 1, + summary: "path B", + start: sharedStart, + lastStep: step2, + }); + + // Use react-turn as the root linking both ends via input/output + const { reactTurn } = await registerWorkflowSchemas(store); + const turnHash = await store.put(reactTurn, { + input: end1, + output: end2, + toolCalls: [], + tokens: { input: 10, output: 5 }, + latencyMs: 50, + }); + + const visited = new Set(); + walk(store, turnHash, (h) => visited.add(h)); + + expect(visited.has(turnHash)).toBe(true); + expect(visited.has(end1)).toBe(true); + expect(visited.has(end2)).toBe(true); + // sharedStart is reached from both end1 and end2, but visited only once + expect(visited.has(sharedStart)).toBe(true); + expect(visited.has(step1)).toBe(true); + expect(visited.has(step2)).toBe(true); + }); + + test("walk visits react-tool-call linked from react-turn", async () => { + const store = createMemoryStore(); + const { reactTurn, reactToolCall, content } = + await registerWorkflowSchemas(store); + + const argsHash = await store.put(content, { text: '{"q":"test"}' }); + const resultHash = await store.put(content, { text: '{"r":"ok"}' }); + const toolCallHash = await store.put(reactToolCall, { + name: "search", + arguments: argsHash, + result: resultHash, + durationMs: 120, + }); + + const inputHash = await store.put(content, { text: "input" }); + const outputHash = await store.put(content, { text: "output" }); + const turnHash = await store.put(reactTurn, { + input: inputHash, + output: outputHash, + toolCalls: [], + tokens: { input: 80, output: 40 }, + latencyMs: 600, + }); + + const visited = new Set(); + walk(store, turnHash, (h) => visited.add(h)); + + expect(visited.has(turnHash)).toBe(true); + expect(visited.has(inputHash)).toBe(true); + expect(visited.has(outputHash)).toBe(true); + // toolCallHash is not in the turn's cas_ref fields (toolCalls array), only linked manually + expect(visited.has(toolCallHash)).toBe(false); + + // walk from toolCallHash to verify it reaches args and result + const tcVisited = new Set(); + walk(store, toolCallHash, (h) => tcVisited.add(h)); + expect(tcVisited.has(toolCallHash)).toBe(true); + expect(tcVisited.has(argsHash)).toBe(true); + expect(tcVisited.has(resultHash)).toBe(true); + }); +}); diff --git a/packages/json-cas-workflow/src/index.ts b/packages/json-cas-workflow/src/index.ts new file mode 100644 index 0000000..5f0e438 --- /dev/null +++ b/packages/json-cas-workflow/src/index.ts @@ -0,0 +1,19 @@ +export { + registerWorkflowSchemas, + type WorkflowSchemaHashes, +} from "./schemas.js"; +export type { + AgentPayload, + ContentPayload, + ReactSessionPayload, + ReactToolCallPayload, + ReactTurnPayload, + ReactTurnTokens, + RolePayload, + RoleSchemaPayload, + ThreadEndPayload, + ThreadStartPayload, + ThreadStepPayload, + WorkflowPayload, + WorkflowTransition, +} from "./types.js"; diff --git a/packages/json-cas-workflow/src/schemas.ts b/packages/json-cas-workflow/src/schemas.ts new file mode 100644 index 0000000..58694af --- /dev/null +++ b/packages/json-cas-workflow/src/schemas.ts @@ -0,0 +1,236 @@ +import type { Hash, Store } from "@uncaged/json-cas"; +import { type JSONSchema, putSchema } from "@uncaged/json-cas"; + +// ── Definition layer ────────────────────────────────────────────────────────── + +const AGENT: JSONSchema = { + type: "object", + required: ["package", "version", "config"], + properties: { + package: { type: "string" }, + version: { type: "string" }, + config: { type: "object" }, + }, + additionalProperties: false, +}; + +/** role-schema nodes hold raw JSON Schema documents, so any object is valid. */ +const ROLE_SCHEMA: JSONSchema = { + type: "object", +}; + +const ROLE: JSONSchema = { + type: "object", + required: ["name", "description", "systemPrompt", "extractPrompt", "schema"], + properties: { + name: { type: "string" }, + description: { type: "string" }, + systemPrompt: { type: "string" }, + extractPrompt: { type: "string" }, + schema: { type: "string", format: "cas_ref" }, + }, + additionalProperties: false, +}; + +const WORKFLOW: JSONSchema = { + type: "object", + required: ["name", "description", "roles", "moderator"], + properties: { + name: { type: "string" }, + description: { type: "string" }, + roles: { + type: "object", + additionalProperties: { type: "string", format: "cas_ref" }, + }, + moderator: { + type: "array", + items: { + type: "object", + required: ["from", "to", "when"], + properties: { + from: { type: "string" }, + to: { type: "string" }, + when: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + additionalProperties: false, + }, + }, + }, + additionalProperties: false, +}; + +// ── Execution layer ─────────────────────────────────────────────────────────── + +const THREAD_START: JSONSchema = { + type: "object", + required: ["workflow", "input", "depth", "parentThread", "agents"], + properties: { + workflow: { type: "string", format: "cas_ref" }, + input: { type: "string" }, + depth: { type: "number" }, + parentThread: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + agents: { + type: "object", + additionalProperties: { type: "string", format: "cas_ref" }, + }, + }, + additionalProperties: false, +}; + +const THREAD_STEP: JSONSchema = { + type: "object", + required: ["role", "meta", "content", "react", "start", "previous"], + properties: { + role: { type: "string" }, + meta: { type: "object" }, + content: { type: "string", format: "cas_ref" }, + react: { type: "string", format: "cas_ref" }, + start: { type: "string", format: "cas_ref" }, + previous: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + additionalProperties: false, +}; + +const THREAD_END: JSONSchema = { + type: "object", + required: ["returnCode", "summary", "start", "lastStep"], + properties: { + returnCode: { type: "number" }, + summary: { type: "string" }, + start: { type: "string", format: "cas_ref" }, + lastStep: { type: "string", format: "cas_ref" }, + }, + additionalProperties: false, +}; + +const CONTENT: JSONSchema = { + type: "object", + required: ["text"], + properties: { + text: { type: "string" }, + }, + additionalProperties: false, +}; + +// ── React layer ─────────────────────────────────────────────────────────────── + +const REACT_SESSION: JSONSchema = { + type: "object", + required: ["agent", "role", "turns", "totalTokens", "durationMs"], + properties: { + agent: { type: "string", format: "cas_ref" }, + role: { type: "string" }, + turns: { + type: "array", + items: { type: "string", format: "cas_ref" }, + }, + totalTokens: { type: "number" }, + durationMs: { type: "number" }, + }, + additionalProperties: false, +}; + +const REACT_TURN: JSONSchema = { + type: "object", + required: ["input", "output", "toolCalls", "tokens", "latencyMs"], + properties: { + input: { type: "string", format: "cas_ref" }, + output: { type: "string", format: "cas_ref" }, + toolCalls: { + type: "array", + items: { type: "string", format: "cas_ref" }, + }, + tokens: { + type: "object", + required: ["input", "output"], + properties: { + input: { type: "number" }, + output: { type: "number" }, + }, + additionalProperties: false, + }, + latencyMs: { type: "number" }, + }, + additionalProperties: false, +}; + +const REACT_TOOL_CALL: JSONSchema = { + type: "object", + required: ["name", "arguments", "result", "durationMs"], + properties: { + name: { type: "string" }, + arguments: { type: "string", format: "cas_ref" }, + result: { type: "string", format: "cas_ref" }, + durationMs: { type: "number" }, + }, + additionalProperties: false, +}; + +// ── Registry ────────────────────────────────────────────────────────────────── + +export type WorkflowSchemaHashes = { + agent: Hash; + roleSchema: Hash; + role: Hash; + workflow: Hash; + threadStart: Hash; + threadStep: Hash; + threadEnd: Hash; + content: Hash; + reactSession: Hash; + reactTurn: Hash; + reactToolCall: Hash; +}; + +/** + * Register all 11 workflow schemas into the given store. + * Returns a map from camelCase schema name to its CAS type hash. + * Idempotent: safe to call multiple times on the same store. + */ +export async function registerWorkflowSchemas( + store: Store, +): Promise { + const [ + agent, + roleSchema, + role, + workflow, + threadStart, + threadStep, + threadEnd, + content, + reactSession, + reactTurn, + reactToolCall, + ] = await Promise.all([ + putSchema(store, AGENT), + putSchema(store, ROLE_SCHEMA), + putSchema(store, ROLE), + putSchema(store, WORKFLOW), + putSchema(store, THREAD_START), + putSchema(store, THREAD_STEP), + putSchema(store, THREAD_END), + putSchema(store, CONTENT), + putSchema(store, REACT_SESSION), + putSchema(store, REACT_TURN), + putSchema(store, REACT_TOOL_CALL), + ]); + + return { + agent, + roleSchema, + role, + workflow, + threadStart, + threadStep, + threadEnd, + content, + reactSession, + reactTurn, + reactToolCall, + }; +} diff --git a/packages/json-cas-workflow/src/types.ts b/packages/json-cas-workflow/src/types.ts new file mode 100644 index 0000000..2a69cbc --- /dev/null +++ b/packages/json-cas-workflow/src/types.ts @@ -0,0 +1,111 @@ +import type { Hash } from "@uncaged/json-cas"; + +// ── Definition layer ────────────────────────────────────────────────────────── + +export type AgentPayload = { + package: string; + version: string; + config: Record; +}; + +/** A JSON Schema document stored as-is. */ +export type RoleSchemaPayload = Record; + +export type RolePayload = { + name: string; + description: string; + systemPrompt: string; + extractPrompt: string; + /** cas_ref → role-schema */ + schema: Hash; +}; + +export type WorkflowTransition = { + from: string; + to: string; + when: string | null; +}; + +export type WorkflowPayload = { + name: string; + description: string; + /** cas_ref → role */ + roles: Record; + moderator: WorkflowTransition[]; +}; + +// ── Execution layer ─────────────────────────────────────────────────────────── + +export type ThreadStartPayload = { + /** cas_ref → workflow */ + workflow: Hash; + input: string; + depth: number; + /** cas_ref → thread-start | null */ + parentThread: Hash | null; + /** cas_ref → agent */ + agents: Record; +}; + +export type ThreadStepPayload = { + role: string; + meta: Record; + /** cas_ref → content */ + content: Hash; + /** cas_ref → react-session */ + react: Hash; + /** cas_ref → thread-start */ + start: Hash; + /** cas_ref → thread-step | null */ + previous: Hash | null; +}; + +export type ThreadEndPayload = { + returnCode: number; + summary: string; + /** cas_ref → thread-start */ + start: Hash; + /** cas_ref → thread-step */ + lastStep: Hash; +}; + +export type ContentPayload = { + text: string; +}; + +// ── React layer ─────────────────────────────────────────────────────────────── + +export type ReactSessionPayload = { + /** cas_ref → agent */ + agent: Hash; + role: string; + /** cas_ref → react-turn */ + turns: Hash[]; + totalTokens: number; + durationMs: number; +}; + +export type ReactTurnTokens = { + input: number; + output: number; +}; + +export type ReactTurnPayload = { + /** cas_ref → content */ + input: Hash; + /** cas_ref → content */ + output: Hash; + /** cas_ref → react-tool-call */ + toolCalls: Hash[]; + tokens: ReactTurnTokens; + latencyMs: number; +}; + +export type ReactToolCallPayload = { + name: string; + /** cas_ref → content (arguments) */ + arguments: Hash; + /** cas_ref → content (result) */ + result: Hash; + durationMs: number; +}; diff --git a/packages/json-cas-workflow/tsconfig.json b/packages/json-cas-workflow/tsconfig.json new file mode 100644 index 0000000..75eba9f --- /dev/null +++ b/packages/json-cas-workflow/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/json-cas/src/schema.test.ts b/packages/json-cas/src/schema.test.ts index bd95721..8c3ffa1 100644 --- a/packages/json-cas/src/schema.test.ts +++ b/packages/json-cas/src/schema.test.ts @@ -283,7 +283,7 @@ describe("walk", () => { // A → B, B → A (manual cycle by inserting pre-known hash) const hashA = await store.put(schemaHash, { val: 1 }); - const hashB = await store.put(schemaHash, { peerHash: hashA, val: 2 }); + const _hashB = await store.put(schemaHash, { peerHash: hashA, val: 2 }); // update A to point at B — since store is content-addressed we can't mutate, // so we build a diamond: root → A and root → B, A → C, B → C diff --git a/packages/json-cas/src/schema.ts b/packages/json-cas/src/schema.ts index 2819de2..384ef83 100644 --- a/packages/json-cas/src/schema.ts +++ b/packages/json-cas/src/schema.ts @@ -6,6 +6,7 @@ import type { CasNode, Hash, Store } from "./types.js"; export type JSONSchema = Record; const ajv = new Ajv(); +ajv.addFormat("cas_ref", /^[0-9A-HJKMNP-TV-Z]{13}$/); /** * Store a JSON Schema as a CAS node typed by the meta-schema hash. @@ -44,6 +45,8 @@ export function validate(store: Store, node: CasNode): boolean { /** * Recursively collect values of all properties whose schema has format: 'cas_ref'. + * Handles: direct format, anyOf (nullable refs), items (array refs), + * properties (nested objects), and additionalProperties (record refs). */ function collectRefs(schema: JSONSchema, value: unknown): Hash[] { const result: Hash[] = []; @@ -55,17 +58,39 @@ function collectRefs(schema: JSONSchema, value: unknown): Hash[] { return result; } - if ( - schema.properties && - typeof schema.properties === "object" && - value !== null && - typeof value === "object" && - !Array.isArray(value) - ) { - const props = schema.properties as Record; - const obj = value as Record; - for (const [key, subSchema] of Object.entries(props)) { - result.push(...collectRefs(subSchema, obj[key])); + if (Array.isArray(schema.anyOf)) { + for (const sub of schema.anyOf as JSONSchema[]) { + result.push(...collectRefs(sub, value)); + } + return result; + } + + if (schema.type === "array" && schema.items && Array.isArray(value)) { + const itemSchema = schema.items as JSONSchema; + for (const item of value as unknown[]) { + result.push(...collectRefs(itemSchema, item)); + } + return result; + } + + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + if (schema.properties && typeof schema.properties === "object") { + const props = schema.properties as Record; + const obj = value as Record; + for (const [key, subSchema] of Object.entries(props)) { + result.push(...collectRefs(subSchema, obj[key])); + } + } + + if ( + schema.additionalProperties && + typeof schema.additionalProperties === "object" + ) { + const addlSchema = schema.additionalProperties as JSONSchema; + const obj = value as Record; + for (const val of Object.values(obj)) { + result.push(...collectRefs(addlSchema, val)); + } } }