From 21616ed9a5daf594121e73abff878cdc3c8d08d3 Mon Sep 17 00:00:00 2001 From: Zachary Story Date: Wed, 2 Sep 2026 11:26:25 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(core):=20horizon=20message=20=E2=80=94?= =?UTF-8?q?=20rendered=20history=20begins=20at=20the=20latest=20visible=20?= =?UTF-8?q?horizon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `horizon` message is a history boundary: for a generator that can see it, projection renders history from the frame carrying it (messages before it in that frame drop; the horizon itself stays so history projections see the cut). It inherits audience semantics like any message (default broadcast; a targeted horizon cuts only its targets). The fold, work scheduling, and absorption ignore horizons — history is what the model sees, work is durable state. Core has no opinion about what follows a horizon; an app that wants a summary puts it in the same frame or the next. visibleFramesForGenerator gains { horizon: "apply" | "ignore" }: projection applies, absorption ignores. Co-Authored-By: Claude Fable 5.1 --- packages/projector/src/compile.ts | 1 + packages/projector/src/types.ts | 17 ++++++ packages/projector/src/visibility.ts | 56 ++++++++++++++++++- .../test/conformance/projection.test.ts | 28 ++++++++++ .../projector/test/conformance/work.test.ts | 29 ++++++++++ 5 files changed, 130 insertions(+), 1 deletion(-) diff --git a/packages/projector/src/compile.ts b/packages/projector/src/compile.ts index 2c26515..63fe6c9 100644 --- a/packages/projector/src/compile.ts +++ b/packages/projector/src/compile.ts @@ -1493,6 +1493,7 @@ function visibleHistoryForTarget( targetGeneratorId, runtime, options.activationId, + { horizon: "apply" }, ); return visible.map(stripProvenance); } diff --git a/packages/projector/src/types.ts b/packages/projector/src/types.ts index 9625154..fe100e8 100644 --- a/packages/projector/src/types.ts +++ b/packages/projector/src/types.ts @@ -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; /** @@ -873,6 +889,7 @@ export type FrameMessage = ( | ActionMessage | InstanceMessage | WorkMessage + | HorizonMessage ) & Record; 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/work.test.ts b/packages/projector/test/conformance/work.test.ts index 7d4039d..a47983d 100644 --- a/packages/projector/test/conformance/work.test.ts +++ b/packages/projector/test/conformance/work.test.ts @@ -96,6 +96,35 @@ 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("records terminal-action completions from the executor verbatim", async () => { const { executor, requests } = createRecordingExecutor(() => ({ completionReason: "terminal-action", From af496b4c316f321d976cedbfbf6789f12d41d1ab Mon Sep 17 00:00:00 2001 From: Zachary Story Date: Wed, 2 Sep 2026 11:38:29 -0700 Subject: [PATCH 2/5] Schema positions accept Standard Schema; JSON Schema is the internal form Every schema position in @projectors/core (StateDescriptor.schema, Action.inputSchema, OutputConfig.schema, executor configSchema, charter and node params, dataContent) now accepts any StandardSchemaV1 instead of a z.ZodType. New src/schema.ts normalizes a schema once (memoized per object) into a NormalizedSchema with synchronous validate/parse/accepts and a jsonSchema() converter sourced from ~standard.jsonSchema (StandardJSONSchemaV1, which zod 4 implements), falling back to z.toJSONSchema for vendor "zod", or an explicit document attached via withJsonSchema(). Async validators and schemas with no JSON Schema source are rejected at creation. Type inference uses StandardSchemaV1.InferOutput/InferInput, so createState and createAction infer the same types for zod schemas as before, and no zod type appears in the public surface. zod stays a private dependency (hydration of serialized JSON Schema via z.fromJSONSchema) and is no longer a peer dependency consumers must pin. Params compatibility checks now read the schema's input JSON Schema (properties/required) instead of zod's .shape. Executors build AI SDK / LiveKit tools from the normalized JSON Schema and validate through the normalized schema; the cascade executor validates tool input itself since LiveKit no longer receives a zod object. Validation failures surface as SchemaError with " (at path)" text. Co-Authored-By: Claude Fable 5.1 --- bun.lock | 40 +--- packages/aisdk-executor/package.json | 9 +- packages/aisdk-executor/src/executor.ts | 40 ++-- .../livekit-cascade-executor/package.json | 3 - .../livekit-cascade-executor/src/executor.ts | 27 ++- .../livekit-realtime-executor/package.json | 3 - .../livekit-realtime-executor/src/executor.ts | 11 +- packages/projector/index.ts | 1 + packages/projector/package.json | 8 +- .../projector/src/__tests__/projector.test.ts | 11 +- packages/projector/src/actions.ts | 18 +- packages/projector/src/client/index.ts | 24 +- packages/projector/src/compile.ts | 6 +- packages/projector/src/contributors.ts | 3 +- packages/projector/src/create.ts | 3 + packages/projector/src/history.ts | 5 +- packages/projector/src/machine.ts | 17 +- packages/projector/src/params.ts | 64 ++++-- packages/projector/src/schema.ts | 215 ++++++++++++++++++ packages/projector/src/serialization.ts | 22 +- packages/projector/src/state.ts | 5 +- packages/projector/src/types.ts | 20 +- .../projector/test/conformance/schema.test.ts | 127 +++++++++++ 23 files changed, 517 insertions(+), 165 deletions(-) create mode 100644 packages/projector/src/schema.ts create mode 100644 packages/projector/test/conformance/schema.test.ts diff --git a/bun.lock b/bun.lock index 7a9715e..a186ad5 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,24 +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": { + "@standard-schema/spec": "^1.1.0", + "zod": "^4.4.3", + }, "devDependencies": { "@projectors/aisdk-executor": "workspace:*", "@projectors/livekit-realtime-executor": "workspace:*", "@types/bun": "latest", "vitest": "^4.0.17", }, - "peerDependencies": { - "zod": ">=4.0.0 <5", - }, }, }, "trustedDependencies": [ @@ -2032,7 +2024,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 +2190,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 +2310,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/executor.ts b/packages/aisdk-executor/src/executor.ts index 29c4a74..a8af6f7 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,7 @@ function buildAiSdkInput( function parseNodeConfig(config: unknown): AiSdkExecutorNodeConfig { if (config === undefined) return {}; - return nodeConfigSchema.parse(config); + return normalizeSchema(nodeConfigSchema).parse(config); } function normalizeTurnPolicy( @@ -972,20 +974,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 the schema itself. */ -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.validate(value); + return result.issues + ? { success: false, error: new SchemaError(result.issues) } + : { success: true, value: result.value }; + }, + }); } function executorOwnedAction( 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..eb79858 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,9 +213,16 @@ export class LiveKitCascadeConnection { throw new Error(`No LiveKit tool named "${name}" is registered in the current projection`); } + let parsedInput = input; + if (action.inputSchema) { + const parsed = normalizeSchema(action.inputSchema).validate(input); + if (parsed.issues) throw new SchemaError(parsed.issues); + parsedInput = parsed.value; + } + const callId = crypto.randomUUID(); const actionRequest = { - ...createToolActionRequest(name, input, callId), + ...createToolActionRequest(name, parsedInput, callId), source: { external: true }, }; const context: ActionContext = @@ -224,7 +232,7 @@ export class LiveKitCascadeConnection { context.getState ??= (address) => this.getRetrievableState(address); } const runAction = this.config.runAction; - const runInput: RunActionInput = { action, input, context, liveKitContext }; + const runInput: RunActionInput = { action, input: parsedInput, context, liveKitContext }; const result = await executeActionInvocation({ request: actionRequest, throwErrors: true, @@ -726,15 +734,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 +757,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..34b2da2 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, @@ -378,9 +379,9 @@ 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 parsed = normalizeSchema(action.inputSchema).validate(input); + if (parsed.issues) this.recordFailedToolInvocation(name, input, callId, new SchemaError(parsed.issues)); + parsedInput = parsed.value; } const actionRequest = { @@ -2106,7 +2107,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/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..0ac736c 100644 --- a/packages/projector/package.json +++ b/packages/projector/package.json @@ -31,8 +31,8 @@ "@types/bun": "latest", "vitest": "^4.0.17" }, - "peerDependencies": { - "zod": ">=4.0.0 <5" - }, - "dependencies": {} + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "zod": "^4.4.3" + } } diff --git a/packages/projector/src/__tests__/projector.test.ts b/packages/projector/src/__tests__/projector.test.ts index 3c0daf8..ad6608e 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,7 +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({ + expect(normalizeSchema(hydrated.node.params).parse({ userId: "user_456" })).toEqual({ userId: "user_456", }); }); @@ -3607,7 +3608,7 @@ describe("serialization and refs", () => { serializedInline.node.states![0]!, registry, ); - expect(hydratedState.schema.parse(3)).toBe(3); + expect(normalizeSchema(hydratedState.schema).parse(3)).toBe(3); } const serializedRegistered = serializeInstance( @@ -3617,7 +3618,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).parse(4)).toBe(4); }); it("serializes inline output schema and rejects inline output mappers", () => { @@ -3639,7 +3640,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({ + expect(normalizeSchema(hydrated.node.output!.schema!).parse({ answer: "ok" })).toEqual({ answer: "ok", }); @@ -3696,7 +3697,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/actions.ts b/packages/projector/src/actions.ts index 6274fec..ddb8bc0 100644 --- a/packages/projector/src/actions.ts +++ b/packages/projector/src/actions.ts @@ -1,7 +1,8 @@ -import { z } from "zod"; +import * as z from "zod"; +import { normalizeSchema, type InferSchemaOutput, type Schema } 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, @@ -58,7 +59,7 @@ export type ActionResultEnvelope = ( __dataContent?: TDataContent; }; -type InputOf = TSchema extends z.ZodType ? TInput : unknown; +type InputOf = TSchema extends Schema ? InferSchemaOutput : unknown; type StateOf = TState extends StateDescriptor ? S : undefined; type ActionStateRequirement = StateDescriptor | null; @@ -82,7 +83,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 +116,7 @@ type CreatedAction< type ActionWithSchema< TState extends ActionStateRequirement, TParams extends AnyParamsSchema, - TSchema extends z.ZodType, + TSchema extends Schema, O, TName extends string, TDataContent, @@ -126,7 +127,7 @@ type ActionWithSchema< export function createAction< const TName extends string, const TState extends ActionStateRequirement, - const TSchema extends z.ZodType, + const TSchema extends Schema, const TParams extends AnyParamsSchema = typeof emptyParamsSchema, O = unknown, TDataContent = never, @@ -151,6 +152,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; } @@ -512,12 +514,12 @@ export function createGetStateAction( 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 { address } = normalizeSchema(inputSchema).parse(input); return ctx.getState(address); }, }; diff --git a/packages/projector/src/client/index.ts b/packages/projector/src/client/index.ts index 06f6845..e22f9b2 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..30195a5 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), }; } diff --git a/packages/projector/src/contributors.ts b/packages/projector/src/contributors.ts index 80c5b20..08b68a9 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,7 +94,7 @@ export function createRoot< instances: Instance[], params: InputCharterParams, ): Instance { - const parsedParams = charter.params.parse(params); + const parsedParams = normalizeSchema(charter.params).parse(params); return createRootInstance(instances, parsedParams); } diff --git a/packages/projector/src/create.ts b/packages/projector/src/create.ts index 460d9b2..679df8b 100644 --- a/packages/projector/src/create.ts +++ b/packages/projector/src/create.ts @@ -1,3 +1,4 @@ +import { normalizeSchema } from "./schema.ts"; import type { ActionConfigEntry, ActionPart, @@ -233,6 +234,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 +342,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..75eccb4 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,7 @@ export function assistantMessageFromTextOutput( if (mapTextBlock) { const mapped = mapTextBlock(text); - const content = schema ? schema.parse(mapped) : mapped; + const content = schema ? normalizeSchema(schema).parse(mapped) : mapped; return { type: "assistant", content: [dataContent(content)], @@ -356,7 +357,7 @@ export function assistantMessageFromTextOutput( if (schema) { return { type: "assistant", - content: [dataContent(schema.parse(text))], + content: [dataContent(normalizeSchema(schema).parse(text))], text, ...(output?.audience ? { audience: output.audience } : {}), }; diff --git a/packages/projector/src/machine.ts b/packages/projector/src/machine.ts index 18d46fe..05a747e 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, @@ -242,7 +243,7 @@ export function createMachine({ }; assertUniqueInstanceIds(machine.instance); assertHasSourceInstance(machine.instance); - charter.params.parse(resolveEffectiveParams([machine.instance])); + normalizeSchema(charter.params).parse(resolveEffectiveParams([machine.instance])); validateMachineActionStateCompatibility(machine.instance, machine.charter); validateExecutorConfig(machine.instance, machine.charter, executor); return machine; @@ -271,7 +272,7 @@ function validateExecutorConfig( const config = node.executorConfig?.[namespace]; if (config !== undefined) { try { - schema.parse(config); + normalizeSchema(schema).parse(config); } catch (error) { throw new Error( `Invalid executorConfig["${namespace}"] on node "${node.key}": ${ @@ -806,15 +807,15 @@ export async function executeCommand< let input = message.input; if (resolved.command.inputSchema) { - const parsed = resolved.command.inputSchema.safeParse(input); - if (!parsed.success) { + const parsed = normalizeSchema(resolved.command.inputSchema).validate(input); + if (parsed.issues) { return enqueueImmediateActionResult(machine, message, { success: false, - error: parsed.error.message, + error: formatSchemaIssues(parsed.issues), callId: message.callId, }); } - input = parsed.data; + input = parsed.value; } const projectorMachine = machine as ProjectorMachine; @@ -1484,7 +1485,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).parse(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 +1650,7 @@ function validateStateValue( value: unknown, ): void { const state = findResolvedState(root, address); - state.descriptor.schema.parse(value); + normalizeSchema(state.descriptor.schema).parse(value); } function findResolvedState( diff --git a/packages/projector/src/params.ts b/packages/projector/src/params.ts index 5ffa58c..2d5c727 100644 --- a/packages/projector/src/params.ts +++ b/packages/projector/src/params.ts @@ -1,18 +1,26 @@ -import { z } from "zod"; +import * as z from "zod"; +import { + jsonSchemaProperties, + jsonSchemaRequired, + normalizeSchema, + type InferSchemaInput, + type InferSchemaOutput, + 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: Schema<{}, {}> = z.object({}); +type ParamsSchemaKeys = keyof InferSchemaInput; export type InferParams = TSchema extends AnyParamsSchema - ? z.output + ? InferSchemaOutput : {}; export type InputParams = TSchema extends AnyParamsSchema - ? z.input + ? InferSchemaInput : {}; /** @@ -27,12 +35,12 @@ export type ParamsSatisfyError< TSub extends AnyParamsSchema, > = ParamsSchemaKeys extends never ? never - : z.output extends z.input + : InferSchemaOutput extends InferSchemaInput ? never : { readonly __paramCompatibilityError: "params do not satisfy required schema"; - readonly expected: z.input; - readonly received: z.output; + readonly expected: InferSchemaInput; + readonly received: InferSchemaOutput; }; export type InferNodeParams = @@ -50,7 +58,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 +87,7 @@ export function resolveNodeParams( ): JsonObject { const schema = normalizeParamsSchema(node.params); const picked = pickDeclaredParamKeys(effectiveParams, schema); - return schema.parse(picked) as JsonObject; + return normalizeSchema(schema).parse(picked); } export function resolveActionParams( @@ -86,16 +96,17 @@ export function resolveActionParams( ): JsonObject { const schema = normalizeParamsSchema(action.params); const picked = pickDeclaredParamKeys(nodeParams, schema); - return schema.parse(picked) as JsonObject; + return normalizeSchema(schema).parse(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 +116,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) { - continue; - } - if ((field as z.ZodType).safeParse(undefined).success) { + const nodeKeys = declaredParamKeys(normalizeParamsSchema(node.params)); + const actionInput = normalizeSchema(action.params).jsonSchema({ io: "input" }); + const required = jsonSchemaRequired(actionInput); + for (const key of jsonSchemaProperties(actionInput)) { + if (nodeKeys.includes(key) || !required.has(key)) { 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 +135,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({ io: "input" })); +} diff --git a/packages/projector/src/schema.ts b/packages/projector/src/schema.ts new file mode 100644 index 0000000..ec30407 --- /dev/null +++ b/packages/projector/src/schema.ts @@ -0,0 +1,215 @@ +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; +import * as z from "zod"; + +/** + * Projector's schema seam. Every schema position (state values, action + * inputs, params, output contracts, executor node config) accepts any + * Standard Schema; projector normalizes it once into a {@link NormalizedSchema} + * that validates synchronously and yields JSON Schema. zod is a private + * dependency here only (hydration of serialized JSON Schema) — it never + * appears in a public type. + */ +export type Schema = StandardSchemaV1; +export type AnySchema = StandardSchemaV1; +export type InferSchemaOutput = StandardSchemaV1.InferOutput; +export type InferSchemaInput = StandardSchemaV1.InferInput; + +export type JsonSchema = Record; +export type SchemaIssue = StandardSchemaV1.Issue; +export type SchemaResult = StandardSchemaV1.Result; + +export type JsonSchemaOptions = { + /** Which side of the schema to describe. Defaults to `"output"`. */ + io?: "input" | "output"; + /** Defaults to `"draft-2020-12"`. */ + target?: StandardJSONSchemaV1.Target; + libraryOptions?: Record; +}; + +export type NormalizedSchema = { + readonly schema: StandardSchemaV1; + /** Synchronous Standard Schema validation. */ + validate(value: unknown): SchemaResult; + /** Validates and returns the output value; throws {@link SchemaError}. */ + parse(value: unknown): Output; + accepts(value: unknown): boolean; + /** The JSON Schema document describing this schema (memoized per io). */ + jsonSchema(options?: JsonSchemaOptions): JsonSchema; +}; + +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>(); + +/** + * Normalizes a Standard Schema. Memoized per schema object, so calling this at + * every use site costs one lookup; creation sites call it eagerly so an + * unusable schema (async validation, no JSON Schema source) fails at + * declaration time with a clear message rather than mid-activation. + */ +export function normalizeSchema( + schema: T, +): 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 validate = (value: unknown): SchemaResult => { + const result = props.validate(value); + if (isPromiseLike(result)) { + throw new Error( + `Schema (vendor "${props.vendor}") validates asynchronously; projector schemas must validate synchronously`, + ); + } + return result; + }; + // Probe: an always-async validator is rejected at creation, not at first use. + validate(undefined); + + const convert = resolveJsonSchemaConverter(schema, props); + const memo = new Map<"input" | "output", JsonSchema>(); + + return { + schema, + validate, + parse(value) { + const result = validate(value); + if (result.issues) throw new SchemaError(result.issues); + return result.value; + }, + accepts(value) { + return !validate(value).issues; + }, + jsonSchema(options = {}) { + const io = options.io ?? "output"; + const isDefault = options.target === undefined && options.libraryOptions === undefined; + if (isDefault) { + const hit = memo.get(io); + if (hit) return hit; + } + const document = convert(io, { + target: options.target ?? DEFAULT_TARGET, + libraryOptions: options.libraryOptions, + }); + if (isDefault) memo.set(io, document); + return document; + }, + }; +} + +type Converter = ( + io: "input" | "output", + options: StandardJSONSchemaV1.Options, +) => JsonSchema; + +function resolveJsonSchemaConverter( + schema: StandardSchemaV1, + props: StandardSchemaV1.Props, +): Converter { + const json = (props as Partial).jsonSchema; + if (json && typeof json.input === "function" && typeof json.output === "function") { + return (io, options) => json[io](options); + } + if (props.vendor === "zod") { + return (io, options) => + z.toJSONSchema(schema as never, { + io, + target: options.target as never, + ...(options.libraryOptions ?? {}), + }) as JsonSchema; + } + throw new Error( + `Schema (vendor "${props.vendor}") has no JSON Schema source: implement \`~standard.jsonSchema\` or wrap it with withJsonSchema()`, + ); +} + +/** + * Attaches an explicit JSON Schema to a Standard Schema whose library cannot + * produce one. The result implements StandardJSONSchemaV1 (inheriting from + * the original schema) and normalizes like any other schema. + */ +export function withJsonSchema( + schema: T, + output: JsonSchema, + input: JsonSchema = output, +): T & StandardJSONSchemaV1, StandardSchemaV1.InferOutput> { + const converter: StandardJSONSchemaV1.Converter = { + input: () => input, + output: () => output, + }; + const wrapped = Object.create(schema); + Object.defineProperty(wrapped, "~standard", { + value: { ...schema["~standard"], jsonSchema: converter }, + enumerable: false, + }); + return wrapped; +} + +/** + * Re-enters a serialized JSON Schema as a Standard Schema (via zod, privately). + * Hydrated schemas normalize like any other. + */ +export function schemaFromJsonSchema(jsonSchema: unknown): Schema { + return z.fromJSONSchema(jsonSchema as Parameters[0]); +} + +/** 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) : []); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === "object" && + value !== null && + typeof (value as { then?: unknown }).then === "function" + ); +} 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..ea4c711 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"; @@ -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..197af77 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 } from "./schema.ts"; +import type { AnyParamsSchema, InferParams, JsonObject } from "./params.ts"; export type Ref = string; @@ -239,7 +239,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 +363,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 +377,7 @@ export type AnyAction = { params?: TParams; name: string; description?: string; - inputSchema?: z.ZodType; + inputSchema?: Schema; executorOwned?: boolean; run?: (input: any, ctx: any) => any | Promise; }; @@ -940,7 +940,7 @@ export type Frame = FrameDraft & { */ export type OutputConfig = { audience?: Audience; - schema?: z.ZodType; + schema?: Schema; mapTextBlock?: (text: string) => TDataContent; }; @@ -1025,7 +1025,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 +1044,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 +1072,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/test/conformance/schema.test.ts b/packages/projector/test/conformance/schema.test.ts new file mode 100644 index 0000000..7448bc2 --- /dev/null +++ b/packages/projector/test/conformance/schema.test.ts @@ -0,0 +1,127 @@ +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + applyInstanceMessage, + createAction, + createNode, + createSourceInstance, + createState, + hydrateInstance, + inspectCompiledProjectionTree, + normalizeSchema, + resolveStates, + serializeInstance, + type Instance, +} from "../../index.ts"; +import { charter } from "./helpers.ts"; + +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 a 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("rejects schemas that validate asynchronously or lack a JSON Schema source at creation", () => { + const asyncSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "handwritten", + validate: async (value) => ({ value: value as Counter }), + }, + }; + expect(() => createState({ key: "counter", schema: asyncSchema })).toThrow(/asynchronously/); + + 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/, + ); + }); +}); From 15b36ac10aee00c157c5731e607b2581522bbf06 Mon Sep 17 00:00:00 2001 From: Zachary Story Date: Wed, 2 Sep 2026 12:19:13 -0700 Subject: [PATCH 3/5] feat(core): completions point at the step's last result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkCompletionMessage.lastResult is a MessageRef to the last result-bearing message the completing step produced — an action result (terminal actions included) or an assistant message — so hosts need not scan frames for an activation's outcome. Absent when the step produced neither. Apps with another convention read the frames themselves; core takes no position on what an activation "returns". Also: ExecuteActionResult.error stays a human-readable string; structured issues may be added alongside later. Co-Authored-By: Claude Fable 5.1 --- packages/projector/src/machine.ts | 25 ++++++++++- packages/projector/src/types.ts | 19 ++++++++ .../projector/test/conformance/work.test.ts | 45 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/projector/src/machine.ts b/packages/projector/src/machine.ts index 05a747e..42a3603 100644 --- a/packages/projector/src/machine.ts +++ b/packages/projector/src/machine.ts @@ -75,6 +75,7 @@ import type { WorkActivationMessage, WorkCompletionMessage, WorkCompletionReason, + MessageRef, } from "./types.ts"; import { compileProjection } from "./compile.ts"; import { @@ -1241,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", @@ -1262,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, diff --git a/packages/projector/src/types.ts b/packages/projector/src/types.ts index c3e6d2e..11ca809 100644 --- a/packages/projector/src/types.ts +++ b/packages/projector/src/types.ts @@ -752,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 @@ -877,6 +891,11 @@ export type ExecuteActionResult = } | { success: false; + /** + * Human-readable. Input rejections render schema issues into one line + * (see formatSchemaIssues); structured issues may be added alongside + * later, the string stays. + */ error: string; value?: T; messages?: FrameMessage[]; diff --git a/packages/projector/test/conformance/work.test.ts b/packages/projector/test/conformance/work.test.ts index a47983d..09a6f86 100644 --- a/packages/projector/test/conformance/work.test.ts +++ b/packages/projector/test/conformance/work.test.ts @@ -125,6 +125,51 @@ describe("conformance: work scheduling", () => { 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", From c3aa30aa7c2c391d372df6ddd40f7937c75cc18f Mon Sep 17 00:00:00 2001 From: Zachary Story Date: Wed, 2 Sep 2026 12:25:17 -0700 Subject: [PATCH 4/5] feat(core): input rejections carry Standard Schema issues ExecuteActionResult and the durable ActionResultMessage carry the schema's issues verbatim on input validation failure; the error string is their rendering (formatSchemaIssues). Co-Authored-By: Claude Fable 5.1 --- packages/projector/src/actions.ts | 7 ++++-- packages/projector/src/machine.ts | 1 + packages/projector/src/types.ts | 12 +++++----- .../projector/test/conformance/schema.test.ts | 22 +++++++++++++++++++ 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/projector/src/actions.ts b/packages/projector/src/actions.ts index ddb8bc0..a258c9b 100644 --- a/packages/projector/src/actions.ts +++ b/packages/projector/src/actions.ts @@ -1,5 +1,5 @@ import * as z from "zod"; -import { normalizeSchema, type InferSchemaOutput, type Schema } from "./schema.ts"; +import { normalizeSchema, type InferSchemaOutput, type Schema, type SchemaIssue } from "./schema.ts"; import { markActionExposure } from "./action-exposure.ts"; import { assertProjectorIdentifier } from "./identifiers.ts"; import { emptyParamsSchema, normalizeParamsSchema, type AnyParamsSchema, type InferParams } from "./params.ts"; @@ -42,6 +42,8 @@ export type ActionResultEnvelope = ( | { success: false; error: string; + /** Standard Schema issues, verbatim, when the failure was input validation. */ + issues?: readonly SchemaIssue[]; value?: T; messages?: FrameMessage[]; terminal?: boolean; @@ -286,7 +288,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 } : {}), }; @@ -346,6 +348,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 } : {}), diff --git a/packages/projector/src/machine.ts b/packages/projector/src/machine.ts index 42a3603..ec7e6e5 100644 --- a/packages/projector/src/machine.ts +++ b/packages/projector/src/machine.ts @@ -813,6 +813,7 @@ export async function executeCommand< return enqueueImmediateActionResult(machine, message, { success: false, error: formatSchemaIssues(parsed.issues), + issues: parsed.issues, callId: message.callId, }); } diff --git a/packages/projector/src/types.ts b/packages/projector/src/types.ts index 11ca809..a564274 100644 --- a/packages/projector/src/types.ts +++ b/packages/projector/src/types.ts @@ -1,4 +1,4 @@ -import type { Schema } from "./schema.ts"; +import type { Schema, SchemaIssue } from "./schema.ts"; import type { AnyParamsSchema, InferParams, JsonObject } from "./params.ts"; export type Ref = string; @@ -873,6 +873,8 @@ export type ActionResultMessage = { success: boolean; value?: unknown; error?: string; + /** Standard Schema issues, verbatim, when the failure was input validation. */ + issues?: readonly SchemaIssue[]; terminal?: boolean; outputMessageIndices?: number[]; audience?: Audience; @@ -891,12 +893,10 @@ export type ExecuteActionResult = } | { success: false; - /** - * Human-readable. Input rejections render schema issues into one line - * (see formatSchemaIssues); structured issues may be added alongside - * later, the string stays. - */ + /** Human-readable rendering; for input rejections, formatSchemaIssues(issues). */ error: string; + /** Standard Schema issues, verbatim, when the failure was input validation. */ + issues?: readonly SchemaIssue[]; value?: T; messages?: FrameMessage[]; terminal?: boolean; diff --git a/packages/projector/test/conformance/schema.test.ts b/packages/projector/test/conformance/schema.test.ts index 7448bc2..5e4d2e8 100644 --- a/packages/projector/test/conformance/schema.test.ts +++ b/packages/projector/test/conformance/schema.test.ts @@ -3,9 +3,12 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { applyInstanceMessage, createAction, + createCharter, + createMachine, createNode, createSourceInstance, createState, + executeCommand, hydrateInstance, inspectCompiledProjectionTree, normalizeSchema, @@ -14,6 +17,7 @@ import { type Instance, } from "../../index.ts"; import { charter } from "./helpers.ts"; +import * as z from "zod"; type Counter = { count: number }; @@ -124,4 +128,22 @@ describe("conformance: Standard Schema positions", () => { /no JSON Schema source/, ); }); + + it("rejects command input with Standard Schema issues, verbatim and durable", 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(["NAME", ""]); + expect(result.error).toContain("NAME"); + const recorded = machine.frames.flatMap((frame) => frame.messages).find((m) => m.type === "action" && m.kind === "result"); + expect(recorded).toMatchObject({ success: false, issues: result.issues }); + }); }); From e04e0685993451ae7f6ce4e2b1cf674697a585cb Mon Sep 17 00:00:00 2001 From: Zachary Story Date: Fri, 4 Sep 2026 22:19:43 -0700 Subject: [PATCH 5/5] refactor(core): validate canonical JSON Schema without transforming values --- bun.lock | 5 +- .../src/__tests__/executor.test.ts | 18 ++ packages/aisdk-executor/src/executor.ts | 41 ++- .../livekit-cascade-executor/src/executor.ts | 10 +- .../livekit-realtime-executor/src/executor.ts | 10 +- packages/projector/ROADMAP.md | 10 + packages/projector/package.json | 7 +- .../projector/src/__tests__/projector.test.ts | 12 +- .../projector/src/__tests__/schema.test.ts | 99 ++++++++ packages/projector/src/actions.ts | 33 ++- packages/projector/src/client/index.ts | 4 +- packages/projector/src/contributors.ts | 4 +- packages/projector/src/create.ts | 15 +- packages/projector/src/history.ts | 7 +- packages/projector/src/machine.ts | 17 +- packages/projector/src/params.ts | 40 +-- packages/projector/src/schema.ts | 239 +++++++++++------- packages/projector/src/state.ts | 2 +- packages/projector/src/types.ts | 16 +- .../projector/test/conformance/schema.test.ts | 39 ++- 20 files changed, 437 insertions(+), 191 deletions(-) create mode 100644 packages/projector/src/__tests__/schema.test.ts diff --git a/bun.lock b/bun.lock index a186ad5..9dae334 100644 --- a/bun.lock +++ b/bun.lock @@ -144,14 +144,15 @@ "name": "@projectors/core", "version": "0.0.2", "dependencies": { + "@cfworker/json-schema": "^4.1.1", "@standard-schema/spec": "^1.1.0", - "zod": "^4.4.3", }, "devDependencies": { "@projectors/aisdk-executor": "workspace:*", "@projectors/livekit-realtime-executor": "workspace:*", "@types/bun": "latest", "vitest": "^4.0.17", + "zod": "^4.4.3", }, }, }, @@ -224,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=="], 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 a8af6f7..06c83ae 100644 --- a/packages/aisdk-executor/src/executor.ts +++ b/packages/aisdk-executor/src/executor.ts @@ -532,7 +532,9 @@ function buildAiSdkInput( function parseNodeConfig(config: unknown): AiSdkExecutorNodeConfig { if (config === undefined) return {}; - return normalizeSchema(nodeConfigSchema).parse(config); + const schema = normalizeSchema(nodeConfigSchema); + schema.assert(config); + return config as AiSdkExecutorNodeConfig; } function normalizeTurnPolicy( @@ -866,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( ( @@ -983,17 +992,17 @@ function compactToolInputSchema(schema: AnyAction["inputSchema"]) { /** * 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 the schema itself. + * function-tool dialect), validated by Projector's canonical validator. */ -function aiSdkSchema(schema: Schema) { +function aiSdkSchema(schema: Schema) { const normalized = normalizeSchema(schema); const { $schema: _dialect, ...compact } = normalized.jsonSchema(); return jsonSchema(compact as never, { validate: (value) => { - const result = normalized.validate(value); + const result = normalized.check(value); return result.issues ? { success: false, error: new SchemaError(result.issues) } - : { success: true, value: result.value }; + : { success: true, value: value as T }; }, }); } @@ -1301,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/src/executor.ts b/packages/livekit-cascade-executor/src/executor.ts index eb79858..8bf381f 100644 --- a/packages/livekit-cascade-executor/src/executor.ts +++ b/packages/livekit-cascade-executor/src/executor.ts @@ -213,16 +213,14 @@ export class LiveKitCascadeConnection { throw new Error(`No LiveKit tool named "${name}" is registered in the current projection`); } - let parsedInput = input; if (action.inputSchema) { - const parsed = normalizeSchema(action.inputSchema).validate(input); - if (parsed.issues) throw new SchemaError(parsed.issues); - parsedInput = parsed.value; + const checked = normalizeSchema(action.inputSchema).check(input); + if (checked.issues) throw new SchemaError(checked.issues); } const callId = crypto.randomUUID(); const actionRequest = { - ...createToolActionRequest(name, parsedInput, callId), + ...createToolActionRequest(name, input, callId), source: { external: true }, }; const context: ActionContext = @@ -232,7 +230,7 @@ export class LiveKitCascadeConnection { 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, diff --git a/packages/livekit-realtime-executor/src/executor.ts b/packages/livekit-realtime-executor/src/executor.ts index 34b2da2..9ab99ce 100644 --- a/packages/livekit-realtime-executor/src/executor.ts +++ b/packages/livekit-realtime-executor/src/executor.ts @@ -377,15 +377,13 @@ export class LiveKitRealtimeConnection { ); } - let parsedInput = input; if (action.inputSchema) { - const parsed = normalizeSchema(action.inputSchema).validate(input); - if (parsed.issues) this.recordFailedToolInvocation(name, input, callId, new SchemaError(parsed.issues)); - parsedInput = parsed.value; + 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 = @@ -395,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, 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/package.json b/packages/projector/package.json index 0ac736c..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" }, "dependencies": { - "@standard-schema/spec": "^1.1.0", - "zod": "^4.4.3" + "@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 ad6608e..8653118 100644 --- a/packages/projector/src/__tests__/projector.test.ts +++ b/packages/projector/src/__tests__/projector.test.ts @@ -514,9 +514,7 @@ describe("params", () => { const hydrated = hydrateInstance(serialized, appCharter); expect(hydrated.params).toEqual({ userId: "user_123" }); - expect(normalizeSchema(hydrated.node.params).parse({ userId: "user_456" })).toEqual({ - userId: "user_456", - }); + expect(normalizeSchema(hydrated.node.params).accepts({ userId: "user_456" })).toBe(true); }); }); @@ -3608,7 +3606,7 @@ describe("serialization and refs", () => { serializedInline.node.states![0]!, registry, ); - expect(normalizeSchema(hydratedState.schema).parse(3)).toBe(3); + expect(normalizeSchema(hydratedState.schema).accepts(3)).toBe(true); } const serializedRegistered = serializeInstance( @@ -3618,7 +3616,7 @@ describe("serialization and refs", () => { expect(serializedRegistered.node).toBe("registered"); const hydratedInline = hydrateInstance(serializedInline, registry); - expect(normalizeSchema(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", () => { @@ -3640,9 +3638,7 @@ describe("serialization and refs", () => { } expect(serialized.node.output?.audience).toBe("broadcast"); const hydrated = hydrateInstance(serialized, registry); - expect(normalizeSchema(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", 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 a258c9b..9830f54 100644 --- a/packages/projector/src/actions.ts +++ b/packages/projector/src/actions.ts @@ -1,5 +1,11 @@ -import * as z from "zod"; -import { normalizeSchema, type InferSchemaOutput, type Schema, type SchemaIssue } from "./schema.ts"; +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, type InferParams } from "./params.ts"; @@ -42,7 +48,7 @@ export type ActionResultEnvelope = ( | { success: false; error: string; - /** Standard Schema issues, verbatim, when the failure was input validation. */ + /** Canonical validator issues when the failure was input validation. */ issues?: readonly SchemaIssue[]; value?: T; messages?: FrameMessage[]; @@ -61,7 +67,7 @@ export type ActionResultEnvelope = ( __dataContent?: TDataContent; }; -type InputOf = TSchema extends Schema ? InferSchemaOutput : unknown; +type InputOf = TSchema extends AnySchema ? InferSchemaValue : unknown; type StateOf = TState extends StateDescriptor ? S : undefined; type ActionStateRequirement = StateDescriptor | null; @@ -118,7 +124,7 @@ type CreatedAction< type ActionWithSchema< TState extends ActionStateRequirement, TParams extends AnyParamsSchema, - TSchema extends Schema, + TSchema extends AnySchema, O, TName extends string, TDataContent, @@ -129,12 +135,14 @@ type ActionWithSchema< export function createAction< const TName extends string, const TState extends ActionStateRequirement, - const TSchema extends Schema, + 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, @@ -510,8 +518,11 @@ 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, @@ -522,7 +533,9 @@ export function createGetStateAction( if (!ctx.getState) { throw new Error("No getState handler is available for this action context"); } - const { address } = normalizeSchema(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 e22f9b2..d372f85 100644 --- a/packages/projector/src/client/index.ts +++ b/packages/projector/src/client/index.ts @@ -156,7 +156,7 @@ export type ClientCommandDefinitionName = TCommand extends { : string; export type ClientCommandDefinitionInput = TCommand extends { - inputSchema?: Schema; + inputSchema?: Schema; } ? TInput : unknown; @@ -172,7 +172,7 @@ export type ClientToolOf = ClientToolMeta< >; export type ClientStateOf = TStateDescriptor extends { - schema: Schema; + schema: Schema; } ? TValue : unknown; diff --git a/packages/projector/src/contributors.ts b/packages/projector/src/contributors.ts index 08b68a9..694a3f4 100644 --- a/packages/projector/src/contributors.ts +++ b/packages/projector/src/contributors.ts @@ -94,8 +94,8 @@ export function createRoot< instances: Instance[], params: InputCharterParams, ): Instance { - const parsedParams = normalizeSchema(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 679df8b..fdf9075 100644 --- a/packages/projector/src/create.ts +++ b/packages/projector/src/create.ts @@ -1,4 +1,9 @@ -import { normalizeSchema } from "./schema.ts"; +import { + normalizeSchema, + type AnySchema, + type InferSchemaValue, + type SchemaTransformError, +} from "./schema.ts"; import type { ActionConfigEntry, ActionPart, @@ -213,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); } diff --git a/packages/projector/src/history.ts b/packages/projector/src/history.ts index 75eccb4..24beb07 100644 --- a/packages/projector/src/history.ts +++ b/packages/projector/src/history.ts @@ -345,7 +345,8 @@ export function assistantMessageFromTextOutput( if (mapTextBlock) { const mapped = mapTextBlock(text); - const content = schema ? normalizeSchema(schema).parse(mapped) : mapped; + if (schema) normalizeSchema(schema).assert(mapped); + const content = mapped; return { type: "assistant", content: [dataContent(content)], @@ -355,9 +356,11 @@ export function assistantMessageFromTextOutput( } if (schema) { + const normalized = normalizeSchema(schema); + normalized.assert(text); return { type: "assistant", - content: [dataContent(normalizeSchema(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 ec7e6e5..56acf57 100644 --- a/packages/projector/src/machine.ts +++ b/packages/projector/src/machine.ts @@ -244,7 +244,7 @@ export function createMachine({ }; assertUniqueInstanceIds(machine.instance); assertHasSourceInstance(machine.instance); - normalizeSchema(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; @@ -273,7 +273,7 @@ function validateExecutorConfig( const config = node.executorConfig?.[namespace]; if (config !== undefined) { try { - normalizeSchema(schema).parse(config); + normalizeSchema(schema).assert(config); } catch (error) { throw new Error( `Invalid executorConfig["${namespace}"] on node "${node.key}": ${ @@ -808,16 +808,15 @@ export async function executeCommand< let input = message.input; if (resolved.command.inputSchema) { - const parsed = normalizeSchema(resolved.command.inputSchema).validate(input); - if (parsed.issues) { + const checked = normalizeSchema(resolved.command.inputSchema).check(input); + if (checked.issues) { return enqueueImmediateActionResult(machine, message, { success: false, - error: formatSchemaIssues(parsed.issues), - issues: parsed.issues, + error: formatSchemaIssues(checked.issues), + issues: checked.issues, callId: message.callId, }); } - input = parsed.value; } const projectorMachine = machine as ProjectorMachine; @@ -1509,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); - normalizeSchema(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`. @@ -1674,7 +1673,7 @@ function validateStateValue( value: unknown, ): void { const state = findResolvedState(root, address); - normalizeSchema(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 2d5c727..b978f32 100644 --- a/packages/projector/src/params.ts +++ b/packages/projector/src/params.ts @@ -1,46 +1,48 @@ -import * as z from "zod"; import { jsonSchemaProperties, jsonSchemaRequired, normalizeSchema, - type InferSchemaInput, - type InferSchemaOutput, + schemaFromJsonSchema, + type InferSchemaValue, type Schema, } from "./schema.ts"; import type { AnyAction, Instance, Node } from "./types.ts"; export type JsonObject = Record; /** A params schema: any Standard Schema over an object. */ -export type AnyParamsSchema = Schema; -export const emptyParamsSchema: Schema<{}, {}> = z.object({}); -type ParamsSchemaKeys = keyof InferSchemaInput; +export type AnyParamsSchema = Schema; +export const emptyParamsSchema = schemaFromJsonSchema<{}>({ + type: "object", + properties: {}, + additionalProperties: false, +}); +type ParamsSchemaKeys = keyof InferSchemaValue; export type InferParams = TSchema extends AnyParamsSchema - ? InferSchemaOutput + ? InferSchemaValue : {}; export type InputParams = TSchema extends AnyParamsSchema - ? InferSchemaInput + ? 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 - : InferSchemaOutput extends InferSchemaInput + : InferSchemaValue extends InferSchemaValue ? never : { readonly __paramCompatibilityError: "params do not satisfy required schema"; - readonly expected: InferSchemaInput; - readonly received: InferSchemaOutput; + readonly expected: InferSchemaValue; + readonly received: InferSchemaValue; }; export type InferNodeParams = @@ -87,7 +89,8 @@ export function resolveNodeParams( ): JsonObject { const schema = normalizeParamsSchema(node.params); const picked = pickDeclaredParamKeys(effectiveParams, schema); - return normalizeSchema(schema).parse(picked); + normalizeSchema(schema).assert(picked); + return picked; } export function resolveActionParams( @@ -96,7 +99,8 @@ export function resolveActionParams( ): JsonObject { const schema = normalizeParamsSchema(action.params); const picked = pickDeclaredParamKeys(nodeParams, schema); - return normalizeSchema(schema).parse(picked); + normalizeSchema(schema).assert(picked); + return picked; } /** @@ -117,7 +121,7 @@ export function assertNodeActionParamsCompatibility( return; } const nodeKeys = declaredParamKeys(normalizeParamsSchema(node.params)); - const actionInput = normalizeSchema(action.params).jsonSchema({ io: "input" }); + const actionInput = normalizeSchema(action.params).jsonSchema(); const required = jsonSchemaRequired(actionInput); for (const key of jsonSchemaProperties(actionInput)) { if (nodeKeys.includes(key) || !required.has(key)) { @@ -144,5 +148,5 @@ export function pickDeclaredParamKeys( } function declaredParamKeys(schema: AnyParamsSchema): string[] { - return jsonSchemaProperties(normalizeSchema(schema).jsonSchema({ io: "input" })); + return jsonSchemaProperties(normalizeSchema(schema).jsonSchema()); } diff --git a/packages/projector/src/schema.ts b/packages/projector/src/schema.ts index ec30407..51c1d20 100644 --- a/packages/projector/src/schema.ts +++ b/packages/projector/src/schema.ts @@ -1,42 +1,51 @@ +import { Validator, type OutputUnit } from "@cfworker/json-schema"; import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; -import * as z from "zod"; /** - * Projector's schema seam. Every schema position (state values, action - * inputs, params, output contracts, executor node config) accepts any - * Standard Schema; projector normalizes it once into a {@link NormalizedSchema} - * that validates synchronously and yields JSON Schema. zod is a private - * dependency here only (hydration of serialized JSON Schema) — it never - * appears in a public type. + * 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 Schema = StandardSchemaV1; export type AnySchema = StandardSchemaV1; -export type InferSchemaOutput = StandardSchemaV1.InferOutput; -export type InferSchemaInput = StandardSchemaV1.InferInput; +export type InferSchemaValue = StandardSchemaV1.InferInput; export type JsonSchema = Record; export type SchemaIssue = StandardSchemaV1.Issue; -export type SchemaResult = StandardSchemaV1.Result; +export type SchemaCheckResult = + | { readonly issues?: undefined } + | { readonly issues: readonly SchemaIssue[] }; export type JsonSchemaOptions = { - /** Which side of the schema to describe. Defaults to `"output"`. */ - io?: "input" | "output"; /** Defaults to `"draft-2020-12"`. */ target?: StandardJSONSchemaV1.Target; libraryOptions?: Record; }; -export type NormalizedSchema = { - readonly schema: StandardSchemaV1; - /** Synchronous Standard Schema validation. */ - validate(value: unknown): SchemaResult; - /** Validates and returns the output value; throws {@link SchemaError}. */ - parse(value: unknown): Output; - accepts(value: unknown): boolean; - /** The JSON Schema document describing this schema (memoized per io). */ +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[]; @@ -72,14 +81,14 @@ const DEFAULT_TARGET: StandardJSONSchemaV1.Target = "draft-2020-12"; const normalized = new WeakMap>(); /** - * Normalizes a Standard Schema. Memoized per schema object, so calling this at - * every use site costs one lookup; creation sites call it eagerly so an - * unusable schema (async validation, no JSON Schema source) fails at - * declaration time with a clear message rather than mid-activation. + * 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, -): NormalizedSchema> { + schema: T & SchemaTransformError, +): NormalizedSchema> { const cached = normalized.get(schema); if (cached) return cached; const built = buildNormalizedSchema(schema); @@ -87,96 +96,62 @@ export function normalizeSchema( return built; } -function buildNormalizedSchema( - schema: StandardSchemaV1, -): NormalizedSchema { +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 validate = (value: unknown): SchemaResult => { - const result = props.validate(value); - if (isPromiseLike(result)) { - throw new Error( - `Schema (vendor "${props.vendor}") validates asynchronously; projector schemas must validate synchronously`, - ); - } - return result; - }; - // Probe: an always-async validator is rejected at creation, not at first use. - validate(undefined); - const convert = resolveJsonSchemaConverter(schema, props); - const memo = new Map<"input" | "output", JsonSchema>(); + const canonicalDocument = convert({ target: DEFAULT_TARGET }); + const check = createJsonSchemaCheck(canonicalDocument); return { - schema, - validate, - parse(value) { - const result = validate(value); + check, + assert(value) { + const result = check(value); if (result.issues) throw new SchemaError(result.issues); - return result.value; }, - accepts(value) { - return !validate(value).issues; + accepts(value): value is Value { + return !check(value).issues; }, jsonSchema(options = {}) { - const io = options.io ?? "output"; - const isDefault = options.target === undefined && options.libraryOptions === undefined; - if (isDefault) { - const hit = memo.get(io); - if (hit) return hit; + if (options.target === undefined && options.libraryOptions === undefined) { + return canonicalDocument; } - const document = convert(io, { + return convert({ target: options.target ?? DEFAULT_TARGET, libraryOptions: options.libraryOptions, }); - if (isDefault) memo.set(io, document); - return document; }, }; } -type Converter = ( - io: "input" | "output", - options: StandardJSONSchemaV1.Options, -) => JsonSchema; +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" && typeof json.output === "function") { - return (io, options) => json[io](options); - } - if (props.vendor === "zod") { - return (io, options) => - z.toJSONSchema(schema as never, { - io, - target: options.target as never, - ...(options.libraryOptions ?? {}), - }) as 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 an explicit JSON Schema to a Standard Schema whose library cannot - * produce one. The result implements StandardJSONSchemaV1 (inheriting from - * the original schema) and normalizes like any other schema. - */ +/** Attaches Projector's single canonical JSON Schema to a Standard Schema. */ export function withJsonSchema( schema: T, - output: JsonSchema, - input: JsonSchema = output, + jsonSchema: JsonSchema, ): T & StandardJSONSchemaV1, StandardSchemaV1.InferOutput> { const converter: StandardJSONSchemaV1.Converter = { - input: () => input, - output: () => output, + input: () => jsonSchema, + output: () => jsonSchema, }; const wrapped = Object.create(schema); Object.defineProperty(wrapped, "~standard", { @@ -186,12 +161,98 @@ export function withJsonSchema( 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; +} + /** - * Re-enters a serialized JSON Schema as a Standard Schema (via zod, privately). - * Hydrated schemas normalize like any other. + * 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. */ -export function schemaFromJsonSchema(jsonSchema: unknown): Schema { - return z.fromJSONSchema(jsonSchema as Parameters[0]); +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. */ @@ -205,11 +266,3 @@ export function jsonSchemaRequired(document: JsonSchema): Set { const required = document.required; return new Set(Array.isArray(required) ? required.map(String) : []); } - -function isPromiseLike(value: unknown): value is PromiseLike { - return ( - typeof value === "object" && - value !== null && - typeof (value as { then?: unknown }).then === "function" - ); -} diff --git a/packages/projector/src/state.ts b/packages/projector/src/state.ts index ea4c711..b35325b 100644 --- a/packages/projector/src/state.ts +++ b/packages/projector/src/state.ts @@ -125,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 — diff --git a/packages/projector/src/types.ts b/packages/projector/src/types.ts index a564274..f627a39 100644 --- a/packages/projector/src/types.ts +++ b/packages/projector/src/types.ts @@ -255,7 +255,7 @@ export type StateProjection = { export type StateDescriptor = { key: string; - schema: Schema; + schema: Schema; init?: S | (() => S); scope?: "hoist" | "local"; onInitConflict?: "error" | "replace"; @@ -379,7 +379,7 @@ export type Action< params?: TParams; name: TName; description?: string; - inputSchema?: Schema; + inputSchema?: Schema; /** The executor must supply this action's native tool representation and execution. */ executorOwned?: boolean; run?: ( @@ -393,7 +393,7 @@ export type AnyAction = { params?: TParams; name: string; description?: string; - inputSchema?: Schema; + inputSchema?: Schema; executorOwned?: boolean; run?: (input: any, ctx: any) => any | Promise; }; @@ -873,7 +873,7 @@ export type ActionResultMessage = { success: boolean; value?: unknown; error?: string; - /** Standard Schema issues, verbatim, when the failure was input validation. */ + /** Canonical validator issues when the failure was input validation. */ issues?: readonly SchemaIssue[]; terminal?: boolean; outputMessageIndices?: number[]; @@ -895,7 +895,7 @@ export type ExecuteActionResult = success: false; /** Human-readable rendering; for input rejections, formatSchemaIssues(issues). */ error: string; - /** Standard Schema issues, verbatim, when the failure was input validation. */ + /** Canonical validator issues when the failure was input validation. */ issues?: readonly SchemaIssue[]; value?: T; messages?: FrameMessage[]; @@ -976,7 +976,7 @@ export type Frame = FrameDraft & { */ export type OutputConfig = { audience?: Audience; - schema?: Schema; + schema?: Schema; mapTextBlock?: (text: string) => TDataContent; }; @@ -1080,7 +1080,7 @@ export type Charter< version?: string; params: TParams; /** The log's data-content vocabulary (see CharterConfig.dataContent). */ - dataContent?: Schema; + dataContent?: Schema; nodes: Record>; /** Unified action registry; tools and commands share one namespace. */ actions: Record; @@ -1108,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?: Schema; + dataContent?: Schema; nodes: readonly Node>[]; /** Sugar: registered into `actions` alongside `commands`. */ tools?: readonly AnyAction[]; diff --git a/packages/projector/test/conformance/schema.test.ts b/packages/projector/test/conformance/schema.test.ts index 5e4d2e8..85b81fc 100644 --- a/packages/projector/test/conformance/schema.test.ts +++ b/packages/projector/test/conformance/schema.test.ts @@ -67,7 +67,7 @@ describe("conformance: Standard Schema positions", () => { { type: "instance", kind: "state.update", instanceId: "r", stateKey: "counter", update: { op: "replace", value: { count: "two" } } }, registry, ), - ).toThrow(/expected a number \(at count\)/); + ).toThrow(/Expected "number"\. \(at count\)/); }); it("carries the schema's JSON Schema on the compiled tool surface", () => { @@ -111,15 +111,26 @@ describe("conformance: Standard Schema positions", () => { expect(normalizeSchema(hydrated.node.output!.schema!).accepts({ count: 3 })).toBe(true); }); - it("rejects schemas that validate asynchronously or lack a JSON Schema source at creation", () => { - const asyncSchema: StandardSchemaV1 = { + 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) => ({ value: value as Counter }), + validate: async (value) => { + validationCalls += 1; + return { value: value as Counter }; + }, + jsonSchema: { + input: () => counterJsonSchema, + output: () => counterJsonSchema, + }, }, }; - expect(() => createState({ key: "counter", schema: asyncSchema })).toThrow(/asynchronously/); + 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 }) }, @@ -129,7 +140,7 @@ describe("conformance: Standard Schema positions", () => { ); }); - it("rejects command input with Standard Schema issues, verbatim and durable", async () => { + it("records canonical validation issues durably for rejected command input", async () => { const command = createAction({ state: null, name: "greet", @@ -141,9 +152,23 @@ describe("conformance: Standard Schema positions", () => { 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(["NAME", ""]); + 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); + }); });