From 94c719870f37d14e9fd11530abcff23ce41f91c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 02:44:32 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=205=20=E2=80=94=20React=20layer?= =?UTF-8?q?=20instrumentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - json-cas-react-recorder.ts: writeReactSession stores full ReAct trace - ReactTrace/ReactTurnTrace/ReactToolCallTrace types - JsonCasAgentResult with optional react trace - Engine integration: real react-session when trace provided, placeholder when null - 10 new tests (29 total for json-cas engine), biome clean Closes #301 小橘 --- .../__tests__/package-descriptor.test.ts | 40 ++ packages/workflow-agent-cursor/src/index.ts | 1 + .../src/package-descriptor.ts | 34 ++ .../__tests__/package-descriptor.test.ts | 38 ++ packages/workflow-agent-hermes/src/index.ts | 1 + .../src/package-descriptor.ts | 30 ++ .../__tests__/package-descriptor.test.ts | 40 ++ packages/workflow-agent-llm/src/index.ts | 1 + .../src/package-descriptor.ts | 30 ++ .../__tests__/package-descriptor.test.ts | 36 ++ packages/workflow-agent-react/src/index.ts | 1 + .../src/package-descriptor.ts | 25 ++ .../__tests__/json-cas-engine.test.ts | 51 ++- .../__tests__/json-cas-react-recorder.test.ts | 415 ++++++++++++++++++ packages/workflow-execute/src/engine/index.ts | 11 +- .../src/engine/json-cas-engine.ts | 17 +- .../src/engine/json-cas-react-recorder.ts | 92 ++++ .../src/engine/json-cas-types.ts | 47 +- .../__tests__/agent-node.test.ts | 238 ++++++++++ packages/workflow-json-def/src/agent.ts | 19 + packages/workflow-json-def/src/index.ts | 1 + packages/workflow-protocol/src/index.ts | 1 + packages/workflow-protocol/src/types.ts | 20 + packages/workflow-runtime/src/index.ts | 1 + 24 files changed, 1155 insertions(+), 35 deletions(-) create mode 100644 packages/workflow-agent-cursor/__tests__/package-descriptor.test.ts create mode 100644 packages/workflow-agent-cursor/src/package-descriptor.ts create mode 100644 packages/workflow-agent-hermes/__tests__/package-descriptor.test.ts create mode 100644 packages/workflow-agent-hermes/src/package-descriptor.ts create mode 100644 packages/workflow-agent-llm/__tests__/package-descriptor.test.ts create mode 100644 packages/workflow-agent-llm/src/package-descriptor.ts create mode 100644 packages/workflow-agent-react/__tests__/package-descriptor.test.ts create mode 100644 packages/workflow-agent-react/src/package-descriptor.ts create mode 100644 packages/workflow-execute/__tests__/json-cas-react-recorder.test.ts create mode 100644 packages/workflow-execute/src/engine/json-cas-react-recorder.ts create mode 100644 packages/workflow-json-def/__tests__/agent-node.test.ts create mode 100644 packages/workflow-json-def/src/agent.ts diff --git a/packages/workflow-agent-cursor/__tests__/package-descriptor.test.ts b/packages/workflow-agent-cursor/__tests__/package-descriptor.test.ts new file mode 100644 index 0000000..dca7f07 --- /dev/null +++ b/packages/workflow-agent-cursor/__tests__/package-descriptor.test.ts @@ -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; + expect(props).toHaveProperty("command"); + expect(props).toHaveProperty("model"); + expect(props).toHaveProperty("timeout"); + expect(props).toHaveProperty("workspace"); + }); +}); diff --git a/packages/workflow-agent-cursor/src/index.ts b/packages/workflow-agent-cursor/src/index.ts index b3a7fec..516ded3 100644 --- a/packages/workflow-agent-cursor/src/index.ts +++ b/packages/workflow-agent-cursor/src/index.ts @@ -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"; diff --git a/packages/workflow-agent-cursor/src/package-descriptor.ts b/packages/workflow-agent-cursor/src/package-descriptor.ts new file mode 100644 index 0000000..f8a1388 --- /dev/null +++ b/packages/workflow-agent-cursor/src/package-descriptor.ts @@ -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, + }, +}; diff --git a/packages/workflow-agent-hermes/__tests__/package-descriptor.test.ts b/packages/workflow-agent-hermes/__tests__/package-descriptor.test.ts new file mode 100644 index 0000000..ef0ddc6 --- /dev/null +++ b/packages/workflow-agent-hermes/__tests__/package-descriptor.test.ts @@ -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; + expect(props).toHaveProperty("command"); + expect(props).toHaveProperty("model"); + expect(props).toHaveProperty("timeout"); + }); +}); diff --git a/packages/workflow-agent-hermes/src/index.ts b/packages/workflow-agent-hermes/src/index.ts index 2ab9273..56c2f55 100644 --- a/packages/workflow-agent-hermes/src/index.ts +++ b/packages/workflow-agent-hermes/src/index.ts @@ -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"; diff --git a/packages/workflow-agent-hermes/src/package-descriptor.ts b/packages/workflow-agent-hermes/src/package-descriptor.ts new file mode 100644 index 0000000..be5aaea --- /dev/null +++ b/packages/workflow-agent-hermes/src/package-descriptor.ts @@ -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, + }, +}; diff --git a/packages/workflow-agent-llm/__tests__/package-descriptor.test.ts b/packages/workflow-agent-llm/__tests__/package-descriptor.test.ts new file mode 100644 index 0000000..5a53e91 --- /dev/null +++ b/packages/workflow-agent-llm/__tests__/package-descriptor.test.ts @@ -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; + expect(props).toHaveProperty("baseUrl"); + expect(props).toHaveProperty("apiKey"); + expect(props).toHaveProperty("model"); + }); +}); diff --git a/packages/workflow-agent-llm/src/index.ts b/packages/workflow-agent-llm/src/index.ts index 3038f2e..13ea181 100644 --- a/packages/workflow-agent-llm/src/index.ts +++ b/packages/workflow-agent-llm/src/index.ts @@ -4,3 +4,4 @@ export { type LlmChatError, type LlmMessage, } from "./create-llm-adapter.js"; +export { packageDescriptor } from "./package-descriptor.js"; diff --git a/packages/workflow-agent-llm/src/package-descriptor.ts b/packages/workflow-agent-llm/src/package-descriptor.ts new file mode 100644 index 0000000..3c52ece --- /dev/null +++ b/packages/workflow-agent-llm/src/package-descriptor.ts @@ -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, + }, +}; diff --git a/packages/workflow-agent-react/__tests__/package-descriptor.test.ts b/packages/workflow-agent-react/__tests__/package-descriptor.test.ts new file mode 100644 index 0000000..b67fd53 --- /dev/null +++ b/packages/workflow-agent-react/__tests__/package-descriptor.test.ts @@ -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; + expect(props).toHaveProperty("maxRounds"); + }); +}); diff --git a/packages/workflow-agent-react/src/index.ts b/packages/workflow-agent-react/src/index.ts index 61b66a4..debf375 100644 --- a/packages/workflow-agent-react/src/index.ts +++ b/packages/workflow-agent-react/src/index.ts @@ -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"; diff --git a/packages/workflow-agent-react/src/package-descriptor.ts b/packages/workflow-agent-react/src/package-descriptor.ts new file mode 100644 index 0000000..449aef4 --- /dev/null +++ b/packages/workflow-agent-react/src/package-descriptor.ts @@ -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, + }, +}; diff --git a/packages/workflow-execute/__tests__/json-cas-engine.test.ts b/packages/workflow-execute/__tests__/json-cas-engine.test.ts index e904aa7..412e3aa 100644 --- a/packages/workflow-execute/__tests__/json-cas-engine.test.ts +++ b/packages/workflow-execute/__tests__/json-cas-engine.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test"; import { createMemoryStore, type Store, walk } from "@uncaged/json-cas"; import { - registerWorkflowSchemas, - type WorkflowSchemaHashes, 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"; @@ -145,11 +145,7 @@ function makeOptions(overrides: Partial = {}): JsonCasEngi }; } -function makeIo( - store: Store, - typeHashes: WorkflowSchemaHashes, - threadId: string, -): JsonCasEngineIo { +function makeIo(store: Store, typeHashes: WorkflowSchemaHashes, threadId: string): JsonCasEngineIo { return { threadId, store, typeHashes }; } @@ -164,7 +160,7 @@ function createMockAgent( if (resp === undefined) { throw new Error(`mock agent: no response configured for role "${role}"`); } - return resp; + return { ...resp, react: null }; }; } @@ -456,11 +452,11 @@ describe("executeJsonCasThread", () => { if (role === "checker") { checkerCallCount++; if (checkerCallCount === 1) { - return { text: "found issue", meta: { status: "bad" } }; + return { text: "found issue", meta: { status: "bad" }, react: null }; } - return { text: "all good now", meta: { status: "ok" } }; + return { text: "all good now", meta: { status: "ok" }, react: null }; } - return { text: "fixed it", meta: { fix: "patched" } }; + return { text: "fixed it", meta: { fix: "patched" }, react: null }; }; const result = await executeJsonCasThread({ @@ -508,7 +504,7 @@ describe("executeJsonCasThread", () => { const { workflowHash } = await setupWorkflow(store, typeHashes, immediateEnd); - const agentFn: JsonCasAgentFn = async () => { + const agentFn: JsonCasAgentFn = async (): Promise => { throw new Error("should not be called"); }; @@ -541,7 +537,7 @@ describe("executeJsonCasThread", () => { const ac = new AbortController(); ac.abort(); - const agentFn: JsonCasAgentFn = async () => { + const agentFn: JsonCasAgentFn = async (): Promise => { throw new Error("should not be called"); }; @@ -586,7 +582,7 @@ describe("executeJsonCasThread", () => { stepCount: snapshot.steps.length, input: snapshot.start.input, }); - return { text: `output for ${role}`, meta: {} }; + return { text: `output for ${role}`, meta: {}, react: null }; }; await executeJsonCasThread({ @@ -617,17 +613,20 @@ describe("executeJsonCasThread", () => { const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW); let round = 0; - const snapshots: Array<{ role: string; steps: readonly { role: string; meta: Record }[] }> = []; + const snapshots: Array<{ + role: string; + steps: readonly { role: string; meta: Record }[]; + }> = []; 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" } } - : { text: "ok", meta: { status: "ok" } }; + ? { text: "bad", meta: { status: "bad" }, react: null } + : { text: "ok", meta: { status: "ok" }, react: null }; } - return { text: "fixed", meta: { fix: "yes" } }; + return { text: "fixed", meta: { fix: "yes" }, react: null }; }; await executeJsonCasThread({ @@ -682,7 +681,11 @@ describe("buildJsonCasThreadSnapshot", () => { const lastStepHash = endPayload.lastStep; const snapshot = buildJsonCasThreadSnapshot( - store, typeHashes, startHash, lastStepHash, "THREAD_SNAP", + store, + typeHashes, + startHash, + lastStepHash, + "THREAD_SNAP", ); expect(snapshot.threadId).toBe("THREAD_SNAP"); @@ -707,9 +710,7 @@ describe("buildJsonCasThreadSnapshot", () => { agents: {}, }); - const snapshot = buildJsonCasThreadSnapshot( - store, typeHashes, startHash, null, "THREAD_SNAP2", - ); + const snapshot = buildJsonCasThreadSnapshot(store, typeHashes, startHash, null, "THREAD_SNAP2"); expect(snapshot.threadId).toBe("THREAD_SNAP2"); expect(snapshot.start.input).toBe("just started"); @@ -739,9 +740,7 @@ describe("buildJsonCasThreadContext", () => { }); const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload; - const ctx = buildJsonCasThreadContext( - store, typeHashes, endPayload.start, endPayload.lastStep, - ); + const ctx = buildJsonCasThreadContext(store, typeHashes, endPayload.start, endPayload.lastStep); expect(ctx.threadId).toBe(""); expect(ctx.depth).toBe(3); @@ -835,7 +834,7 @@ describe("CAS graph integrity", () => { } }); - test("react session nodes have placeholder structure", async () => { + 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); diff --git a/packages/workflow-execute/__tests__/json-cas-react-recorder.test.ts b/packages/workflow-execute/__tests__/json-cas-react-recorder.test.ts new file mode 100644 index 0000000..c9d66df --- /dev/null +++ b/packages/workflow-execute/__tests__/json-cas-react-recorder.test.ts @@ -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>["store"], + typeHashes: Awaited>["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"); + }); +}); diff --git a/packages/workflow-execute/src/engine/index.ts b/packages/workflow-execute/src/engine/index.ts index 9d3c1ab..a9d2c44 100644 --- a/packages/workflow-execute/src/engine/index.ts +++ b/packages/workflow-execute/src/engine/index.ts @@ -7,17 +7,26 @@ export { walkStateFramesNewestFirst, } from "./fork-thread.js"; export { garbageCollectCas } from "./gc.js"; -export { buildJsonCasThreadContext, buildJsonCasThreadSnapshot, readContentText } from "./json-cas-context.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"; diff --git a/packages/workflow-execute/src/engine/json-cas-engine.ts b/packages/workflow-execute/src/engine/json-cas-engine.ts index c898a36..2cf2f97 100644 --- a/packages/workflow-execute/src/engine/json-cas-engine.ts +++ b/packages/workflow-execute/src/engine/json-cas-engine.ts @@ -11,6 +11,8 @@ 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, @@ -31,7 +33,7 @@ async function writeContent( return store.put(typeHashes.content, payload); } -async function writePlaceholderReactSession( +async function writeEmptyReactSession( store: Store, typeHashes: WorkflowSchemaHashes, role: string, @@ -216,7 +218,7 @@ export async function executeJsonCasThread(params: { if (headStepHash === null) { const dummyContentHash = await writeContent(store, typeHashes, "no-op"); - const dummyReactHash = await writePlaceholderReactSession( + const dummyReactHash = await writeEmptyReactSession( store, typeHashes, END, @@ -252,7 +254,14 @@ export async function executeJsonCasThread(params: { const contentHash = await writeContent(store, typeHashes, agentResult.text); const agentHash = options.agents[nextRole] ?? placeholderAgentHash; - const reactHash = await writePlaceholderReactSession(store, typeHashes, nextRole, agentHash); + 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, @@ -290,7 +299,7 @@ async function abortThread( let lastStep = headStepHash; if (lastStep === null) { const dummyContentHash = await writeContent(store, typeHashes, "thread aborted"); - const dummyReactHash = await writePlaceholderReactSession( + const dummyReactHash = await writeEmptyReactSession( store, typeHashes, END, diff --git a/packages/workflow-execute/src/engine/json-cas-react-recorder.ts b/packages/workflow-execute/src/engine/json-cas-react-recorder.ts new file mode 100644 index 0000000..fe4c533 --- /dev/null +++ b/packages/workflow-execute/src/engine/json-cas-react-recorder.ts @@ -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 { + const payload: ContentPayload = { text }; + return store.put(typeHashes.content, payload); +} + +async function writeToolCall( + store: Store, + typeHashes: WorkflowSchemaHashes, + toolCall: ReactToolCallTrace, +): Promise { + 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 { + 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 { + 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); +} diff --git a/packages/workflow-execute/src/engine/json-cas-types.ts b/packages/workflow-execute/src/engine/json-cas-types.ts index 20706af..e6cafb8 100644 --- a/packages/workflow-execute/src/engine/json-cas-types.ts +++ b/packages/workflow-execute/src/engine/json-cas-types.ts @@ -28,18 +28,57 @@ export type JsonCasEngineOptions = { 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; + /** + * 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 and - * structured meta. The engine stores the text in a content node and the - * meta inside the thread-step node. + * 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<{ text: string; meta: Record }>; +) => Promise; // ── Thread snapshot (read-only view for agents & moderator) ─────────── diff --git a/packages/workflow-json-def/__tests__/agent-node.test.ts b/packages/workflow-json-def/__tests__/agent-node.test.ts new file mode 100644 index 0000000..26ade08 --- /dev/null +++ b/packages/workflow-json-def/__tests__/agent-node.test.ts @@ -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; + 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); + }); +}); diff --git a/packages/workflow-json-def/src/agent.ts b/packages/workflow-json-def/src/agent.ts new file mode 100644 index 0000000..ec42547 --- /dev/null +++ b/packages/workflow-json-def/src/agent.ts @@ -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, +): Promise { + return store.put(typeHashes.agent, { package: pkg, version, config }); +} diff --git a/packages/workflow-json-def/src/index.ts b/packages/workflow-json-def/src/index.ts index e567402..1d24cdb 100644 --- a/packages/workflow-json-def/src/index.ts +++ b/packages/workflow-json-def/src/index.ts @@ -1,3 +1,4 @@ +export { putAgentNode } from "./agent.js"; export { DEVELOP_WORKFLOW_DESCRIPTION, developWorkflow, diff --git a/packages/workflow-protocol/src/index.ts b/packages/workflow-protocol/src/index.ts index 9ae5b42..b7c3170 100644 --- a/packages/workflow-protocol/src/index.ts +++ b/packages/workflow-protocol/src/index.ts @@ -23,6 +23,7 @@ export type { ModeratorContext, ModeratorTable, ModeratorTransition, + PackageDescriptor, ProviderConfig, ResolvedModel, Result, diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index 5214b46..f03897c 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -130,6 +130,26 @@ export type WorkflowConfig = { models: Record; }; +// ── 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; +}; + // ── Functions ────────────────────────────────────────────────────── /** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */ diff --git a/packages/workflow-runtime/src/index.ts b/packages/workflow-runtime/src/index.ts index 01295e8..dd64e84 100644 --- a/packages/workflow-runtime/src/index.ts +++ b/packages/workflow-runtime/src/index.ts @@ -10,6 +10,7 @@ export type { ModeratorCondition, ModeratorContext, ModeratorTable, + PackageDescriptor, Result, RoleDefinition, RoleFn, -- 2.43.0