diff --git a/bun.lock b/bun.lock index 7a9715e..9dae334 100644 --- a/bun.lock +++ b/bun.lock @@ -105,14 +105,11 @@ "@ai-sdk/openai": "catalog:", "@projectors/core": "workspace:*", "ai": "catalog:", + "zod": "catalog:", }, "devDependencies": { "@types/bun": "latest", "vitest": "^4.0.17", - "zod": "^4.0.0", - }, - "peerDependencies": { - "zod": "catalog:", }, }, "packages/livekit-cascade-executor": { @@ -126,9 +123,6 @@ "devDependencies": { "@types/bun": "latest", "vitest": "^4.0.17", - "zod": "^4.0.0", - }, - "peerDependencies": { "zod": "catalog:", }, }, @@ -143,23 +137,22 @@ "devDependencies": { "@types/bun": "latest", "vitest": "^4.0.17", - "zod": "^4.0.0", - }, - "peerDependencies": { "zod": "catalog:", }, }, "packages/projector": { "name": "@projectors/core", - "version": "0.0.1", + "version": "0.0.2", + "dependencies": { + "@cfworker/json-schema": "^4.1.1", + "@standard-schema/spec": "^1.1.0", + }, "devDependencies": { "@projectors/aisdk-executor": "workspace:*", "@projectors/livekit-realtime-executor": "workspace:*", "@types/bun": "latest", "vitest": "^4.0.17", - }, - "peerDependencies": { - "zod": ">=4.0.0 <5", + "zod": "^4.4.3", }, }, }, @@ -232,6 +225,8 @@ "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="], + "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + "@convex-dev/auth": ["@convex-dev/auth@0.0.95", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "cookie": "^1.0.1", "is-network-error": "^1.1.0", "jose": "^5.2.2", "jwt-decode": "^4.0.0", "lucia": "^3.2.0", "oauth4webapi": "^3.1.2", "path-to-regexp": "^6.3.0", "server-only": "^0.0.1" }, "peerDependencies": { "@auth/core": "^0.41.1", "convex": "^1.17.0", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["react"], "bin": { "auth": "dist/bin.cjs" } }, "sha512-LgUujfXRm7FcdYpTozjn0MHdVFxaxvZdWVFOQEkrL4AkY+k/8E8B4EFjJOG9S8Pq6QWfLJ8MCObeMJg0hAfKzA=="], "@convex-dev/rate-limiter": ["@convex-dev/rate-limiter@0.3.2", "", { "peerDependencies": { "convex": "^1.24.8", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react"] }, "sha512-+oBPsBfFbzdxiF/9XaaTQmVnvDlvEfg/c69/v8LxTbw4VLuiflIKlfnPQL8OS0azXQQ11hcPWHmU8ytFmHKDXA=="], @@ -2032,7 +2027,7 @@ "@projectors/core/@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], - "@projectors/livekit-cascade-executor/vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + "@projectors/livekit-cascade-executor/@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@projectors/livekit-realtime-executor/@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], @@ -2198,21 +2193,7 @@ "@projectors/core/@types/bun/bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], - "@projectors/livekit-cascade-executor/vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], - - "@projectors/livekit-cascade-executor/vitest/@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], - - "@projectors/livekit-cascade-executor/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], - - "@projectors/livekit-cascade-executor/vitest/@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], - - "@projectors/livekit-cascade-executor/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], - - "@projectors/livekit-cascade-executor/vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], - - "@projectors/livekit-cascade-executor/vitest/@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], - - "@projectors/livekit-cascade-executor/vitest/vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], + "@projectors/livekit-cascade-executor/@types/bun/bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "@projectors/livekit-realtime-executor/@types/bun/bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], @@ -2332,6 +2313,8 @@ "@projectors/core/@types/bun/bun-types/@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + "@projectors/livekit-cascade-executor/@types/bun/bun-types/@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + "@projectors/livekit-realtime-executor/@types/bun/bun-types/@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/packages/aisdk-executor/package.json b/packages/aisdk-executor/package.json index 12ab17c..eaf2561 100644 --- a/packages/aisdk-executor/package.json +++ b/packages/aisdk-executor/package.json @@ -18,14 +18,11 @@ "@ai-sdk/anthropic": "catalog:", "@ai-sdk/openai": "catalog:", "ai": "catalog:", - "@projectors/core": "workspace:*" + "@projectors/core": "workspace:*", + "zod": "catalog:" }, "devDependencies": { "@types/bun": "latest", - "vitest": "^4.0.17", - "zod": "^4.0.0" - }, - "peerDependencies": { - "zod": "catalog:" + "vitest": "^4.0.17" } } diff --git a/packages/aisdk-executor/src/__tests__/executor.test.ts b/packages/aisdk-executor/src/__tests__/executor.test.ts index 2ef2a18..b9d9aca 100644 --- a/packages/aisdk-executor/src/__tests__/executor.test.ts +++ b/packages/aisdk-executor/src/__tests__/executor.test.ts @@ -103,6 +103,24 @@ describe("AI SDK prompt rendering", () => { ]); }); + it("preserves tool call arguments, results and errors across continuation history", () => { + expect(buildAiSdkMessages(inference({ history: [ + { ...textUserMessage("remember this") }, + { type: "action", kind: "request", action: "tool", name: "remember", callId: "save-1", input: { note: "already saved" } }, + { type: "action", kind: "result", action: "tool", name: "remember", callId: "save-1", success: true, value: "saved" }, + { type: "action", kind: "request", action: "tool", name: "check", callId: "check-1", input: {} }, + { type: "action", kind: "result", action: "tool", name: "check", callId: "check-1", success: false, error: "offline" }, + { type: "action", kind: "request", action: "tool", name: "unfinished", callId: "pending-1", input: { path: "work" } }, + ] }))).toEqual([ + { role: "user", content: "remember this" }, + { role: "assistant", content: [{ type: "tool-call", toolCallId: "save-1", toolName: "remember", input: { note: "already saved" } }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "save-1", toolName: "remember", output: { type: "text", value: "saved" } }] }, + { role: "assistant", content: [{ type: "tool-call", toolCallId: "check-1", toolName: "check", input: {} }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "check-1", toolName: "check", output: { type: "error-text", value: "offline" } }] }, + { role: "assistant", content: 'Tool call unfinished (pending-1) with {"path":"work"}; no result recorded.' }, + ]); + }); + it("inserts dynamic context before the latest user request", () => { expect( buildAiSdkMessages( diff --git a/packages/aisdk-executor/src/executor.ts b/packages/aisdk-executor/src/executor.ts index 29c4a74..06c83ae 100644 --- a/packages/aisdk-executor/src/executor.ts +++ b/packages/aisdk-executor/src/executor.ts @@ -1,6 +1,5 @@ import { Output, - asSchema, generateText, jsonSchema, streamText, @@ -21,6 +20,8 @@ import { executeActionInvocation, hasActionOutputMessages, isActorMessage, + normalizeSchema, + SchemaError, } from "@projectors/core"; import type { ActionContext, @@ -38,6 +39,7 @@ import type { FrameDraft, FrameMessage, ProjectorExecutor, + Schema, } from "@projectors/core"; import { z } from "zod"; import type { @@ -520,7 +522,7 @@ function buildAiSdkInput( frequencyPenalty: config.frequencyPenalty, seed: config.seed, experimental_output: request.output?.schema - ? Output.object({ schema: request.output.schema }) + ? Output.object({ schema: aiSdkSchema(request.output.schema) }) : undefined, providerOptions: config.providerOptions as never, toolChoice: respondNow ? undefined : (config.toolChoice as never), @@ -530,7 +532,9 @@ function buildAiSdkInput( function parseNodeConfig(config: unknown): AiSdkExecutorNodeConfig { if (config === undefined) return {}; - return nodeConfigSchema.parse(config); + const schema = normalizeSchema(nodeConfigSchema); + schema.assert(config); + return config as AiSdkExecutorNodeConfig; } function normalizeTurnPolicy( @@ -864,10 +868,17 @@ export function buildAiSdkMessages( message: ActorMessage, ) => ModelMessage | undefined, ): ModelMessage[] { + // Preserve completed tool exchanges as calls/results, including their inputs. + // A horizon or interrupted action can leave only one half; keep that as text + // rather than sending an invalid, unmatched tool exchange to the provider. + const requests = new Set(inference.history.flatMap((message) => + message.type === "action" && message.action === "tool" && message.kind === "request" ? [message.callId] : [])); + const completedCalls = new Set(inference.history.flatMap((message) => + message.type === "action" && message.action === "tool" && message.kind === "result" && requests.has(message.callId) ? [message.callId] : [])); const entries = inference.history .map((source) => ({ source, - message: frameMessageToModelMessage(source, messageToModelMessage), + message: frameMessageToModelMessage(source, messageToModelMessage, completedCalls), })) .filter( ( @@ -972,20 +983,28 @@ export function buildAiSdkTools( return tools; } +const EMPTY_OBJECT_SCHEMA = { type: "object", properties: {}, additionalProperties: false }; + +function compactToolInputSchema(schema: AnyAction["inputSchema"]) { + return schema ? aiSdkSchema(schema) : jsonSchema(EMPTY_OBJECT_SCHEMA as never); +} + /** - * The provider already knows the function-tool JSON Schema dialect. Zod's - * standard conversion adds the same `$schema` URI to every tool, so strip only - * that redundant marker while preserving the AI SDK validator unchanged. + * An AI SDK schema over projector's normalized schema: its JSON Schema + * document minus the `$schema` dialect marker (the provider already knows the + * function-tool dialect), validated by Projector's canonical validator. */ -function compactToolInputSchema(schema: AnyAction["inputSchema"]) { - const normalized = asSchema(schema ?? z.object({})); - return jsonSchema( - Promise.resolve(normalized.jsonSchema).then((value) => { - const { $schema: _dialect, ...compact } = value; - return compact; - }), - { validate: normalized.validate }, - ); +function aiSdkSchema(schema: Schema) { + const normalized = normalizeSchema(schema); + const { $schema: _dialect, ...compact } = normalized.jsonSchema(); + return jsonSchema(compact as never, { + validate: (value) => { + const result = normalized.check(value); + return result.issues + ? { success: false, error: new SchemaError(result.issues) } + : { success: true, value: value as T }; + }, + }); } function executorOwnedAction( @@ -1291,17 +1310,27 @@ function frameMessageToModelMessage( messageToModelMessage?: ( message: ActorMessage, ) => ModelMessage | undefined, + completedCalls: ReadonlySet = new Set(), ): ModelMessage | undefined { if (isActorMessage(message)) { return ( messageToModelMessage?.(message) ?? actorMessageToModelMessage(message) ); } - if ( - message.type === "action" && - message.kind === "result" && - message.action === "tool" - ) { + if (message.type === "action" && message.action === "tool") { + if (message.kind === "request") { + return completedCalls.has(message.callId) + ? { role: "assistant", content: [{ type: "tool-call", toolCallId: message.callId, toolName: message.name, input: message.input }] } + : { role: "assistant", content: `Tool call ${message.name} (${message.callId}) with ${stringifyValue(message.input)}; no result recorded.` }; + } + if (completedCalls.has(message.callId)) { + return { role: "tool", content: [{ + type: "tool-result", toolCallId: message.callId, toolName: message.name, + output: message.success + ? { type: "text", value: stringifyValue(message.value) } + : { type: "error-text", value: message.error ?? "tool failed" }, + }] }; + } return { role: "user", content: renderToolResult(message.name, message) }; } return undefined; diff --git a/packages/livekit-cascade-executor/package.json b/packages/livekit-cascade-executor/package.json index 119fe4e..0b9842d 100644 --- a/packages/livekit-cascade-executor/package.json +++ b/packages/livekit-cascade-executor/package.json @@ -22,9 +22,6 @@ "devDependencies": { "@types/bun": "latest", "vitest": "^4.0.17", - "zod": "^4.0.0" - }, - "peerDependencies": { "zod": "catalog:" } } diff --git a/packages/livekit-cascade-executor/src/executor.ts b/packages/livekit-cascade-executor/src/executor.ts index 615bc2a..8bf381f 100644 --- a/packages/livekit-cascade-executor/src/executor.ts +++ b/packages/livekit-cascade-executor/src/executor.ts @@ -7,6 +7,8 @@ import { hasActionOutputMessages, ROOT_GENERATOR_ID, isActorMessage, + normalizeSchema, + SchemaError, textContent, } from "@projectors/core"; import type { @@ -21,7 +23,6 @@ import type { ExecutorRealizePromptRequest, RuntimeSyncContext, } from "@projectors/core"; -import { z } from "zod"; import type { ExecutorRunRequest, ExecutorRunResult, @@ -212,6 +213,11 @@ export class LiveKitCascadeConnection { throw new Error(`No LiveKit tool named "${name}" is registered in the current projection`); } + if (action.inputSchema) { + const checked = normalizeSchema(action.inputSchema).check(input); + if (checked.issues) throw new SchemaError(checked.issues); + } + const callId = crypto.randomUUID(); const actionRequest = { ...createToolActionRequest(name, input, callId), @@ -726,15 +732,20 @@ export function buildLiveKitToolDefinitions( type: "function", name: action.name, description: action.description ?? "", - parameters: action.inputSchema - ? z.toJSONSchema(action.inputSchema) - : { type: "object", properties: {}, additionalProperties: false }, + parameters: liveKitToolParameters(action), }); } return [...definitions.values()]; } +/** Raw JSON Schema for LiveKit; the executor validates input itself (executeTool). */ +function liveKitToolParameters(action: AnyAction): Record { + return action.inputSchema + ? normalizeSchema(action.inputSchema).jsonSchema() + : { type: "object", properties: {}, additionalProperties: false }; +} + export function buildLiveKitToolContext( inference: CompiledInference, connection: LiveKitCascadeConnection, @@ -744,7 +755,7 @@ export function buildLiveKitToolContext( for (const action of inference.tools) { tools[action.name] = createLiveKitTool({ description: action.description ?? "", - parameters: action.inputSchema ?? z.object({}), + parameters: liveKitToolParameters(action) as never, execute: (input, liveKitContext) => connection.executeTool(action.name, input, liveKitContext), }); } diff --git a/packages/livekit-realtime-executor/package.json b/packages/livekit-realtime-executor/package.json index ea2357e..8112053 100644 --- a/packages/livekit-realtime-executor/package.json +++ b/packages/livekit-realtime-executor/package.json @@ -22,9 +22,6 @@ "devDependencies": { "@types/bun": "latest", "vitest": "^4.0.17", - "zod": "^4.0.0" - }, - "peerDependencies": { "zod": "catalog:" } } diff --git a/packages/livekit-realtime-executor/src/executor.ts b/packages/livekit-realtime-executor/src/executor.ts index 453b5ca..9ab99ce 100644 --- a/packages/livekit-realtime-executor/src/executor.ts +++ b/packages/livekit-realtime-executor/src/executor.ts @@ -11,6 +11,8 @@ import { ROOT_GENERATOR_ID, createRuntimeTurnFrame, isActorMessage, + normalizeSchema, + SchemaError, textContent, } from "@projectors/core"; import type { @@ -27,7 +29,6 @@ import type { ExecutorRealizePromptRequest, RuntimeSyncContext, } from "@projectors/core"; -import { z } from "zod"; import type { ExecutorRunRequest, ExecutorRunResult, @@ -376,15 +377,13 @@ export class LiveKitRealtimeConnection { ); } - let parsedInput = input; if (action.inputSchema) { - const parsed = action.inputSchema.safeParse(input); - if (!parsed.success) this.recordFailedToolInvocation(name, input, callId, parsed.error); - parsedInput = parsed.data; + const checked = normalizeSchema(action.inputSchema).check(input); + if (checked.issues) this.recordFailedToolInvocation(name, input, callId, new SchemaError(checked.issues)); } const actionRequest = { - ...createToolActionRequest(name, parsedInput, callId), + ...createToolActionRequest(name, input, callId), source: { external: true }, }; const context: ActionContext = @@ -394,7 +393,7 @@ export class LiveKitRealtimeConnection { context.getState ??= (address) => this.getRetrievableState(address); } const runAction = this.config.runAction; - const runInput: RunActionInput = { action, input: parsedInput, context, liveKitContext }; + const runInput: RunActionInput = { action, input, context, liveKitContext }; const result = await executeActionInvocation({ request: actionRequest, throwErrors: true, @@ -2106,7 +2105,7 @@ function toolExchangeToRealtimeItems( function liveKitToolParameters(action: AnyAction): unknown { return action.inputSchema - ? z.toJSONSchema(action.inputSchema, { target: "draft-7", io: "output", reused: "inline" }) + ? normalizeSchema(action.inputSchema).jsonSchema({ target: "draft-07", libraryOptions: { reused: "inline" } }) : { type: "object", properties: {}, additionalProperties: false }; } diff --git a/packages/projector/ROADMAP.md b/packages/projector/ROADMAP.md index 03290d0..6d6757f 100644 --- a/packages/projector/ROADMAP.md +++ b/packages/projector/ROADMAP.md @@ -115,3 +115,13 @@ ordering across slots is unanchored (items append at the conversation tail — `previous_item_id` anchoring is a follow-up); tool-list `session.update` diffing. +### 9. Params declaration metadata + +Params currently discover declared keys and required keys by inspecting the +top-level `properties` and `required` fields of the schema's input JSON Schema. +That is intentionally sufficient for today's flat object schemas, but it is not +a general JSON Schema reflection mechanism: valid schemas may hide the same +object shape behind `$ref`, `$defs`, `allOf`, or other composition. Revisit this +when params need to support those forms. Prefer explicit params declaration +metadata (or another small params-specific contract) over growing a partial +JSON Schema resolver inside core. diff --git a/packages/projector/index.ts b/packages/projector/index.ts index cf40955..e4e475d 100644 --- a/packages/projector/index.ts +++ b/packages/projector/index.ts @@ -17,6 +17,7 @@ export * from "./src/params.ts"; export * from "./src/refs.ts"; export * from "./src/regions.ts"; export * from "./src/scoped-actions.ts"; +export * from "./src/schema.ts"; export * from "./src/scopes.ts"; export * from "./src/projection-address.ts"; export * from "./src/serialization.ts"; diff --git a/packages/projector/package.json b/packages/projector/package.json index 588b50e..241dc30 100644 --- a/packages/projector/package.json +++ b/packages/projector/package.json @@ -29,10 +29,11 @@ "@projectors/aisdk-executor": "workspace:*", "@projectors/livekit-realtime-executor": "workspace:*", "@types/bun": "latest", - "vitest": "^4.0.17" + "vitest": "^4.0.17", + "zod": "^4.4.3" }, - "peerDependencies": { - "zod": ">=4.0.0 <5" - }, - "dependencies": {} + "dependencies": { + "@cfworker/json-schema": "^4.1.1", + "@standard-schema/spec": "^1.1.0" + } } diff --git a/packages/projector/src/__tests__/projector.test.ts b/packages/projector/src/__tests__/projector.test.ts index 3c0daf8..8653118 100644 --- a/packages/projector/src/__tests__/projector.test.ts +++ b/packages/projector/src/__tests__/projector.test.ts @@ -1,6 +1,7 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; import { + normalizeSchema, actorMessages, appendState, compileProjection, @@ -513,9 +514,7 @@ describe("params", () => { const hydrated = hydrateInstance(serialized, appCharter); expect(hydrated.params).toEqual({ userId: "user_123" }); - expect(hydrated.node.params.parse({ userId: "user_456" })).toEqual({ - userId: "user_456", - }); + expect(normalizeSchema(hydrated.node.params).accepts({ userId: "user_456" })).toBe(true); }); }); @@ -3607,7 +3606,7 @@ describe("serialization and refs", () => { serializedInline.node.states![0]!, registry, ); - expect(hydratedState.schema.parse(3)).toBe(3); + expect(normalizeSchema(hydratedState.schema).accepts(3)).toBe(true); } const serializedRegistered = serializeInstance( @@ -3617,7 +3616,7 @@ describe("serialization and refs", () => { expect(serializedRegistered.node).toBe("registered"); const hydratedInline = hydrateInstance(serializedInline, registry); - expect(hydratedInline.node.states[0]?.schema.parse(4)).toBe(4); + expect(normalizeSchema(hydratedInline.node.states[0]!.schema).accepts(4)).toBe(true); }); it("serializes inline output schema and rejects inline output mappers", () => { @@ -3639,9 +3638,7 @@ describe("serialization and refs", () => { } expect(serialized.node.output?.audience).toBe("broadcast"); const hydrated = hydrateInstance(serialized, registry); - expect(hydrated.node.output?.schema?.parse({ answer: "ok" })).toEqual({ - answer: "ok", - }); + expect(normalizeSchema(hydrated.node.output!.schema!).accepts({ answer: "ok" })).toBe(true); const mapped = createNode({ key: "mappedOutput", @@ -3696,7 +3693,7 @@ function getStateJsonSchema( if (!getState?.inputSchema) { throw new Error("Expected getState tool schema"); } - return z.toJSONSchema(getState.inputSchema); + return normalizeSchema(getState.inputSchema).jsonSchema(); } async function flushPromises(): Promise { diff --git a/packages/projector/src/__tests__/schema.test.ts b/packages/projector/src/__tests__/schema.test.ts new file mode 100644 index 0000000..c8a9c95 --- /dev/null +++ b/packages/projector/src/__tests__/schema.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import * as z from "zod"; +import { + createAction, + createCharter, + createMachine, + createNode, + createState, + executeCommand, + normalizeSchema, + schemaFromJsonSchema, + type Schema, +} from "../../index.ts"; + +describe("validation-only schema contract", () => { + it("validates the canonical input JSON Schema without applying source coercions", () => { + // Runtime callers can bypass authoring types (plain JS, hydrated data), so + // canonical validation must still ignore source coercion behavior. + const schema = normalizeSchema(z.coerce.number() as unknown as Schema); + + expect(schema.accepts(42)).toBe(true); + expect(schema.accepts("42")).toBe(false); + + const hydrated = normalizeSchema( + schemaFromJsonSchema(schema.jsonSchema()), + ); + expect(hydrated.accepts(42)).toBe(true); + expect(hydrated.accepts("42")).toBe(false); + }); + + it("passes the original validated value to actions", async () => { + let observed: string | undefined; + const command = createAction({ + state: null, + name: "echo", + inputSchema: z.string().trim(), + run: (input) => { + observed = input; + return input; + }, + }); + const node = createNode({ key: "root", commands: [command] }); + const charter = createCharter({ nodes: [node], commands: [command] }); + const machine = createMachine({ + instance: { id: "root", isSource: true, node }, + charter, + }); + + const result = await executeCommand(machine, { + type: "action", + kind: "request", + action: "command", + name: "echo", + input: " unchanged ", + callId: "echo-1", + }); + + expect(result).toMatchObject({ success: true, value: " unchanged " }); + expect(observed).toBe(" unchanged "); + }); + + it("exposes one JSON Schema with no input/output switch", () => { + const schema = normalizeSchema(z.string()); + expect(schema.jsonSchema()).toMatchObject({ type: "string" }); + + if (false) { + // @ts-expect-error Projector has one canonical JSON Schema, not separate IO forms. + schema.jsonSchema({ io: "output" }); + } + }); + + it("rejects schemas whose declared input and output types differ", () => { + if (false) { + // @ts-expect-error Canonical schemas cannot coerce unknown input into a number. + normalizeSchema(z.coerce.number()); + + createAction({ + state: null, + name: "length", + // @ts-expect-error Projector action schemas cannot transform their input type. + inputSchema: z.string().transform((value) => value.length), + run: () => undefined, + }); + + // @ts-expect-error Projector state schemas cannot transform their input type. + createState({ + key: "count", + schema: z.string().transform((value) => value.length), + init: "0", + }); + + createNode({ + key: "transforming-output", + // @ts-expect-error Projector output schemas cannot transform their input type. + output: { schema: z.string().transform((value) => value.length) }, + }); + } + }); +}); diff --git a/packages/projector/src/actions.ts b/packages/projector/src/actions.ts index 6274fec..9830f54 100644 --- a/packages/projector/src/actions.ts +++ b/packages/projector/src/actions.ts @@ -1,7 +1,14 @@ -import { z } from "zod"; +import { + normalizeSchema, + schemaFromJsonSchema, + type AnySchema, + type InferSchemaValue, + type SchemaIssue, + type SchemaTransformError, +} from "./schema.ts"; import { markActionExposure } from "./action-exposure.ts"; import { assertProjectorIdentifier } from "./identifiers.ts"; -import { emptyParamsSchema, normalizeParamsSchema, type AnyParamsSchema } from "./params.ts"; +import { emptyParamsSchema, normalizeParamsSchema, type AnyParamsSchema, type InferParams } from "./params.ts"; import type { Action, ActionRequestMessage, @@ -41,6 +48,8 @@ export type ActionResultEnvelope = ( | { success: false; error: string; + /** Canonical validator issues when the failure was input validation. */ + issues?: readonly SchemaIssue[]; value?: T; messages?: FrameMessage[]; terminal?: boolean; @@ -58,7 +67,7 @@ export type ActionResultEnvelope = ( __dataContent?: TDataContent; }; -type InputOf = TSchema extends z.ZodType ? TInput : unknown; +type InputOf = TSchema extends AnySchema ? InferSchemaValue : unknown; type StateOf = TState extends StateDescriptor ? S : undefined; type ActionStateRequirement = StateDescriptor | null; @@ -82,7 +91,7 @@ type ActionConfig< } | { executorOwned?: false; - run?: (input: I, ctx: ActionContext, TDataContent, z.output>) => O | Promise; + run?: (input: I, ctx: ActionContext, TDataContent, InferParams>) => O | Promise; } ); @@ -115,7 +124,7 @@ type CreatedAction< type ActionWithSchema< TState extends ActionStateRequirement, TParams extends AnyParamsSchema, - TSchema extends z.ZodType, + TSchema extends AnySchema, O, TName extends string, TDataContent, @@ -126,12 +135,14 @@ type ActionWithSchema< export function createAction< const TName extends string, const TState extends ActionStateRequirement, - const TSchema extends z.ZodType, + const TSchema extends AnySchema, const TParams extends AnyParamsSchema = typeof emptyParamsSchema, O = unknown, TDataContent = never, >( - action: ActionConfig, O, TName, TDataContent> & { inputSchema: TSchema }, + action: ActionConfig, O, TName, TDataContent> + & { inputSchema: TSchema } + & SchemaTransformError, ): ActionWithSchema; export function createAction< const TName extends string, @@ -151,6 +162,7 @@ export function createAction(action: AnyAction): AnyAction { assertProjectorIdentifier(action.state.key, "State key"); } action.params = normalizeParamsSchema(action.params); + if (action.inputSchema) normalizeSchema(action.inputSchema); return action; } @@ -284,7 +296,7 @@ export function createActionResultMessage( ...(request.target ? { target: request.target } : {}), success: result.success, ...("value" in result && result.value !== undefined ? { value: result.value } : {}), - ...(!result.success ? { error: result.error } : {}), + ...(!result.success ? { error: result.error, ...(result.issues ? { issues: result.issues } : {}) } : {}), ...(result.terminal ? { terminal: true } : {}), ...(options.outputMessageIndices?.length ? { outputMessageIndices: options.outputMessageIndices } : {}), }; @@ -344,6 +356,7 @@ function normalizeActionReturn( return { success: false, error: value.error, + ...(value.issues ? { issues: value.issues } : {}), ...(value.value !== undefined ? { value: value.value } : {}), ...(value.messages !== undefined ? { messages: value.messages } : {}), ...(value.terminal ? { terminal: true } : {}), @@ -505,19 +518,24 @@ export function createGetStateAction( unknown, typeof GET_STATE_ACTION_NAME > { - const inputSchema = z.object({ - address: z.string(), + const inputSchema = schemaFromJsonSchema<{ address: string }>({ + type: "object", + properties: { address: { type: "string" } }, + required: ["address"], + additionalProperties: false, }); return { state: null, name: GET_STATE_ACTION_NAME, description: "Retrieve a projected state value by exact address.", - inputSchema: inputSchema as z.ZodType, + inputSchema, run: (input, ctx) => { if (!ctx.getState) { throw new Error("No getState handler is available for this action context"); } - const { address } = inputSchema.parse(input); + const schema = normalizeSchema(inputSchema); + schema.assert(input); + const { address } = input as { address: string }; return ctx.getState(address); }, }; diff --git a/packages/projector/src/client/index.ts b/packages/projector/src/client/index.ts index 06f6845..d372f85 100644 --- a/packages/projector/src/client/index.ts +++ b/packages/projector/src/client/index.ts @@ -1,4 +1,4 @@ -import * as z from "zod"; +import { normalizeSchema, schemaFromJsonSchema, type Schema } from "../schema.ts"; import { collectContributors, computedPartEnv, @@ -146,7 +146,7 @@ export type ClientInstance< export type AnyCommandDefinition = { name: string; - inputSchema?: z.ZodType; + inputSchema?: Schema; }; export type ClientCommandDefinitionName = TCommand extends { @@ -156,7 +156,7 @@ export type ClientCommandDefinitionName = TCommand extends { : string; export type ClientCommandDefinitionInput = TCommand extends { - inputSchema?: z.ZodType; + inputSchema?: Schema; } ? TInput : unknown; @@ -172,7 +172,7 @@ export type ClientToolOf = ClientToolMeta< >; export type ClientStateOf = TStateDescriptor extends { - schema: z.ZodType; + schema: Schema; } ? TValue : unknown; @@ -621,7 +621,7 @@ function realizeTool( name: entry.action.name, description: entry.action.description, inputSchema: entry.action.inputSchema - ? z.toJSONSchema(entry.action.inputSchema) + ? normalizeSchema(entry.action.inputSchema).jsonSchema() : undefined, exposure: entry.exposure, target, @@ -636,7 +636,7 @@ function realizeCommand( name: command.name, description: command.description, inputSchema: command.inputSchema - ? z.toJSONSchema(command.inputSchema) + ? normalizeSchema(command.inputSchema).jsonSchema() : undefined, target, }; @@ -749,7 +749,7 @@ function realizeStateDescriptor( key: descriptor.key, scope: descriptor.scope, onInitConflict: descriptor.onInitConflict, - schema: z.toJSONSchema(descriptor.schema), + schema: normalizeSchema(descriptor.schema).jsonSchema(), projection: descriptor.projection ? { ...slotPlacement(descriptor.projection.slot), @@ -767,7 +767,7 @@ function realizeState( key: state.address.stateKey, address: state.address, value: state.container.value, - schema: z.toJSONSchema(descriptor.schema), + schema: normalizeSchema(descriptor.schema).jsonSchema(), projection: descriptor.projection ? { ...slotPlacement(descriptor.projection.slot), @@ -844,12 +844,10 @@ function createOptimisticContext( function commitValidState(state: ClientStateView, candidate: unknown): void { if (state.schema !== undefined) { try { - const schema = z.fromJSONSchema( - state.schema as Parameters[0], - ); - if (!schema.safeParse(candidate).success) return; + const schema = normalizeSchema(schemaFromJsonSchema(state.schema)); + if (!schema.accepts(candidate)) return; } catch { - // A projection may carry JSON Schema features z.fromJSONSchema cannot + // A projection may carry JSON Schema features hydration cannot // reconstruct. In that case the client cannot prove invalidity, so keep // the optimistic behavior and let the durable machine arbitrate. } diff --git a/packages/projector/src/compile.ts b/packages/projector/src/compile.ts index 2c26515..69f87d0 100644 --- a/packages/projector/src/compile.ts +++ b/packages/projector/src/compile.ts @@ -1,4 +1,4 @@ -import * as z from "zod"; +import { normalizeSchema } from "./schema.ts"; import { actionExposure } from "./action-exposure.ts"; import { assertNodeActionStateCompatibility, @@ -1225,7 +1225,7 @@ function actionMeta(action: AnyAction, exposure: Exposure): ActionMeta { name: action.name, description: action.description, inputSchema: action.inputSchema - ? z.toJSONSchema(action.inputSchema) + ? normalizeSchema(action.inputSchema).jsonSchema() : undefined, exposure, }; @@ -1239,7 +1239,7 @@ function outputMeta( } return { audience: output.audience, - schema: output.schema ? z.toJSONSchema(output.schema) : undefined, + schema: output.schema ? normalizeSchema(output.schema).jsonSchema() : undefined, mapsTextBlock: Boolean(output.mapTextBlock), }; } @@ -1493,6 +1493,7 @@ function visibleHistoryForTarget( targetGeneratorId, runtime, options.activationId, + { horizon: "apply" }, ); return visible.map(stripProvenance); } diff --git a/packages/projector/src/contributors.ts b/packages/projector/src/contributors.ts index 80c5b20..694a3f4 100644 --- a/packages/projector/src/contributors.ts +++ b/packages/projector/src/contributors.ts @@ -1,3 +1,4 @@ +import { normalizeSchema } from "./schema.ts"; import { createNode } from "./create.ts"; // Function-level cycle with discriminator-eval.ts (it reads contributor // params); safe because both modules only declare functions at init. @@ -93,8 +94,8 @@ export function createRoot< instances: Instance[], params: InputCharterParams, ): Instance { - const parsedParams = charter.params.parse(params); - return createRootInstance(instances, parsedParams); + normalizeSchema(charter.params).assert(params); + return createRootInstance(instances, params as JsonObject); } export function createRootInstance( diff --git a/packages/projector/src/create.ts b/packages/projector/src/create.ts index 460d9b2..fdf9075 100644 --- a/packages/projector/src/create.ts +++ b/packages/projector/src/create.ts @@ -1,3 +1,9 @@ +import { + normalizeSchema, + type AnySchema, + type InferSchemaValue, + type SchemaTransformError, +} from "./schema.ts"; import type { ActionConfigEntry, ActionPart, @@ -212,9 +218,11 @@ export function isNode( * schemas by reference, so sharing one created descriptor is the way to * satisfy it). */ -export function createState( - descriptor: StateDescriptor, -): NormalizedStateDescriptor { +export function createState( + descriptor: Omit>, "schema"> + & { schema: TSchema } + & SchemaTransformError, +): NormalizedStateDescriptor> { return normalizeStateDescriptor(descriptor); } @@ -233,6 +241,7 @@ export function normalizeStateDescriptor( descriptor: StateDescriptor, ): NormalizedStateDescriptor { assertProjectorIdentifier(descriptor.key, "State key"); + normalizeSchema(descriptor.schema); const scope = descriptor.scope ?? "hoist"; const onInitConflict = descriptor.onInitConflict ?? "replace"; if ( @@ -340,6 +349,7 @@ export function createNode< throw new Error("Node requires key or name"); } assertProjectorIdentifier(key, "Node key"); + if (config.output?.schema) normalizeSchema(config.output.schema); return { [NODE_BRAND]: true, diff --git a/packages/projector/src/history.ts b/packages/projector/src/history.ts index 6f5ce66..24beb07 100644 --- a/packages/projector/src/history.ts +++ b/packages/projector/src/history.ts @@ -1,3 +1,4 @@ +import { normalizeSchema } from "./schema.ts"; import { assertProjectorIdentifier } from "./identifiers.ts"; import type { ActorHistoryProjection, @@ -344,7 +345,8 @@ export function assistantMessageFromTextOutput( if (mapTextBlock) { const mapped = mapTextBlock(text); - const content = schema ? schema.parse(mapped) : mapped; + if (schema) normalizeSchema(schema).assert(mapped); + const content = mapped; return { type: "assistant", content: [dataContent(content)], @@ -354,9 +356,11 @@ export function assistantMessageFromTextOutput( } if (schema) { + const normalized = normalizeSchema(schema); + normalized.assert(text); return { type: "assistant", - content: [dataContent(schema.parse(text))], + content: [dataContent(text as TDataContent)], text, ...(output?.audience ? { audience: output.audience } : {}), }; diff --git a/packages/projector/src/machine.ts b/packages/projector/src/machine.ts index 18d46fe..56acf57 100644 --- a/packages/projector/src/machine.ts +++ b/packages/projector/src/machine.ts @@ -1,3 +1,4 @@ +import { formatSchemaIssues, normalizeSchema } from "./schema.ts"; import { assertNodeActionStateCompatibility, createActionTerminalMessages, @@ -74,6 +75,7 @@ import type { WorkActivationMessage, WorkCompletionMessage, WorkCompletionReason, + MessageRef, } from "./types.ts"; import { compileProjection } from "./compile.ts"; import { @@ -242,7 +244,7 @@ export function createMachine({ }; assertUniqueInstanceIds(machine.instance); assertHasSourceInstance(machine.instance); - charter.params.parse(resolveEffectiveParams([machine.instance])); + normalizeSchema(charter.params).assert(resolveEffectiveParams([machine.instance])); validateMachineActionStateCompatibility(machine.instance, machine.charter); validateExecutorConfig(machine.instance, machine.charter, executor); return machine; @@ -271,7 +273,7 @@ function validateExecutorConfig( const config = node.executorConfig?.[namespace]; if (config !== undefined) { try { - schema.parse(config); + normalizeSchema(schema).assert(config); } catch (error) { throw new Error( `Invalid executorConfig["${namespace}"] on node "${node.key}": ${ @@ -806,15 +808,15 @@ export async function executeCommand< let input = message.input; if (resolved.command.inputSchema) { - const parsed = resolved.command.inputSchema.safeParse(input); - if (!parsed.success) { + const checked = normalizeSchema(resolved.command.inputSchema).check(input); + if (checked.issues) { return enqueueImmediateActionResult(machine, message, { success: false, - error: parsed.error.message, + error: formatSchemaIssues(checked.issues), + issues: checked.issues, callId: message.callId, }); } - input = parsed.data; } const projectorMachine = machine as ProjectorMachine; @@ -1240,7 +1242,16 @@ function enqueueActivationStep( if (result.value !== undefined) { messages.push(assistantMessageFromTextOutput(result.value, output) as FrameMessage); } - messages.push(...completionMessages); + // This step's completion points at the last result it produced (an action + // result or an assistant message) so hosts need not scan for it. + const lastResult = lastResultRef(stepFrameId, messages); + messages.push( + ...completionMessages.map((message) => + lastResult && isWorkCompletionMessage(message) && message.activationId === activation.activationId + ? ({ ...message, lastResult } as FrameMessage) + : message, + ), + ); if (continues) { messages.push({ type: "work", @@ -1261,6 +1272,19 @@ function enqueueActivationStep( machine.enqueueFrame(frame); } +function lastResultRef( + frameId: string, + messages: readonly FrameMessage[], +): MessageRef | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]!; + if (message.type === "assistant" || (message.type === "action" && message.kind === "result")) { + return { frameId, messageIndex: index }; + } + } + return undefined; +} + function mergeExecutionReports( first: ExecutionReport | undefined, second: ExecutionReport | undefined, @@ -1484,7 +1508,7 @@ export function applyInstanceMessage( const address = { instanceId: message.instanceId, stateKey: message.stateKey }; const state = findResolvedState(root, address, options); const next = applyStateUpdate(state.container.value, message.update); - state.descriptor.schema.parse(next); + normalizeSchema(state.descriptor.schema).assert(next); // Realization is a logged write: an unrealized state's container attaches // here, at the instance resolveStates derived for the declaring // contributor's scope, with the updater having seen init as `current`. @@ -1649,7 +1673,7 @@ function validateStateValue( value: unknown, ): void { const state = findResolvedState(root, address); - state.descriptor.schema.parse(value); + normalizeSchema(state.descriptor.schema).assert(value); } function findResolvedState( diff --git a/packages/projector/src/params.ts b/packages/projector/src/params.ts index 5ffa58c..b978f32 100644 --- a/packages/projector/src/params.ts +++ b/packages/projector/src/params.ts @@ -1,38 +1,48 @@ -import { z } from "zod"; +import { + jsonSchemaProperties, + jsonSchemaRequired, + normalizeSchema, + schemaFromJsonSchema, + type InferSchemaValue, + type Schema, +} from "./schema.ts"; import type { AnyAction, Instance, Node } from "./types.ts"; -export const emptyParamsSchema = z.object({}); - export type JsonObject = Record; -export type AnyParamsSchema = z.ZodObject; -type ParamsSchemaKeys = keyof TSchema["shape"]; +/** A params schema: any Standard Schema over an object. */ +export type AnyParamsSchema = Schema; +export const emptyParamsSchema = schemaFromJsonSchema<{}>({ + type: "object", + properties: {}, + additionalProperties: false, +}); +type ParamsSchemaKeys = keyof InferSchemaValue; export type InferParams = TSchema extends AnyParamsSchema - ? z.output + ? InferSchemaValue : {}; export type InputParams = TSchema extends AnyParamsSchema - ? z.input + ? InferSchemaValue : {}; /** * never = compatible; otherwise a diagnostic object validators intersect into * the config parameter so the assignability failure names the mismatch. - * Compares the provider's resolved output against the consumer's INPUT: the - * consumer re-parses what it picks (resolveActionParams), so a key its schema - * can default or treat as optional need not be provided. + * Compares the value supplied by the provider with the value accepted by the + * consumer. The consumer revalidates the keys selected by resolveActionParams. */ export type ParamsSatisfyError< TSuper extends AnyParamsSchema, TSub extends AnyParamsSchema, > = ParamsSchemaKeys extends never ? never - : z.output extends z.input + : InferSchemaValue extends InferSchemaValue ? never : { readonly __paramCompatibilityError: "params do not satisfy required schema"; - readonly expected: z.input; - readonly received: z.output; + readonly expected: InferSchemaValue; + readonly received: InferSchemaValue; }; export type InferNodeParams = @@ -50,7 +60,9 @@ export type InputCharterParams = export function normalizeParamsSchema( schema: AnyParamsSchema | undefined, ): AnyParamsSchema { - return schema ?? emptyParamsSchema; + const resolved = schema ?? emptyParamsSchema; + normalizeSchema(resolved); + return resolved; } export function resolveEffectiveParams(instancePath: readonly Instance[]): JsonObject { @@ -77,7 +89,8 @@ export function resolveNodeParams( ): JsonObject { const schema = normalizeParamsSchema(node.params); const picked = pickDeclaredParamKeys(effectiveParams, schema); - return schema.parse(picked) as JsonObject; + normalizeSchema(schema).assert(picked); + return picked; } export function resolveActionParams( @@ -86,16 +99,18 @@ export function resolveActionParams( ): JsonObject { const schema = normalizeParamsSchema(action.params); const picked = pickDeclaredParamKeys(nodeParams, schema); - return schema.parse(picked) as JsonObject; + normalizeSchema(schema).assert(picked); + return picked; } /** * Bind-time mirror of the type-level params check, for everything types cannot * see (string refs, computed-closure returns, hydrated dry nodes, JS callers). - * Every param key the action's schema cannot resolve without (no optional/ - * default) must be declared by the node: resolveNodeParams filters effective - * params down to the node's declared keys before resolveActionParams picks - * from them, so an undeclared key can never reach the action at runtime. + * Every param key the action's schema cannot resolve without (required on the + * input side of its JSON Schema) must be declared by the node: + * resolveNodeParams filters effective params down to the node's declared keys + * before resolveActionParams picks from them, so an undeclared key can never + * reach the action at runtime. */ export function assertNodeActionParamsCompatibility( action: AnyAction, @@ -105,15 +120,14 @@ export function assertNodeActionParamsCompatibility( if (!action.params) { return; } - const nodeSchema = normalizeParamsSchema(node.params); - for (const [key, field] of Object.entries(action.params.shape)) { - if (key in nodeSchema.shape) { + const nodeKeys = declaredParamKeys(normalizeParamsSchema(node.params)); + const actionInput = normalizeSchema(action.params).jsonSchema(); + const required = jsonSchemaRequired(actionInput); + for (const key of jsonSchemaProperties(actionInput)) { + if (nodeKeys.includes(key) || !required.has(key)) { continue; } - if ((field as z.ZodType).safeParse(undefined).success) { - continue; - } - const declared = Object.keys(nodeSchema.shape).join(", ") || "none"; + const declared = nodeKeys.join(", ") || "none"; throw new Error( `Node "${node.key}" ${kind} "${action.name}" requires param "${key}" but the node declares: ${declared}`, ); @@ -125,10 +139,14 @@ export function pickDeclaredParamKeys( schema: AnyParamsSchema, ): JsonObject { const picked: JsonObject = {}; - for (const key of Object.keys(schema.shape)) { + for (const key of declaredParamKeys(schema)) { if (key in params) { picked[key] = params[key]; } } return picked; } + +function declaredParamKeys(schema: AnyParamsSchema): string[] { + return jsonSchemaProperties(normalizeSchema(schema).jsonSchema()); +} diff --git a/packages/projector/src/schema.ts b/packages/projector/src/schema.ts new file mode 100644 index 0000000..51c1d20 --- /dev/null +++ b/packages/projector/src/schema.ts @@ -0,0 +1,268 @@ +import { Validator, type OutputUnit } from "@cfworker/json-schema"; +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; + +/** + * Projector schemas are validation-only contracts: accepted values keep the + * same type and value. A source library may expose richer decoding behavior, + * but Projector converts only its input contract to JSON Schema and uses that + * document as the canonical validator and serialized form. + */ +export type Schema = StandardSchemaV1; +export type AnySchema = StandardSchemaV1; +export type InferSchemaValue = StandardSchemaV1.InferInput; + +export type JsonSchema = Record; +export type SchemaIssue = StandardSchemaV1.Issue; +export type SchemaCheckResult = + | { readonly issues?: undefined } + | { readonly issues: readonly SchemaIssue[] }; + +export type JsonSchemaOptions = { + /** Defaults to `"draft-2020-12"`. */ + target?: StandardJSONSchemaV1.Target; + libraryOptions?: Record; +}; + +export type NormalizedSchema = { + /** Checks the original value without returning or applying a decoded value. */ + check(value: unknown): SchemaCheckResult; + /** Throws {@link SchemaError} when the original value is invalid. */ + assert(value: unknown): void; + accepts(value: unknown): value is Value; + /** The canonical input JSON Schema document. */ + jsonSchema(options?: JsonSchemaOptions): JsonSchema; +}; + +export type SchemaTransformError = + [StandardSchemaV1.InferInput] extends [StandardSchemaV1.InferOutput] + ? [StandardSchemaV1.InferOutput] extends [StandardSchemaV1.InferInput] + ? unknown + : SchemaTransformDiagnostic + : SchemaTransformDiagnostic; + +type SchemaTransformDiagnostic = { + readonly __schemaTransformError: "Projector schemas must have identical input and output types"; + readonly input: StandardSchemaV1.InferInput; + readonly output: StandardSchemaV1.InferOutput; +}; + +export class SchemaError extends Error { + readonly issues: readonly SchemaIssue[]; + + constructor(issues: readonly SchemaIssue[]) { + super(formatSchemaIssues(issues)); + this.name = "SchemaError"; + this.issues = issues; + } +} + +export function formatSchemaIssues(issues: readonly SchemaIssue[]): string { + if (issues.length === 0) return "Validation failed"; + return issues + .map((issue) => { + const path = formatIssuePath(issue.path); + return path ? `${issue.message} (at ${path})` : issue.message; + }) + .join("; "); +} + +function formatIssuePath(path: SchemaIssue["path"]): string { + if (!path || path.length === 0) return ""; + return path + .map((segment) => + typeof segment === "object" && segment !== null ? segment.key : segment, + ) + .map(String) + .join("."); +} + +const DEFAULT_TARGET: StandardJSONSchemaV1.Target = "draft-2020-12"; + +const normalized = new WeakMap>(); + +/** + * Converts a Standard Schema's input contract to Projector's canonical JSON + * Schema form. The source validator is deliberately never invoked: authored + * and hydrated schemas therefore have exactly the same validation behavior, + * and source-library coercions/transforms cannot enter the durable machine. + */ +export function normalizeSchema( + schema: T & SchemaTransformError, +): NormalizedSchema> { + const cached = normalized.get(schema); + if (cached) return cached; + const built = buildNormalizedSchema(schema); + normalized.set(schema, built); + return built; +} + +function buildNormalizedSchema( + schema: StandardSchemaV1, +): NormalizedSchema { + const props = schema["~standard"]; + if (!props || props.version !== 1 || typeof props.validate !== "function") { + throw new Error("Expected a Standard Schema (an object with a `~standard` v1 property)"); + } + + const convert = resolveJsonSchemaConverter(schema, props); + const canonicalDocument = convert({ target: DEFAULT_TARGET }); + const check = createJsonSchemaCheck(canonicalDocument); + + return { + check, + assert(value) { + const result = check(value); + if (result.issues) throw new SchemaError(result.issues); + }, + accepts(value): value is Value { + return !check(value).issues; + }, + jsonSchema(options = {}) { + if (options.target === undefined && options.libraryOptions === undefined) { + return canonicalDocument; + } + return convert({ + target: options.target ?? DEFAULT_TARGET, + libraryOptions: options.libraryOptions, + }); + }, + }; +} + +type Converter = (options: StandardJSONSchemaV1.Options) => JsonSchema; + +function resolveJsonSchemaConverter( + schema: StandardSchemaV1, + props: StandardSchemaV1.Props, +): Converter { + const json = (props as Partial).jsonSchema; + if (json && typeof json.input === "function") { + return (options) => json.input(options); + } + throw new Error( + `Schema (vendor "${props.vendor}") has no JSON Schema source: implement \`~standard.jsonSchema\` or wrap it with withJsonSchema()`, + ); +} + +/** Attaches Projector's single canonical JSON Schema to a Standard Schema. */ +export function withJsonSchema( + schema: T, + jsonSchema: JsonSchema, +): T & StandardJSONSchemaV1, StandardSchemaV1.InferOutput> { + const converter: StandardJSONSchemaV1.Converter = { + input: () => jsonSchema, + output: () => jsonSchema, + }; + const wrapped = Object.create(schema); + Object.defineProperty(wrapped, "~standard", { + value: { ...schema["~standard"], jsonSchema: converter }, + enumerable: false, + }); + return wrapped; +} + +/** Re-enters Projector's canonical JSON Schema as a validation-only schema. */ +export function schemaFromJsonSchema( + jsonSchema: unknown, +): Schema { + const document = jsonSchema as JsonSchema; + let check: ((value: unknown) => SchemaCheckResult) | undefined; + const schema: Schema = { + "~standard": { + version: 1, + vendor: "projector", + validate(value) { + const result = (check ??= createJsonSchemaCheck(document))(value); + return result.issues ? result : { value: value as Value }; + }, + }, + }; + return withJsonSchema(schema, document); +} + +function createJsonSchemaCheck( + document: JsonSchema, +): (value: unknown) => SchemaCheckResult { + // Report every problem (no short-circuit): a caller fixes all fields in + // one round trip, and the issue list is the durable record of a rejection. + const validator = new Validator(document as never, "2020-12", false); + return (value) => { + try { + // Validate the value as JSON would carry it: the machine's durable form + // is JSON, so a key holding `undefined` is absent, not a wrong type. + const result = validator.validate(jsonView(value)); + return result.valid ? {} : { issues: jsonSchemaIssues(result.errors) }; + } catch (error) { + return { + issues: [{ message: error instanceof Error ? error.message : String(error) }], + }; + } + }; +} + +/** The value as JSON serialization sees it: undefined properties dropped, undefined array items null. */ +function jsonView(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => (item === undefined ? null : jsonView(item))); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, item] of Object.entries(value as Record)) { + if (item !== undefined) out[key] = jsonView(item); + } + return out; + } + return value; +} + +/** + * Validator errors → Standard Schema issues, structurally. The validator + * reports each problem at a JSON pointer; we keep the leaf-most report and + * drop its container echoes: + * - `properties` at the parent ("Property x does not match schema") echoes + * the error at `#/x`. + * - `additionalProperties` at the parent names the offending key only in + * prose; the paired `false` error at `#/x` carries the path, so that one + * becomes the issue — unless `#/x` also failed its own subschema, in which + * case the property is declared and the `false` report is an echo too. + * A missing required property is reported at its parent; the message names it. + */ +function jsonSchemaIssues(errors: OutputUnit[]): readonly SchemaIssue[] { + const reportedAt = new Set( + errors.filter((error) => error.keyword !== "false").map((error) => error.instanceLocation), + ); + const issues: SchemaIssue[] = []; + for (const error of errors) { + if (error.keyword === "properties" || error.keyword === "additionalProperties") continue; + const path = decodeJsonPointer(error.instanceLocation); + if (error.keyword === "false") { + if (reportedAt.has(error.instanceLocation)) continue; + issues.push({ message: "Property is not allowed.", path }); + continue; + } + issues.push({ message: error.error, path }); + } + return issues; +} + +function decodeJsonPointer(pointer: string): string[] { + if (pointer === "#") return []; + const path = pointer.startsWith("#") ? pointer.slice(1) : pointer; + if (!path.startsWith("/")) return [path]; + return path + .slice(1) + .split("/") + .map((segment) => + decodeURIComponent(segment).replace(/~1/g, "/").replace(/~0/g, "~"), + ); +} + +/** JSON Schema properties declared by an object schema, in declaration order. */ +export function jsonSchemaProperties(document: JsonSchema): string[] { + const properties = document.properties; + return properties && typeof properties === "object" ? Object.keys(properties) : []; +} + +/** Property names an object schema cannot resolve without. */ +export function jsonSchemaRequired(document: JsonSchema): Set { + const required = document.required; + return new Set(Array.isArray(required) ? required.map(String) : []); +} diff --git a/packages/projector/src/serialization.ts b/packages/projector/src/serialization.ts index a1b8d51..360d0d4 100644 --- a/packages/projector/src/serialization.ts +++ b/packages/projector/src/serialization.ts @@ -1,4 +1,4 @@ -import * as z from "zod"; +import { normalizeSchema, schemaFromJsonSchema } from "./schema.ts"; import { createNode, normalizeStateDescriptor } from "./create.ts"; import { isComputedMemberDef } from "./computed-parts.ts"; import { @@ -514,18 +514,16 @@ export function serializeOutputConfig( return { audience: output.audience, - schema: output.schema ? z.toJSONSchema(output.schema) : undefined, + schema: output.schema ? normalizeSchema(output.schema).jsonSchema() : undefined, }; } function serializeParams(params: Node["params"]): unknown { - return z.toJSONSchema(params); + return normalizeSchema(params).jsonSchema(); } function hydrateParams(params: unknown): Node["params"] { - return z.fromJSONSchema( - params as Parameters[0], - ) as Node["params"]; + return schemaFromJsonSchema(params) as Node["params"]; } export function hydrateOutputConfig( @@ -533,11 +531,7 @@ export function hydrateOutputConfig( ): AnyOutputConfig { return { audience: output.audience, - schema: output.schema - ? z.fromJSONSchema( - output.schema as Parameters[0], - ) - : undefined, + schema: output.schema ? schemaFromJsonSchema(output.schema) : undefined, }; } @@ -588,7 +582,7 @@ export function serializeStateDescriptor( } : {}), init: state.init, - schema: z.toJSONSchema(state.schema), + schema: normalizeSchema(state.schema).jsonSchema(), }; } @@ -606,9 +600,7 @@ export function hydrateStateDescriptor( return normalizeStateDescriptor({ key: serialized.key, - schema: z.fromJSONSchema( - serialized.schema as Parameters[0], - ), + schema: schemaFromJsonSchema(serialized.schema), init: serialized.init, scope: serialized.scope, onInitConflict: serialized.onInitConflict, diff --git a/packages/projector/src/state.ts b/packages/projector/src/state.ts index eac6bdb..b35325b 100644 --- a/packages/projector/src/state.ts +++ b/packages/projector/src/state.ts @@ -1,3 +1,4 @@ +import { normalizeSchema } from "./schema.ts"; import type { Contributor } from "./contributors.ts"; import { hoistStateInstance, collectContributors } from "./contributors.ts"; import { encodeProjectionAddress } from "./projection-address.ts"; @@ -124,7 +125,7 @@ type StateGroup = { /** * Resolves every state declared in scope of the instance tree WITHOUT - * provisioning: existing containers are validated/parsed (keeping the + * provisioning: existing containers are validated (keeping the * onInitConflict reset path for schema evolution); declared-but-unrealized * states resolve to a detached container view carrying the init value, and * nothing is attached to the tree. Realization is a logged write — @@ -355,7 +356,7 @@ function allSchemasValidate( entries: StateGroup["entries"], value: unknown, ): boolean { - return entries.every((entry) => entry.descriptor.schema.safeParse(value).success); + return entries.every((entry) => normalizeSchema(entry.descriptor.schema).accepts(value)); } function validateAllSchemas( @@ -363,7 +364,7 @@ function validateAllSchemas( value: unknown, stateKey: string, ): void { - const invalid = entries.find((entry) => !entry.descriptor.schema.safeParse(value).success); + const invalid = entries.find((entry) => !normalizeSchema(entry.descriptor.schema).accepts(value)); if (invalid) { throw new Error(`Incompatible state descriptors for "${stateKey}": schema validation failed`); } diff --git a/packages/projector/src/types.ts b/packages/projector/src/types.ts index 9625154..f627a39 100644 --- a/packages/projector/src/types.ts +++ b/packages/projector/src/types.ts @@ -1,5 +1,5 @@ -import type { z } from "zod"; -import type { AnyParamsSchema, JsonObject } from "./params.ts"; +import type { Schema, SchemaIssue } from "./schema.ts"; +import type { AnyParamsSchema, InferParams, JsonObject } from "./params.ts"; export type Ref = string; @@ -153,6 +153,22 @@ export type AssistantMessage = { export type ActorMessage = UserMessage | AssistantMessage; +/** + * A history boundary. For a generator that can see it, rendered history + * begins at the frame carrying it; earlier frames are not projected. They + * stay in the log and in the fold — state is unaffected — and work + * scheduling and absorption ignore horizons entirely: history is what the + * model sees, work is durable state. Messages after the horizon in the same + * frame render normally, so an app that wants a summary puts it there or in + * the next frame; core has no opinion about what follows. Audience defaults + * to broadcast, like a user message; a targeted horizon cuts only its + * targets' histories. + */ +export type HorizonMessage = { + type: "horizon"; + audience?: Audience; +}; + export type AnyActorMessage = ActorMessage; /** @@ -239,7 +255,7 @@ export type StateProjection = { export type StateDescriptor = { key: string; - schema: z.ZodType; + schema: Schema; init?: S | (() => S); scope?: "hoist" | "local"; onInitConflict?: "error" | "replace"; @@ -363,12 +379,12 @@ export type Action< params?: TParams; name: TName; description?: string; - inputSchema?: z.ZodType; + inputSchema?: Schema; /** The executor must supply this action's native tool representation and execution. */ executorOwned?: boolean; run?: ( input: I, - ctx: ActionContext>, + ctx: ActionContext>, ) => O | Promise; }; @@ -377,7 +393,7 @@ export type AnyAction = { params?: TParams; name: string; description?: string; - inputSchema?: z.ZodType; + inputSchema?: Schema; executorOwned?: boolean; run?: (input: any, ctx: any) => any | Promise; }; @@ -736,10 +752,24 @@ export type WorkActivationMessage = { continuationState?: unknown; }; +/** A pointer to one message in the log. */ +export type MessageRef = { + frameId: string; + messageIndex: number; +}; + export type WorkCompletionMessage = { type: "work"; kind: "completion"; activationId: string; + /** + * The last result-bearing message the completing step produced — an + * action result (a terminal action's included) or an assistant message — + * as a pointer into the completion's own frame. Most apps treat it as the + * activation's result; apps with another convention read the frames + * themselves. Absent when the step produced neither (cancelled, empty). + */ + lastResult?: MessageRef; /** * The generator whose work completed. Optional for completions that pair * with an activation message already in the log; required to record a @@ -843,6 +873,8 @@ export type ActionResultMessage = { success: boolean; value?: unknown; error?: string; + /** Canonical validator issues when the failure was input validation. */ + issues?: readonly SchemaIssue[]; terminal?: boolean; outputMessageIndices?: number[]; audience?: Audience; @@ -861,7 +893,10 @@ export type ExecuteActionResult = } | { success: false; + /** Human-readable rendering; for input rejections, formatSchemaIssues(issues). */ error: string; + /** Canonical validator issues when the failure was input validation. */ + issues?: readonly SchemaIssue[]; value?: T; messages?: FrameMessage[]; terminal?: boolean; @@ -873,6 +908,7 @@ export type FrameMessage = ( | ActionMessage | InstanceMessage | WorkMessage + | HorizonMessage ) & Record; @@ -940,7 +976,7 @@ export type Frame = FrameDraft & { */ export type OutputConfig = { audience?: Audience; - schema?: z.ZodType; + schema?: Schema; mapTextBlock?: (text: string) => TDataContent; }; @@ -1025,7 +1061,7 @@ export type ProjectorExecutor = { * Validates each node's `executorConfig[identity.name]` at machine creation * so misconfiguration fails at bind time, not mid-activation. */ - configSchema?: z.ZodType; + configSchema?: Schema; run( request: ExecutorRunRequest, ): ExecutorRunResult | Promise>; @@ -1044,7 +1080,7 @@ export type Charter< version?: string; params: TParams; /** The log's data-content vocabulary (see CharterConfig.dataContent). */ - dataContent?: z.ZodType; + dataContent?: Schema; nodes: Record>; /** Unified action registry; tools and commands share one namespace. */ actions: Record; @@ -1072,7 +1108,7 @@ export type CharterConfig = { * sole inference site — otherwise a mismatched node's data type would union * into TDataContent instead of failing against it. */ - dataContent?: z.ZodType; + dataContent?: Schema; nodes: readonly Node>[]; /** Sugar: registered into `actions` alongside `commands`. */ tools?: readonly AnyAction[]; diff --git a/packages/projector/src/visibility.ts b/packages/projector/src/visibility.ts index 389ce74..d6799a8 100644 --- a/packages/projector/src/visibility.ts +++ b/packages/projector/src/visibility.ts @@ -9,6 +9,7 @@ import type { Frame, GeneratorId, GeneratorRuntime, + HorizonMessage, } from "./types.ts"; export function isActorMessage( @@ -22,6 +23,22 @@ export function isActorMessage( return false; } +export function isHorizonMessage(message: unknown): message is HorizonMessage { + return Boolean( + message && + typeof message === "object" && + (message as { type?: unknown }).type === "horizon", + ); +} + +export function horizonVisibleToGenerator( + message: HorizonMessage, + frame: Frame, + targetGeneratorId: GeneratorId, +): boolean { + return audienceAllowsGenerator(message.audience ?? "broadcast", frame, targetGeneratorId); +} + export function isActionMessage( message: unknown, ): message is ActionMessage { @@ -128,12 +145,13 @@ export function visibleFramesForGenerator( targetGeneratorId: GeneratorId, runtime: GeneratorRuntime, activationId?: string, + options: VisibleFramesOptions = {}, ): Frame[] { const activationFrameIndex = activationFrameIndexFor(frames, activationId, { requireActivationFrame: activationId !== undefined, }); - return frames.flatMap((frame, frameIndex) => { + const visible = frames.flatMap((frame, frameIndex) => { if (!frameVisibleByActivationHistory( frame, frameIndex, @@ -154,11 +172,47 @@ export function visibleFramesForGenerator( if (isActionMessage(message)) { return actionMessageVisibleToGenerator(message, frame, targetGeneratorId); } + if (isHorizonMessage(message)) { + return horizonVisibleToGenerator(message, frame, targetGeneratorId); + } return true; }); return frameMessages.length > 0 ? [{ ...frame, messages: frameMessages }] : []; }); + return options.horizon === "apply" ? fromLatestHorizon(visible) : visible; +} + +export type VisibleFramesOptions = { + /** + * "apply": history begins at the latest visible horizon (projection). + * "ignore" (default): horizons are ordinary messages (work absorption — + * work is durable state and never forgets a frame). + */ + horizon?: "apply" | "ignore"; +}; + +/** + * Rendered history starts at the latest visible horizon: earlier frames + * drop, and within the horizon's frame so do the messages before it. The + * horizon message itself stays, so history projections can see the cut. + */ +function fromLatestHorizon( + frames: Frame[], +): Frame[] { + for (let index = frames.length - 1; index >= 0; index -= 1) { + const frame = frames[index]!; + let at = -1; + for (let m = frame.messages.length - 1; m >= 0; m -= 1) { + if (isHorizonMessage(frame.messages[m])) { + at = m; + break; + } + } + if (at === -1) continue; + return [{ ...frame, messages: frame.messages.slice(at) }, ...frames.slice(index + 1)]; + } + return frames; } export function frameVisibleByActivationHistory( diff --git a/packages/projector/test/conformance/projection.test.ts b/packages/projector/test/conformance/projection.test.ts index 331f090..0af0898 100644 --- a/packages/projector/test/conformance/projection.test.ts +++ b/packages/projector/test/conformance/projection.test.ts @@ -203,6 +203,34 @@ describe("conformance: projection IR", () => { ]); }); + it("renders history from the latest visible horizon; a targeted horizon cuts only its targets", () => { + const generator = createNode({ + key: "generator", + runtime: { type: "generator", trigger: { type: "parent-completion" } }, + }); + const root = createNode({ key: "root", members: [generator] }); + const generatorId = "member:r/generator"; + const compile = (frameHistory: Frame[]) => + compileProjection({ id: "r", isSource: true, node: root }, { targetGeneratorId: generatorId, frameHistory }).history; + + expect( + compile([ + frame("before", [{ ...textUserMessage("before") }]), + frame("cut", [{ ...textUserMessage("same frame, before the cut") }, { type: "horizon" }, { ...textUserMessage("summary") }]), + frame("after", [{ ...textUserMessage("after") }]), + ]), + ).toEqual([{ type: "horizon" }, { ...textUserMessage("summary") }, { ...textUserMessage("after") }]); + + // A horizon addressed to someone else is not this generator's cut. + expect( + compile([ + frame("before", [{ ...textUserMessage("before") }]), + frame("other", [{ type: "horizon", audience: { type: "instance", instanceId: "r" } }]), + frame("after", [{ ...textUserMessage("after") }]), + ]), + ).toEqual([{ ...textUserMessage("before") }, { ...textUserMessage("after") }]); + }); + it("applies queued delivery and live activation history", () => { const root = createNode({ key: "root", diff --git a/packages/projector/test/conformance/schema.test.ts b/packages/projector/test/conformance/schema.test.ts new file mode 100644 index 0000000..85b81fc --- /dev/null +++ b/packages/projector/test/conformance/schema.test.ts @@ -0,0 +1,174 @@ +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + applyInstanceMessage, + createAction, + createCharter, + createMachine, + createNode, + createSourceInstance, + createState, + executeCommand, + hydrateInstance, + inspectCompiledProjectionTree, + normalizeSchema, + resolveStates, + serializeInstance, + type Instance, +} from "../../index.ts"; +import { charter } from "./helpers.ts"; +import * as z from "zod"; + +type Counter = { count: number }; + +const counterJsonSchema = { + type: "object", + properties: { count: { type: "number" } }, + required: ["count"], + additionalProperties: false, +}; + +/** A hand-written Standard Schema (no library) that also converts to JSON Schema. */ +const counterSchema: StandardSchemaV1 & StandardJSONSchemaV1 = { + "~standard": { + version: 1, + vendor: "handwritten", + validate: (value) => + typeof value === "object" && + value !== null && + typeof (value as Counter).count === "number" + ? { value: value as Counter } + : { issues: [{ message: "expected a number", path: ["count"] }] }, + jsonSchema: { + input: () => counterJsonSchema, + output: () => counterJsonSchema, + }, + }, +}; + +describe("conformance: Standard Schema positions", () => { + it("validates state updates through a non-zod schema", () => { + const counter = createState({ key: "counter", schema: counterSchema, init: { count: 0 } }); + expectTypeOf(counter.init).toEqualTypeOf Counter) | undefined>(); + const node = createNode({ key: "root", states: [counter] }); + const root: Instance = { id: "r", isSource: true, node }; + const registry = charter({ nodes: [node], states: [counter] }); + + applyInstanceMessage( + root, + { type: "instance", kind: "state.update", instanceId: "r", stateKey: "counter", update: { op: "replace", value: { count: 2 } } }, + registry, + ); + expect(resolveStates(root).find((s) => s.address.stateKey === "counter")?.container.value).toEqual({ count: 2 }); + + expect(() => + applyInstanceMessage( + root, + { type: "instance", kind: "state.update", instanceId: "r", stateKey: "counter", update: { op: "replace", value: { count: "two" } } }, + registry, + ), + ).toThrow(/Expected "number"\. \(at count\)/); + }); + + it("carries the schema's JSON Schema on the compiled tool surface", () => { + const setCounter = createAction({ + state: null, + name: "setCounter", + inputSchema: counterSchema, + run: (input) => { + expectTypeOf(input).toEqualTypeOf(); + }, + }); + const node = createNode({ + key: "root", + tools: [setCounter], + runtime: { type: "generator", trigger: { type: "actor-frame" } }, + }); + const tree = inspectCompiledProjectionTree(createSourceInstance({ id: "r", node }), { + charter: charter({ nodes: [node] }), + }); + + const tools = tree.roots[0]!.compiled.tools; + expect(tools.find((tool) => tool.name === "setCounter")?.inputSchema).toEqual(counterJsonSchema); + }); + + it("round-trips inline state and output schemas through serialization as JSON Schema", () => { + const node = createNode({ + key: "inline", + states: [{ key: "counter", schema: counterSchema, init: { count: 0 } }], + output: { audience: "broadcast", schema: counterSchema }, + }); + const registry = charter(); + + const serialized = serializeInstance({ id: "i", isSource: true, node }, registry); + if (typeof serialized.node === "string") throw new Error("expected inline node"); + expect(serialized.node.output?.schema).toEqual(counterJsonSchema); + + const hydrated = hydrateInstance(JSON.parse(JSON.stringify(serialized)), registry); + const state = normalizeSchema(hydrated.node.states[0]!.schema); + expect(state.accepts({ count: 1 })).toBe(true); + expect(state.accepts({ count: "one" })).toBe(false); + expect(normalizeSchema(hydrated.node.output!.schema!).accepts({ count: 3 })).toBe(true); + }); + + it("never invokes source validators and rejects schemas without a JSON Schema source", () => { + let validationCalls = 0; + const asyncSchema: StandardSchemaV1 & StandardJSONSchemaV1 = { + "~standard": { + version: 1, + vendor: "handwritten", + validate: async (value) => { + validationCalls += 1; + return { value: value as Counter }; + }, + jsonSchema: { + input: () => counterJsonSchema, + output: () => counterJsonSchema, + }, + }, + }; + const state = createState({ key: "counter", schema: asyncSchema }); + expect(validationCalls).toBe(0); + expect(normalizeSchema(state.schema).accepts({ count: 1 })).toBe(true); + expect(validationCalls).toBe(0); + + const opaqueSchema: StandardSchemaV1 = { + "~standard": { version: 1, vendor: "handwritten", validate: (value) => ({ value: value as Counter }) }, + }; + expect(() => createAction({ state: null, name: "opaque", inputSchema: opaqueSchema })).toThrow( + /no JSON Schema source/, + ); + }); + + it("records canonical validation issues durably for rejected command input", async () => { + const command = createAction({ + state: null, + name: "greet", + inputSchema: z.object({ NAME: z.string() }).strict(), + run: ({ NAME }) => `hi ${NAME}`, + }); + const node = createNode({ key: "root", commands: [command] }); + const machine = createMachine({ instance: { id: "r", isSource: true, node }, charter: createCharter({ nodes: [node], commands: [command] }) }); + const result = await executeCommand(machine, { type: "action", kind: "request", action: "command", name: "greet", input: { NOPE: 1 }, callId: "c1" }); + expect(result.success).toBe(false); + if (result.success) throw new Error(); + expect(result.issues?.map((issue) => issue.path?.map(String).join("."))).toEqual(["", "NOPE"]); + expect(result.error).toContain("NAME"); + expect(result.issues?.[1]).toEqual({ message: "Property is not allowed.", path: ["NOPE"] }); + const recorded = machine.frames.flatMap((frame) => frame.messages).find((m) => m.type === "action" && m.kind === "result"); + expect(recorded).toMatchObject({ success: false, issues: result.issues }); + }); + + it("reports every issue structurally, validating the value as JSON carries it", () => { + const schema = normalizeSchema( + z.object({ a: z.number(), b: z.string(), nested: z.object({ NAME: z.string() }).strict().optional() }).strict(), + ); + // Three problems, three issues: no short-circuit, and a declared property + // that fails its own subschema is reported once, at its own path. + expect(schema.check({ a: "1", b: 2, c: 3 }).issues?.map((issue) => issue.path?.join("."))).toEqual(["a", "b", "c"]); + // Nested unknown keys keep their full path. + expect(schema.check({ a: 1, b: "x", nested: { NOPE: 1 } }).issues?.map((issue) => issue.path?.join("."))).toEqual(["nested", "nested.NOPE"]); + // A key holding undefined is absent, as it would be in JSON. + expect(schema.accepts({ a: 1, b: "x", nested: undefined })).toBe(true); + }); +}); diff --git a/packages/projector/test/conformance/work.test.ts b/packages/projector/test/conformance/work.test.ts index 7d4039d..09a6f86 100644 --- a/packages/projector/test/conformance/work.test.ts +++ b/packages/projector/test/conformance/work.test.ts @@ -96,6 +96,80 @@ describe("conformance: work scheduling", () => { await expect(drain(runMachine(machine, { scheduleWork: false }))).resolves.toEqual([]); }); + it("schedules and absorbs work across a horizon while activations see history from it", async () => { + const { executor, requests } = createRecordingExecutor(); + const root = createNode({ + key: "root", + runtime: { type: "generator", trigger: { type: "actor-frame" } }, + }); + const machine = createMachine({ + id: "horizon-demo", + instance: { id: "r", isSource: true, node: root }, + charter: charter(), + executor, + }); + const texts = (history: FrameMessage[]) => history.map((message) => ("text" in message ? message.text : message.type)); + + machine.enqueueFrame({ messages: [{ ...textUserMessage("old") }] }); + await drain(runMachine(machine)); + expect(texts(requests[0]!.inference.history)).toContain("old"); + + // The horizon's frame is an actor frame like any other: it schedules work. + machine.enqueueFrame({ messages: [{ type: "horizon" }, { ...textUserMessage("summary") }] }); + await drain(runMachine(machine)); + expect(requests).toHaveLength(2); + // An activation compiled after the horizon sees history from it. + expect(requests[1]!.inference.history[0]).toEqual({ type: "horizon" }); + expect(texts(requests[1]!.inference.history)).not.toContain("old"); + // The fold kept every frame: the log is untouched. + expect(machine.frames.some((frame) => frame.messages.some((message) => "text" in message && message.text === "old"))).toBe(true); + }); + + it("points each completion at the last result its step produced", async () => { + const generator = () => + createNode({ key: "root", runtime: { type: "generator", trigger: { type: "actor-frame" } } }); + const completionOf = (frames: Frame[]) => { + for (const frame of frames) { + const index = frame.messages.findIndex((m) => m.type === "work" && m.kind === "completion"); + if (index !== -1) return { frame, completion: frame.messages[index] as FrameMessage & { lastResult?: { frameId: string; messageIndex: number } } }; + } + throw new Error("no completion"); + }; + + // Final text: the pointer lands on the assistant message. + const text = createMachine({ + instance: { id: "r", isSource: true, node: generator() }, + charter: charter(), + executor: createRecordingExecutor(() => ({ completionReason: "done", value: "all done" })).executor, + }); + text.enqueueFrame({ messages: [{ ...textUserMessage("go") }] }); + const t = completionOf(await drain(runMachine(text))); + expect(t.completion.lastResult).toEqual({ frameId: t.frame.id, messageIndex: expect.any(Number) }); + expect(t.frame.messages[t.completion.lastResult!.messageIndex]).toMatchObject({ type: "assistant", text: "all done" }); + + // Terminal action: the pointer lands on its result, even with later frames from the executor. + const terminal = createMachine({ + instance: { id: "r", isSource: true, node: generator() }, + charter: charter(), + executor: createRecordingExecutor(() => ({ + completionReason: "terminal-action", + frames: [{ messages: [{ type: "action", kind: "result", action: "tool", name: "resolve", callId: "c1", success: true, value: "resolved", terminal: true }] }], + })).executor, + }); + terminal.enqueueFrame({ messages: [{ ...textUserMessage("finish") }] }); + const r = completionOf(await drain(runMachine(terminal))); + expect(r.frame.messages[r.completion.lastResult!.messageIndex]).toMatchObject({ type: "action", kind: "result", name: "resolve", value: "resolved" }); + + // Nothing produced: no pointer. + const empty = createMachine({ + instance: { id: "r", isSource: true, node: generator() }, + charter: charter(), + executor: createRecordingExecutor(() => ({ completionReason: "done" })).executor, + }); + empty.enqueueFrame({ messages: [{ ...textUserMessage("silence") }] }); + expect(completionOf(await drain(runMachine(empty))).completion.lastResult).toBeUndefined(); + }); + it("records terminal-action completions from the executor verbatim", async () => { const { executor, requests } = createRecordingExecutor(() => ({ completionReason: "terminal-action",