From 1778562d3ddfadb62dcd1f62822786dbf03b5630 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Tue, 31 Mar 2026 16:40:03 -0400 Subject: [PATCH 01/50] WIP --- {examples => examples_old}/chatbot-alt.ts | 0 {examples => examples_old}/chatbot.ts | 0 {examples => examples_old}/cot.ts | 0 {examples => examples_old}/email.ts | 0 examples_old/email2.ts | 59 + {examples => examples_old}/example.ts | 0 {examples => examples_old}/executor.ts | 0 {examples => examples_old}/goal.ts | 0 {examples => examples_old}/helpers/helpers.ts | 0 {examples => examples_old}/helpers/loader.ts | 0 {examples => examples_old}/helpers/runner.ts | 0 examples_old/hono/.gitignore | 34 + examples_old/hono/README.md | 21 + examples_old/hono/package.json | 23 + examples_old/hono/pnpm-lock.yaml | 1643 ++++++++ examples_old/hono/public/.assetsignore | 1 + examples_old/hono/public/favicon.ico | Bin 0 -> 15406 bytes examples_old/hono/src/agent.ts | 90 + examples_old/hono/src/db.ts | 76 + examples_old/hono/src/events.ts | 32 + examples_old/hono/src/index.ts | 362 ++ examples_old/hono/src/machine.ts | 74 + examples_old/hono/src/style.css | 3 + examples_old/hono/src/utils.ts | 29 + examples_old/hono/tsconfig.json | 15 + examples_old/hono/vite.config.ts | 6 + examples_old/hono/wrangler.jsonc | 6 + {examples => examples_old}/joke.ts | 0 {examples => examples_old}/multi.ts | 0 {examples => examples_old}/newspaper.ts | 0 {examples => examples_old}/number.ts | 0 {examples => examples_old}/raffle.ts | 0 {examples => examples_old}/sandbox.ts | 0 {examples => examples_old}/simple.ts | 0 {examples => examples_old}/support.ts | 0 {examples => examples_old}/ticTacToe.ts | 0 {examples => examples_old}/todo.ts | 0 {examples => examples_old}/tutor.ts | 0 {examples => examples_old}/verify.ts | 0 {examples => examples_old}/weather.ts | 0 {examples => examples_old}/wiki.ts | 0 {examples => examples_old}/word.ts | 0 package.json | 93 +- pnpm-lock.yaml | 3613 +++++------------ src/adapter.ts | 8 + src/agent.test.ts | 1431 +++++-- src/agent.ts | 687 ---- src/ai-sdk/index.ts | 93 + src/classify.ts | 32 + src/decide.ts | 13 + src/decision.test.ts | 155 - src/decision.ts | 85 - src/event.ts | 107 + src/graph/index.ts | 33 + src/index.ts | 41 +- src/machine.ts | 9 + src/memory.ts | 25 - src/middleware.ts | 103 - src/mockModel.ts | 47 - src/planners/shortestPathPlanner.ts | 22 - src/planners/simplePlanner.ts | 162 - src/run.ts | 51 + src/schemas.ts | 11 - src/state.ts | 50 + src/step.ts | 167 + src/strategies/chain-of-note.ts | 106 - src/stream.ts | 29 + src/templates/defaultText.ts | 18 - src/text.ts | 148 - src/types.ts | 615 +-- src/utils.ts | 295 +- src/xstate/index.ts | 10 + tsconfig.json | 121 +- tsdown.config.ts | 13 + 74 files changed, 5841 insertions(+), 5026 deletions(-) rename {examples => examples_old}/chatbot-alt.ts (100%) rename {examples => examples_old}/chatbot.ts (100%) rename {examples => examples_old}/cot.ts (100%) rename {examples => examples_old}/email.ts (100%) create mode 100644 examples_old/email2.ts rename {examples => examples_old}/example.ts (100%) rename {examples => examples_old}/executor.ts (100%) rename {examples => examples_old}/goal.ts (100%) rename {examples => examples_old}/helpers/helpers.ts (100%) rename {examples => examples_old}/helpers/loader.ts (100%) rename {examples => examples_old}/helpers/runner.ts (100%) create mode 100644 examples_old/hono/.gitignore create mode 100644 examples_old/hono/README.md create mode 100644 examples_old/hono/package.json create mode 100644 examples_old/hono/pnpm-lock.yaml create mode 100644 examples_old/hono/public/.assetsignore create mode 100644 examples_old/hono/public/favicon.ico create mode 100644 examples_old/hono/src/agent.ts create mode 100644 examples_old/hono/src/db.ts create mode 100644 examples_old/hono/src/events.ts create mode 100644 examples_old/hono/src/index.ts create mode 100644 examples_old/hono/src/machine.ts create mode 100644 examples_old/hono/src/style.css create mode 100644 examples_old/hono/src/utils.ts create mode 100644 examples_old/hono/tsconfig.json create mode 100644 examples_old/hono/vite.config.ts create mode 100644 examples_old/hono/wrangler.jsonc rename {examples => examples_old}/joke.ts (100%) rename {examples => examples_old}/multi.ts (100%) rename {examples => examples_old}/newspaper.ts (100%) rename {examples => examples_old}/number.ts (100%) rename {examples => examples_old}/raffle.ts (100%) rename {examples => examples_old}/sandbox.ts (100%) rename {examples => examples_old}/simple.ts (100%) rename {examples => examples_old}/support.ts (100%) rename {examples => examples_old}/ticTacToe.ts (100%) rename {examples => examples_old}/todo.ts (100%) rename {examples => examples_old}/tutor.ts (100%) rename {examples => examples_old}/verify.ts (100%) rename {examples => examples_old}/weather.ts (100%) rename {examples => examples_old}/wiki.ts (100%) rename {examples => examples_old}/word.ts (100%) create mode 100644 src/adapter.ts delete mode 100644 src/agent.ts create mode 100644 src/ai-sdk/index.ts create mode 100644 src/classify.ts create mode 100644 src/decide.ts delete mode 100644 src/decision.test.ts delete mode 100644 src/decision.ts create mode 100644 src/event.ts create mode 100644 src/graph/index.ts create mode 100644 src/machine.ts delete mode 100644 src/memory.ts delete mode 100644 src/middleware.ts delete mode 100644 src/mockModel.ts delete mode 100644 src/planners/shortestPathPlanner.ts delete mode 100644 src/planners/simplePlanner.ts create mode 100644 src/run.ts delete mode 100644 src/schemas.ts create mode 100644 src/state.ts create mode 100644 src/step.ts delete mode 100644 src/strategies/chain-of-note.ts create mode 100644 src/stream.ts delete mode 100644 src/templates/defaultText.ts delete mode 100644 src/text.ts create mode 100644 src/xstate/index.ts create mode 100644 tsdown.config.ts diff --git a/examples/chatbot-alt.ts b/examples_old/chatbot-alt.ts similarity index 100% rename from examples/chatbot-alt.ts rename to examples_old/chatbot-alt.ts diff --git a/examples/chatbot.ts b/examples_old/chatbot.ts similarity index 100% rename from examples/chatbot.ts rename to examples_old/chatbot.ts diff --git a/examples/cot.ts b/examples_old/cot.ts similarity index 100% rename from examples/cot.ts rename to examples_old/cot.ts diff --git a/examples/email.ts b/examples_old/email.ts similarity index 100% rename from examples/email.ts rename to examples_old/email.ts diff --git a/examples_old/email2.ts b/examples_old/email2.ts new file mode 100644 index 0000000..31ac3f5 --- /dev/null +++ b/examples_old/email2.ts @@ -0,0 +1,59 @@ +import { setup, SnapshotFrom } from 'xstate'; +import { mapState } from '../src/mapState'; + +const machine = setup({}).createMachine({ + initial: 'checking', + states: { + checking: { + on: { + askForClarification: { + target: 'clarifying', + }, + submitEmail: { + target: 'submitting', + }, + }, + }, + clarifying: { + on: { + provideClarification: { + target: 'checking', + }, + }, + }, + submitting: { + on: { + confirm: { + target: 'done', + }, + }, + }, + done: { + type: 'final', + }, + }, +}); + +function getStuff(snapshot: SnapshotFrom) { + return mapState< + typeof snapshot, + { + goal: string; + } + >(snapshot, { + states: { + checking: { + map: () => ({ + goal: 'Respond to the email given the instructions and the provided clarifications. If not enough information is provided, ask for clarification. Otherwise, if you are absolutely sure that there is no ambiguous or missing information, create and submit a response email.', + }), + }, + submitting: { + map: () => ({ + goal: 'Create and submit an email based on the instructions.', + }), + }, + }, + }); +} + +async function main() {} diff --git a/examples/example.ts b/examples_old/example.ts similarity index 100% rename from examples/example.ts rename to examples_old/example.ts diff --git a/examples/executor.ts b/examples_old/executor.ts similarity index 100% rename from examples/executor.ts rename to examples_old/executor.ts diff --git a/examples/goal.ts b/examples_old/goal.ts similarity index 100% rename from examples/goal.ts rename to examples_old/goal.ts diff --git a/examples/helpers/helpers.ts b/examples_old/helpers/helpers.ts similarity index 100% rename from examples/helpers/helpers.ts rename to examples_old/helpers/helpers.ts diff --git a/examples/helpers/loader.ts b/examples_old/helpers/loader.ts similarity index 100% rename from examples/helpers/loader.ts rename to examples_old/helpers/loader.ts diff --git a/examples/helpers/runner.ts b/examples_old/helpers/runner.ts similarity index 100% rename from examples/helpers/runner.ts rename to examples_old/helpers/runner.ts diff --git a/examples_old/hono/.gitignore b/examples_old/hono/.gitignore new file mode 100644 index 0000000..c363919 --- /dev/null +++ b/examples_old/hono/.gitignore @@ -0,0 +1,34 @@ +# prod +dist/ +dist-server/ + +# dev +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ +.wrangler + +# env +.env +.env.production +.dev.vars + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# misc +.DS_Store diff --git a/examples_old/hono/README.md b/examples_old/hono/README.md new file mode 100644 index 0000000..eba2b1e --- /dev/null +++ b/examples_old/hono/README.md @@ -0,0 +1,21 @@ +```txt +npm install +npm run dev +``` + +```txt +npm run deploy +``` + +[For generating/synchronizing types based on your Worker configuration run](https://developers.cloudflare.com/workers/wrangler/commands/#types): + +```txt +npm run cf-typegen +``` + +Pass the `CloudflareBindings` as generics when instantiation `Hono`: + +```ts +// src/index.ts +const app = new Hono<{ Bindings: CloudflareBindings }>() +``` diff --git a/examples_old/hono/package.json b/examples_old/hono/package.json new file mode 100644 index 0000000..8bd9148 --- /dev/null +++ b/examples_old/hono/package.json @@ -0,0 +1,23 @@ +{ + "name": "hono", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "$npm_execpath run build && vite preview", + "deploy": "$npm_execpath run build && wrangler deploy", + "cf-typegen": "wrangler types --env-interface CloudflareBindings" + }, + "dependencies": { + "@ai-sdk/openai": "^3.0.24", + "ai": "^6.0.66", + "hono": "^4.11.7", + "xstate": "^5.26.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.2.3", + "vite": "^6.3.5", + "wrangler": "^4.17.0" + } +} \ No newline at end of file diff --git a/examples_old/hono/pnpm-lock.yaml b/examples_old/hono/pnpm-lock.yaml new file mode 100644 index 0000000..77d37c1 --- /dev/null +++ b/examples_old/hono/pnpm-lock.yaml @@ -0,0 +1,1643 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ai-sdk/openai': + specifier: ^3.0.24 + version: 3.0.24(zod@3.25.76) + ai: + specifier: ^6.0.66 + version: 6.0.66(zod@3.25.76) + hono: + specifier: ^4.11.7 + version: 4.11.7 + xstate: + specifier: ^5.26.0 + version: 5.26.0 + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@cloudflare/vite-plugin': + specifier: ^1.2.3 + version: 1.22.1(vite@6.4.1)(workerd@1.20260128.0)(wrangler@4.61.1) + vite: + specifier: ^6.3.5 + version: 6.4.1 + wrangler: + specifier: ^4.17.0 + version: 4.61.1 + +packages: + + '@ai-sdk/gateway@3.0.31': + resolution: {integrity: sha512-WActnxPeW46XcfZWWEcJ1FytpjCtKQEo25WZVa2xZSf+u2FgSNVt/dXIvlSZetPnXo6T2P/GhFAPBULMN6siRA==, tarball: https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.31.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@3.0.24': + resolution: {integrity: sha512-f4d2z4cQpaLnCxlhL5X+/FIpA7u55eYbfCtu7hJxukav7MIQi+5uufy5OAXdCieqPnsdoiGRWaI+VTPh151mZQ==, tarball: https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.24.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.12': + resolution: {integrity: sha512-sdC3eUTa5W4r/bISlF3nxmM6zc8mV7Nj3mWI9iUO0cib70h0Zr52Tz5gGzO6HcDirbKVTR2ywmZb61MHU68prA==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.12.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.6': + resolution: {integrity: sha512-hSfoJtLtpMd7YxKM+iTqlJ0ZB+kJ83WESMiWuWrNVey3X8gg97x0OdAAaeAeclZByCX3UdPOTqhvJdK8qYA3ww==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.6.tgz} + engines: {node: '>=18'} + + '@cloudflare/kv-asset-handler@0.4.2': + resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==, tarball: https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz} + engines: {node: '>=18.0.0'} + + '@cloudflare/unenv-preset@2.12.0': + resolution: {integrity: sha512-NK4vN+2Z/GbfGS4BamtbbVk1rcu5RmqaYGiyHJQrA09AoxdZPHDF3W/EhgI0YSK8p3vRo/VNCtbSJFPON7FWMQ==, tarball: https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.12.0.tgz} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: ^1.20260115.0 + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vite-plugin@1.22.1': + resolution: {integrity: sha512-RDWc6WtrdjVDfpBeO3MYcgJIbq+Phg9qBXq1Ixl00qPqM8bgKp9oPLhg8oayynQs8udNnqkV0CjfojvIhhfZWg==, tarball: https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.22.1.tgz} + peerDependencies: + vite: ^6.1.0 || ^7.0.0 + wrangler: ^4.61.1 + + '@cloudflare/workerd-darwin-64@1.20260128.0': + resolution: {integrity: sha512-XJN8zWWNG3JwAUqqwMLNKJ9fZfdlQkx/zTTHW/BB8wHat9LjKD6AzxqCu432YmfjR+NxEKCzUOxMu1YOxlVxmg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260128.0.tgz} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260128.0': + resolution: {integrity: sha512-vKnRcmnm402GQ5DOdfT5H34qeR2m07nhnTtky8mTkNWP+7xmkz32AMdclwMmfO/iX9ncyKwSqmml2wPG32eq/w==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260128.0.tgz} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260128.0': + resolution: {integrity: sha512-RiaR+Qugof/c6oI5SagD2J5wJmIfI8wQWaV2Y9905Raj6sAYOFaEKfzkKnoLLLNYb4NlXicBrffJi1j7R/ypUA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260128.0.tgz} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260128.0': + resolution: {integrity: sha512-U39U9vcXLXYDbrJ112Q7D0LDUUnM54oXfAxPgrL2goBwio7Z6RnsM25TRvm+Q06F4+FeDOC4D51JXlFHb9t1OA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260128.0.tgz} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260128.0': + resolution: {integrity: sha512-fdJwSqRkJsAJFJ7+jy0th2uMO6fwaDA8Ny6+iFCssfzlNkc4dP/twXo+3F66FMLMe/6NIqjzVts0cpiv7ERYbQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260128.0.tgz} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==, tarball: https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz} + engines: {node: '>=12'} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==, tarball: https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.0': + resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.0': + resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.0': + resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.0': + resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.0': + resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.0': + resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.0': + resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.0': + resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.0': + resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.0': + resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.0': + resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.0': + resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.0': + resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.0': + resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.0': + resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.0': + resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.0': + resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.0': + resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.0': + resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.0': + resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.0': + resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.0': + resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.0': + resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.0': + resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.0': + resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.0': + resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==, tarball: https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==, tarball: https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==, tarball: https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==, tarball: https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==, tarball: https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==, tarball: https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==, tarball: https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==, tarball: https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==, tarball: https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==, tarball: https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==, tarball: https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==, tarball: https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==, tarball: https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==, tarball: https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==, tarball: https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==, tarball: https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==, tarball: https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, tarball: https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==, tarball: https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==, tarball: https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz} + engines: {node: '>=8.0.0'} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==, tarball: https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==, tarball: https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==, tarball: https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz} + + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==, tarball: https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==, tarball: https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz} + cpu: [x64] + os: [win32] + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==, tarball: https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.14': + resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==, tarball: https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, tarball: https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz} + + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==, tarball: https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz} + engines: {node: '>= 20'} + + ai@6.0.66: + resolution: {integrity: sha512-Klnzjlc3JczRykD75t+Qn5Jt5HwUCaLlN9aZku9KrSDjhc/pab54YH0w85huue7FLPlbTVF5zaQrw3NdEwiGpA==, tarball: https://registry.npmjs.org/ai/-/ai-6.0.66.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==, tarball: https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, tarball: https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==, tarball: https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.0: + resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz} + engines: {node: '>=18'} + hasBin: true + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==, tarball: https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz} + engines: {node: '>=18.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, tarball: https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hono@4.11.7: + resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==, tarball: https://registry.npmjs.org/hono/-/hono-4.11.7.tgz} + engines: {node: '>=16.9.0'} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==, tarball: https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==, tarball: https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz} + engines: {node: '>=6'} + + miniflare@4.20260128.0: + resolution: {integrity: sha512-AVCn3vDRY+YXu1sP4mRn81ssno6VUqxo29uY2QVfgxXU2TMLvhRIoGwm7RglJ3Gzfuidit5R86CMQ6AvdFTGAw==, tarball: https://registry.npmjs.org/miniflare/-/miniflare-4.20260128.0.tgz} + engines: {node: '>=18.0.0'} + hasBin: true + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==, tarball: https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, tarball: https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==, tarball: https://registry.npmjs.org/semver/-/semver-7.7.3.tgz} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==, tarball: https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz} + engines: {node: '>=0.10.0'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==, tarball: https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz} + + undici@7.18.2: + resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==, tarball: https://registry.npmjs.org/undici/-/undici-7.18.2.tgz} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==, tarball: https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz} + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==, tarball: https://registry.npmjs.org/vite/-/vite-6.4.1.tgz} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + workerd@1.20260128.0: + resolution: {integrity: sha512-EhLJGptSGFi8AEErLiamO3PoGpbRqL+v4Ve36H2B38VxmDgFOSmDhfepBnA14sCQzGf1AEaoZX2DCwZsmO74yQ==, tarball: https://registry.npmjs.org/workerd/-/workerd-1.20260128.0.tgz} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.61.1: + resolution: {integrity: sha512-hfYQ16VLPkNi8xE1/V3052S2stM5e+vq3Idpt83sXoDC3R7R1CLgMkK6M6+Qp3G+9GVDNyHCkvohMPdfFTaD4Q==, tarball: https://registry.npmjs.org/wrangler/-/wrangler-4.61.1.tgz} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260128.0 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==, tarball: https://registry.npmjs.org/ws/-/ws-8.18.0.tgz} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xstate@5.26.0: + resolution: {integrity: sha512-Fvi9VBoqHgsGYLU2NTag8xDTWtKqUC0+ue7EAhBNBb06wf620QEy05upBaEI1VLMzIn63zugLV8nHb69ZUWYAA==, tarball: https://registry.npmjs.org/xstate/-/xstate-5.26.0.tgz} + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==, tarball: https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==, tarball: https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz} + +snapshots: + + '@ai-sdk/gateway@3.0.31(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.6 + '@ai-sdk/provider-utils': 4.0.12(zod@3.25.76) + '@vercel/oidc': 3.1.0 + zod: 3.25.76 + + '@ai-sdk/openai@3.0.24(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.6 + '@ai-sdk/provider-utils': 4.0.12(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/provider-utils@4.0.12(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.6 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + + '@ai-sdk/provider@3.0.6': + dependencies: + json-schema: 0.4.0 + + '@cloudflare/kv-asset-handler@0.4.2': {} + + '@cloudflare/unenv-preset@2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260128.0)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260128.0 + + '@cloudflare/vite-plugin@1.22.1(vite@6.4.1)(workerd@1.20260128.0)(wrangler@4.61.1)': + dependencies: + '@cloudflare/unenv-preset': 2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260128.0) + miniflare: 4.20260128.0 + unenv: 2.0.0-rc.24 + vite: 6.4.1 + wrangler: 4.61.1 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - workerd + + '@cloudflare/workerd-darwin-64@1.20260128.0': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260128.0': + optional: true + + '@cloudflare/workerd-linux-64@1.20260128.0': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260128.0': + optional: true + + '@cloudflare/workerd-windows-64@1.20260128.0': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.0': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.0': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.0': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.0': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.0': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.0': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.0': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.0': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.0': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.0': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.0': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.0': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.0': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.0': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.0': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.0': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.0': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.0': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.0': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.0': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.0': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.0': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.0': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.0': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.0': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.0': + optional: true + + '@img/colour@1.0.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@opentelemetry/api@1.9.0': {} + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.14': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/estree@1.0.8': {} + + '@vercel/oidc@3.1.0': {} + + ai@6.0.66(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 3.0.31(zod@3.25.76) + '@ai-sdk/provider': 3.0.6 + '@ai-sdk/provider-utils': 4.0.12(zod@3.25.76) + '@opentelemetry/api': 1.9.0 + zod: 3.25.76 + + blake3-wasm@2.1.5: {} + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.0 + '@esbuild/android-arm': 0.27.0 + '@esbuild/android-arm64': 0.27.0 + '@esbuild/android-x64': 0.27.0 + '@esbuild/darwin-arm64': 0.27.0 + '@esbuild/darwin-x64': 0.27.0 + '@esbuild/freebsd-arm64': 0.27.0 + '@esbuild/freebsd-x64': 0.27.0 + '@esbuild/linux-arm': 0.27.0 + '@esbuild/linux-arm64': 0.27.0 + '@esbuild/linux-ia32': 0.27.0 + '@esbuild/linux-loong64': 0.27.0 + '@esbuild/linux-mips64el': 0.27.0 + '@esbuild/linux-ppc64': 0.27.0 + '@esbuild/linux-riscv64': 0.27.0 + '@esbuild/linux-s390x': 0.27.0 + '@esbuild/linux-x64': 0.27.0 + '@esbuild/netbsd-arm64': 0.27.0 + '@esbuild/netbsd-x64': 0.27.0 + '@esbuild/openbsd-arm64': 0.27.0 + '@esbuild/openbsd-x64': 0.27.0 + '@esbuild/openharmony-arm64': 0.27.0 + '@esbuild/sunos-x64': 0.27.0 + '@esbuild/win32-arm64': 0.27.0 + '@esbuild/win32-ia32': 0.27.0 + '@esbuild/win32-x64': 0.27.0 + + eventsource-parser@3.0.6: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + hono@4.11.7: {} + + json-schema@0.4.0: {} + + kleur@4.1.5: {} + + miniflare@4.20260128.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.18.2 + workerd: 1.20260128.0 + ws: 8.18.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + nanoid@3.3.11: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + + semver@7.7.3: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + source-map-js@1.2.1: {} + + supports-color@10.2.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tslib@2.8.1: + optional: true + + undici@7.18.2: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + vite@6.4.1: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + + workerd@1.20260128.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260128.0 + '@cloudflare/workerd-darwin-arm64': 1.20260128.0 + '@cloudflare/workerd-linux-64': 1.20260128.0 + '@cloudflare/workerd-linux-arm64': 1.20260128.0 + '@cloudflare/workerd-windows-64': 1.20260128.0 + + wrangler@4.61.1: + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@cloudflare/unenv-preset': 2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260128.0) + blake3-wasm: 2.1.5 + esbuild: 0.27.0 + miniflare: 4.20260128.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260128.0 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.18.0: {} + + xstate@5.26.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.14 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod@3.25.76: {} diff --git a/examples_old/hono/public/.assetsignore b/examples_old/hono/public/.assetsignore new file mode 100644 index 0000000..9f1f131 --- /dev/null +++ b/examples_old/hono/public/.assetsignore @@ -0,0 +1 @@ +.vite \ No newline at end of file diff --git a/examples_old/hono/public/favicon.ico b/examples_old/hono/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..543164354afc72a8b8de19830b6b9c06af58c0ae GIT binary patch literal 15406 zcmeHOdr(x@8NZ+)h{!{Yf~ZJHzz|{A<&7-MQ{D(dFls}51Y#7yNHo!C)>p7KjWw~Y zopdtOOgl}d|MZb*XPTxn&X{D(w05SQ{*jsVF=?xvnzqD_F|p#Wzi)TXa#^^*-Cd|{ zcV~Wc?m72-kMo^#&*S^fYFd~!ON)=!n5Jrv&(^ejP190S-TBM}O?#DP7K`Wo{hId9 zB2CL=9g>j3UC!jc<~KVJRbf`VN&J(zL0p_=!=|Y;AhHnq=M>=1vQ{8(50Kjnq_hAm zLsTKYJ`s=DQk2Xq#U1NT;NkP5k-HnkBwr)h^_v8KiKD=1;B+&jcu<@&a z{Q$u7Tlu}nnTTHAS)7R1oCq&HfcHe_+7keuv35V4lhNwmJDjkTJ8`T0IiUOqE;7%r z%-pZ~1P<1mz+dW|NHIt0sm*pY9LFlZXlIB>=9yH&LCE|R`h_eIxCIcd<)BfsaIUi8 zec}`5Z!-M@@jYUPmVwy7<&5Ppdksp%ZTNNg8o&@%zO&(P#2!tfu0i@m6re96AGag( z0T=SP-q?Y~^?bY<)py~_FykhkFYlGM_{ER@X19*UI ztdcQVDtikTozRxyn&v>1SsfB9@j!e9AV{bjf9*EXqm%uS)aa&l@hC@|S@{dsVc)_U zKp=f?B+ICL%@b}~p--&AX|wVdH{z8g4IJZa$AjC=%eOyGTA?SFG~zY0@|U*YwWZvb z306I7UVg~{X}Q1j;JjJ+%QoPgd`|-I-_XH5kCCm%D_^|>Sl;FadF?bSKdlo_%AQl9 z?BLYpx4y_RL)w*{F5Fq!h81Sz-?s(T+*8WF(gpecI?hc^heGZV@83d@mg!q&WlR0E zZp3A7#ck<(7bw5vsmbLxpJsj5_0n~r!5{fTll>Sj640aZ^Ts;JJeO+#A#St2~Isl}=nEpu;W7E}T}uI_6c! z(N=wo`z{Y^j>(>LW`DIOah^ckNd)CvGpjxA9aT4ouQfRX-{+c@9jYI)Krk#IeiFK9 zwMU7NpM*vT{X!N9S>S)v0tll|YN2LDE`4?Ti8qK3h#M|HUJgFw^94S?K&;d9uuMzG zoV&spRwUE!x7!tevfz4{1>mzA^6Z@2^(`hPQHy;HxOBX>!PO~@#R4JCZ1^-JCqny1ZReIlMpkdJ12Owwi_Ltl~V)~H0eq%)+ zRe)hW|5VFD&(Nh$nhX{T4DzZDW_w<@0V_4@ep zEIuNM*9hOZ5&mz-R0hXSB;Ao%hZNo;_!WT#9?0DSUtgI&`U{Qt)+83eG3o;4d`vk( zi$f>WBAaoC&j=_;zx%7NFP+R=i-b305(){`s5cnOri+sr(B_Icu%A^b_Z58g@HfBy zi@dYK{=A3$6)0z$X+;ePlGkH9@0vi5VC%n}@r)%+BS>z-b^~=x7mNO4A3bC}_1E6M zbmDv0^OonO&HnrMeH+MlP5ZJoPt3-X${Om=yzi^K zHso>dGA#QSJ!F3~o<3&EWDQu=HJa~=cDzkFOZ?T>m+syzNatOsYjPhd%>#K}%@~Z_ zIWnWKZ(WIxE1s^j6L;9RXRCGjs0GHJJ}=ls`S)-Jib#h4sq z|K~~n!98q?uMGpqr*Fpmj4pgf^!3FW#-QZBn6F(Y%gfp)>$k+@e0aXQd=q#}GKfFk z%VM1JC}j;xymAz_xnd)x^_RW0w0#sW^3K?X%|X=M%Zf3>HpUN!Bz`y^CW=4$_J!Eq z!I;o$#_t6Z$9G`|-qUzw_6iN+LsmDITYB&Tf$<<`fYvip8%OoeyU$~ak9-3;-5APw zjJ}EK`f&_dcHvctxw^nJqW76)G8W2sR-9~2kT&Ks7OAI|ST65ne3N5mTJA_GlUR2n z*Vc#6Bv$Ofr%eCIGt6x{$H+dij<)q3FDfr>D}UBaYh!%{(q6UgLAjDMooPWoUMC>n z9nC{{C&g+Mza38Mn&hA?Uy_nz8 zfmw`QpJf?bF>LC;_5jfKrO|C@W=vV``Kmg)^yek}r+3^a-6hR(BC=#)xQFrgbX6|* z0q7&SQv4Lpv>wR1_7KO5j2&4YN}q9`cje5h!!;E{SG;B>9XrQy54M#)pPSIdCzs6NUv=G8k-`YFqfNvv~bbe>pBkrRoG^M7CbRbKF072PE-bCVwSL%3Aq#61&p zo!Pg%w@}MF1b-zk5jY2ZWIv46GppU?(&XEyJc2J&aO``*Jx+x{l^wwl@2NjBIk*2z z4Vg>0FMGF&<7O1t2A1FEe)+8JE|2Ti9fOtkfUc-J7~b?KF8O`8e+IjwgX4(*McB{( zB78!)?(#7G9q*FZW>AfQL{*(aTJw&Q)6Q4y{P^aF7qzSA?Xw2 fns@IT_Cadv^H^~AY8cWiWPy+cLKgV{w7|as=smC{ literal 0 HcmV?d00001 diff --git a/examples_old/hono/src/agent.ts b/examples_old/hono/src/agent.ts new file mode 100644 index 0000000..602c253 --- /dev/null +++ b/examples_old/hono/src/agent.ts @@ -0,0 +1,90 @@ +import { generateText, tool } from 'ai'; +import { createOpenAI } from '@ai-sdk/openai'; +import type { StateValue } from 'xstate'; +import { emailMachine } from './machine'; +import { agentEvents } from './events'; +import { getAllTransitions } from './utils'; +import { z } from 'zod'; + +type ObservedState = { + value: StateValue; + context: Record; +}; + +// States where agent makes decisions +export function requiresAgentDecision(stateValue: StateValue): boolean { + return stateValue === 'checking'; +} + +// Build AI SDK tools from Zod events, filtered by available transitions +function buildTools( + resolvedState: ReturnType +) { + const transitions = getAllTransitions(resolvedState); + const availableEventTypes = new Set(transitions.map((t) => t.eventType)); + + const tools: Record> = {}; + + for (const [eventType, schema] of Object.entries(agentEvents)) { + if (!availableEventTypes.has(eventType)) continue; + + tools[eventType] = tool({ + description: schema.description!, + inputSchema: schema, + execute: async (params) => ({ type: eventType, ...params }), + }); + } + + return tools; +} + +export async function getAgentDecision( + observedState: ObservedState, + goal: string, + apiKey: string +): Promise<{ type: string; [key: string]: unknown } | null> { + // Rehydrate state from serialized form + const resolvedState = emailMachine.resolveState(observedState); + const tools = buildTools(resolvedState); + + console.log('tools', tools); + + if (Object.keys(tools).length === 0) { + return null; + } + + const context = observedState.context as { + userRequest: string; + clarifications: string[]; + questions: string[]; + }; + + const systemPrompt = `You are an email assistant helping draft emails. + +User's request: ${context.userRequest} + +${ + context.clarifications.length > 0 + ? `Previous clarifications provided:\n${context.clarifications.join('\n')}` + : '' +} + +${goal} + +If you need more information to write a proper email (recipient, tone, specific details), ask for clarification. +If you have enough information, submit the email with recipient, subject, and body.`; + + const openai = createOpenAI({ apiKey }); + const result = await generateText({ + model: openai.chat('gpt-5-mini'), + system: systemPrompt, + messages: [{ role: 'user', content: goal }], + tools, + toolChoice: 'required', + }); + + const toolResult = result.toolResults[0]; + return ( + (toolResult?.result as { type: string; [key: string]: unknown }) ?? null + ); +} diff --git a/examples_old/hono/src/db.ts b/examples_old/hono/src/db.ts new file mode 100644 index 0000000..4aa0d30 --- /dev/null +++ b/examples_old/hono/src/db.ts @@ -0,0 +1,76 @@ +import type { StateValue } from 'xstate'; + +export interface StateEntry { + id: string; + value: StateValue; + context: Record; + event: { type: string; [key: string]: unknown } | null; + timestamp: number; +} + +export interface Session { + sessionId: string; + value: StateValue; + context: Record; + history: StateEntry[]; + createdAt: number; +} + +export interface SessionDB { + createSession(initialContext: Record): string; + getSession(sessionId: string): Session | null; + appendState( + sessionId: string, + entry: { + value: StateValue; + context: Record; + event: { type: string; [key: string]: unknown } | null; + } + ): void; +} + +// In-memory implementation +const sessions = new Map(); + +export const db: SessionDB = { + createSession(initialContext) { + const sessionId = crypto.randomUUID(); + const now = Date.now(); + const session: Session = { + sessionId, + value: 'checking', + context: initialContext, + history: [ + { + id: crypto.randomUUID(), + value: 'checking', + context: initialContext, + event: null, + timestamp: now, + }, + ], + createdAt: now, + }; + sessions.set(sessionId, session); + return sessionId; + }, + + getSession(sessionId) { + return sessions.get(sessionId) ?? null; + }, + + appendState(sessionId, entry) { + const session = sessions.get(sessionId); + if (!session) throw new Error('Session not found'); + + const stateEntry: StateEntry = { + id: crypto.randomUUID(), + timestamp: Date.now(), + ...entry, + }; + + session.history.push(stateEntry); + session.value = entry.value; + session.context = entry.context; + }, +}; diff --git a/examples_old/hono/src/events.ts b/examples_old/hono/src/events.ts new file mode 100644 index 0000000..b37788b --- /dev/null +++ b/examples_old/hono/src/events.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +// Events the agent can choose +export const agentEvents = { + askForClarification: z + .object({ + questions: z + .array(z.string()) + .describe('Questions to ask the user for clarification'), + }) + .describe('Ask the user for more information before drafting email'), + + submitEmail: z + .object({ + recipient: z.string().describe('Email recipient address'), + subject: z.string().describe('Email subject line'), + body: z.string().describe('Email body content'), + }) + .describe('Submit the final drafted email'), +}; + +// Events the user sends +export const userEvents = { + provideClarification: z.object({ + answers: z.string().describe('User answers to clarification questions'), + }), + + confirm: z.object({}).describe('Confirm and send the email'), +}; + +export type AgentEventType = keyof typeof agentEvents; +export type UserEventType = keyof typeof userEvents; diff --git a/examples_old/hono/src/index.ts b/examples_old/hono/src/index.ts new file mode 100644 index 0000000..09606ac --- /dev/null +++ b/examples_old/hono/src/index.ts @@ -0,0 +1,362 @@ +import { Hono } from 'hono'; +import { html, raw } from 'hono/html'; +import { emailMachine } from './machine'; +import { db } from './db'; +import { getAgentDecision, requiresAgentDecision } from './agent'; +import { transition } from 'xstate'; + +type Bindings = { + OPENAI_API_KEY: string; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +// GET / - Simple UI +app.get('/', async (c) => { + const sessionId = c.req.query('sessionId'); + let initialSession = null; + + if (sessionId) { + const session = db.getSession(sessionId); + if (session) { + initialSession = { + sessionId, + state: { value: session.value, context: session.context }, + }; + } + } + + return c.html(html` + + + + Email Agent + + + +

Email Agent

+ +
+ + +
+ + + + + + + `); +}); + +// POST /sessions - Start new email session +app.post('/sessions', async (c) => { + const body = await c.req.json<{ userRequest: string }>(); + + const sessionId = db.createSession({ + userRequest: body.userRequest, + recipient: '', + subject: '', + body: '', + clarifications: [], + questions: [], + }); + + const session = db.getSession(sessionId)!; + + const response: { + sessionId: string; + state: { value: unknown; context: Record }; + agentResponse?: { type: string; [key: string]: unknown }; + } = { + sessionId, + state: { value: session.value, context: session.context }, + }; + + // Initial state requires agent decision + if (requiresAgentDecision(session.value)) { + console.log('requiresAgentDecision', session.value); + const event = await getAgentDecision( + { value: session.value, context: session.context }, + 'Help the user draft and send an email based on their request.', + c.env.OPENAI_API_KEY + ); + + console.log('event', event); + + if (event) { + const resolvedState = emailMachine.resolveState({ + value: session.value, + context: session.context, + }); + const [nextState] = transition(emailMachine, resolvedState, event as any); + + console.log('nextState', nextState); + + db.appendState(sessionId, { + value: nextState.value, + context: nextState.context, + event, + }); + + response.state = { value: nextState.value, context: nextState.context }; + response.agentResponse = event; + } + } + + return c.json(response); +}); + +// POST /sessions/:id/events - Send event to session +app.post('/sessions/:id/events', async (c) => { + const sessionId = c.req.param('id'); + const session = db.getSession(sessionId); + + if (!session) { + return c.json({ error: 'Session not found' }, 404); + } + + const event = await c.req.json<{ type: string; [key: string]: unknown }>(); + + // Transition with user event + const resolvedState = emailMachine.resolveState({ + value: session.value, + context: session.context, + }); + const [nextState] = transition(emailMachine, resolvedState, event as any); + + db.appendState(sessionId, { + value: nextState.value, + context: nextState.context, + event, + }); + + const response: { + state: { value: unknown; context: Record }; + agentResponse?: { type: string; [key: string]: unknown }; + } = { + state: { value: nextState.value, context: nextState.context }, + }; + + // If new state requires agent, call LLM + if (requiresAgentDecision(nextState.value)) { + console.log('requiresAgentDecision', nextState.value); + const agentEvent = await getAgentDecision( + { value: nextState.value, context: nextState.context }, + 'Continue helping draft the email based on the clarifications provided.', + c.env.OPENAI_API_KEY + ); + + console.log('agentEvent', agentEvent); + + if (agentEvent) { + const [afterAgentState] = transition( + emailMachine, + emailMachine.resolveState({ + value: nextState.value, + context: nextState.context, + }), + agentEvent as any + ); + + console.log('afterAgentState', afterAgentState); + + db.appendState(sessionId, { + value: afterAgentState.value, + context: afterAgentState.context, + event: agentEvent, + }); + + response.state = { + value: afterAgentState.value, + context: afterAgentState.context, + }; + response.agentResponse = agentEvent; + } + } + + return c.json(response); +}); + +// GET /sessions/:id - Get current state +app.get('/sessions/:id', (c) => { + const sessionId = c.req.param('id'); + const session = db.getSession(sessionId); + + if (!session) { + return c.json({ error: 'Session not found' }, 404); + } + + return c.json({ + sessionId, + state: { value: session.value, context: session.context }, + }); +}); + +// GET /sessions/:id/history - Get full append-only history +app.get('/sessions/:id/history', (c) => { + const sessionId = c.req.param('id'); + const session = db.getSession(sessionId); + + if (!session) { + return c.json({ error: 'Session not found' }, 404); + } + + return c.json({ sessionId, history: session.history }); +}); + +export default app; diff --git a/examples_old/hono/src/machine.ts b/examples_old/hono/src/machine.ts new file mode 100644 index 0000000..bdcf230 --- /dev/null +++ b/examples_old/hono/src/machine.ts @@ -0,0 +1,74 @@ +import { setup, assign } from 'xstate'; + +export const emailMachine = setup({ + types: { + context: {} as { + userRequest: string; + recipient: string; + subject: string; + body: string; + clarifications: string[]; + questions: string[]; + }, + events: {} as + | { type: 'askForClarification'; questions: string[] } + | { type: 'provideClarification'; answers: string } + | { type: 'submitEmail'; recipient: string; subject: string; body: string } + | { type: 'confirm' }, + }, +}).createMachine({ + id: 'emailAgent', + initial: 'checking', + context: { + userRequest: '', + recipient: '', + subject: '', + body: '', + clarifications: [], + questions: [], + }, + states: { + checking: { + // Agent decides: askForClarification or submitEmail + on: { + askForClarification: { + target: 'clarifying', + actions: assign({ + questions: ({ event }) => event.questions, + }), + }, + submitEmail: { + target: 'submitting', + actions: assign({ + recipient: ({ event }) => event.recipient, + subject: ({ event }) => event.subject, + body: ({ event }) => event.body, + }), + }, + }, + }, + clarifying: { + // Wait for user clarification + on: { + provideClarification: { + target: 'checking', + actions: assign({ + clarifications: ({ context, event }) => [ + ...context.clarifications, + event.answers, + ], + }), + }, + }, + }, + submitting: { + // User confirms or edits + on: { + confirm: 'done', + }, + }, + done: { + type: 'final', + }, + }, +}); diff --git a/examples_old/hono/src/style.css b/examples_old/hono/src/style.css new file mode 100644 index 0000000..50969c8 --- /dev/null +++ b/examples_old/hono/src/style.css @@ -0,0 +1,3 @@ +h1 { + font-family: Arial, Helvetica, sans-serif; +} diff --git a/examples_old/hono/src/utils.ts b/examples_old/hono/src/utils.ts new file mode 100644 index 0000000..5d9636b --- /dev/null +++ b/examples_old/hono/src/utils.ts @@ -0,0 +1,29 @@ +import type { AnyMachineSnapshot, AnyStateNode } from 'xstate'; + +export interface TransitionData { + eventType: string; + description?: string; +} + +export function getAllTransitions(state: AnyMachineSnapshot): TransitionData[] { + const nodes = state._nodes; + const transitions = (nodes as AnyStateNode[]) + .map((node) => [...(node as AnyStateNode).transitions.values()]) + .map((nodeTransitions) => { + return nodeTransitions.map((nodeEventTransitions) => { + return nodeEventTransitions.map((transition) => ({ + eventType: transition.eventType, + description: transition.description, + })); + }); + }) + .flat(2); + + return transitions; +} + +export function randomId() { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 9); + return timestamp + random; +} diff --git a/examples_old/hono/tsconfig.json b/examples_old/hono/tsconfig.json new file mode 100644 index 0000000..fe4b04f --- /dev/null +++ b/examples_old/hono/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": [ + "ESNext" + ], + "types": ["vite/client"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + }, +} \ No newline at end of file diff --git a/examples_old/hono/vite.config.ts b/examples_old/hono/vite.config.ts new file mode 100644 index 0000000..f626b72 --- /dev/null +++ b/examples_old/hono/vite.config.ts @@ -0,0 +1,6 @@ +import { cloudflare } from '@cloudflare/vite-plugin' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [cloudflare()] +}) diff --git a/examples_old/hono/wrangler.jsonc b/examples_old/hono/wrangler.jsonc new file mode 100644 index 0000000..8441ec8 --- /dev/null +++ b/examples_old/hono/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "hono", + "compatibility_date": "2025-08-03", + "main": "./src/index.ts" +} \ No newline at end of file diff --git a/examples/joke.ts b/examples_old/joke.ts similarity index 100% rename from examples/joke.ts rename to examples_old/joke.ts diff --git a/examples/multi.ts b/examples_old/multi.ts similarity index 100% rename from examples/multi.ts rename to examples_old/multi.ts diff --git a/examples/newspaper.ts b/examples_old/newspaper.ts similarity index 100% rename from examples/newspaper.ts rename to examples_old/newspaper.ts diff --git a/examples/number.ts b/examples_old/number.ts similarity index 100% rename from examples/number.ts rename to examples_old/number.ts diff --git a/examples/raffle.ts b/examples_old/raffle.ts similarity index 100% rename from examples/raffle.ts rename to examples_old/raffle.ts diff --git a/examples/sandbox.ts b/examples_old/sandbox.ts similarity index 100% rename from examples/sandbox.ts rename to examples_old/sandbox.ts diff --git a/examples/simple.ts b/examples_old/simple.ts similarity index 100% rename from examples/simple.ts rename to examples_old/simple.ts diff --git a/examples/support.ts b/examples_old/support.ts similarity index 100% rename from examples/support.ts rename to examples_old/support.ts diff --git a/examples/ticTacToe.ts b/examples_old/ticTacToe.ts similarity index 100% rename from examples/ticTacToe.ts rename to examples_old/ticTacToe.ts diff --git a/examples/todo.ts b/examples_old/todo.ts similarity index 100% rename from examples/todo.ts rename to examples_old/todo.ts diff --git a/examples/tutor.ts b/examples_old/tutor.ts similarity index 100% rename from examples/tutor.ts rename to examples_old/tutor.ts diff --git a/examples/verify.ts b/examples_old/verify.ts similarity index 100% rename from examples/verify.ts rename to examples_old/verify.ts diff --git a/examples/weather.ts b/examples_old/weather.ts similarity index 100% rename from examples/weather.ts rename to examples_old/weather.ts diff --git a/examples/wiki.ts b/examples_old/wiki.ts similarity index 100% rename from examples/wiki.ts rename to examples_old/wiki.ts diff --git a/examples/word.ts b/examples_old/word.ts similarity index 100% rename from examples/word.ts rename to examples_old/word.ts diff --git a/package.json b/package.json index 019cbb9..a4b1c0e 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,62 @@ { "name": "@statelyai/agent", - "version": "1.1.6", - "description": "Stateful agents that make decisions based on finite-state machine models", - "main": "dist/index.js", + "version": "2.0.0", + "description": "Lightweight, stateless, framework-agnostic state-machine-driven AI agents", + "type": "module", + "main": "dist/index.cjs", "module": "dist/index.mjs", - "types": "dist/index.d.ts", + "types": "dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./ai-sdk": { + "import": { + "types": "./dist/ai-sdk.d.mts", + "default": "./dist/ai-sdk.mjs" + }, + "require": { + "types": "./dist/ai-sdk.d.cts", + "default": "./dist/ai-sdk.cjs" + } + }, + "./graph": { + "import": { + "types": "./dist/graph.d.mts", + "default": "./dist/graph.mjs" + }, + "require": { + "types": "./dist/graph.d.cts", + "default": "./dist/graph.cjs" + } + }, + "./xstate": { + "import": { + "types": "./dist/xstate.d.mts", + "default": "./dist/xstate.mjs" + }, + "require": { + "types": "./dist/xstate.d.cts", + "default": "./dist/xstate.cjs" + } + } + }, + "files": [ + "dist" + ], "scripts": { - "build": "tsup src/index.ts --format cjs,esm --dts", + "build": "tsdown", "lint": "tsc --noEmit", "test": "vitest", "test:ci": "vitest --run", - "example": "ts-node examples/helpers/runner.ts", - "prepublishOnly": "tsup src/index.ts --format cjs,esm --dts", + "prepublishOnly": "tsdown", "changeset": "changeset", "release": "changeset publish", "version": "changeset version" @@ -20,37 +65,37 @@ "ai", "state machine", "agent", - "rl", - "reinforcement learning" + "statechart", + "classify", + "decide" ], "author": "David Khourshid ", "license": "MIT", "devDependencies": { - "@ai-sdk/openai": "^0.0.40", + "@ai-sdk/openai": "^3.0.25", "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.9", - "@langchain/community": "^0.0.53", - "@langchain/core": "^0.1.63", - "@langchain/openai": "^0.0.28", "@types/node": "^20.16.10", - "@types/object-hash": "^3.0.6", "dotenv": "^16.4.5", - "json-schema-to-ts": "^3.1.1", - "ts-node": "^10.9.2", - "tsup": "^8.3.0", + "tsdown": "^0.21.7", "typescript": "^5.6.2", "vitest": "^2.1.2", - "wikipedia": "^2.1.2", - "zod": "^3.23.8" + "zod": "^4.3.6" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } }, "publishConfig": { "access": "public" }, "dependencies": { - "@xstate/graph": "^2.0.1", - "ai": "^3.4.9", - "object-hash": "^3.0.0", - "xstate": "^5.18.2" + "ai": "^6.0.67", + "xstate": "^5.26.0" }, - "packageManager": "pnpm@8.11.0" + "packageManager": "pnpm@10.28.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d617c3e..86020b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,237 +10,160 @@ importers: dependencies: '@xstate/graph': specifier: ^2.0.1 - version: 2.0.1(xstate@5.18.2) + version: 2.0.1(xstate@5.26.0) ai: - specifier: ^3.4.9 - version: 3.4.9(openai@4.67.1(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.5.11(typescript@5.6.2))(zod@3.23.8) - object-hash: - specifier: ^3.0.0 - version: 3.0.0 + specifier: ^6.0.67 + version: 6.0.67(zod@4.3.6) xstate: - specifier: ^5.18.2 - version: 5.18.2 + specifier: ^5.26.0 + version: 5.26.0 devDependencies: '@ai-sdk/openai': - specifier: ^0.0.40 - version: 0.0.40(zod@3.23.8) + specifier: ^3.0.25 + version: 3.0.25(zod@4.3.6) '@changesets/changelog-github': specifier: ^0.5.0 - version: 0.5.0 + version: 0.5.2 '@changesets/cli': specifier: ^2.27.9 - version: 2.27.9 - '@langchain/community': - specifier: ^0.0.53 - version: 0.0.53(openai@4.67.1(zod@3.23.8)) - '@langchain/core': - specifier: ^0.1.63 - version: 0.1.63(openai@4.67.1(zod@3.23.8)) - '@langchain/openai': - specifier: ^0.0.28 - version: 0.0.28 + version: 2.29.8(@types/node@20.19.30) '@types/node': specifier: ^20.16.10 - version: 20.16.10 - '@types/object-hash': - specifier: ^3.0.6 - version: 3.0.6 + version: 20.19.30 dotenv: specifier: ^16.4.5 - version: 16.4.5 - json-schema-to-ts: - specifier: ^3.1.1 - version: 3.1.1 - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@20.16.10)(typescript@5.6.2) - tsup: - specifier: ^8.3.0 - version: 8.3.0(postcss@8.4.47)(typescript@5.6.2) + version: 16.6.1 + tsdown: + specifier: ^0.21.7 + version: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(typescript@5.9.3) typescript: specifier: ^5.6.2 - version: 5.6.2 + version: 5.9.3 vitest: specifier: ^2.1.2 - version: 2.1.2(@types/node@20.16.10) - wikipedia: - specifier: ^2.1.2 - version: 2.1.2 + version: 2.1.9(@types/node@20.19.30) zod: - specifier: ^3.23.8 - version: 3.23.8 + specifier: ^4.3.6 + version: 4.3.6 packages: - '@ai-sdk/openai@0.0.40': - resolution: {integrity: sha512-9Iq1UaBHA5ZzNv6j3govuKGXrbrjuWvZIgWNJv4xzXlDMHu9P9hnqlBr/Aiay54WwCuTVNhTzAUTfFgnTs2kbQ==, tarball: https://registry.npmjs.org/@ai-sdk/openai/-/openai-0.0.40.tgz} + '@ai-sdk/gateway@3.0.32': + resolution: {integrity: sha512-7clZRr07P9rpur39t1RrbIe7x8jmwnwUWI8tZs+BvAfX3NFgdSVGGIaT7bTz2pb08jmLXzTSDbrOTqAQ7uBkBQ==, tarball: https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.32.tgz} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@1.0.20': - resolution: {integrity: sha512-ngg/RGpnA00eNOWEtXHenpX1MsM2QshQh4QJFjUfwcqHpM5kTfG7je7Rc3HcEDP+OkRVv2GF+X4fC1Vfcnl8Ow==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-1.0.20.tgz} + '@ai-sdk/openai@3.0.25': + resolution: {integrity: sha512-DsaN46R98+D1W3lU3fKuPU3ofacboLaHlkAwxJPgJ8eup1AJHmPK1N1y10eJJbJcF6iby8Tf/vanoZxc9JPUfw==, tarball: https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.25.tgz} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 - peerDependenciesMeta: - zod: - optional: true + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@1.0.5': - resolution: {integrity: sha512-XfOawxk95X3S43arn2iQIFyWGMi0DTxsf9ETc6t7bh91RPWOOPYN1tsmS5MTKD33OGJeaDQ/gnVRzXUCRBrckQ==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-1.0.5.tgz} + '@ai-sdk/provider-utils@4.0.13': + resolution: {integrity: sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.13.tgz} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 - peerDependenciesMeta: - zod: - optional: true + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@0.0.14': - resolution: {integrity: sha512-gaQ5Y033nro9iX1YUjEDFDRhmMcEiCk56LJdIUbX5ozEiCNCfpiBpEqrjSp/Gp5RzBS2W0BVxfG7UGW6Ezcrzg==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-0.0.14.tgz} + '@ai-sdk/provider@3.0.7': + resolution: {integrity: sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.7.tgz} engines: {node: '>=18'} - '@ai-sdk/provider@0.0.24': - resolution: {integrity: sha512-XMsNGJdGO+L0cxhhegtqZ8+T6nn4EoShS819OvCgI2kLbYTIvk0GWFGD0AXJmxkxs3DrpsJxKAFukFR7bvTkgQ==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-0.0.24.tgz} - engines: {node: '>=18'} - - '@ai-sdk/react@0.0.62': - resolution: {integrity: sha512-1asDpxgmeHWL0/EZPCLENxfOHT+0jce0z/zasRhascodm2S6f6/KZn5doLG9jdmarcb+GjMjFmmwyOVXz3W1xg==, tarball: https://registry.npmjs.org/@ai-sdk/react/-/react-0.0.62.tgz} - engines: {node: '>=18'} - peerDependencies: - react: ^18 || ^19 - zod: ^3.0.0 - peerDependenciesMeta: - react: - optional: true - zod: - optional: true - - '@ai-sdk/solid@0.0.49': - resolution: {integrity: sha512-KnfWTt640cS1hM2fFIba8KHSPLpOIWXtEm28pNCHTvqasVKlh2y/zMQANTwE18pF2nuXL9P9F5/dKWaPsaEzQw==, tarball: https://registry.npmjs.org/@ai-sdk/solid/-/solid-0.0.49.tgz} - engines: {node: '>=18'} - peerDependencies: - solid-js: ^1.7.7 - peerDependenciesMeta: - solid-js: - optional: true - - '@ai-sdk/svelte@0.0.51': - resolution: {integrity: sha512-aIZJaIds+KpCt19yUDCRDWebzF/17GCY7gN9KkcA2QM6IKRO5UmMcqEYja0ZmwFQPm1kBZkF2njhr8VXis2mAw==, tarball: https://registry.npmjs.org/@ai-sdk/svelte/-/svelte-0.0.51.tgz} - engines: {node: '>=18'} - peerDependencies: - svelte: ^3.0.0 || ^4.0.0 - peerDependenciesMeta: - svelte: - optional: true - - '@ai-sdk/ui-utils@0.0.46': - resolution: {integrity: sha512-ZG/wneyJG+6w5Nm/hy1AKMuRgjPQToAxBsTk61c9sVPUTaxo+NNjM2MhXQMtmsja2N5evs8NmHie+ExEgpL3cA==, tarball: https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-0.0.46.tgz} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.0.0 - peerDependenciesMeta: - zod: - optional: true - - '@ai-sdk/vue@0.0.54': - resolution: {integrity: sha512-Ltu6gbuii8Qlp3gg7zdwdnHdS4M8nqKDij2VVO1223VOtIFwORFJzKqpfx44U11FW8z2TPVBYN+FjkyVIcN2hg==, tarball: https://registry.npmjs.org/@ai-sdk/vue/-/vue-0.0.54.tgz} - engines: {node: '>=18'} - peerDependencies: - vue: ^3.3.4 - peerDependenciesMeta: - vue: - optional: true - - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==, tarball: https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz} - engines: {node: '>=6.0.0'} + '@babel/generator@8.0.0-rc.3': + resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz} + engines: {node: ^20.19.0 || >=22.12.0} - '@babel/helper-string-parser@7.25.7': - resolution: {integrity: sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==, tarball: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz} - engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0-rc.3': + resolution: {integrity: sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==, tarball: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz} + engines: {node: ^20.19.0 || >=22.12.0} - '@babel/helper-validator-identifier@7.25.7': - resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==, tarball: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz} - engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.0-rc.3': + resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==, tarball: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz} + engines: {node: ^20.19.0 || >=22.12.0} - '@babel/parser@7.25.7': - resolution: {integrity: sha512-aZn7ETtQsjjGG5HruveUK06cU3Hljuhd9Iojm4M8WWv3wLE6OkE5PWbDUkItmMgegmccaITudyuW5RPYrYlgWw==, tarball: https://registry.npmjs.org/@babel/parser/-/parser-7.25.7.tgz} - engines: {node: '>=6.0.0'} + '@babel/parser@8.0.0-rc.3': + resolution: {integrity: sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==, tarball: https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - '@babel/runtime@7.25.7': - resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==, tarball: https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz} + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==, tarball: https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz} engines: {node: '>=6.9.0'} - '@babel/types@7.25.7': - resolution: {integrity: sha512-vwIVdXG+j+FOpkwqHRcBgHLYNL7XMkufrlaFvL9o6Ai9sJn9+PdyIL5qa0XzTZw084c+u9LOls53eoZWP/W5WQ==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.25.7.tgz} - engines: {node: '>=6.9.0'} + '@babel/types@8.0.0-rc.3': + resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==, tarball: https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz} + engines: {node: ^20.19.0 || >=22.12.0} - '@changesets/apply-release-plan@7.0.5': - resolution: {integrity: sha512-1cWCk+ZshEkSVEZrm2fSj1Gz8sYvxgUL4Q78+1ZZqeqfuevPTPk033/yUZ3df8BKMohkqqHfzj0HOOrG0KtXTw==, tarball: https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.5.tgz} + '@changesets/apply-release-plan@7.0.14': + resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==, tarball: https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.14.tgz} - '@changesets/assemble-release-plan@6.0.4': - resolution: {integrity: sha512-nqICnvmrwWj4w2x0fOhVj2QEGdlUuwVAwESrUo5HLzWMI1rE5SWfsr9ln+rDqWB6RQ2ZyaMZHUcU7/IRaUJS+Q==, tarball: https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.4.tgz} + '@changesets/assemble-release-plan@6.0.9': + resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==, tarball: https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.9.tgz} - '@changesets/changelog-git@0.2.0': - resolution: {integrity: sha512-bHOx97iFI4OClIT35Lok3sJAwM31VbUM++gnMBV16fdbtBhgYu4dxsphBF/0AZZsyAHMrnM0yFcj5gZM1py6uQ==, tarball: https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.2.0.tgz} + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==, tarball: https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.2.1.tgz} - '@changesets/changelog-github@0.5.0': - resolution: {integrity: sha512-zoeq2LJJVcPJcIotHRJEEA2qCqX0AQIeFE+L21L8sRLPVqDhSXY8ZWAt2sohtBpFZkBwu+LUwMSKRr2lMy3LJA==, tarball: https://registry.npmjs.org/@changesets/changelog-github/-/changelog-github-0.5.0.tgz} + '@changesets/changelog-github@0.5.2': + resolution: {integrity: sha512-HeGeDl8HaIGj9fQHo/tv5XKQ2SNEi9+9yl1Bss1jttPqeiASRXhfi0A2wv8yFKCp07kR1gpOI5ge6+CWNm1jPw==, tarball: https://registry.npmjs.org/@changesets/changelog-github/-/changelog-github-0.5.2.tgz} - '@changesets/cli@2.27.9': - resolution: {integrity: sha512-q42a/ZbDnxPpCb5Wkm6tMVIxgeI9C/bexntzTeCFBrQEdpisQqk8kCHllYZMDjYtEc1ZzumbMJAG8H0Z4rdvjg==, tarball: https://registry.npmjs.org/@changesets/cli/-/cli-2.27.9.tgz} + '@changesets/cli@2.29.8': + resolution: {integrity: sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==, tarball: https://registry.npmjs.org/@changesets/cli/-/cli-2.29.8.tgz} hasBin: true - '@changesets/config@3.0.3': - resolution: {integrity: sha512-vqgQZMyIcuIpw9nqFIpTSNyc/wgm/Lu1zKN5vECy74u95Qx/Wa9g27HdgO4NkVAaq+BGA8wUc/qvbvVNs93n6A==, tarball: https://registry.npmjs.org/@changesets/config/-/config-3.0.3.tgz} + '@changesets/config@3.1.2': + resolution: {integrity: sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog==, tarball: https://registry.npmjs.org/@changesets/config/-/config-3.1.2.tgz} '@changesets/errors@0.2.0': resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==, tarball: https://registry.npmjs.org/@changesets/errors/-/errors-0.2.0.tgz} - '@changesets/get-dependents-graph@2.1.2': - resolution: {integrity: sha512-sgcHRkiBY9i4zWYBwlVyAjEM9sAzs4wYVwJUdnbDLnVG3QwAaia1Mk5P8M7kraTOZN+vBET7n8KyB0YXCbFRLQ==, tarball: https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.2.tgz} + '@changesets/get-dependents-graph@2.1.3': + resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==, tarball: https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.3.tgz} - '@changesets/get-github-info@0.6.0': - resolution: {integrity: sha512-v/TSnFVXI8vzX9/w3DU2Ol+UlTZcu3m0kXTjTT4KlAdwSvwutcByYwyYn9hwerPWfPkT2JfpoX0KgvCEi8Q/SA==, tarball: https://registry.npmjs.org/@changesets/get-github-info/-/get-github-info-0.6.0.tgz} + '@changesets/get-github-info@0.7.0': + resolution: {integrity: sha512-+i67Bmhfj9V4KfDeS1+Tz3iF32btKZB2AAx+cYMqDSRFP7r3/ZdGbjCo+c6qkyViN9ygDuBjzageuPGJtKGe5A==, tarball: https://registry.npmjs.org/@changesets/get-github-info/-/get-github-info-0.7.0.tgz} - '@changesets/get-release-plan@4.0.4': - resolution: {integrity: sha512-SicG/S67JmPTrdcc9Vpu0wSQt7IiuN0dc8iR5VScnnTVPfIaLvKmEGRvIaF0kcn8u5ZqLbormZNTO77bCEvyWw==, tarball: https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.4.tgz} + '@changesets/get-release-plan@4.0.14': + resolution: {integrity: sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==, tarball: https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.14.tgz} '@changesets/get-version-range-type@0.4.0': resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==, tarball: https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.4.0.tgz} - '@changesets/git@3.0.1': - resolution: {integrity: sha512-pdgHcYBLCPcLd82aRcuO0kxCDbw/yISlOtkmwmE8Odo1L6hSiZrBOsRl84eYG7DRCab/iHnOkWqExqc4wxk2LQ==, tarball: https://registry.npmjs.org/@changesets/git/-/git-3.0.1.tgz} + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==, tarball: https://registry.npmjs.org/@changesets/git/-/git-3.0.4.tgz} '@changesets/logger@0.1.1': resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==, tarball: https://registry.npmjs.org/@changesets/logger/-/logger-0.1.1.tgz} - '@changesets/parse@0.4.0': - resolution: {integrity: sha512-TS/9KG2CdGXS27S+QxbZXgr8uPsP4yNJYb4BC2/NeFUj80Rni3TeD2qwWmabymxmrLo7JEsytXH1FbpKTbvivw==, tarball: https://registry.npmjs.org/@changesets/parse/-/parse-0.4.0.tgz} + '@changesets/parse@0.4.2': + resolution: {integrity: sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA==, tarball: https://registry.npmjs.org/@changesets/parse/-/parse-0.4.2.tgz} - '@changesets/pre@2.0.1': - resolution: {integrity: sha512-vvBJ/If4jKM4tPz9JdY2kGOgWmCowUYOi5Ycv8dyLnEE8FgpYYUo1mgJZxcdtGGP3aG8rAQulGLyyXGSLkIMTQ==, tarball: https://registry.npmjs.org/@changesets/pre/-/pre-2.0.1.tgz} + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==, tarball: https://registry.npmjs.org/@changesets/pre/-/pre-2.0.2.tgz} - '@changesets/read@0.6.1': - resolution: {integrity: sha512-jYMbyXQk3nwP25nRzQQGa1nKLY0KfoOV7VLgwucI0bUO8t8ZLCr6LZmgjXsiKuRDc+5A6doKPr9w2d+FEJ55zQ==, tarball: https://registry.npmjs.org/@changesets/read/-/read-0.6.1.tgz} + '@changesets/read@0.6.6': + resolution: {integrity: sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg==, tarball: https://registry.npmjs.org/@changesets/read/-/read-0.6.6.tgz} - '@changesets/should-skip-package@0.1.1': - resolution: {integrity: sha512-H9LjLbF6mMHLtJIc/eHR9Na+MifJ3VxtgP/Y+XLn4BF7tDTEN1HNYtH6QMcjP1uxp9sjaFYmW8xqloaCi/ckTg==, tarball: https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.1.tgz} + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==, tarball: https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.2.tgz} '@changesets/types@4.1.0': resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==, tarball: https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz} - '@changesets/types@6.0.0': - resolution: {integrity: sha512-b1UkfNulgKoWfqyHtzKS5fOZYSJO+77adgL7DLRDr+/7jhChN+QcHnbjiQVOz/U+Ts3PGNySq7diAItzDgugfQ==, tarball: https://registry.npmjs.org/@changesets/types/-/types-6.0.0.tgz} + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==, tarball: https://registry.npmjs.org/@changesets/types/-/types-6.1.0.tgz} - '@changesets/write@0.3.2': - resolution: {integrity: sha512-kDxDrPNpUgsjDbWBvUo27PzKX4gqeKOlhibaOXDJA6kuBisGqNHv/HwGJrAu8U/dSf8ZEFIeHIPtvSlZI1kULw==, tarball: https://registry.npmjs.org/@changesets/write/-/write-0.3.2.tgz} + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==, tarball: https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz} - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==, tarball: https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz} - engines: {node: '>=12'} + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==, tarball: https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==, tarball: https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==, tarball: https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz} '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz} @@ -248,750 +171,459 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.23.1': - resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.23.1': - resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.23.1': - resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.23.1': - resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.23.1': - resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.23.1': - resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.23.1': - resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.23.1': - resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.23.1': - resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.23.1': - resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz} engines: {node: '>=12'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.23.1': - resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz} engines: {node: '>=12'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.23.1': - resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz} engines: {node: '>=12'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.23.1': - resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz} engines: {node: '>=12'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.23.1': - resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz} engines: {node: '>=12'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.23.1': - resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz} engines: {node: '>=12'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.23.1': - resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.23.1': - resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.23.1': - resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.23.1': - resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.23.1': - resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.23.1': - resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.23.1': - resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz} engines: {node: '>=12'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.23.1': - resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.23.1': - resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz} + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==, tarball: https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz} engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==, tarball: https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz} - engines: {node: '>=12'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - '@jridgewell/gen-mapping@0.3.5': - resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==, tarball: https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz} - engines: {node: '>=6.0.0'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==, tarball: https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, tarball: https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz} engines: {node: '>=6.0.0'} - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==, tarball: https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz} - engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==, tarball: https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz} - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==, tarball: https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz} + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==, tarball: https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz} + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==, tarball: https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz} - '@langchain/community@0.0.53': - resolution: {integrity: sha512-iFqZPt4MRssGYsQoKSXWJQaYTZCC7WNuilp2JCCs3wKmJK3l6mR0eV+PDrnT+TaDHUVxt/b0rwgM0sOiy0j2jA==, tarball: https://registry.npmjs.org/@langchain/community/-/community-0.0.53.tgz} - engines: {node: '>=18'} + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz} peerDependencies: - '@aws-crypto/sha256-js': ^5.0.0 - '@aws-sdk/client-bedrock-agent-runtime': ^3.485.0 - '@aws-sdk/client-bedrock-runtime': ^3.422.0 - '@aws-sdk/client-dynamodb': ^3.310.0 - '@aws-sdk/client-kendra': ^3.352.0 - '@aws-sdk/client-lambda': ^3.310.0 - '@aws-sdk/client-sagemaker-runtime': ^3.310.0 - '@aws-sdk/client-sfn': ^3.310.0 - '@aws-sdk/credential-provider-node': ^3.388.0 - '@azure/search-documents': ^12.0.0 - '@clickhouse/client': ^0.2.5 - '@cloudflare/ai': '*' - '@datastax/astra-db-ts': ^1.0.0 - '@elastic/elasticsearch': ^8.4.0 - '@getmetal/metal-sdk': '*' - '@getzep/zep-js': ^0.9.0 - '@gomomento/sdk': ^1.51.1 - '@gomomento/sdk-core': ^1.51.1 - '@google-ai/generativelanguage': ^0.2.1 - '@gradientai/nodejs-sdk': ^1.2.0 - '@huggingface/inference': ^2.6.4 - '@mozilla/readability': '*' - '@neondatabase/serverless': '*' - '@opensearch-project/opensearch': '*' - '@pinecone-database/pinecone': '*' - '@planetscale/database': ^1.8.0 - '@premai/prem-sdk': ^0.3.25 - '@qdrant/js-client-rest': ^1.8.2 - '@raycast/api': ^1.55.2 - '@rockset/client': ^0.9.1 - '@smithy/eventstream-codec': ^2.0.5 - '@smithy/protocol-http': ^3.0.6 - '@smithy/signature-v4': ^2.0.10 - '@smithy/util-utf8': ^2.0.0 - '@supabase/postgrest-js': ^1.1.1 - '@supabase/supabase-js': ^2.10.0 - '@tensorflow-models/universal-sentence-encoder': '*' - '@tensorflow/tfjs-converter': '*' - '@tensorflow/tfjs-core': '*' - '@upstash/redis': ^1.20.6 - '@upstash/vector': ^1.0.7 - '@vercel/kv': ^0.2.3 - '@vercel/postgres': ^0.5.0 - '@writerai/writer-sdk': ^0.40.2 - '@xata.io/client': ^0.28.0 - '@xenova/transformers': ^2.5.4 - '@zilliz/milvus2-sdk-node': '>=2.2.7' - better-sqlite3: ^9.4.0 - cassandra-driver: ^4.7.2 - cborg: ^4.1.1 - chromadb: '*' - closevector-common: 0.1.3 - closevector-node: 0.1.6 - closevector-web: 0.1.6 - cohere-ai: '*' - convex: ^1.3.1 - couchbase: ^4.3.0 - discord.js: ^14.14.1 - dria: ^0.0.3 - duck-duck-scrape: ^2.2.5 - faiss-node: ^0.5.1 - firebase-admin: ^11.9.0 || ^12.0.0 - google-auth-library: ^8.9.0 - googleapis: ^126.0.1 - hnswlib-node: ^3.0.0 - html-to-text: ^9.0.5 - interface-datastore: ^8.2.11 - ioredis: ^5.3.2 - it-all: ^3.0.4 - jsdom: '*' - jsonwebtoken: ^9.0.2 - llmonitor: ^0.5.9 - lodash: ^4.17.21 - lunary: ^0.6.11 - mongodb: '>=5.2.0' - mysql2: ^3.3.3 - neo4j-driver: '*' - node-llama-cpp: '*' - pg: ^8.11.0 - pg-copy-streams: ^6.0.5 - pickleparser: ^0.2.1 - portkey-ai: ^0.1.11 - redis: '*' - replicate: ^0.18.0 - typeorm: ^0.3.12 - typesense: ^1.5.3 - usearch: ^1.1.1 - vectordb: ^0.1.4 - voy-search: 0.6.2 - weaviate-ts-client: '*' - web-auth-library: ^1.0.3 - ws: ^8.14.2 - peerDependenciesMeta: - '@aws-crypto/sha256-js': - optional: true - '@aws-sdk/client-bedrock-agent-runtime': - optional: true - '@aws-sdk/client-bedrock-runtime': - optional: true - '@aws-sdk/client-dynamodb': - optional: true - '@aws-sdk/client-kendra': - optional: true - '@aws-sdk/client-lambda': - optional: true - '@aws-sdk/client-sagemaker-runtime': - optional: true - '@aws-sdk/client-sfn': - optional: true - '@aws-sdk/credential-provider-node': - optional: true - '@azure/search-documents': - optional: true - '@clickhouse/client': - optional: true - '@cloudflare/ai': - optional: true - '@datastax/astra-db-ts': - optional: true - '@elastic/elasticsearch': - optional: true - '@getmetal/metal-sdk': - optional: true - '@getzep/zep-js': - optional: true - '@gomomento/sdk': - optional: true - '@gomomento/sdk-core': - optional: true - '@google-ai/generativelanguage': - optional: true - '@gradientai/nodejs-sdk': - optional: true - '@huggingface/inference': - optional: true - '@mozilla/readability': - optional: true - '@neondatabase/serverless': - optional: true - '@opensearch-project/opensearch': - optional: true - '@pinecone-database/pinecone': - optional: true - '@planetscale/database': - optional: true - '@premai/prem-sdk': - optional: true - '@qdrant/js-client-rest': - optional: true - '@raycast/api': - optional: true - '@rockset/client': - optional: true - '@smithy/eventstream-codec': - optional: true - '@smithy/protocol-http': - optional: true - '@smithy/signature-v4': - optional: true - '@smithy/util-utf8': - optional: true - '@supabase/postgrest-js': - optional: true - '@supabase/supabase-js': - optional: true - '@tensorflow-models/universal-sentence-encoder': - optional: true - '@tensorflow/tfjs-converter': - optional: true - '@tensorflow/tfjs-core': - optional: true - '@upstash/redis': - optional: true - '@upstash/vector': - optional: true - '@vercel/kv': - optional: true - '@vercel/postgres': - optional: true - '@writerai/writer-sdk': - optional: true - '@xata.io/client': - optional: true - '@xenova/transformers': - optional: true - '@zilliz/milvus2-sdk-node': - optional: true - better-sqlite3: - optional: true - cassandra-driver: - optional: true - cborg: - optional: true - chromadb: - optional: true - closevector-common: - optional: true - closevector-node: - optional: true - closevector-web: - optional: true - cohere-ai: - optional: true - convex: - optional: true - couchbase: - optional: true - discord.js: - optional: true - dria: - optional: true - duck-duck-scrape: - optional: true - faiss-node: - optional: true - firebase-admin: - optional: true - google-auth-library: - optional: true - googleapis: - optional: true - hnswlib-node: - optional: true - html-to-text: - optional: true - interface-datastore: - optional: true - ioredis: - optional: true - it-all: - optional: true - jsdom: - optional: true - jsonwebtoken: - optional: true - llmonitor: - optional: true - lodash: - optional: true - lunary: - optional: true - mongodb: - optional: true - mysql2: - optional: true - neo4j-driver: - optional: true - node-llama-cpp: - optional: true - pg: - optional: true - pg-copy-streams: - optional: true - pickleparser: - optional: true - portkey-ai: - optional: true - redis: - optional: true - replicate: - optional: true - typeorm: - optional: true - typesense: - optional: true - usearch: - optional: true - vectordb: - optional: true - voy-search: - optional: true - weaviate-ts-client: - optional: true - web-auth-library: - optional: true - ws: - optional: true + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 - '@langchain/core@0.1.63': - resolution: {integrity: sha512-+fjyYi8wy6x1P+Ee1RWfIIEyxd9Ee9jksEwvrggPwwI/p45kIDTdYTblXsM13y4mNWTiACyLSdbwnPaxxdoz+w==, tarball: https://registry.npmjs.org/@langchain/core/-/core-0.1.63.tgz} - engines: {node: '>=18'} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, tarball: https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz} + engines: {node: '>= 8'} - '@langchain/openai@0.0.28': - resolution: {integrity: sha512-2s1RA3/eAnz4ahdzsMPBna9hfAqpFNlWdHiPxVGZ5yrhXsbLWWoPcF+22LCk9t0HJKtazi2GCIWc0HVXH9Abig==, tarball: https://registry.npmjs.org/@langchain/openai/-/openai-0.0.28.tgz} - engines: {node: '>=18'} + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==, tarball: https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz} + engines: {node: '>= 8'} - '@manypkg/find-root@1.1.0': - resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==, tarball: https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz} + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, tarball: https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz} + engines: {node: '>= 8'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==, tarball: https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz} + engines: {node: '>=8.0.0'} + + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==, tarball: https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==, tarball: https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz} + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==, tarball: https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==, tarball: https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==, tarball: https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==, tarball: https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] - '@manypkg/get-packages@1.1.3': - resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==, tarball: https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, tarball: https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz} - engines: {node: '>= 8'} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==, tarball: https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz} - engines: {node: '>= 8'} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==, tarball: https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, tarball: https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz} - engines: {node: '>= 8'} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==, tarball: https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz} + engines: {node: '>=14.0.0'} + cpu: [wasm32] - '@opentelemetry/api@1.9.0': - resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==, tarball: https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz} - engines: {node: '>=8.0.0'} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==, tarball: https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz} - engines: {node: '>=14'} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==, tarball: https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==, tarball: https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz} - '@rollup/rollup-android-arm-eabi@4.24.0': - resolution: {integrity: sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz} + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.24.0': - resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz} + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.24.0': - resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz} + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.24.0': - resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz} + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz} cpu: [x64] os: [darwin] - '@rollup/rollup-linux-arm-gnueabihf@4.24.0': - resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz} + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz} cpu: [arm] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.24.0': - resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz} + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz} cpu: [arm] os: [linux] + libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.24.0': - resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz} + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz} cpu: [arm64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.24.0': - resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz} + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz} cpu: [arm64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': - resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz} + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz} cpu: [ppc64] os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz} + cpu: [riscv64] + os: [linux] + libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.24.0': - resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz} + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz} cpu: [riscv64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.24.0': - resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz} + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz} cpu: [s390x] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.24.0': - resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz} + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.24.0': - resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz} + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz} cpu: [x64] os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==, tarball: https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==, tarball: https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz} + cpu: [arm64] + os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.24.0': - resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz} + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.24.0': - resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz} + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.24.0': - resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz} + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz} cpu: [x64] os: [win32] - '@tsconfig/node10@1.0.11': - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==, tarball: https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==, tarball: https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==, tarball: https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz} + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz} + cpu: [x64] + os: [win32] - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==, tarball: https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, tarball: https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz} - '@types/diff-match-patch@1.0.36': - resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==, tarball: https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==, tarball: https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz} - '@types/estree@1.0.6': - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz} - '@types/node-fetch@2.6.11': - resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==, tarball: https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==, tarball: https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz} '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==, tarball: https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz} - '@types/node@18.19.54': - resolution: {integrity: sha512-+BRgt0G5gYjTvdLac9sIeE0iZcJxi4Jc4PV5EUzqi+88jmQLr+fRZdv2tCTV7IHKSGxM6SaLoOXQWWUiLUItMw==, tarball: https://registry.npmjs.org/@types/node/-/node-18.19.54.tgz} - - '@types/node@20.16.10': - resolution: {integrity: sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==, tarball: https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz} - - '@types/object-hash@3.0.6': - resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==, tarball: https://registry.npmjs.org/@types/object-hash/-/object-hash-3.0.6.tgz} - - '@types/retry@0.12.0': - resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==, tarball: https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz} + '@types/node@20.19.30': + resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==, tarball: https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz} - '@types/uuid@10.0.0': - resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==, tarball: https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==, tarball: https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz} + engines: {node: '>= 20'} - '@vitest/expect@2.1.2': - resolution: {integrity: sha512-FEgtlN8mIUSEAAnlvn7mP8vzaWhEaAEvhSXCqrsijM7K6QqjB11qoRZYEd4AKSCDz8p0/+yH5LzhZ47qt+EyPg==, tarball: https://registry.npmjs.org/@vitest/expect/-/expect-2.1.2.tgz} + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==, tarball: https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz} - '@vitest/mocker@2.1.2': - resolution: {integrity: sha512-ExElkCGMS13JAJy+812fw1aCv2QO/LBK6CyO4WOPAzLTmve50gydOlWhgdBJPx2ztbADUq3JVI0C5U+bShaeEA==, tarball: https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.2.tgz} + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==, tarball: https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz} peerDependencies: - '@vitest/spy': 2.1.2 - msw: ^2.3.5 + msw: ^2.4.9 vite: ^5.0.0 peerDependenciesMeta: msw: @@ -999,92 +631,31 @@ packages: vite: optional: true - '@vitest/pretty-format@2.1.2': - resolution: {integrity: sha512-FIoglbHrSUlOJPDGIrh2bjX1sNars5HbxlcsFKCtKzu4+5lpsRhOCVcuzp0fEhAGHkPZRIXVNzPcpSlkoZ3LuA==, tarball: https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.2.tgz} - - '@vitest/runner@2.1.2': - resolution: {integrity: sha512-UCsPtvluHO3u7jdoONGjOSil+uON5SSvU9buQh3lP7GgUXHp78guN1wRmZDX4wGK6J10f9NUtP6pO+SFquoMlw==, tarball: https://registry.npmjs.org/@vitest/runner/-/runner-2.1.2.tgz} - - '@vitest/snapshot@2.1.2': - resolution: {integrity: sha512-xtAeNsZ++aRIYIUsek7VHzry/9AcxeULlegBvsdLncLmNCR6tR8SRjn8BbDP4naxtccvzTqZ+L1ltZlRCfBZFA==, tarball: https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.2.tgz} - - '@vitest/spy@2.1.2': - resolution: {integrity: sha512-GSUi5zoy+abNRJwmFhBDC0yRuVUn8WMlQscvnbbXdKLXX9dE59YbfwXxuJ/mth6eeqIzofU8BB5XDo/Ns/qK2A==, tarball: https://registry.npmjs.org/@vitest/spy/-/spy-2.1.2.tgz} - - '@vitest/utils@2.1.2': - resolution: {integrity: sha512-zMO2KdYy6mx56btx9JvAqAZ6EyS3g49krMPPrgOp1yxGZiA93HumGk+bZ5jIZtOg5/VBYl5eBmGRQHqq4FG6uQ==, tarball: https://registry.npmjs.org/@vitest/utils/-/utils-2.1.2.tgz} - - '@vue/compiler-core@3.5.11': - resolution: {integrity: sha512-PwAdxs7/9Hc3ieBO12tXzmTD+Ln4qhT/56S+8DvrrZ4kLDn4Z/AMUr8tXJD0axiJBS0RKIoNaR0yMuQB9v9Udg==, tarball: https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.11.tgz} - - '@vue/compiler-dom@3.5.11': - resolution: {integrity: sha512-pyGf8zdbDDRkBrEzf8p7BQlMKNNF5Fk/Cf/fQ6PiUz9at4OaUfyXW0dGJTo2Vl1f5U9jSLCNf0EZJEogLXoeew==, tarball: https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.11.tgz} - - '@vue/compiler-sfc@3.5.11': - resolution: {integrity: sha512-gsbBtT4N9ANXXepprle+X9YLg2htQk1sqH/qGJ/EApl+dgpUBdTv3yP7YlR535uHZY3n6XaR0/bKo0BgwwDniw==, tarball: https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.11.tgz} - - '@vue/compiler-ssr@3.5.11': - resolution: {integrity: sha512-P4+GPjOuC2aFTk1Z4WANvEhyOykcvEd5bIj2KVNGKGfM745LaXGr++5njpdBTzVz5pZifdlR1kpYSJJpIlSePA==, tarball: https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.11.tgz} + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==, tarball: https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz} - '@vue/reactivity@3.5.11': - resolution: {integrity: sha512-Nqo5VZEn8MJWlCce8XoyVqHZbd5P2NH+yuAaFzuNSR96I+y1cnuUiq7xfSG+kyvLSiWmaHTKP1r3OZY4mMD50w==, tarball: https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.11.tgz} + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==, tarball: https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz} - '@vue/runtime-core@3.5.11': - resolution: {integrity: sha512-7PsxFGqwfDhfhh0OcDWBG1DaIQIVOLgkwA5q6MtkPiDFjp5gohVnJEahSktwSFLq7R5PtxDKy6WKURVN1UDbzA==, tarball: https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.11.tgz} + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==, tarball: https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz} - '@vue/runtime-dom@3.5.11': - resolution: {integrity: sha512-GNghjecT6IrGf0UhuYmpgaOlN7kxzQBhxWEn08c/SQDxv1yy4IXI1bn81JgEpQ4IXjRxWtPyI8x0/7TF5rPfYQ==, tarball: https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.11.tgz} + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==, tarball: https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz} - '@vue/server-renderer@3.5.11': - resolution: {integrity: sha512-cVOwYBxR7Wb1B1FoxYvtjJD8X/9E5nlH4VSkJy2uMA1MzYNdzAAB//l8nrmN9py/4aP+3NjWukf9PZ3TeWULaA==, tarball: https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.11.tgz} - peerDependencies: - vue: 3.5.11 - - '@vue/shared@3.5.11': - resolution: {integrity: sha512-W8GgysJVnFo81FthhzurdRAWP/byq3q2qIw70e0JWblzVhjgOMiC2GyovXrZTFQJnFVryYaKGP3Tc9vYzYm6PQ==, tarball: https://registry.npmjs.org/@vue/shared/-/shared-3.5.11.tgz} + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==, tarball: https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz} '@xstate/graph@2.0.1': resolution: {integrity: sha512-WWfL97yvyVISbmetqrspd6mUn13UKoHZ+/FBSU17n+YPdMrYnKaP8UDe/HjNoZAVYsR3wuQLoitTW9cxud0DIA==, tarball: https://registry.npmjs.org/@xstate/graph/-/graph-2.0.1.tgz} peerDependencies: xstate: ^5.18.2 - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==, tarball: https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz} - engines: {node: '>=6.5'} - - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==, tarball: https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz} - engines: {node: '>=0.4.0'} - - acorn@8.12.1: - resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==, tarball: https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz} - engines: {node: '>=0.4.0'} - hasBin: true - - agentkeepalive@4.5.0: - resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==, tarball: https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz} - engines: {node: '>= 8.0.0'} - - ai@3.4.9: - resolution: {integrity: sha512-wmVzpIHNGjCEjIJ/3945a/DIkz+gwObjC767ZRgO8AmtIZMO5KqvqNr7n2KF+gQrCPCMC8fM1ICQFXSvBZnBlA==, tarball: https://registry.npmjs.org/ai/-/ai-3.4.9.tgz} + ai@6.0.67: + resolution: {integrity: sha512-xBnTcByHCj3OcG6V8G1s6zvSEqK0Bdiu+IEXYcpGrve1iGFFRgcrKeZtr/WAW/7gupnSvBbDF24BEv1OOfqi1g==, tarball: https://registry.npmjs.org/ai/-/ai-6.0.67.tgz} engines: {node: '>=18'} peerDependencies: - openai: ^4.42.0 - react: ^18 || ^19 - sswr: ^2.1.0 - svelte: ^3.0.0 || ^4.0.0 - zod: ^3.0.0 - peerDependenciesMeta: - openai: - optional: true - react: - optional: true - sswr: - optional: true - svelte: - optional: true - zod: - optional: true + zod: ^3.25.76 || ^4.1.8 ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==, tarball: https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz} @@ -1094,38 +665,15 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, tarball: https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz} engines: {node: '>=8'} - ansi-regex@6.1.0: - resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==, tarball: https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, tarball: https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==, tarball: https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz} - engines: {node: '>=10'} - - ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==, tarball: https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz} - engines: {node: '>=12'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==, tarball: https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==, tarball: https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz} - engines: {node: '>= 8'} - - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==, tarball: https://registry.npmjs.org/arg/-/arg-4.1.3.tgz} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==, tarball: https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz} + engines: {node: '>=14'} argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==, tarball: https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz} - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==, tarball: https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz} - engines: {node: '>= 0.4'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, tarball: https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz} array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==, tarball: https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz} @@ -1135,132 +683,53 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==, tarball: https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz} engines: {node: '>=12'} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, tarball: https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz} - - axios@1.7.7: - resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==, tarball: https://registry.npmjs.org/axios/-/axios-1.7.7.tgz} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==, tarball: https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz} - engines: {node: '>= 0.4'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, tarball: https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, tarball: https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz} + ast-kit@3.0.0-beta.1: + resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==, tarball: https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz} + engines: {node: '>=20.19.0'} better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==, tarball: https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz} engines: {node: '>=4'} - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz} - engines: {node: '>=8'} - - binary-search@1.3.6: - resolution: {integrity: sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA==, tarball: https://registry.npmjs.org/binary-search/-/binary-search-1.3.6.tgz} - - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==, tarball: https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz} + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==, tarball: https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, tarball: https://registry.npmjs.org/braces/-/braces-3.0.3.tgz} engines: {node: '>=8'} - bundle-require@5.0.0: - resolution: {integrity: sha512-GuziW3fSSmopcx4KRymQEJVbZUfqlCqcq7dvs6TYwKRZiegK/2buMxQTPs6MGlNv50wms1699qYO54R8XfRX4w==, tarball: https://registry.npmjs.org/bundle-require/-/bundle-require-5.0.0.tgz} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.18' - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==, tarball: https://registry.npmjs.org/cac/-/cac-6.7.14.tgz} engines: {node: '>=8'} - camelcase@4.1.0: - resolution: {integrity: sha512-FxAv7HpHrXbh3aPo4o2qxHay2lkLY3x5Mw3KeE4KQE8ysVfziWeRZDwcjauvwBSGEC/nXUPzZy8zeh4HokqOnw==, tarball: https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz} - engines: {node: '>=4'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==, tarball: https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz} - engines: {node: '>=10'} - - chai@5.1.1: - resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==, tarball: https://registry.npmjs.org/chai/-/chai-5.1.1.tgz} - engines: {node: '>=12'} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==, tarball: https://registry.npmjs.org/cac/-/cac-7.0.0.tgz} + engines: {node: '>=20.19.0'} - chalk@5.3.0: - resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==, tarball: https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, tarball: https://registry.npmjs.org/chai/-/chai-5.3.3.tgz} + engines: {node: '>=18'} - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==, tarball: https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==, tarball: https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz} - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==, tarball: https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==, tarball: https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz} engines: {node: '>= 16'} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz} - engines: {node: '>= 8.10.0'} - ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==, tarball: https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz} engines: {node: '>=8'} - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==, tarball: https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz} - - code-red@1.0.4: - resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==, tarball: https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, tarball: https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, tarball: https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz} - engines: {node: '>= 0.8'} - - commander@10.0.1: - resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==, tarball: https://registry.npmjs.org/commander/-/commander-10.0.1.tgz} - engines: {node: '>=14'} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==, tarball: https://registry.npmjs.org/commander/-/commander-4.1.1.tgz} - engines: {node: '>= 6'} - - consola@3.2.3: - resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==, tarball: https://registry.npmjs.org/consola/-/consola-3.2.3.tgz} - engines: {node: ^14.18.0 || >=16.10.0} - - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==, tarball: https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz} - - cross-spawn@5.1.0: - resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz} - - cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz} engines: {node: '>= 8'} - css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==, tarball: https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==, tarball: https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz} - dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==, tarball: https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz} - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==, tarball: https://registry.npmjs.org/debug/-/debug-4.3.7.tgz} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, tarball: https://registry.npmjs.org/debug/-/debug-4.4.3.tgz} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -1268,113 +737,83 @@ packages: supports-color: optional: true - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==, tarball: https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz} - engines: {node: '>=0.10.0'} - deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==, tarball: https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz} engines: {node: '>=6'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, tarball: https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz} - engines: {node: '>=0.4.0'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==, tarball: https://registry.npmjs.org/defu/-/defu-6.1.4.tgz} detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==, tarball: https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz} engines: {node: '>=8'} - diff-match-patch@1.0.5: - resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==, tarball: https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz} - - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==, tarball: https://registry.npmjs.org/diff/-/diff-4.0.2.tgz} - engines: {node: '>=0.3.1'} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==, tarball: https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz} engines: {node: '>=8'} - dotenv@16.4.5: - resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz} engines: {node: '>=12'} dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz} engines: {node: '>=10'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, tarball: https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, tarball: https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz} + dts-resolver@2.1.3: + resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==, tarball: https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.3.tgz} + engines: {node: '>=20.19.0'} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, tarball: https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==, tarball: https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz} + engines: {node: '>=14'} enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==, tarball: https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz} engines: {node: '>=8.6'} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, tarball: https://registry.npmjs.org/entities/-/entities-4.5.0.tgz} - engines: {node: '>=0.12'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==, tarball: https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz} esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz} engines: {node: '>=12'} hasBin: true - esbuild@0.23.1: - resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz} - engines: {node: '>=18'} - hasBin: true - esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, tarball: https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz} engines: {node: '>=4'} hasBin: true - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==, tarball: https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, tarball: https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz} - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==, tarball: https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz} - engines: {node: '>=6'} - - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz} - - eventsource-parser@1.1.2: - resolution: {integrity: sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==, tarball: https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz} - engines: {node: '>=14.18'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==, tarball: https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz} + engines: {node: '>=18.0.0'} - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==, tarball: https://registry.npmjs.org/execa/-/execa-5.1.1.tgz} - engines: {node: '>=10'} - - expr-eval@2.0.2: - resolution: {integrity: sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==, tarball: https://registry.npmjs.org/expr-eval/-/expr-eval-2.0.2.tgz} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==, tarball: https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz} + engines: {node: '>=12.0.0'} extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==, tarball: https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz} - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==, tarball: https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz} - engines: {node: '>=4'} - - fast-glob@3.3.2: - resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==, tarball: https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, tarball: https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz} engines: {node: '>=8.6.0'} - fastq@1.17.1: - resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==, tarball: https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==, tarball: https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz} - fdir@6.4.0: - resolution: {integrity: sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==, tarball: https://registry.npmjs.org/fdir/-/fdir-6.4.0.tgz} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, tarball: https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -1389,34 +828,6 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==, tarball: https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz} engines: {node: '>=8'} - flat@5.0.2: - resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==, tarball: https://registry.npmjs.org/flat/-/flat-5.0.2.tgz} - hasBin: true - - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==, tarball: https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - foreground-child@3.3.0: - resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==, tarball: https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz} - engines: {node: '>=14'} - - form-data-encoder@1.7.2: - resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==, tarball: https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz} - - form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==, tarball: https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz} - engines: {node: '>= 6'} - - formdata-node@4.4.1: - resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==, tarball: https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz} - engines: {node: '>= 12.20'} - fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==, tarball: https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz} engines: {node: '>=6 <7 || >=8'} @@ -1430,21 +841,13 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==, tarball: https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==, tarball: https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz} - engines: {node: '>=10'} + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==, tarball: https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==, tarball: https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz} engines: {node: '>= 6'} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==, tarball: https://registry.npmjs.org/glob/-/glob-10.4.5.tgz} - hasBin: true - globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==, tarball: https://registry.npmjs.org/globby/-/globby-11.1.0.tgz} engines: {node: '>=10'} @@ -1452,42 +855,29 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz} - human-id@1.0.2: - resolution: {integrity: sha512-UNopramDEhHJD+VR+ehk8rOslwSfByxPIZyJRfV739NDhN5LF1fa1MqnzKm2lGTQRjNrjK19Q5fhkgIfjlVUKw==, tarball: https://registry.npmjs.org/human-id/-/human-id-1.0.2.tgz} + hookable@6.1.0: + resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==, tarball: https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz} - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, tarball: https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz} - engines: {node: '>=10.17.0'} - - humanize-ms@1.2.1: - resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==, tarball: https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz} + human-id@4.1.3: + resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==, tarball: https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz} + hasBin: true - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz} engines: {node: '>=0.10.0'} ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, tarball: https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz} engines: {node: '>= 4'} - infobox-parser@3.6.4: - resolution: {integrity: sha512-d2lTlxKZX7WsYxk9/UPt51nkmZv5tbC75SSw4hfHqZ3LpRAn6ug0oru9xI2X+S78va3aUAze3xl/UqMuwLmJUw==, tarball: https://registry.npmjs.org/infobox-parser/-/infobox-parser-3.6.4.tgz} - - is-any-array@2.0.1: - resolution: {integrity: sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==, tarball: https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==, tarball: https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz} - engines: {node: '>=8'} + import-without-cache@0.2.5: + resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==, tarball: https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.2.5.tgz} + engines: {node: '>=20.19.0'} is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, tarball: https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==, tarball: https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz} - engines: {node: '>=8'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, tarball: https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz} engines: {node: '>=0.10.0'} @@ -1496,13 +886,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, tarball: https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz} engines: {node: '>=0.12.0'} - is-reference@3.0.2: - resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==, tarball: https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==, tarball: https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz} - engines: {node: '>=8'} - is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==, tarball: https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz} engines: {node: '>=4'} @@ -1514,94 +897,37 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==, tarball: https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz} - - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==, tarball: https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz} - engines: {node: '>=10'} - - js-tiktoken@1.0.15: - resolution: {integrity: sha512-65ruOWWXDEZHHbAo7EjOcNxOGasQKbL4Fq3jEr2xsCqSsoOo6VVSqzWQb6PRIqypFSDcma4jO90YP0w5X8qVXQ==, tarball: https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.15.tgz} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==, tarball: https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz} + hasBin: true - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==, tarball: https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==, tarball: https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz} hasBin: true - json-schema-to-ts@3.1.1: - resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==, tarball: https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz} - engines: {node: '>=16'} + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==, tarball: https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz} + engines: {node: '>=6'} + hasBin: true json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==, tarball: https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz} - jsondiffpatch@0.6.0: - resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==, tarball: https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==, tarball: https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz} - langsmith@0.1.61: - resolution: {integrity: sha512-XQE4KPScwPmdaT0mWDzhNxj9gvqXUR+C7urLA0QFi27XeoQdm17eYpudenn4wxC0gIyUJutQCyuYJpfwlT5JnQ==, tarball: https://registry.npmjs.org/langsmith/-/langsmith-0.1.61.tgz} - peerDependencies: - openai: '*' - peerDependenciesMeta: - openai: - optional: true - - lilconfig@3.1.2: - resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==, tarball: https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, tarball: https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz} - - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==, tarball: https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - locate-character@3.0.0: - resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==, tarball: https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz} - locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz} engines: {node: '>=8'} - lodash.sortby@4.7.0: - resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==, tarball: https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz} - lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==, tarball: https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz} - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==, tarball: https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz} - hasBin: true - - loupe@3.1.1: - resolution: {integrity: sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==, tarball: https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz} - - lru-cache@4.1.5: - resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==, tarball: https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz} - magic-string@0.30.11: - resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==, tarball: https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz} - - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==, tarball: https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz} - - mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==, tarball: https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==, tarball: https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, tarball: https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz} merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, tarball: https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz} @@ -1611,41 +937,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, tarball: https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, tarball: https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, tarball: https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz} - engines: {node: '>= 0.6'} - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==, tarball: https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz} - engines: {node: '>=6'} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==, tarball: https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz} - engines: {node: '>=16 || 14 >=14.17'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==, tarball: https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz} - engines: {node: '>=16 || 14 >=14.17'} - - ml-array-mean@1.1.6: - resolution: {integrity: sha512-MIdf7Zc8HznwIisyiJGRH9tRigg3Yf4FldW8DxKxpCCv/g5CafTw0RRu51nojVEOXuCQC7DRVVu5c7XXO/5joQ==, tarball: https://registry.npmjs.org/ml-array-mean/-/ml-array-mean-1.1.6.tgz} - - ml-array-sum@1.1.6: - resolution: {integrity: sha512-29mAh2GwH7ZmiRnup4UyibQZB9+ZLyMShvt4cH4eTK+cL2oEMIZFnSyB3SS8MlsTh6q/w/yh48KmqLxmovN4Dw==, tarball: https://registry.npmjs.org/ml-array-sum/-/ml-array-sum-1.1.6.tgz} - - ml-distance-euclidean@2.0.0: - resolution: {integrity: sha512-yC9/2o8QF0A3m/0IXqCTXCzz2pNEzvmcE/9HFKOZGnTjatvBbsn4lWYJkxENkA4Ug2fnYl7PXQxnPi21sgMy/Q==, tarball: https://registry.npmjs.org/ml-distance-euclidean/-/ml-distance-euclidean-2.0.0.tgz} - - ml-distance@4.0.1: - resolution: {integrity: sha512-feZ5ziXs01zhyFUUUeZV5hwc0f5JW0Sh0ckU1koZe/wdVkJdGxcP06KNQuF0WBTj8FttQUzcvQcpcrOp/XrlEw==, tarball: https://registry.npmjs.org/ml-distance/-/ml-distance-4.0.1.tgz} - - ml-tree-similarity@1.0.0: - resolution: {integrity: sha512-XJUyYqjSuUQkNQHMscr6tcjldsOoAekxADTplt40QKfwW6nd++1wHWV9AArl0Zvw/TIHgNaZZNvr8QGvE8wLRg==, tarball: https://registry.npmjs.org/ml-tree-similarity/-/ml-tree-similarity-1.0.0.tgz} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, tarball: https://registry.npmjs.org/mri/-/mri-1.2.0.tgz} engines: {node: '>=4'} @@ -1653,27 +944,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, tarball: https://registry.npmjs.org/ms/-/ms-2.1.3.tgz} - mustache@4.2.0: - resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==, tarball: https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz} - hasBin: true - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==, tarball: https://registry.npmjs.org/mz/-/mz-2.7.0.tgz} - - nanoid@3.3.6: - resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanoid@3.3.7: - resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==, tarball: https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz} - engines: {node: '>=10.5.0'} - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==, tarball: https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz} engines: {node: 4.x || >=6.0.0} @@ -1683,42 +958,8 @@ packages: encoding: optional: true - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==, tarball: https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz} - engines: {node: '>=0.10.0'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==, tarball: https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz} - engines: {node: '>=8'} - - num-sort@2.1.0: - resolution: {integrity: sha512-1MQz1Ed8z2yckoBeSfkQHHO9K1yDRxxtotKSJ9yvcTUUxSvfvzEq5GwBrjjHEpMlq/k5gvXdmJ1SbYxWtpNoVg==, tarball: https://registry.npmjs.org/num-sort/-/num-sort-2.1.0.tgz} - engines: {node: '>=8'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz} - engines: {node: '>=0.10.0'} - - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==, tarball: https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz} - engines: {node: '>= 6'} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==, tarball: https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz} - engines: {node: '>=6'} - - openai@4.67.1: - resolution: {integrity: sha512-2YbRFy6qaYRJabK2zLMn4txrB2xBy0KP5g/eoqeSPTT31mIJMnkT75toagvfE555IKa2RdrzJrZwdDsUipsAMw==, tarball: https://registry.npmjs.org/openai/-/openai-4.67.1.tgz} - hasBin: true - peerDependencies: - zod: ^3.23.8 - peerDependenciesMeta: - zod: - optional: true - - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==, tarball: https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz} - engines: {node: '>=0.10.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==, tarball: https://registry.npmjs.org/obug/-/obug-2.1.1.tgz} outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==, tarball: https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz} @@ -1727,10 +968,6 @@ packages: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==, tarball: https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz} engines: {node: '>=8'} - p-finally@1.0.0: - resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==, tarball: https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz} - engines: {node: '>=4'} - p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==, tarball: https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz} engines: {node: '>=6'} @@ -1743,27 +980,12 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==, tarball: https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz} engines: {node: '>=6'} - p-queue@6.6.2: - resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==, tarball: https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz} - engines: {node: '>=8'} - - p-retry@4.6.2: - resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==, tarball: https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz} - engines: {node: '>=8'} - - p-timeout@3.2.0: - resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==, tarball: https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz} - engines: {node: '>=8'} - p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==, tarball: https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz} engines: {node: '>=6'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==, tarball: https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz} - - package-manager-detector@0.2.1: - resolution: {integrity: sha512-/hVW2fZvAdEas+wyKh0SnlZ2mx0NIa1+j11YaQkogEJkcMErbwchHCuo8z7lEtajZJQZ6rgZNVTWMVVd71Bjng==, tarball: https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.1.tgz} + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==, tarball: https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz} path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} @@ -1773,10 +995,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, tarball: https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz} engines: {node: '>=8'} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==, tarball: https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz} - engines: {node: '>=16 || 14 >=14.18'} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==, tarball: https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz} engines: {node: '>=8'} @@ -1784,52 +1002,34 @@ packages: pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==, tarball: https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz} - pathval@2.0.0: - resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==, tarball: https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz} - engines: {node: '>= 14.16'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, tarball: https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz} - periscopic@3.1.0: - resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==, tarball: https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==, tarball: https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz} + engines: {node: '>= 14.16'} - picocolors@1.1.0: - resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz} picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz} engines: {node: '>=8.6'} - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz} engines: {node: '>=12'} - pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==, tarball: https://registry.npmjs.org/pify/-/pify-4.0.1.tgz} - engines: {node: '>=6'} - - pirates@4.0.6: - resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==, tarball: https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz} - engines: {node: '>= 6'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz} + engines: {node: '>=12'} - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==, tarball: https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==, tarball: https://registry.npmjs.org/pify/-/pify-4.0.1.tgz} + engines: {node: '>=6'} - postcss@8.4.47: - resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz} engines: {node: ^10 || ^12 || >=14} prettier@2.8.8: @@ -1837,48 +1037,56 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, tarball: https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz} - - pseudomap@1.0.2: - resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==, tarball: https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==, tarball: https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, tarball: https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz} - engines: {node: '>=6'} + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==, tarball: https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz} queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, tarball: https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz} - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==, tarball: https://registry.npmjs.org/react/-/react-18.3.1.tgz} - engines: {node: '>=0.10.0'} - read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==, tarball: https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz} engines: {node: '>=6'} - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz} - engines: {node: '>=8.10.0'} - - regenerator-runtime@0.14.1: - resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==, tarball: https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz} - resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==, tarball: https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz} engines: {node: '>=8'} - retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==, tarball: https://registry.npmjs.org/retry/-/retry-0.13.1.tgz} - engines: {node: '>= 4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, tarball: https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz} - reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==, tarball: https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==, tarball: https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.24.0: - resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz} + rolldown-plugin-dts@0.23.2: + resolution: {integrity: sha512-PbSqLawLgZBGcOGT3yqWBGn4cX+wh2nt5FuBGdcMHyOhoukmjbhYAl8NT9sE4U38Cm9tqLOIQeOrvzeayM0DLQ==, tarball: https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.23.2.tgz} + engines: {node: '>=20.19.0'} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20260325.1' + rolldown: ^1.0.0-rc.12 + typescript: ^5.0.0 || ^6.0.0 + vue-tsc: ~3.2.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==, tarball: https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -1888,26 +1096,20 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, tarball: https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz} - secure-json-parse@2.7.0: - resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==, tarball: https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz} - - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==, tarball: https://registry.npmjs.org/semver/-/semver-7.6.3.tgz} + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==, tarball: https://registry.npmjs.org/semver/-/semver-7.7.3.tgz} engines: {node: '>=10'} hasBin: true - shebang-command@1.2.0: - resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==, tarball: https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz} - engines: {node: '>=0.10.0'} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==, tarball: https://registry.npmjs.org/semver/-/semver-7.7.4.tgz} + engines: {node: '>=10'} + hasBin: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, tarball: https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz} engines: {node: '>=8'} - shebang-regex@1.0.0: - resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==, tarball: https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz} - engines: {node: '>=0.10.0'} - shebang-regex@3.0.0: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, tarball: https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz} engines: {node: '>=8'} @@ -1915,9 +1117,6 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, tarball: https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==, tarball: https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==, tarball: https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz} engines: {node: '>=14'} @@ -1930,96 +1129,46 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz} engines: {node: '>=0.10.0'} - source-map@0.8.0-beta.0: - resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==, tarball: https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz} - engines: {node: '>= 8'} - - spawndamnit@2.0.0: - resolution: {integrity: sha512-j4JKEcncSjFlqIwU5L/rp2N5SIPsdxaRsIv678+TZxZ0SRDJTm8JrxJMjE/XuiEZNEir3S8l0Fa3Ke339WI4qA==, tarball: https://registry.npmjs.org/spawndamnit/-/spawndamnit-2.0.0.tgz} + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==, tarball: https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz} sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==, tarball: https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz} - sswr@2.1.0: - resolution: {integrity: sha512-Cqc355SYlTAaUt8iDPaC/4DPPXK925PePLMxyBKuWd5kKc5mwsG3nT9+Mq2tyguL5s7b4Jg+IRMpTRsNTAfpSQ==, tarball: https://registry.npmjs.org/sswr/-/sswr-2.1.0.tgz} - peerDependencies: - svelte: ^4.0.0 || ^5.0.0-next.0 - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, tarball: https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz} - std-env@3.7.0: - resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==, tarball: https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, tarball: https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==, tarball: https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz} - engines: {node: '>=12'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==, tarball: https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz} strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, tarball: https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz} engines: {node: '>=8'} - strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==, tarball: https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz} - engines: {node: '>=12'} - strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==, tarball: https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz} engines: {node: '>=4'} - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==, tarball: https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz} - engines: {node: '>=6'} - - sucrase@3.35.0: - resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==, tarball: https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - svelte@4.2.19: - resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==, tarball: https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz} - engines: {node: '>=16'} - - swr@2.2.5: - resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==, tarball: https://registry.npmjs.org/swr/-/swr-2.2.5.tgz} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 - - swrev@4.0.0: - resolution: {integrity: sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==, tarball: https://registry.npmjs.org/swrev/-/swrev-4.0.0.tgz} - - swrv@1.0.4: - resolution: {integrity: sha512-zjEkcP8Ywmj+xOJW3lIT65ciY/4AL4e/Or7Gj0MzU3zBJNMdJiT8geVZhINavnlHRMMCcJLHhraLTAiDOTmQ9g==, tarball: https://registry.npmjs.org/swrv/-/swrv-1.0.4.tgz} - peerDependencies: - vue: '>=3.2.26 < 4' - term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==, tarball: https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz} engines: {node: '>=8'} - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==, tarball: https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==, tarball: https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, tarball: https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz} - tinyexec@0.3.0: - resolution: {integrity: sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==, tarball: https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, tarball: https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==, tarball: https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz} + engines: {node: '>=18'} - tinyglobby@0.2.9: - resolution: {integrity: sha512-8or1+BGEdk1Zkkw2ii16qSS7uVrQJPre5A9o/XkWPATkk23FZh/15BKFxPnlTy6vkljZxLqYCzzBMj30ZrSvjw==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.9.tgz} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz} engines: {node: '>=12.0.0'} - tinypool@1.0.1: - resolution: {integrity: sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==, tarball: https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==, tarball: https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz} engines: {node: ^18.0.0 || >=20.0.0} tinyrainbow@1.2.0: @@ -2030,14 +1179,6 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==, tarball: https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz} engines: {node: '>=14.0.0'} - tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==, tarball: https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz} - engines: {node: '>=0.6.0'} - - to-fast-properties@2.0.0: - resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==, tarball: https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz} - engines: {node: '>=4'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, tarball: https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz} engines: {node: '>=8.0'} @@ -2045,90 +1186,73 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==, tarball: https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz} - tr46@1.0.1: - resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==, tarball: https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz} - tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==, tarball: https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz} hasBin: true - ts-algebra@2.0.0: - resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==, tarball: https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz} - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==, tarball: https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz} - - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==, tarball: https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz} + tsdown@0.21.7: + resolution: {integrity: sha512-ukKIxKQzngkWvOYJAyptudclkm4VQqbjq+9HF5K5qDO8GJsYtMh8gIRwicbnZEnvFPr6mquFwYAVZ8JKt3rY2g==, tarball: https://registry.npmjs.org/tsdown/-/tsdown-0.21.7.tgz} + engines: {node: '>=20.19.0'} hasBin: true peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.21.7 + '@tsdown/exe': 0.21.7 + '@vitejs/devtools': '*' + publint: ^0.3.0 + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 peerDependenciesMeta: - '@swc/core': + '@arethetypeswrong/core': optional: true - '@swc/wasm': + '@tsdown/css': optional: true - - tsup@8.3.0: - resolution: {integrity: sha512-ALscEeyS03IomcuNdFdc0YWGVIkwH1Ws7nfTbAPuoILvEV2hpGQAY72LIOjglGo4ShWpZfpBqP/jpQVCzqYQag==, tarball: https://registry.npmjs.org/tsup/-/tsup-8.3.0.tgz} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': + '@tsdown/exe': optional: true - '@swc/core': + '@vitejs/devtools': optional: true - postcss: + publint: optional: true typescript: optional: true + unplugin-unused: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz} - typescript@5.6.2: - resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz} engines: {node: '>=14.17'} hasBin: true - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz} + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==, tarball: https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz} - undici-types@6.19.8: - resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz} universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==, tarball: https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz} engines: {node: '>= 4.0.0'} - use-sync-external-store@1.2.2: - resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==, tarball: https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - - uuid@10.0.0: - resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==, tarball: https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz} - hasBin: true - - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==, tarball: https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz} + unrun@0.2.34: + resolution: {integrity: sha512-LyaghRBR++r7svhDK6tnDz2XaYHWdneBOA0jbS8wnRsHerI9MFljX4fIiTgbbNbEVzZ0C9P1OjWLLe1OqoaaEw==, tarball: https://registry.npmjs.org/unrun/-/unrun-0.2.34.tgz} + engines: {node: '>=20.19.0'} hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==, tarball: https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz} - - vite-node@2.1.2: - resolution: {integrity: sha512-HPcGNN5g/7I2OtPjLqgOtCRu/qhVvBxTUD3qzitmL0SrG1cWFzxzhMDWussxSbrRYWqnKf8P2jiNhPMSN+ymsQ==, tarball: https://registry.npmjs.org/vite-node/-/vite-node-2.1.2.tgz} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==, tarball: https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true - vite@5.4.8: - resolution: {integrity: sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==, tarball: https://registry.npmjs.org/vite/-/vite-5.4.8.tgz} + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==, tarball: https://registry.npmjs.org/vite/-/vite-5.4.21.tgz} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -2158,15 +1282,15 @@ packages: terser: optional: true - vitest@2.1.2: - resolution: {integrity: sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==, tarball: https://registry.npmjs.org/vitest/-/vitest-2.1.2.tgz} + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==, tarball: https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.2 - '@vitest/ui': 2.1.2 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -2183,34 +1307,12 @@ packages: jsdom: optional: true - vue@3.5.11: - resolution: {integrity: sha512-/8Wurrd9J3lb72FTQS7gRMNQD4nztTtKPmuDuPuhqXmmpD6+skVjAeahNpVzsuky6Sy9gy7wn8UadqPtt9SQIg==, tarball: https://registry.npmjs.org/vue/-/vue-3.5.11.tgz} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - web-streams-polyfill@4.0.0-beta.3: - resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==, tarball: https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz} - engines: {node: '>= 14'} - webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==, tarball: https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz} - webidl-conversions@4.0.2: - resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==, tarball: https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz} - whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==, tarball: https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz} - whatwg-url@7.1.0: - resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==, tarball: https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz} - - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==, tarball: https://registry.npmjs.org/which/-/which-1.3.1.tgz} - hasBin: true - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, tarball: https://registry.npmjs.org/which/-/which-2.0.2.tgz} engines: {node: '>= 8'} @@ -2221,151 +1323,69 @@ packages: engines: {node: '>=8'} hasBin: true - wikipedia@2.1.2: - resolution: {integrity: sha512-RAYaMpXC9/E873RaSEtlEa8dXK4e0p5k98GKOd210MtkE5emm6fcnwD+N6ZA4cuffjDWagvhaQKtp/mGp2BOVQ==, tarball: https://registry.npmjs.org/wikipedia/-/wikipedia-2.1.2.tgz} - engines: {node: '>=10'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, tarball: https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==, tarball: https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz} - engines: {node: '>=12'} - - xstate@5.18.2: - resolution: {integrity: sha512-hab5VOe29D0agy8/7dH1lGw+7kilRQyXwpaChoMu4fe6rDP+nsHYhDYKfS2O4iXE7myA98TW6qMEudj/8NXEkA==, tarball: https://registry.npmjs.org/xstate/-/xstate-5.18.2.tgz} + xstate@5.26.0: + resolution: {integrity: sha512-Fvi9VBoqHgsGYLU2NTag8xDTWtKqUC0+ue7EAhBNBb06wf620QEy05upBaEI1VLMzIn63zugLV8nHb69ZUWYAA==, tarball: https://registry.npmjs.org/xstate/-/xstate-5.26.0.tgz} - yallist@2.1.2: - resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==, tarball: https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz} - - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==, tarball: https://registry.npmjs.org/yn/-/yn-3.1.1.tgz} - engines: {node: '>=6'} - - zod-to-json-schema@3.23.2: - resolution: {integrity: sha512-uSt90Gzc/tUfyNqxnjlfBs8W6WSGpNBv0rVsNxP/BVSMHMKGdthPYff4xtCHYloJGM0CFxFsb3NbC0eqPhfImw==, tarball: https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.2.tgz} - peerDependencies: - zod: ^3.23.3 - - zod-to-json-schema@3.23.3: - resolution: {integrity: sha512-TYWChTxKQbRJp5ST22o/Irt9KC5nj7CdBKYB/AosCRdj/wxEMvv4NNaj9XVUHDOIp53ZxArGhnw5HMZziPFjog==, tarball: https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.3.tgz} - peerDependencies: - zod: ^3.23.3 - - zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==, tarball: https://registry.npmjs.org/zod/-/zod-3.23.8.tgz} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==, tarball: https://registry.npmjs.org/zod/-/zod-4.3.6.tgz} snapshots: - '@ai-sdk/openai@0.0.40(zod@3.23.8)': - dependencies: - '@ai-sdk/provider': 0.0.14 - '@ai-sdk/provider-utils': 1.0.5(zod@3.23.8) - zod: 3.23.8 - - '@ai-sdk/provider-utils@1.0.20(zod@3.23.8)': - dependencies: - '@ai-sdk/provider': 0.0.24 - eventsource-parser: 1.1.2 - nanoid: 3.3.6 - secure-json-parse: 2.7.0 - optionalDependencies: - zod: 3.23.8 - - '@ai-sdk/provider-utils@1.0.5(zod@3.23.8)': - dependencies: - '@ai-sdk/provider': 0.0.14 - eventsource-parser: 1.1.2 - nanoid: 3.3.6 - secure-json-parse: 2.7.0 - optionalDependencies: - zod: 3.23.8 - - '@ai-sdk/provider@0.0.14': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/provider@0.0.24': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/react@0.0.62(react@18.3.1)(zod@3.23.8)': + '@ai-sdk/gateway@3.0.32(zod@4.3.6)': dependencies: - '@ai-sdk/provider-utils': 1.0.20(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.46(zod@3.23.8) - swr: 2.2.5(react@18.3.1) - optionalDependencies: - react: 18.3.1 - zod: 3.23.8 + '@ai-sdk/provider': 3.0.7 + '@ai-sdk/provider-utils': 4.0.13(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 - '@ai-sdk/solid@0.0.49(zod@3.23.8)': + '@ai-sdk/openai@3.0.25(zod@4.3.6)': dependencies: - '@ai-sdk/provider-utils': 1.0.20(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.46(zod@3.23.8) - transitivePeerDependencies: - - zod + '@ai-sdk/provider': 3.0.7 + '@ai-sdk/provider-utils': 4.0.13(zod@4.3.6) + zod: 4.3.6 - '@ai-sdk/svelte@0.0.51(svelte@4.2.19)(zod@3.23.8)': + '@ai-sdk/provider-utils@4.0.13(zod@4.3.6)': dependencies: - '@ai-sdk/provider-utils': 1.0.20(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.46(zod@3.23.8) - sswr: 2.1.0(svelte@4.2.19) - optionalDependencies: - svelte: 4.2.19 - transitivePeerDependencies: - - zod + '@ai-sdk/provider': 3.0.7 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 - '@ai-sdk/ui-utils@0.0.46(zod@3.23.8)': + '@ai-sdk/provider@3.0.7': dependencies: - '@ai-sdk/provider': 0.0.24 - '@ai-sdk/provider-utils': 1.0.20(zod@3.23.8) json-schema: 0.4.0 - secure-json-parse: 2.7.0 - zod-to-json-schema: 3.23.2(zod@3.23.8) - optionalDependencies: - zod: 3.23.8 - - '@ai-sdk/vue@0.0.54(vue@3.5.11(typescript@5.6.2))(zod@3.23.8)': - dependencies: - '@ai-sdk/provider-utils': 1.0.20(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.46(zod@3.23.8) - swrv: 1.0.4(vue@3.5.11(typescript@5.6.2)) - optionalDependencies: - vue: 3.5.11(typescript@5.6.2) - transitivePeerDependencies: - - zod - '@ampproject/remapping@2.3.0': + '@babel/generator@8.0.0-rc.3': dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@babel/parser': 8.0.0-rc.3 + '@babel/types': 8.0.0-rc.3 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 - '@babel/helper-string-parser@7.25.7': {} + '@babel/helper-string-parser@8.0.0-rc.3': {} - '@babel/helper-validator-identifier@7.25.7': {} + '@babel/helper-validator-identifier@8.0.0-rc.3': {} - '@babel/parser@7.25.7': + '@babel/parser@8.0.0-rc.3': dependencies: - '@babel/types': 7.25.7 + '@babel/types': 8.0.0-rc.3 - '@babel/runtime@7.25.7': - dependencies: - regenerator-runtime: 0.14.1 + '@babel/runtime@7.28.6': {} - '@babel/types@7.25.7': + '@babel/types@8.0.0-rc.3': dependencies: - '@babel/helper-string-parser': 7.25.7 - '@babel/helper-validator-identifier': 7.25.7 - to-fast-properties: 2.0.0 + '@babel/helper-string-parser': 8.0.0-rc.3 + '@babel/helper-validator-identifier': 8.0.0-rc.3 - '@changesets/apply-release-plan@7.0.5': + '@changesets/apply-release-plan@7.0.14': dependencies: - '@changesets/config': 3.0.3 + '@changesets/config': 3.1.2 '@changesets/get-version-range-type': 0.4.0 - '@changesets/git': 3.0.1 - '@changesets/should-skip-package': 0.1.1 - '@changesets/types': 6.0.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 detect-indent: 6.1.0 fs-extra: 7.0.1 @@ -2373,66 +1393,68 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.6.3 + semver: 7.7.3 - '@changesets/assemble-release-plan@6.0.4': + '@changesets/assemble-release-plan@6.0.9': dependencies: '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.2 - '@changesets/should-skip-package': 0.1.1 - '@changesets/types': 6.0.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - semver: 7.6.3 + semver: 7.7.3 - '@changesets/changelog-git@0.2.0': + '@changesets/changelog-git@0.2.1': dependencies: - '@changesets/types': 6.0.0 + '@changesets/types': 6.1.0 - '@changesets/changelog-github@0.5.0': + '@changesets/changelog-github@0.5.2': dependencies: - '@changesets/get-github-info': 0.6.0 - '@changesets/types': 6.0.0 + '@changesets/get-github-info': 0.7.0 + '@changesets/types': 6.1.0 dotenv: 8.6.0 transitivePeerDependencies: - encoding - '@changesets/cli@2.27.9': + '@changesets/cli@2.29.8(@types/node@20.19.30)': dependencies: - '@changesets/apply-release-plan': 7.0.5 - '@changesets/assemble-release-plan': 6.0.4 - '@changesets/changelog-git': 0.2.0 - '@changesets/config': 3.0.3 + '@changesets/apply-release-plan': 7.0.14 + '@changesets/assemble-release-plan': 6.0.9 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.2 '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.2 - '@changesets/get-release-plan': 4.0.4 - '@changesets/git': 3.0.1 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-release-plan': 4.0.14 + '@changesets/git': 3.0.4 '@changesets/logger': 0.1.1 - '@changesets/pre': 2.0.1 - '@changesets/read': 0.6.1 - '@changesets/should-skip-package': 0.1.1 - '@changesets/types': 6.0.0 - '@changesets/write': 0.3.2 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.6 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@20.19.30) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 enquirer: 2.4.1 - external-editor: 3.1.0 fs-extra: 7.0.1 mri: 1.2.0 p-limit: 2.3.0 - package-manager-detector: 0.2.1 - picocolors: 1.1.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 resolve-from: 5.0.0 - semver: 7.6.3 - spawndamnit: 2.0.0 + semver: 7.7.3 + spawndamnit: 3.0.1 term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' - '@changesets/config@3.0.3': + '@changesets/config@3.1.2': dependencies: '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.2 + '@changesets/get-dependents-graph': 2.1.3 '@changesets/logger': 0.1.1 - '@changesets/types': 6.0.0 + '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 fs-extra: 7.0.1 micromatch: 4.0.8 @@ -2441,314 +1463,210 @@ snapshots: dependencies: extendable-error: 0.1.7 - '@changesets/get-dependents-graph@2.1.2': + '@changesets/get-dependents-graph@2.1.3': dependencies: - '@changesets/types': 6.0.0 + '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - picocolors: 1.1.0 - semver: 7.6.3 + picocolors: 1.1.1 + semver: 7.7.3 - '@changesets/get-github-info@0.6.0': + '@changesets/get-github-info@0.7.0': dependencies: dataloader: 1.4.0 node-fetch: 2.7.0 transitivePeerDependencies: - encoding - '@changesets/get-release-plan@4.0.4': + '@changesets/get-release-plan@4.0.14': dependencies: - '@changesets/assemble-release-plan': 6.0.4 - '@changesets/config': 3.0.3 - '@changesets/pre': 2.0.1 - '@changesets/read': 0.6.1 - '@changesets/types': 6.0.0 + '@changesets/assemble-release-plan': 6.0.9 + '@changesets/config': 3.1.2 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.6 + '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 '@changesets/get-version-range-type@0.4.0': {} - '@changesets/git@3.0.1': + '@changesets/git@3.0.4': dependencies: '@changesets/errors': 0.2.0 '@manypkg/get-packages': 1.1.3 is-subdir: 1.2.0 micromatch: 4.0.8 - spawndamnit: 2.0.0 + spawndamnit: 3.0.1 '@changesets/logger@0.1.1': dependencies: - picocolors: 1.1.0 + picocolors: 1.1.1 - '@changesets/parse@0.4.0': + '@changesets/parse@0.4.2': dependencies: - '@changesets/types': 6.0.0 - js-yaml: 3.14.1 + '@changesets/types': 6.1.0 + js-yaml: 4.1.1 - '@changesets/pre@2.0.1': + '@changesets/pre@2.0.2': dependencies: '@changesets/errors': 0.2.0 - '@changesets/types': 6.0.0 + '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 fs-extra: 7.0.1 - '@changesets/read@0.6.1': + '@changesets/read@0.6.6': dependencies: - '@changesets/git': 3.0.1 + '@changesets/git': 3.0.4 '@changesets/logger': 0.1.1 - '@changesets/parse': 0.4.0 - '@changesets/types': 6.0.0 + '@changesets/parse': 0.4.2 + '@changesets/types': 6.1.0 fs-extra: 7.0.1 p-filter: 2.1.0 - picocolors: 1.1.0 + picocolors: 1.1.1 - '@changesets/should-skip-package@0.1.1': + '@changesets/should-skip-package@0.1.2': dependencies: - '@changesets/types': 6.0.0 + '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 '@changesets/types@4.1.0': {} - '@changesets/types@6.0.0': {} + '@changesets/types@6.1.0': {} - '@changesets/write@0.3.2': + '@changesets/write@0.4.0': dependencies: - '@changesets/types': 6.0.0 + '@changesets/types': 6.1.0 fs-extra: 7.0.1 - human-id: 1.0.2 + human-id: 4.1.3 prettier: 2.8.8 - '@cspotcode/source-map-support@0.8.1': + '@emnapi/core@1.9.1': dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@esbuild/aix-ppc64@0.21.5': + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.23.1': + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/android-arm64@0.21.5': + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/android-arm64@0.23.1': + '@esbuild/aix-ppc64@0.21.5': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/android-arm64@0.21.5': optional: true - '@esbuild/android-arm@0.23.1': + '@esbuild/android-arm@0.21.5': optional: true '@esbuild/android-x64@0.21.5': optional: true - '@esbuild/android-x64@0.23.1': - optional: true - '@esbuild/darwin-arm64@0.21.5': optional: true - '@esbuild/darwin-arm64@0.23.1': - optional: true - '@esbuild/darwin-x64@0.21.5': optional: true - '@esbuild/darwin-x64@0.23.1': - optional: true - '@esbuild/freebsd-arm64@0.21.5': optional: true - '@esbuild/freebsd-arm64@0.23.1': - optional: true - '@esbuild/freebsd-x64@0.21.5': optional: true - '@esbuild/freebsd-x64@0.23.1': - optional: true - '@esbuild/linux-arm64@0.21.5': optional: true - '@esbuild/linux-arm64@0.23.1': - optional: true - '@esbuild/linux-arm@0.21.5': optional: true - '@esbuild/linux-arm@0.23.1': - optional: true - '@esbuild/linux-ia32@0.21.5': optional: true - '@esbuild/linux-ia32@0.23.1': - optional: true - '@esbuild/linux-loong64@0.21.5': optional: true - '@esbuild/linux-loong64@0.23.1': - optional: true - '@esbuild/linux-mips64el@0.21.5': optional: true - '@esbuild/linux-mips64el@0.23.1': - optional: true - '@esbuild/linux-ppc64@0.21.5': optional: true - '@esbuild/linux-ppc64@0.23.1': - optional: true - '@esbuild/linux-riscv64@0.21.5': optional: true - '@esbuild/linux-riscv64@0.23.1': - optional: true - '@esbuild/linux-s390x@0.21.5': optional: true - '@esbuild/linux-s390x@0.23.1': - optional: true - '@esbuild/linux-x64@0.21.5': optional: true - '@esbuild/linux-x64@0.23.1': - optional: true - '@esbuild/netbsd-x64@0.21.5': optional: true - '@esbuild/netbsd-x64@0.23.1': - optional: true - - '@esbuild/openbsd-arm64@0.23.1': - optional: true - '@esbuild/openbsd-x64@0.21.5': optional: true - '@esbuild/openbsd-x64@0.23.1': - optional: true - '@esbuild/sunos-x64@0.21.5': optional: true - '@esbuild/sunos-x64@0.23.1': - optional: true - '@esbuild/win32-arm64@0.21.5': optional: true - '@esbuild/win32-arm64@0.23.1': - optional: true - '@esbuild/win32-ia32@0.21.5': optional: true - '@esbuild/win32-ia32@0.23.1': - optional: true - '@esbuild/win32-x64@0.21.5': optional: true - '@esbuild/win32-x64@0.23.1': - optional: true - - '@isaacs/cliui@8.0.2': + '@inquirer/external-editor@1.0.3(@types/node@20.19.30)': dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 20.19.30 - '@jridgewell/gen-mapping@0.3.5': + '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/set-array@1.2.1': {} - - '@jridgewell/sourcemap-codec@1.5.0': {} - - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.9': + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - - '@langchain/community@0.0.53(openai@4.67.1(zod@3.23.8))': - dependencies: - '@langchain/core': 0.1.63(openai@4.67.1(zod@3.23.8)) - '@langchain/openai': 0.0.28 - expr-eval: 2.0.2 - flat: 5.0.2 - langsmith: 0.1.61(openai@4.67.1(zod@3.23.8)) - uuid: 9.0.1 - zod: 3.23.8 - zod-to-json-schema: 3.23.3(zod@3.23.8) - transitivePeerDependencies: - - encoding - - openai - - '@langchain/core@0.1.63(openai@4.67.1(zod@3.23.8))': - dependencies: - ansi-styles: 5.2.0 - camelcase: 6.3.0 - decamelize: 1.2.0 - js-tiktoken: 1.0.15 - langsmith: 0.1.61(openai@4.67.1(zod@3.23.8)) - ml-distance: 4.0.1 - mustache: 4.2.0 - p-queue: 6.6.2 - p-retry: 4.6.2 - uuid: 9.0.1 - zod: 3.23.8 - zod-to-json-schema: 3.23.3(zod@3.23.8) - transitivePeerDependencies: - - openai - - '@langchain/openai@0.0.28': - dependencies: - '@langchain/core': 0.1.63(openai@4.67.1(zod@3.23.8)) - js-tiktoken: 1.0.15 - openai: 4.67.1(zod@3.23.8) - zod: 3.23.8 - zod-to-json-schema: 3.23.3(zod@3.23.8) - transitivePeerDependencies: - - encoding + '@jridgewell/sourcemap-codec': 1.5.5 '@manypkg/find-root@1.1.0': dependencies: - '@babel/runtime': 7.25.7 + '@babel/runtime': 7.28.6 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 '@manypkg/get-packages@1.1.3': dependencies: - '@babel/runtime': 7.25.7 + '@babel/runtime': 7.28.6 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 globby: 11.1.0 read-yaml-file: 1.1.0 + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2759,421 +1677,300 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.17.1 + fastq: 1.20.1 '@opentelemetry/api@1.9.0': {} - '@pkgjs/parseargs@0.11.0': + '@oxc-project/types@0.122.0': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': optional: true - '@rollup/rollup-android-arm-eabi@4.24.0': + '@rolldown/binding-darwin-x64@1.0.0-rc.12': optional: true - '@rollup/rollup-android-arm64@4.24.0': + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': optional: true - '@rollup/rollup-darwin-arm64@4.24.0': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': optional: true - '@rollup/rollup-darwin-x64@4.24.0': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.24.0': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.24.0': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-arm64-gnu@4.24.0': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-arm64-musl@4.24.0': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.24.0': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-s390x-gnu@4.24.0': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true - '@rollup/rollup-linux-x64-gnu@4.24.0': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-x64-musl@4.24.0': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': optional: true - '@rollup/rollup-win32-arm64-msvc@4.24.0': + '@rolldown/pluginutils@1.0.0-rc.12': {} + + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.24.0': + '@rollup/rollup-android-arm64@4.57.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.24.0': + '@rollup/rollup-darwin-arm64@4.57.1': optional: true - '@tsconfig/node10@1.0.11': {} + '@rollup/rollup-darwin-x64@4.57.1': + optional: true - '@tsconfig/node12@1.0.11': {} + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true - '@tsconfig/node14@1.0.3': {} + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true - '@tsconfig/node16@1.0.4': {} + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true - '@types/diff-match-patch@1.0.36': {} + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true - '@types/estree@1.0.6': {} + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true - '@types/node-fetch@2.6.11': - dependencies: - '@types/node': 20.16.10 - form-data: 4.0.0 + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true - '@types/node@12.20.55': {} + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true - '@types/node@18.19.54': - dependencies: - undici-types: 5.26.5 + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true - '@types/node@20.16.10': - dependencies: - undici-types: 6.19.8 + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true - '@types/object-hash@3.0.6': {} + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true - '@types/retry@0.12.0': {} + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true - '@types/uuid@10.0.0': {} + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true - '@vitest/expect@2.1.2': - dependencies: - '@vitest/spy': 2.1.2 - '@vitest/utils': 2.1.2 - chai: 5.1.1 - tinyrainbow: 1.2.0 + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true - '@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(vite@5.4.8(@types/node@20.16.10))': - dependencies: - '@vitest/spy': 2.1.2 - estree-walker: 3.0.3 - magic-string: 0.30.11 - optionalDependencies: - vite: 5.4.8(@types/node@20.16.10) + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true - '@vitest/pretty-format@2.1.2': - dependencies: - tinyrainbow: 1.2.0 + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true - '@vitest/runner@2.1.2': - dependencies: - '@vitest/utils': 2.1.2 - pathe: 1.1.2 + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true - '@vitest/snapshot@2.1.2': - dependencies: - '@vitest/pretty-format': 2.1.2 - magic-string: 0.30.11 - pathe: 1.1.2 + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true - '@vitest/spy@2.1.2': - dependencies: - tinyspy: 3.0.2 + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true - '@vitest/utils@2.1.2': - dependencies: - '@vitest/pretty-format': 2.1.2 - loupe: 3.1.1 - tinyrainbow: 1.2.0 + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true - '@vue/compiler-core@3.5.11': - dependencies: - '@babel/parser': 7.25.7 - '@vue/shared': 3.5.11 - entities: 4.5.0 - estree-walker: 2.0.2 - source-map-js: 1.2.1 + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true - '@vue/compiler-dom@3.5.11': - dependencies: - '@vue/compiler-core': 3.5.11 - '@vue/shared': 3.5.11 + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true - '@vue/compiler-sfc@3.5.11': - dependencies: - '@babel/parser': 7.25.7 - '@vue/compiler-core': 3.5.11 - '@vue/compiler-dom': 3.5.11 - '@vue/compiler-ssr': 3.5.11 - '@vue/shared': 3.5.11 - estree-walker: 2.0.2 - magic-string: 0.30.11 - postcss: 8.4.47 - source-map-js: 1.2.1 + '@standard-schema/spec@1.1.0': {} - '@vue/compiler-ssr@3.5.11': + '@tybys/wasm-util@0.10.1': dependencies: - '@vue/compiler-dom': 3.5.11 - '@vue/shared': 3.5.11 + tslib: 2.8.1 + optional: true - '@vue/reactivity@3.5.11': - dependencies: - '@vue/shared': 3.5.11 + '@types/estree@1.0.8': {} + + '@types/jsesc@2.5.1': {} - '@vue/runtime-core@3.5.11': + '@types/node@12.20.55': {} + + '@types/node@20.19.30': dependencies: - '@vue/reactivity': 3.5.11 - '@vue/shared': 3.5.11 + undici-types: 6.21.0 - '@vue/runtime-dom@3.5.11': + '@vercel/oidc@3.1.0': {} + + '@vitest/expect@2.1.9': dependencies: - '@vue/reactivity': 3.5.11 - '@vue/runtime-core': 3.5.11 - '@vue/shared': 3.5.11 - csstype: 3.1.3 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 - '@vue/server-renderer@3.5.11(vue@3.5.11(typescript@5.6.2))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.30))': dependencies: - '@vue/compiler-ssr': 3.5.11 - '@vue/shared': 3.5.11 - vue: 3.5.11(typescript@5.6.2) + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@20.19.30) - '@vue/shared@3.5.11': {} + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 - '@xstate/graph@2.0.1(xstate@5.18.2)': + '@vitest/runner@2.1.9': dependencies: - xstate: 5.18.2 + '@vitest/utils': 2.1.9 + pathe: 1.1.2 - abort-controller@3.0.0: + '@vitest/snapshot@2.1.9': dependencies: - event-target-shim: 5.0.1 + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 - acorn-walk@8.3.4: + '@vitest/spy@2.1.9': dependencies: - acorn: 8.12.1 + tinyspy: 3.0.2 - acorn@8.12.1: {} + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 - agentkeepalive@4.5.0: + '@xstate/graph@2.0.1(xstate@5.26.0)': dependencies: - humanize-ms: 1.2.1 + xstate: 5.26.0 - ai@3.4.9(openai@4.67.1(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.5.11(typescript@5.6.2))(zod@3.23.8): + ai@6.0.67(zod@4.3.6): dependencies: - '@ai-sdk/provider': 0.0.24 - '@ai-sdk/provider-utils': 1.0.20(zod@3.23.8) - '@ai-sdk/react': 0.0.62(react@18.3.1)(zod@3.23.8) - '@ai-sdk/solid': 0.0.49(zod@3.23.8) - '@ai-sdk/svelte': 0.0.51(svelte@4.2.19)(zod@3.23.8) - '@ai-sdk/ui-utils': 0.0.46(zod@3.23.8) - '@ai-sdk/vue': 0.0.54(vue@3.5.11(typescript@5.6.2))(zod@3.23.8) + '@ai-sdk/gateway': 3.0.32(zod@4.3.6) + '@ai-sdk/provider': 3.0.7 + '@ai-sdk/provider-utils': 4.0.13(zod@4.3.6) '@opentelemetry/api': 1.9.0 - eventsource-parser: 1.1.2 - json-schema: 0.4.0 - jsondiffpatch: 0.6.0 - nanoid: 3.3.6 - secure-json-parse: 2.7.0 - zod-to-json-schema: 3.23.2(zod@3.23.8) - optionalDependencies: - openai: 4.67.1(zod@3.23.8) - react: 18.3.1 - sswr: 2.1.0(svelte@4.2.19) - svelte: 4.2.19 - zod: 3.23.8 - transitivePeerDependencies: - - solid-js - - vue + zod: 4.3.6 ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} - ansi-regex@6.1.0: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - - ansi-styles@6.2.1: {} - - any-promise@1.3.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - arg@4.1.3: {} + ansis@4.2.0: {} argparse@1.0.10: dependencies: sprintf-js: 1.0.3 - aria-query@5.3.2: {} + argparse@2.0.1: {} array-union@2.1.0: {} assertion-error@2.0.1: {} - asynckit@0.4.0: {} - - axios@1.7.7: + ast-kit@3.0.0-beta.1: dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.0 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - axobject-query@4.1.0: {} - - balanced-match@1.0.2: {} - - base64-js@1.5.1: {} + '@babel/parser': 8.0.0-rc.3 + estree-walker: 3.0.3 + pathe: 2.0.3 better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 - binary-extensions@2.3.0: {} - - binary-search@1.3.6: {} - - brace-expansion@2.0.1: - dependencies: - balanced-match: 1.0.2 + birpc@4.0.0: {} braces@3.0.3: dependencies: fill-range: 7.1.1 - bundle-require@5.0.0(esbuild@0.23.1): - dependencies: - esbuild: 0.23.1 - load-tsconfig: 0.2.5 - cac@6.7.14: {} - camelcase@4.1.0: {} + cac@7.0.0: {} - camelcase@6.3.0: {} - - chai@5.1.1: + chai@5.3.3: dependencies: assertion-error: 2.0.1 - check-error: 2.1.1 + check-error: 2.1.3 deep-eql: 5.0.2 - loupe: 3.1.1 - pathval: 2.0.0 - - chalk@5.3.0: {} + loupe: 3.2.1 + pathval: 2.0.1 - chardet@0.7.0: {} + chardet@2.1.1: {} - check-error@2.1.1: {} - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 + check-error@2.1.3: {} ci-info@3.9.0: {} - client-only@0.0.1: {} - - code-red@1.0.4: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - '@types/estree': 1.0.6 - acorn: 8.12.1 - estree-walker: 3.0.3 - periscopic: 3.1.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - commander@10.0.1: {} - - commander@4.1.1: {} - - consola@3.2.3: {} - - create-require@1.1.1: {} - - cross-spawn@5.1.0: - dependencies: - lru-cache: 4.1.5 - shebang-command: 1.2.0 - which: 1.3.1 - - cross-spawn@7.0.3: + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - css-tree@2.3.1: - dependencies: - mdn-data: 2.0.30 - source-map-js: 1.2.1 - - csstype@3.1.3: {} - dataloader@1.4.0: {} - debug@4.3.7: + debug@4.4.3: dependencies: ms: 2.1.3 - decamelize@1.2.0: {} - deep-eql@5.0.2: {} - delayed-stream@1.0.0: {} + defu@6.1.4: {} detect-indent@6.1.0: {} - diff-match-patch@1.0.5: {} - - diff@4.0.2: {} - dir-glob@3.0.1: dependencies: path-type: 4.0.0 - dotenv@16.4.5: {} + dotenv@16.6.1: {} dotenv@8.6.0: {} - eastasianwidth@0.2.0: {} - - emoji-regex@8.0.0: {} + dts-resolver@2.1.3: {} - emoji-regex@9.2.2: {} + empathic@2.0.0: {} enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - entities@4.5.0: {} + es-module-lexer@1.7.0: {} esbuild@0.21.5: optionalDependencies: @@ -3201,70 +1998,19 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 - esbuild@0.23.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.23.1 - '@esbuild/android-arm': 0.23.1 - '@esbuild/android-arm64': 0.23.1 - '@esbuild/android-x64': 0.23.1 - '@esbuild/darwin-arm64': 0.23.1 - '@esbuild/darwin-x64': 0.23.1 - '@esbuild/freebsd-arm64': 0.23.1 - '@esbuild/freebsd-x64': 0.23.1 - '@esbuild/linux-arm': 0.23.1 - '@esbuild/linux-arm64': 0.23.1 - '@esbuild/linux-ia32': 0.23.1 - '@esbuild/linux-loong64': 0.23.1 - '@esbuild/linux-mips64el': 0.23.1 - '@esbuild/linux-ppc64': 0.23.1 - '@esbuild/linux-riscv64': 0.23.1 - '@esbuild/linux-s390x': 0.23.1 - '@esbuild/linux-x64': 0.23.1 - '@esbuild/netbsd-x64': 0.23.1 - '@esbuild/openbsd-arm64': 0.23.1 - '@esbuild/openbsd-x64': 0.23.1 - '@esbuild/sunos-x64': 0.23.1 - '@esbuild/win32-arm64': 0.23.1 - '@esbuild/win32-ia32': 0.23.1 - '@esbuild/win32-x64': 0.23.1 - esprima@4.0.1: {} - estree-walker@2.0.2: {} - estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.6 - - event-target-shim@5.0.1: {} + '@types/estree': 1.0.8 - eventemitter3@4.0.7: {} + eventsource-parser@3.0.6: {} - eventsource-parser@1.1.2: {} - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.3 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - expr-eval@2.0.2: {} + expect-type@1.3.0: {} extendable-error@0.1.7: {} - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.0.33 - - fast-glob@3.3.2: + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 '@nodelib/fs.walk': 1.2.8 @@ -3272,13 +2018,13 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fastq@1.17.1: + fastq@1.20.1: dependencies: - reusify: 1.0.4 + reusify: 1.1.0 - fdir@6.4.0(picomatch@4.0.2): + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: - picomatch: 4.0.2 + picomatch: 4.0.3 fill-range@7.1.1: dependencies: @@ -3289,28 +2035,6 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 - flat@5.0.2: {} - - follow-redirects@1.15.9: {} - - foreground-child@3.3.0: - dependencies: - cross-spawn: 7.0.3 - signal-exit: 4.1.0 - - form-data-encoder@1.7.2: {} - - form-data@4.0.0: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - - formdata-node@4.4.1: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 4.0.0-beta.3 - fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -3326,74 +2050,45 @@ snapshots: fsevents@2.3.3: optional: true - get-func-name@2.0.2: {} - - get-stream@6.0.1: {} + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 glob-parent@5.1.2: dependencies: is-glob: 4.0.3 - glob@10.4.5: - dependencies: - foreground-child: 3.3.0 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - globby@11.1.0: dependencies: array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.3.2 + fast-glob: 3.3.3 ignore: 5.3.2 merge2: 1.4.1 slash: 3.0.0 graceful-fs@4.2.11: {} - human-id@1.0.2: {} - - human-signals@2.1.0: {} + hookable@6.1.0: {} - humanize-ms@1.2.1: - dependencies: - ms: 2.1.3 + human-id@4.1.3: {} - iconv-lite@0.4.24: + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 ignore@5.3.2: {} - infobox-parser@3.6.4: - dependencies: - camelcase: 4.1.0 - - is-any-array@2.0.1: {} - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 + import-without-cache@0.2.5: {} is-extglob@2.1.1: {} - is-fullwidth-code-point@3.0.0: {} - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 is-number@7.0.0: {} - is-reference@3.0.2: - dependencies: - '@types/estree': 1.0.6 - - is-stream@2.0.1: {} - is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -3402,93 +2097,34 @@ snapshots: isexe@2.0.0: {} - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - joycon@3.1.1: {} - - js-tiktoken@1.0.15: - dependencies: - base64-js: 1.5.1 - - js-tokens@4.0.0: {} - - js-yaml@3.14.1: + js-yaml@3.14.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 - json-schema-to-ts@3.1.1: + js-yaml@4.1.1: dependencies: - '@babel/runtime': 7.25.7 - ts-algebra: 2.0.0 + argparse: 2.0.1 - json-schema@0.4.0: {} + jsesc@3.1.0: {} - jsondiffpatch@0.6.0: - dependencies: - '@types/diff-match-patch': 1.0.36 - chalk: 5.3.0 - diff-match-patch: 1.0.5 + json-schema@0.4.0: {} jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 - langsmith@0.1.61(openai@4.67.1(zod@3.23.8)): - dependencies: - '@types/uuid': 10.0.0 - commander: 10.0.1 - p-queue: 6.6.2 - p-retry: 4.6.2 - semver: 7.6.3 - uuid: 10.0.0 - optionalDependencies: - openai: 4.67.1(zod@3.23.8) - - lilconfig@3.1.2: {} - - lines-and-columns@1.2.4: {} - - load-tsconfig@0.2.5: {} - - locate-character@3.0.0: {} - locate-path@5.0.0: dependencies: p-locate: 4.1.0 - lodash.sortby@4.7.0: {} - lodash.startcase@4.4.0: {} - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - loupe@3.1.1: - dependencies: - get-func-name: 2.0.2 - - lru-cache@10.4.3: {} + loupe@3.2.1: {} - lru-cache@4.1.5: + magic-string@0.30.21: dependencies: - pseudomap: 1.0.2 - yallist: 2.1.2 - - magic-string@0.30.11: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - - make-error@1.3.6: {} - - mdn-data@2.0.30: {} - - merge-stream@2.0.0: {} + '@jridgewell/sourcemap-codec': 1.5.5 merge2@1.4.1: {} @@ -3497,94 +2133,17 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mimic-fn@2.1.0: {} - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.1 - - minipass@7.1.2: {} - - ml-array-mean@1.1.6: - dependencies: - ml-array-sum: 1.1.6 - - ml-array-sum@1.1.6: - dependencies: - is-any-array: 2.0.1 - - ml-distance-euclidean@2.0.0: {} - - ml-distance@4.0.1: - dependencies: - ml-array-mean: 1.1.6 - ml-distance-euclidean: 2.0.0 - ml-tree-similarity: 1.0.0 - - ml-tree-similarity@1.0.0: - dependencies: - binary-search: 1.3.6 - num-sort: 2.1.0 - mri@1.2.0: {} ms@2.1.3: {} - mustache@4.2.0: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.6: {} - - nanoid@3.3.7: {} - - node-domexception@1.0.0: {} + nanoid@3.3.11: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 - normalize-path@3.0.0: {} - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - num-sort@2.1.0: {} - - object-assign@4.1.1: {} - - object-hash@3.0.0: {} - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - openai@4.67.1(zod@3.23.8): - dependencies: - '@types/node': 18.19.54 - '@types/node-fetch': 2.6.11 - abort-controller: 3.0.0 - agentkeepalive: 4.5.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 - optionalDependencies: - zod: 3.23.8 - transitivePeerDependencies: - - encoding - - os-tmpdir@1.0.2: {} + obug@2.1.1: {} outdent@0.5.0: {} @@ -3592,8 +2151,6 @@ snapshots: dependencies: p-map: 2.1.0 - p-finally@1.0.0: {} - p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -3604,122 +2161,132 @@ snapshots: p-map@2.1.0: {} - p-queue@6.6.2: - dependencies: - eventemitter3: 4.0.7 - p-timeout: 3.2.0 - - p-retry@4.6.2: - dependencies: - '@types/retry': 0.12.0 - retry: 0.13.1 - - p-timeout@3.2.0: - dependencies: - p-finally: 1.0.0 - p-try@2.2.0: {} - package-json-from-dist@1.0.1: {} - - package-manager-detector@0.2.1: {} + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 path-exists@4.0.0: {} path-key@3.1.1: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 - path-type@4.0.0: {} pathe@1.1.2: {} - pathval@2.0.0: {} + pathe@2.0.3: {} - periscopic@3.1.0: - dependencies: - '@types/estree': 1.0.6 - estree-walker: 3.0.3 - is-reference: 3.0.2 + pathval@2.0.1: {} - picocolors@1.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} - picomatch@4.0.2: {} - - pify@4.0.1: {} + picomatch@4.0.3: {} - pirates@4.0.6: {} + picomatch@4.0.4: {} - postcss-load-config@6.0.1(postcss@8.4.47): - dependencies: - lilconfig: 3.1.2 - optionalDependencies: - postcss: 8.4.47 + pify@4.0.1: {} - postcss@8.4.47: + postcss@8.5.6: dependencies: - nanoid: 3.3.7 - picocolors: 1.1.0 + nanoid: 3.3.11 + picocolors: 1.1.1 source-map-js: 1.2.1 prettier@2.8.8: {} - proxy-from-env@1.1.0: {} - - pseudomap@1.0.2: {} + quansync@0.2.11: {} - punycode@2.3.1: {} + quansync@1.0.0: {} queue-microtask@1.2.3: {} - react@18.3.1: - dependencies: - loose-envify: 1.4.0 - read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.14.1 + js-yaml: 3.14.2 pify: 4.0.1 strip-bom: 3.0.0 - readdirp@3.6.0: - dependencies: - picomatch: 2.3.1 + resolve-from@5.0.0: {} - regenerator-runtime@0.14.1: {} + resolve-pkg-maps@1.0.0: {} - resolve-from@5.0.0: {} + reusify@1.1.0: {} - retry@0.13.1: {} + rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(typescript@5.9.3): + dependencies: + '@babel/generator': 8.0.0-rc.3 + '@babel/helper-validator-identifier': 8.0.0-rc.3 + '@babel/parser': 8.0.0-rc.3 + '@babel/types': 8.0.0-rc.3 + ast-kit: 3.0.0-beta.1 + birpc: 4.0.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.13.7 + obug: 2.1.1 + picomatch: 4.0.4 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver - reusify@1.0.4: {} + rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - rollup@4.24.0: + rollup@4.57.1: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.24.0 - '@rollup/rollup-android-arm64': 4.24.0 - '@rollup/rollup-darwin-arm64': 4.24.0 - '@rollup/rollup-darwin-x64': 4.24.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.24.0 - '@rollup/rollup-linux-arm-musleabihf': 4.24.0 - '@rollup/rollup-linux-arm64-gnu': 4.24.0 - '@rollup/rollup-linux-arm64-musl': 4.24.0 - '@rollup/rollup-linux-powerpc64le-gnu': 4.24.0 - '@rollup/rollup-linux-riscv64-gnu': 4.24.0 - '@rollup/rollup-linux-s390x-gnu': 4.24.0 - '@rollup/rollup-linux-x64-gnu': 4.24.0 - '@rollup/rollup-linux-x64-musl': 4.24.0 - '@rollup/rollup-win32-arm64-msvc': 4.24.0 - '@rollup/rollup-win32-ia32-msvc': 4.24.0 - '@rollup/rollup-win32-x64-msvc': 4.24.0 + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 run-parallel@1.2.0: @@ -3728,231 +2295,125 @@ snapshots: safer-buffer@2.1.2: {} - secure-json-parse@2.7.0: {} + semver@7.7.3: {} - semver@7.6.3: {} - - shebang-command@1.2.0: - dependencies: - shebang-regex: 1.0.0 + semver@7.7.4: {} shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 - shebang-regex@1.0.0: {} - shebang-regex@3.0.0: {} siginfo@2.0.0: {} - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} slash@3.0.0: {} source-map-js@1.2.1: {} - source-map@0.8.0-beta.0: + spawndamnit@3.0.1: dependencies: - whatwg-url: 7.1.0 - - spawndamnit@2.0.0: - dependencies: - cross-spawn: 5.1.0 - signal-exit: 3.0.7 + cross-spawn: 7.0.6 + signal-exit: 4.1.0 sprintf-js@1.0.3: {} - sswr@2.1.0(svelte@4.2.19): - dependencies: - svelte: 4.2.19 - swrev: 4.0.0 - stackback@0.0.2: {} - std-env@3.7.0: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.0 + std-env@3.10.0: {} strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.0: - dependencies: - ansi-regex: 6.1.0 - strip-bom@3.0.0: {} - strip-final-newline@2.0.0: {} - - sucrase@3.35.0: - dependencies: - '@jridgewell/gen-mapping': 0.3.5 - commander: 4.1.1 - glob: 10.4.5 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.6 - ts-interface-checker: 0.1.13 - - svelte@4.2.19: - dependencies: - '@ampproject/remapping': 2.3.0 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - '@types/estree': 1.0.6 - acorn: 8.12.1 - aria-query: 5.3.2 - axobject-query: 4.1.0 - code-red: 1.0.4 - css-tree: 2.3.1 - estree-walker: 3.0.3 - is-reference: 3.0.2 - locate-character: 3.0.0 - magic-string: 0.30.11 - periscopic: 3.1.0 - - swr@2.2.5(react@18.3.1): - dependencies: - client-only: 0.0.1 - react: 18.3.1 - use-sync-external-store: 1.2.2(react@18.3.1) - - swrev@4.0.0: {} - - swrv@1.0.4(vue@3.5.11(typescript@5.6.2)): - dependencies: - vue: 3.5.11(typescript@5.6.2) - term-size@2.2.1: {} - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - tinybench@2.9.0: {} - tinyexec@0.3.0: {} + tinyexec@0.3.2: {} + + tinyexec@1.0.4: {} - tinyglobby@0.2.9: + tinyglobby@0.2.15: dependencies: - fdir: 6.4.0(picomatch@4.0.2) - picomatch: 4.0.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 - tinypool@1.0.1: {} + tinypool@1.1.1: {} tinyrainbow@1.2.0: {} tinyspy@3.0.2: {} - tmp@0.0.33: - dependencies: - os-tmpdir: 1.0.2 - - to-fast-properties@2.0.0: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 tr46@0.0.3: {} - tr46@1.0.1: - dependencies: - punycode: 2.3.1 - tree-kill@1.2.2: {} - ts-algebra@2.0.0: {} - - ts-interface-checker@0.1.13: {} - - ts-node@10.9.2(@types/node@20.16.10)(typescript@5.6.2): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.16.10 - acorn: 8.12.1 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.6.2 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - - tsup@8.3.0(postcss@8.4.47)(typescript@5.6.2): - dependencies: - bundle-require: 5.0.0(esbuild@0.23.1) - cac: 6.7.14 - chokidar: 3.6.0 - consola: 3.2.3 - debug: 4.3.7 - esbuild: 0.23.1 - execa: 5.1.1 - joycon: 3.1.1 - picocolors: 1.1.0 - postcss-load-config: 6.0.1(postcss@8.4.47) - resolve-from: 5.0.0 - rollup: 4.24.0 - source-map: 0.8.0-beta.0 - sucrase: 3.35.0 - tinyglobby: 0.2.9 + tsdown@0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(typescript@5.9.3): + dependencies: + ansis: 4.2.0 + cac: 7.0.0 + defu: 6.1.4 + empathic: 2.0.0 + hookable: 6.1.0 + import-without-cache: 0.2.5 + obug: 2.1.1 + picomatch: 4.0.4 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + rolldown-plugin-dts: 0.23.2(rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(typescript@5.9.3) + semver: 7.7.4 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 tree-kill: 1.2.2 + unconfig-core: 7.5.0 + unrun: 0.2.34(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optionalDependencies: - postcss: 8.4.47 - typescript: 5.6.2 + typescript: 5.9.3 transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - - typescript@5.6.2: {} + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc - undici-types@5.26.5: {} - - undici-types@6.19.8: {} + tslib@2.8.1: + optional: true - universalify@0.1.2: {} + typescript@5.9.3: {} - use-sync-external-store@1.2.2(react@18.3.1): + unconfig-core@7.5.0: dependencies: - react: 18.3.1 + '@quansync/fs': 1.0.0 + quansync: 1.0.0 - uuid@10.0.0: {} + undici-types@6.21.0: {} - uuid@9.0.1: {} + universalify@0.1.2: {} - v8-compile-cache-lib@3.0.1: {} + unrun@0.2.34(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + dependencies: + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - vite-node@2.1.2(@types/node@20.16.10): + vite-node@2.1.9(@types/node@20.19.30): dependencies: cac: 6.7.14 - debug: 4.3.7 + debug: 4.4.3 + es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.8(@types/node@20.16.10) + vite: 5.4.21(@types/node@20.19.30) transitivePeerDependencies: - '@types/node' - less @@ -3964,38 +2425,39 @@ snapshots: - supports-color - terser - vite@5.4.8(@types/node@20.16.10): + vite@5.4.21(@types/node@20.19.30): dependencies: esbuild: 0.21.5 - postcss: 8.4.47 - rollup: 4.24.0 + postcss: 8.5.6 + rollup: 4.57.1 optionalDependencies: - '@types/node': 20.16.10 + '@types/node': 20.19.30 fsevents: 2.3.3 - vitest@2.1.2(@types/node@20.16.10): - dependencies: - '@vitest/expect': 2.1.2 - '@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(vite@5.4.8(@types/node@20.16.10)) - '@vitest/pretty-format': 2.1.2 - '@vitest/runner': 2.1.2 - '@vitest/snapshot': 2.1.2 - '@vitest/spy': 2.1.2 - '@vitest/utils': 2.1.2 - chai: 5.1.1 - debug: 4.3.7 - magic-string: 0.30.11 + vitest@2.1.9(@types/node@20.19.30): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.30)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 pathe: 1.1.2 - std-env: 3.7.0 + std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.0 - tinypool: 1.0.1 + tinyexec: 0.3.2 + tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.8(@types/node@20.16.10) - vite-node: 2.1.2(@types/node@20.16.10) + vite: 5.4.21(@types/node@20.19.30) + vite-node: 2.1.9(@types/node@20.19.30) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 20.16.10 + '@types/node': 20.19.30 transitivePeerDependencies: - less - lightningcss @@ -4007,37 +2469,13 @@ snapshots: - supports-color - terser - vue@3.5.11(typescript@5.6.2): - dependencies: - '@vue/compiler-dom': 3.5.11 - '@vue/compiler-sfc': 3.5.11 - '@vue/runtime-dom': 3.5.11 - '@vue/server-renderer': 3.5.11(vue@3.5.11(typescript@5.6.2)) - '@vue/shared': 3.5.11 - optionalDependencies: - typescript: 5.6.2 - - web-streams-polyfill@4.0.0-beta.3: {} - webidl-conversions@3.0.1: {} - webidl-conversions@4.0.2: {} - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1 - whatwg-url@7.1.0: - dependencies: - lodash.sortby: 4.7.0 - tr46: 1.0.1 - webidl-conversions: 4.0.2 - - which@1.3.1: - dependencies: - isexe: 2.0.0 - which@2.0.2: dependencies: isexe: 2.0.0 @@ -4047,37 +2485,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - wikipedia@2.1.2: - dependencies: - axios: 1.7.7 - infobox-parser: 3.6.4 - transitivePeerDependencies: - - debug - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.1 - string-width: 5.1.2 - strip-ansi: 7.1.0 - - xstate@5.18.2: {} - - yallist@2.1.2: {} - - yn@3.1.1: {} - - zod-to-json-schema@3.23.2(zod@3.23.8): - dependencies: - zod: 3.23.8 - - zod-to-json-schema@3.23.3(zod@3.23.8): - dependencies: - zod: 3.23.8 + xstate@5.26.0: {} - zod@3.23.8: {} + zod@4.3.6: {} diff --git a/src/adapter.ts b/src/adapter.ts new file mode 100644 index 0000000..9cc3e54 --- /dev/null +++ b/src/adapter.ts @@ -0,0 +1,8 @@ +import type { AgentAdapter } from './types.js'; + +/** + * Create a custom adapter for AI primitives (classify/decide). + */ +export function createAdapter(impl: AgentAdapter): AgentAdapter { + return impl; +} diff --git a/src/agent.test.ts b/src/agent.test.ts index 443f423..0d1d94f 100644 --- a/src/agent.test.ts +++ b/src/agent.test.ts @@ -1,326 +1,1267 @@ -import { test, expect, vi } from 'vitest'; -import { createAgent } from './'; -import { createActor, createMachine } from 'xstate'; -import { LanguageModelV1CallOptions } from 'ai'; +import { describe, expect, test, vi } from 'vitest'; import { z } from 'zod'; -import { dummyResponseValues, MockLanguageModelV1 } from './mockModel'; +import { + createAgentMachine, + createInitialState, + step, + run, + stream, + sendEvent, + decide, + classify, + createAdapter, +} from './index.js'; +import type { AgentAdapter } from './types.js'; -test('an agent has the expected interface', () => { - const agent = createAgent({ - name: 'test', - events: {}, - model: new MockLanguageModelV1(), +// ─── Test helpers ─── + +function mockAdapter( + responses: Array<{ choice: string; data?: Record; reasoning?: string }> +): AgentAdapter { + let index = 0; + return { + decide: async () => { + const response = responses[index++]; + if (!response) throw new Error('No more mock responses'); + return { + choice: response.choice, + data: response.data ?? {}, + reasoning: response.reasoning, + }; + }, + }; +} + +// ─── Simple machine for basic tests ─── + +function createSimpleMachine() { + return createAgentMachine({ + id: 'simple', + context: () => ({ count: 0 }), + initial: 'idle', + states: { + idle: { + on: { + start: () => ({ target: 'running' }), + }, + }, + running: { + run: async ({ context }) => { + return { value: (context.count as number) + 1 }; + }, + onDone: ({ result, context }) => ({ + target: 'done', + context: { count: (result as any).value }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ result: context.count }), + }, + }, + }); +} + +// ─── Machine with events for HITL ─── + +function createHitlMachine() { + return createAgentMachine({ + id: 'hitl', + inputSchema: z.object({ task: z.string() }), + context: (input) => ({ + task: input.task, + messages: [] as Array<{ role: string; content: string }>, + result: null as string | null, + }), + events: { + 'user.message': z.object({ message: z.string() }), + 'user.approve': z.object({}), + 'user.cancel': z.object({}), + }, + initial: 'gathering', + states: { + gathering: { + on: { + 'user.message': ({ event, context }) => ({ + context: { + messages: [ + ...(context.messages as any[]), + { role: 'user', content: (event as any).message }, + ], + }, + }), + 'user.approve': ({ context }) => ({ + target: 'processing', + }), + 'user.cancel': () => ({ target: 'cancelled' }), + }, + }, + processing: { + run: async ({ context }) => { + const msgs = context.messages as Array<{ content: string }>; + return { output: `Processed: ${msgs.map((m) => m.content).join(', ')}` }; + }, + onDone: ({ result }) => ({ + target: 'reviewing', + context: { result: (result as any).output }, + }), + }, + reviewing: { + on: { + 'user.approve': () => ({ target: 'done' }), + 'user.message': ({ event, context }) => ({ + target: 'processing', + context: { + messages: [ + ...(context.messages as any[]), + { role: 'user', content: (event as any).message }, + ], + }, + }), + 'user.cancel': () => ({ target: 'cancelled' }), + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ result: context.result }), + }, + cancelled: { + type: 'final', + output: () => ({ cancelled: true }), + }, + }, + }); +} + +// ─── Machine with decide state ─── + +function createDecideMachine(adapter: AgentAdapter) { + return createAgentMachine({ + id: 'decider', + context: () => ({ + issue: 'App crashes on login', + category: null as string | null, + }), + adapter, + initial: 'classifying', + states: { + classifying: decide({ + model: 'test-model', + prompt: ({ context }) => `Classify: ${context.issue}`, + options: { + billing: { description: 'Billing issues' }, + technical: { description: 'Technical issues' }, + general: { description: 'General inquiries' }, + }, + onDone: ({ result }) => ({ + target: 'handling', + context: { category: result.choice }, + }), + }), + handling: { + run: async ({ context }) => ({ + resolution: `Handled ${context.category} issue`, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { resolution: (result as any).resolution }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + category: context.category, + resolution: context.resolution, + }), + }, + }, + }); +} + +// ─── Machine with classify state ─── + +function createClassifyMachine(adapter: AgentAdapter) { + return createAgentMachine({ + id: 'classifier', + context: () => ({ issue: 'I want my money back', category: null as string | null }), + adapter, + initial: 'classifyIntent', + states: { + classifyIntent: classify({ + model: 'test-model', + prompt: ({ context }) => `Classify: "${context.issue}"`, + into: { + billing: { description: 'Billing, payments, refunds' }, + technical: { description: 'Technical issues, bugs' }, + general: { description: 'General inquiries' }, + }, + onDone: ({ result }) => ({ + target: 'done', + context: { category: result.category }, + }), + }), + done: { + type: 'final', + output: ({ context }) => ({ category: context.category }), + }, + }, + }); +} + +// ─── Nested/compound state machine ─── + +function createNestedMachine() { + return createAgentMachine({ + id: 'nested', + context: () => ({ + resolution: null as string | null, + category: 'billing' as string, + }), + initial: 'handling', + states: { + handling: { + initial: ({ context }) => { + if (context.category === 'billing') { + return { target: 'checkEligibility' }; + } + return { target: 'diagnose' }; + }, + states: { + checkEligibility: { + run: async () => ({ eligible: true }), + onDone: ({ result }) => { + if ((result as any).eligible) return { target: 'processRefund' }; + return { target: 'deny' }; + }, + }, + processRefund: { + run: async () => ({}), + onDone: ({ context }) => ({ + target: 'childDone', + context: { resolution: 'Refund processed' }, + }), + }, + deny: { + run: async () => ({ message: 'Not eligible' }), + onDone: ({ result }) => ({ + target: 'childDone', + context: { resolution: (result as any).message }, + }), + }, + diagnose: { + run: async () => ({ diagnosis: 'It is a bug' }), + onDone: ({ result }) => ({ + target: 'childDone', + context: { resolution: (result as any).diagnosis }, + }), + }, + childDone: { type: 'final' }, + }, + onDone: () => ({ + target: 'respond', + }), + on: { + 'user.cancel': () => ({ target: 'cancelled' }), + }, + }, + respond: { + run: async ({ context }) => ({ message: context.resolution }), + onDone: () => ({ target: 'done' }), + }, + done: { + type: 'final', + output: ({ context }) => ({ resolution: context.resolution }), + }, + cancelled: { + type: 'final', + output: () => ({ cancelled: true }), + }, + }, }); +} - expect(agent.decide).toBeDefined(); +// ═══════════════════════════════════════ +// Tests +// ═══════════════════════════════════════ - expect(agent.addMessage).toBeDefined(); - expect(agent.addObservation).toBeDefined(); - expect(agent.addFeedback).toBeDefined(); - expect(agent.addPlan).toBeDefined(); +describe('createAgentMachine', () => { + test('creates a machine config', () => { + const machine = createSimpleMachine(); + expect(machine.id).toBe('simple'); + expect(machine.states).toBeDefined(); + expect(machine.states.idle).toBeDefined(); + expect(machine.states.running).toBeDefined(); + expect(machine.states.done).toBeDefined(); + }); +}); + +describe('createInitialState', () => { + test('creates initial state with context', async () => { + const machine = createSimpleMachine(); + const state = await createInitialState(machine, undefined); + expect(state.value).toBe('idle'); + expect(state.context).toEqual({ count: 0 }); + expect(state.status).toBe('running'); + expect(state.params).toEqual({}); + }); + + test('validates input against schema', async () => { + const machine = createHitlMachine(); + const state = await createInitialState(machine, { task: 'test task' }); + expect(state.context.task).toBe('test task'); + expect(state.value).toBe('gathering'); + }); - expect(agent.getMessages).toBeDefined(); - expect(agent.getObservations).toBeDefined(); - expect(agent.getFeedback).toBeDefined(); - expect(agent.getPlans).toBeDefined(); + test('rejects invalid input', async () => { + const machine = createHitlMachine(); + await expect(createInitialState(machine, { task: 123 })).rejects.toThrow(); + }); + + test('resolves string initial', async () => { + const machine = createSimpleMachine(); + const state = await createInitialState(machine, undefined); + expect(state.value).toBe('idle'); + }); + + test('resolves function initial', async () => { + const machine = createAgentMachine({ + id: 'fn-initial', + context: (input) => ({ mode: input }), + initial: ({ context }) => ({ + target: context.mode === 'fast' ? 'fast' : 'slow', + }), + states: { + fast: { type: 'final' }, + slow: { type: 'final' }, + }, + }); + const state = await createInitialState(machine, 'fast'); + expect(state.value).toBe('fast'); + }); - expect(agent.interact).toBeDefined(); + test('resolves compound state initial', async () => { + const machine = createNestedMachine(); + const state = await createInitialState(machine, undefined); + // Should enter handling → checkEligibility (since category is 'billing') + expect(state.value).toBe('handling.checkEligibility'); + }); }); -test('agent.addMessage() adds to message history', () => { - const model = new MockLanguageModelV1(); +describe('step', () => { + test('executes run and transitions via onDone', async () => { + const machine = createSimpleMachine(); + let state = await createInitialState(machine, undefined); + // idle → send start event to get to 'running' + state = sendEvent(machine, state, { type: 'start' }); + expect(state.value).toBe('running'); + + state = await step(machine, state); + expect(state.value).toBe('done'); + expect(state.context.count).toBe(1); + }); - const agent = createAgent({ - name: 'test', - events: {}, - model, + test('returns waiting for event-only states', async () => { + const machine = createHitlMachine(); + let state = await createInitialState(machine, { task: 'test' }); + state = await step(machine, state); + expect(state.status).toBe('waiting'); + expect(state.value).toBe('gathering'); }); - agent.addMessage({ - role: 'user', - content: [{ type: 'text', text: 'msg 1' }], + test('returns done for final states', async () => { + const machine = createSimpleMachine(); + let state = await createInitialState(machine, undefined); + state = sendEvent(machine, state, { type: 'start' }); + state = await step(machine, state); // run → done + state = await step(machine, state); // final + expect(state.status).toBe('done'); + expect(state.output).toEqual({ result: 1 }); }); - const messageHistory = agent.addMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'response 1' }], + test('handles context updates in transitions', async () => { + const machine = createSimpleMachine(); + let state = await createInitialState(machine, undefined); + state = sendEvent(machine, state, { type: 'start' }); + state = await step(machine, state); + expect(state.context.count).toBe(1); }); - expect(messageHistory.sessionId).toEqual(agent.sessionId); + test('handles decide state with adapter', async () => { + const adapter = mockAdapter([ + { choice: 'technical', data: {} }, + ]); + const machine = createDecideMachine(adapter); + let state = await createInitialState(machine, undefined); + expect(state.value).toBe('classifying'); + + state = await step(machine, state); + expect(state.value).toBe('handling'); + expect(state.context.category).toBe('technical'); + }); - expect(agent.getMessages()).toContainEqual( - expect.objectContaining({ - content: [expect.objectContaining({ text: 'msg 1' })], - }) - ); + test('handles classify state', async () => { + const adapter = mockAdapter([ + { choice: 'billing', data: {} }, + ]); + const machine = createClassifyMachine(adapter); + let state = await createInitialState(machine, undefined); + + state = await step(machine, state); + expect(state.value).toBe('done'); + expect(state.context.category).toBe('billing'); + }); + + test('errors without adapter on decide state', async () => { + const machine = createAgentMachine({ + id: 'no-adapter', + context: () => ({}), + initial: 'deciding', + states: { + deciding: decide({ + model: 'test', + prompt: 'test', + options: { a: { description: 'A' } }, + onDone: () => ({ target: 'done' }), + }), + done: { type: 'final' }, + }, + }); + const state = await createInitialState(machine, undefined); + const result = await step(machine, state); + expect(result.status).toBe('error'); + expect(result.error).toContain('No adapter'); + }); + + test('bubbles error from run', async () => { + const machine = createAgentMachine({ + id: 'error-machine', + context: () => ({}), + initial: 'failing', + states: { + failing: { + run: async () => { + throw new Error('boom'); + }, + onDone: () => ({ target: 'done' }), + }, + done: { type: 'final' }, + }, + }); + const state = await createInitialState(machine, undefined); + const result = await step(machine, state); + expect(result.status).toBe('error'); + expect((result.error as Error).message).toBe('boom'); + }); - expect(agent.getMessages()).toContainEqual( - expect.objectContaining({ - content: [expect.objectContaining({ text: 'response 1' })], - sessionId: expect.any(String), - timestamp: expect.any(Number), - }) - ); + test('handles nested state entry and execution', async () => { + const machine = createNestedMachine(); + let state = await createInitialState(machine, undefined); + expect(state.value).toBe('handling.checkEligibility'); + + // Step through checkEligibility → processRefund + state = await step(machine, state); + expect(state.value).toBe('handling.processRefund'); + + // Step through processRefund → childDone + state = await step(machine, state); + expect(state.value).toBe('handling.childDone'); + expect(state.context.resolution).toBe('Refund processed'); + + // Step: childDone is final → parent onDone → respond + state = await step(machine, state); + expect(state.value).toBe('respond'); + }); }); -test('agent.addFeedback() adds to feedback', () => { - const agent = createAgent({ - name: 'test', - events: {}, - model: {} as any, +describe('run', () => { + test('runs until completion', async () => { + const machine = createSimpleMachine(); + let state = await createInitialState(machine, undefined); + state = sendEvent(machine, state, { type: 'start' }); + + const result = await run(machine, state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ result: 1 }); + expect(result.context.count).toBe(1); + } }); - const feedback = agent.addFeedback({ - attributes: { - score: -1, - }, - goal: 'Win the game', - observationId: 'obs-1', - }); - - expect(feedback.sessionId).toEqual(agent.sessionId); - - expect(agent.getFeedback()).toContainEqual( - expect.objectContaining({ - attributes: { - score: -1, - }, - goal: 'Win the game', - observationId: 'obs-1', - sessionId: expect.any(String), - timestamp: expect.any(Number), - }) - ); - expect(agent.getFeedback()).toContainEqual( - expect.objectContaining({ - attributes: { - score: -1, - }, - goal: 'Win the game', - observationId: 'obs-1', - sessionId: expect.any(String), - timestamp: expect.any(Number), - }) - ); + test('stops at waiting state', async () => { + const machine = createHitlMachine(); + const state = await createInitialState(machine, { task: 'test' }); + + const result = await run(machine, state); + expect(result.status).toBe('waiting'); + if (result.status === 'waiting') { + expect(result.value).toBe('gathering'); + expect(result.events).toBeDefined(); + } + }); + + test('stops on error', async () => { + const machine = createAgentMachine({ + id: 'err', + context: () => ({}), + initial: 'fail', + states: { + fail: { + run: async () => { + throw new Error('nope'); + }, + onDone: () => ({ target: 'ok' }), + }, + ok: { type: 'final' }, + }, + }); + const state = await createInitialState(machine, undefined); + const result = await run(machine, state); + expect(result.status).toBe('error'); + }); + + test('runs through multiple transitions', async () => { + const adapter = mockAdapter([{ choice: 'technical' }]); + const machine = createDecideMachine(adapter); + const state = await createInitialState(machine, undefined); + + const result = await run(machine, state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + category: 'technical', + resolution: 'Handled technical issue', + }); + } + }); + + test('runs nested states to completion', async () => { + const machine = createNestedMachine(); + const state = await createInitialState(machine, undefined); + + const result = await run(machine, state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ resolution: 'Refund processed' }); + } + }); + + test('waiting result includes available events', async () => { + const machine = createHitlMachine(); + const state = await createInitialState(machine, { task: 'test' }); + + const result = await run(machine, state); + expect(result.status).toBe('waiting'); + if (result.status === 'waiting') { + expect(result.events['user.message']).toBeDefined(); + expect(result.events['user.approve']).toBeDefined(); + expect(result.events['user.cancel']).toBeDefined(); + } + }); }); -test('agent.addObservation() adds to observations', () => { - const agent = createAgent({ - name: 'test', - events: {}, - model: {} as any, +describe('sendEvent', () => { + test('transitions on matching event', async () => { + const machine = createSimpleMachine(); + const state = await createInitialState(machine, undefined); + const next = sendEvent(machine, state, { type: 'start' }); + expect(next.value).toBe('running'); + expect(next.status).toBe('running'); + }); + + test('handles self-transition (no target)', async () => { + const machine = createHitlMachine(); + let state = await createInitialState(machine, { task: 'test' }); + state = await step(machine, state); // → waiting + + const next = sendEvent(machine, state, { + type: 'user.message', + message: 'hello', + }); + expect(next.value).toBe('gathering'); // same state + expect((next.context.messages as any[]).length).toBe(1); + expect((next.context.messages as any[])[0].content).toBe('hello'); + }); + + test('accumulates context on repeated self-transitions', async () => { + const machine = createHitlMachine(); + let state = await createInitialState(machine, { task: 'test' }); + state = await step(machine, state); // → waiting + + state = sendEvent(machine, state, { type: 'user.message', message: 'one' }); + state = sendEvent(machine, state, { type: 'user.message', message: 'two' }); + state = sendEvent(machine, state, { type: 'user.message', message: 'three' }); + + expect((state.context.messages as any[]).length).toBe(3); + }); + + test('transitions to new state with event', async () => { + const machine = createHitlMachine(); + let state = await createInitialState(machine, { task: 'test' }); + state = await step(machine, state); // → waiting at gathering + + state = sendEvent(machine, state, { type: 'user.approve' }); + expect(state.value).toBe('processing'); + expect(state.status).toBe('running'); }); - const observation = agent.addObservation({ - prevState: { value: 'playing', context: {} }, - event: { type: 'play', position: 3 }, - state: { value: 'lost', context: {} }, + test('throws on unknown event', async () => { + const machine = createSimpleMachine(); + const state = await createInitialState(machine, undefined); + expect(() => + sendEvent(machine, state, { type: 'nonexistent' }) + ).toThrow("No handler for event 'nonexistent'"); }); - expect(observation.sessionId).toEqual(agent.sessionId); + test('parent event preempts child in nested state', async () => { + const machine = createNestedMachine(); + let state = await createInitialState(machine, undefined); + expect(state.value).toBe('handling.checkEligibility'); - expect(agent.getObservations()).toContainEqual( - expect.objectContaining({ - prevState: { value: 'playing', context: {} }, - event: { type: 'play', position: 3 }, - state: { value: 'lost', context: {} }, - sessionId: expect.any(String), - timestamp: expect.any(Number), - }) - ); + // Parent's on handler should preempt + const next = sendEvent(machine, state, { type: 'user.cancel' }); + expect(next.value).toBe('cancelled'); + }); }); -test('agent.addObservation() adds to observations with machine hash', () => { - const agent = createAgent({ - name: 'test', - events: {}, - model: {} as any, +describe('stream', () => { + test('yields snapshots for each transition', async () => { + const adapter = mockAdapter([{ choice: 'technical' }]); + const machine = createDecideMachine(adapter); + const state = await createInitialState(machine, undefined); + + const snapshots = []; + for await (const snapshot of stream(machine, state)) { + snapshots.push(snapshot); + } + + expect(snapshots.length).toBeGreaterThanOrEqual(3); // initial + classifying→handling + handling→done + done + expect(snapshots[0]!.value).toBe('classifying'); + const last = snapshots[snapshots.length - 1]!; + expect(last.status).toBe('done'); }); +}); - const machine = createMachine({ - initial: 'playing', - states: { - playing: { - on: { - play: 'lost', - }, +describe('decide', () => { + test('creates state config with decide type', () => { + const config = decide({ + model: 'test', + prompt: 'test prompt', + options: { + a: { description: 'Option A' }, + b: { description: 'Option B' }, }, - lost: {}, - }, + onDone: ({ result }) => ({ target: result.choice }), + }); + expect(config.__type).toBe('decide'); + expect(config.__decideConfig).toBeDefined(); + expect(config.__decideConfig!.model).toBe('test'); + }); + + test('calls adapter with resolved prompt function', async () => { + const decideSpy = vi.fn().mockResolvedValue({ + choice: 'a', + data: {}, + }); + const adapter: AgentAdapter = { decide: decideSpy }; + + const machine = createAgentMachine({ + id: 'decide-test', + context: () => ({ topic: 'cats' }), + adapter, + initial: 'choosing', + states: { + choosing: decide({ + model: 'my-model', + prompt: ({ context }) => `About ${context.topic}`, + options: { + a: { description: 'A' }, + b: { description: 'B' }, + }, + onDone: ({ result }) => ({ + target: 'done', + context: { choice: result.choice }, + }), + }), + done: { type: 'final' }, + }, + }); + + const state = await createInitialState(machine, undefined); + await step(machine, state); + + expect(decideSpy).toHaveBeenCalledWith({ + model: 'my-model', + prompt: 'About cats', + options: { + a: { description: 'A' }, + b: { description: 'B' }, + }, + reasoning: undefined, + }); }); - const observation = agent.addObservation({ - prevState: { value: 'playing', context: {} }, - event: { type: 'play', position: 3 }, - state: { value: 'lost', context: {} }, - machine, + test('supports per-state adapter override', async () => { + const machineAdapter = mockAdapter([{ choice: 'machine' }]); + const stateAdapter = mockAdapter([{ choice: 'state' }]); + + const machine = createAgentMachine({ + id: 'override-test', + context: () => ({ choice: null as string | null }), + adapter: machineAdapter, + initial: 'choosing', + states: { + choosing: decide({ + model: 'test', + adapter: stateAdapter, // overrides machine adapter + prompt: 'pick', + options: { state: { description: 'S' }, machine: { description: 'M' } }, + onDone: ({ result }) => ({ + target: 'done', + context: { choice: result.choice }, + }), + }), + done: { type: 'final' }, + }, + }); + + const state = await createInitialState(machine, undefined); + const result = await run(machine, state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.context.choice).toBe('state'); // used state adapter, not machine + } }); - expect(observation.sessionId).toEqual(agent.sessionId); + test('supports reasoning', async () => { + const adapter: AgentAdapter = { + decide: async () => ({ + choice: 'a', + data: {}, + reasoning: 'Because reasons', + }), + }; - expect(agent.getObservations()).toContainEqual( - expect.objectContaining({ - prevState: { value: 'playing', context: {} }, - event: { type: 'play', position: 3 }, - state: { value: 'lost', context: {} }, - machineHash: expect.any(String), - sessionId: expect.any(String), - timestamp: expect.any(Number), - }) - ); + const machine = createAgentMachine({ + id: 'reasoning-test', + context: () => ({ reasoning: null as string | null }), + adapter, + initial: 'choosing', + states: { + choosing: decide({ + model: 'test', + prompt: 'pick', + reasoning: true, + options: { a: { description: 'A' } }, + onDone: ({ result }) => ({ + target: 'done', + context: { reasoning: result.reasoning ?? null }, + }), + }), + done: { type: 'final' }, + }, + }); + + const state = await createInitialState(machine, undefined); + const result = await run(machine, state); + if (result.status === 'done') { + expect(result.context.reasoning).toBe('Because reasons'); + } + }); + + test('decide with option schemas passes data', async () => { + const adapter: AgentAdapter = { + decide: async () => ({ + choice: 'withData', + data: { items: ['a', 'b'] }, + }), + }; + + const machine = createAgentMachine({ + id: 'data-test', + context: () => ({ items: null as string[] | null }), + adapter, + initial: 'choosing', + states: { + choosing: decide({ + model: 'test', + prompt: 'pick', + options: { + withData: { + description: 'Has data', + schema: z.object({ items: z.array(z.string()) }), + }, + withoutData: { description: 'No data' }, + }, + onDone: ({ result }) => ({ + target: 'done', + context: { + items: result.choice === 'withData' ? (result.data as any).items : null, + }, + }), + }), + done: { type: 'final' }, + }, + }); + + const state = await createInitialState(machine, undefined); + const result = await run(machine, state); + if (result.status === 'done') { + expect(result.context.items).toEqual(['a', 'b']); + } + }); }); -test('agent.interact() observes machine actors (no 2nd arg)', () => { - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { NEXT: 'b' }, +describe('classify', () => { + test('creates state config with classify type', () => { + const config = classify({ + model: 'test', + prompt: 'classify this', + into: { + a: { description: 'Category A' }, + b: { description: 'Category B' }, }, - b: {}, - }, + onDone: ({ result }) => ({ target: result.category }), + }); + expect(config.__type).toBe('classify'); + expect(config.__classifyConfig).toBeDefined(); + expect(config.__decideConfig).toBeDefined(); // classify wraps decide }); - const agent = createAgent({ - name: 'test', - events: {}, - model: {} as any, + test('result has category field', async () => { + const adapter = mockAdapter([{ choice: 'billing' }]); + const machine = createClassifyMachine(adapter); + const state = await createInitialState(machine, undefined); + + const result = await run(machine, state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ category: 'billing' }); + } }); +}); - const actor = createActor(machine); +describe('nested states', () => { + test('enters compound state initial child', async () => { + const machine = createNestedMachine(); + const state = await createInitialState(machine, undefined); + expect(state.value).toBe('handling.checkEligibility'); + }); - agent.interact(actor); + test('conditional compound initial based on context', async () => { + const machine = createAgentMachine({ + id: 'cond-nested', + context: () => ({ category: 'technical' as string }), + initial: 'handling', + states: { + handling: { + initial: ({ context }) => { + if (context.category === 'billing') return { target: 'billing' }; + return { target: 'technical' }; + }, + states: { + billing: { + run: async () => ({ result: 'billing handled' }), + onDone: () => ({ target: 'childDone' }), + }, + technical: { + run: async () => ({ result: 'tech handled' }), + onDone: ({ result }) => ({ + target: 'childDone', + context: { resolution: (result as any).result }, + }), + }, + childDone: { type: 'final' }, + }, + onDone: () => ({ target: 'done' }), + }, + done: { + type: 'final', + output: ({ context }) => ({ resolution: context.resolution }), + }, + }, + }); - actor.start(); + const state = await createInitialState(machine, undefined); + expect(state.value).toBe('handling.technical'); - expect(agent.getObservations()).toContainEqual( - expect.objectContaining({ - prevState: undefined, - state: expect.objectContaining({ value: 'a' }), - }) - ); - expect(agent.getObservations()).toContainEqual( - expect.objectContaining({ - prevState: undefined, - state: expect.objectContaining({ value: 'a' }), - }) - ); + const result = await run(machine, state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ resolution: 'tech handled' }); + } + }); - actor.send({ type: 'NEXT' }); + test('parent onDone fires when child reaches final', async () => { + const machine = createNestedMachine(); + const state = await createInitialState(machine, undefined); - expect(agent.getObservations()).toContainEqual( - expect.objectContaining({ - prevState: expect.objectContaining({ value: 'a' }), - event: { type: 'NEXT' }, - state: expect.objectContaining({ value: 'b' }), - }) - ); + const result = await run(machine, state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + // The chain: checkEligibility → processRefund → childDone → (parent onDone) → respond → done + expect(result.output).toEqual({ resolution: 'Refund processed' }); + } + }); + + test('parent event handler preempts children', async () => { + const machine = createNestedMachine(); + const state = await createInitialState(machine, undefined); + expect(state.value).toBe('handling.checkEligibility'); + + const next = sendEvent(machine, state, { type: 'user.cancel' }); + expect(next.value).toBe('cancelled'); + expect(next.status).toBe('running'); + + const result = await run(machine, next); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ cancelled: true }); + } + }); }); -test('You can listen for feedback events', () => { - const fn = vi.fn(); - const agent = createAgent({ - name: 'test', - events: {}, - model: {} as any, +describe('full workflow: HITL', () => { + test('gather → process → review → done', async () => { + const machine = createHitlMachine(); + + // Start + let state = await createInitialState(machine, { task: 'build feature' }); + let result = await run(machine, state); + expect(result.status).toBe('waiting'); + expect(result.status === 'waiting' && result.value).toBe('gathering'); + + // Send messages + state = sendEvent(machine, result.state, { type: 'user.message', message: 'req A' }); + state = sendEvent(machine, state, { type: 'user.message', message: 'req B' }); + + // Approve to move to processing + state = sendEvent(machine, state, { type: 'user.approve' }); + result = await run(machine, state); + expect(result.status).toBe('waiting'); + expect(result.status === 'waiting' && result.value).toBe('reviewing'); + expect(result.status === 'waiting' && result.context.result).toBe('Processed: req A, req B'); + + // Approve the review + state = sendEvent(machine, result.state, { type: 'user.approve' }); + result = await run(machine, state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ result: 'Processed: req A, req B' }); + } }); - agent.on('feedback', fn); + test('gather → process → review → reject → process → review → done', async () => { + const machine = createHitlMachine(); - agent.addFeedback({ - attributes: { - score: -1, - }, - goal: 'Win the game', - observationId: 'obs-1', + let state = await createInitialState(machine, { task: 'write code' }); + let result = await run(machine, state); + + // Send a message + state = sendEvent(machine, result.state, { type: 'user.message', message: 'initial' }); + state = sendEvent(machine, state, { type: 'user.approve' }); + result = await run(machine, state); + expect(result.status === 'waiting' && result.value).toBe('reviewing'); + + // Reject with feedback (sends us back to processing) + state = sendEvent(machine, result.state, { type: 'user.message', message: 'fix this' }); + result = await run(machine, state); + expect(result.status === 'waiting' && result.value).toBe('reviewing'); + expect(result.status === 'waiting' && result.context.result).toBe('Processed: initial, fix this'); + + // Approve + state = sendEvent(machine, result.state, { type: 'user.approve' }); + result = await run(machine, state); + expect(result.status).toBe('done'); }); - expect(fn).toHaveBeenCalled(); + test('cancel at any point', async () => { + const machine = createHitlMachine(); + + let state = await createInitialState(machine, { task: 'test' }); + let result = await run(machine, state); + + state = sendEvent(machine, result.state, { type: 'user.cancel' }); + result = await run(machine, state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ cancelled: true }); + } + }); }); -test('You can listen for plan events', async () => { - const fn = vi.fn(); - const model = new MockLanguageModelV1({ - doGenerate: async (params: LanguageModelV1CallOptions) => { - const keys = - params.mode.type === 'regular' - ? params.mode.tools?.map((t) => t.name) - : []; +describe('serialization', () => { + test('state round-trips through JSON', async () => { + const machine = createHitlMachine(); + let state = await createInitialState(machine, { task: 'test' }); + let result = await run(machine, state); - return { - ...dummyResponseValues, - finishReason: 'tool-calls', - toolCalls: [ - { - toolCallType: 'function', - toolCallId: 'call-1', - toolName: keys![0], - args: `{ "type": "${keys?.[0]}" }`, - }, - ], - } as any; - }, + // Serialize → deserialize + const json = JSON.stringify(result.state); + const restored = JSON.parse(json); + + // Send event on restored state + const next = sendEvent(machine, restored, { + type: 'user.message', + message: 'from restored', + }); + expect((next.context.messages as any[])[0].content).toBe('from restored'); }); - const agent = createAgent({ - name: 'test', - model, - events: { - WIN: z.object({}), - }, + test('nested state round-trips through JSON', async () => { + const machine = createNestedMachine(); + const state = await createInitialState(machine, undefined); + + const json = JSON.stringify(state); + const restored = JSON.parse(json); + + expect(restored.value).toBe('handling.checkEligibility'); + + // Can continue execution from restored state + const result = await run(machine, restored); + expect(result.status).toBe('done'); }); +}); - agent.on('plan', fn); +describe('createAdapter', () => { + test('creates a custom adapter', () => { + const adapter = createAdapter({ + decide: async () => ({ choice: 'a', data: {} }), + }); + expect(adapter.decide).toBeDefined(); + }); +}); - await agent.decide({ - goal: 'Win the game', - state: { - value: 'playing', - context: {}, - }, - machine: createMachine({ - initial: 'playing', +describe('edge cases', () => { + test('state with run but no onDone and no on is a dead end', async () => { + const machine = createAgentMachine({ + id: 'dead-end', + context: () => ({}), + initial: 'stuck', states: { - playing: { - on: { - WIN: { - target: 'won', + stuck: { + run: async () => ({ done: true }), + }, + }, + }); + const state = await createInitialState(machine, undefined); + const result = await step(machine, state); + // run completes but no onDone and no on → state doesn't change + expect(result.value).toBe('stuck'); + }); + + test('already done state returns as-is', async () => { + const machine = createSimpleMachine(); + const doneState = { + value: 'done', + params: {}, + context: { count: 1 }, + status: 'done' as const, + output: { result: 1 }, + }; + const result = await step(machine, doneState); + expect(result).toEqual(doneState); + }); + + test('already errored state returns as-is', async () => { + const machine = createSimpleMachine(); + const errorState = { + value: 'running', + params: {}, + context: { count: 0 }, + status: 'error' as const, + error: 'something went wrong', + }; + const result = await step(machine, errorState); + expect(result).toEqual(errorState); + }); +}); + +describe('P1: nested final state without parent onDone', () => { + test('does not mark machine as done when parent lacks onDone', async () => { + // a.b.c where c is final, b has NO onDone, a has onDone + const machine = createAgentMachine({ + id: 'p1-bug', + context: () => ({ resolved: false }), + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + // NO onDone — should halt here, not mark machine done + states: { + c: { type: 'final' }, + }, }, }, + onDone: () => ({ + target: 'result', + context: { resolved: true }, + }), + }, + result: { + type: 'final', + output: ({ context }) => ({ resolved: context.resolved }), }, - won: {}, }, - }), + }); + + const state = await createInitialState(machine, undefined); + expect(state.value).toBe('a.b.c'); + + // Step: c is final, parent b has no onDone → should wait, NOT done + const next = await step(machine, state); + expect(next.status).toBe('waiting'); + expect(next.value).toBe('a.b.c'); // stays put }); - expect(fn).toHaveBeenCalledWith( - expect.objectContaining({ - plan: expect.objectContaining({ - nextEvent: { - type: 'WIN', + test('correctly bubbles when parent has onDone', async () => { + // Same structure but b HAS onDone → bDone(final) → a.onDone → result + const machine = createAgentMachine({ + id: 'p1-fixed', + context: () => ({}), + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { + c: { type: 'final' }, + }, + onDone: () => ({ target: 'bDone' }), + }, + bDone: { type: 'final' }, + }, + onDone: () => ({ target: 'result' }), }, - }), - }) - ); + result: { + type: 'final', + output: () => ({ ok: true }), + }, + }, + }); + + const state = await createInitialState(machine, undefined); + const result = await run(machine, state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ ok: true }); + } + }); + + test('ancestor on handlers still work when halted at final child', async () => { + const machine = createAgentMachine({ + id: 'p1-escape', + context: () => ({}), + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { c: { type: 'final' } }, + // no onDone + }, + }, + on: { + escape: () => ({ target: 'escaped' }), + }, + }, + escaped: { + type: 'final', + output: () => ({ escaped: true }), + }, + }, + }); + + const state = await createInitialState(machine, undefined); + let result = await run(machine, state); + expect(result.status).toBe('waiting'); // halted at a.b.c + + // Ancestor on handler should still be reachable + const next = sendEvent(machine, result.state, { type: 'escape' }); + expect(next.value).toBe('escaped'); + result = await run(machine, next); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ escaped: true }); + } + }); }); -test('agent.types provides context and event types', () => { - const agent = createAgent({ - model: {} as any, - events: { - setScore: z.object({ - score: z.number(), - }), - }, - context: { - score: z.number(), - }, +describe('P2: event payload validation', () => { + test('rejects event with invalid payload', async () => { + const machine = createHitlMachine(); + let state = await createInitialState(machine, { task: 'test' }); + state = await step(machine, state); // → waiting + + // user.message schema requires { message: string } + // Sending wrong type should throw + expect(() => + sendEvent(machine, state, { type: 'user.message', message: 123 as any }) + ).toThrow(); + }); + + test('accepts event with valid payload', async () => { + const machine = createHitlMachine(); + let state = await createInitialState(machine, { task: 'test' }); + state = await step(machine, state); + + // Should not throw + const next = sendEvent(machine, state, { + type: 'user.message', + message: 'valid string', + }); + expect((next.context.messages as any[]).length).toBe(1); }); - agent.types satisfies { context: any; events: any }; + test('skips validation when no schema declared', async () => { + const machine = createSimpleMachine(); + const state = await createInitialState(machine, undefined); + + // 'start' event has no schema — should not throw + const next = sendEvent(machine, state, { type: 'start' }); + expect(next.value).toBe('running'); + }); + + test('state-level schema overrides root-level', async () => { + const machine = createAgentMachine({ + id: 'schema-override', + context: () => ({ val: '' }), + events: { + act: z.object({ type: z.literal('act'), val: z.string() }), + }, + initial: 'a', + states: { + a: { + events: { + // Override: requires val to be a number + act: z.object({ type: z.literal('act'), val: z.number() }), + }, + on: { + act: ({ event }) => ({ + target: 'b', + context: { val: String((event as any).val) }, + }), + }, + }, + b: { type: 'final' }, + }, + }); + + const state = await createInitialState(machine, undefined); - agent.types.context satisfies { score: number }; + // String val should fail (state schema requires number) + expect(() => + sendEvent(machine, state, { type: 'act', val: 'nope' }) + ).toThrow(); - // @ts-expect-error - agent.types.context satisfies { score: string }; + // Number val should succeed + const next = sendEvent(machine, state, { type: 'act', val: 42 }); + expect(next.value).toBe('b'); + }); }); diff --git a/src/agent.ts b/src/agent.ts deleted file mode 100644 index 74f3a45..0000000 --- a/src/agent.ts +++ /dev/null @@ -1,687 +0,0 @@ -import { - Actor, - AnyActorRef, - AnyEventObject, - AnyStateMachine, - EventObject, - fromTransition, - Subscription, -} from 'xstate'; -import { ZodContextMapping, ZodEventMapping } from './schemas'; -import { - AgentLogic, - AgentMessage, - AgentPlanner, - EventsFromZodEventMapping, - GenerateTextOptions, - AgentLongTermMemory, - ObservedState, - AgentObservationInput, - AgentMemoryContext, - AgentObservation, - ContextFromZodContextMapping, - AgentFeedback, - AgentMessageInput, - AgentFeedbackInput, - AgentPlan, - AnyAgent, - Compute, - AgentDecisionInput, - AgentDecideOptions, -} from './types'; -import { simplePlanner } from './planners/simplePlanner'; -import { agentDecide } from './decision'; -import { getMachineHash, randomId } from './utils'; -import { - experimental_wrapLanguageModel, - LanguageModel, - LanguageModelV1, -} from 'ai'; -import { createAgentMiddleware } from './middleware'; - -export const agentLogic: AgentLogic = fromTransition( - (state, event, { emit }) => { - switch (event.type) { - case 'agent.feedback': { - state.feedback.push(event.feedback); - emit({ - type: 'feedback', - // @ts-ignore TODO: fix types in XState - feedback: event.feedback, - }); - break; - } - case 'agent.observe': { - state.observations.push(event.observation); - emit({ - type: 'observation', - // @ts-ignore TODO: fix types in XState - observation: event.observation, - }); - break; - } - case 'agent.message': { - state.messages.push(event.message); - emit({ - type: 'message', - // @ts-ignore TODO: fix types in XState - message: event.message, - }); - break; - } - case 'agent.plan': { - state.plans.push(event.plan); - emit({ - type: 'plan', - // @ts-ignore TODO: fix types in XState - plan: event.plan, - }); - break; - } - default: - break; - } - return state; - }, - () => - ({ - feedback: [], - messages: [], - observations: [], - plans: [], - } as AgentMemoryContext) -); - -export function createAgent< - const TContextSchema extends ZodContextMapping, - const TEventSchemas extends ZodEventMapping, - TEvents extends EventObject = EventsFromZodEventMapping, - TContext = ContextFromZodContextMapping ->({ - id, - name, - description, - model, - events, - context, - planner = simplePlanner as AgentPlanner>, - stringify = JSON.stringify, - getMemory, - logic = agentLogic as AgentLogic, - ...generateTextOptions -}: { - /** - * The unique identifier for the agent. - * - * This should be the same across all sessions of a specific agent, as it can be - * used to retrieve memory for this agent. - * - * @example - * ```ts - * const agent = createAgent({ - * id: 'recipe-assistant', - * // ... - * }); - * ``` - */ - id?: string; - /** - * The name of the agent - */ - name?: string; - /** - * A description of the role of the agent - */ - description?: string; - /** - * Events that the agent can cause (send) in an environment - * that the agent knows about. - */ - events: TEventSchemas; - context?: TContextSchema; - planner?: AgentPlanner>; - stringify?: typeof JSON.stringify; - /** - * A function that retrieves the agent's long term memory - */ - getMemory?: ( - agent: Agent - ) => AgentLongTermMemory; - /** - * Agent logic - */ - logic?: AgentLogic; -} & GenerateTextOptions): Agent { - return new Agent({ - id, - context, - events, - name, - description, - planner, - model, - logic, - }) as any; - // const agent = createActor(logic) as unknown as Agent; - // agent.events = events; - // agent.model = model; - // agent.name = name; - // agent.description = description; - // agent.defaultOptions = { ...generateTextOptions, model }; - // agent.memory = getMemory ? getMemory(agent) : undefined; - - // agent.onMessage = (callback) => { - // agent.on('message', (ev) => callback(ev.message)); - // }; - - // agent.decide = (opts) => { - // return agentDecide(agent, opts); - // }; - - // agent.addMessage = (messageInput) => { - // const message = { - // ...messageInput, - // id: messageInput.id ?? randomId(), - // timestamp: messageInput.timestamp ?? Date.now(), - // sessionId: agent.sessionId, - // } satisfies AgentMessage; - // agent.send({ - // type: 'agent.message', - // message, - // }); - - // return message; - // }; - // agent.getMessages = () => agent.getSnapshot().context.messages; - - // agent.addFeedback = (feedbackInput) => { - // const feedback = { - // ...feedbackInput, - // attributes: { ...feedbackInput.attributes }, - // reward: feedbackInput.reward ?? 0, - // timestamp: feedbackInput.timestamp ?? Date.now(), - // sessionId: agent.sessionId, - // } satisfies AgentFeedback; - // agent.send({ - // type: 'agent.feedback', - // feedback, - // }); - // return feedback; - // }; - // agent.getFeedback = () => agent.getSnapshot().context.feedback; - - // agent.addObservation = (observationInput) => { - // const { prevState, event, state } = observationInput; - // const observedState = { context: state.context, value: state.value }; - // const observedPrevState = prevState - // ? { - // context: prevState.context, - // value: prevState.value, - // } - // : undefined; - // const observation = { - // prevState: observedPrevState, - // event, - // state: observedState, - // id: observationInput.id ?? randomId(), - // sessionId: agent.sessionId, - // timestamp: observationInput.timestamp ?? Date.now(), - // machineHash: observationInput.machine - // ? getMachineHash(observationInput.machine) - // : undefined, - // } satisfies AgentObservation; - - // agent.send({ - // type: 'agent.observe', - // observation, - // }); - - // return observation; - // }; - // agent.getObservations = () => agent.getSnapshot().context.observations; - - // agent.addPlan = (plan) => { - // agent.send({ - // type: 'agent.plan', - // plan, - // }); - // }; - // agent.getPlans = () => agent.getSnapshot().context.plans; - - // agent.interact = ((actorRef, getInput) => { - // let prevState: ObservedState | undefined = undefined; - // let subscribed = true; - - // async function handleObservation(observationInput: AgentObservationInput) { - // const observation = agent.addObservation(observationInput); - - // const input = getInput?.(observation); - - // if (input) { - // await agentDecide(agent, { - // machine: actorRef.src as AnyStateMachine, - // state: observation.state, - // execute: async (event) => { - // actorRef.send(event); - // }, - // ...input, - // }); - // } - - // prevState = observationInput.state; - // } - - // // Inspect system, but only observe specified actor - // const sub = actorRef.system.inspect({ - // next: async (inspEvent) => { - // if ( - // !subscribed || - // inspEvent.actorRef !== actorRef || - // inspEvent.type !== '@xstate.snapshot' - // ) { - // return; - // } - - // const observationInput = { - // event: inspEvent.event, - // prevState, - // state: inspEvent.snapshot as any, - // machine: (actorRef as any).src, - // } satisfies AgentObservationInput; - - // await handleObservation(observationInput); - // }, - // }); - - // // If actor already started, interact with current state - // if ((actorRef as any)._processingStatus === 1) { - // handleObservation({ - // prevState: undefined, - // event: { type: '' }, // TODO: unknown events? - // state: actorRef.getSnapshot(), - // machine: (actorRef as any).src, - // }); - // } - - // return { - // unsubscribe: () => { - // sub.unsubscribe(); - // subscribed = false; - // }, - // }; - // }) as typeof agent.interact; - - // agent.observe = (actorRef) => { - // let prevState: ObservedState = actorRef.getSnapshot(); - - // const sub = actorRef.system.inspect({ - // next: async (inspEvent) => { - // if ( - // inspEvent.actorRef !== actorRef || - // inspEvent.type !== '@xstate.snapshot' - // ) { - // return; - // } - - // const observationInput = { - // event: inspEvent.event, - // prevState, - // state: inspEvent.snapshot as any, - // machine: (actorRef as any).src, - // } satisfies AgentObservationInput; - - // prevState = observationInput.state; - - // agent.addObservation(observationInput); - // }, - // }); - - // return sub; - // }; - - // agent.types = {} as any; - - // agent.wrap = (modelToWrap) => - // experimental_wrapLanguageModel({ - // model: modelToWrap, - // middleware: createAgentMiddleware(agent), - // }); - - // agent.model = experimental_wrapLanguageModel({ - // model, - // middleware: createAgentMiddleware(agent), - // }); - - // agent.start(); - - // return agent; -} - -export class Agent< - const TContextSchema extends ZodContextMapping, - const TEventSchemas extends ZodEventMapping, - TEvents extends EventObject = EventsFromZodEventMapping, - TContext = ContextFromZodContextMapping -> extends Actor> { - /** - * The name of the agent. All agents with the same name are related and - * able to share experiences (observations, feedback) with each other. - */ - public name?: string; - /** - * The unique identifier for the agent. - */ - public id: string; - public description?: string; - public events: TEventSchemas; - public context?: TContextSchema; - public planner?: AgentPlanner>; - public types: { - events: TEvents; - context: Compute; - }; - public model: LanguageModel; - public memory: AgentLongTermMemory | undefined; - public defaultOptions: any; // todo - - constructor({ - logic = agentLogic as AgentLogic, - id, - name, - description, - model, - events, - context, - planner = simplePlanner, - }: { - logic: AgentLogic; - id?: string; - name?: string; - description?: string; - model: GenerateTextOptions['model']; - events: TEventSchemas; - context?: TContextSchema; - planner?: AgentPlanner>; - }) { - super(logic); - this.model = model; - this.id = id ?? ''; - this.name = name; - this.description = description; - this.events = events; - this.context = context; - this.planner = planner; - this.types = {} as any; - - this.start(); - } - - /** - * Called whenever the agent (LLM assistant) receives or sends a message. - */ - public onMessage(fn: (message: AgentMessage) => void) { - return this.on('message', (ev) => fn(ev.message)); - } - - /** - * Retrieves messages from the agent's short-term (local) memory. - */ - public addMessage(messageInput: AgentMessageInput) { - const message = { - ...messageInput, - id: messageInput.id ?? randomId(), - timestamp: messageInput.timestamp ?? Date.now(), - sessionId: this.sessionId, - } satisfies AgentMessage; - this.send({ - type: 'agent.message', - message, - }); - - return message; - } - - public getMessages() { - return this.getSnapshot().context.messages; - } - - public addFeedback(feedbackInput: AgentFeedbackInput) { - const feedback = { - ...feedbackInput, - attributes: { ...feedbackInput.attributes }, - reward: feedbackInput.reward ?? 0, - timestamp: feedbackInput.timestamp ?? Date.now(), - sessionId: this.sessionId, - } satisfies AgentFeedback; - this.send({ - type: 'agent.feedback', - feedback, - }); - return feedback; - } - - /** - * Retrieves feedback from the agent's short-term (local) memory. - */ - public getFeedback() { - return this.getSnapshot().context.feedback; - } - - public addObservation( - observationInput: AgentObservationInput - ): AgentObservation { - const { prevState, event, state } = observationInput; - const observedState = { context: state.context, value: state.value }; - const observedPrevState = prevState - ? { - context: prevState.context, - value: prevState.value, - } - : undefined; - const observation = { - prevState: observedPrevState, - event, - state: observedState, - id: observationInput.id ?? randomId(), - sessionId: this.sessionId, - timestamp: observationInput.timestamp ?? Date.now(), - machineHash: observationInput.machine - ? getMachineHash(observationInput.machine) - : undefined, - } satisfies AgentObservation; - - this.send({ - type: 'agent.observe', - observation, - }); - - return observation; - } - - /** - * Retrieves observations from the agent's short-term (local) memory. - */ - public getObservations() { - return this.getSnapshot().context.observations; - } - - public addPlan(plan: AgentPlan) { - this.send({ - type: 'agent.plan', - plan, - }); - } - /** - * Retrieves strategies from the agent's short-term (local) memory. - */ - public getPlans() { - return this.getSnapshot().context.plans; - } - - /** - * Interacts with this state machine actor by inspecting state transitions and storing them as observations. - * - * Observations contain the `prevState`, `event`, and current `state` of this - * actor, as well as other properties that are useful when recalled. - * These observations are stored in the `agent`'s short-term (local) memory - * and can be retrieved via `agent.getObservations()`. - * - * @example - * ```ts - * // Only observes the actor's state transitions - * agent.interact(actor); - * - * actor.start(); - * ``` - */ - public interact(actorRef: TActor): Subscription; - /** - * Interacts with this state machine actor by: - * 1. Inspecting state transitions and storing them as observations - * 2. Deciding what to do next (which event to send the actor) based on - * the agent input returned from `getInput(observation)`, if `getInput(…)` is provided as the 2nd argument. - * - * Observations contain the `prevState`, `event`, and current `state` of this - * actor, as well as other properties that are useful when recalled. - * These observations are stored in the `agent`'s short-term (local) memory - * and can be retrieved via `agent.getObservations()`. - * - * @example - * ```ts - * // Observes the actor's state transitions and - * // makes a decision if on the "summarize" state - * agent.interact(actor, observed => { - * if (observed.state.matches('summarize')) { - * return { - * context: observed.state.context, - * goal: 'Summarize the message' - * } - * } - * }); - * - * actor.start(); - * ``` - */ - public interact( - actorRef: TActor, - getInput: ( - observation: AgentObservation - ) => AgentDecisionInput | undefined - ): Subscription; - public interact( - actorRef: TActor, - getInput?: ( - observation: AgentObservation - ) => AgentDecisionInput | undefined - ): Subscription { - let prevState: ObservedState | undefined = undefined; - let subscribed = true; - - const agent = this; - - async function handleObservation(observationInput: AgentObservationInput) { - const observation = agent.addObservation(observationInput); - - const input = getInput?.(observation); - - if (input) { - await agentDecide(agent, { - machine: actorRef.src as AnyStateMachine, - state: observation.state, - execute: async (event) => { - actorRef.send(event); - }, - ...input, - }); - } - - prevState = observationInput.state; - } - - // Inspect system, but only observe specified actor - const sub = actorRef.system.inspect({ - next: async (inspEvent) => { - if ( - !subscribed || - inspEvent.actorRef !== actorRef || - inspEvent.type !== '@xstate.snapshot' - ) { - return; - } - - const observationInput = { - event: inspEvent.event, - prevState, - state: inspEvent.snapshot as any, - machine: (actorRef as any).src, - } satisfies AgentObservationInput; - - await handleObservation(observationInput); - }, - }); - - // If actor already started, interact with current state - if ((actorRef as any)._processingStatus === 1) { - handleObservation({ - prevState: undefined, - event: { type: '' }, // TODO: unknown events? - state: actorRef.getSnapshot(), - machine: (actorRef as any).src, - }); - } - - return { - unsubscribe: () => { - sub.unsubscribe(); - subscribed = false; - }, - }; - } - - public observe(actorRef: TActor) { - let prevState: ObservedState = actorRef.getSnapshot(); - - const sub = actorRef.system.inspect({ - next: async (inspEvent) => { - if ( - inspEvent.actorRef !== actorRef || - inspEvent.type !== '@xstate.snapshot' - ) { - return; - } - - const observationInput = { - event: inspEvent.event, - prevState, - state: inspEvent.snapshot as any, - machine: (actorRef as any).src, - } satisfies AgentObservationInput; - - prevState = observationInput.state; - - this.addObservation(observationInput); - }, - }); - - return sub; - } - - public wrap(modelToWrap: LanguageModelV1) { - return experimental_wrapLanguageModel({ - model: modelToWrap, - middleware: createAgentMiddleware(this), - }); - } - - /** - * Resolves with an `AgentPlan` based on the information provided in the `options`, including: - * - * - The `goal` for the agent to achieve - * - The observed current `state` - * - The `machine` (e.g. a state machine) that specifies what can happen next - * - Additional `context` - */ - public decide(opts: AgentDecideOptions) { - return agentDecide(this, opts); - } -} diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts new file mode 100644 index 0000000..1215ee2 --- /dev/null +++ b/src/ai-sdk/index.ts @@ -0,0 +1,93 @@ +import { generateObject } from 'ai'; +import { z } from 'zod'; +import type { AgentAdapter, StandardSchemaV1 } from '../types.js'; + +/** + * Create an adapter that uses the Vercel AI SDK for decide/classify. + * Model strings like 'anthropic/claude-sonnet-4.5' are resolved via the + * AI SDK's model registry. + */ +export function createAiSdkAdapter(): AgentAdapter { + return { + async decide({ model, prompt, options, reasoning }) { + // Build the discriminated union schema for options + const optionKeys = Object.keys(options); + + // Build per-option schemas + const optionSchemas: Record = {}; + for (const [key, opt] of Object.entries(options)) { + if (opt.schema) { + // Use the provided schema as the data shape + optionSchemas[key] = z.object({ + choice: z.literal(key), + data: toZodSchema(opt.schema), + ...(reasoning ? { reasoning: z.string().describe('Chain-of-thought reasoning for this decision') } : {}), + }); + } else { + optionSchemas[key] = z.object({ + choice: z.literal(key), + data: z.object({}), + ...(reasoning ? { reasoning: z.string().describe('Chain-of-thought reasoning for this decision') } : {}), + }); + } + } + + // Build the union schema + const schemas = optionKeys.map((k) => optionSchemas[k]!); + const schema = + schemas.length === 1 + ? schemas[0]! + : z.union(schemas as [z.ZodType, z.ZodType, ...z.ZodType[]]); + + // Build the system prompt with option descriptions + const optionDescriptions = Object.entries(options) + .map(([key, opt]) => `- ${key}: ${opt.description}`) + .join('\n'); + + const systemPrompt = `You must choose exactly one of the following options:\n${optionDescriptions}\n\nRespond with your choice and any required data.`; + + const result = await generateObject({ + model: resolveModel(model), + system: systemPrompt, + prompt, + schema, + }); + + const obj = result.object as { + choice: string; + data: Record; + reasoning?: string; + }; + + return { + choice: obj.choice, + data: obj.data ?? {}, + reasoning: obj.reasoning, + }; + }, + }; +} + +/** + * Convert a StandardSchemaV1 to a zod schema. + * If it's already a zod schema, return as-is. + * Otherwise, fall back to z.record for basic compatibility. + */ +function toZodSchema(schema: StandardSchemaV1): z.ZodType { + // Check if it's already a zod schema (has _zod property in v4) + if ('_zod' in schema || '_def' in schema) { + return schema as unknown as z.ZodType; + } + // Fallback: accept any object + return z.record(z.string(), z.unknown()); +} + +/** + * Resolve a model string to an AI SDK model. + * Supports the `provider/model` format via the AI SDK registry. + */ +function resolveModel(model: string): Parameters[0]['model'] { + // The AI SDK accepts model strings when using a provider registry. + // For now, return as-is — users configure their provider registry externally. + return model as any; +} diff --git a/src/classify.ts b/src/classify.ts new file mode 100644 index 0000000..78590ba --- /dev/null +++ b/src/classify.ts @@ -0,0 +1,32 @@ +import type { ClassifyConfig, StateConfig } from './types.js'; + +/** + * Create a classification state. Sugar over `decide` for simple routing — + * categories with descriptions, no per-option schemas. + */ +export function classify(config: ClassifyConfig): StateConfig { + // Convert classify categories into decide options + const decideOptions: Record = {}; + for (const [key, val] of Object.entries(config.into)) { + decideOptions[key] = { description: val.description }; + } + + return { + __type: 'classify', + __classifyConfig: config, + __decideConfig: { + model: config.model, + adapter: config.adapter, + prompt: config.prompt, + options: decideOptions, + onDone: ({ result, context }) => { + // Transform decide result → classify result + return config.onDone({ + result: { category: result.choice }, + context, + }); + }, + }, + on: config.on, + }; +} diff --git a/src/decide.ts b/src/decide.ts new file mode 100644 index 0000000..cdc84aa --- /dev/null +++ b/src/decide.ts @@ -0,0 +1,13 @@ +import type { DecideConfig, StateConfig } from './types.js'; + +/** + * Create a decision state where an LLM picks from constrained options. + * Each option has a description and optional schema for structured data. + */ +export function decide(config: DecideConfig): StateConfig { + return { + __type: 'decide', + __decideConfig: config, + on: config.on, + }; +} diff --git a/src/decision.test.ts b/src/decision.test.ts deleted file mode 100644 index 5a768b3..0000000 --- a/src/decision.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { test, expect } from 'vitest'; -import { createAgent, fromDecision } from '.'; -import { createActor, createMachine, waitFor } from 'xstate'; -import { z } from 'zod'; -import { LanguageModelV1CallOptions } from 'ai'; -import { dummyResponseValues, MockLanguageModelV1 } from './mockModel'; - -const doGenerate = async (params: LanguageModelV1CallOptions) => { - const keys = - params.mode.type === 'regular' ? params.mode.tools?.map((t) => t.name) : []; - - return { - ...dummyResponseValues, - finishReason: 'tool-calls', - toolCalls: [ - { - toolCallType: 'function', - toolCallId: 'call-1', - toolName: keys![0], - args: `{ "type": "${keys?.[0]}" }`, - }, - ], - } as any; -}; - -test('fromDecision() makes a decision', async () => { - const model = new MockLanguageModelV1({ - doGenerate, - }); - const agent = createAgent({ - name: 'test', - model, - events: { - doFirst: z.object({}), - doSecond: z.object({}), - }, - }); - - const machine = createMachine({ - initial: 'first', - states: { - first: { - invoke: { - src: fromDecision(agent), - }, - on: { - doFirst: 'second', - }, - }, - second: { - invoke: { - src: fromDecision(agent), - }, - on: { - doSecond: 'third', - }, - }, - third: {}, - }, - }); - - const actor = createActor(machine); - - actor.start(); - - await waitFor(actor, (s) => s.matches('third')); - - expect(actor.getSnapshot().value).toBe('third'); -}); - -test('interacts with an actor', async () => { - const model = new MockLanguageModelV1({ - doGenerate, - }); - const agent = createAgent({ - name: 'test', - model, - events: { - doFirst: z.object({}), - doSecond: z.object({}), - }, - }); - - const machine = createMachine({ - initial: 'first', - states: { - first: { - on: { - doFirst: 'second', - }, - }, - second: { - on: { - doSecond: 'third', - }, - }, - third: {}, - }, - }); - - const actor = createActor(machine); - - agent.interact(actor, () => ({ - goal: 'Some goal', - })); - - actor.start(); - - await waitFor(actor, (s) => s.matches('third')); - - expect(actor.getSnapshot().value).toBe('third'); -}); - -test('interacts with an actor (late interaction)', async () => { - const model = new MockLanguageModelV1({ - doGenerate, - }); - const agent = createAgent({ - name: 'test', - model, - events: { - doFirst: z.object({}), - doSecond: z.object({}), - }, - }); - - const machine = createMachine({ - initial: 'first', - states: { - first: { - on: { - doFirst: 'second', - }, - }, - second: { - on: { - doSecond: 'third', - }, - }, - third: {}, - }, - }); - - const actor = createActor(machine); - - actor.start(); - - agent.interact(actor, () => ({ - goal: 'Some goal', - })); - - await waitFor(actor, (s) => s.matches('third')); - - expect(actor.getSnapshot().value).toBe('third'); -}); diff --git a/src/decision.ts b/src/decision.ts deleted file mode 100644 index 7f93c94..0000000 --- a/src/decision.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { AnyActor, AnyMachineSnapshot, fromPromise } from 'xstate'; -import { - AnyAgent, - AgentDecideOptions, - AgentDecisionLogic, - AgentDecisionInput, - AgentPlanner, - AgentPlan, - EventsFromZodEventMapping, -} from './types'; -import { simplePlanner } from './planners/simplePlanner'; - -export async function agentDecide( - agent: T, - options: AgentDecideOptions -): Promise> | undefined> { - const resolvedOptions = { - ...agent.defaultOptions, - ...options, - }; - const { - planner = simplePlanner as AgentPlanner, - goal, - events = agent.events, - state, - machine, - model = agent.model, - ...otherPlanInput - } = resolvedOptions; - - const plan = await planner(agent, { - model, - goal, - events, - state, - machine, - ...otherPlanInput, - }); - - if (plan?.nextEvent) { - agent.addPlan(plan); - await resolvedOptions.execute?.(plan.nextEvent); - } - - return plan; -} - -export function fromDecision( - agent: AnyAgent, - defaultInput?: AgentDecisionInput -): AgentDecisionLogic { - return fromPromise(async ({ input, self }) => { - const parentRef = self._parent; - if (!parentRef) { - return; - } - - const snapshot = parentRef.getSnapshot() as AnyMachineSnapshot; - const inputObject = typeof input === 'string' ? { goal: input } : input; - const resolvedInput = { - ...defaultInput, - ...inputObject, - }; - const contextToInclude = - resolvedInput.context === true - ? // include entire context - parentRef.getSnapshot().context - : resolvedInput.context; - const state = { - value: snapshot.value, - context: contextToInclude, - }; - - const plan = await agentDecide(agent, { - machine: (parentRef as AnyActor).logic, - state, - execute: async (event) => { - parentRef.send(event); - }, - ...resolvedInput, - }); - - return plan; - }) as AgentDecisionLogic; -} diff --git a/src/event.ts b/src/event.ts new file mode 100644 index 0000000..151167d --- /dev/null +++ b/src/event.ts @@ -0,0 +1,107 @@ +import type { AgentEvent, AgentMachine, AgentState, StandardSchemaV1 } from './types.js'; +import { applyTransition, resolveStateConfig } from './utils.js'; + +/** + * Send a typed event to the current state. + * Validates the event payload against declared schemas, then searches from + * the current state up through ancestors for a matching handler. + * Parent handlers preempt children. + * + * Returns a new AgentState (synchronous — no async work). + */ +export function sendEvent( + machine: AgentMachine, + state: AgentState, + event: AgentEvent +): AgentState { + // Validate event payload against declared schemas + validateEventSync(machine, state.value, event); + + const parts = state.value.split('.'); + + // Walk from outermost to innermost for preemption semantics: + // parent `on` preempts children. + for (let i = 1; i <= parts.length; i++) { + const path = parts.slice(0, i).join('.'); + const config = resolveStateConfig(machine, path); + + if (config.on && config.on[event.type]) { + const handler = config.on[event.type]!; + const transition = handler({ context: state.context, event }); + + if (transition.target) { + return applyTransition(machine, state, transition, path); + } + + // Self-transition: update context, keep same state/status + return { + ...state, + context: transition.context + ? { ...state.context, ...transition.context } + : state.context, + }; + } + } + + throw new Error( + `No handler for event '${event.type}' in state '${state.value}'` + ); +} + +/** + * Validate event payload against the schema declared in state-level or + * root-level `events`. State events override root events. + * Uses synchronous validation — throws on invalid payload. + */ +function validateEventSync( + machine: AgentMachine, + value: string, + event: AgentEvent +): void { + const schema = findEventSchema(machine, value, event.type); + if (!schema) return; // no schema declared — skip validation + + const result = schema['~standard'].validate(event); + + // Handle sync result (most schema libs return sync for simple schemas) + if (result && typeof result === 'object' && 'issues' in result && result.issues) { + const messages = (result.issues as Array<{ message: string }>) + .map((i) => i.message) + .join(', '); + throw new Error( + `Invalid event '${event.type}': ${messages}` + ); + } + + // If validate returns a Promise, we can't block on it synchronously. + // For async schemas, users should validate before calling sendEvent. + if (result instanceof Promise) { + // Can't await in sync function — skip async validation. + // This is a known limitation; createInitialState handles async validation. + return; + } +} + +/** + * Find the event schema for a given event type. + * Walks from the current state up to root, with state-level schemas + * overriding root-level schemas. + */ +function findEventSchema( + machine: AgentMachine, + value: string, + eventType: string +): StandardSchemaV1 | undefined { + // Check state-level events (innermost wins for schemas) + const parts = value.split('.'); + for (let i = parts.length; i >= 1; i--) { + const path = parts.slice(0, i).join('.'); + const config = resolveStateConfig(machine, path); + if (config.events?.[eventType]) { + return config.events[eventType]; + } + } + + // Fall back to root-level events + return machine.events?.[eventType]; +} diff --git a/src/graph/index.ts b/src/graph/index.ts new file mode 100644 index 0000000..5e5554e --- /dev/null +++ b/src/graph/index.ts @@ -0,0 +1,33 @@ +import type { AgentMachine } from '../types.js'; + +export interface GraphNode { + id: string; + type: 'state' | 'decide' | 'classify' | 'final'; +} + +export interface GraphEdge { + source: string; + target: string; + label?: string; +} + +export interface Graph { + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +/** + * Convert an agent machine to a graph representation. + * TODO: implement AST analysis for edge extraction + */ +export function toGraph(_machine: AgentMachine): Graph { + throw new Error('toGraph is not yet implemented'); +} + +/** + * Convert an agent machine to a Mermaid stateDiagram-v2 string. + * TODO: implement + */ +export function toMermaid(_machine: AgentMachine): string { + throw new Error('toMermaid is not yet implemented'); +} diff --git a/src/index.ts b/src/index.ts index 8e2826a..4e5e097 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,37 @@ -export { createAgent } from './agent'; -export { fromText, fromTextStream } from './text'; -export { fromDecision } from './decision'; -export * from './types'; +// Core +export { createAgentMachine } from './machine.js'; +export { createInitialState } from './state.js'; +export { step } from './step.js'; +export { run } from './run.js'; +export { stream } from './stream.js'; +export { sendEvent } from './event.js'; + +// AI primitives +export { decide } from './decide.js'; +export { classify } from './classify.js'; + +// Adapter +export { createAdapter } from './adapter.js'; + +// Types +export type { + AgentAdapter, + AgentEvent, + AgentMachine, + AgentRunResult, + AgentSnapshot, + AgentState, + ClassifyConfig, + ClassifyResult, + DecideConfig, + DecideResult, + MachineConfig, + OnDoneArgs, + OutputArgs, + RunArgs, + StandardSchemaV1, + StateConfig, + Trace, + TransitionArgs, + TransitionResult, +} from './types.js'; diff --git a/src/machine.ts b/src/machine.ts new file mode 100644 index 0000000..2f07bbf --- /dev/null +++ b/src/machine.ts @@ -0,0 +1,9 @@ +import type { AgentMachine, MachineConfig } from './types.js'; + +/** + * Create an agent machine definition. + * The machine is a pure configuration object — no runtime state. + */ +export function createAgentMachine(config: MachineConfig): AgentMachine { + return config; +} diff --git a/src/memory.ts b/src/memory.ts deleted file mode 100644 index a6994ab..0000000 --- a/src/memory.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AgentMemory, AgentMemoryContext } from './types'; - -export function createAgentMemory(): AgentMemory { - const storage = { - sessions: {} as Record, - }; - - return { - append: async (sessionId, key, item) => { - storage.sessions[sessionId] = - storage.sessions[sessionId] || - ({ - observations: [], - messages: [], - plans: [], - feedback: [], - } satisfies AgentMemoryContext); - - storage.sessions[sessionId]![key].push(item as any); - }, - getAll: async (sessionId, key) => { - return storage.sessions[sessionId]?.[key]; - }, - }; -} diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index ff1713c..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - Experimental_LanguageModelV1Middleware as LanguageModelV1Middleware, - LanguageModelV1StreamPart, -} from 'ai'; -import { - AnyAgent, - LanguageModelV1TextPart, - LanguageModelV1ToolCallPart, -} from './types'; -import { randomId } from './utils'; - -export function createAgentMiddleware(agent: AnyAgent) { - const middleware: LanguageModelV1Middleware = { - transformParams: async ({ params }) => { - return params; - }, - wrapGenerate: async ({ doGenerate, params }) => { - const id = randomId(); - - params.prompt.forEach((p) => { - agent.addMessage({ - id, - ...p, - timestamp: Date.now(), - correlationId: params.providerMetadata - ?.correlationId as unknown as string, - parentCorrelationId: params.providerMetadata - ?.parentCorrelationId as unknown as string, - }); - }); - - const result = await doGenerate(); - - return result; - }, - - wrapStream: async ({ doStream, params }) => { - const id = randomId(); - - params.prompt.forEach((message) => { - message.content; - agent.addMessage({ - id, - ...message, - timestamp: Date.now(), - correlationId: params.providerMetadata - ?.correlationId as unknown as string, - parentCorrelationId: params.providerMetadata - ?.parentCorrelationId as unknown as string, - }); - }); - - const { stream, ...rest } = await doStream(); - - let generatedText = ''; - - const transformStream = new TransformStream< - LanguageModelV1StreamPart, - LanguageModelV1StreamPart - >({ - transform(chunk, controller) { - if (chunk.type === 'text-delta') { - generatedText += chunk.textDelta; - } - - controller.enqueue(chunk); - }, - - flush() { - const content: ( - | LanguageModelV1TextPart - | LanguageModelV1ToolCallPart - )[] = []; - - if (generatedText) { - content.push({ - type: 'text', - text: generatedText, - }); - } - - agent.addMessage({ - id: randomId(), - timestamp: Date.now(), - role: 'assistant', - content, - responseId: id, - correlationId: params.providerMetadata - ?.correlationId as unknown as string, - parentCorrelationId: params.providerMetadata - ?.parentCorrelationId as unknown as string, - }); - }, - }); - - return { - stream: stream.pipeThrough(transformStream), - ...rest, - }; - }, - }; - return middleware; -} diff --git a/src/mockModel.ts b/src/mockModel.ts deleted file mode 100644 index 653bd38..0000000 --- a/src/mockModel.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { LanguageModelV1 } from 'ai'; - -export class MockLanguageModelV1 implements LanguageModelV1 { - readonly specificationVersion = 'v1'; - - readonly provider: LanguageModelV1['provider']; - readonly modelId: LanguageModelV1['modelId']; - - doGenerate: LanguageModelV1['doGenerate']; - doStream: LanguageModelV1['doStream']; - - readonly defaultObjectGenerationMode: LanguageModelV1['defaultObjectGenerationMode']; - readonly supportsStructuredOutputs: LanguageModelV1['supportsStructuredOutputs']; - constructor({ - provider = 'mock-provider', - modelId = 'mock-model-id', - doGenerate = notImplemented, - doStream = notImplemented, - defaultObjectGenerationMode = undefined, - supportsStructuredOutputs = undefined, - }: { - provider?: LanguageModelV1['provider']; - modelId?: LanguageModelV1['modelId']; - doGenerate?: LanguageModelV1['doGenerate']; - doStream?: LanguageModelV1['doStream']; - defaultObjectGenerationMode?: LanguageModelV1['defaultObjectGenerationMode']; - supportsStructuredOutputs?: LanguageModelV1['supportsStructuredOutputs']; - } = {}) { - this.provider = provider; - this.modelId = modelId; - this.doGenerate = doGenerate; - this.doStream = doStream; - - this.defaultObjectGenerationMode = defaultObjectGenerationMode; - this.supportsStructuredOutputs = supportsStructuredOutputs; - } -} - -function notImplemented(): never { - throw new Error('Not implemented'); -} - -export const dummyResponseValues = { - rawCall: { rawPrompt: 'prompt', rawSettings: {} }, - finishReason: 'stop' as const, - usage: { promptTokens: 10, completionTokens: 20 }, -}; diff --git a/src/planners/shortestPathPlanner.ts b/src/planners/shortestPathPlanner.ts deleted file mode 100644 index 3abc85c..0000000 --- a/src/planners/shortestPathPlanner.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AgentPlan, AgentPlanInput, AnyAgent } from '../types'; -import { getShortestPaths } from '@xstate/graph'; - -export async function simplePlanner( - agent: T, - input: AgentPlanInput -): Promise | undefined> { - // 1. Determine goal state criteria - // e.g. a state where the agent has won a game - void 0; - - // 2. Determine possible events that can occur - void 0; - - // 3. Get shortest paths from current state to - // a state matching the criteria, using - // possible events - void 0; - - // 4. Return shortest path as a plan - return null as any; -} diff --git a/src/planners/simplePlanner.ts b/src/planners/simplePlanner.ts deleted file mode 100644 index bb2de97..0000000 --- a/src/planners/simplePlanner.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { CoreMessage, type CoreTool, generateText, tool } from 'ai'; -import { - AgentPlan, - AgentPlanInput, - ObservedState, - PromptTemplate, - TransitionData, - AnyAgent, -} from '../types'; -import { getAllTransitions, randomId } from '../utils'; -import { AnyStateMachine } from 'xstate'; -import { defaultTextTemplate } from '../templates/defaultText'; -import { getMessages } from '../text'; - -function getTransitions( - state: ObservedState, - machine: AnyStateMachine -): TransitionData[] { - if (!machine) { - return []; - } - - const resolvedState = machine.resolveState(state); - return getAllTransitions(resolvedState); -} - -const simplePlannerPromptTemplate: PromptTemplate = (data) => { - return ` -${defaultTextTemplate(data)} - -Make at most one tool call to achieve the above goal. If the goal cannot be achieved with any tool calls, do not make any tool call. - `.trim(); -}; - -export async function simplePlanner( - agent: T, - input: AgentPlanInput -): Promise | undefined> { - // Get all of the possible next transitions - const transitions: TransitionData[] = input.machine - ? getTransitions(input.state, input.machine) - : Object.entries(input.events).map(([eventType, { description }]) => ({ - eventType, - description, - })); - - // Only keep the transitions that match the event types that are in the event mapping - // TODO: allow for custom filters - const filter = (eventType: string) => - Object.keys(input.events).includes(eventType); - - // Mapping of each event type (e.g. "mouse.click") - // to a valid function name (e.g. "mouse_click") - const functionNameMapping: Record = {}; - - const toolTransitions = transitions - .filter((t) => { - return filter(t.eventType); - }) - .map((t) => { - const name = t.eventType.replace(/\./g, '_'); - functionNameMapping[name] = t.eventType; - - return { - type: 'function', - eventType: t.eventType, - description: t.description, - name, - } as const; - }); - - // Convert the transition data to a tool map that the - // Vercel AI SDK can use - const toolMap: Record> = {}; - for (const toolTransitionData of toolTransitions) { - const toolZodType = input.events?.[toolTransitionData.eventType]; - - if (!toolZodType) { - continue; - } - - toolMap[toolTransitionData.name] = tool({ - description: toolZodType?.description ?? toolTransitionData.description, - parameters: toolZodType, - execute: async (params: Record) => { - const event = { - type: toolTransitionData.eventType, - ...params, - }; - - return event; - }, - }); - } - - if (!Object.keys(toolMap).length) { - // No valid transitions for the specified tools - return undefined; - } - - // Create a prompt with the given context and goal. - // The template is used to ensure that a single tool call at most is made. - const prompt = simplePlannerPromptTemplate({ - context: input.state.context, - goal: input.goal, - }); - - const messages = await getMessages(agent, prompt, input); - - const model = input.model ? agent.wrap(input.model) : agent.model; - - const { - state, - machine, - previousPlan, - events, - goal, - model: _, - ...rest - } = input; - - const result = await generateText({ - // ...input, - ...rest, - model, - messages, - tools: toolMap as any, - toolChoice: input.toolChoice ?? 'required', - }); - - result.responseMessages.forEach((m) => { - const message: CoreMessage = m; - - agent.addMessage({ - ...message, - id: randomId(), - timestamp: Date.now(), - }); - }); - - const singleResult = result.toolResults[0]; - - if (!singleResult) { - // TODO: retries? - console.warn('No tool call results returned'); - return undefined; - } - - return { - goal: input.goal, - state: input.state, - execute: async (state) => { - if (JSON.stringify(state) === JSON.stringify(input.state)) { - return singleResult.result; - } - return undefined; - }, - nextEvent: singleResult.result, - sessionId: agent.sessionId, - timestamp: Date.now(), - }; -} diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 0000000..16590ce --- /dev/null +++ b/src/run.ts @@ -0,0 +1,51 @@ +import type { AgentMachine, AgentRunResult, AgentState } from './types.js'; +import { step } from './step.js'; +import { getAvailableEvents, resolveStateConfig } from './utils.js'; + +/** + * Run the machine until completion, waiting, or error. + * Loops `step()` while status is 'running'. + */ +export async function run( + machine: AgentMachine, + state: AgentState +): Promise { + let current = state; + + while (current.status === 'running') { + current = await step(machine, current); + } + + switch (current.status) { + case 'done': + return { + status: 'done', + state: current, + output: current.output, + context: current.context, + }; + + case 'waiting': + return { + status: 'waiting', + state: current, + value: current.value, + events: getAvailableEvents(machine, current.value), + context: current.context, + }; + + case 'error': + return { + status: 'error', + state: current, + error: current.error, + }; + + default: + return { + status: 'error', + state: current, + error: `Unexpected status: ${current.status}`, + }; + } +} diff --git a/src/schemas.ts b/src/schemas.ts deleted file mode 100644 index aefa24a..0000000 --- a/src/schemas.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ZodType, type SomeZodObject } from 'zod'; - -export type ZodEventMapping = { - // map event types to Zod types - [eventType: string]: SomeZodObject; -}; - -export type ZodContextMapping = { - // map context keys to Zod types - [contextKey: string]: ZodType; -}; diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..b9d595b --- /dev/null +++ b/src/state.ts @@ -0,0 +1,50 @@ +import type { AgentMachine, AgentState } from './types.js'; +import { + enterCompoundStates, + resolveInitial, + validateSchema, +} from './utils.js'; + +/** + * Create the initial serializable state for a machine + input. + * Validates input, initializes context, resolves the initial transition. + */ +export async function createInitialState( + machine: AgentMachine, + input: unknown +): Promise { + // Validate input if schema provided + let validatedInput = input; + if (machine.inputSchema) { + validatedInput = await validateSchema(machine.inputSchema, input); + } + + // Initialize context + const context = machine.context(validatedInput); + + // Resolve initial transition + const init = resolveInitial(machine.initial, { + context, + parentParams: {}, + }); + + if (!init.target) { + throw new Error('Initial transition must specify a target state'); + } + + let state: AgentState = { + value: init.target, + params: {}, + context: init.context ? { ...context, ...init.context } : context, + status: 'running', + }; + + if (init.params) { + state.params = { [init.target]: init.params }; + } + + // Enter compound states if needed + state = enterCompoundStates(machine, state); + + return state; +} diff --git a/src/step.ts b/src/step.ts new file mode 100644 index 0000000..a873ce6 --- /dev/null +++ b/src/step.ts @@ -0,0 +1,167 @@ +import type { AgentMachine, AgentState } from './types.js'; +import { + applyTransition, + getParentConfig, + getParentParams, + resolveStateConfig, +} from './utils.js'; + +/** + * Execute one state transition. + * + * - Final state → status 'done' (or bubble to parent onDone) + * - Decide/classify → call adapter, apply onDone + * - Run state → execute run, apply onDone + * - Waiting state (on, no run) → status 'waiting' + */ +export async function step( + machine: AgentMachine, + state: AgentState +): Promise { + if (state.status === 'done' || state.status === 'error') { + return state; + } + + const config = resolveStateConfig(machine, state.value); + + // ─── Final state ─── + if (config.type === 'final') { + return handleFinalState(machine, state); + } + + // ─── Decide / Classify state ─── + if (config.__decideConfig) { + return handleDecideState(machine, state); + } + + // ─── Run state ─── + if (config.run) { + return handleRunState(machine, state); + } + + // ─── Waiting state ─── + if (config.on) { + return { ...state, status: 'waiting' }; + } + + // ─── Compound state with no run (just initial + children) ─── + // This shouldn't normally happen since enterCompoundStates resolves on entry. + // But handle defensively. + if (config.states && config.initial) { + return { ...state, status: 'running' }; + } + + return { + ...state, + status: 'error', + error: `State '${state.value}' has no run, events, or children`, + }; +} + +async function handleFinalState( + machine: AgentMachine, + state: AgentState +): Promise { + const config = resolveStateConfig(machine, state.value); + + // Compute output + const output = config.output + ? config.output({ context: state.context }) + : undefined; + + const parts = state.value.split('.'); + + // Root-level final state → done + if (parts.length <= 1) { + return { ...state, status: 'done', output }; + } + + // Nested final state — check parent for onDone + const parentConfig = getParentConfig(machine, state.value); + if (parentConfig?.onDone) { + const parentPath = parts.slice(0, -1).join('.'); + const transition = parentConfig.onDone({ + result: output, + context: state.context, + }); + return applyTransition(machine, state, transition, parentPath); + } + + // Parent has no onDone — match xstate semantics: compound state is "done" + // but no transition fires. Machine halts here; ancestor on handlers can + // still match events via sendEvent. + return { ...state, status: 'waiting' }; +} + +async function handleDecideState( + machine: AgentMachine, + state: AgentState +): Promise { + const config = resolveStateConfig(machine, state.value); + const decideConfig = config.__decideConfig!; + + // Get adapter + const adapter = decideConfig.adapter ?? machine.adapter; + if (!adapter) { + return { + ...state, + status: 'error', + error: `No adapter configured for decide state '${state.value}'`, + }; + } + + // Resolve prompt + const parentParams = getParentParams(state); + const prompt = + typeof decideConfig.prompt === 'function' + ? decideConfig.prompt({ context: state.context, parentParams }) + : decideConfig.prompt; + + try { + const result = await adapter.decide({ + model: decideConfig.model, + prompt, + options: decideConfig.options, + reasoning: decideConfig.reasoning, + }); + + // Apply onDone + const transition = decideConfig.onDone({ + result, + context: state.context, + }); + return applyTransition(machine, state, transition, state.value); + } catch (error) { + return { ...state, status: 'error', error }; + } +} + +async function handleRunState( + machine: AgentMachine, + state: AgentState +): Promise { + const config = resolveStateConfig(machine, state.value); + + try { + const result = await config.run!({ + context: state.context, + parentParams: getParentParams(state), + }); + + if (config.onDone) { + const transition = config.onDone({ + result, + context: state.context, + }); + return applyTransition(machine, state, transition, state.value); + } + + // run with no onDone — stay in state, mark waiting if has events + if (config.on) { + return { ...state, status: 'waiting' }; + } + return state; + } catch (error) { + return { ...state, status: 'error', error }; + } +} diff --git a/src/strategies/chain-of-note.ts b/src/strategies/chain-of-note.ts deleted file mode 100644 index 877799c..0000000 --- a/src/strategies/chain-of-note.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { GenerateTextResult, LanguageModel } from 'ai'; -import wiki, { wikiSearchResult, wikiSummary } from 'wikipedia'; -import { assign, fromPromise, setup } from 'xstate'; -import { AnyAgent } from '../types'; - -const searchWiki = fromPromise( - async ({ - input, - }: { - input: { - query: string; - limit?: number; - }; - }) => { - const passages = await wiki.search(input.query, { - limit: input.limit ?? 5, - }); - return passages; - } -); - -const extractSummaries = fromPromise( - async ({ - input, - }: { - input: { - searchResult: wikiSearchResult; - }; - }) => { - const summaries = await Promise.all( - input.searchResult.results.map(async (result) => { - const summary = await wiki.summary(result.title); - return { - title: result.title, - summary, - }; - }) - ); - return summaries; - } -); - -export const chainOfNote = setup({ - types: { - input: {} as { - model: LanguageModel; - agent: AnyAgent; - prompt: string; - }, - context: {} as { - searchResults: wikiSearchResult | null; - summaries: - | { - title: any; - summary: wikiSummary; - }[] - | null; - model: LanguageModel; - agent: AnyAgent; - prompt: string; - }, - output: {} as GenerateTextResult, - }, - actors: { - searchWiki, - extractSummaries, - }, -}).createMachine({ - initial: 'searching', - context: ({ input }) => ({ - ...input, - searchResults: null, - summaries: null, - }), - states: { - searching: { - invoke: { - src: 'searchWiki', - input: ({ context }) => ({ - query: context.prompt, - }), - onDone: { - actions: assign({ - searchResults: ({ event }) => event.output, - }), - target: 'extracting', - }, - }, - }, - extracting: { - invoke: { - src: 'extractSummaries', - input: ({ context }) => ({ - searchResult: context.searchResults!, - }), - onDone: { - actions: assign({ - summaries: ({ event }) => event.output, - }), - target: 'generating', - }, - }, - }, - generating: {}, - }, -}); diff --git a/src/stream.ts b/src/stream.ts new file mode 100644 index 0000000..b7261aa --- /dev/null +++ b/src/stream.ts @@ -0,0 +1,29 @@ +import type { AgentMachine, AgentSnapshot, AgentState } from './types.js'; +import { step } from './step.js'; + +/** + * Yields a snapshot after each transition until completion, waiting, or error. + */ +export async function* stream( + machine: AgentMachine, + state: AgentState +): AsyncGenerator { + let current = state; + + // Yield initial snapshot + yield toSnapshot(current); + + while (current.status === 'running') { + current = await step(machine, current); + yield toSnapshot(current); + } +} + +function toSnapshot(state: AgentState): AgentSnapshot { + return { + value: state.value, + context: state.context, + status: state.status, + params: state.params, + }; +} diff --git a/src/templates/defaultText.ts b/src/templates/defaultText.ts deleted file mode 100644 index 2cb841b..0000000 --- a/src/templates/defaultText.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { PromptTemplate } from '../types'; -import { wrapInXml } from '../utils'; - -export const defaultTextTemplate: PromptTemplate = (data) => { - const preamble = [ - data.context - ? wrapInXml('context', JSON.stringify(data.context)) - : undefined, - ] - .filter(Boolean) - .join('\n'); - - return ` -${preamble} - -${data.goal} - `.trim(); -}; diff --git a/src/text.ts b/src/text.ts deleted file mode 100644 index 5f66b6e..0000000 --- a/src/text.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - generateText, - streamText, - type CoreMessage, - type CoreTool, - type GenerateTextResult, -} from 'ai'; -import { - AgentGenerateTextOptions, - AgentStreamTextOptions, - AnyAgent, -} from './types'; -import { defaultTextTemplate } from './templates/defaultText'; -import { - ObservableActorLogic, - Observer, - PromiseActorLogic, - fromObservable, - fromPromise, - toObserver, -} from 'xstate'; - -/** - * Gets an array of messages from the given prompt, based on the agent and options. - * - * @param agent - * @param prompt - * @param options - * @returns - */ -export async function getMessages( - agent: AnyAgent, - prompt: string, - options: Omit -): Promise { - let messages: CoreMessage[] = []; - if (typeof options.messages === 'function') { - messages = await options.messages(agent); - } else if (options.messages) { - messages = options.messages; - } - - messages = messages.concat({ - role: 'user', - content: prompt, - }); - - return messages; -} - -export function fromTextStream( - agent: T, - options?: AgentStreamTextOptions -): ObservableActorLogic< - { textDelta: string }, - Omit & { - context?: AgentStreamTextOptions['context']; - } -> { - const template = options?.template ?? defaultTextTemplate; - return fromObservable(({ input }) => { - const observers = new Set>(); - - // TODO: check if messages was provided instead - - (async () => { - const model = input.model ? agent.wrap(input.model) : agent.model; - const goal = - typeof input.prompt === 'string' - ? input.prompt - : await input.prompt(agent); - const promptWithContext = template({ - goal, - context: input.context, - }); - const messages = await getMessages(agent, promptWithContext, input); - const result = await streamText({ - ...options, - ...input, - prompt: undefined, // overwritten by messages - model, - messages, - }); - - for await (const part of result.fullStream) { - if (part.type === 'text-delta') { - observers.forEach((observer) => { - observer.next?.(part); - }); - } - } - })(); - - return { - subscribe: (...args: any[]) => { - const observer = toObserver(...args); - observers.add(observer); - - return { - unsubscribe: () => { - observers.delete(observer); - }, - }; - }, - }; - }); -} - -export function fromText( - agent: T, - options?: AgentGenerateTextOptions -): PromiseActorLogic< - GenerateTextResult>>, - Omit & { - context?: AgentGenerateTextOptions['context']; - } -> { - const resolvedOptions = { - ...agent.defaultOptions, - ...options, - }; - - const template = resolvedOptions.template ?? defaultTextTemplate; - - return fromPromise(async ({ input }) => { - const goal = - typeof input.prompt === 'string' - ? input.prompt - : await input.prompt(agent); - - const promptWithContext = template({ - goal, - context: input.context, - }); - - const messages = await getMessages(agent, promptWithContext, input); - - const model = input.model ? agent.wrap(input.model) : agent.model; - - return await generateText({ - ...input, - ...options, - prompt: undefined, - messages, - model, - }); - }); -} diff --git a/src/types.ts b/src/types.ts index 5e504ca..93c5a4a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,447 +1,230 @@ -import { - ActorLogic, - ActorRefFrom, - AnyActorRef, - AnyEventObject, - AnyStateMachine, - EventFrom, - EventObject, - PromiseActorLogic, - SnapshotFrom, - StateValue, - Subscription, - TransitionSnapshot, - Values, -} from 'xstate'; -import { - CoreMessage, - generateText, - GenerateTextResult, - LanguageModel, - streamText, -} from 'ai'; -import { ZodContextMapping, ZodEventMapping } from './schemas'; -import { TypeOf } from 'zod'; -import { Agent } from './agent'; - -export type GenerateTextOptions = Parameters[0]; - -export type StreamTextOptions = Parameters[0]; - -export type AgentPlanInput = Omit< - GenerateTextOptions, - 'prompt' | 'tools' -> & { - /** - * The currently observed state. - */ - state: ObservedState; - /** - * The goal for the agent to accomplish. - * The agent will create a plan based on this goal. - */ - goal: string; - /** - * The events that the agent can trigger. This is a mapping of - * event types to Zod event schemas. - */ - events: ZodEventMapping; - /** - * The state machine that represents the environment the agent - * is interacting with. - */ - machine?: AnyStateMachine; - /** - * The previous plan. - */ - previousPlan?: AgentPlan; -}; - -export type AgentPlan = { - goal: string; - state: ObservedState; - content?: string; - /** - * Executes the plan based on the given `state` and resolves with - * a potential next `event` to trigger to achieve the `goal`. - */ - execute: (state: ObservedState) => Promise; - nextEvent: TEvent | undefined; - sessionId: string; - timestamp: number; -}; - -export interface TransitionData { - eventType: string; - description?: string; - guard?: { type: string }; - target?: any; +// ─── Standard Schema compatibility ─── +// Minimal Standard Schema V1 interface so any compliant library (zod, valibot, arktype) works. + +export interface StandardSchemaV1 { + readonly '~standard': { + readonly version: 1; + readonly vendor: string; + readonly validate: (value: unknown) => any; + readonly types?: { readonly input?: unknown; readonly output?: Output }; + }; } -export type PromptTemplate = (data: { - goal: string; - /** - * The observed state - */ - state?: ObservedState; - /** - * The context to provide. - * This overrides the observed state.context, if provided. - */ - context?: any; - /** - * The state machine model of the observed environment - */ - machine?: unknown; - /** - * The potential next transitions that can be taken - * in the state machine - */ - transitions?: TransitionData[]; - /** - * Past observations - */ - observations?: AgentObservation[]; // TODO - feedback?: AgentFeedback[]; - messages?: AgentMessage[]; - plans?: AgentPlan[]; -}) => string; - -export type AgentPlanner = ( - agent: T, - input: AgentPlanInput -) => Promise | undefined>; - -export type AgentDecideOptions = { - goal: string; - model?: LanguageModel; - state: ObservedState; - machine?: AnyStateMachine; - execute?: (event: AnyEventObject) => Promise; - planner?: AgentPlanner; - events?: ZodEventMapping; -} & Omit[0], 'model' | 'tools' | 'prompt'>; - -export interface AgentFeedback { - goal?: string; - observationId?: string; - /** - * The message correlation that the feedback is relevant for - */ - correlationId?: string; - attributes: Record; - reward: number; - timestamp: number; - sessionId: string; +export type StandardSchemaResult = + | { value: T; issues?: undefined } + | { value?: undefined; issues: ReadonlyArray<{ message: string }> }; + +export type InferOutput = T extends StandardSchemaV1 ? O : never; + +// ─── Adapter ─── + +export interface AgentAdapter { + decide: (options: { + model: string; + prompt: string; + options: Record; + reasoning?: boolean; + }) => Promise<{ + choice: string; + data: Record; + reasoning?: string; + }>; } -export interface AgentFeedbackInput { - goal?: string; - observationId?: string; - correlationId?: string; - attributes?: Record; - timestamp?: number; - reward?: number; +// ─── Events ─── + +export interface AgentEvent { + type: string; + [key: string]: unknown; } -export type AgentMessage = CoreMessage & { - timestamp: number; - id: string; - /** - * The response ID of the message, which references - * which message this message is responding to, if any. - */ - responseId?: string; - result?: GenerateTextResult; - sessionId: string; -}; - -type JSONObject = { - [key: string]: JSONValue; -}; -type JSONArray = JSONValue[]; -type JSONValue = null | string | number | boolean | JSONObject | JSONArray; - -type LanguageModelV1ProviderMetadata = Record< - string, - Record ->; - -interface LanguageModelV1ImagePart { - type: 'image'; - /** -Image data as a Uint8Array (e.g. from a Blob or Buffer) or a URL. - */ - image: Uint8Array | URL; - /** -Optional mime type of the image. - */ - mimeType?: string; - /** - * Additional provider-specific metadata. They are passed through - * to the provider from the AI SDK and enable provider-specific - * functionality that can be fully encapsulated in the provider. - */ - providerMetadata?: LanguageModelV1ProviderMetadata; +// ─── Transition ─── + +export interface TransitionResult { + target?: string; + context?: Record; + params?: Record; } -export interface LanguageModelV1TextPart { - type: 'text'; - /** -The text content. - */ - text: string; - /** - * Additional provider-specific metadata. They are passed through - * to the provider from the AI SDK and enable provider-specific - * functionality that can be fully encapsulated in the provider. - */ - providerMetadata?: LanguageModelV1ProviderMetadata; +export interface TransitionArgs< + TContext = Record, + TEvent extends AgentEvent = AgentEvent, +> { + context: TContext; + event: TEvent; } -export interface LanguageModelV1ToolCallPart { - type: 'tool-call'; - /** -ID of the tool call. This ID is used to match the tool call with the tool result. - */ - toolCallId: string; - /** -Name of the tool that is being called. - */ - toolName: string; - /** -Arguments of the tool call. This is a JSON-serializable object that matches the tool's input schema. - */ - args: unknown; - /** - * Additional provider-specific metadata. They are passed through - * to the provider from the AI SDK and enable provider-specific - * functionality that can be fully encapsulated in the provider. - */ - providerMetadata?: LanguageModelV1ProviderMetadata; +export interface OnDoneArgs< + TContext = Record, + TResult = unknown, +> { + result: TResult; + context: TContext; } -interface LanguageModelV1ToolResultPart { - type: 'tool-result'; - /** -ID of the tool call that this result is associated with. - */ - toolCallId: string; - /** -Name of the tool that generated this result. - */ - toolName: string; - /** -Result of the tool call. This is a JSON-serializable object. - */ - result: unknown; - /** -Optional flag if the result is an error or an error message. - */ - isError?: boolean; - /** - * Additional provider-specific metadata. They are passed through - * to the provider from the AI SDK and enable provider-specific - * functionality that can be fully encapsulated in the provider. - */ - providerMetadata?: LanguageModelV1ProviderMetadata; + +export interface RunArgs> { + context: TContext; + parentParams: Record; + signal?: AbortSignal; } -type LanguageModelV1Message = ( - | { - role: 'system'; - content: string; - } - | { - role: 'user'; - content: Array; - } - | { - role: 'assistant'; - content: Array; - } - | { - role: 'tool'; - content: Array; - } -) & { - /** - * Additional provider-specific metadata. They are passed through - * to the provider from the AI SDK and enable provider-specific - * functionality that can be fully encapsulated in the provider. - */ - providerMetadata?: LanguageModelV1ProviderMetadata; -}; - -export type AgentMessageInput = CoreMessage & { - timestamp?: number; - id?: string; - /** - * The response ID of the message, which references - * which message this message is responding to, if any. - */ - responseId?: string; - correlationId?: string; - parentCorrelationId?: string; - result?: GenerateTextResult; -}; - -export interface AgentObservation { - id: string; - prevState: SnapshotFrom | undefined; - event: EventFrom; - state: SnapshotFrom; - machineHash: string | undefined; - sessionId: string; - timestamp: number; + +export interface OutputArgs> { + context: TContext; } -export interface AgentObservationInput { - id?: string; - prevState: ObservedState | undefined; - event: AnyEventObject; - state: ObservedState; - machine?: AnyStateMachine; - timestamp?: number; +// ─── Decide / Classify ─── + +export interface DecideResult { + choice: string; + data: Record; + reasoning?: string; } -export type AgentDecisionInput = { - goal: string; - model?: LanguageModel; - context?: any; -} & Omit[0], 'model' | 'tools' | 'prompt'>; +export interface ClassifyResult { + category: string; +} -export type AgentDecisionLogic = PromiseActorLogic< - AgentPlan | undefined, - AgentDecisionInput | string ->; +export interface DecideConfig { + model: string; + adapter?: AgentAdapter; + prompt: + | string + | ((args: { + context: Record; + parentParams: Record; + }) => string); + options: Record< + string, + { description: string; schema?: StandardSchemaV1 } + >; + reasoning?: boolean; + onDone: (args: OnDoneArgs, DecideResult>) => TransitionResult; + on?: Record< + string, + (args: TransitionArgs) => TransitionResult + >; +} -export type AgentEmitted = - | { - type: 'feedback'; - feedback: AgentFeedback; - } +export interface ClassifyConfig { + model: string; + adapter?: AgentAdapter; + prompt: + | string + | ((args: { + context: Record; + parentParams: Record; + }) => string); + into: Record; + examples?: Array<{ input: string; category: string }>; + onDone: ( + args: OnDoneArgs, ClassifyResult> + ) => TransitionResult; + on?: Record< + string, + (args: TransitionArgs) => TransitionResult + >; +} + +// ─── State config ─── + +export interface StateConfig { + type?: 'final'; + outputSchema?: StandardSchemaV1; + paramsSchema?: StandardSchemaV1; + run?: (args: RunArgs) => Promise; + onDone?: (args: OnDoneArgs) => TransitionResult; + on?: Record< + string, + (args: TransitionArgs) => TransitionResult + >; + events?: Record; + output?: (args: OutputArgs) => unknown; + // Compound state + initial?: + | string + | ((args: { + context: Record; + parentParams: Record; + }) => TransitionResult); + states?: Record; + + // Internal — set by decide/classify helpers + /** @internal */ + __type?: 'decide' | 'classify'; + /** @internal */ + __decideConfig?: DecideConfig; + /** @internal */ + __classifyConfig?: ClassifyConfig; +} + +// ─── Machine config ─── + +export interface MachineConfig { + id: string; + inputSchema?: StandardSchemaV1; + context: (input: any) => Record; + contextSchema?: StandardSchemaV1; + events?: Record; + adapter?: AgentAdapter; + initial: + | string + | ((args: { context: Record }) => TransitionResult); + states: Record; +} + +// ─── Agent Machine (returned by createAgentMachine) ─── + +export interface AgentMachine extends MachineConfig {} + +// ─── Agent State (serializable) ─── + +export interface AgentState { + value: string; + params: Record>; + context: Record; + status: 'running' | 'waiting' | 'done' | 'error'; + output?: unknown; + error?: unknown; +} + +// ─── Run result (discriminated union) ─── + +export type AgentRunResult = | { - type: 'observation'; - observation: AgentObservation; // TODO + status: 'done'; + state: AgentState; + output: unknown; + context: Record; } | { - type: 'message'; - message: AgentMessage; + status: 'waiting'; + state: AgentState; + value: string; + events: Record; + context: Record; } | { - type: 'plan'; - plan: AgentPlan; + status: 'error'; + state: AgentState; + error: unknown; }; -export type AgentLogic = ActorLogic< - TransitionSnapshot, - | { - type: 'agent.feedback'; - feedback: AgentFeedback; - } - | { - type: 'agent.observe'; - observation: AgentObservation; // TODO - } - | { - type: 'agent.message'; - message: AgentMessage; - } - | { - type: 'agent.plan'; - plan: AgentPlan; - }, - any, // TODO: input - any, - AgentEmitted ->; - -export type EventsFromZodEventMapping = - Values<{ - [K in keyof TEventSchemas & string]: { - type: K; - } & TypeOf; - }>; +// ─── Snapshot (for streaming) ─── -export type ContextFromZodContextMapping< - TContextSchema extends ZodContextMapping -> = { - [K in keyof TContextSchema & string]: TypeOf; -}; - -export type AnyAgent = Agent; - -export type FromAgent = T | ((agent: AnyAgent) => T | Promise); - -export type CommonTextOptions = { - prompt: FromAgent; - model?: LanguageModel; - context?: Record; - messages?: FromAgent; - template?: PromptTemplate; -}; - -export type AgentGenerateTextOptions = Omit< - GenerateTextOptions, - 'model' | 'prompt' | 'messages' -> & - CommonTextOptions; - -export type AgentStreamTextOptions = Omit< - StreamTextOptions, - 'model' | 'prompt' | 'messages' -> & - CommonTextOptions; - -export interface ObservedState { - /** - * The current state value of the state machine, e.g. - * `"loading"` or `"processing"` or `"ready"` - */ - value: StateValue; - /** - * Additional contextual data related to the current state - */ +export interface AgentSnapshot { + value: string; context: Record; + status: AgentState['status']; + params: Record>; } -export type ObservedStateFrom = Pick< - SnapshotFrom, - 'value' | 'context' ->; - -export type AgentMemoryContext = { - observations: AgentObservation[]; // TODO - messages: AgentMessage[]; - plans: AgentPlan[]; - feedback: AgentFeedback[]; -}; - -export type AgentMemory = AppendOnlyStorage; - -export interface AppendOnlyStorage> { - append( - sessionId: string, - key: K, - item: T[K][0] - ): Promise; - getAll( - sessionId: string, - key: K - ): Promise; -} +// ─── Trace ─── -export interface AgentLongTermMemory { - get( - key: K - ): Promise; - append( - key: K, - item: AgentMemoryContext[K][0] - ): Promise; - set( - key: K, - items: AgentMemoryContext[K] - ): Promise; +export interface Trace { + state: string; + event: { + type: string; + timestamp: number; + [key: string]: unknown; + }; } - -export type Compute = { [K in keyof A]: A[K] } & unknown; diff --git a/src/utils.ts b/src/utils.ts index a1ae4ac..23524b7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,72 +1,249 @@ -import { AnyMachineSnapshot, AnyStateMachine, AnyStateNode } from 'xstate'; -import hash from 'object-hash'; -import { TransitionData } from './types'; - -export function getAllTransitions(state: AnyMachineSnapshot): TransitionData[] { - const nodes = state._nodes; - const transitions = (nodes as AnyStateNode[]) - .map((node) => [...(node as AnyStateNode).transitions.values()]) - .map((nodeTransitions) => { - return nodeTransitions.map((nodeEventTransitions) => { - return nodeEventTransitions.map((transition) => { - return { - ...transition, - guard: - typeof transition.guard === 'string' - ? { type: transition.guard } - : (transition.guard as any), // TODO: fix - }; - }); - }); - }) - .flat(2); - - return transitions; +import type { + AgentMachine, + AgentState, + StandardSchemaResult, + StandardSchemaV1, + StateConfig, + TransitionResult, +} from './types.js'; + +/** + * Validate a value against a Standard Schema V1 schema. + */ +export async function validateSchema( + schema: StandardSchemaV1, + value: unknown +): Promise { + const result = await schema['~standard'].validate(value) as StandardSchemaResult; + if (result.issues) { + const messages = result.issues.map((i: { message: string }) => i.message).join(', '); + throw new Error(`Validation failed: ${messages}`); + } + return result.value as T; +} + +/** + * Resolve a StateConfig from a dot-separated state path. + */ +export function resolveStateConfig( + machine: AgentMachine, + value: string +): StateConfig { + const parts = value.split('.'); + let current: Record = machine.states; + let config: StateConfig | undefined; + + for (const part of parts) { + config = current[part]; + if (!config) { + throw new Error(`State '${part}' not found in path '${value}'`); + } + if (config.states) { + current = config.states; + } + } + + return config!; +} + +/** + * Get the parent state config for a nested state, or null for root states. + */ +export function getParentConfig( + machine: AgentMachine, + value: string +): StateConfig | null { + const parts = value.split('.'); + if (parts.length <= 1) return null; + const parentPath = parts.slice(0, -1).join('.'); + return resolveStateConfig(machine, parentPath); +} + +/** + * Get the parent's params for the current state. + */ +export function getParentParams( + state: AgentState +): Record { + const parts = state.value.split('.'); + if (parts.length <= 1) return {}; + const parentPath = parts.slice(0, -1).join('.'); + return state.params[parentPath] ?? {}; +} + +/** + * Resolve an initial transition value. + * Accepts string shorthand, object shorthand, or function. + */ +export function resolveInitial( + initial: + | string + | ((args: { + context: Record; + parentParams: Record; + }) => TransitionResult), + args: { + context: Record; + parentParams: Record; + } +): TransitionResult { + if (typeof initial === 'string') { + return { target: initial }; + } + return initial(args); +} + +/** + * Resolve a target state path. Targets are siblings of the handler's state. + * `handlerStatePath` is the dot-path of the state where the handler is defined. + */ +export function resolveTarget( + handlerStatePath: string, + target: string +): string { + const parts = handlerStatePath.split('.'); + if (parts.length <= 1) { + // Handler on a root-level state → target is root-level + return target; + } + // Handler on a nested state → target is a sibling (under same parent) + const parentParts = parts.slice(0, -1); + return [...parentParts, target].join('.'); } -export function getAllMachineTransitions( - stateNode: AnyStateNode -): TransitionData[] { - const transitions: TransitionData[] = [...stateNode.transitions.values()] - .map((nodeTransitions) => { - return nodeTransitions.map((transition) => { - return { - ...transition, - guard: - typeof transition.guard === 'string' - ? { type: transition.guard } - : (transition.guard as any), // TODO: fix - }; - }); - }) - .flat(2); - - for (const s of Object.values(stateNode.states)) { - const stateTransitions = getAllMachineTransitions(s); - transitions.push(...stateTransitions); +/** + * Apply a transition result to produce a new state. + * Handles context merging, target resolution, and compound state entry. + */ +export function applyTransition( + machine: AgentMachine, + state: AgentState, + transition: TransitionResult, + handlerStatePath: string +): AgentState { + let newState = { ...state }; + + // Merge context + if (transition.context) { + newState.context = { ...state.context, ...transition.context }; + } + + if (transition.target) { + // Resolve target relative to handler's scope + newState.value = resolveTarget(handlerStatePath, transition.target); + newState.status = 'running'; + + // Store params if provided + if (transition.params) { + newState.params = { + ...state.params, + [newState.value]: transition.params, + }; + } + + // Enter compound states recursively + newState = enterCompoundStates(machine, newState); } - return transitions; + return newState; } -export function wrapInXml(tagName: string, content: string): string { - return `<${tagName}>${content}`; +/** + * If the current state is a compound state, resolve its initial and descend. + * Repeats for nested compounds. + */ +export function enterCompoundStates( + machine: AgentMachine, + state: AgentState +): AgentState { + let current = state; + + for (;;) { + const config = resolveStateConfig(machine, current.value); + if (!config.states || !config.initial) break; + + const parentParams = current.params[current.value] ?? {}; + const init = resolveInitial(config.initial, { + context: current.context, + parentParams, + }); + + if (!init.target) break; + + const childValue = `${current.value}.${init.target}`; + current = { ...current, value: childValue }; + + if (init.context) { + current.context = { ...current.context, ...init.context }; + } + if (init.params) { + current.params = { + ...current.params, + [current.value]: init.params, + }; + } + } + + return current; } -export function randomId() { - const timestamp = Date.now().toString(36); - const random = Math.random().toString(36).substring(2, 9); - return timestamp + random; +/** + * Collect available events for a given state path. + * Walks from the current state up to root, merging event schemas. + * State-level events override root-level events. + */ +export function getAvailableEvents( + machine: AgentMachine, + value: string +): Record { + const events: Record = {}; + + // Root-level events + if (machine.events) { + Object.assign(events, machine.events); + } + + // Walk up from current state, collecting event schemas + const parts = value.split('.'); + for (let i = 0; i < parts.length; i++) { + const path = parts.slice(0, i + 1).join('.'); + const config = resolveStateConfig(machine, path); + if (config.events) { + Object.assign(events, config.events); + } + } + + // Filter to only events that have handlers on the current state or ancestors + const handledEvents = getHandledEventTypes(machine, value); + const result: Record = {}; + for (const eventType of handledEvents) { + if (events[eventType]) { + result[eventType] = events[eventType]; + } + } + + return result; } -const machineHashes: WeakMap = new WeakMap(); /** - * Returns a string hash representing only the transitions in the state machine. + * Get all event types that have handlers on the current state or any ancestor. */ -export function getMachineHash(machine: AnyStateMachine): string { - if (machineHashes.has(machine)) return machineHashes.get(machine)!; - const transitions = getAllMachineTransitions(machine.root); - const machineHash = hash(transitions); - machineHashes.set(machine, machineHash); - return machineHash; +function getHandledEventTypes( + machine: AgentMachine, + value: string +): Set { + const handled = new Set(); + const parts = value.split('.'); + + for (let i = parts.length; i >= 1; i--) { + const path = parts.slice(0, i).join('.'); + const config = resolveStateConfig(machine, path); + if (config.on) { + for (const eventType of Object.keys(config.on)) { + handled.add(eventType); + } + } + } + + return handled; } diff --git a/src/xstate/index.ts b/src/xstate/index.ts new file mode 100644 index 0000000..0bd2c66 --- /dev/null +++ b/src/xstate/index.ts @@ -0,0 +1,10 @@ +import type { AgentMachine } from '../types.js'; + +/** + * Convert an agent machine to an XState machine definition + * for visualization in the Stately Editor. + * TODO: implement + */ +export function toXStateMachine(_machine: AgentMachine): unknown { + throw new Error('toXStateMachine is not yet implemented'); +} diff --git a/tsconfig.json b/tsconfig.json index e568eb1..68211cf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,109 +1,18 @@ { "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "es2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "commonjs" /* Specify what module code is generated. */, - // "rootDir": "./", /* Specify the root folder within your source files. */ - // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ - // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ - // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ - // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - "sourceMap": true /* Create source map files for emitted JavaScript files. */, - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - "noEmit": true /* Disable emitting files from a compilation. */, - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - - /* Type Checking */ - "strict": true /* Enable all strict type-checking options. */, - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - "noUncheckedIndexedAccess": true /* Add 'undefined' to a type when accessed using an index. */, - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "declaration": true, + "sourceMap": true, + "isolatedModules": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "examples"] } diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..333bb5f --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'ai-sdk': 'src/ai-sdk/index.ts', + graph: 'src/graph/index.ts', + xstate: 'src/xstate/index.ts', + }, + format: ['esm', 'cjs'], + dts: true, + clean: true, +}); From 0123b2ec7430773df678669a903c462c9ea0322b Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 3 Apr 2026 11:15:27 -0400 Subject: [PATCH 02/50] Rework --- pnpm-lock.yaml | 12 - src/agent.test.ts | 1502 ++++++++++++++++++++++++--------------------- src/classify.ts | 15 +- src/decide.ts | 14 +- src/event.ts | 107 ---- src/index.ts | 20 +- src/machine.ts | 418 ++++++++++++- src/run.ts | 51 -- src/state.ts | 50 -- src/step.ts | 167 ----- src/stream.ts | 29 - src/types.ts | 338 +++++----- src/utils.ts | 205 ++++--- 13 files changed, 1542 insertions(+), 1386 deletions(-) delete mode 100644 src/event.ts delete mode 100644 src/run.ts delete mode 100644 src/state.ts delete mode 100644 src/step.ts delete mode 100644 src/stream.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86020b6..678ed1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@xstate/graph': - specifier: ^2.0.1 - version: 2.0.1(xstate@5.26.0) ai: specifier: ^6.0.67 version: 6.0.67(zod@4.3.6) @@ -646,11 +643,6 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==, tarball: https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz} - '@xstate/graph@2.0.1': - resolution: {integrity: sha512-WWfL97yvyVISbmetqrspd6mUn13UKoHZ+/FBSU17n+YPdMrYnKaP8UDe/HjNoZAVYsR3wuQLoitTW9cxud0DIA==, tarball: https://registry.npmjs.org/@xstate/graph/-/graph-2.0.1.tgz} - peerDependencies: - xstate: ^5.18.2 - ai@6.0.67: resolution: {integrity: sha512-xBnTcByHCj3OcG6V8G1s6zvSEqK0Bdiu+IEXYcpGrve1iGFFRgcrKeZtr/WAW/7gupnSvBbDF24BEv1OOfqi1g==, tarball: https://registry.npmjs.org/ai/-/ai-6.0.67.tgz} engines: {node: '>=18'} @@ -1873,10 +1865,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 - '@xstate/graph@2.0.1(xstate@5.26.0)': - dependencies: - xstate: 5.26.0 - ai@6.0.67(zod@4.3.6): dependencies: '@ai-sdk/gateway': 3.0.32(zod@4.3.6) diff --git a/src/agent.test.ts b/src/agent.test.ts index 0d1d94f..ee63967 100644 --- a/src/agent.test.ts +++ b/src/agent.test.ts @@ -2,11 +2,6 @@ import { describe, expect, test, vi } from 'vitest'; import { z } from 'zod'; import { createAgentMachine, - createInitialState, - step, - run, - stream, - sendEvent, decide, classify, createAdapter, @@ -16,7 +11,11 @@ import type { AgentAdapter } from './types.js'; // ─── Test helpers ─── function mockAdapter( - responses: Array<{ choice: string; data?: Record; reasoning?: string }> + responses: Array<{ + choice: string; + data?: Record; + reasoning?: string; + }> ): AgentAdapter { let index = 0; return { @@ -32,7 +31,7 @@ function mockAdapter( }; } -// ─── Simple machine for basic tests ─── +// ─── Simple machine (no schemas — inferred from context) ─── function createSimpleMachine() { return createAgentMachine({ @@ -46,12 +45,13 @@ function createSimpleMachine() { }, }, running: { - run: async ({ context }) => { - return { value: (context.count as number) + 1 }; + invoke: async ({ context }) => { + // context.count is typed as number ✓ + return { value: context.count + 1 }; }, onDone: ({ result, context }) => ({ target: 'done', - context: { count: (result as any).value }, + context: { count: (result as { value: number }).value }, }), }, done: { @@ -62,22 +62,24 @@ function createSimpleMachine() { }); } -// ─── Machine with events for HITL ─── +// ─── HITL machine (with schemas) ─── function createHitlMachine() { return createAgentMachine({ id: 'hitl', - inputSchema: z.object({ task: z.string() }), - context: (input) => ({ + schemas: { + input: z.object({ task: z.string() }), + events: { + 'user.message': z.object({ message: z.string() }), + 'user.approve': z.object({}), + 'user.cancel': z.object({}), + }, + }, + context: (input: { task: string }) => ({ task: input.task, messages: [] as Array<{ role: string; content: string }>, result: null as string | null, }), - events: { - 'user.message': z.object({ message: z.string() }), - 'user.approve': z.object({}), - 'user.cancel': z.object({}), - }, initial: 'gathering', states: { gathering: { @@ -85,25 +87,25 @@ function createHitlMachine() { 'user.message': ({ event, context }) => ({ context: { messages: [ - ...(context.messages as any[]), - { role: 'user', content: (event as any).message }, + ...context.messages, + { role: 'user', content: (event as { message: string }).message }, ], }, }), - 'user.approve': ({ context }) => ({ - target: 'processing', - }), + 'user.approve': () => ({ target: 'processing' }), 'user.cancel': () => ({ target: 'cancelled' }), }, }, processing: { - run: async ({ context }) => { - const msgs = context.messages as Array<{ content: string }>; - return { output: `Processed: ${msgs.map((m) => m.content).join(', ')}` }; + invoke: async ({ context }) => { + // context.messages is typed ✓ + return { + output: `Processed: ${context.messages.map((m) => m.content).join(', ')}`, + }; }, onDone: ({ result }) => ({ target: 'reviewing', - context: { result: (result as any).output }, + context: { result: (result as { output: string }).output }, }), }, reviewing: { @@ -113,8 +115,8 @@ function createHitlMachine() { target: 'processing', context: { messages: [ - ...(context.messages as any[]), - { role: 'user', content: (event as any).message }, + ...context.messages, + { role: 'user', content: (event as { message: string }).message }, ], }, }), @@ -133,7 +135,7 @@ function createHitlMachine() { }); } -// ─── Machine with decide state ─── +// ─── Decide machine ─── function createDecideMachine(adapter: AgentAdapter) { return createAgentMachine({ @@ -141,6 +143,7 @@ function createDecideMachine(adapter: AgentAdapter) { context: () => ({ issue: 'App crashes on login', category: null as string | null, + resolution: null as string | null, }), adapter, initial: 'classifying', @@ -156,15 +159,17 @@ function createDecideMachine(adapter: AgentAdapter) { onDone: ({ result }) => ({ target: 'handling', context: { category: result.choice }, + params: { category: result.choice }, }), }), handling: { - run: async ({ context }) => ({ - resolution: `Handled ${context.category} issue`, + paramsSchema: z.object({ category: z.string() }), + invoke: async ({ context, params }) => ({ + resolution: `Handled ${params.category} issue`, }), onDone: ({ result }) => ({ target: 'done', - context: { resolution: (result as any).resolution }, + context: { resolution: (result as { resolution: string }).resolution }, }), }, done: { @@ -178,12 +183,15 @@ function createDecideMachine(adapter: AgentAdapter) { }); } -// ─── Machine with classify state ─── +// ─── Classify machine ─── function createClassifyMachine(adapter: AgentAdapter) { return createAgentMachine({ id: 'classifier', - context: () => ({ issue: 'I want my money back', category: null as string | null }), + context: () => ({ + issue: 'I want my money back', + category: null as string | null, + }), adapter, initial: 'classifyIntent', states: { @@ -208,7 +216,7 @@ function createClassifyMachine(adapter: AgentAdapter) { }); } -// ─── Nested/compound state machine ─── +// ─── Nested machine ─── function createNestedMachine() { return createAgentMachine({ @@ -221,51 +229,53 @@ function createNestedMachine() { states: { handling: { initial: ({ context }) => { - if (context.category === 'billing') { + if (context.category === 'billing') return { target: 'checkEligibility' }; - } return { target: 'diagnose' }; }, states: { checkEligibility: { - run: async () => ({ eligible: true }), + invoke: async () => ({ eligible: true }), onDone: ({ result }) => { - if ((result as any).eligible) return { target: 'processRefund' }; + if ((result as { eligible: boolean }).eligible) + return { target: 'processRefund' }; return { target: 'deny' }; }, }, processRefund: { - run: async () => ({}), - onDone: ({ context }) => ({ + invoke: async () => ({}), + onDone: () => ({ target: 'childDone', context: { resolution: 'Refund processed' }, }), }, deny: { - run: async () => ({ message: 'Not eligible' }), + invoke: async () => ({ message: 'Not eligible' }), onDone: ({ result }) => ({ target: 'childDone', - context: { resolution: (result as any).message }, + context: { + resolution: (result as { message: string }).message, + }, }), }, diagnose: { - run: async () => ({ diagnosis: 'It is a bug' }), + invoke: async () => ({ diagnosis: 'It is a bug' }), onDone: ({ result }) => ({ target: 'childDone', - context: { resolution: (result as any).diagnosis }, + context: { + resolution: (result as { diagnosis: string }).diagnosis, + }, }), }, childDone: { type: 'final' }, }, - onDone: () => ({ - target: 'respond', - }), + onDone: () => ({ target: 'respond' }), on: { 'user.cancel': () => ({ target: 'cancelled' }), }, }, respond: { - run: async ({ context }) => ({ message: context.resolution }), + invoke: async ({ context }) => ({ message: context.resolution }), onDone: () => ({ target: 'done' }), }, done: { @@ -285,133 +295,110 @@ function createNestedMachine() { // ═══════════════════════════════════════ describe('createAgentMachine', () => { - test('creates a machine config', () => { + test('returns machine with typed methods', () => { const machine = createSimpleMachine(); expect(machine.id).toBe('simple'); - expect(machine.states).toBeDefined(); - expect(machine.states.idle).toBeDefined(); - expect(machine.states.running).toBeDefined(); - expect(machine.states.done).toBeDefined(); + expect(typeof machine.getInitialState).toBe('function'); + expect(typeof machine.transition).toBe('function'); + expect(typeof machine.invoke).toBe('function'); + expect(typeof machine.execute).toBe('function'); + expect(typeof machine.stream).toBe('function'); + expect(typeof machine.resolveState).toBe('function'); }); }); -describe('createInitialState', () => { - test('creates initial state with context', async () => { +describe('getInitialState', () => { + test('creates initial state (sync)', () => { const machine = createSimpleMachine(); - const state = await createInitialState(machine, undefined); + const state = machine.getInitialState(); expect(state.value).toBe('idle'); expect(state.context).toEqual({ count: 0 }); - expect(state.status).toBe('running'); - expect(state.params).toEqual({}); + expect(state.status).toBe('active'); }); - test('validates input against schema', async () => { + test('validates input via schemas.input (sync)', () => { const machine = createHitlMachine(); - const state = await createInitialState(machine, { task: 'test task' }); + const state = machine.getInitialState({ task: 'test task' }); expect(state.context.task).toBe('test task'); - expect(state.value).toBe('gathering'); }); - test('rejects invalid input', async () => { + test('rejects invalid input', () => { const machine = createHitlMachine(); - await expect(createInitialState(machine, { task: 123 })).rejects.toThrow(); + // @ts-expect-error — deliberately invalid input for runtime test + expect(() => machine.getInitialState({ task: 123 })).toThrow(); }); - test('resolves string initial', async () => { + test('resolves string initial', () => { const machine = createSimpleMachine(); - const state = await createInitialState(machine, undefined); - expect(state.value).toBe('idle'); + expect(machine.getInitialState().value).toBe('idle'); }); - test('resolves function initial', async () => { + test('resolves function initial', () => { const machine = createAgentMachine({ id: 'fn-initial', - context: (input) => ({ mode: input }), + context: (input: string) => ({ mode: input }), initial: ({ context }) => ({ - target: context.mode === 'fast' ? 'fast' : 'slow', + target: (context.mode === 'fast' ? 'fast' : 'slow') as 'fast' | 'slow', }), states: { fast: { type: 'final' }, slow: { type: 'final' }, }, }); - const state = await createInitialState(machine, 'fast'); - expect(state.value).toBe('fast'); + expect(machine.getInitialState('fast').value).toBe('fast'); }); - test('resolves compound state initial', async () => { + test('resolves compound state initial', () => { const machine = createNestedMachine(); - const state = await createInitialState(machine, undefined); - // Should enter handling → checkEligibility (since category is 'billing') - expect(state.value).toBe('handling.checkEligibility'); + expect(machine.getInitialState().value).toEqual({ handling: 'checkEligibility' }); }); }); -describe('step', () => { - test('executes run and transitions via onDone', async () => { +describe('invoke', () => { + test('executes invoke and transitions via onDone', async () => { const machine = createSimpleMachine(); - let state = await createInitialState(machine, undefined); - // idle → send start event to get to 'running' - state = sendEvent(machine, state, { type: 'start' }); - expect(state.value).toBe('running'); - - state = await step(machine, state); + let state = machine.getInitialState(); + state = machine.transition(state, { type: 'start' }); + state = await machine.invoke(state); expect(state.value).toBe('done'); expect(state.context.count).toBe(1); }); - test('returns waiting for event-only states', async () => { + test('returns pending for event-only states', async () => { const machine = createHitlMachine(); - let state = await createInitialState(machine, { task: 'test' }); - state = await step(machine, state); - expect(state.status).toBe('waiting'); + const state = await machine.invoke(machine.getInitialState({ task: 'x' })); + expect(state.status).toBe('pending'); expect(state.value).toBe('gathering'); }); test('returns done for final states', async () => { const machine = createSimpleMachine(); - let state = await createInitialState(machine, undefined); - state = sendEvent(machine, state, { type: 'start' }); - state = await step(machine, state); // run → done - state = await step(machine, state); // final - expect(state.status).toBe('done'); - expect(state.output).toEqual({ result: 1 }); + let s = machine.transition(machine.getInitialState(), { type: 'start' }); + s = await machine.invoke(s); + s = await machine.invoke(s); + expect(s.status).toBe('done'); + expect(s.output).toEqual({ result: 1 }); }); - test('handles context updates in transitions', async () => { - const machine = createSimpleMachine(); - let state = await createInitialState(machine, undefined); - state = sendEvent(machine, state, { type: 'start' }); - state = await step(machine, state); - expect(state.context.count).toBe(1); + test('handles decide with adapter', async () => { + const machine = createDecideMachine( + mockAdapter([{ choice: 'technical' }]) + ); + const s = await machine.invoke(machine.getInitialState()); + expect(s.value).toBe('handling'); + expect(s.context.category).toBe('technical'); }); - test('handles decide state with adapter', async () => { - const adapter = mockAdapter([ - { choice: 'technical', data: {} }, - ]); - const machine = createDecideMachine(adapter); - let state = await createInitialState(machine, undefined); - expect(state.value).toBe('classifying'); - - state = await step(machine, state); - expect(state.value).toBe('handling'); - expect(state.context.category).toBe('technical'); + test('handles classify', async () => { + const machine = createClassifyMachine( + mockAdapter([{ choice: 'billing' }]) + ); + const s = await machine.invoke(machine.getInitialState()); + expect(s.value).toBe('done'); + expect(s.context.category).toBe('billing'); }); - test('handles classify state', async () => { - const adapter = mockAdapter([ - { choice: 'billing', data: {} }, - ]); - const machine = createClassifyMachine(adapter); - let state = await createInitialState(machine, undefined); - - state = await step(machine, state); - expect(state.value).toBe('done'); - expect(state.context.category).toBe('billing'); - }); - - test('errors without adapter on decide state', async () => { + test('errors without adapter', async () => { const machine = createAgentMachine({ id: 'no-adapter', context: () => ({}), @@ -426,76 +413,105 @@ describe('step', () => { done: { type: 'final' }, }, }); - const state = await createInitialState(machine, undefined); - const result = await step(machine, state); - expect(result.status).toBe('error'); - expect(result.error).toContain('No adapter'); + const s = await machine.invoke(machine.getInitialState()); + expect(s.status).toBe('error'); }); - test('bubbles error from run', async () => { + test('catches invoke errors', async () => { const machine = createAgentMachine({ - id: 'error-machine', + id: 'err', context: () => ({}), - initial: 'failing', + initial: 'fail', states: { - failing: { - run: async () => { + fail: { + invoke: async () => { throw new Error('boom'); }, - onDone: () => ({ target: 'done' }), + onDone: () => ({ target: 'ok' }), }, - done: { type: 'final' }, + ok: { type: 'final' }, }, }); - const state = await createInitialState(machine, undefined); - const result = await step(machine, state); - expect(result.status).toBe('error'); - expect((result.error as Error).message).toBe('boom'); + const s = await machine.invoke(machine.getInitialState()); + expect(s.status).toBe('error'); + expect((s.error as Error).message).toBe('boom'); }); - test('handles nested state entry and execution', async () => { + test('nested state entry and execution', async () => { const machine = createNestedMachine(); - let state = await createInitialState(machine, undefined); - expect(state.value).toBe('handling.checkEligibility'); + let s = machine.getInitialState(); + expect(s.value).toEqual({ handling: 'checkEligibility' }); - // Step through checkEligibility → processRefund - state = await step(machine, state); - expect(state.value).toBe('handling.processRefund'); + s = await machine.invoke(s); + expect(s.value).toEqual({ handling: 'processRefund' }); - // Step through processRefund → childDone - state = await step(machine, state); - expect(state.value).toBe('handling.childDone'); - expect(state.context.resolution).toBe('Refund processed'); + s = await machine.invoke(s); + expect(s.value).toEqual({ handling: 'childDone' }); - // Step: childDone is final → parent onDone → respond - state = await step(machine, state); - expect(state.value).toBe('respond'); + s = await machine.invoke(s); + expect(s.value).toBe('respond'); }); }); -describe('run', () => { - test('runs until completion', async () => { +describe('transition', () => { + test('transitions on matching event', () => { const machine = createSimpleMachine(); - let state = await createInitialState(machine, undefined); - state = sendEvent(machine, state, { type: 'start' }); - - const result = await run(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ result: 1 }); - expect(result.context.count).toBe(1); - } + const s = machine.transition(machine.getInitialState(), { type: 'start' }); + expect(s.value).toBe('running'); + expect(s.status).toBe('active'); + }); + + test('self-transition (no target)', async () => { + const machine = createHitlMachine(); + let s = await machine.invoke(machine.getInitialState({ task: 'x' })); + s = machine.transition(s, { type: 'user.message', message: 'hello' }); + expect(s.value).toBe('gathering'); + expect(s.context.messages[0]!.content).toBe('hello'); }); - test('stops at waiting state', async () => { + test('accumulates context', async () => { const machine = createHitlMachine(); - const state = await createInitialState(machine, { task: 'test' }); + let s = await machine.invoke(machine.getInitialState({ task: 'x' })); + s = machine.transition(s, { type: 'user.message', message: 'one' }); + s = machine.transition(s, { type: 'user.message', message: 'two' }); + expect(s.context.messages.length).toBe(2); + }); + + test('throws on unknown event', () => { + const machine = createSimpleMachine(); + expect(() => + machine.transition(machine.getInitialState(), { type: 'nope' }) + ).toThrow("No handler for event 'nope'"); + }); + + test('parent preempts child', () => { + const machine = createNestedMachine(); + const s = machine.transition(machine.getInitialState(), { + type: 'user.cancel', + }); + expect(s.value).toBe('cancelled'); + }); +}); - const result = await run(machine, state); - expect(result.status).toBe('waiting'); - if (result.status === 'waiting') { - expect(result.value).toBe('gathering'); - expect(result.events).toBeDefined(); +describe('execute', () => { + test('runs until done', async () => { + const machine = createSimpleMachine(); + let s = machine.transition(machine.getInitialState(), { type: 'start' }); + const r = await machine.execute(s); + expect(r.status).toBe('done'); + if (r.status === 'done') { + expect(r.output).toEqual({ result: 1 }); + expect(r.context.count).toBe(1); + } + }); + + test('stops at pending', async () => { + const machine = createHitlMachine(); + const r = await machine.execute(machine.getInitialState({ task: 'x' })); + expect(r.status).toBe('pending'); + if (r.status === 'pending') { + expect(r.value).toBe('gathering'); + expect(r.events['user.message']).toBeDefined(); } }); @@ -506,7 +522,7 @@ describe('run', () => { initial: 'fail', states: { fail: { - run: async () => { + invoke: async () => { throw new Error('nope'); }, onDone: () => ({ target: 'ok' }), @@ -514,20 +530,18 @@ describe('run', () => { ok: { type: 'final' }, }, }); - const state = await createInitialState(machine, undefined); - const result = await run(machine, state); - expect(result.status).toBe('error'); + const r = await machine.execute(machine.getInitialState()); + expect(r.status).toBe('error'); }); test('runs through multiple transitions', async () => { - const adapter = mockAdapter([{ choice: 'technical' }]); - const machine = createDecideMachine(adapter); - const state = await createInitialState(machine, undefined); - - const result = await run(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ + const machine = createDecideMachine( + mockAdapter([{ choice: 'technical' }]) + ); + const r = await machine.execute(machine.getInitialState()); + expect(r.status).toBe('done'); + if (r.status === 'done') { + expect(r.output).toEqual({ category: 'technical', resolution: 'Handled technical issue', }); @@ -536,147 +550,63 @@ describe('run', () => { test('runs nested states to completion', async () => { const machine = createNestedMachine(); - const state = await createInitialState(machine, undefined); - - const result = await run(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ resolution: 'Refund processed' }); - } + const r = await machine.execute(machine.getInitialState()); + expect(r.status === 'done' && r.output).toEqual({ + resolution: 'Refund processed', + }); }); +}); - test('waiting result includes available events', async () => { - const machine = createHitlMachine(); - const state = await createInitialState(machine, { task: 'test' }); - - const result = await run(machine, state); - expect(result.status).toBe('waiting'); - if (result.status === 'waiting') { - expect(result.events['user.message']).toBeDefined(); - expect(result.events['user.approve']).toBeDefined(); - expect(result.events['user.cancel']).toBeDefined(); +describe('stream', () => { + test('yields snapshots', async () => { + const machine = createDecideMachine( + mockAdapter([{ choice: 'technical' }]) + ); + const snaps = []; + for await (const snap of machine.stream(machine.getInitialState())) { + snaps.push(snap); } + expect(snaps.length).toBeGreaterThanOrEqual(3); + expect(snaps[0]!.value).toBe('classifying'); + expect(snaps[snaps.length - 1]!.status).toBe('done'); }); }); -describe('sendEvent', () => { - test('transitions on matching event', async () => { - const machine = createSimpleMachine(); - const state = await createInitialState(machine, undefined); - const next = sendEvent(machine, state, { type: 'start' }); - expect(next.value).toBe('running'); - expect(next.status).toBe('running'); - }); - - test('handles self-transition (no target)', async () => { +describe('resolveState', () => { + test('restores from JSON', async () => { const machine = createHitlMachine(); - let state = await createInitialState(machine, { task: 'test' }); - state = await step(machine, state); // → waiting - - const next = sendEvent(machine, state, { + const r = await machine.execute(machine.getInitialState({ task: 'x' })); + const restored = machine.resolveState(JSON.parse(JSON.stringify(r.state))); + const next = machine.transition(restored, { type: 'user.message', - message: 'hello', + message: 'restored', }); - expect(next.value).toBe('gathering'); // same state - expect((next.context.messages as any[]).length).toBe(1); - expect((next.context.messages as any[])[0].content).toBe('hello'); - }); - - test('accumulates context on repeated self-transitions', async () => { - const machine = createHitlMachine(); - let state = await createInitialState(machine, { task: 'test' }); - state = await step(machine, state); // → waiting - - state = sendEvent(machine, state, { type: 'user.message', message: 'one' }); - state = sendEvent(machine, state, { type: 'user.message', message: 'two' }); - state = sendEvent(machine, state, { type: 'user.message', message: 'three' }); - - expect((state.context.messages as any[]).length).toBe(3); + expect(next.context.messages[0]!.content).toBe('restored'); }); - test('transitions to new state with event', async () => { - const machine = createHitlMachine(); - let state = await createInitialState(machine, { task: 'test' }); - state = await step(machine, state); // → waiting at gathering - - state = sendEvent(machine, state, { type: 'user.approve' }); - expect(state.value).toBe('processing'); - expect(state.status).toBe('running'); - }); - - test('throws on unknown event', async () => { - const machine = createSimpleMachine(); - const state = await createInitialState(machine, undefined); - expect(() => - sendEvent(machine, state, { type: 'nonexistent' }) - ).toThrow("No handler for event 'nonexistent'"); - }); - - test('parent event preempts child in nested state', async () => { + test('nested round-trip', async () => { const machine = createNestedMachine(); - let state = await createInitialState(machine, undefined); - expect(state.value).toBe('handling.checkEligibility'); - - // Parent's on handler should preempt - const next = sendEvent(machine, state, { type: 'user.cancel' }); - expect(next.value).toBe('cancelled'); - }); -}); - -describe('stream', () => { - test('yields snapshots for each transition', async () => { - const adapter = mockAdapter([{ choice: 'technical' }]); - const machine = createDecideMachine(adapter); - const state = await createInitialState(machine, undefined); - - const snapshots = []; - for await (const snapshot of stream(machine, state)) { - snapshots.push(snapshot); - } - - expect(snapshots.length).toBeGreaterThanOrEqual(3); // initial + classifying→handling + handling→done + done - expect(snapshots[0]!.value).toBe('classifying'); - const last = snapshots[snapshots.length - 1]!; - expect(last.status).toBe('done'); + const s = machine.getInitialState(); + const restored = machine.resolveState(JSON.parse(JSON.stringify(s))); + expect(restored.value).toEqual({ handling: 'checkEligibility' }); + const r = await machine.execute(restored); + expect(r.status).toBe('done'); }); }); describe('decide', () => { - test('creates state config with decide type', () => { - const config = decide({ - model: 'test', - prompt: 'test prompt', - options: { - a: { description: 'Option A' }, - b: { description: 'Option B' }, - }, - onDone: ({ result }) => ({ target: result.choice }), - }); - expect(config.__type).toBe('decide'); - expect(config.__decideConfig).toBeDefined(); - expect(config.__decideConfig!.model).toBe('test'); - }); - - test('calls adapter with resolved prompt function', async () => { - const decideSpy = vi.fn().mockResolvedValue({ - choice: 'a', - data: {}, - }); - const adapter: AgentAdapter = { decide: decideSpy }; - + test('calls adapter with resolved prompt', async () => { + const spy = vi.fn().mockResolvedValue({ choice: 'a', data: {} }); const machine = createAgentMachine({ - id: 'decide-test', + id: 'dtest', context: () => ({ topic: 'cats' }), - adapter, + adapter: { decide: spy }, initial: 'choosing', states: { choosing: decide({ model: 'my-model', prompt: ({ context }) => `About ${context.topic}`, - options: { - a: { description: 'A' }, - b: { description: 'B' }, - }, + options: { a: { description: 'A' }, b: { description: 'B' } }, onDone: ({ result }) => ({ target: 'done', context: { choice: result.choice }, @@ -685,36 +615,24 @@ describe('decide', () => { done: { type: 'final' }, }, }); - - const state = await createInitialState(machine, undefined); - await step(machine, state); - - expect(decideSpy).toHaveBeenCalledWith({ - model: 'my-model', - prompt: 'About cats', - options: { - a: { description: 'A' }, - b: { description: 'B' }, - }, - reasoning: undefined, - }); + await machine.invoke(machine.getInitialState()); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ model: 'my-model', prompt: 'About cats' }) + ); }); - test('supports per-state adapter override', async () => { - const machineAdapter = mockAdapter([{ choice: 'machine' }]); - const stateAdapter = mockAdapter([{ choice: 'state' }]); - + test('per-state adapter override', async () => { const machine = createAgentMachine({ - id: 'override-test', + id: 'override', context: () => ({ choice: null as string | null }), - adapter: machineAdapter, + adapter: mockAdapter([{ choice: 'machine' }]), initial: 'choosing', states: { choosing: decide({ model: 'test', - adapter: stateAdapter, // overrides machine adapter + adapter: mockAdapter([{ choice: 'state' }]), prompt: 'pick', - options: { state: { description: 'S' }, machine: { description: 'M' } }, + options: { s: { description: 'S' }, m: { description: 'M' } }, onDone: ({ result }) => ({ target: 'done', context: { choice: result.choice }, @@ -723,151 +641,157 @@ describe('decide', () => { done: { type: 'final' }, }, }); - - const state = await createInitialState(machine, undefined); - const result = await run(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.context.choice).toBe('state'); // used state adapter, not machine - } + const r = await machine.execute(machine.getInitialState()); + expect(r.status === 'done' && r.context.choice).toBe('state'); }); - test('supports reasoning', async () => { - const adapter: AgentAdapter = { - decide: async () => ({ - choice: 'a', - data: {}, - reasoning: 'Because reasons', - }), - }; - + test('option schemas typed data', async () => { const machine = createAgentMachine({ - id: 'reasoning-test', - context: () => ({ reasoning: null as string | null }), - adapter, + id: 'data', + context: () => ({ items: null as string[] | null }), + adapter: { + decide: async () => ({ + choice: 'withData', + data: { items: ['a', 'b'] }, + }), + }, initial: 'choosing', states: { choosing: decide({ model: 'test', prompt: 'pick', - reasoning: true, - options: { a: { description: 'A' } }, + options: { + withData: { + description: 'Has data', + schema: z.object({ items: z.array(z.string()) }), + }, + withoutData: { description: 'No data' }, + }, onDone: ({ result }) => ({ target: 'done', - context: { reasoning: result.reasoning ?? null }, + context: { + items: + result.choice === 'withData' + ? (result.data as { items: string[] }).items + : null, + }, }), }), done: { type: 'final' }, }, }); + const r = await machine.execute(machine.getInitialState()); + expect(r.status === 'done' && r.context.items).toEqual(['a', 'b']); + }); +}); + +describe('type: choice', () => { + test('inline choice state with typed context', async () => { + const adapter = mockAdapter([{ choice: 'technical' }]); + const machine = createAgentMachine({ + id: 'choice-test', + context: () => ({ issue: 'App crashes', result: null as string | null }), + adapter, + initial: 'routing', + states: { + routing: { + type: 'choice', + model: 'test-model', + prompt: ({ context }) => `Route: ${context.issue}`, // context typed ✓ + options: { + billing: { description: 'Billing' }, + technical: { description: 'Technical' }, + }, + onDone: ({ result, context }) => ({ + target: 'done', + context: { result: `${result.choice}: ${context.issue}` }, + }), + }, + done: { type: 'final', output: ({ context }) => ({ result: context.result }) }, + }, + }); - const state = await createInitialState(machine, undefined); - const result = await run(machine, state); - if (result.status === 'done') { - expect(result.context.reasoning).toBe('Because reasons'); + const r = await machine.execute(machine.getInitialState()); + expect(r.status).toBe('done'); + if (r.status === 'done') { + expect(r.output).toEqual({ result: 'technical: App crashes' }); } }); - test('decide with option schemas passes data', async () => { + test('choice with event preemption', async () => { + let called = false; const adapter: AgentAdapter = { - decide: async () => ({ - choice: 'withData', - data: { items: ['a', 'b'] }, - }), + decide: async () => { + called = true; + // Slow adapter — in real use, event would preempt + return { choice: 'a', data: {} }; + }, }; - const machine = createAgentMachine({ - id: 'data-test', - context: () => ({ items: null as string[] | null }), + id: 'choice-preempt', + context: () => ({}), adapter, initial: 'choosing', states: { - choosing: decide({ + choosing: { + type: 'choice', model: 'test', prompt: 'pick', - options: { - withData: { - description: 'Has data', - schema: z.object({ items: z.array(z.string()) }), - }, - withoutData: { description: 'No data' }, + options: { a: { description: 'A' } }, + onDone: () => ({ target: 'done' }), + on: { + cancel: () => ({ target: 'cancelled' }), }, - onDone: ({ result }) => ({ - target: 'done', - context: { - items: result.choice === 'withData' ? (result.data as any).items : null, - }, - }), - }), + }, done: { type: 'final' }, + cancelled: { type: 'final' }, }, }); - const state = await createInitialState(machine, undefined); - const result = await run(machine, state); - if (result.status === 'done') { - expect(result.context.items).toEqual(['a', 'b']); - } + // Can send event to choice state (preemption) + const state = machine.getInitialState(); + const next = machine.transition(state, { type: 'cancel' }); + expect(next.value).toBe('cancelled'); }); }); describe('classify', () => { - test('creates state config with classify type', () => { - const config = classify({ - model: 'test', - prompt: 'classify this', - into: { - a: { description: 'Category A' }, - b: { description: 'Category B' }, - }, - onDone: ({ result }) => ({ target: result.category }), - }); - expect(config.__type).toBe('classify'); - expect(config.__classifyConfig).toBeDefined(); - expect(config.__decideConfig).toBeDefined(); // classify wraps decide - }); - - test('result has category field', async () => { - const adapter = mockAdapter([{ choice: 'billing' }]); - const machine = createClassifyMachine(adapter); - const state = await createInitialState(machine, undefined); - - const result = await run(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ category: 'billing' }); - } + test('result has typed category', async () => { + const machine = createClassifyMachine( + mockAdapter([{ choice: 'billing' }]) + ); + const r = await machine.execute(machine.getInitialState()); + expect(r.status === 'done' && r.output).toEqual({ category: 'billing' }); }); }); describe('nested states', () => { - test('enters compound state initial child', async () => { - const machine = createNestedMachine(); - const state = await createInitialState(machine, undefined); - expect(state.value).toBe('handling.checkEligibility'); - }); - - test('conditional compound initial based on context', async () => { + test('conditional compound initial', async () => { const machine = createAgentMachine({ - id: 'cond-nested', - context: () => ({ category: 'technical' as string }), + id: 'cond', + context: () => ({ + category: 'technical' as string, + resolution: null as string | null, + }), initial: 'handling', states: { handling: { - initial: ({ context }) => { - if (context.category === 'billing') return { target: 'billing' }; - return { target: 'technical' }; - }, + initial: ({ context }) => + context.category === 'billing' + ? { target: 'billing' } + : { target: 'technical' }, states: { billing: { - run: async () => ({ result: 'billing handled' }), + invoke: async () => ({}), onDone: () => ({ target: 'childDone' }), }, technical: { - run: async () => ({ result: 'tech handled' }), + invoke: async () => ({ result: 'tech handled' }), onDone: ({ result }) => ({ target: 'childDone', - context: { resolution: (result as any).result }, + context: { + resolution: (result as { result: string }).result, + }, }), }, childDone: { type: 'final' }, @@ -880,388 +804,558 @@ describe('nested states', () => { }, }, }); - - const state = await createInitialState(machine, undefined); - expect(state.value).toBe('handling.technical'); - - const result = await run(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ resolution: 'tech handled' }); - } + expect(machine.getInitialState().value).toEqual({ handling: 'technical' }); + const r = await machine.execute(machine.getInitialState()); + expect(r.status === 'done' && r.output).toEqual({ + resolution: 'tech handled', + }); }); - test('parent onDone fires when child reaches final', async () => { + test('parent preempts → cancel', async () => { const machine = createNestedMachine(); - const state = await createInitialState(machine, undefined); - - const result = await run(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - // The chain: checkEligibility → processRefund → childDone → (parent onDone) → respond → done - expect(result.output).toEqual({ resolution: 'Refund processed' }); - } + let s = machine.transition(machine.getInitialState(), { + type: 'user.cancel', + }); + const r = await machine.execute(s); + expect(r.status === 'done' && r.output).toEqual({ cancelled: true }); }); +}); - test('parent event handler preempts children', async () => { - const machine = createNestedMachine(); - const state = await createInitialState(machine, undefined); - expect(state.value).toBe('handling.checkEligibility'); - - const next = sendEvent(machine, state, { type: 'user.cancel' }); - expect(next.value).toBe('cancelled'); - expect(next.status).toBe('running'); +describe('P1: nested final without parent onDone', () => { + test('halts at pending', async () => { + const machine = createAgentMachine({ + id: 'p1', + context: () => ({}), + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { c: { type: 'final' } }, + }, + }, + onDone: () => ({ target: 'result' }), + }, + result: { type: 'final' }, + }, + }); + const s = await machine.invoke(machine.getInitialState()); + expect(s.status).toBe('pending'); + expect(s.value).toEqual({ a: { b: 'c' } }); + }); - const result = await run(machine, next); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ cancelled: true }); - } + test('ancestor on still reachable', async () => { + const machine = createAgentMachine({ + id: 'p1-esc', + context: () => ({}), + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { c: { type: 'final' } }, + }, + }, + on: { escape: () => ({ target: 'out' }) }, + }, + out: { type: 'final', output: () => ({ escaped: true }) }, + }, + }); + let r = await machine.execute(machine.getInitialState()); + expect(r.status).toBe('pending'); + const s = machine.transition(r.state, { type: 'escape' }); + r = await machine.execute(s); + expect(r.status === 'done' && r.output).toEqual({ escaped: true }); }); }); -describe('full workflow: HITL', () => { - test('gather → process → review → done', async () => { +describe('P2: event validation', () => { + test('rejects invalid payload', async () => { const machine = createHitlMachine(); - - // Start - let state = await createInitialState(machine, { task: 'build feature' }); - let result = await run(machine, state); - expect(result.status).toBe('waiting'); - expect(result.status === 'waiting' && result.value).toBe('gathering'); - - // Send messages - state = sendEvent(machine, result.state, { type: 'user.message', message: 'req A' }); - state = sendEvent(machine, state, { type: 'user.message', message: 'req B' }); - - // Approve to move to processing - state = sendEvent(machine, state, { type: 'user.approve' }); - result = await run(machine, state); - expect(result.status).toBe('waiting'); - expect(result.status === 'waiting' && result.value).toBe('reviewing'); - expect(result.status === 'waiting' && result.context.result).toBe('Processed: req A, req B'); - - // Approve the review - state = sendEvent(machine, result.state, { type: 'user.approve' }); - result = await run(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ result: 'Processed: req A, req B' }); - } + const s = await machine.invoke(machine.getInitialState({ task: 'x' })); + expect(() => + // @ts-expect-error — deliberately invalid for runtime test + machine.transition(s, { type: 'user.message', message: 123 }) + ).toThrow(); }); - test('gather → process → review → reject → process → review → done', async () => { + test('accepts valid payload', async () => { const machine = createHitlMachine(); - - let state = await createInitialState(machine, { task: 'write code' }); - let result = await run(machine, state); - - // Send a message - state = sendEvent(machine, result.state, { type: 'user.message', message: 'initial' }); - state = sendEvent(machine, state, { type: 'user.approve' }); - result = await run(machine, state); - expect(result.status === 'waiting' && result.value).toBe('reviewing'); - - // Reject with feedback (sends us back to processing) - state = sendEvent(machine, result.state, { type: 'user.message', message: 'fix this' }); - result = await run(machine, state); - expect(result.status === 'waiting' && result.value).toBe('reviewing'); - expect(result.status === 'waiting' && result.context.result).toBe('Processed: initial, fix this'); - - // Approve - state = sendEvent(machine, result.state, { type: 'user.approve' }); - result = await run(machine, state); - expect(result.status).toBe('done'); + const s = await machine.invoke(machine.getInitialState({ task: 'x' })); + const next = machine.transition(s, { + type: 'user.message', + message: 'ok', + }); + expect(next.context.messages.length).toBe(1); }); - test('cancel at any point', async () => { - const machine = createHitlMachine(); - - let state = await createInitialState(machine, { task: 'test' }); - let result = await run(machine, state); - - state = sendEvent(machine, result.state, { type: 'user.cancel' }); - result = await run(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ cancelled: true }); - } + test('skips when no schema', () => { + const machine = createSimpleMachine(); + const s = machine.transition(machine.getInitialState(), { type: 'start' }); + expect(s.value).toBe('running'); }); }); -describe('serialization', () => { - test('state round-trips through JSON', async () => { +describe('full HITL workflow', () => { + test('gather → process → review → done', async () => { const machine = createHitlMachine(); - let state = await createInitialState(machine, { task: 'test' }); - let result = await run(machine, state); + let s = machine.getInitialState({ task: 'build' }); + let r = await machine.execute(s); + expect(r.status).toBe('pending'); - // Serialize → deserialize - const json = JSON.stringify(result.state); - const restored = JSON.parse(json); - - // Send event on restored state - const next = sendEvent(machine, restored, { + s = machine.transition(r.state, { type: 'user.message', - message: 'from restored', + message: 'req A', + }); + s = machine.transition(s, { type: 'user.message', message: 'req B' }); + s = machine.transition(s, { type: 'user.approve' }); + r = await machine.execute(s); + expect(r.status === 'pending' && r.context.result).toBe( + 'Processed: req A, req B' + ); + + s = machine.transition(r.state, { type: 'user.approve' }); + r = await machine.execute(s); + expect(r.status === 'done' && r.output).toEqual({ + result: 'Processed: req A, req B', }); - expect((next.context.messages as any[])[0].content).toBe('from restored'); }); - test('nested state round-trips through JSON', async () => { - const machine = createNestedMachine(); - const state = await createInitialState(machine, undefined); - - const json = JSON.stringify(state); - const restored = JSON.parse(json); - - expect(restored.value).toBe('handling.checkEligibility'); - - // Can continue execution from restored state - const result = await run(machine, restored); - expect(result.status).toBe('done'); + test('cancel', async () => { + const machine = createHitlMachine(); + let r = await machine.execute(machine.getInitialState({ task: 'x' })); + const s = machine.transition(r.state, { type: 'user.cancel' }); + r = await machine.execute(s); + expect(r.status === 'done' && r.output).toEqual({ cancelled: true }); }); }); -describe('createAdapter', () => { - test('creates a custom adapter', () => { - const adapter = createAdapter({ - decide: async () => ({ choice: 'a', data: {} }), +describe('type inference', () => { + // ─── state.value ─── + + test('state.value is typed union of state names', () => { + const machine = createAgentMachine({ + id: 't', + context: () => ({ x: 1 }), + initial: 'a', + states: { + a: { on: { go: () => ({ target: 'b' }) } }, + b: { type: 'final' }, + }, }); - expect(adapter.decide).toBeDefined(); + const s = machine.getInitialState(); + + s.value satisfies 'a' | 'b'; + // @ts-expect-error — 'c' is not a valid state name + s.value satisfies 'c'; }); -}); -describe('edge cases', () => { - test('state with run but no onDone and no on is a dead end', async () => { + test('nested state values are xstate-style objects', () => { const machine = createAgentMachine({ - id: 'dead-end', + id: 't', context: () => ({}), - initial: 'stuck', + initial: 'parent', states: { - stuck: { - run: async () => ({ done: true }), + parent: { + initial: 'child', + states: { child: { type: 'final' } }, + onDone: () => ({ target: 'done' }), }, + done: { type: 'final' }, }, }); - const state = await createInitialState(machine, undefined); - const result = await step(machine, state); - // run completes but no onDone and no on → state doesn't change - expect(result.value).toBe('stuck'); - }); + const s = machine.getInitialState(); - test('already done state returns as-is', async () => { - const machine = createSimpleMachine(); - const doneState = { - value: 'done', - params: {}, - context: { count: 1 }, - status: 'done' as const, - output: { result: 1 }, - }; - const result = await step(machine, doneState); - expect(result).toEqual(doneState); + // Runtime check — nested values are objects + expect(s.value).toEqual({ parent: 'child' }); }); - test('already errored state returns as-is', async () => { - const machine = createSimpleMachine(); - const errorState = { - value: 'running', - params: {}, - context: { count: 0 }, - status: 'error' as const, - error: 'something went wrong', - }; - const result = await step(machine, errorState); - expect(result).toEqual(errorState); + // ─── state.context ─── + + test('context typed from context() return', () => { + const machine = createAgentMachine({ + id: 't', + context: () => ({ name: 'test', count: 0, flag: true }), + initial: 'idle', + states: { idle: { type: 'final' } }, + }); + const s = machine.getInitialState(); + + s.context.name satisfies string; + s.context.count satisfies number; + s.context.flag satisfies boolean; + // @ts-expect-error — name is string not number + s.context.name satisfies number; + // @ts-expect-error — 'nope' does not exist + s.context.nope; }); -}); -describe('P1: nested final state without parent onDone', () => { - test('does not mark machine as done when parent lacks onDone', async () => { - // a.b.c where c is final, b has NO onDone, a has onDone + test('context typed in on handlers', () => { const machine = createAgentMachine({ - id: 'p1-bug', - context: () => ({ resolved: false }), - initial: 'a', + id: 't', + context: () => ({ items: ['a', 'b'] }), + initial: 'idle', states: { - a: { - initial: 'b', - states: { - b: { - initial: 'c', - // NO onDone — should halt here, not mark machine done - states: { - c: { type: 'final' }, - }, + idle: { + on: { + add: ({ context }) => { + context.items satisfies string[]; + // @ts-expect-error — 'nope' does not exist + context.nope; + return { context: { items: [...context.items, 'c'] } }; }, }, - onDone: () => ({ - target: 'result', - context: { resolved: true }, + }, + }, + }); + const next = machine.transition(machine.getInitialState(), { type: 'add' }); + expect(next.context.items).toEqual(['a', 'b', 'c']); + }); + + test('context typed in invoke', () => { + const machine = createAgentMachine({ + id: 't', + context: () => ({ n: 42 }), + initial: 'work', + states: { + work: { + invoke: async ({ context }) => { + context.n satisfies number; + // @ts-expect-error — 'nope' does not exist + context.nope; + return { doubled: context.n * 2 }; + }, + onDone: ({ result }) => ({ + target: 'done', + context: { n: (result as { doubled: number }).doubled }, }), }, - result: { + done: { type: 'final' }, + }, + }); + return machine.execute(machine.getInitialState()).then((r) => { + expect(r.status === 'done' && r.context.n).toBe(84); + }); + }); + + test('context typed in output', () => { + const machine = createAgentMachine({ + id: 't', + context: () => ({ score: 100 }), + initial: 'done', + states: { + done: { type: 'final', - output: ({ context }) => ({ resolved: context.resolved }), + output: ({ context }) => { + context.score satisfies number; + // @ts-expect-error — 'nope' does not exist + context.nope; + return { score: context.score }; + }, }, }, }); + expect(machine.getInitialState).toBeDefined(); + }); - const state = await createInitialState(machine, undefined); - expect(state.value).toBe('a.b.c'); - - // Step: c is final, parent b has no onDone → should wait, NOT done - const next = await step(machine, state); - expect(next.status).toBe('waiting'); - expect(next.value).toBe('a.b.c'); // stays put + test('context typed in initial function', () => { + const machine = createAgentMachine({ + id: 't', + context: () => ({ mode: 'fast' as 'fast' | 'slow' }), + initial: ({ context }) => { + context.mode satisfies 'fast' | 'slow'; + // @ts-expect-error — 'nope' does not exist + context.nope; + return { target: (context.mode === 'fast' ? 'a' : 'b') as 'a' | 'b' }; + }, + states: { + a: { type: 'final' }, + b: { type: 'final' }, + }, + }); + expect(machine.getInitialState().value).toBe('a'); }); - test('correctly bubbles when parent has onDone', async () => { - // Same structure but b HAS onDone → bDone(final) → a.onDone → result + // ─── schemas.context (overload 1) ─── + + test('schemas.context drives TContext + input typed from schemas.input', () => { const machine = createAgentMachine({ - id: 'p1-fixed', - context: () => ({}), - initial: 'a', + id: 't', + schemas: { + context: z.object({ count: z.number(), label: z.string() }), + input: z.object({ initial: z.number() }), + }, + context: (input) => { + input.initial satisfies number; + // @ts-expect-error — 'nope' does not exist on input + input.nope; + return { count: input.initial, label: 'hello' }; + }, + initial: 'idle', states: { - a: { - initial: 'b', - states: { - b: { - initial: 'c', - states: { - c: { type: 'final' }, - }, - onDone: () => ({ target: 'bDone' }), - }, - bDone: { type: 'final' }, + idle: { + invoke: async ({ context }) => { + context.count satisfies number; + context.label satisfies string; + // @ts-expect-error — 'nope' does not exist + context.nope; + return {}; }, - onDone: () => ({ target: 'result' }), - }, - result: { - type: 'final', - output: () => ({ ok: true }), }, }, }); + const s = machine.getInitialState({ initial: 5 }); - const state = await createInitialState(machine, undefined); - const result = await run(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ ok: true }); - } + s.context.count satisfies number; + s.context.label satisfies string; + // @ts-expect-error — 'nope' does not exist + s.context.nope; + expect(s.context.count).toBe(5); }); - test('ancestor on handlers still work when halted at final child', async () => { + // ─── schemas.events ─── + + test('transition events typed from schemas.events', () => { const machine = createAgentMachine({ - id: 'p1-escape', - context: () => ({}), - initial: 'a', + id: 't', + schemas: { + events: { + greet: z.object({ name: z.string() }), + ping: z.object({}), + }, + }, + context: () => ({ msg: '' }), + initial: 'idle', states: { - a: { - initial: 'b', - states: { - b: { - initial: 'c', - states: { c: { type: 'final' } }, - // no onDone - }, - }, + idle: { on: { - escape: () => ({ target: 'escaped' }), + greet: ({ event }) => ({ + context: { msg: `hi ${(event as { name: string }).name}` }, + }), + ping: () => ({}), }, }, - escaped: { - type: 'final', - output: () => ({ escaped: true }), - }, }, }); + const s = machine.getInitialState(); - const state = await createInitialState(machine, undefined); - let result = await run(machine, state); - expect(result.status).toBe('waiting'); // halted at a.b.c - - // Ancestor on handler should still be reachable - const next = sendEvent(machine, result.state, { type: 'escape' }); - expect(next.value).toBe('escaped'); - result = await run(machine, next); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ escaped: true }); - } - }); -}); + // Valid events compile + machine.transition(s, { type: 'greet', name: 'world' }); + machine.transition(s, { type: 'ping' }); -describe('P2: event payload validation', () => { - test('rejects event with invalid payload', async () => { - const machine = createHitlMachine(); - let state = await createInitialState(machine, { task: 'test' }); - state = await step(machine, state); // → waiting + // @ts-expect-error — 'bogus' is not a valid event type + expect(() => machine.transition(s, { type: 'bogus' })).toThrow(); + + // @ts-expect-error — missing required 'name' field + expect(() => machine.transition(s, { type: 'greet' })).toThrow(); - // user.message schema requires { message: string } - // Sending wrong type should throw expect(() => - sendEvent(machine, state, { type: 'user.message', message: 123 as any }) + machine.transition(s, { + type: 'greet', + // @ts-expect-error — name must be string + name: 123, + }) ).toThrow(); - }); - test('accepts event with valid payload', async () => { - const machine = createHitlMachine(); - let state = await createInitialState(machine, { task: 'test' }); - state = await step(machine, state); + const next = machine.transition(s, { type: 'greet', name: 'world' }); + expect(next.context.msg).toBe('hi world'); + }); - // Should not throw - const next = sendEvent(machine, state, { - type: 'user.message', - message: 'valid string', + test('no schemas.events → untyped events (any type string)', () => { + const machine = createAgentMachine({ + id: 't', + context: () => ({}), + initial: 'idle', + states: { + idle: { on: { anything: () => ({}) } }, + }, }); - expect((next.context.messages as any[]).length).toBe(1); + // Any event type string accepted when no schemas.events + machine.transition(machine.getInitialState(), { type: 'anything' }); + // Unknown events still throw at runtime (no handler) + expect(() => + machine.transition(machine.getInitialState(), { type: 'nope' }) + ).toThrow(); }); - test('skips validation when no schema declared', async () => { - const machine = createSimpleMachine(); - const state = await createInitialState(machine, undefined); - - // 'start' event has no schema — should not throw - const next = sendEvent(machine, state, { type: 'start' }); - expect(next.value).toBe('running'); - }); + // ─── paramsSchema per state ─── - test('state-level schema overrides root-level', async () => { + test('params typed per state from paramsSchema', async () => { const machine = createAgentMachine({ - id: 'schema-override', - context: () => ({ val: '' }), - events: { - act: z.object({ type: z.literal('act'), val: z.string() }), - }, + id: 't', + context: () => ({ result: '' }), initial: 'a', states: { a: { - events: { - // Override: requires val to be a number - act: z.object({ type: z.literal('act'), val: z.number() }), + paramsSchema: z.object({ count: z.number() }), + invoke: async ({ params }) => { + params.count satisfies number; + // @ts-expect-error — count is number not string + params.count satisfies string; + // @ts-expect-error — 'name' not on a's params + params.name; + return { doubled: params.count * 2 }; }, - on: { - act: ({ event }) => ({ - target: 'b', - context: { val: String((event as any).val) }, - }), + onDone: ({ result }) => ({ + target: 'b', + params: { name: 'hello' }, + context: { result: String((result as { doubled: number }).doubled) }, + }), + }, + b: { + paramsSchema: z.object({ name: z.string() }), + invoke: async ({ params }) => { + params.name satisfies string; + // @ts-expect-error — name is string not number + params.name satisfies number; + // @ts-expect-error — 'count' not on b's params + params.count; + return { greeting: `hi ${params.name}` }; }, + onDone: ({ result }) => ({ + target: 'done', + context: { result: (result as { greeting: string }).greeting }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ result: context.result }), }, - b: { type: 'final' }, }, }); - const state = await createInitialState(machine, undefined); + let state = machine.resolveState({ + ...machine.getInitialState(), + params: { a: { count: 21 } }, + }); + const r = await machine.execute(state); + expect(r.status === 'done' && r.output).toEqual({ result: 'hi hello' }); + }); + + test('no paramsSchema → params is Record', () => { + createAgentMachine({ + id: 't', + context: () => ({}), + initial: 'idle', + states: { + idle: { + invoke: async ({ params }) => { + params satisfies Record; + return {}; + }, + }, + }, + }); + }); + + // ─── type: 'choice' context typing ─── + + test('type: choice gets typed context in prompt and onDone', () => { + const adapter = mockAdapter([{ choice: 'a' }]); + const machine = createAgentMachine({ + id: 't', + context: () => ({ topic: 'cats', result: '' }), + adapter, + initial: 'choosing', + states: { + choosing: { + type: 'choice', + model: 'test', + prompt: ({ context }) => { + context.topic satisfies string; + // @ts-expect-error — 'nope' does not exist + context.nope; + return `About ${context.topic}`; + }, + options: { a: { description: 'A' } }, + onDone: ({ result, context }) => { + result.choice satisfies string; + context.topic satisfies string; + return { target: 'done', context: { result: result.choice } }; + }, + }, + done: { type: 'final' }, + }, + }); + expect(machine.id).toBe('t'); + }); + + // ─── getInitialState input typing ─── + + test('getInitialState requires input when schemas.input provided', () => { + const machine = createAgentMachine({ + id: 't', + schemas: { + context: z.object({ task: z.string() }), + input: z.object({ task: z.string() }), + }, + context: (input) => ({ task: input.task }), + initial: 'idle', + states: { idle: { type: 'final' } }, + }); + + // Valid + machine.getInitialState({ task: 'hello' }); - // String val should fail (state schema requires number) expect(() => - sendEvent(machine, state, { type: 'act', val: 'nope' }) + machine.getInitialState({ + // @ts-expect-error — task must be string + task: 123, + }) ).toThrow(); - // Number val should succeed - const next = sendEvent(machine, state, { type: 'act', val: 42 }); - expect(next.value).toBe('b'); + // @ts-expect-error — missing required input (runtime: validates) + expect(() => machine.getInitialState()).toThrow(); + }); + + test('getInitialState optional when no input schema', () => { + const machine = createAgentMachine({ + id: 't', + context: () => ({ x: 1 }), + initial: 'idle', + states: { idle: { type: 'final' } }, + }); + + // Both valid + machine.getInitialState(); + machine.getInitialState(undefined); + }); +}); + +describe('edge cases', () => { + test('invoke with no onDone is dead end', async () => { + const machine = createAgentMachine({ + id: 'dead', + context: () => ({}), + initial: 'stuck', + states: { stuck: { invoke: async () => ({}) } }, + }); + const s = await machine.invoke(machine.getInitialState()); + expect(s.value).toBe('stuck'); + }); + + test('done state returns as-is', async () => { + const machine = createSimpleMachine(); + const done = { + value: 'done', + params: {}, + context: { count: 1 }, + status: 'done', + output: { result: 1 }, + } as const; + expect(await machine.invoke(done)).toEqual(done); + }); +}); + +describe('createAdapter', () => { + test('creates custom adapter', () => { + const a = createAdapter({ + decide: async () => ({ choice: 'a', data: {} }), + }); + expect(a.decide).toBeDefined(); }); }); diff --git a/src/classify.ts b/src/classify.ts index 78590ba..c4bfff1 100644 --- a/src/classify.ts +++ b/src/classify.ts @@ -1,11 +1,17 @@ -import type { ClassifyConfig, StateConfig } from './types.js'; +import type { ClassifyConfig } from './types.js'; /** * Create a classification state. Sugar over `decide` for simple routing — * categories with descriptions, no per-option schemas. + * + * `result.category` is typed as a union of the `into` keys. + * + * Note: context in prompt callback is untyped. For typed context, use + * inline `type: 'choice'` instead. */ -export function classify(config: ClassifyConfig): StateConfig { - // Convert classify categories into decide options +export function classify< + const TCategories extends Record, +>(config: ClassifyConfig): any { const decideOptions: Record = {}; for (const [key, val] of Object.entries(config.into)) { decideOptions[key] = { description: val.description }; @@ -19,8 +25,7 @@ export function classify(config: ClassifyConfig): StateConfig { adapter: config.adapter, prompt: config.prompt, options: decideOptions, - onDone: ({ result, context }) => { - // Transform decide result → classify result + onDone: ({ result, context }: any) => { return config.onDone({ result: { category: result.choice }, context, diff --git a/src/decide.ts b/src/decide.ts index cdc84aa..4fa8fc6 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -1,10 +1,20 @@ -import type { DecideConfig, StateConfig } from './types.js'; +import type { DecideConfig, StandardSchemaV1 } from './types.js'; /** * Create a decision state where an LLM picks from constrained options. * Each option has a description and optional schema for structured data. + * + * The result type is a discriminated union — `result.choice` narrows `result.data`. + * + * Note: context in prompt callback is untyped. For typed context, use + * inline `type: 'choice'` instead. */ -export function decide(config: DecideConfig): StateConfig { +export function decide< + const TOptions extends Record< + string, + { description: string; schema?: StandardSchemaV1 } + >, +>(config: DecideConfig): any { return { __type: 'decide', __decideConfig: config, diff --git a/src/event.ts b/src/event.ts deleted file mode 100644 index 151167d..0000000 --- a/src/event.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { AgentEvent, AgentMachine, AgentState, StandardSchemaV1 } from './types.js'; -import { applyTransition, resolveStateConfig } from './utils.js'; - -/** - * Send a typed event to the current state. - * Validates the event payload against declared schemas, then searches from - * the current state up through ancestors for a matching handler. - * Parent handlers preempt children. - * - * Returns a new AgentState (synchronous — no async work). - */ -export function sendEvent( - machine: AgentMachine, - state: AgentState, - event: AgentEvent -): AgentState { - // Validate event payload against declared schemas - validateEventSync(machine, state.value, event); - - const parts = state.value.split('.'); - - // Walk from outermost to innermost for preemption semantics: - // parent `on` preempts children. - for (let i = 1; i <= parts.length; i++) { - const path = parts.slice(0, i).join('.'); - const config = resolveStateConfig(machine, path); - - if (config.on && config.on[event.type]) { - const handler = config.on[event.type]!; - const transition = handler({ context: state.context, event }); - - if (transition.target) { - return applyTransition(machine, state, transition, path); - } - - // Self-transition: update context, keep same state/status - return { - ...state, - context: transition.context - ? { ...state.context, ...transition.context } - : state.context, - }; - } - } - - throw new Error( - `No handler for event '${event.type}' in state '${state.value}'` - ); -} - -/** - * Validate event payload against the schema declared in state-level or - * root-level `events`. State events override root events. - * Uses synchronous validation — throws on invalid payload. - */ -function validateEventSync( - machine: AgentMachine, - value: string, - event: AgentEvent -): void { - const schema = findEventSchema(machine, value, event.type); - if (!schema) return; // no schema declared — skip validation - - const result = schema['~standard'].validate(event); - - // Handle sync result (most schema libs return sync for simple schemas) - if (result && typeof result === 'object' && 'issues' in result && result.issues) { - const messages = (result.issues as Array<{ message: string }>) - .map((i) => i.message) - .join(', '); - throw new Error( - `Invalid event '${event.type}': ${messages}` - ); - } - - // If validate returns a Promise, we can't block on it synchronously. - // For async schemas, users should validate before calling sendEvent. - if (result instanceof Promise) { - // Can't await in sync function — skip async validation. - // This is a known limitation; createInitialState handles async validation. - return; - } -} - -/** - * Find the event schema for a given event type. - * Walks from the current state up to root, with state-level schemas - * overriding root-level schemas. - */ -function findEventSchema( - machine: AgentMachine, - value: string, - eventType: string -): StandardSchemaV1 | undefined { - // Check state-level events (innermost wins for schemas) - const parts = value.split('.'); - for (let i = parts.length; i >= 1; i--) { - const path = parts.slice(0, i).join('.'); - const config = resolveStateConfig(machine, path); - if (config.events?.[eventType]) { - return config.events[eventType]; - } - } - - // Fall back to root-level events - return machine.events?.[eventType]; -} diff --git a/src/index.ts b/src/index.ts index 4e5e097..a3d1751 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,5 @@ // Core export { createAgentMachine } from './machine.js'; -export { createInitialState } from './state.js'; -export { step } from './step.js'; -export { run } from './run.js'; -export { stream } from './stream.js'; -export { sendEvent } from './event.js'; // AI primitives export { decide } from './decide.js'; @@ -16,22 +11,21 @@ export { createAdapter } from './adapter.js'; // Types export type { AgentAdapter, - AgentEvent, AgentMachine, - AgentRunResult, AgentSnapshot, AgentState, ClassifyConfig, - ClassifyResult, DecideConfig, - DecideResult, + DecideResultFor, + EventUnion, + ExecuteResult, + InferOutput, MachineConfig, - OnDoneArgs, - OutputArgs, - RunArgs, StandardSchemaV1, StateConfig, + StateValue, + StateValueOf, Trace, - TransitionArgs, + TransitionEvent, TransitionResult, } from './types.js'; diff --git a/src/machine.ts b/src/machine.ts index 2f07bbf..1027e97 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -1,9 +1,411 @@ -import type { AgentMachine, MachineConfig } from './types.js'; - -/** - * Create an agent machine definition. - * The machine is a pure configuration object — no runtime state. - */ -export function createAgentMachine(config: MachineConfig): AgentMachine { - return config; +import type { + AgentMachine, + AgentSnapshot, + AgentState, + ExecuteResult, + MachineConfig, + StandardSchemaV1, + StateConfig, + StateValue, + TransitionEvent, + TransitionResult, +} from './types.js'; +import { + applyTransition, + enterCompoundStates, + findEventSchema, + getAvailableEvents, + getParentConfig, + getParams, + pathToValue, + resolveInitial, + resolveStateConfig, + validateSchemaSync, + valueToPath, +} from './utils.js'; + +import type { InternalState } from './utils.js'; + +function toInternal(state: AgentState): InternalState { + return { ...state, value: valueToPath(state.value) }; +} + +function toExternal(state: InternalState): AgentState { + return { ...state, value: pathToValue(state.value) }; +} + +// Per-state node config with typed params via TParamsMap[K] +type StateNodeDef, TParams> = { + type?: 'final' | 'choice'; + paramsSchema?: StandardSchemaV1; + invoke?: (args: { + context: TContext; + params: NoInfer; + signal?: AbortSignal; + }) => Promise; + onDone?: (args: { result: any; context: TContext }) => TransitionResult; + on?: Record TransitionResult>; + events?: Record; + output?: (args: { context: TContext }) => unknown; + initial?: string | ((args: { context: TContext; params: Record }) => TransitionResult); + states?: Record>; + // choice-specific + model?: string; + adapter?: import('./types.js').AgentAdapter; + prompt?: string | ((args: { context: TContext; params: NoInfer }) => string); + options?: Record; + reasoning?: boolean; + // internal (from decide/classify wrappers) + __type?: 'decide' | 'classify'; + __decideConfig?: any; + __classifyConfig?: any; +}; + +// Mapped states type: each key K gets its own params from TParamsMap[K] +type StatesWithParams< + TContext extends Record, + TParamsMap extends Record, +> = { + [K in keyof TParamsMap]: StateNodeDef; +}; + +// ─── Overload 1: schemas.context drives TContext ─── +export function createAgentMachine< + TInput, + TContext extends Record, + const TEvents extends Record, + const TParamsMap extends Record, +>(config: { + id: string; + schemas: { + context: StandardSchemaV1; + input?: StandardSchemaV1; + events?: TEvents; + }; + context: (input: NoInfer) => NoInfer; + adapter?: import('./types.js').AgentAdapter; + initial: + | (keyof TParamsMap & string) + | ((args: { context: NoInfer }) => { + target: keyof TParamsMap & string; + params?: Record; + }); + states: StatesWithParams, TParamsMap>; +}): AgentMachine>; + +// ─── Overload 2: context() return drives TContext ─── +export function createAgentMachine< + TInput, + TContext extends Record, + const TEvents extends Record, + const TParamsMap extends Record, +>(config: { + id: string; + schemas?: { + input?: StandardSchemaV1; + context?: never; + events?: TEvents; + }; + context: (input: TInput) => TContext; + adapter?: import('./types.js').AgentAdapter; + initial: + | (keyof TParamsMap & string) + | ((args: { context: TContext }) => { + target: keyof TParamsMap & string; + params?: Record; + }); + states: StatesWithParams; +}): AgentMachine>; + +// ─── Implementation ─── + +export function createAgentMachine( + machineConfig: MachineConfig +): AgentMachine { + const cfg = machineConfig; + + // ─── getInitialState (sync) ─── + + function getInitialState(...args: [input?: unknown]): AgentState { + const input = args[0]; + + let validatedInput = input; + const inputSchema = cfg.schemas?.input; + if (inputSchema) { + validatedInput = validateSchemaSync(inputSchema, input); + } + + const context = cfg.context(validatedInput); + const init = resolveInitial(cfg.initial, { context, params: {} }); + + if (!init.target) { + throw new Error('Initial transition must specify a target state'); + } + + let internal: InternalState = { + value: init.target, + params: {}, + context: init.context ? { ...context, ...init.context } : context, + status: 'active', + }; + if (init.params) { + internal.params = { [init.target]: init.params }; + } + internal = enterCompoundStates(cfg, internal as any) as any; + return toExternal(internal); + } + + // ─── resolveState ─── + + function resolveState(raw: { + value: StateValue; + context: Record; + params?: Record>; + status?: AgentState['status']; + output?: unknown; + error?: unknown; + }): AgentState { + return { + value: raw.value, + context: raw.context, + status: raw.status ?? 'active', + params: raw.params ?? {}, + output: raw.output, + error: raw.error, + }; + } + + // ─── transition (sync) ─── + + function transition(state: AgentState, event: { type: string; [k: string]: unknown }): AgentState { + const internal = toInternal(state); + validateEventPayload(internal, event); + + const parts = internal.value.split('.'); + for (let i = 1; i <= parts.length; i++) { + const path = parts.slice(0, i).join('.'); + const stateConfig = resolveStateConfig(cfg, path); + + if (stateConfig.on?.[event.type]) { + const handler = stateConfig.on[event.type]!; + const result = handler({ context: internal.context, event }); + + if (result.target) { + return toExternal( + applyTransition(cfg, internal as any, result, path) as any + ); + } + + return toExternal({ + ...internal, + context: result.context + ? { ...internal.context, ...result.context } + : internal.context, + }); + } + } + + throw new Error( + `No handler for event '${event.type}' in state '${internal.value}'` + ); + } + + function validateEventPayload( + internal: InternalState, + event: { type: string; [k: string]: unknown } + ): void { + const schema = findEventSchema(cfg, internal.value, event.type); + if (!schema) return; + const result = schema['~standard'].validate(event); + if (result instanceof Promise) return; + if (result && typeof result === 'object' && 'issues' in result && result.issues) { + const messages = (result.issues as Array<{ message: string }>) + .map((i) => i.message) + .join(', '); + throw new Error(`Invalid event '${event.type}': ${messages}`); + } + } + + // ─── invoke (async, one step) ─── + + async function invoke(state: AgentState): Promise { + const internal = toInternal(state); + if (internal.status === 'done' || internal.status === 'error') { + return state; + } + const result = await invokeInternal(internal); + return toExternal(result); + } + + async function invokeInternal(state: InternalState): Promise { + const stateConfig = resolveStateConfig(cfg, state.value) as any; + + if (stateConfig.type === 'final') { + return handleFinal(state, stateConfig); + } + // type: 'choice' — inline decide config + if (stateConfig.type === 'choice') { + return handleChoice(state, stateConfig); + } + // decide()/classify() wrapper — __decideConfig set internally + if (stateConfig.__decideConfig) { + return handleDecide(state, stateConfig); + } + if (stateConfig.invoke) { + return handleInvoke(state, stateConfig); + } + if (stateConfig.on) { + return { ...state, status: 'pending' }; + } + if (stateConfig.states && stateConfig.initial) { + return { ...state, status: 'active' }; + } + return { + ...state, + status: 'error', + error: `State '${state.value}' has no invoke, events, or children`, + }; + } + + function handleFinal(state: InternalState, config: any): InternalState { + const output = config.output + ? config.output({ context: state.context }) + : undefined; + + const parts = state.value.split('.'); + if (parts.length <= 1) { + return { ...state, status: 'done', output }; + } + + const parentConfig = getParentConfig(cfg, state.value); + if (parentConfig?.onDone) { + const parentPath = parts.slice(0, -1).join('.'); + const trans = parentConfig.onDone({ result: output, context: state.context }); + return applyTransition(cfg, state as any, trans, parentPath) as any; + } + + return { ...state, status: 'pending' }; + } + + async function handleChoice(state: InternalState, sc: any): Promise { + const adapter = sc.adapter ?? cfg.adapter; + if (!adapter) { + return { ...state, status: 'error', error: `No adapter for choice state '${state.value}'` }; + } + + const params = getParams(state.value, state.params); + const prompt = typeof sc.prompt === 'function' + ? sc.prompt({ context: state.context, params }) + : sc.prompt; + + try { + const result = await adapter.decide({ + model: sc.model, + prompt, + options: sc.options, + reasoning: sc.reasoning, + }); + const trans = sc.onDone({ result, context: state.context }); + return applyTransition(cfg, state as any, trans, state.value) as any; + } catch (error) { + return { ...state, status: 'error', error }; + } + } + + async function handleDecide(state: InternalState, stateConfig: StateConfig): Promise { + const dc = (stateConfig as any).__decideConfig!; + const adapter = dc.adapter ?? cfg.adapter; + if (!adapter) { + return { ...state, status: 'error', error: `No adapter for '${state.value}'` }; + } + + const params = getParams(state.value, state.params); + const prompt = typeof dc.prompt === 'function' + ? dc.prompt({ context: state.context, params }) + : dc.prompt; + + try { + const result = await adapter.decide({ + model: dc.model, + prompt, + options: dc.options, + reasoning: dc.reasoning, + }); + const trans = dc.onDone({ result, context: state.context }); + return applyTransition(cfg, state as any, trans, state.value) as any; + } catch (error) { + return { ...state, status: 'error', error }; + } + } + + async function handleInvoke(state: InternalState, stateConfig: any): Promise { + try { + const result = await stateConfig.invoke!({ + context: state.context, + params: getParams(state.value, state.params), + }); + if (stateConfig.onDone) { + const trans = stateConfig.onDone({ result, context: state.context }); + return applyTransition(cfg, state as any, trans, state.value) as any; + } + if (stateConfig.on) { + return { ...state, status: 'pending' }; + } + return state; + } catch (error) { + return { ...state, status: 'error', error }; + } + } + + // ─── execute ─── + + async function execute(state: AgentState): Promise { + let internal = toInternal(state); + while (internal.status === 'active') { + internal = await invokeInternal(internal); + } + const ext = toExternal(internal); + + switch (internal.status) { + case 'done': + return { status: 'done', state: ext, output: internal.output, context: internal.context }; + case 'pending': + return { + status: 'pending', + state: ext, + value: ext.value, + events: getAvailableEvents(cfg, internal.value), + context: internal.context, + }; + case 'error': + return { status: 'error', state: ext, error: internal.error }; + default: + return { status: 'error', state: ext, error: `Unexpected: ${internal.status}` }; + } + } + + // ─── stream ─── + + async function* stream(state: AgentState): AsyncGenerator { + let internal = toInternal(state); + yield toSnap(internal); + while (internal.status === 'active') { + internal = await invokeInternal(internal); + yield toSnap(internal); + } + } + + function toSnap(s: InternalState): AgentSnapshot { + return { value: pathToValue(s.value), context: s.context, status: s.status, params: s.params }; + } + + return { + id: cfg.id, + getInitialState, + resolveState, + transition, + invoke, + execute, + stream, + } as any; } diff --git a/src/run.ts b/src/run.ts deleted file mode 100644 index 16590ce..0000000 --- a/src/run.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { AgentMachine, AgentRunResult, AgentState } from './types.js'; -import { step } from './step.js'; -import { getAvailableEvents, resolveStateConfig } from './utils.js'; - -/** - * Run the machine until completion, waiting, or error. - * Loops `step()` while status is 'running'. - */ -export async function run( - machine: AgentMachine, - state: AgentState -): Promise { - let current = state; - - while (current.status === 'running') { - current = await step(machine, current); - } - - switch (current.status) { - case 'done': - return { - status: 'done', - state: current, - output: current.output, - context: current.context, - }; - - case 'waiting': - return { - status: 'waiting', - state: current, - value: current.value, - events: getAvailableEvents(machine, current.value), - context: current.context, - }; - - case 'error': - return { - status: 'error', - state: current, - error: current.error, - }; - - default: - return { - status: 'error', - state: current, - error: `Unexpected status: ${current.status}`, - }; - } -} diff --git a/src/state.ts b/src/state.ts deleted file mode 100644 index b9d595b..0000000 --- a/src/state.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { AgentMachine, AgentState } from './types.js'; -import { - enterCompoundStates, - resolveInitial, - validateSchema, -} from './utils.js'; - -/** - * Create the initial serializable state for a machine + input. - * Validates input, initializes context, resolves the initial transition. - */ -export async function createInitialState( - machine: AgentMachine, - input: unknown -): Promise { - // Validate input if schema provided - let validatedInput = input; - if (machine.inputSchema) { - validatedInput = await validateSchema(machine.inputSchema, input); - } - - // Initialize context - const context = machine.context(validatedInput); - - // Resolve initial transition - const init = resolveInitial(machine.initial, { - context, - parentParams: {}, - }); - - if (!init.target) { - throw new Error('Initial transition must specify a target state'); - } - - let state: AgentState = { - value: init.target, - params: {}, - context: init.context ? { ...context, ...init.context } : context, - status: 'running', - }; - - if (init.params) { - state.params = { [init.target]: init.params }; - } - - // Enter compound states if needed - state = enterCompoundStates(machine, state); - - return state; -} diff --git a/src/step.ts b/src/step.ts deleted file mode 100644 index a873ce6..0000000 --- a/src/step.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { AgentMachine, AgentState } from './types.js'; -import { - applyTransition, - getParentConfig, - getParentParams, - resolveStateConfig, -} from './utils.js'; - -/** - * Execute one state transition. - * - * - Final state → status 'done' (or bubble to parent onDone) - * - Decide/classify → call adapter, apply onDone - * - Run state → execute run, apply onDone - * - Waiting state (on, no run) → status 'waiting' - */ -export async function step( - machine: AgentMachine, - state: AgentState -): Promise { - if (state.status === 'done' || state.status === 'error') { - return state; - } - - const config = resolveStateConfig(machine, state.value); - - // ─── Final state ─── - if (config.type === 'final') { - return handleFinalState(machine, state); - } - - // ─── Decide / Classify state ─── - if (config.__decideConfig) { - return handleDecideState(machine, state); - } - - // ─── Run state ─── - if (config.run) { - return handleRunState(machine, state); - } - - // ─── Waiting state ─── - if (config.on) { - return { ...state, status: 'waiting' }; - } - - // ─── Compound state with no run (just initial + children) ─── - // This shouldn't normally happen since enterCompoundStates resolves on entry. - // But handle defensively. - if (config.states && config.initial) { - return { ...state, status: 'running' }; - } - - return { - ...state, - status: 'error', - error: `State '${state.value}' has no run, events, or children`, - }; -} - -async function handleFinalState( - machine: AgentMachine, - state: AgentState -): Promise { - const config = resolveStateConfig(machine, state.value); - - // Compute output - const output = config.output - ? config.output({ context: state.context }) - : undefined; - - const parts = state.value.split('.'); - - // Root-level final state → done - if (parts.length <= 1) { - return { ...state, status: 'done', output }; - } - - // Nested final state — check parent for onDone - const parentConfig = getParentConfig(machine, state.value); - if (parentConfig?.onDone) { - const parentPath = parts.slice(0, -1).join('.'); - const transition = parentConfig.onDone({ - result: output, - context: state.context, - }); - return applyTransition(machine, state, transition, parentPath); - } - - // Parent has no onDone — match xstate semantics: compound state is "done" - // but no transition fires. Machine halts here; ancestor on handlers can - // still match events via sendEvent. - return { ...state, status: 'waiting' }; -} - -async function handleDecideState( - machine: AgentMachine, - state: AgentState -): Promise { - const config = resolveStateConfig(machine, state.value); - const decideConfig = config.__decideConfig!; - - // Get adapter - const adapter = decideConfig.adapter ?? machine.adapter; - if (!adapter) { - return { - ...state, - status: 'error', - error: `No adapter configured for decide state '${state.value}'`, - }; - } - - // Resolve prompt - const parentParams = getParentParams(state); - const prompt = - typeof decideConfig.prompt === 'function' - ? decideConfig.prompt({ context: state.context, parentParams }) - : decideConfig.prompt; - - try { - const result = await adapter.decide({ - model: decideConfig.model, - prompt, - options: decideConfig.options, - reasoning: decideConfig.reasoning, - }); - - // Apply onDone - const transition = decideConfig.onDone({ - result, - context: state.context, - }); - return applyTransition(machine, state, transition, state.value); - } catch (error) { - return { ...state, status: 'error', error }; - } -} - -async function handleRunState( - machine: AgentMachine, - state: AgentState -): Promise { - const config = resolveStateConfig(machine, state.value); - - try { - const result = await config.run!({ - context: state.context, - parentParams: getParentParams(state), - }); - - if (config.onDone) { - const transition = config.onDone({ - result, - context: state.context, - }); - return applyTransition(machine, state, transition, state.value); - } - - // run with no onDone — stay in state, mark waiting if has events - if (config.on) { - return { ...state, status: 'waiting' }; - } - return state; - } catch (error) { - return { ...state, status: 'error', error }; - } -} diff --git a/src/stream.ts b/src/stream.ts deleted file mode 100644 index b7261aa..0000000 --- a/src/stream.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AgentMachine, AgentSnapshot, AgentState } from './types.js'; -import { step } from './step.js'; - -/** - * Yields a snapshot after each transition until completion, waiting, or error. - */ -export async function* stream( - machine: AgentMachine, - state: AgentState -): AsyncGenerator { - let current = state; - - // Yield initial snapshot - yield toSnapshot(current); - - while (current.status === 'running') { - current = await step(machine, current); - yield toSnapshot(current); - } -} - -function toSnapshot(state: AgentState): AgentSnapshot { - return { - value: state.value, - context: state.context, - status: state.status, - params: state.params, - }; -} diff --git a/src/types.ts b/src/types.ts index 93c5a4a..8df97c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,4 @@ -// ─── Standard Schema compatibility ─── -// Minimal Standard Schema V1 interface so any compliant library (zod, valibot, arktype) works. +// ─── Standard Schema V1 ─── export interface StandardSchemaV1 { readonly '~standard': { @@ -16,6 +15,37 @@ export type StandardSchemaResult = export type InferOutput = T extends StandardSchemaV1 ? O : never; +// ─── State Value (xstate-style) ─── + +/** `'idle'` or `{ handling: 'check' }` or `{ a: { b: 'deep' } }` */ +export type StateValue = string | { [key: string]: StateValue }; + +/** Derive the state value union from a states config (depth-limited to 4) */ +export type StateValueOf = _SV1; +type _SV1 = T extends Record + ? { [K in keyof T & string]: T[K] extends { states: infer S extends Record } ? { [P in K]: _SV2 } : K }[keyof T & string] + : never; +type _SV2 = T extends Record + ? { [K in keyof T & string]: T[K] extends { states: infer S extends Record } ? { [P in K]: _SV3 } : K }[keyof T & string] + : never; +type _SV3 = T extends Record + ? { [K in keyof T & string]: K }[keyof T & string] + : never; + +// ─── Event Helpers ─── + +type EventPayload = T extends Record ? unknown : T; + +export type EventUnion> = { + [K in keyof T & string]: { type: K } & EventPayload>; +}[keyof T & string]; + +export type TransitionEvent< + TEvents extends Record, +> = [keyof TEvents & string] extends [never] + ? { type: string; [key: string]: unknown } + : EventUnion; + // ─── Adapter ─── export interface AgentAdapter { @@ -31,13 +61,6 @@ export interface AgentAdapter { }>; } -// ─── Events ─── - -export interface AgentEvent { - type: string; - [key: string]: unknown; -} - // ─── Transition ─── export interface TransitionResult { @@ -46,185 +69,178 @@ export interface TransitionResult { params?: Record; } -export interface TransitionArgs< - TContext = Record, - TEvent extends AgentEvent = AgentEvent, -> { - context: TContext; - event: TEvent; -} +// ─── State Config ─── -export interface OnDoneArgs< - TContext = Record, - TResult = unknown, +export interface StateConfig< + TContext extends Record = Record, > { - result: TResult; - context: TContext; + type?: 'final' | 'choice'; + paramsSchema?: StandardSchemaV1; + invoke?: (args: { + context: TContext; + params: Record; + signal?: AbortSignal; + }) => Promise; + onDone?: (args: { result: any; context: TContext }) => TransitionResult; + on?: Record TransitionResult>; + events?: Record; + output?: (args: { context: TContext }) => unknown; + initial?: + | string + | ((args: { context: TContext; params: Record }) => TransitionResult); + states?: Record>; + // choice-specific + model?: string; + adapter?: AgentAdapter; + prompt?: string | ((args: { context: TContext; params: Record }) => string); + options?: Record; + reasoning?: boolean; + /** @internal */ __type?: 'decide' | 'classify'; + /** @internal */ __decideConfig?: any; + /** @internal */ __classifyConfig?: any; } -export interface RunArgs> { - context: TContext; - parentParams: Record; - signal?: AbortSignal; -} +// ─── Agent State (POJO) ─── -export interface OutputArgs> { +export interface AgentState< + TContext extends Record = Record, + TValue extends StateValue = StateValue, +> { + value: TValue; context: TContext; + status: 'active' | 'pending' | 'done' | 'error'; + params: Record>; + output?: unknown; + error?: unknown; } -// ─── Decide / Classify ─── - -export interface DecideResult { - choice: string; - data: Record; - reasoning?: string; -} - -export interface ClassifyResult { - category: string; -} - -export interface DecideConfig { - model: string; - adapter?: AgentAdapter; - prompt: - | string - | ((args: { - context: Record; - parentParams: Record; - }) => string); - options: Record< - string, - { description: string; schema?: StandardSchemaV1 } - >; - reasoning?: boolean; - onDone: (args: OnDoneArgs, DecideResult>) => TransitionResult; - on?: Record< - string, - (args: TransitionArgs) => TransitionResult - >; -} +// ─── Execute Result ─── -export interface ClassifyConfig { - model: string; - adapter?: AgentAdapter; - prompt: - | string - | ((args: { - context: Record; - parentParams: Record; - }) => string); - into: Record; - examples?: Array<{ input: string; category: string }>; - onDone: ( - args: OnDoneArgs, ClassifyResult> - ) => TransitionResult; - on?: Record< - string, - (args: TransitionArgs) => TransitionResult - >; -} +export type ExecuteResult< + TContext extends Record = Record, + TValue extends StateValue = StateValue, + TEvents extends Record = {}, +> = + | { status: 'done'; state: AgentState; output: unknown; context: TContext } + | { status: 'pending'; state: AgentState; value: TValue; events: Record; context: TContext } + | { status: 'error'; state: AgentState; error: unknown }; -// ─── State config ─── +// ─── Snapshot ─── -export interface StateConfig { - type?: 'final'; - outputSchema?: StandardSchemaV1; - paramsSchema?: StandardSchemaV1; - run?: (args: RunArgs) => Promise; - onDone?: (args: OnDoneArgs) => TransitionResult; - on?: Record< - string, - (args: TransitionArgs) => TransitionResult - >; - events?: Record; - output?: (args: OutputArgs) => unknown; - // Compound state - initial?: - | string - | ((args: { - context: Record; - parentParams: Record; - }) => TransitionResult); - states?: Record; - - // Internal — set by decide/classify helpers - /** @internal */ - __type?: 'decide' | 'classify'; - /** @internal */ - __decideConfig?: DecideConfig; - /** @internal */ - __classifyConfig?: ClassifyConfig; +export interface AgentSnapshot< + TContext extends Record = Record, + TValue extends StateValue = StateValue, +> { + value: TValue; + context: TContext; + status: AgentState['status']; + params: Record>; } -// ─── Machine config ─── +// ─── Agent Machine ─── -export interface MachineConfig { +export interface AgentMachine< + TInput = unknown, + TContext extends Record = Record, + TEvents extends Record = {}, + TStates extends Record = Record>, +> { + readonly id: string; + + getInitialState( + ...args: unknown extends TInput ? [input?: TInput] : [input: TInput] + ): AgentState>; + + resolveState(raw: { + value: StateValue; + context: TContext; + params?: Record>; + status?: AgentState['status']; + output?: unknown; + error?: unknown; + }): AgentState>; + + transition( + state: AgentState>, + event: TransitionEvent + ): AgentState>; + + invoke( + state: AgentState> + ): Promise>>; + + execute( + state: AgentState> + ): Promise, TEvents>>; + + stream( + state: AgentState> + ): AsyncGenerator>>; +} + +// ─── Machine Config (internal) ─── + +export interface MachineConfig< + TInput = unknown, + TContext extends Record = Record, + TEvents extends Record = {}, + TStates extends Record> = Record>, +> { id: string; - inputSchema?: StandardSchemaV1; - context: (input: any) => Record; - contextSchema?: StandardSchemaV1; - events?: Record; + schemas?: { + input?: StandardSchemaV1; + context?: StandardSchemaV1; + events?: TEvents; + }; + context: (input: TInput) => TContext; adapter?: AgentAdapter; initial: - | string - | ((args: { context: Record }) => TransitionResult); - states: Record; + | (keyof TStates & string) + | ((args: { context: TContext }) => { target: keyof TStates & string; params?: Record }); + states: TStates; } -// ─── Agent Machine (returned by createAgentMachine) ─── +// ─── Decide (wrapper fn — typed result, untyped context) ─── -export interface AgentMachine extends MachineConfig {} - -// ─── Agent State (serializable) ─── +export type DecideResultFor< + TOptions extends Record, +> = { + [K in keyof TOptions & string]: { + choice: K; + data: TOptions[K] extends { schema: StandardSchemaV1 } ? O : Record; + reasoning?: string; + }; +}[keyof TOptions & string]; -export interface AgentState { - value: string; - params: Record>; - context: Record; - status: 'running' | 'waiting' | 'done' | 'error'; - output?: unknown; - error?: unknown; +export interface DecideConfig< + TOptions extends Record = Record, +> { + model: string; + adapter?: AgentAdapter; + prompt: string | ((args: { context: Record; params: Record }) => string); + options: TOptions; + reasoning?: boolean; + onDone: (args: { result: DecideResultFor; context: Record }) => TransitionResult; + on?: Record }) => TransitionResult>; } -// ─── Run result (discriminated union) ─── - -export type AgentRunResult = - | { - status: 'done'; - state: AgentState; - output: unknown; - context: Record; - } - | { - status: 'waiting'; - state: AgentState; - value: string; - events: Record; - context: Record; - } - | { - status: 'error'; - state: AgentState; - error: unknown; - }; - -// ─── Snapshot (for streaming) ─── - -export interface AgentSnapshot { - value: string; - context: Record; - status: AgentState['status']; - params: Record>; +// ─── Classify (wrapper fn — typed category, untyped context) ─── + +export interface ClassifyConfig< + TCategories extends Record = Record, +> { + model: string; + adapter?: AgentAdapter; + prompt: string | ((args: { context: Record; params: Record }) => string); + into: TCategories; + examples?: Array<{ input: string; category: keyof TCategories & string }>; + onDone: (args: { result: { category: keyof TCategories & string }; context: Record }) => TransitionResult; + on?: Record }) => TransitionResult>; } // ─── Trace ─── export interface Trace { state: string; - event: { - type: string; - timestamp: number; - [key: string]: unknown; - }; + event: { type: string; timestamp: number; [key: string]: unknown }; } diff --git a/src/utils.ts b/src/utils.ts index 23524b7..e8a38d0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,90 +1,137 @@ import type { - AgentMachine, - AgentState, + MachineConfig, StandardSchemaResult, StandardSchemaV1, StateConfig, + StateValue, TransitionResult, } from './types.js'; +/** Internal state representation with dot-path string value */ +export interface InternalState { + value: string; + context: Record; + status: 'active' | 'pending' | 'done' | 'error'; + params: Record>; + output?: unknown; + error?: unknown; +} + +// ─── StateValue ↔ dot-path conversion ─── + +/** Convert xstate-style value `{ handling: 'check' }` to dot-path `'handling.check'` */ +export function valueToPath(value: StateValue): string { + if (typeof value === 'string') return value; + const key = Object.keys(value)[0]!; + const child = (value as Record)[key]!; + return typeof child === 'string' + ? `${key}.${child}` + : `${key}.${valueToPath(child)}`; +} + +/** Convert dot-path `'handling.check'` to xstate-style value `{ handling: 'check' }` */ +export function pathToValue(path: string): StateValue { + const parts = path.split('.'); + if (parts.length === 1) return parts[0]!; + let result: StateValue = parts[parts.length - 1]!; + for (let i = parts.length - 2; i >= 0; i--) { + result = { [parts[i]!]: result }; + } + return result; +} + /** - * Validate a value against a Standard Schema V1 schema. + * Validate a value against a Standard Schema synchronously. + * Throws if validation returns a Promise (async schemas not supported here). */ -export async function validateSchema( +export function validateSchemaSync( schema: StandardSchemaV1, value: unknown -): Promise { - const result = await schema['~standard'].validate(value) as StandardSchemaResult; - if (result.issues) { - const messages = result.issues.map((i: { message: string }) => i.message).join(', '); +): T { + const result = schema['~standard'].validate(value); + if (result instanceof Promise) { + throw new Error( + 'Async schema validation is not supported in sync context. Validate input before calling getInitialState.' + ); + } + const syncResult = result as StandardSchemaResult; + if (syncResult.issues) { + const messages = syncResult.issues + .map((i: { message: string }) => i.message) + .join(', '); throw new Error(`Validation failed: ${messages}`); } - return result.value as T; + return syncResult.value as T; } /** * Resolve a StateConfig from a dot-separated state path. */ export function resolveStateConfig( - machine: AgentMachine, + config: MachineConfig, value: string -): StateConfig { +): any { const parts = value.split('.'); - let current: Record = machine.states; - let config: StateConfig | undefined; + let current: Record = config.states; + let stateConfig: any; for (const part of parts) { - config = current[part]; - if (!config) { + stateConfig = current[part]; + if (!stateConfig) { throw new Error(`State '${part}' not found in path '${value}'`); } - if (config.states) { - current = config.states; + if (stateConfig.states) { + current = stateConfig.states; } } - return config!; + return stateConfig!; } /** - * Get the parent state config for a nested state, or null for root states. + * Get the parent state config, or null for root states. */ export function getParentConfig( - machine: AgentMachine, + config: MachineConfig, value: string -): StateConfig | null { +): any { const parts = value.split('.'); if (parts.length <= 1) return null; const parentPath = parts.slice(0, -1).join('.'); - return resolveStateConfig(machine, parentPath); + return resolveStateConfig(config, parentPath); } /** - * Get the parent's params for the current state. + * Get the params for the current state. + * Params are stored at `state.params[statePath]` when transitioning. + * For nested states, also checks the parent path. */ -export function getParentParams( - state: AgentState +export function getParams( + valuePath: string, + params: Record> ): Record { - const parts = state.value.split('.'); + // Check own params first (set when transitioning TO this state) + if (params[valuePath]) return params[valuePath]!; + // Fall back to parent params (for compound state children) + const parts = valuePath.split('.'); if (parts.length <= 1) return {}; const parentPath = parts.slice(0, -1).join('.'); - return state.params[parentPath] ?? {}; + return params[parentPath] ?? {}; } /** - * Resolve an initial transition value. - * Accepts string shorthand, object shorthand, or function. + * Resolve an initial transition (string shorthand or function). */ export function resolveInitial( initial: | string | ((args: { context: Record; - parentParams: Record; + params: Record; }) => TransitionResult), args: { context: Record; - parentParams: Record; + params: Record; } ): TransitionResult { if (typeof initial === 'string') { @@ -94,46 +141,38 @@ export function resolveInitial( } /** - * Resolve a target state path. Targets are siblings of the handler's state. - * `handlerStatePath` is the dot-path of the state where the handler is defined. + * Resolve a target relative to the handler's state path. + * Targets are siblings of the state where the handler is defined. */ export function resolveTarget( handlerStatePath: string, target: string ): string { const parts = handlerStatePath.split('.'); - if (parts.length <= 1) { - // Handler on a root-level state → target is root-level - return target; - } - // Handler on a nested state → target is a sibling (under same parent) + if (parts.length <= 1) return target; const parentParts = parts.slice(0, -1); return [...parentParts, target].join('.'); } /** * Apply a transition result to produce a new state. - * Handles context merging, target resolution, and compound state entry. */ export function applyTransition( - machine: AgentMachine, - state: AgentState, + config: MachineConfig, + state: InternalState, transition: TransitionResult, handlerStatePath: string -): AgentState { +): InternalState { let newState = { ...state }; - // Merge context if (transition.context) { newState.context = { ...state.context, ...transition.context }; } if (transition.target) { - // Resolve target relative to handler's scope newState.value = resolveTarget(handlerStatePath, transition.target); - newState.status = 'running'; + newState.status = 'active'; - // Store params if provided if (transition.params) { newState.params = { ...state.params, @@ -141,8 +180,7 @@ export function applyTransition( }; } - // Enter compound states recursively - newState = enterCompoundStates(machine, newState); + newState = enterCompoundStates(config, newState); } return newState; @@ -150,22 +188,21 @@ export function applyTransition( /** * If the current state is a compound state, resolve its initial and descend. - * Repeats for nested compounds. */ export function enterCompoundStates( - machine: AgentMachine, - state: AgentState -): AgentState { + config: MachineConfig, + state: InternalState +): InternalState { let current = state; for (;;) { - const config = resolveStateConfig(machine, current.value); - if (!config.states || !config.initial) break; + const stateConfig = resolveStateConfig(config, current.value); + if (!stateConfig.states || !stateConfig.initial) break; - const parentParams = current.params[current.value] ?? {}; - const init = resolveInitial(config.initial, { + const params = current.params[current.value] ?? {}; + const init = resolveInitial(stateConfig.initial, { context: current.context, - parentParams, + params, }); if (!init.target) break; @@ -188,35 +225,32 @@ export function enterCompoundStates( } /** - * Collect available events for a given state path. - * Walks from the current state up to root, merging event schemas. + * Collect available events for a state path. * State-level events override root-level events. + * Only includes events that have handlers. */ export function getAvailableEvents( - machine: AgentMachine, + config: MachineConfig, value: string ): Record { const events: Record = {}; - // Root-level events - if (machine.events) { - Object.assign(events, machine.events); + if (config.schemas?.events) { + Object.assign(events, config.schemas.events); } - // Walk up from current state, collecting event schemas const parts = value.split('.'); for (let i = 0; i < parts.length; i++) { const path = parts.slice(0, i + 1).join('.'); - const config = resolveStateConfig(machine, path); - if (config.events) { - Object.assign(events, config.events); + const stateConfig = resolveStateConfig(config, path); + if (stateConfig.events) { + Object.assign(events, stateConfig.events); } } - // Filter to only events that have handlers on the current state or ancestors - const handledEvents = getHandledEventTypes(machine, value); + const handledTypes = getHandledEventTypes(config, value); const result: Record = {}; - for (const eventType of handledEvents) { + for (const eventType of handledTypes) { if (events[eventType]) { result[eventType] = events[eventType]; } @@ -225,11 +259,8 @@ export function getAvailableEvents( return result; } -/** - * Get all event types that have handlers on the current state or any ancestor. - */ function getHandledEventTypes( - machine: AgentMachine, + config: MachineConfig, value: string ): Set { const handled = new Set(); @@ -237,9 +268,9 @@ function getHandledEventTypes( for (let i = parts.length; i >= 1; i--) { const path = parts.slice(0, i).join('.'); - const config = resolveStateConfig(machine, path); - if (config.on) { - for (const eventType of Object.keys(config.on)) { + const stateConfig = resolveStateConfig(config, path); + if (stateConfig.on) { + for (const eventType of Object.keys(stateConfig.on)) { handled.add(eventType); } } @@ -247,3 +278,23 @@ function getHandledEventTypes( return handled; } + +/** + * Find the event schema for a given event type. + * State-level schemas override root-level. + */ +export function findEventSchema( + config: MachineConfig, + value: string, + eventType: string +): StandardSchemaV1 | undefined { + const parts = value.split('.'); + for (let i = parts.length; i >= 1; i--) { + const path = parts.slice(0, i).join('.'); + const stateConfig = resolveStateConfig(config, path); + if (stateConfig.events?.[eventType]) { + return stateConfig.events[eventType]; + } + } + return config.schemas?.events?.[eventType]; +} From 2020c013e549200f3e000114056a7c48c2bda927 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 4 Apr 2026 05:26:41 -0400 Subject: [PATCH 03/50] Types WIP --- src/agent.test.ts | 153 +++++++++++++++++++++++++++++++++++++++++----- src/index.ts | 1 + src/machine.ts | 109 +++++++++++++++++++++++++++------ src/types.ts | 4 +- 4 files changed, 230 insertions(+), 37 deletions(-) diff --git a/src/agent.test.ts b/src/agent.test.ts index ee63967..2722d05 100644 --- a/src/agent.test.ts +++ b/src/agent.test.ts @@ -45,17 +45,19 @@ function createSimpleMachine() { }, }, running: { + resultSchema: z.object({ value: z.number() }), invoke: async ({ context }) => { // context.count is typed as number ✓ return { value: context.count + 1 }; }, - onDone: ({ result, context }) => ({ + onDone: ({ result }) => ({ target: 'done', - context: { count: (result as { value: number }).value }, + context: { count: result.value }, }), }, done: { type: 'final', + // is the machine output inferred? should we have top-level outputSchema? output: ({ context }) => ({ result: context.count }), }, }, @@ -75,7 +77,7 @@ function createHitlMachine() { 'user.cancel': z.object({}), }, }, - context: (input: { task: string }) => ({ + context: (input) => ({ task: input.task, messages: [] as Array<{ role: string; content: string }>, result: null as string | null, @@ -84,19 +86,22 @@ function createHitlMachine() { states: { gathering: { on: { + // events are now typed from schemas.events 'user.message': ({ event, context }) => ({ context: { messages: [ ...context.messages, - { role: 'user', content: (event as { message: string }).message }, + { role: 'user', content: event.message }, ], }, }), - 'user.approve': () => ({ target: 'processing' }), - 'user.cancel': () => ({ target: 'cancelled' }), + // static shorthand — string target + 'user.approve': 'processing', + 'user.cancel': 'cancelled', }, }, processing: { + resultSchema: z.object({ output: z.string() }), invoke: async ({ context }) => { // context.messages is typed ✓ return { @@ -105,22 +110,23 @@ function createHitlMachine() { }, onDone: ({ result }) => ({ target: 'reviewing', - context: { result: (result as { output: string }).output }, + context: { result: result.output }, }), }, reviewing: { on: { - 'user.approve': () => ({ target: 'done' }), + // static shorthand — object target + 'user.approve': { target: 'done' }, 'user.message': ({ event, context }) => ({ target: 'processing', context: { messages: [ ...context.messages, - { role: 'user', content: (event as { message: string }).message }, + { role: 'user', content: event.message }, ], }, }), - 'user.cancel': () => ({ target: 'cancelled' }), + 'user.cancel': 'cancelled', }, }, done: { @@ -150,6 +156,7 @@ function createDecideMachine(adapter: AgentAdapter) { states: { classifying: decide({ model: 'test-model', + // context is Record here, not typed from context!! prompt: ({ context }) => `Classify: ${context.issue}`, options: { billing: { description: 'Billing issues' }, @@ -164,12 +171,13 @@ function createDecideMachine(adapter: AgentAdapter) { }), handling: { paramsSchema: z.object({ category: z.string() }), + resultSchema: z.object({ resolution: z.string() }), invoke: async ({ context, params }) => ({ resolution: `Handled ${params.category} issue`, }), onDone: ({ result }) => ({ target: 'done', - context: { resolution: (result as { resolution: string }).resolution }, + context: { resolution: result.resolution }, }), }, done: { @@ -1023,6 +1031,7 @@ describe('type inference', () => { initial: 'work', states: { work: { + resultSchema: z.object({ doubled: z.number() }), invoke: async ({ context }) => { context.n satisfies number; // @ts-expect-error — 'nope' does not exist @@ -1031,7 +1040,7 @@ describe('type inference', () => { }, onDone: ({ result }) => ({ target: 'done', - context: { n: (result as { doubled: number }).doubled }, + context: { n: result.doubled }, }), }, done: { type: 'final' }, @@ -1134,7 +1143,7 @@ describe('type inference', () => { idle: { on: { greet: ({ event }) => ({ - context: { msg: `hi ${(event as { name: string }).name}` }, + context: { msg: `hi ${event.name}` }, }), ping: () => ({}), }, @@ -1192,6 +1201,7 @@ describe('type inference', () => { states: { a: { paramsSchema: z.object({ count: z.number() }), + resultSchema: z.object({ doubled: z.number() }), invoke: async ({ params }) => { params.count satisfies number; // @ts-expect-error — count is number not string @@ -1203,11 +1213,12 @@ describe('type inference', () => { onDone: ({ result }) => ({ target: 'b', params: { name: 'hello' }, - context: { result: String((result as { doubled: number }).doubled) }, + context: { result: String(result.doubled) }, }), }, b: { paramsSchema: z.object({ name: z.string() }), + resultSchema: z.object({ greeting: z.string() }), invoke: async ({ params }) => { params.name satisfies string; // @ts-expect-error — name is string not number @@ -1218,7 +1229,7 @@ describe('type inference', () => { }, onDone: ({ result }) => ({ target: 'done', - context: { result: (result as { greeting: string }).greeting }, + context: { result: result.greeting }, }), }, done: { @@ -1324,6 +1335,118 @@ describe('type inference', () => { machine.getInitialState(); machine.getInitialState(undefined); }); + + // ─── resultSchema ─── + + test('resultSchema types invoke return and onDone result', () => { + createAgentMachine({ + id: 't', + context: () => ({ total: 0 }), + initial: 'work', + states: { + work: { + resultSchema: z.object({ value: z.number() }), + invoke: async () => { + // return type must match resultSchema + return { value: 42 }; + }, + onDone: ({ result }) => { + // result is typed from resultSchema + result.value satisfies number; + // @ts-expect-error — 'nope' does not exist on result + result.nope; + return { target: 'done', context: { total: result.value } }; + }, + }, + done: { type: 'final' }, + }, + }); + }); + + test('no resultSchema → onDone result is any', () => { + createAgentMachine({ + id: 't', + context: () => ({}), + initial: 'work', + states: { + work: { + invoke: async () => ({ anything: true }), + onDone: ({ result }) => { + // result is any when no resultSchema — no errors + result.whatever; + return { target: 'done' }; + }, + }, + done: { type: 'final' }, + }, + }); + }); + + // ─── events typed in on handlers ─── + + test('on handler event typed from schemas.events', () => { + createAgentMachine({ + id: 't', + schemas: { + events: { + 'msg': z.object({ text: z.string() }), + }, + }, + context: () => ({ last: '' }), + initial: 'idle', + states: { + idle: { + on: { + msg: ({ event }) => { + // event.text is typed from schemas.events + event.text satisfies string; + event.type satisfies 'msg'; + return { context: { last: event.text } }; + }, + }, + }, + }, + }); + }); + + // ─── static transition shorthand ─── + + test('on handler accepts string shorthand', () => { + const machine = createAgentMachine({ + id: 't', + context: () => ({}), + initial: 'a', + states: { + a: { + on: { + go: 'b', + }, + }, + b: { type: 'final' }, + }, + }); + const s = machine.transition(machine.getInitialState(), { type: 'go' }); + expect(s.value).toBe('b'); + }); + + test('on handler accepts static TransitionResult object', () => { + const machine = createAgentMachine({ + id: 't', + context: () => ({ x: 0 }), + initial: 'a', + states: { + a: { + on: { + go: { target: 'b', context: { x: 1 } }, + }, + }, + b: { type: 'final' }, + }, + }); + const s = machine.transition(machine.getInitialState(), { type: 'go' }); + expect(s.value).toBe('b'); + expect(s.context.x).toBe(1); + }); }); describe('edge cases', () => { diff --git a/src/index.ts b/src/index.ts index a3d1751..ead8991 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export type { ClassifyConfig, DecideConfig, DecideResultFor, + EventPayload, EventUnion, ExecuteResult, InferOutput, diff --git a/src/machine.ts b/src/machine.ts index 1027e97..4fb1b86 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -2,7 +2,9 @@ import type { AgentMachine, AgentSnapshot, AgentState, + EventPayload, ExecuteResult, + InferOutput, MachineConfig, StandardSchemaV1, StateConfig, @@ -34,17 +36,48 @@ function toExternal(state: InternalState): AgentState { return { ...state, value: pathToValue(state.value) }; } -// Per-state node config with typed params via TParamsMap[K] -type StateNodeDef, TParams> = { +// Falls back to `any` when TResult was not inferred (unknown) +type FallbackAny = unknown extends T ? any : T; + +// Handler for a specific known event type +type TypedOnHandler> = + | string + | TransitionResult + | ((args: { + event: { type: E } & EventPayload>; + context: TContext; + }) => TransitionResult); + +// Handler for an unknown event type +type UntypedOnHandler> = + | string + | TransitionResult + | ((args: { event: any; context: TContext }) => TransitionResult); + +// When TEvents has keys, known events get typed handlers; others get untyped. +// When TEvents is empty (no schemas.events), all handlers are untyped. +type OnHandlers> = + [keyof TEvents] extends [never] + ? Record> + : { [E in keyof TEvents & string]?: TypedOnHandler }; + +// Per-state node config with typed params via TParamsMap[K] and typed result via TResultMap[K] +type StateNodeDef< + TContext extends Record, + TParams, + TResult, + TEvents, +> = { type?: 'final' | 'choice'; paramsSchema?: StandardSchemaV1; + resultSchema?: StandardSchemaV1; invoke?: (args: { context: TContext; params: NoInfer; signal?: AbortSignal; - }) => Promise; - onDone?: (args: { result: any; context: TContext }) => TransitionResult; - on?: Record TransitionResult>; + }) => Promise>; + onDone?: (args: { result: FallbackAny>; context: TContext }) => TransitionResult; + on?: Record TransitionResult)>; events?: Record; output?: (args: { context: TContext }) => unknown; initial?: string | ((args: { context: TContext; params: Record }) => TransitionResult); @@ -61,12 +94,14 @@ type StateNodeDef, TParams> = { __classifyConfig?: any; }; -// Mapped states type: each key K gets its own params from TParamsMap[K] -type StatesWithParams< +// Mapped states type: each key K gets its own params and result types +type StatesMap< TContext extends Record, TParamsMap extends Record, + TResultMap extends Record, + TEvents, > = { - [K in keyof TParamsMap]: StateNodeDef; + [K in keyof TParamsMap & keyof TResultMap]: StateNodeDef; }; // ─── Overload 1: schemas.context drives TContext ─── @@ -75,6 +110,7 @@ export function createAgentMachine< TContext extends Record, const TEvents extends Record, const TParamsMap extends Record, + TResultMap extends Record, >(config: { id: string; schemas: { @@ -85,37 +121,63 @@ export function createAgentMachine< context: (input: NoInfer) => NoInfer; adapter?: import('./types.js').AgentAdapter; initial: - | (keyof TParamsMap & string) + | (keyof TParamsMap & keyof TResultMap & string) | ((args: { context: NoInfer }) => { - target: keyof TParamsMap & string; + target: keyof TParamsMap & keyof TResultMap & string; + params?: Record; + }); + states: StatesMap, TParamsMap, TResultMap, TEvents>; +}): AgentMachine>; + +// ─── Overload 2: schemas.input present, context() return drives TContext ─── +export function createAgentMachine< + TInput, + TContext extends Record, + const TEvents extends Record, + const TParamsMap extends Record, + TResultMap extends Record, +>(config: { + id: string; + schemas: { + input: StandardSchemaV1; + context?: never; + events?: TEvents; + }; + context: (input: NoInfer) => TContext; + adapter?: import('./types.js').AgentAdapter; + initial: + | (keyof TParamsMap & keyof TResultMap & string) + | ((args: { context: TContext }) => { + target: keyof TParamsMap & keyof TResultMap & string; params?: Record; }); - states: StatesWithParams, TParamsMap>; -}): AgentMachine>; + states: StatesMap; +}): AgentMachine>; -// ─── Overload 2: context() return drives TContext ─── +// ─── Overload 3: no schemas.input/context — all from context() ─── export function createAgentMachine< TInput, TContext extends Record, const TEvents extends Record, const TParamsMap extends Record, + TResultMap extends Record, >(config: { id: string; schemas?: { - input?: StandardSchemaV1; + input?: never; context?: never; events?: TEvents; }; context: (input: TInput) => TContext; adapter?: import('./types.js').AgentAdapter; initial: - | (keyof TParamsMap & string) + | (keyof TParamsMap & keyof TResultMap & string) | ((args: { context: TContext }) => { - target: keyof TParamsMap & string; + target: keyof TParamsMap & keyof TResultMap & string; params?: Record; }); - states: StatesWithParams; -}): AgentMachine>; + states: StatesMap; +}): AgentMachine>; // ─── Implementation ─── @@ -186,9 +248,16 @@ export function createAgentMachine( const path = parts.slice(0, i).join('.'); const stateConfig = resolveStateConfig(cfg, path); - if (stateConfig.on?.[event.type]) { + if (stateConfig.on?.[event.type] !== undefined) { const handler = stateConfig.on[event.type]!; - const result = handler({ context: internal.context, event }); + let result: TransitionResult; + if (typeof handler === 'string') { + result = { target: handler }; + } else if (typeof handler === 'function') { + result = handler({ context: internal.context, event }); + } else { + result = handler; + } if (result.target) { return toExternal( diff --git a/src/types.ts b/src/types.ts index 8df97c6..1de6054 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,7 +34,7 @@ type _SV3 = T extends Record // ─── Event Helpers ─── -type EventPayload = T extends Record ? unknown : T; +export type EventPayload = T extends Record ? unknown : T; export type EventUnion> = { [K in keyof T & string]: { type: K } & EventPayload>; @@ -82,7 +82,7 @@ export interface StateConfig< signal?: AbortSignal; }) => Promise; onDone?: (args: { result: any; context: TContext }) => TransitionResult; - on?: Record TransitionResult>; + on?: Record TransitionResult)>; events?: Record; output?: (args: { context: TContext }) => unknown; initial?: From 27a6697b5bd56776ebbf3870b0d2229f3a5ad01e Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 4 Apr 2026 21:04:45 -0400 Subject: [PATCH 04/50] Simplify: flat state --- src/agent.test.ts | 298 ++++++---------------------------- src/index.ts | 2 - src/machine.ts | 403 ++++++++++++++++++---------------------------- src/types.ts | 63 +++----- src/utils.ts | 234 ++++++--------------------- 5 files changed, 278 insertions(+), 722 deletions(-) diff --git a/src/agent.test.ts b/src/agent.test.ts index 2722d05..6bb8d7c 100644 --- a/src/agent.test.ts +++ b/src/agent.test.ts @@ -96,8 +96,8 @@ function createHitlMachine() { }, }), // static shorthand — string target - 'user.approve': 'processing', - 'user.cancel': 'cancelled', + 'user.approve': { target: 'processing' }, + 'user.cancel': { target: 'cancelled' }, }, }, processing: { @@ -126,7 +126,7 @@ function createHitlMachine() { ], }, }), - 'user.cancel': 'cancelled', + 'user.cancel': { target: 'cancelled' }, }, }, done: { @@ -224,79 +224,6 @@ function createClassifyMachine(adapter: AgentAdapter) { }); } -// ─── Nested machine ─── - -function createNestedMachine() { - return createAgentMachine({ - id: 'nested', - context: () => ({ - resolution: null as string | null, - category: 'billing' as string, - }), - initial: 'handling', - states: { - handling: { - initial: ({ context }) => { - if (context.category === 'billing') - return { target: 'checkEligibility' }; - return { target: 'diagnose' }; - }, - states: { - checkEligibility: { - invoke: async () => ({ eligible: true }), - onDone: ({ result }) => { - if ((result as { eligible: boolean }).eligible) - return { target: 'processRefund' }; - return { target: 'deny' }; - }, - }, - processRefund: { - invoke: async () => ({}), - onDone: () => ({ - target: 'childDone', - context: { resolution: 'Refund processed' }, - }), - }, - deny: { - invoke: async () => ({ message: 'Not eligible' }), - onDone: ({ result }) => ({ - target: 'childDone', - context: { - resolution: (result as { message: string }).message, - }, - }), - }, - diagnose: { - invoke: async () => ({ diagnosis: 'It is a bug' }), - onDone: ({ result }) => ({ - target: 'childDone', - context: { - resolution: (result as { diagnosis: string }).diagnosis, - }, - }), - }, - childDone: { type: 'final' }, - }, - onDone: () => ({ target: 'respond' }), - on: { - 'user.cancel': () => ({ target: 'cancelled' }), - }, - }, - respond: { - invoke: async ({ context }) => ({ message: context.resolution }), - onDone: () => ({ target: 'done' }), - }, - done: { - type: 'final', - output: ({ context }) => ({ resolution: context.resolution }), - }, - cancelled: { - type: 'final', - output: () => ({ cancelled: true }), - }, - }, - }); -} // ═══════════════════════════════════════ // Tests @@ -332,7 +259,7 @@ describe('getInitialState', () => { test('rejects invalid input', () => { const machine = createHitlMachine(); - // @ts-expect-error — deliberately invalid input for runtime test + // Runtime validation catches invalid input (schemas.input validates) expect(() => machine.getInitialState({ task: 123 })).toThrow(); }); @@ -356,10 +283,6 @@ describe('getInitialState', () => { expect(machine.getInitialState('fast').value).toBe('fast'); }); - test('resolves compound state initial', () => { - const machine = createNestedMachine(); - expect(machine.getInitialState().value).toEqual({ handling: 'checkEligibility' }); - }); }); describe('invoke', () => { @@ -445,20 +368,6 @@ describe('invoke', () => { expect((s.error as Error).message).toBe('boom'); }); - test('nested state entry and execution', async () => { - const machine = createNestedMachine(); - let s = machine.getInitialState(); - expect(s.value).toEqual({ handling: 'checkEligibility' }); - - s = await machine.invoke(s); - expect(s.value).toEqual({ handling: 'processRefund' }); - - s = await machine.invoke(s); - expect(s.value).toEqual({ handling: 'childDone' }); - - s = await machine.invoke(s); - expect(s.value).toBe('respond'); - }); }); describe('transition', () => { @@ -492,13 +401,6 @@ describe('transition', () => { ).toThrow("No handler for event 'nope'"); }); - test('parent preempts child', () => { - const machine = createNestedMachine(); - const s = machine.transition(machine.getInitialState(), { - type: 'user.cancel', - }); - expect(s.value).toBe('cancelled'); - }); }); describe('execute', () => { @@ -556,13 +458,6 @@ describe('execute', () => { } }); - test('runs nested states to completion', async () => { - const machine = createNestedMachine(); - const r = await machine.execute(machine.getInitialState()); - expect(r.status === 'done' && r.output).toEqual({ - resolution: 'Refund processed', - }); - }); }); describe('stream', () => { @@ -592,14 +487,6 @@ describe('resolveState', () => { expect(next.context.messages[0]!.content).toBe('restored'); }); - test('nested round-trip', async () => { - const machine = createNestedMachine(); - const s = machine.getInitialState(); - const restored = machine.resolveState(JSON.parse(JSON.stringify(s))); - expect(restored.value).toEqual({ handling: 'checkEligibility' }); - const r = await machine.execute(restored); - expect(r.status).toBe('done'); - }); }); describe('decide', () => { @@ -680,7 +567,7 @@ describe('decide', () => { context: { items: result.choice === 'withData' - ? (result.data as { items: string[] }).items + ? result.data.items : null, }, }), @@ -773,114 +660,6 @@ describe('classify', () => { }); }); -describe('nested states', () => { - test('conditional compound initial', async () => { - const machine = createAgentMachine({ - id: 'cond', - context: () => ({ - category: 'technical' as string, - resolution: null as string | null, - }), - initial: 'handling', - states: { - handling: { - initial: ({ context }) => - context.category === 'billing' - ? { target: 'billing' } - : { target: 'technical' }, - states: { - billing: { - invoke: async () => ({}), - onDone: () => ({ target: 'childDone' }), - }, - technical: { - invoke: async () => ({ result: 'tech handled' }), - onDone: ({ result }) => ({ - target: 'childDone', - context: { - resolution: (result as { result: string }).result, - }, - }), - }, - childDone: { type: 'final' }, - }, - onDone: () => ({ target: 'done' }), - }, - done: { - type: 'final', - output: ({ context }) => ({ resolution: context.resolution }), - }, - }, - }); - expect(machine.getInitialState().value).toEqual({ handling: 'technical' }); - const r = await machine.execute(machine.getInitialState()); - expect(r.status === 'done' && r.output).toEqual({ - resolution: 'tech handled', - }); - }); - - test('parent preempts → cancel', async () => { - const machine = createNestedMachine(); - let s = machine.transition(machine.getInitialState(), { - type: 'user.cancel', - }); - const r = await machine.execute(s); - expect(r.status === 'done' && r.output).toEqual({ cancelled: true }); - }); -}); - -describe('P1: nested final without parent onDone', () => { - test('halts at pending', async () => { - const machine = createAgentMachine({ - id: 'p1', - context: () => ({}), - initial: 'a', - states: { - a: { - initial: 'b', - states: { - b: { - initial: 'c', - states: { c: { type: 'final' } }, - }, - }, - onDone: () => ({ target: 'result' }), - }, - result: { type: 'final' }, - }, - }); - const s = await machine.invoke(machine.getInitialState()); - expect(s.status).toBe('pending'); - expect(s.value).toEqual({ a: { b: 'c' } }); - }); - - test('ancestor on still reachable', async () => { - const machine = createAgentMachine({ - id: 'p1-esc', - context: () => ({}), - initial: 'a', - states: { - a: { - initial: 'b', - states: { - b: { - initial: 'c', - states: { c: { type: 'final' } }, - }, - }, - on: { escape: () => ({ target: 'out' }) }, - }, - out: { type: 'final', output: () => ({ escaped: true }) }, - }, - }); - let r = await machine.execute(machine.getInitialState()); - expect(r.status).toBe('pending'); - const s = machine.transition(r.state, { type: 'escape' }); - r = await machine.execute(s); - expect(r.status === 'done' && r.output).toEqual({ escaped: true }); - }); -}); - describe('P2: event validation', () => { test('rejects invalid payload', async () => { const machine = createHitlMachine(); @@ -962,26 +741,6 @@ describe('type inference', () => { s.value satisfies 'c'; }); - test('nested state values are xstate-style objects', () => { - const machine = createAgentMachine({ - id: 't', - context: () => ({}), - initial: 'parent', - states: { - parent: { - initial: 'child', - states: { child: { type: 'final' } }, - onDone: () => ({ target: 'done' }), - }, - done: { type: 'final' }, - }, - }); - const s = machine.getInitialState(); - - // Runtime check — nested values are objects - expect(s.value).toEqual({ parent: 'child' }); - }); - // ─── state.context ─── test('context typed from context() return', () => { @@ -1002,9 +761,48 @@ describe('type inference', () => { s.context.nope; }); + test('transition context is Partial — rejects unknown keys', () => { + createAgentMachine({ + id: 't', + schemas: { events: { go: z.object({}) } }, + context: () => ({ count: 0, name: 'hello' }), + initial: 'idle', + states: { + idle: { + on: { + go: ({ context }) => ({ + target: 'idle', + // valid: known key + context: { count: context.count + 1 }, + }), + }, + }, + }, + }); + + // @ts-expect-error — 'foo' not a valid context key + createAgentMachine({ + id: 't2', + schemas: { events: { go: z.object({}) } }, + context: () => ({ count: 0 }), + initial: 'idle', + states: { + idle: { + on: { + go: () => ({ + target: 'idle', + context: { foo: 'bar' }, + }), + }, + }, + }, + }); + }); + test('context typed in on handlers', () => { const machine = createAgentMachine({ id: 't', + schemas: { events: { add: z.object({}) } }, context: () => ({ items: ['a', 'b'] }), initial: 'idle', states: { @@ -1285,6 +1083,8 @@ describe('type inference', () => { options: { a: { description: 'A' } }, onDone: ({ result, context }) => { result.choice satisfies string; + // @ts-expect-error + result.nope; context.topic satisfies string; return { target: 'done', context: { result: result.choice } }; }, @@ -1372,7 +1172,9 @@ describe('type inference', () => { work: { invoke: async () => ({ anything: true }), onDone: ({ result }) => { - // result is any when no resultSchema — no errors + // Without resultSchema, result is ChoiceResult (default) + result.choice satisfies string; + // @ts-expect-error — 'whatever' not on ChoiceResult result.whatever; return { target: 'done' }; }, @@ -1419,7 +1221,7 @@ describe('type inference', () => { states: { a: { on: { - go: 'b', + go: { target: 'b' }, }, }, b: { type: 'final' }, diff --git a/src/index.ts b/src/index.ts index ead8991..5868e9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,8 +24,6 @@ export type { MachineConfig, StandardSchemaV1, StateConfig, - StateValue, - StateValueOf, Trace, TransitionEvent, TransitionResult, diff --git a/src/machine.ts b/src/machine.ts index 4fb1b86..9f6f7df 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -7,61 +7,33 @@ import type { InferOutput, MachineConfig, StandardSchemaV1, - StateConfig, - StateValue, - TransitionEvent, TransitionResult, } from './types.js'; import { applyTransition, - enterCompoundStates, findEventSchema, getAvailableEvents, - getParentConfig, getParams, - pathToValue, resolveInitial, resolveStateConfig, validateSchemaSync, - valueToPath, } from './utils.js'; +import type { StateConfigAny } from './utils.js'; -import type { InternalState } from './utils.js'; +// ─── Type helpers ─── -function toInternal(state: AgentState): InternalState { - return { ...state, value: valueToPath(state.value) }; -} +type FallbackAny = unknown extends T ? any : T; -function toExternal(state: InternalState): AgentState { - return { ...state, value: pathToValue(state.value) }; -} +/** Choice result shape — always the same for type: 'choice' */ +type ChoiceResult = { choice: string; data: Record; reasoning?: string }; -// Falls back to `any` when TResult was not inferred (unknown) -type FallbackAny = unknown extends T ? any : T; +/** Result type for onDone: typed from resultSchema when present */ +type OnDoneResult = unknown extends TResult ? ChoiceResult : NoInfer; + +type EventFor = E extends keyof TEvents & string + ? { type: E } & EventPayload> + : { type: E & string; [k: string]: unknown }; -// Handler for a specific known event type -type TypedOnHandler> = - | string - | TransitionResult - | ((args: { - event: { type: E } & EventPayload>; - context: TContext; - }) => TransitionResult); - -// Handler for an unknown event type -type UntypedOnHandler> = - | string - | TransitionResult - | ((args: { event: any; context: TContext }) => TransitionResult); - -// When TEvents has keys, known events get typed handlers; others get untyped. -// When TEvents is empty (no schemas.events), all handlers are untyped. -type OnHandlers> = - [keyof TEvents] extends [never] - ? Record> - : { [E in keyof TEvents & string]?: TypedOnHandler }; - -// Per-state node config with typed params via TParamsMap[K] and typed result via TResultMap[K] type StateNodeDef< TContext extends Record, TParams, @@ -76,25 +48,24 @@ type StateNodeDef< params: NoInfer; signal?: AbortSignal; }) => Promise>; - onDone?: (args: { result: FallbackAny>; context: TContext }) => TransitionResult; - on?: Record TransitionResult)>; + onDone?: (args: { result: OnDoneResult; context: TContext }) => TransitionResult; + on?: { [E in keyof TEvents & string]?: TransitionResult | ((args: { + event: EventFor; + context: TContext; + }) => TransitionResult) }; events?: Record; output?: (args: { context: TContext }) => unknown; - initial?: string | ((args: { context: TContext; params: Record }) => TransitionResult); - states?: Record>; // choice-specific model?: string; adapter?: import('./types.js').AgentAdapter; prompt?: string | ((args: { context: TContext; params: NoInfer }) => string); options?: Record; reasoning?: boolean; - // internal (from decide/classify wrappers) + // internal __type?: 'decide' | 'classify'; - __decideConfig?: any; - __classifyConfig?: any; + __decideConfig?: Record; }; -// Mapped states type: each key K gets its own params and result types type StatesMap< TContext extends Record, TParamsMap extends Record, @@ -104,7 +75,7 @@ type StatesMap< [K in keyof TParamsMap & keyof TResultMap]: StateNodeDef; }; -// ─── Overload 1: schemas.context drives TContext ─── +// ─── Overload A: schemas.context present ─── export function createAgentMachine< TInput, TContext extends Record, @@ -129,34 +100,8 @@ export function createAgentMachine< states: StatesMap, TParamsMap, TResultMap, TEvents>; }): AgentMachine>; -// ─── Overload 2: schemas.input present, context() return drives TContext ─── +// ─── Overload B: no schemas.context ─── export function createAgentMachine< - TInput, - TContext extends Record, - const TEvents extends Record, - const TParamsMap extends Record, - TResultMap extends Record, ->(config: { - id: string; - schemas: { - input: StandardSchemaV1; - context?: never; - events?: TEvents; - }; - context: (input: NoInfer) => TContext; - adapter?: import('./types.js').AgentAdapter; - initial: - | (keyof TParamsMap & keyof TResultMap & string) - | ((args: { context: TContext }) => { - target: keyof TParamsMap & keyof TResultMap & string; - params?: Record; - }); - states: StatesMap; -}): AgentMachine>; - -// ─── Overload 3: no schemas.input/context — all from context() ─── -export function createAgentMachine< - TInput, TContext extends Record, const TEvents extends Record, const TParamsMap extends Record, @@ -164,11 +109,11 @@ export function createAgentMachine< >(config: { id: string; schemas?: { - input?: never; + input?: StandardSchemaV1; context?: never; events?: TEvents; }; - context: (input: TInput) => TContext; + context: (...args: any[]) => TContext; adapter?: import('./types.js').AgentAdapter; initial: | (keyof TParamsMap & keyof TResultMap & string) @@ -177,24 +122,21 @@ export function createAgentMachine< params?: Record; }); states: StatesMap; -}): AgentMachine>; +}): AgentMachine>; // ─── Implementation ─── export function createAgentMachine( machineConfig: MachineConfig -): AgentMachine { - const cfg = machineConfig; - - // ─── getInitialState (sync) ─── +): AgentMachine { + const cfg = machineConfig as MachineConfig; function getInitialState(...args: [input?: unknown]): AgentState { const input = args[0]; let validatedInput = input; - const inputSchema = cfg.schemas?.input; - if (inputSchema) { - validatedInput = validateSchemaSync(inputSchema, input); + if (cfg.schemas?.input) { + validatedInput = validateSchemaSync(cfg.schemas.input, input); } const context = cfg.context(validatedInput); @@ -204,23 +146,16 @@ export function createAgentMachine( throw new Error('Initial transition must specify a target state'); } - let internal: InternalState = { + return { value: init.target, - params: {}, context: init.context ? { ...context, ...init.context } : context, status: 'active', + params: init.params ? { [init.target]: init.params } : {}, }; - if (init.params) { - internal.params = { [init.target]: init.params }; - } - internal = enterCompoundStates(cfg, internal as any) as any; - return toExternal(internal); } - // ─── resolveState ─── - function resolveState(raw: { - value: StateValue; + value: string; context: Record; params?: Record>; status?: AgentState['status']; @@ -237,57 +172,51 @@ export function createAgentMachine( }; } - // ─── transition (sync) ─── - - function transition(state: AgentState, event: { type: string; [k: string]: unknown }): AgentState { - const internal = toInternal(state); - validateEventPayload(internal, event); - - const parts = internal.value.split('.'); - for (let i = 1; i <= parts.length; i++) { - const path = parts.slice(0, i).join('.'); - const stateConfig = resolveStateConfig(cfg, path); - - if (stateConfig.on?.[event.type] !== undefined) { - const handler = stateConfig.on[event.type]!; - let result: TransitionResult; - if (typeof handler === 'string') { - result = { target: handler }; - } else if (typeof handler === 'function') { - result = handler({ context: internal.context, event }); - } else { - result = handler; - } - - if (result.target) { - return toExternal( - applyTransition(cfg, internal as any, result, path) as any - ); - } - - return toExternal({ - ...internal, - context: result.context - ? { ...internal.context, ...result.context } - : internal.context, - }); + function transition( + state: AgentState, + event: { type: string; [k: string]: unknown } + ): AgentState { + validateEventPayload(state.value, event); + + const sc = resolveStateConfig(cfg, state.value); + if (sc.on?.[event.type] !== undefined) { + const handler = sc.on[event.type]!; + const result: TransitionResult = + typeof handler === 'function' + ? handler({ context: state.context, event }) + : handler; + + if (result.target) { + return applyTransition(state, result); } + + return { + ...state, + context: result.context + ? { ...state.context, ...result.context } + : state.context, + }; } throw new Error( - `No handler for event '${event.type}' in state '${internal.value}'` + `No handler for event '${event.type}' in state '${state.value}'` ); } function validateEventPayload( - internal: InternalState, - event: { type: string; [k: string]: unknown } + value: string, + event: { type: string } ): void { - const schema = findEventSchema(cfg, internal.value, event.type); + const schema = findEventSchema(cfg, value, event.type); if (!schema) return; const result = schema['~standard'].validate(event); if (result instanceof Promise) return; - if (result && typeof result === 'object' && 'issues' in result && result.issues) { + if ( + result && + typeof result === 'object' && + 'issues' in result && + result.issues + ) { const messages = (result.issues as Array<{ message: string }>) .map((i) => i.message) .join(', '); @@ -295,129 +224,92 @@ export function createAgentMachine( } } - // ─── invoke (async, one step) ─── - async function invoke(state: AgentState): Promise { - const internal = toInternal(state); - if (internal.status === 'done' || internal.status === 'error') { + if (state.status === 'done' || state.status === 'error') { return state; } - const result = await invokeInternal(internal); - return toExternal(result); - } - async function invokeInternal(state: InternalState): Promise { - const stateConfig = resolveStateConfig(cfg, state.value) as any; + const sc = resolveStateConfig(cfg, state.value); - if (stateConfig.type === 'final') { - return handleFinal(state, stateConfig); - } - // type: 'choice' — inline decide config - if (stateConfig.type === 'choice') { - return handleChoice(state, stateConfig); + if (sc.type === 'final') { + const output = sc.output + ? sc.output({ context: state.context }) + : undefined; + return { ...state, status: 'done', output }; } - // decide()/classify() wrapper — __decideConfig set internally - if (stateConfig.__decideConfig) { - return handleDecide(state, stateConfig); + + if (sc.type === 'choice' || sc.__decideConfig) { + return handleChoice(state, sc); } - if (stateConfig.invoke) { - return handleInvoke(state, stateConfig); + + if (sc.invoke) { + return handleInvoke(state, sc); } - if (stateConfig.on) { + + if (sc.on) { return { ...state, status: 'pending' }; } - if (stateConfig.states && stateConfig.initial) { - return { ...state, status: 'active' }; - } + return { ...state, status: 'error', - error: `State '${state.value}' has no invoke, events, or children`, + error: `State '${state.value}' has no invoke, events, or final type`, }; } - function handleFinal(state: InternalState, config: any): InternalState { - const output = config.output - ? config.output({ context: state.context }) - : undefined; - - const parts = state.value.split('.'); - if (parts.length <= 1) { - return { ...state, status: 'done', output }; - } - - const parentConfig = getParentConfig(cfg, state.value); - if (parentConfig?.onDone) { - const parentPath = parts.slice(0, -1).join('.'); - const trans = parentConfig.onDone({ result: output, context: state.context }); - return applyTransition(cfg, state as any, trans, parentPath) as any; - } - - return { ...state, status: 'pending' }; - } - - async function handleChoice(state: InternalState, sc: any): Promise { - const adapter = sc.adapter ?? cfg.adapter; + async function handleChoice( + state: AgentState, + sc: StateConfigAny + ): Promise { + // Merge __decideConfig props onto sc for decide() wrapper compat + const dc = sc.__decideConfig + ? { ...sc, ...(sc.__decideConfig as Record) } + : sc; + const adapter = (dc as StateConfigAny).adapter ?? cfg.adapter; if (!adapter) { - return { ...state, status: 'error', error: `No adapter for choice state '${state.value}'` }; + return { + ...state, + status: 'error', + error: `No adapter for '${state.value}'`, + }; } const params = getParams(state.value, state.params); - const prompt = typeof sc.prompt === 'function' - ? sc.prompt({ context: state.context, params }) - : sc.prompt; + const prompt = + typeof dc.prompt === 'function' + ? dc.prompt({ context: state.context, params }) + : dc.prompt; try { const result = await adapter.decide({ - model: sc.model, - prompt, - options: sc.options, - reasoning: sc.reasoning, + model: (dc as StateConfigAny).model!, + prompt: prompt as string, + options: (dc as StateConfigAny).options!, + reasoning: (dc as StateConfigAny).reasoning, }); - const trans = sc.onDone({ result, context: state.context }); - return applyTransition(cfg, state as any, trans, state.value) as any; + const onDone = (dc as StateConfigAny).onDone; + if (!onDone) return { ...state, status: 'error', error: 'choice state missing onDone' }; + const trans = onDone({ result, context: state.context }); + return applyTransition(state, trans); } catch (error) { return { ...state, status: 'error', error }; } } - async function handleDecide(state: InternalState, stateConfig: StateConfig): Promise { - const dc = (stateConfig as any).__decideConfig!; - const adapter = dc.adapter ?? cfg.adapter; - if (!adapter) { - return { ...state, status: 'error', error: `No adapter for '${state.value}'` }; - } - - const params = getParams(state.value, state.params); - const prompt = typeof dc.prompt === 'function' - ? dc.prompt({ context: state.context, params }) - : dc.prompt; - - try { - const result = await adapter.decide({ - model: dc.model, - prompt, - options: dc.options, - reasoning: dc.reasoning, - }); - const trans = dc.onDone({ result, context: state.context }); - return applyTransition(cfg, state as any, trans, state.value) as any; - } catch (error) { - return { ...state, status: 'error', error }; - } - } - - async function handleInvoke(state: InternalState, stateConfig: any): Promise { + async function handleInvoke( + state: AgentState, + sc: StateConfigAny + ): Promise { try { - const result = await stateConfig.invoke!({ + const result = await sc.invoke!({ context: state.context, params: getParams(state.value, state.params), }); - if (stateConfig.onDone) { - const trans = stateConfig.onDone({ result, context: state.context }); - return applyTransition(cfg, state as any, trans, state.value) as any; + if (sc.onDone) { + const trans = sc.onDone({ result, context: state.context }); + return applyTransition(state, trans); } - if (stateConfig.on) { + if (sc.on) { return { ...state, status: 'pending' }; } return state; @@ -426,46 +318,61 @@ export function createAgentMachine( } } - // ─── execute ─── - async function execute(state: AgentState): Promise { - let internal = toInternal(state); - while (internal.status === 'active') { - internal = await invokeInternal(internal); + let current = state; + while (current.status === 'active') { + current = await invoke(current); } - const ext = toExternal(internal); - switch (internal.status) { + switch (current.status) { case 'done': - return { status: 'done', state: ext, output: internal.output, context: internal.context }; + return { + status: 'done', + state: current, + output: current.output, + context: current.context, + }; case 'pending': return { status: 'pending', - state: ext, - value: ext.value, - events: getAvailableEvents(cfg, internal.value), - context: internal.context, + state: current, + value: current.value, + events: getAvailableEvents(cfg, current.value), + context: current.context, }; case 'error': - return { status: 'error', state: ext, error: internal.error }; + return { + status: 'error', + state: current, + error: current.error, + }; default: - return { status: 'error', state: ext, error: `Unexpected: ${internal.status}` }; + return { + status: 'error', + state: current, + error: `Unexpected: ${current.status}`, + }; } } - // ─── stream ─── - - async function* stream(state: AgentState): AsyncGenerator { - let internal = toInternal(state); - yield toSnap(internal); - while (internal.status === 'active') { - internal = await invokeInternal(internal); - yield toSnap(internal); + async function* stream( + state: AgentState + ): AsyncGenerator { + let current = state; + yield toSnap(current); + while (current.status === 'active') { + current = await invoke(current); + yield toSnap(current); } } - function toSnap(s: InternalState): AgentSnapshot { - return { value: pathToValue(s.value), context: s.context, status: s.status, params: s.params }; + function toSnap(s: AgentState): AgentSnapshot { + return { + value: s.value, + context: s.context, + status: s.status, + params: s.params, + }; } return { @@ -476,5 +383,5 @@ export function createAgentMachine( invoke, execute, stream, - } as any; + } as AgentMachine; } diff --git a/src/types.ts b/src/types.ts index 1de6054..6ba580d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,23 +15,6 @@ export type StandardSchemaResult = export type InferOutput = T extends StandardSchemaV1 ? O : never; -// ─── State Value (xstate-style) ─── - -/** `'idle'` or `{ handling: 'check' }` or `{ a: { b: 'deep' } }` */ -export type StateValue = string | { [key: string]: StateValue }; - -/** Derive the state value union from a states config (depth-limited to 4) */ -export type StateValueOf = _SV1; -type _SV1 = T extends Record - ? { [K in keyof T & string]: T[K] extends { states: infer S extends Record } ? { [P in K]: _SV2 } : K }[keyof T & string] - : never; -type _SV2 = T extends Record - ? { [K in keyof T & string]: T[K] extends { states: infer S extends Record } ? { [P in K]: _SV3 } : K }[keyof T & string] - : never; -type _SV3 = T extends Record - ? { [K in keyof T & string]: K }[keyof T & string] - : never; - // ─── Event Helpers ─── export type EventPayload = T extends Record ? unknown : T; @@ -63,9 +46,11 @@ export interface AgentAdapter { // ─── Transition ─── -export interface TransitionResult { +export interface TransitionResult< + TContext extends Record = Record, +> { target?: string; - context?: Record; + context?: Partial; params?: Record; } @@ -81,14 +66,10 @@ export interface StateConfig< params: Record; signal?: AbortSignal; }) => Promise; - onDone?: (args: { result: any; context: TContext }) => TransitionResult; - on?: Record TransitionResult)>; + onDone?: (args: { result: any; context: TContext }) => TransitionResult; + on?: Record | ((args: { event: any; context: TContext }) => TransitionResult)>; events?: Record; output?: (args: { context: TContext }) => unknown; - initial?: - | string - | ((args: { context: TContext; params: Record }) => TransitionResult); - states?: Record>; // choice-specific model?: string; adapter?: AgentAdapter; @@ -104,7 +85,7 @@ export interface StateConfig< export interface AgentState< TContext extends Record = Record, - TValue extends StateValue = StateValue, + TValue extends string = string, > { value: TValue; context: TContext; @@ -118,7 +99,7 @@ export interface AgentState< export type ExecuteResult< TContext extends Record = Record, - TValue extends StateValue = StateValue, + TValue extends string = string, TEvents extends Record = {}, > = | { status: 'done'; state: AgentState; output: unknown; context: TContext } @@ -129,7 +110,7 @@ export type ExecuteResult< export interface AgentSnapshot< TContext extends Record = Record, - TValue extends StateValue = StateValue, + TValue extends string = string, > { value: TValue; context: TContext; @@ -149,33 +130,33 @@ export interface AgentMachine< getInitialState( ...args: unknown extends TInput ? [input?: TInput] : [input: TInput] - ): AgentState>; + ): AgentState; resolveState(raw: { - value: StateValue; + value: string; context: TContext; params?: Record>; status?: AgentState['status']; output?: unknown; error?: unknown; - }): AgentState>; + }): AgentState; transition( - state: AgentState>, + state: AgentState, event: TransitionEvent - ): AgentState>; + ): AgentState; invoke( - state: AgentState> - ): Promise>>; + state: AgentState + ): Promise>; execute( - state: AgentState> - ): Promise, TEvents>>; + state: AgentState + ): Promise>; stream( - state: AgentState> - ): AsyncGenerator>>; + state: AgentState + ): AsyncGenerator>; } // ─── Machine Config (internal) ─── @@ -200,7 +181,7 @@ export interface MachineConfig< states: TStates; } -// ─── Decide (wrapper fn — typed result, untyped context) ─── +// ─── Decide ─── export type DecideResultFor< TOptions extends Record, @@ -224,7 +205,7 @@ export interface DecideConfig< on?: Record }) => TransitionResult>; } -// ─── Classify (wrapper fn — typed category, untyped context) ─── +// ─── Classify ─── export interface ClassifyConfig< TCategories extends Record = Record, diff --git a/src/utils.ts b/src/utils.ts index e8a38d0..1c8b73c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,48 +1,13 @@ import type { + AgentState, MachineConfig, StandardSchemaResult, StandardSchemaV1, - StateConfig, - StateValue, TransitionResult, } from './types.js'; -/** Internal state representation with dot-path string value */ -export interface InternalState { - value: string; - context: Record; - status: 'active' | 'pending' | 'done' | 'error'; - params: Record>; - output?: unknown; - error?: unknown; -} - -// ─── StateValue ↔ dot-path conversion ─── - -/** Convert xstate-style value `{ handling: 'check' }` to dot-path `'handling.check'` */ -export function valueToPath(value: StateValue): string { - if (typeof value === 'string') return value; - const key = Object.keys(value)[0]!; - const child = (value as Record)[key]!; - return typeof child === 'string' - ? `${key}.${child}` - : `${key}.${valueToPath(child)}`; -} - -/** Convert dot-path `'handling.check'` to xstate-style value `{ handling: 'check' }` */ -export function pathToValue(path: string): StateValue { - const parts = path.split('.'); - if (parts.length === 1) return parts[0]!; - let result: StateValue = parts[parts.length - 1]!; - for (let i = parts.length - 2; i >= 0; i--) { - result = { [parts[i]!]: result }; - } - return result; -} - /** * Validate a value against a Standard Schema synchronously. - * Throws if validation returns a Promise (async schemas not supported here). */ export function validateSchemaSync( schema: StandardSchemaV1, @@ -51,7 +16,7 @@ export function validateSchemaSync( const result = schema['~standard'].validate(value); if (result instanceof Promise) { throw new Error( - 'Async schema validation is not supported in sync context. Validate input before calling getInitialState.' + 'Async schema validation is not supported in sync context.' ); } const syncResult = result as StandardSchemaResult; @@ -65,58 +30,44 @@ export function validateSchemaSync( } /** - * Resolve a StateConfig from a dot-separated state path. + * Get the state config for a given state name. */ export function resolveStateConfig( - config: MachineConfig, + config: MachineConfig, value: string -): any { - const parts = value.split('.'); - let current: Record = config.states; - let stateConfig: any; - - for (const part of parts) { - stateConfig = current[part]; - if (!stateConfig) { - throw new Error(`State '${part}' not found in path '${value}'`); - } - if (stateConfig.states) { - current = stateConfig.states; - } +): StateConfigAny { + const stateConfig = config.states[value]; + if (!stateConfig) { + throw new Error(`State '${value}' not found`); } - - return stateConfig!; + return stateConfig as StateConfigAny; } -/** - * Get the parent state config, or null for root states. - */ -export function getParentConfig( - config: MachineConfig, - value: string -): any { - const parts = value.split('.'); - if (parts.length <= 1) return null; - const parentPath = parts.slice(0, -1).join('.'); - return resolveStateConfig(config, parentPath); -} +/** Loose state config for internal runtime use */ +export type StateConfigAny = { + type?: 'final' | 'choice'; + invoke?: (args: { context: Record; params: Record }) => Promise; + onDone?: (args: { result: unknown; context: Record }) => TransitionResult; + on?: Record; context: Record }) => TransitionResult)>; + output?: (args: { context: Record }) => unknown; + model?: string; + adapter?: { decide: (...args: unknown[]) => Promise }; + prompt?: string | ((args: { context: Record; params: Record }) => string); + options?: Record; + reasoning?: boolean; + events?: Record; + __type?: string; + __decideConfig?: Record; +}; /** * Get the params for the current state. - * Params are stored at `state.params[statePath]` when transitioning. - * For nested states, also checks the parent path. */ export function getParams( - valuePath: string, + value: string, params: Record> ): Record { - // Check own params first (set when transitioning TO this state) - if (params[valuePath]) return params[valuePath]!; - // Fall back to parent params (for compound state children) - const parts = valuePath.split('.'); - if (parts.length <= 1) return {}; - const parentPath = parts.slice(0, -1).join('.'); - return params[parentPath] ?? {}; + return params[value] ?? {}; } /** @@ -140,29 +91,13 @@ export function resolveInitial( return initial(args); } -/** - * Resolve a target relative to the handler's state path. - * Targets are siblings of the state where the handler is defined. - */ -export function resolveTarget( - handlerStatePath: string, - target: string -): string { - const parts = handlerStatePath.split('.'); - if (parts.length <= 1) return target; - const parentParts = parts.slice(0, -1); - return [...parentParts, target].join('.'); -} - /** * Apply a transition result to produce a new state. */ export function applyTransition( - config: MachineConfig, - state: InternalState, - transition: TransitionResult, - handlerStatePath: string -): InternalState { + state: AgentState, + transition: TransitionResult +): AgentState { let newState = { ...state }; if (transition.context) { @@ -170,67 +105,25 @@ export function applyTransition( } if (transition.target) { - newState.value = resolveTarget(handlerStatePath, transition.target); + newState.value = transition.target; newState.status = 'active'; if (transition.params) { newState.params = { ...state.params, - [newState.value]: transition.params, + [transition.target]: transition.params, }; } - - newState = enterCompoundStates(config, newState); } return newState; } /** - * If the current state is a compound state, resolve its initial and descend. - */ -export function enterCompoundStates( - config: MachineConfig, - state: InternalState -): InternalState { - let current = state; - - for (;;) { - const stateConfig = resolveStateConfig(config, current.value); - if (!stateConfig.states || !stateConfig.initial) break; - - const params = current.params[current.value] ?? {}; - const init = resolveInitial(stateConfig.initial, { - context: current.context, - params, - }); - - if (!init.target) break; - - const childValue = `${current.value}.${init.target}`; - current = { ...current, value: childValue }; - - if (init.context) { - current.context = { ...current.context, ...init.context }; - } - if (init.params) { - current.params = { - ...current.params, - [current.value]: init.params, - }; - } - } - - return current; -} - -/** - * Collect available events for a state path. - * State-level events override root-level events. - * Only includes events that have handlers. + * Collect available events for a state. */ export function getAvailableEvents( - config: MachineConfig, + config: MachineConfig, value: string ): Record { const events: Record = {}; @@ -239,62 +132,37 @@ export function getAvailableEvents( Object.assign(events, config.schemas.events); } - const parts = value.split('.'); - for (let i = 0; i < parts.length; i++) { - const path = parts.slice(0, i + 1).join('.'); - const stateConfig = resolveStateConfig(config, path); - if (stateConfig.events) { - Object.assign(events, stateConfig.events); - } - } - - const handledTypes = getHandledEventTypes(config, value); - const result: Record = {}; - for (const eventType of handledTypes) { - if (events[eventType]) { - result[eventType] = events[eventType]; - } + const stateConfig = resolveStateConfig(config, value); + if (stateConfig.events) { + Object.assign(events, stateConfig.events); } - return result; -} - -function getHandledEventTypes( - config: MachineConfig, - value: string -): Set { - const handled = new Set(); - const parts = value.split('.'); - - for (let i = parts.length; i >= 1; i--) { - const path = parts.slice(0, i).join('.'); - const stateConfig = resolveStateConfig(config, path); - if (stateConfig.on) { - for (const eventType of Object.keys(stateConfig.on)) { - handled.add(eventType); + if (stateConfig.on) { + const handled = new Set(Object.keys(stateConfig.on)); + const result: Record = {}; + for (const key of handled) { + if (events[key]) { + result[key] = events[key]; } } + return result; } - return handled; + return {}; } /** * Find the event schema for a given event type. - * State-level schemas override root-level. */ export function findEventSchema( - config: MachineConfig, + config: MachineConfig, value: string, eventType: string ): StandardSchemaV1 | undefined { - const parts = value.split('.'); - for (let i = parts.length; i >= 1; i--) { - const path = parts.slice(0, i).join('.'); - const stateConfig = resolveStateConfig(config, path); - if (stateConfig.events?.[eventType]) { - return stateConfig.events[eventType]; - } + const stateConfig = resolveStateConfig(config, value); + if (stateConfig.events?.[eventType]) { + return stateConfig.events[eventType]; } - return config.schemas?.events?.[eventType]; + const events = config.schemas?.events as Record | undefined; + return events?.[eventType]; } From a4b80719e1ac06d78cae0198c8f8eec75c7e9da4 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Wed, 8 Apr 2026 19:35:06 -0400 Subject: [PATCH 05/50] docs: add langgraph core replacement design --- ...04-08-langgraph-core-replacement-design.md | 549 ++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md diff --git a/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md b/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md new file mode 100644 index 0000000..29eeb94 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md @@ -0,0 +1,549 @@ +# LangGraph Core Replacement Design + +## Goal + +Evolve `agent` into a LangGraph-core replacement in terms of runtime behavior and developer outcomes, while improving on LangGraph through a simpler, more explicit state-machine model. + +The target is semantic parity for core orchestration use cases, not API compatibility. Developers should be able to build the same classes of systems in `agent` that they can build with LangGraph core, but using `agent`'s state-machine API and philosophy. + +## Core Philosophies + +The design is constrained by these principles: + +1. Logic is pure. + The semantic center remains: + + ```ts + (currentState, event) => { + return { nextState, effects }; + } + ``` + + State transition logic should stay deterministic, replayable, and inspectable. + +2. Effect execution is first-class. + The runtime must make it easy to both transition state and execute effects, but without collapsing transition logic into effectful code. Effects are driven by the machine, not hidden as the machine. + +3. Durability is core. + `agent` should treat persisted state and event history as first-class runtime concerns, not as optional add-ons. + +4. Runner-agnostic execution. + The runtime must be able to run anywhere: Node, Vercel, Cloudflare, Durable Objects, workers, and other environments. Storage and execution coordination must be abstracted behind portable interfaces. + +5. Improve on LangGraph rather than imitate it. + Do not copy LangGraph's graph-builder surface area or its more complex runtime semantics where a simpler state-machine formulation produces the same outcome. + +## Scope + +In scope: + +- core orchestration behavior currently covered by `@langchain/langgraph` +- runtime behavior tests and runnable examples from LangGraph core, rewritten as `agent`-idiomatic equivalents +- persistence, replay, resume, streaming, pending states, submachine composition, and high-value prebuilt agent patterns + +Out of scope for this design: + +- LangGraph monorepo packages outside core +- API/CLI/server packages +- UI framework SDKs and app templates +- type-level compatibility with LangGraph +- exact API or import-path matching + +## Design Summary + +`agent` should become a durable run engine for state machines. + +A machine definition remains declarative and mostly pure: + +- states +- transitions +- invoke/effect boundaries +- final outputs + +A run becomes the primary runtime object: + +- backed by an append-only event log +- accelerated by persisted snapshots +- observable through a first-class event stream +- resumable from persisted state +- portable across runners via abstract persistence and scheduling interfaces + +This yields a model where the semantics are simple: + +- transitions are deterministic +- invokes are explicit effect boundaries +- external events drive progress +- streaming is run-level, not bolted on +- persistence is a core contract + +## Runtime Model + +The machine model should stay state-machine-first rather than graph-builder-first. + +### Machine Definition + +A machine definition remains responsible for: + +- context initialization +- current state value +- transition handlers +- invoke definitions +- terminal outputs + +The machine should continue to express workflows such as: + +- branching +- tool-using agents +- human review loops +- multi-step planning and execution +- nested machine orchestration + +### Run Model + +Introduce a durable run as the central execution concept. + +Each run has: + +- `runId` +- `machineId` +- input payload +- current snapshot +- append-only event history +- status +- subscribers + +Suggested shape: + +```ts +interface AgentRun { + id: string; + status: "active" | "pending" | "done" | "error"; + getSnapshot(): AgentState; + send(event: { type: string; [key: string]: unknown }): Promise; + on(type: string, handler: (event: unknown) => void): () => void; + [Symbol.asyncIterator](): AsyncIterator; +} +``` + +### Durable Execution Boundaries + +Phase 1 durability should exist at machine boundaries, not inside arbitrary user async code. + +Persist: + +- run start +- external event receipt +- state entry +- invoke start +- invoke success +- invoke failure +- transition application +- run completion +- run failure + +Do not claim sub-invoke durability for plain `Promise.all(...)` or arbitrary nested promises. + +### Pending and Human-in-the-Loop + +Do not introduce an interrupt primitive as a core concept. + +Use explicit pending states and external events: + +```ts +review: { + on: { + approve: { target: "send" }, + reject: { target: "revise" }, + }, +} +``` + +This preserves: + +- deterministic replay +- explicit control flow +- durable resume semantics +- runner portability + +### Submachine Composition + +Do not introduce graph/subgraph composition as a first-class structural primitive in phase 1. + +Instead, allow composition through normal execution: + +```ts +writing: { + invoke: async ({ context }) => { + return executeAgentMachine(writerMachine, { + input: { + topic: context.topic, + research: context.research, + }, + }); + }, +} +``` + +This is sufficient for most LangGraph subgraph outcomes without graph-specific composition APIs. + +## Purity and Effects + +The central architectural requirement is preserving pure transition logic while still making effects first-class. + +Conceptually, every runtime step should be explainable as: + +```ts +const { nextState, effects } = transition(currentState, event); +``` + +Where: + +- `nextState` is deterministic +- `effects` are explicit runtime work to perform next + +In practice, current `agent` APIs already combine these concerns inside state configs. The design should move the runtime toward an explicit internal split even if the external authoring API remains ergonomic. + +That means: + +- transition logic should remain replayable without rerunning effects +- effect lifecycle should be represented in runtime events +- invoke results should be fed back as events, not hidden mutations + +This is the main improvement opportunity over LangGraph's more graph-runtime-centric model. + +## Persistence Model + +The canonical persisted representation is an append-only event log. + +Snapshots are derived state used to accelerate replay and resume. + +### Event Log + +Suggested minimal durable event family: + +```ts +type PersistedRunEvent = + | { type: "run.started"; input: unknown; at: number } + | { type: "event.received"; event: { type: string; [k: string]: unknown }; at: number } + | { type: "state.entered"; value: string; params?: Record; at: number } + | { type: "invoke.started"; state: string; at: number } + | { type: "invoke.succeeded"; state: string; result: unknown; at: number } + | { type: "invoke.failed"; state: string; error: SerializedError; at: number } + | { type: "transition.applied"; from: string; to: string; at: number } + | { type: "run.completed"; output: unknown; at: number } + | { type: "run.failed"; error: SerializedError; at: number }; +``` + +### Snapshots + +Suggested snapshot shape: + +```ts +type PersistedSnapshot = { + runId: string; + version: number; + state: AgentState; + lastEventIndex: number; + createdAt: number; +}; +``` + +### Replay Model + +Restore a run by: + +1. loading the latest snapshot +2. replaying all events after that snapshot +3. reconstructing the current live run state + +If no snapshot exists, replay from `run.started`. + +### Storage Interface + +Persistence must be abstracted behind a portable interface: + +```ts +interface RunStore { + append(runId: string, event: PersistedRunEvent): Promise; + loadEvents(runId: string, afterVersion?: number): Promise; + loadLatestSnapshot(runId: string): Promise; + saveSnapshot(snapshot: PersistedSnapshot): Promise; +} +``` + +This is what makes the runtime portable to: + +- in-memory test stores +- SQL or key-value stores +- Cloudflare Durable Objects +- Vercel-backed durable layers +- custom app infrastructure + +### Important Phase 1 Constraint + +Invoke internals are opaque unless user code or future helpers explicitly expose finer-grained durable progress. + +This means: + +- plain async code remains ergonomic +- invoke-level durability is honest +- future `task(...)` or `parallel(...)` helpers remain additive + +## Streaming Model + +Streaming must be a first-class capability of a run. + +Separate: + +1. durable runtime events +2. ephemeral stream parts + +### Run-Level Events + +Suggested public stream model: + +```ts +type RunEmitterEvent = + | { type: "state"; snapshot: AgentState } + | { type: "machine.event"; event: PersistedRunEvent } + | { type: "part"; part: StreamPart } + | { type: "done"; output: unknown } + | { type: "error"; error: unknown }; +``` + +### Stream Parts + +For common model/tool streaming shapes, align with Vercel AI SDK-style part conventions where practical: + +```ts +type StreamPart = + | { type: "text-start"; id: string } + | { type: "text-delta"; id: string; delta: string } + | { type: "text-end"; id: string } + | { type: "tool-input-start"; toolCallId: string; toolName: string } + | { type: "tool-input-delta"; toolCallId: string; inputTextDelta: string } + | { type: "tool-input-available"; toolCallId: string; toolName: string; input: unknown } + | { type: "tool-output-available"; toolCallId: string; output: unknown } + | { type: "reasoning-part"; text: string } + | { type: "data"; data: unknown } + | { type: "error"; errorText: string }; +``` + +Provide convenience listeners on top: + +```ts +run.on("textPart", ({ delta }) => {}); +run.on("toolCall", ({ toolCallId, toolName, input }) => {}); +run.on("toolResult", ({ toolCallId, output }) => {}); +``` + +### Emission Model + +Invoke code should be able to emit live parts: + +```ts +drafting: { + invoke: async ({ emit }) => { + for await (const chunk of streamText(...)) { + emit({ type: "text-delta", id: "draft", delta: chunk }); + } + return { draft: finalText }; + }, +} +``` + +Durable runtime events are persisted. Stream parts are ephemeral by default in phase 1. + +## Runner-Agnostic Architecture + +The runtime must not assume: + +- long-lived Node processes +- a specific queue system +- a specific database +- process-local memory as truth + +The core should be split into: + +1. pure machine semantics +2. durable run orchestration +3. storage abstraction +4. environment-specific runner adapters + +This makes it possible to showcase: + +- standard Node process usage +- Vercel usage +- Cloudflare Worker usage +- Cloudflare Durable Object usage + +Durable Objects are especially relevant because they demonstrate the design clearly: + +- event log and snapshot persistence can live in DO state +- run coordination can be serialized naturally +- stream subscriptions can be implemented via the object lifecycle + +The important point is that Durable Objects should be an example adapter, not the core assumption. + +## Capability Mapping from LangGraph Core + +### Directly Mappable + +- graph orchestration -> explicit machine states and transitions +- shared state update workflows -> `invoke` + `onDone` context updates +- human-in-the-loop -> pending states + external events +- subgraphs/subflows -> nested machine execution +- streaming -> run-level event emitter + stream parts +- persistence/resume -> event log + snapshots +- prebuilt agent patterns -> curated machine factories + +### Needs Reinterpretation + +- reducers/channels -> avoid first-class graph-channel runtime semantics in phase 1 +- graph builder APIs -> do not mirror +- `START` / `END` constants -> unnecessary as authoring primitives +- explicit interrupt primitive -> defer + +### Deferred + +- graph-level true concurrent branch semantics with reducer joins +- durable sub-invoke task boundaries +- remote/API client compatibility +- type-level compatibility tests + +## LangGraph Test Port Strategy + +Only port: + +- runtime behavior tests +- runnable examples + +Do not port: + +- type-only tests +- API surface compatibility tests + +### Priority Test Groups + +1. Graph/state behavior + - `graph.test.ts` + - `errors.test.ts` + - `constants.test.ts` + +2. Execution/runtime behavior + - selected `pregel.test.ts` + - `pregel.read.test.ts` + - `pregel/stream.test.ts` + - `execution_info.test.ts` + +3. Persistence and replay + - `python_port/checkpoint.test.ts` + - `remote-graph-resumable.test.ts` + +4. Prebuilt agent behavior + - `prebuilt.test.ts` + - `prebuilt.int.test.ts` + +5. Runtime schema behavior + - relevant portions of `zod_state.test.ts` + +Each imported test should become an `agent`-idiomatic equivalent that asserts the same end-result behavior through the state-machine runtime. + +## Example Port Strategy + +Priority LangGraph-equivalent examples to rebuild in `agent`: + +1. quickstart +2. branching +3. wait-user-input / breakpoints +4. persistence +5. subgraph +6. tool-calling +7. create-react-agent / react-agent-from-scratch +8. multi-agent-network +9. plan-and-execute +10. reflection +11. rewoo +12. sql-agent + +Each example should: + +- use `agent`'s machine API +- be runnable locally +- demonstrate the same user outcome +- prefer explicit machine structure over graph-builder mimicry + +## Phased Delivery Plan + +### Phase 0: Lock the Core Contract + +Define: + +- durable run contract +- store interfaces +- restore/replay semantics +- stream event model + +### Phase 1: Durable Runtime + +Build: + +- run object +- event logging +- snapshotting +- restoration +- run subscriptions + +### Phase 2: Expressiveness + +Build: + +- better nested machine execution +- pending-state ergonomics +- inspection/trace support +- graph/diagram export + +### Phase 3: Prebuilt Patterns + +Build: + +- ReAct-style machine factory +- tool-calling helpers +- transcript/message helpers + +### Phase 4: Example Corpus + +Rebuild high-value LangGraph examples in `agent`. + +### Phase 5: Behavioral Regression Coverage + +Port and maintain semantic-equivalence tests grouped by capability family. + +## Risks + +1. Conflating transition logic with invoke execution. + This weakens replay semantics and makes portability worse. + +2. Over-promising invoke-level durability. + Plain async code is not automatically resumable at subtask granularity. + +3. Recreating LangGraph builder abstractions instead of improving on them. + This increases complexity without serving the machine-first philosophy. + +4. Mixing durable and ephemeral streams carelessly. + Runtime events and text/tool stream parts need distinct semantics. + +5. Allowing runner assumptions to leak into core. + This would compromise portability across Vercel, Cloudflare, and other environments. + +## Recommendation + +Proceed with a capability-first expansion of `agent`'s runtime: + +- keep the machine API central +- make durable runs the execution center +- treat event persistence and snapshots as first-class +- make streaming run-level and explicit +- port LangGraph tests/examples as semantic benchmarks + +This produces a cleaner, more durable, and more portable core than LangGraph while still reaching the same practical developer outcomes. From a3a2d7551879f819a6a9537f7ea38da0301e7ff1 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Wed, 8 Apr 2026 20:48:53 -0400 Subject: [PATCH 06/50] docs: refine langgraph replacement event model --- ...04-08-langgraph-core-replacement-design.md | 218 ++++++++++++++---- 1 file changed, 169 insertions(+), 49 deletions(-) diff --git a/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md b/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md index 29eeb94..0ff6aed 100644 --- a/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md +++ b/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md @@ -62,7 +62,7 @@ A machine definition remains declarative and mostly pure: A run becomes the primary runtime object: -- backed by an append-only event log +- backed by an append-only replay journal - accelerated by persisted snapshots - observable through a first-class event stream - resumable from persisted state @@ -72,7 +72,7 @@ This yields a model where the semantics are simple: - transitions are deterministic - invokes are explicit effect boundaries -- external events drive progress +- external and internal machine events drive progress - streaming is run-level, not bolted on - persistence is a core contract @@ -100,15 +100,19 @@ The machine should continue to express workflows such as: ### Run Model -Introduce a durable run as the central execution concept. +Introduce a durable session as the central execution concept. + +`sessionId` should be the canonical persisted identifier. + +`run` can still be a useful public term for the live handle returned by the runtime, but the durable identity should align with actor/session terminology. Each run has: -- `runId` +- `sessionId` - `machineId` - input payload - current snapshot -- append-only event history +- append-only replay journal - status - subscribers @@ -116,9 +120,9 @@ Suggested shape: ```ts interface AgentRun { - id: string; + sessionId: string; status: "active" | "pending" | "done" | "error"; - getSnapshot(): AgentState; + getSnapshot(): AgentSnapshot; send(event: { type: string; [key: string]: unknown }): Promise; on(type: string, handler: (event: unknown) => void): () => void; [Symbol.asyncIterator](): AsyncIterator; @@ -129,17 +133,12 @@ interface AgentRun { Phase 1 durability should exist at machine boundaries, not inside arbitrary user async code. -Persist: +Persist the replayable machine events: -- run start -- external event receipt -- state entry -- invoke start -- invoke success -- invoke failure -- transition application -- run completion -- run failure +- external events sent to the actor +- internal machine events emitted by the runtime +- invoke completion events +- invoke failure events Do not claim sub-invoke durability for plain `Promise.all(...)` or arbitrary nested promises. @@ -206,57 +205,106 @@ In practice, current `agent` APIs already combine these concerns inside state co That means: - transition logic should remain replayable without rerunning effects -- effect lifecycle should be represented in runtime events +- effect lifecycle should be represented through emitted machine events - invoke results should be fed back as events, not hidden mutations +This should follow the same philosophy as XState invoke completion: + +- invoke completion becomes an internal done event +- invoke failure becomes an internal error event +- the machine progresses by consuming events, not by direct mutation from effect code + This is the main improvement opportunity over LangGraph's more graph-runtime-centric model. ## Persistence Model -The canonical persisted representation is an append-only event log. +The canonical persisted representation is an append-only replay journal. Snapshots are derived state used to accelerate replay and resume. -### Event Log +### Replay Journal + +The replay journal is the source of truth. It contains the actual events consumed by the actor, including synthetic internal events produced by the runtime. -Suggested minimal durable event family: +Suggested minimal replayable event family: ```ts -type PersistedRunEvent = - | { type: "run.started"; input: unknown; at: number } - | { type: "event.received"; event: { type: string; [k: string]: unknown }; at: number } - | { type: "state.entered"; value: string; params?: Record; at: number } - | { type: "invoke.started"; state: string; at: number } - | { type: "invoke.succeeded"; state: string; result: unknown; at: number } - | { type: "invoke.failed"; state: string; error: SerializedError; at: number } - | { type: "transition.applied"; from: string; to: string; at: number } - | { type: "run.completed"; output: unknown; at: number } - | { type: "run.failed"; error: SerializedError; at: number }; +type JournalEvent = + | { type: "xstate.init"; input?: unknown; at: number } + | { type: "user.message"; [key: string]: unknown; at: number } + | { type: "approve"; at: number } + | { type: "xstate.done.invoke.research"; output: unknown; at: number } + | { + type: "xstate.error.invoke.research"; + error: SerializedError; + at: number; + }; ``` +The exact event naming can be refined, but the important property is that invoke done/error are actor events, not metadata records. + +### Runtime and Audit Events + +Derived runtime records can still exist for observability and subscriptions, but they are not the canonical replay source. + +Examples: + +- state entered +- transition applied +- snapshot persisted +- session completed +- session failed + +These belong in the runtime event stream and diagnostics layer. + ### Snapshots Suggested snapshot shape: ```ts +type AgentSnapshot = { + value: string; + context: Record; + status: "active" | "done" | "error" | "pending"; + createdAt: number; + sessionId: string; + output?: unknown; + error?: SerializedError; +}; + type PersistedSnapshot = { - runId: string; - version: number; - state: AgentState; - lastEventIndex: number; + sessionId: string; + sequence: number; + snapshot: AgentSnapshot; + lastJournalIndex: number; createdAt: number; }; ``` +This aligns the live snapshot shape closely with XState snapshots: + +- `value` +- `context` +- `status` + +with additional metadata such as: + +- `createdAt` +- `sessionId` +- optional `output` +- optional `error` + +The `sequence` field exists so storage can identify which snapshot is the latest persisted derivation and so replay can resume from a known journal offset. It should track journal position rather than inventing a separate semantic version. + ### Replay Model Restore a run by: 1. loading the latest snapshot -2. replaying all events after that snapshot +2. replaying all journal events after that snapshot 3. reconstructing the current live run state -If no snapshot exists, replay from `run.started`. +If no snapshot exists, replay from `xstate.init`. ### Storage Interface @@ -264,9 +312,9 @@ Persistence must be abstracted behind a portable interface: ```ts interface RunStore { - append(runId: string, event: PersistedRunEvent): Promise; - loadEvents(runId: string, afterVersion?: number): Promise; - loadLatestSnapshot(runId: string): Promise; + append(sessionId: string, event: JournalEvent): Promise; + loadEvents(sessionId: string, afterSequence?: number): Promise; + loadLatestSnapshot(sessionId: string): Promise; saveSnapshot(snapshot: PersistedSnapshot): Promise; } ``` @@ -304,13 +352,27 @@ Suggested public stream model: ```ts type RunEmitterEvent = - | { type: "state"; snapshot: AgentState } - | { type: "machine.event"; event: PersistedRunEvent } + | { type: "state"; snapshot: AgentSnapshot } + | { type: "machine.event"; event: JournalEvent } + | { type: "runtime"; event: RuntimeEvent } | { type: "part"; part: StreamPart } | { type: "done"; output: unknown } | { type: "error"; error: unknown }; ``` +Where `machine.event` refers to replayable actor events and `runtime` refers to derived lifecycle records useful for debugging and orchestration. + +Suggested runtime event family: + +```ts +type RuntimeEvent = + | { type: "state.entered"; value: string; at: number } + | { type: "transition.applied"; from: string; to: string; at: number } + | { type: "snapshot.saved"; sessionId: string; sequence: number; at: number } + | { type: "session.completed"; sessionId: string; at: number } + | { type: "session.failed"; sessionId: string; error: SerializedError; at: number }; +``` + ### Stream Parts For common model/tool streaming shapes, align with Vercel AI SDK-style part conventions where practical: @@ -339,13 +401,13 @@ run.on("toolResult", ({ toolCallId, output }) => {}); ### Emission Model -Invoke code should be able to emit live parts: +Invoke code should be able to emit live parts using a separate enqueue/emission argument: ```ts drafting: { - invoke: async ({ emit }) => { + invoke: async ({ context }, enq) => { for await (const chunk of streamText(...)) { - emit({ type: "text-delta", id: "draft", delta: chunk }); + enq.emit({ type: "text-delta", id: "draft", delta: chunk }); } return { draft: finalText }; }, @@ -354,6 +416,39 @@ drafting: { Durable runtime events are persisted. Stream parts are ephemeral by default in phase 1. +Using a second argument is important because it preserves a useful authoring distinction: + +- one-argument functions are easier to lint as pure/no-emission +- two-argument functions explicitly opt into streaming side effects + +### Emitted Schemas + +Machine definitions should support emitted event schemas alongside input and external event schemas. + +Suggested direction: + +```ts +schemas: { + input: ..., + events: { + approve: ..., + reject: ..., + }, + emitted: { + textPart: ..., + toolCall: ..., + toolResult: ..., + }, +} +``` + +This gives: + +- typed live emissions +- runtime validation of emitted parts +- stronger UI integration +- symmetry with event schemas + ## Runner-Agnostic Architecture The runtime must not assume: @@ -379,7 +474,7 @@ This makes it possible to showcase: Durable Objects are especially relevant because they demonstrate the design clearly: -- event log and snapshot persistence can live in DO state +- replay journal and snapshot persistence can live in DO state - run coordination can be serialized naturally - stream subscriptions can be implemented via the object lifecycle @@ -393,8 +488,8 @@ The important point is that Durable Objects should be an example adapter, not th - shared state update workflows -> `invoke` + `onDone` context updates - human-in-the-loop -> pending states + external events - subgraphs/subflows -> nested machine execution -- streaming -> run-level event emitter + stream parts -- persistence/resume -> event log + snapshots +- streaming -> run-level event emitter + stream parts + emitted schemas +- persistence/resume -> event journal + snapshots - prebuilt agent patterns -> curated machine factories ### Needs Reinterpretation @@ -489,7 +584,7 @@ Define: Build: - run object -- event logging +- journal append/load - snapshotting - restoration - run subscriptions @@ -536,6 +631,31 @@ Port and maintain semantic-equivalence tests grouped by capability family. 5. Allowing runner assumptions to leak into core. This would compromise portability across Vercel, Cloudflare, and other environments. +## Advantages Over LangGraph + +This design improves on LangGraph core in several important ways: + +1. Clearer semantic center. + LangGraph is graph-runtime-first. This design is actor/state-machine-first, so the progression model stays grounded in event consumption and snapshot derivation. + +2. Better purity boundary. + Transition logic remains conceptually pure, while effect execution is explicit and first-class rather than interwoven with graph runtime semantics. + +3. Simpler human-in-the-loop model. + Pending states plus external events are easier to reason about than a dedicated interrupt abstraction for most workflows. + +4. More honest durability. + The replay source is the actor event journal, not a mixed bag of runtime metadata. This makes replay and debugging cleaner. + +5. Better portability. + The runtime is explicitly designed to be runner-agnostic and storage-agnostic, making it a stronger fit for Vercel, Cloudflare Workers, Durable Objects, and other environments. + +6. Easier mental model for composition. + Nested machine execution is ordinary execution, not a special graph/subgraph system. + +7. Better streaming ergonomics. + Run-level subscriptions plus emitted schemas provide a clearer UI/runtime boundary than LangGraph's graph-oriented stream modes. + ## Recommendation Proceed with a capability-first expansion of `agent`'s runtime: From d1842e2d414db765b184f0b63eeed7128d7d45bb Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 9 Apr 2026 05:48:11 -0400 Subject: [PATCH 07/50] feat: add durable session store foundation --- src/index.ts | 4 ++ src/persistence.test.ts | 84 +++++++++++++++++++++++++++++++++++++ src/runtime/events.ts | 7 ++++ src/runtime/memory-store.ts | 51 ++++++++++++++++++++++ src/runtime/store.ts | 22 ++++++++++ src/session-types.test.ts | 27 ++++++++++++ src/types.ts | 28 +++++++++---- 7 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 src/persistence.test.ts create mode 100644 src/runtime/events.ts create mode 100644 src/runtime/memory-store.ts create mode 100644 src/runtime/store.ts create mode 100644 src/session-types.test.ts diff --git a/src/index.ts b/src/index.ts index 5868e9e..a422e2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export { classify } from './classify.js'; // Adapter export { createAdapter } from './adapter.js'; +export { createMemoryRunStore } from './runtime/memory-store.js'; // Types export type { @@ -21,7 +22,10 @@ export type { EventUnion, ExecuteResult, InferOutput, + JournalEvent, MachineConfig, + PersistedSnapshot, + RunStore, StandardSchemaV1, StateConfig, Trace, diff --git a/src/persistence.test.ts b/src/persistence.test.ts new file mode 100644 index 0000000..d4ab086 --- /dev/null +++ b/src/persistence.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from 'vitest'; +import { createMemoryRunStore } from './index.js'; + +test('appends and loads journal events in sequence order', async () => { + const store = createMemoryRunStore(); + + await store.append('session-1', [ + { + sessionId: 'session-1', + sequence: 2, + type: 'xstate.done.invoke.worker', + at: 20, + }, + { + sessionId: 'session-1', + sequence: 1, + type: 'xstate.init', + at: 10, + }, + ]); + + expect(await store.loadEvents('session-1')).toEqual([ + { + sessionId: 'session-1', + sequence: 1, + type: 'xstate.init', + at: 10, + }, + { + sessionId: 'session-1', + sequence: 2, + type: 'xstate.done.invoke.worker', + at: 20, + }, + ]); +}); + +test('loads the latest saved snapshot', async () => { + const store = createMemoryRunStore(); + + await store.saveSnapshot({ + sessionId: 'session-1', + sequence: 1, + snapshot: { + value: 'idle', + context: { count: 1 }, + status: 'active', + createdAt: 100, + sessionId: 'session-1', + }, + lastJournalIndex: 1, + createdAt: 100, + }); + + await store.saveSnapshot({ + sessionId: 'session-1', + sequence: 3, + snapshot: { + value: 'done', + context: { count: 2 }, + status: 'done', + createdAt: 300, + sessionId: 'session-1', + output: { count: 2 }, + }, + lastJournalIndex: 3, + createdAt: 300, + }); + + expect(await store.loadLatestSnapshot('session-1')).toEqual({ + sessionId: 'session-1', + sequence: 3, + snapshot: { + value: 'done', + context: { count: 2 }, + status: 'done', + createdAt: 300, + sessionId: 'session-1', + output: { count: 2 }, + }, + lastJournalIndex: 3, + createdAt: 300, + }); +}); diff --git a/src/runtime/events.ts b/src/runtime/events.ts new file mode 100644 index 0000000..d0a864c --- /dev/null +++ b/src/runtime/events.ts @@ -0,0 +1,7 @@ +export interface JournalEvent { + sessionId: string; + sequence: number; + type: 'xstate.init' | (string & {}); + at: number; + [key: string]: unknown; +} diff --git a/src/runtime/memory-store.ts b/src/runtime/memory-store.ts new file mode 100644 index 0000000..0888ee2 --- /dev/null +++ b/src/runtime/memory-store.ts @@ -0,0 +1,51 @@ +import type { AgentSnapshot } from '../types.js'; +import type { JournalEvent } from './events.js'; +import type { PersistedSnapshot, RunStore } from './store.js'; + +function compareEvents(a: JournalEvent, b: JournalEvent): number { + return a.sequence - b.sequence || a.at - b.at; +} + +function compareSnapshots( + a: PersistedSnapshot, + b: PersistedSnapshot +): number { + return a.sequence - b.sequence || a.createdAt - b.createdAt; +} + +export function createMemoryRunStore< + TSnapshot extends AgentSnapshot = AgentSnapshot, + TEvent extends JournalEvent = JournalEvent, +>(): RunStore { + const journals = new Map(); + const snapshots = new Map[]>(); + + return { + async append(sessionId, events) { + const current = journals.get(sessionId) ?? []; + current.push(...events); + journals.set(sessionId, current); + }, + + async loadEvents(sessionId) { + const events = journals.get(sessionId) ?? []; + return [...events].sort(compareEvents) as TEvent[]; + }, + + async loadLatestSnapshot(sessionId) { + const saved = snapshots.get(sessionId); + if (!saved?.length) { + return null; + } + + const sorted = [...saved].sort(compareSnapshots); + return sorted[sorted.length - 1] ?? null; + }, + + async saveSnapshot(snapshot) { + const current = snapshots.get(snapshot.sessionId) ?? []; + current.push(snapshot); + snapshots.set(snapshot.sessionId, current); + }, + }; +} diff --git a/src/runtime/store.ts b/src/runtime/store.ts new file mode 100644 index 0000000..98089cb --- /dev/null +++ b/src/runtime/store.ts @@ -0,0 +1,22 @@ +import type { AgentSnapshot } from '../types.js'; +import type { JournalEvent } from './events.js'; + +export interface PersistedSnapshot< + TSnapshot extends AgentSnapshot = AgentSnapshot, +> { + sessionId: string; + sequence: number; + snapshot: TSnapshot; + lastJournalIndex: number; + createdAt: number; +} + +export interface RunStore< + TSnapshot extends AgentSnapshot = AgentSnapshot, + TEvent extends JournalEvent = JournalEvent, +> { + append(sessionId: string, events: TEvent[]): Promise; + loadEvents(sessionId: string): Promise; + loadLatestSnapshot(sessionId: string): Promise | null>; + saveSnapshot(snapshot: PersistedSnapshot): Promise; +} diff --git a/src/session-types.test.ts b/src/session-types.test.ts new file mode 100644 index 0000000..35b4e19 --- /dev/null +++ b/src/session-types.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'vitest'; +import type { AgentSnapshot, JournalEvent } from './index.js'; + +test('AgentSnapshot includes durable session fields', () => { + const snapshot: AgentSnapshot<{ count: number }, 'idle'> = { + value: 'idle', + context: { count: 1 }, + status: 'active', + createdAt: 123, + sessionId: 'session-1', + }; + + expect(snapshot.sessionId).toBe('session-1'); + expect(snapshot.createdAt).toBe(123); +}); + +test('JournalEvent supports invoke completion events', () => { + const event: JournalEvent = { + sessionId: 'session-1', + sequence: 2, + type: 'xstate.done.invoke.worker', + at: 456, + }; + + expect(event.type).toBe('xstate.done.invoke.worker'); + expect(event.at).toBe(456); +}); diff --git a/src/types.ts b/src/types.ts index 6ba580d..7d4038f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,10 @@ export type TransitionEvent< ? { type: string; [key: string]: unknown } : EventUnion; +// ─── Durable Session Vocabulary ─── + +export type { JournalEvent } from './runtime/events.js'; + // ─── Adapter ─── export interface AgentAdapter { @@ -115,9 +119,15 @@ export interface AgentSnapshot< value: TValue; context: TContext; status: AgentState['status']; - params: Record>; + createdAt?: number; + sessionId?: string; + output?: unknown; + error?: unknown; + params?: Record>; } +export type { PersistedSnapshot, RunStore } from './runtime/store.js'; + // ─── Agent Machine ─── export interface AgentMachine< @@ -194,29 +204,33 @@ export type DecideResultFor< }[keyof TOptions & string]; export interface DecideConfig< + TContext extends Record = Record, + TParams extends Record = Record, TOptions extends Record = Record, > { model: string; adapter?: AgentAdapter; - prompt: string | ((args: { context: Record; params: Record }) => string); + prompt: string | ((args: { context: TContext; params: TParams }) => string); options: TOptions; reasoning?: boolean; - onDone: (args: { result: DecideResultFor; context: Record }) => TransitionResult; - on?: Record }) => TransitionResult>; + onDone: (args: { result: DecideResultFor; context: TContext }) => TransitionResult; + on?: Record TransitionResult>; } // ─── Classify ─── export interface ClassifyConfig< + TContext extends Record = Record, + TParams extends Record = Record, TCategories extends Record = Record, > { model: string; adapter?: AgentAdapter; - prompt: string | ((args: { context: Record; params: Record }) => string); + prompt: string | ((args: { context: TContext; params: TParams }) => string); into: TCategories; examples?: Array<{ input: string; category: keyof TCategories & string }>; - onDone: (args: { result: { category: keyof TCategories & string }; context: Record }) => TransitionResult; - on?: Record }) => TransitionResult>; + onDone: (args: { result: { category: keyof TCategories & string }; context: TContext }) => TransitionResult; + on?: Record TransitionResult>; } // ─── Trace ─── From 3c85ab97c0fcd2312ae4d64abfff7e70177591bc Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 9 Apr 2026 05:51:41 -0400 Subject: [PATCH 08/50] fix: align durable snapshot contract --- src/persistence.test.ts | 27 +++++++++++++-------------- src/runtime/memory-store.ts | 4 ++-- src/runtime/store.ts | 2 +- src/types.ts | 5 ++--- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/persistence.test.ts b/src/persistence.test.ts index d4ab086..d4ba8ec 100644 --- a/src/persistence.test.ts +++ b/src/persistence.test.ts @@ -4,20 +4,19 @@ import { createMemoryRunStore } from './index.js'; test('appends and loads journal events in sequence order', async () => { const store = createMemoryRunStore(); - await store.append('session-1', [ - { - sessionId: 'session-1', - sequence: 2, - type: 'xstate.done.invoke.worker', - at: 20, - }, - { - sessionId: 'session-1', - sequence: 1, - type: 'xstate.init', - at: 10, - }, - ]); + await store.append('session-1', { + sessionId: 'session-1', + sequence: 2, + type: 'xstate.done.invoke.worker', + at: 20, + }); + + await store.append('session-1', { + sessionId: 'session-1', + sequence: 1, + type: 'xstate.init', + at: 10, + }); expect(await store.loadEvents('session-1')).toEqual([ { diff --git a/src/runtime/memory-store.ts b/src/runtime/memory-store.ts index 0888ee2..ee77053 100644 --- a/src/runtime/memory-store.ts +++ b/src/runtime/memory-store.ts @@ -21,9 +21,9 @@ export function createMemoryRunStore< const snapshots = new Map[]>(); return { - async append(sessionId, events) { + async append(sessionId, event) { const current = journals.get(sessionId) ?? []; - current.push(...events); + current.push(event); journals.set(sessionId, current); }, diff --git a/src/runtime/store.ts b/src/runtime/store.ts index 98089cb..297bd05 100644 --- a/src/runtime/store.ts +++ b/src/runtime/store.ts @@ -15,7 +15,7 @@ export interface RunStore< TSnapshot extends AgentSnapshot = AgentSnapshot, TEvent extends JournalEvent = JournalEvent, > { - append(sessionId: string, events: TEvent[]): Promise; + append(sessionId: string, event: TEvent): Promise; loadEvents(sessionId: string): Promise; loadLatestSnapshot(sessionId: string): Promise | null>; saveSnapshot(snapshot: PersistedSnapshot): Promise; diff --git a/src/types.ts b/src/types.ts index 7d4038f..f274f16 100644 --- a/src/types.ts +++ b/src/types.ts @@ -119,11 +119,10 @@ export interface AgentSnapshot< value: TValue; context: TContext; status: AgentState['status']; - createdAt?: number; - sessionId?: string; + createdAt: number; + sessionId: string; output?: unknown; error?: unknown; - params?: Record>; } export type { PersistedSnapshot, RunStore } from './runtime/store.js'; From 50b382bb47fe23be9fe9366806c85db840317f67 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 9 Apr 2026 05:56:18 -0400 Subject: [PATCH 09/50] fix: emit durable stream snapshots --- src/machine.ts | 5 ++++- src/stream-snapshot.test.ts | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/stream-snapshot.test.ts diff --git a/src/machine.ts b/src/machine.ts index 9f6f7df..09e2482 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -371,7 +371,10 @@ export function createAgentMachine( value: s.value, context: s.context, status: s.status, - params: s.params, + sessionId: cfg.id, + createdAt: Date.now(), + output: s.output, + error: s.error, }; } diff --git a/src/stream-snapshot.test.ts b/src/stream-snapshot.test.ts new file mode 100644 index 0000000..233fb5f --- /dev/null +++ b/src/stream-snapshot.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'vitest'; +import { createAgentMachine } from './index.js'; + +test('stream emits durable snapshots with session metadata', async () => { + const machine = createAgentMachine({ + id: 'snapshot-machine', + context: () => ({}), + initial: 'done', + states: { + done: { + type: 'final', + output: () => ({ ok: true }), + }, + }, + }); + + const snaps = []; + for await (const snap of machine.stream(machine.getInitialState())) { + snaps.push(snap); + } + + expect(snaps.length).toBeGreaterThanOrEqual(2); + expect(snaps[0]).toEqual( + expect.objectContaining({ + sessionId: 'snapshot-machine', + createdAt: expect.any(Number), + value: 'done', + context: {}, + status: 'active', + }) + ); + expect(snaps[0]).not.toHaveProperty('params'); + expect(snaps[snaps.length - 1]).toEqual( + expect.objectContaining({ + sessionId: 'snapshot-machine', + createdAt: expect.any(Number), + value: 'done', + context: {}, + status: 'done', + output: { ok: true }, + }) + ); +}); From 18057e6d3e2ec60fc7f2fac916f6ccc17a510f2f Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 9 Apr 2026 06:00:25 -0400 Subject: [PATCH 10/50] fix: key run store by event session --- src/persistence.test.ts | 4 ++-- src/runtime/memory-store.ts | 6 +++--- src/runtime/store.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/persistence.test.ts b/src/persistence.test.ts index d4ba8ec..3bc7ecf 100644 --- a/src/persistence.test.ts +++ b/src/persistence.test.ts @@ -4,14 +4,14 @@ import { createMemoryRunStore } from './index.js'; test('appends and loads journal events in sequence order', async () => { const store = createMemoryRunStore(); - await store.append('session-1', { + await store.append({ sessionId: 'session-1', sequence: 2, type: 'xstate.done.invoke.worker', at: 20, }); - await store.append('session-1', { + await store.append({ sessionId: 'session-1', sequence: 1, type: 'xstate.init', diff --git a/src/runtime/memory-store.ts b/src/runtime/memory-store.ts index ee77053..70db402 100644 --- a/src/runtime/memory-store.ts +++ b/src/runtime/memory-store.ts @@ -21,10 +21,10 @@ export function createMemoryRunStore< const snapshots = new Map[]>(); return { - async append(sessionId, event) { - const current = journals.get(sessionId) ?? []; + async append(event) { + const current = journals.get(event.sessionId) ?? []; current.push(event); - journals.set(sessionId, current); + journals.set(event.sessionId, current); }, async loadEvents(sessionId) { diff --git a/src/runtime/store.ts b/src/runtime/store.ts index 297bd05..67acede 100644 --- a/src/runtime/store.ts +++ b/src/runtime/store.ts @@ -15,7 +15,7 @@ export interface RunStore< TSnapshot extends AgentSnapshot = AgentSnapshot, TEvent extends JournalEvent = JournalEvent, > { - append(sessionId: string, event: TEvent): Promise; + append(event: TEvent): Promise; loadEvents(sessionId: string): Promise; loadLatestSnapshot(sessionId: string): Promise | null>; saveSnapshot(snapshot: PersistedSnapshot): Promise; From 07c06bcfcca941174a38c6dbd71a94f2682aa8cd Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 9 Apr 2026 06:11:21 -0400 Subject: [PATCH 11/50] fix: stabilize stream snapshot runtime --- src/machine.ts | 69 +++++++++++++++++++++++++++++++++---- src/persistence.test.ts | 25 +++++++------- src/runtime/events.ts | 4 +-- src/runtime/memory-store.ts | 24 +++++++------ src/runtime/store.ts | 4 +-- src/session-types.test.ts | 2 -- src/stream-snapshot.test.ts | 23 ++++++++++--- 7 files changed, 112 insertions(+), 39 deletions(-) diff --git a/src/machine.ts b/src/machine.ts index 09e2482..74f3cd0 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -101,6 +101,31 @@ export function createAgentMachine< }): AgentMachine>; // ─── Overload B: no schemas.context ─── +export function createAgentMachine< + TInput, + TContext extends Record, + const TEvents extends Record, + const TParamsMap extends Record, + TResultMap extends Record, +>(config: { + id: string; + schemas: { + input: StandardSchemaV1; + context?: never; + events?: TEvents; + }; + context: (input: NoInfer) => TContext; + adapter?: import('./types.js').AgentAdapter; + initial: + | (keyof TParamsMap & keyof TResultMap & string) + | ((args: { context: TContext }) => { + target: keyof TParamsMap & keyof TResultMap & string; + params?: Record; + }); + states: StatesMap; +}): AgentMachine>; + +// ─── Overload C: no schemas.input or schemas.context ─── export function createAgentMachine< TContext extends Record, const TEvents extends Record, @@ -109,7 +134,7 @@ export function createAgentMachine< >(config: { id: string; schemas?: { - input?: StandardSchemaV1; + input?: never; context?: never; events?: TEvents; }; @@ -130,6 +155,33 @@ export function createAgentMachine( machineConfig: MachineConfig ): AgentMachine { const cfg = machineConfig as MachineConfig; + const snapshotRuntimeByState = new WeakMap(); + + function createSnapshotRuntime() { + const sessionId = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `session-${Math.random().toString(36).slice(2)}`; + + return { + sessionId, + createdAt: Date.now(), + }; + } + + function getSnapshotRuntime(state: AgentState) { + let runtime = snapshotRuntimeByState.get(state); + if (!runtime) { + runtime = createSnapshotRuntime(); + snapshotRuntimeByState.set(state, runtime); + } + return runtime; + } + + function bindSnapshotRuntime(state: AgentState, runtime: { sessionId: string; createdAt: number }) { + snapshotRuntimeByState.set(state, runtime); + return state; + } function getInitialState(...args: [input?: unknown]): AgentState { const input = args[0]; @@ -359,20 +411,25 @@ export function createAgentMachine( state: AgentState ): AsyncGenerator { let current = state; - yield toSnap(current); + const runtime = getSnapshotRuntime(current); + yield toSnap(current, runtime); while (current.status === 'active') { current = await invoke(current); - yield toSnap(current); + bindSnapshotRuntime(current, runtime); + yield toSnap(current, runtime); } } - function toSnap(s: AgentState): AgentSnapshot { + function toSnap( + s: AgentState, + runtime = getSnapshotRuntime(s) + ): AgentSnapshot { return { value: s.value, context: s.context, status: s.status, - sessionId: cfg.id, - createdAt: Date.now(), + sessionId: runtime.sessionId, + createdAt: runtime.createdAt, output: s.output, error: s.error, }; diff --git a/src/persistence.test.ts b/src/persistence.test.ts index 3bc7ecf..b9584a9 100644 --- a/src/persistence.test.ts +++ b/src/persistence.test.ts @@ -4,32 +4,31 @@ import { createMemoryRunStore } from './index.js'; test('appends and loads journal events in sequence order', async () => { const store = createMemoryRunStore(); - await store.append({ - sessionId: 'session-1', - sequence: 2, + await store.append('session-1', { type: 'xstate.done.invoke.worker', at: 20, }); - await store.append({ - sessionId: 'session-1', - sequence: 1, + await store.append('session-1', { type: 'xstate.init', at: 10, }); expect(await store.loadEvents('session-1')).toEqual([ { - sessionId: 'session-1', - sequence: 1, - type: 'xstate.init', + at: 20, + type: 'xstate.done.invoke.worker', + }, + { at: 10, + type: 'xstate.init', }, + ]); + + expect(await store.loadEvents('session-1', 1)).toEqual([ { - sessionId: 'session-1', - sequence: 2, - type: 'xstate.done.invoke.worker', - at: 20, + at: 10, + type: 'xstate.init', }, ]); }); diff --git a/src/runtime/events.ts b/src/runtime/events.ts index d0a864c..ee30061 100644 --- a/src/runtime/events.ts +++ b/src/runtime/events.ts @@ -1,7 +1,7 @@ export interface JournalEvent { - sessionId: string; - sequence: number; type: 'xstate.init' | (string & {}); at: number; + sessionId?: string; + sequence?: number; [key: string]: unknown; } diff --git a/src/runtime/memory-store.ts b/src/runtime/memory-store.ts index 70db402..0f1f60b 100644 --- a/src/runtime/memory-store.ts +++ b/src/runtime/memory-store.ts @@ -2,9 +2,10 @@ import type { AgentSnapshot } from '../types.js'; import type { JournalEvent } from './events.js'; import type { PersistedSnapshot, RunStore } from './store.js'; -function compareEvents(a: JournalEvent, b: JournalEvent): number { - return a.sequence - b.sequence || a.at - b.at; -} +type StoredJournalEvent = { + sequence: number; + event: TEvent; +}; function compareSnapshots( a: PersistedSnapshot, @@ -17,19 +18,22 @@ export function createMemoryRunStore< TSnapshot extends AgentSnapshot = AgentSnapshot, TEvent extends JournalEvent = JournalEvent, >(): RunStore { - const journals = new Map(); + const journals = new Map>>(); const snapshots = new Map[]>(); return { - async append(event) { - const current = journals.get(event.sessionId) ?? []; - current.push(event); - journals.set(event.sessionId, current); + async append(sessionId, event) { + const current = journals.get(sessionId) ?? []; + const sequence = current.length === 0 ? 1 : current[current.length - 1]!.sequence + 1; + current.push({ sequence, event }); + journals.set(sessionId, current); }, - async loadEvents(sessionId) { + async loadEvents(sessionId, afterSequence = 0) { const events = journals.get(sessionId) ?? []; - return [...events].sort(compareEvents) as TEvent[]; + return events + .filter((entry) => entry.sequence > afterSequence) + .map((entry) => entry.event); }, async loadLatestSnapshot(sessionId) { diff --git a/src/runtime/store.ts b/src/runtime/store.ts index 67acede..ddd841e 100644 --- a/src/runtime/store.ts +++ b/src/runtime/store.ts @@ -15,8 +15,8 @@ export interface RunStore< TSnapshot extends AgentSnapshot = AgentSnapshot, TEvent extends JournalEvent = JournalEvent, > { - append(event: TEvent): Promise; - loadEvents(sessionId: string): Promise; + append(sessionId: string, event: TEvent): Promise; + loadEvents(sessionId: string, afterSequence?: number): Promise; loadLatestSnapshot(sessionId: string): Promise | null>; saveSnapshot(snapshot: PersistedSnapshot): Promise; } diff --git a/src/session-types.test.ts b/src/session-types.test.ts index 35b4e19..821721d 100644 --- a/src/session-types.test.ts +++ b/src/session-types.test.ts @@ -16,8 +16,6 @@ test('AgentSnapshot includes durable session fields', () => { test('JournalEvent supports invoke completion events', () => { const event: JournalEvent = { - sessionId: 'session-1', - sequence: 2, type: 'xstate.done.invoke.worker', at: 456, }; diff --git a/src/stream-snapshot.test.ts b/src/stream-snapshot.test.ts index 233fb5f..34fbd66 100644 --- a/src/stream-snapshot.test.ts +++ b/src/stream-snapshot.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { createAgentMachine } from './index.js'; -test('stream emits durable snapshots with session metadata', async () => { +async function collectSnapshots() { const machine = createAgentMachine({ id: 'snapshot-machine', context: () => ({}), @@ -19,10 +19,18 @@ test('stream emits durable snapshots with session metadata', async () => { snaps.push(snap); } + return snaps; +} + +test('stream emits durable snapshots with stable session metadata', async () => { + const snaps = await collectSnapshots(); + expect(snaps.length).toBeGreaterThanOrEqual(2); + expect(new Set(snaps.map((snap) => snap.sessionId)).size).toBe(1); + expect(new Set(snaps.map((snap) => snap.createdAt)).size).toBe(1); expect(snaps[0]).toEqual( expect.objectContaining({ - sessionId: 'snapshot-machine', + sessionId: expect.any(String), createdAt: expect.any(Number), value: 'done', context: {}, @@ -32,8 +40,8 @@ test('stream emits durable snapshots with session metadata', async () => { expect(snaps[0]).not.toHaveProperty('params'); expect(snaps[snaps.length - 1]).toEqual( expect.objectContaining({ - sessionId: 'snapshot-machine', - createdAt: expect.any(Number), + sessionId: snaps[0]!.sessionId, + createdAt: snaps[0]!.createdAt, value: 'done', context: {}, status: 'done', @@ -41,3 +49,10 @@ test('stream emits durable snapshots with session metadata', async () => { }) ); }); + +test('separate machine executions get distinct session ids', async () => { + const firstRun = await collectSnapshots(); + const secondRun = await collectSnapshots(); + + expect(firstRun[0]!.sessionId).not.toBe(secondRun[0]!.sessionId); +}); From d659e947c8429325322e02e14a16c94f6d46d8af Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 9 Apr 2026 06:29:09 -0400 Subject: [PATCH 12/50] fix: harden stream and replay metadata --- src/index.ts | 1 + src/machine.ts | 28 +++++++--------------------- src/persistence.test.ts | 16 ++++++++++++++-- src/runtime/memory-store.ts | 19 ++++++++----------- src/runtime/store.ts | 10 +++++++++- src/stream-snapshot.test.ts | 30 ++++++++++++++++-------------- src/types.ts | 3 +-- 7 files changed, 56 insertions(+), 51 deletions(-) diff --git a/src/index.ts b/src/index.ts index a422e2f..6ad8a53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export type { ExecuteResult, InferOutput, JournalEvent, + JournalEventRecord, MachineConfig, PersistedSnapshot, RunStore, diff --git a/src/machine.ts b/src/machine.ts index 74f3cd0..b80479a 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -155,34 +155,21 @@ export function createAgentMachine( machineConfig: MachineConfig ): AgentMachine { const cfg = machineConfig as MachineConfig; - const snapshotRuntimeByState = new WeakMap(); + let snapshotRunIndex = 0; function createSnapshotRuntime() { const sessionId = - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() + typeof globalThis.crypto !== 'undefined' && + typeof globalThis.crypto.randomUUID === 'function' + ? globalThis.crypto.randomUUID() : `session-${Math.random().toString(36).slice(2)}`; return { sessionId, - createdAt: Date.now(), + createdAt: Date.now() + snapshotRunIndex++, }; } - function getSnapshotRuntime(state: AgentState) { - let runtime = snapshotRuntimeByState.get(state); - if (!runtime) { - runtime = createSnapshotRuntime(); - snapshotRuntimeByState.set(state, runtime); - } - return runtime; - } - - function bindSnapshotRuntime(state: AgentState, runtime: { sessionId: string; createdAt: number }) { - snapshotRuntimeByState.set(state, runtime); - return state; - } - function getInitialState(...args: [input?: unknown]): AgentState { const input = args[0]; @@ -411,18 +398,17 @@ export function createAgentMachine( state: AgentState ): AsyncGenerator { let current = state; - const runtime = getSnapshotRuntime(current); + const runtime = createSnapshotRuntime(); yield toSnap(current, runtime); while (current.status === 'active') { current = await invoke(current); - bindSnapshotRuntime(current, runtime); yield toSnap(current, runtime); } } function toSnap( s: AgentState, - runtime = getSnapshotRuntime(s) + runtime: { sessionId: string; createdAt: number } ): AgentSnapshot { return { value: s.value, diff --git a/src/persistence.test.ts b/src/persistence.test.ts index b9584a9..887dc9a 100644 --- a/src/persistence.test.ts +++ b/src/persistence.test.ts @@ -16,17 +16,20 @@ test('appends and loads journal events in sequence order', async () => { expect(await store.loadEvents('session-1')).toEqual([ { - at: 20, + sequence: 1, type: 'xstate.done.invoke.worker', + at: 20, }, { - at: 10, + sequence: 2, type: 'xstate.init', + at: 10, }, ]); expect(await store.loadEvents('session-1', 1)).toEqual([ { + sequence: 2, at: 10, type: 'xstate.init', }, @@ -46,6 +49,9 @@ test('loads the latest saved snapshot', async () => { createdAt: 100, sessionId: 'session-1', }, + params: { + idle: { count: 1 }, + }, lastJournalIndex: 1, createdAt: 100, }); @@ -61,6 +67,9 @@ test('loads the latest saved snapshot', async () => { sessionId: 'session-1', output: { count: 2 }, }, + params: { + done: { count: 2 }, + }, lastJournalIndex: 3, createdAt: 300, }); @@ -76,6 +85,9 @@ test('loads the latest saved snapshot', async () => { sessionId: 'session-1', output: { count: 2 }, }, + params: { + done: { count: 2 }, + }, lastJournalIndex: 3, createdAt: 300, }); diff --git a/src/runtime/memory-store.ts b/src/runtime/memory-store.ts index 0f1f60b..bd65f35 100644 --- a/src/runtime/memory-store.ts +++ b/src/runtime/memory-store.ts @@ -1,11 +1,10 @@ import type { AgentSnapshot } from '../types.js'; import type { JournalEvent } from './events.js'; -import type { PersistedSnapshot, RunStore } from './store.js'; - -type StoredJournalEvent = { - sequence: number; - event: TEvent; -}; +import type { + JournalEventRecord, + PersistedSnapshot, + RunStore, +} from './store.js'; function compareSnapshots( a: PersistedSnapshot, @@ -18,22 +17,20 @@ export function createMemoryRunStore< TSnapshot extends AgentSnapshot = AgentSnapshot, TEvent extends JournalEvent = JournalEvent, >(): RunStore { - const journals = new Map>>(); + const journals = new Map>>(); const snapshots = new Map[]>(); return { async append(sessionId, event) { const current = journals.get(sessionId) ?? []; const sequence = current.length === 0 ? 1 : current[current.length - 1]!.sequence + 1; - current.push({ sequence, event }); + current.push({ ...event, sequence }); journals.set(sessionId, current); }, async loadEvents(sessionId, afterSequence = 0) { const events = journals.get(sessionId) ?? []; - return events - .filter((entry) => entry.sequence > afterSequence) - .map((entry) => entry.event); + return events.filter((entry) => entry.sequence > afterSequence); }, async loadLatestSnapshot(sessionId) { diff --git a/src/runtime/store.ts b/src/runtime/store.ts index ddd841e..4aa8adf 100644 --- a/src/runtime/store.ts +++ b/src/runtime/store.ts @@ -1,12 +1,17 @@ import type { AgentSnapshot } from '../types.js'; import type { JournalEvent } from './events.js'; +export type JournalEventRecord< + TEvent extends JournalEvent = JournalEvent, +> = TEvent & { sequence: number }; + export interface PersistedSnapshot< TSnapshot extends AgentSnapshot = AgentSnapshot, > { sessionId: string; sequence: number; snapshot: TSnapshot; + params: Record>; lastJournalIndex: number; createdAt: number; } @@ -16,7 +21,10 @@ export interface RunStore< TEvent extends JournalEvent = JournalEvent, > { append(sessionId: string, event: TEvent): Promise; - loadEvents(sessionId: string, afterSequence?: number): Promise; + loadEvents( + sessionId: string, + afterSequence?: number + ): Promise[]>; loadLatestSnapshot(sessionId: string): Promise | null>; saveSnapshot(snapshot: PersistedSnapshot): Promise; } diff --git a/src/stream-snapshot.test.ts b/src/stream-snapshot.test.ts index 34fbd66..3a8d7a6 100644 --- a/src/stream-snapshot.test.ts +++ b/src/stream-snapshot.test.ts @@ -1,21 +1,21 @@ import { expect, test } from 'vitest'; import { createAgentMachine } from './index.js'; -async function collectSnapshots() { - const machine = createAgentMachine({ - id: 'snapshot-machine', - context: () => ({}), - initial: 'done', - states: { - done: { - type: 'final', - output: () => ({ ok: true }), - }, +const machine = createAgentMachine({ + id: 'snapshot-machine', + context: () => ({}), + initial: 'done', + states: { + done: { + type: 'final', + output: () => ({ ok: true }), }, - }); + }, +}); +async function collectSnapshots(state = machine.getInitialState()) { const snaps = []; - for await (const snap of machine.stream(machine.getInitialState())) { + for await (const snap of machine.stream(state)) { snaps.push(snap); } @@ -51,8 +51,10 @@ test('stream emits durable snapshots with stable session metadata', async () => }); test('separate machine executions get distinct session ids', async () => { - const firstRun = await collectSnapshots(); - const secondRun = await collectSnapshots(); + const state = machine.getInitialState(); + const firstRun = await collectSnapshots(state); + const secondRun = await collectSnapshots(state); expect(firstRun[0]!.sessionId).not.toBe(secondRun[0]!.sessionId); + expect(firstRun[0]!.createdAt).not.toBe(secondRun[0]!.createdAt); }); diff --git a/src/types.ts b/src/types.ts index f274f16..b39f942 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,7 @@ export type TransitionEvent< // ─── Durable Session Vocabulary ─── export type { JournalEvent } from './runtime/events.js'; +export type { JournalEventRecord, PersistedSnapshot, RunStore } from './runtime/store.js'; // ─── Adapter ─── @@ -125,8 +126,6 @@ export interface AgentSnapshot< error?: unknown; } -export type { PersistedSnapshot, RunStore } from './runtime/store.js'; - // ─── Agent Machine ─── export interface AgentMachine< From 6c4bccb0758187212accadeffc193f848e37df10 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 9 Apr 2026 07:00:45 -0400 Subject: [PATCH 13/50] fix: unify durable snapshot restore shape --- src/machine.ts | 19 +++++++++++++++---- src/persistence.test.ts | 24 ++++++++++++------------ src/runtime/store.ts | 3 +-- src/session-types.test.ts | 1 + src/stream-snapshot.test.ts | 25 +++++++++++++++++++++---- src/types.ts | 5 +++++ 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/src/machine.ts b/src/machine.ts index b80479a..52bdc2e 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -155,9 +155,15 @@ export function createAgentMachine( machineConfig: MachineConfig ): AgentMachine { const cfg = machineConfig as MachineConfig; - let snapshotRunIndex = 0; - function createSnapshotRuntime() { + function createSnapshotRuntime(state: AgentState) { + if (state.sessionId && state.createdAt !== undefined) { + return { + sessionId: state.sessionId, + createdAt: state.createdAt, + }; + } + const sessionId = typeof globalThis.crypto !== 'undefined' && typeof globalThis.crypto.randomUUID === 'function' @@ -166,7 +172,7 @@ export function createAgentMachine( return { sessionId, - createdAt: Date.now() + snapshotRunIndex++, + createdAt: Date.now(), }; } @@ -197,6 +203,8 @@ export function createAgentMachine( value: string; context: Record; params?: Record>; + sessionId?: string; + createdAt?: number; status?: AgentState['status']; output?: unknown; error?: unknown; @@ -206,6 +214,8 @@ export function createAgentMachine( context: raw.context, status: raw.status ?? 'active', params: raw.params ?? {}, + sessionId: raw.sessionId, + createdAt: raw.createdAt, output: raw.output, error: raw.error, }; @@ -398,7 +408,7 @@ export function createAgentMachine( state: AgentState ): AsyncGenerator { let current = state; - const runtime = createSnapshotRuntime(); + const runtime = createSnapshotRuntime(current); yield toSnap(current, runtime); while (current.status === 'active') { current = await invoke(current); @@ -416,6 +426,7 @@ export function createAgentMachine( status: s.status, sessionId: runtime.sessionId, createdAt: runtime.createdAt, + params: s.params, output: s.output, error: s.error, }; diff --git a/src/persistence.test.ts b/src/persistence.test.ts index 887dc9a..bd89816 100644 --- a/src/persistence.test.ts +++ b/src/persistence.test.ts @@ -42,53 +42,53 @@ test('loads the latest saved snapshot', async () => { await store.saveSnapshot({ sessionId: 'session-1', sequence: 1, + afterSequence: 1, snapshot: { value: 'idle', context: { count: 1 }, status: 'active', createdAt: 100, sessionId: 'session-1', + params: { + idle: { count: 1 }, + }, }, - params: { - idle: { count: 1 }, - }, - lastJournalIndex: 1, createdAt: 100, }); await store.saveSnapshot({ sessionId: 'session-1', sequence: 3, + afterSequence: 3, snapshot: { value: 'done', context: { count: 2 }, status: 'done', createdAt: 300, sessionId: 'session-1', + params: { + done: { count: 2 }, + }, output: { count: 2 }, }, - params: { - done: { count: 2 }, - }, - lastJournalIndex: 3, createdAt: 300, }); expect(await store.loadLatestSnapshot('session-1')).toEqual({ sessionId: 'session-1', sequence: 3, + afterSequence: 3, snapshot: { value: 'done', context: { count: 2 }, status: 'done', createdAt: 300, sessionId: 'session-1', + params: { + done: { count: 2 }, + }, output: { count: 2 }, }, - params: { - done: { count: 2 }, - }, - lastJournalIndex: 3, createdAt: 300, }); }); diff --git a/src/runtime/store.ts b/src/runtime/store.ts index 4aa8adf..07c7a7f 100644 --- a/src/runtime/store.ts +++ b/src/runtime/store.ts @@ -11,8 +11,7 @@ export interface PersistedSnapshot< sessionId: string; sequence: number; snapshot: TSnapshot; - params: Record>; - lastJournalIndex: number; + afterSequence: number; createdAt: number; } diff --git a/src/session-types.test.ts b/src/session-types.test.ts index 821721d..c1cf2ac 100644 --- a/src/session-types.test.ts +++ b/src/session-types.test.ts @@ -8,6 +8,7 @@ test('AgentSnapshot includes durable session fields', () => { status: 'active', createdAt: 123, sessionId: 'session-1', + params: {}, }; expect(snapshot.sessionId).toBe('session-1'); diff --git a/src/stream-snapshot.test.ts b/src/stream-snapshot.test.ts index 3a8d7a6..91157f8 100644 --- a/src/stream-snapshot.test.ts +++ b/src/stream-snapshot.test.ts @@ -4,7 +4,10 @@ import { createAgentMachine } from './index.js'; const machine = createAgentMachine({ id: 'snapshot-machine', context: () => ({}), - initial: 'done', + initial: () => ({ + target: 'done', + params: { step: 1 }, + }), states: { done: { type: 'final', @@ -28,6 +31,7 @@ test('stream emits durable snapshots with stable session metadata', async () => expect(snaps.length).toBeGreaterThanOrEqual(2); expect(new Set(snaps.map((snap) => snap.sessionId)).size).toBe(1); expect(new Set(snaps.map((snap) => snap.createdAt)).size).toBe(1); + expect(snaps[0]!.params).toEqual({ done: { step: 1 } }); expect(snaps[0]).toEqual( expect.objectContaining({ sessionId: expect.any(String), @@ -35,9 +39,9 @@ test('stream emits durable snapshots with stable session metadata', async () => value: 'done', context: {}, status: 'active', + params: { done: { step: 1 } }, }) ); - expect(snaps[0]).not.toHaveProperty('params'); expect(snaps[snaps.length - 1]).toEqual( expect.objectContaining({ sessionId: snaps[0]!.sessionId, @@ -45,16 +49,29 @@ test('stream emits durable snapshots with stable session metadata', async () => value: 'done', context: {}, status: 'done', + params: { done: { step: 1 } }, output: { ok: true }, }) ); }); -test('separate machine executions get distinct session ids', async () => { +test('snapshot roundtrips through resolveState without losing identity', async () => { + const emitted = await collectSnapshots(); + const restored = machine.resolveState(emitted[0]!); + const rerun = await collectSnapshots(restored); + + expect(restored.sessionId).toBe(emitted[0]!.sessionId); + expect(restored.createdAt).toBe(emitted[0]!.createdAt); + expect(restored.params).toEqual(emitted[0]!.params); + expect(rerun[0]!.sessionId).toBe(emitted[0]!.sessionId); + expect(rerun[0]!.createdAt).toBe(emitted[0]!.createdAt); + expect(rerun[0]!.params).toEqual(emitted[0]!.params); +}); + +test('fresh machine executions on the same raw state get distinct session ids', async () => { const state = machine.getInitialState(); const firstRun = await collectSnapshots(state); const secondRun = await collectSnapshots(state); expect(firstRun[0]!.sessionId).not.toBe(secondRun[0]!.sessionId); - expect(firstRun[0]!.createdAt).not.toBe(secondRun[0]!.createdAt); }); diff --git a/src/types.ts b/src/types.ts index b39f942..2528c13 100644 --- a/src/types.ts +++ b/src/types.ts @@ -96,6 +96,8 @@ export interface AgentState< context: TContext; status: 'active' | 'pending' | 'done' | 'error'; params: Record>; + sessionId?: string; + createdAt?: number; output?: unknown; error?: unknown; } @@ -122,6 +124,7 @@ export interface AgentSnapshot< status: AgentState['status']; createdAt: number; sessionId: string; + params: Record>; output?: unknown; error?: unknown; } @@ -144,6 +147,8 @@ export interface AgentMachine< value: string; context: TContext; params?: Record>; + sessionId?: string; + createdAt?: number; status?: AgentState['status']; output?: unknown; error?: unknown; From 4f305d192f2bab2c3f523ebd801eba45bf644d77 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 9 Apr 2026 07:06:12 -0400 Subject: [PATCH 14/50] fix: simplify replay cursor contract --- src/persistence.test.ts | 58 +++++++++++++++++++++++++++++++++---- src/runtime/memory-store.ts | 7 +++-- src/runtime/store.ts | 3 +- src/types.ts | 24 ++++++++------- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/src/persistence.test.ts b/src/persistence.test.ts index bd89816..406ef40 100644 --- a/src/persistence.test.ts +++ b/src/persistence.test.ts @@ -4,16 +4,19 @@ import { createMemoryRunStore } from './index.js'; test('appends and loads journal events in sequence order', async () => { const store = createMemoryRunStore(); - await store.append('session-1', { + const first = await store.append('session-1', { type: 'xstate.done.invoke.worker', at: 20, }); - await store.append('session-1', { + const second = await store.append('session-1', { type: 'xstate.init', at: 10, }); + expect(first.sequence).toBe(1); + expect(second.sequence).toBe(2); + expect(await store.loadEvents('session-1')).toEqual([ { sequence: 1, @@ -36,12 +39,11 @@ test('appends and loads journal events in sequence order', async () => { ]); }); -test('loads the latest saved snapshot', async () => { +test('loads the most replay-advanced saved snapshot', async () => { const store = createMemoryRunStore(); await store.saveSnapshot({ sessionId: 'session-1', - sequence: 1, afterSequence: 1, snapshot: { value: 'idle', @@ -58,7 +60,6 @@ test('loads the latest saved snapshot', async () => { await store.saveSnapshot({ sessionId: 'session-1', - sequence: 3, afterSequence: 3, snapshot: { value: 'done', @@ -76,7 +77,6 @@ test('loads the latest saved snapshot', async () => { expect(await store.loadLatestSnapshot('session-1')).toEqual({ sessionId: 'session-1', - sequence: 3, afterSequence: 3, snapshot: { value: 'done', @@ -92,3 +92,49 @@ test('loads the latest saved snapshot', async () => { createdAt: 300, }); }); + +test('loads the most replay-advanced snapshot even if saved earlier', async () => { + const store = createMemoryRunStore(); + + await store.saveSnapshot({ + sessionId: 'session-1', + afterSequence: 5, + snapshot: { + value: 'done', + context: { count: 5 }, + status: 'done', + createdAt: 500, + sessionId: 'session-1', + params: { done: { count: 5 } }, + }, + createdAt: 500, + }); + + await store.saveSnapshot({ + sessionId: 'session-1', + afterSequence: 2, + snapshot: { + value: 'review', + context: { count: 2 }, + status: 'active', + createdAt: 200, + sessionId: 'session-1', + params: { review: { count: 2 } }, + }, + createdAt: 200, + }); + + expect(await store.loadLatestSnapshot('session-1')).toEqual({ + sessionId: 'session-1', + afterSequence: 5, + snapshot: { + value: 'done', + context: { count: 5 }, + status: 'done', + createdAt: 500, + sessionId: 'session-1', + params: { done: { count: 5 } }, + }, + createdAt: 500, + }); +}); diff --git a/src/runtime/memory-store.ts b/src/runtime/memory-store.ts index bd65f35..f2a6961 100644 --- a/src/runtime/memory-store.ts +++ b/src/runtime/memory-store.ts @@ -10,7 +10,7 @@ function compareSnapshots( a: PersistedSnapshot, b: PersistedSnapshot ): number { - return a.sequence - b.sequence || a.createdAt - b.createdAt; + return a.afterSequence - b.afterSequence || a.createdAt - b.createdAt; } export function createMemoryRunStore< @@ -26,11 +26,14 @@ export function createMemoryRunStore< const sequence = current.length === 0 ? 1 : current[current.length - 1]!.sequence + 1; current.push({ ...event, sequence }); journals.set(sessionId, current); + return { sequence }; }, async loadEvents(sessionId, afterSequence = 0) { const events = journals.get(sessionId) ?? []; - return events.filter((entry) => entry.sequence > afterSequence); + return [...events] + .filter((entry) => entry.sequence > afterSequence) + .sort((a, b) => a.sequence - b.sequence); }, async loadLatestSnapshot(sessionId) { diff --git a/src/runtime/store.ts b/src/runtime/store.ts index 07c7a7f..4446165 100644 --- a/src/runtime/store.ts +++ b/src/runtime/store.ts @@ -9,7 +9,6 @@ export interface PersistedSnapshot< TSnapshot extends AgentSnapshot = AgentSnapshot, > { sessionId: string; - sequence: number; snapshot: TSnapshot; afterSequence: number; createdAt: number; @@ -19,7 +18,7 @@ export interface RunStore< TSnapshot extends AgentSnapshot = AgentSnapshot, TEvent extends JournalEvent = JournalEvent, > { - append(sessionId: string, event: TEvent): Promise; + append(sessionId: string, event: TEvent): Promise<{ sequence: number }>; loadEvents( sessionId: string, afterSequence?: number diff --git a/src/types.ts b/src/types.ts index 2528c13..4082e00 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,16 +143,20 @@ export interface AgentMachine< ...args: unknown extends TInput ? [input?: TInput] : [input: TInput] ): AgentState; - resolveState(raw: { - value: string; - context: TContext; - params?: Record>; - sessionId?: string; - createdAt?: number; - status?: AgentState['status']; - output?: unknown; - error?: unknown; - }): AgentState; + resolveState( + raw: + | AgentSnapshot + | { + value: string; + context: TContext; + params?: Record>; + sessionId?: string; + createdAt?: number; + status?: AgentState['status']; + output?: unknown; + error?: unknown; + } + ): AgentState; transition( state: AgentState, From 4b9ab8df556f0e6a30b292b67f9ee03048c4cd83 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 9 Apr 2026 07:21:06 -0400 Subject: [PATCH 15/50] feat: add durable session runtime --- src/index.ts | 7 + src/invoke-events.test.ts | 86 ++++++++++ src/machine.ts | 276 +++++++++++++++++++++++++------- src/restore.test.ts | 74 +++++++++ src/runtime/emitter.ts | 45 ++++++ src/runtime/session.ts | 305 ++++++++++++++++++++++++++++++++++++ src/session-runtime.test.ts | 60 +++++++ src/streaming.test.ts | 103 ++++++++++++ src/types.ts | 40 ++++- src/utils.ts | 52 +++++- 10 files changed, 989 insertions(+), 59 deletions(-) create mode 100644 src/invoke-events.test.ts create mode 100644 src/restore.test.ts create mode 100644 src/runtime/emitter.ts create mode 100644 src/runtime/session.ts create mode 100644 src/session-runtime.test.ts create mode 100644 src/streaming.test.ts diff --git a/src/index.ts b/src/index.ts index 6ad8a53..f59582c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,25 +8,32 @@ export { classify } from './classify.js'; // Adapter export { createAdapter } from './adapter.js'; export { createMemoryRunStore } from './runtime/memory-store.js'; +export { restoreSession, startSession } from './runtime/session.js'; // Types export type { AgentAdapter, AgentMachine, + AgentRun, AgentSnapshot, AgentState, ClassifyConfig, DecideConfig, DecideResultFor, + EmittedPart, + EmittedUnion, EventPayload, EventUnion, ExecuteResult, InferOutput, + InvokeEnqueue, JournalEvent, JournalEventRecord, MachineConfig, PersistedSnapshot, + RestoreSessionOptions, RunStore, + SessionOptions, StandardSchemaV1, StateConfig, Trace, diff --git a/src/invoke-events.test.ts b/src/invoke-events.test.ts new file mode 100644 index 0000000..e70061a --- /dev/null +++ b/src/invoke-events.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from './index.js'; + +test('invoke success is journaled as an internal machine event', async () => { + const machine = createAgentMachine({ + id: 'invoke-success', + context: () => ({ result: null as string | null }), + initial: 'processing', + states: { + processing: { + resultSchema: z.object({ value: z.string() }), + invoke: async () => ({ value: 'ok' }), + onDone: ({ result }) => ({ + target: 'done', + context: { result: result.value }, + }), + }, + done: { + type: 'final', + output: ({ context }) => context, + }, + }, + }); + + const store = createMemoryRunStore(); + const run = await startSession(machine, { store }); + const journal = await store.loadEvents(run.sessionId); + + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + context: { result: 'ok' }, + output: { result: 'ok' }, + }) + ); + expect(journal).toEqual([ + expect.objectContaining({ sequence: 1, type: 'xstate.init' }), + expect.objectContaining({ + sequence: 2, + type: 'xstate.done.invoke.processing', + output: { value: 'ok' }, + }), + ]); +}); + +test('invoke failure is journaled as an internal machine event', async () => { + const machine = createAgentMachine({ + id: 'invoke-failure', + context: () => ({ count: 0 }), + initial: 'processing', + states: { + processing: { + invoke: async () => { + throw new Error('boom'); + }, + }, + }, + }); + + const store = createMemoryRunStore(); + const run = await startSession(machine, { store }); + const journal = await store.loadEvents(run.sessionId); + + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'processing', + status: 'error', + context: { count: 0 }, + error: expect.objectContaining({ message: 'boom' }), + }) + ); + expect(journal).toEqual([ + expect.objectContaining({ sequence: 1, type: 'xstate.init' }), + expect.objectContaining({ + sequence: 2, + type: 'xstate.error.invoke.processing', + error: expect.objectContaining({ message: 'boom' }), + }), + ]); +}); diff --git a/src/machine.ts b/src/machine.ts index 52bdc2e..1c521f0 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -2,6 +2,7 @@ import type { AgentMachine, AgentSnapshot, AgentState, + EmittedPart, EventPayload, ExecuteResult, InferOutput, @@ -9,13 +10,19 @@ import type { StandardSchemaV1, TransitionResult, } from './types.js'; +import type { JournalEvent } from './runtime/events.js'; import { applyTransition, + findEmittedSchema, findEventSchema, + formatSchemaIssues, getAvailableEvents, getParams, + isDoneInvokeEventType, + isErrorInvokeEventType, resolveInitial, resolveStateConfig, + serializeError, validateSchemaSync, } from './utils.js'; import type { StateConfigAny } from './utils.js'; @@ -47,7 +54,7 @@ type StateNodeDef< context: TContext; params: NoInfer; signal?: AbortSignal; - }) => Promise>; + }, enq: { emit(part: EmittedPart): void }) => Promise>; onDone?: (args: { result: OnDoneResult; context: TContext }) => TransitionResult; on?: { [E in keyof TEvents & string]?: TransitionResult | ((args: { event: EventFor; @@ -88,6 +95,7 @@ export function createAgentMachine< context: StandardSchemaV1; input?: StandardSchemaV1; events?: TEvents; + emitted?: Record; }; context: (input: NoInfer) => NoInfer; adapter?: import('./types.js').AgentAdapter; @@ -113,6 +121,7 @@ export function createAgentMachine< input: StandardSchemaV1; context?: never; events?: TEvents; + emitted?: Record; }; context: (input: NoInfer) => TContext; adapter?: import('./types.js').AgentAdapter; @@ -137,6 +146,7 @@ export function createAgentMachine< input?: never; context?: never; events?: TEvents; + emitted?: Record; }; context: (...args: any[]) => TContext; adapter?: import('./types.js').AgentAdapter; @@ -156,6 +166,8 @@ export function createAgentMachine( ): AgentMachine { const cfg = machineConfig as MachineConfig; + type SnapshotRuntime = { sessionId: string; createdAt: number }; + function createSnapshotRuntime(state: AgentState) { if (state.sessionId && state.createdAt !== undefined) { return { @@ -176,6 +188,17 @@ export function createAgentMachine( }; } + function withRuntimeMetadata( + state: AgentState, + runtime: SnapshotRuntime + ): AgentState { + return { + ...state, + sessionId: runtime.sessionId, + createdAt: runtime.createdAt, + }; + } + function getInitialState(...args: [input?: unknown]): AgentState { const input = args[0]; @@ -225,9 +248,87 @@ export function createAgentMachine( state: AgentState, event: { type: string; [k: string]: unknown } ): AgentState { + const sc = resolveStateConfig(cfg, state.value); + const effectiveConfig = sc.__decideConfig + ? { ...sc, ...(sc.__decideConfig as Record) } + : sc; + if (isDoneInvokeEventType(state.value, event.type)) { + const result = 'output' in event ? event.output : undefined; + const validatedResult = effectiveConfig.resultSchema + ? validateSchemaSync(effectiveConfig.resultSchema, result) + : result; + + if (effectiveConfig.onDone) { + const trans = effectiveConfig.onDone({ + result: validatedResult, + context: state.context, + }); + + if (trans.target) { + return applyTransition(state, trans); + } + + return { + ...state, + status: 'pending', + context: trans.context + ? { ...state.context, ...trans.context } + : state.context, + }; + } + + const internalHandler = sc.on?.[event.type]; + if (internalHandler !== undefined) { + const result: TransitionResult = + typeof internalHandler === 'function' + ? internalHandler({ context: state.context, event }) + : internalHandler; + + if (result.target) { + return applyTransition(state, result); + } + + return { + ...state, + status: 'pending', + context: result.context + ? { ...state.context, ...result.context } + : state.context, + }; + } + + return { ...state, status: 'pending' }; + } + + if (isErrorInvokeEventType(state.value, event.type)) { + const internalHandler = sc.on?.[event.type]; + if (internalHandler !== undefined) { + const result: TransitionResult = + typeof internalHandler === 'function' + ? internalHandler({ context: state.context, event }) + : internalHandler; + + if (result.target) { + return applyTransition(state, result); + } + + return { + ...state, + context: result.context + ? { ...state.context, ...result.context } + : state.context, + }; + } + + return { + ...state, + status: 'error', + error: 'error' in event ? event.error : undefined, + }; + } + validateEventPayload(state.value, event); - const sc = resolveStateConfig(cfg, state.value); if (sc.on?.[event.type] !== undefined) { const handler = sc.on[event.type]!; const result: TransitionResult = @@ -266,60 +367,52 @@ export function createAgentMachine( 'issues' in result && result.issues ) { - const messages = (result.issues as Array<{ message: string }>) - .map((i) => i.message) - .join(', '); + const messages = formatSchemaIssues( + result.issues as Array<{ message: string }> + ); throw new Error(`Invalid event '${event.type}': ${messages}`); } } - async function invoke(state: AgentState): Promise { - if (state.status === 'done' || state.status === 'error') { - return state; - } - - const sc = resolveStateConfig(cfg, state.value); - - if (sc.type === 'final') { - const output = sc.output - ? sc.output({ context: state.context }) - : undefined; - return { ...state, status: 'done', output }; + function validateEmittedPart(part: EmittedPart): void { + const schema = findEmittedSchema(cfg, part.type); + if (!schema) { + return; } - if (sc.type === 'choice' || sc.__decideConfig) { - return handleChoice(state, sc); + const result = schema['~standard'].validate(part); + if (result instanceof Promise) { + throw new Error( + 'Async schema validation is not supported in sync context.' + ); } - if (sc.invoke) { - return handleInvoke(state, sc); - } - - if (sc.on) { - return { ...state, status: 'pending' }; + if (result.issues) { + const messages = formatSchemaIssues(result.issues); + throw new Error(`Invalid emitted part '${part.type}': ${messages}`); } + } + function createEnqueue(onEmit?: (part: EmittedPart) => void) { return { - ...state, - status: 'error', - error: `State '${state.value}' has no invoke, events, or final type`, + emit(part: EmittedPart) { + validateEmittedPart(part); + onEmit?.(part); + }, }; } - async function handleChoice( - state: AgentState, - sc: StateConfigAny - ): Promise { - // Merge __decideConfig props onto sc for decide() wrapper compat + async function createChoiceEvent(state: AgentState): Promise { + const sc = resolveStateConfig(cfg, state.value); const dc = sc.__decideConfig ? { ...sc, ...(sc.__decideConfig as Record) } : sc; const adapter = (dc as StateConfigAny).adapter ?? cfg.adapter; if (!adapter) { return { - ...state, - status: 'error', - error: `No adapter for '${state.value}'`, + type: `xstate.error.invoke.${state.value}`, + error: { message: `No adapter for '${state.value}'` }, + at: Date.now(), }; } @@ -336,37 +429,99 @@ export function createAgentMachine( options: (dc as StateConfigAny).options!, reasoning: (dc as StateConfigAny).reasoning, }); - const onDone = (dc as StateConfigAny).onDone; - if (!onDone) return { ...state, status: 'error', error: 'choice state missing onDone' }; - const trans = onDone({ result, context: state.context }); - return applyTransition(state, trans); + + return { + type: `xstate.done.invoke.${state.value}`, + output: result, + at: Date.now(), + }; } catch (error) { - return { ...state, status: 'error', error }; + return { + type: `xstate.error.invoke.${state.value}`, + error: serializeError(error), + at: Date.now(), + }; } } - async function handleInvoke( + async function createInvokeEvent( state: AgentState, - sc: StateConfigAny - ): Promise { + sc: StateConfigAny, + onEmit?: (part: EmittedPart) => void + ): Promise { try { - const result = await sc.invoke!({ - context: state.context, - params: getParams(state.value, state.params), - }); - if (sc.onDone) { - const trans = sc.onDone({ result, context: state.context }); - return applyTransition(state, trans); - } - if (sc.on) { - return { ...state, status: 'pending' }; - } - return state; + const result = await sc.invoke!( + { + context: state.context, + params: getParams(state.value, state.params), + }, + createEnqueue(onEmit) + ); + + return { + type: `xstate.done.invoke.${state.value}`, + output: result, + at: Date.now(), + }; } catch (error) { - return { ...state, status: 'error', error }; + return { + type: `xstate.error.invoke.${state.value}`, + error: serializeError(error), + at: Date.now(), + }; } } + async function getEffectEvent( + state: AgentState, + onEmit?: (part: EmittedPart) => void + ): Promise { + if (state.status === 'done' || state.status === 'error') { + return null; + } + + const sc = resolveStateConfig(cfg, state.value); + if (sc.type === 'choice' || sc.__decideConfig) { + return createChoiceEvent(state); + } + + if (sc.invoke) { + return createInvokeEvent(state, sc, onEmit); + } + + return null; + } + + async function invoke(state: AgentState): Promise { + if (state.status === 'done' || state.status === 'error') { + return state; + } + + const sc = resolveStateConfig(cfg, state.value); + + if (sc.type === 'final') { + const output = sc.output + ? sc.output({ context: state.context }) + : undefined; + return { ...state, status: 'done', output }; + } + + const effectEvent = await getEffectEvent(state); + if (effectEvent) { + return transition(state, effectEvent); + } + + if (sc.on) { + return { ...state, status: 'pending' }; + } + + return { + ...state, + status: 'error', + error: `State '${state.value}' has no invoke, events, or final type`, + }; + } + async function execute(state: AgentState): Promise { let current = state; while (current.status === 'active') { @@ -409,9 +564,11 @@ export function createAgentMachine( ): AsyncGenerator { let current = state; const runtime = createSnapshotRuntime(current); + current = withRuntimeMetadata(current, runtime); yield toSnap(current, runtime); while (current.status === 'active') { current = await invoke(current); + current = withRuntimeMetadata(current, runtime); yield toSnap(current, runtime); } } @@ -440,5 +597,10 @@ export function createAgentMachine( invoke, execute, stream, + __runtime: { + toSnapshot: toSnap, + withRuntimeMetadata, + getEffectEvent, + }, } as AgentMachine; } diff --git a/src/restore.test.ts b/src/restore.test.ts new file mode 100644 index 0000000..7d9de1f --- /dev/null +++ b/src/restore.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + restoreSession, + startSession, +} from './index.js'; + +test('restoreSession reconstructs from the latest snapshot plus replay tail', async () => { + const machine = createAgentMachine({ + id: 'restore-session', + context: () => ({ approved: false, result: null as string | null }), + initial: 'review', + states: { + review: { + on: { + approve: { + target: 'processing', + context: { approved: true }, + }, + }, + }, + processing: { + resultSchema: z.object({ value: z.string() }), + invoke: async ({ context }) => ({ + value: context.approved ? 'approved' : 'rejected', + }), + onDone: ({ result }) => ({ + target: 'done', + context: { result: result.value }, + }), + }, + done: { + type: 'final', + output: ({ context }) => context, + }, + }, + }); + + const baseStore = createMemoryRunStore(); + let snapshotWrites = 0; + const store = { + append: baseStore.append, + loadEvents: baseStore.loadEvents, + loadLatestSnapshot: baseStore.loadLatestSnapshot, + async saveSnapshot(snapshot: Awaited< + ReturnType + > extends infer TSaved + ? Exclude + : never) { + snapshotWrites += 1; + if (snapshotWrites === 1) { + await baseStore.saveSnapshot(snapshot); + } + }, + }; + + const liveRun = await startSession(machine, { store }); + await liveRun.send({ type: 'approve' }); + + expect(await store.loadLatestSnapshot(liveRun.sessionId)).toEqual( + expect.objectContaining({ + afterSequence: 1, + }) + ); + + const restoredRun = await restoreSession(machine, { + sessionId: liveRun.sessionId, + store, + }); + + expect(restoredRun.getSnapshot()).toEqual(liveRun.getSnapshot()); +}); diff --git a/src/runtime/emitter.ts b/src/runtime/emitter.ts new file mode 100644 index 0000000..1b83786 --- /dev/null +++ b/src/runtime/emitter.ts @@ -0,0 +1,45 @@ +type Handler = (event: unknown) => void; + +export interface RunEmitter { + emit(type: string, event: unknown): void; + on(type: string, handler: Handler): () => void; +} + +export function createRunEmitter(): RunEmitter { + const listeners = new Map>(); + const history = new Map(); + + return { + emit(type, event) { + const events = history.get(type) ?? []; + events.push(event); + history.set(type, events); + + for (const handler of listeners.get(type) ?? []) { + handler(event); + } + }, + + on(type, handler) { + const current = listeners.get(type) ?? new Set(); + current.add(handler); + listeners.set(type, current); + + for (const event of history.get(type) ?? []) { + handler(event); + } + + return () => { + const active = listeners.get(type); + if (!active) { + return; + } + + active.delete(handler); + if (active.size === 0) { + listeners.delete(type); + } + }; + }, + }; +} diff --git a/src/runtime/session.ts b/src/runtime/session.ts new file mode 100644 index 0000000..c47a0de --- /dev/null +++ b/src/runtime/session.ts @@ -0,0 +1,305 @@ +import type { JournalEvent } from './events.js'; +import { createRunEmitter } from './emitter.js'; +import type { + AgentMachine, + AgentRun, + AgentSnapshot, + AgentState, + EmittedPart, + RestoreSessionOptions, + SessionOptions, +} from '../types.js'; + +type SnapshotRuntime = { + sessionId: string; + createdAt: number; +}; + +type RuntimeMachine = AgentMachine & { + __runtime: { + toSnapshot(state: AgentState, runtime: SnapshotRuntime): AgentSnapshot; + withRuntimeMetadata(state: AgentState, runtime: SnapshotRuntime): AgentState; + getEffectEvent( + state: AgentState, + onEmit?: (part: EmittedPart) => void + ): Promise; + }; +}; + +type RunState = { + current: AgentState; + snapshot: AgentSnapshot; + lastSequence: number; + runtime: SnapshotRuntime; +}; + +function createSessionId(): string { + if ( + typeof globalThis.crypto !== 'undefined' && + typeof globalThis.crypto.randomUUID === 'function' + ) { + return globalThis.crypto.randomUUID(); + } + + return `session-${Math.random().toString(36).slice(2)}`; +} + +function asRuntimeMachine(machine: AgentMachine): RuntimeMachine { + const runtimeMachine = machine as RuntimeMachine; + if (!runtimeMachine.__runtime) { + throw new Error('Machine runtime internals are unavailable'); + } + + return runtimeMachine; +} + +function toJournalEvent( + event: { type: string; [key: string]: unknown } +): JournalEvent { + return { + ...event, + at: typeof event.at === 'number' ? event.at : Date.now(), + }; +} + +function createRun( + machine: AgentMachine, + store: SessionOptions['store'], + runtimeMachine: RuntimeMachine, + runState: RunState, + emitter = createRunEmitter() +): AgentRun { + async function persistSnapshot() { + runState.snapshot = runtimeMachine.__runtime.toSnapshot( + runState.current, + runState.runtime + ); + + await store.saveSnapshot({ + sessionId: runState.runtime.sessionId, + afterSequence: runState.lastSequence, + snapshot: runState.snapshot, + createdAt: Date.now(), + }); + + emitter.emit('runtime', { + type: 'snapshot.persisted', + sessionId: runState.runtime.sessionId, + afterSequence: runState.lastSequence, + }); + emitter.emit('state', runState.snapshot); + } + + async function appendMachineEvent(event: JournalEvent) { + const record = await store.append(runState.runtime.sessionId, event); + runState.lastSequence = record.sequence; + emitter.emit('machine.event', { + ...event, + sequence: record.sequence, + }); + } + + async function settle() { + while (runState.current.status === 'active') { + const effectEvent = await runtimeMachine.__runtime.getEffectEvent( + runState.current, + (part) => { + emitter.emit(part.type, part); + } + ); + + if (effectEvent) { + await appendMachineEvent(effectEvent); + runState.current = runtimeMachine.__runtime.withRuntimeMetadata( + machine.transition(runState.current, effectEvent), + runState.runtime + ); + await persistSnapshot(); + continue; + } + + runState.current = runtimeMachine.__runtime.withRuntimeMetadata( + await machine.invoke(runState.current), + runState.runtime + ); + await persistSnapshot(); + } + } + + return { + get sessionId() { + return runState.runtime.sessionId; + }, + + get status() { + return runState.snapshot.status; + }, + + getSnapshot() { + return runState.snapshot; + }, + + async send(event) { + const journalEvent = toJournalEvent(event); + const next = machine.transition(runState.current, journalEvent); + + await appendMachineEvent(journalEvent); + runState.current = runtimeMachine.__runtime.withRuntimeMetadata( + next, + runState.runtime + ); + await persistSnapshot(); + await settle(); + }, + + on(type, handler) { + return emitter.on(type, handler); + }, + + /** @internal */ + async __persistCurrent() { + await persistSnapshot(); + }, + + /** @internal */ + async __settle() { + await settle(); + }, + + /** @internal */ + __emit(type: string, event: unknown) { + emitter.emit(type, event); + }, + } as AgentRun; +} + +export async function startSession( + machine: AgentMachine, + options: SessionOptions +): Promise { + const runtimeMachine = asRuntimeMachine(machine); + const initialState = machine.getInitialState(options.input); + const runtime = { + sessionId: options.sessionId ?? createSessionId(), + createdAt: Date.now(), + }; + const runState: RunState = { + current: runtimeMachine.__runtime.withRuntimeMetadata(initialState, runtime), + snapshot: runtimeMachine.__runtime.toSnapshot(initialState, runtime), + lastSequence: 0, + runtime, + }; + + const run = createRun( + machine, + options.store, + runtimeMachine, + runState + ) as AgentRun & { + __persistCurrent(): Promise; + __settle(): Promise; + __emit(type: string, event: unknown): void; + }; + + const initEvent = { + type: 'xstate.init', + input: options.input, + at: runtime.createdAt, + } satisfies JournalEvent; + const record = await options.store.append(runtime.sessionId, initEvent); + runState.lastSequence = record.sequence; + run.__emit('machine.event', { ...initEvent, sequence: record.sequence }); + run.__emit('runtime', { + type: 'session.started', + sessionId: runtime.sessionId, + }); + + await run.__persistCurrent(); + await run.__settle(); + + return run; +} + +export async function restoreSession( + machine: AgentMachine, + options: RestoreSessionOptions +): Promise { + const runtimeMachine = asRuntimeMachine(machine); + const persisted = await options.store.loadLatestSnapshot(options.sessionId); + const allEvents = await options.store.loadEvents(options.sessionId); + const initEvent = allEvents.find( + (event) => event.type === 'xstate.init' + ); + + if (!persisted && !initEvent) { + throw new Error(`No persisted session '${options.sessionId}' found`); + } + + const runtime = { + sessionId: options.sessionId, + createdAt: persisted?.snapshot.createdAt ?? initEvent?.at ?? Date.now(), + }; + const initialState = persisted + ? machine.resolveState(persisted.snapshot) + : machine.getInitialState(initEvent?.input); + const runState: RunState = { + current: runtimeMachine.__runtime.withRuntimeMetadata(initialState, runtime), + snapshot: + persisted?.snapshot + ?? runtimeMachine.__runtime.toSnapshot(initialState, runtime), + lastSequence: persisted?.afterSequence ?? (initEvent?.sequence ?? 0), + runtime, + }; + const run = createRun( + machine, + options.store, + runtimeMachine, + runState + ) as AgentRun & { + __persistCurrent(): Promise; + __settle(): Promise; + __emit(type: string, event: unknown): void; + }; + + if (initEvent && !persisted) { + run.__emit('machine.event', initEvent); + } + + const replayTail = await options.store.loadEvents( + options.sessionId, + runState.lastSequence + ); + + if (!persisted) { + run.__emit('state', runState.snapshot); + } + + for (const event of replayTail) { + runState.current = runtimeMachine.__runtime.withRuntimeMetadata( + machine.transition(runState.current, event), + runState.runtime + ); + runState.lastSequence = event.sequence; + runState.snapshot = runtimeMachine.__runtime.toSnapshot( + runState.current, + runState.runtime + ); + run.__emit('machine.event', event); + run.__emit('state', runState.snapshot); + } + + if (persisted) { + run.__emit('state', runState.snapshot); + } + + run.__emit('runtime', { + type: 'session.restored', + sessionId: runState.runtime.sessionId, + afterSequence: runState.lastSequence, + }); + + await run.__persistCurrent(); + await run.__settle(); + + return run; +} diff --git a/src/session-runtime.test.ts b/src/session-runtime.test.ts new file mode 100644 index 0000000..f6efd81 --- /dev/null +++ b/src/session-runtime.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'vitest'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from './index.js'; + +test('startSession creates a session and persists xstate.init', async () => { + const machine = createAgentMachine({ + id: 'session-runtime', + context: () => ({ count: 0 }), + initial: 'idle', + states: { + idle: { + on: { + increment: { + target: 'done', + context: { count: 1 }, + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => context, + }, + }, + }); + + const store = createMemoryRunStore(); + const run = await startSession(machine, { store }); + const snapshot = run.getSnapshot(); + const journal = await store.loadEvents(run.sessionId); + const persisted = await store.loadLatestSnapshot(run.sessionId); + + expect(run.sessionId).toBe(snapshot.sessionId); + expect(run.status).toBe('pending'); + expect(snapshot).toEqual( + expect.objectContaining({ + sessionId: run.sessionId, + value: 'idle', + status: 'pending', + context: { count: 0 }, + params: {}, + }) + ); + expect(journal).toEqual([ + expect.objectContaining({ + sequence: 1, + type: 'xstate.init', + at: expect.any(Number), + }), + ]); + expect(persisted).toEqual( + expect.objectContaining({ + sessionId: run.sessionId, + afterSequence: 1, + snapshot, + }) + ); +}); diff --git a/src/streaming.test.ts b/src/streaming.test.ts new file mode 100644 index 0000000..8ec5530 --- /dev/null +++ b/src/streaming.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from './index.js'; + +test('emitted parts flow through the run-level API', async () => { + const machine = createAgentMachine({ + id: 'streaming-parts', + schemas: { + emitted: { + textPart: z.object({ delta: z.string() }), + }, + }, + context: () => ({ finalText: '' }), + initial: 'writing', + states: { + writing: { + resultSchema: z.object({ text: z.string() }), + invoke: async (_args, enq) => { + enq.emit({ type: 'textPart', delta: 'hel' }); + enq.emit({ type: 'textPart', delta: 'lo' }); + + return { text: 'hello' }; + }, + onDone: ({ result }) => ({ + target: 'done', + context: { finalText: result.text }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ text: context.finalText }), + }, + }, + }); + + const store = createMemoryRunStore(); + const run = await startSession(machine, { store }); + const parts: Array<{ type: string; delta: string }> = []; + const states: string[] = []; + const events: string[] = []; + + const offPart = run.on('textPart', (part) => { + parts.push(part as { type: string; delta: string }); + }); + const offState = run.on('state', (snapshot) => { + states.push((snapshot as { value: string }).value); + }); + const offEvent = run.on('machine.event', (event) => { + events.push((event as { type: string }).type); + }); + + expect(parts).toEqual([ + { type: 'textPart', delta: 'hel' }, + { type: 'textPart', delta: 'lo' }, + ]); + expect(states).toContain('writing'); + expect(states[states.length - 1]).toBe('done'); + expect(events).toContain('xstate.done.invoke.writing'); + expect(run.getSnapshot().output).toEqual({ text: 'hello' }); + + offPart(); + offState(); + offEvent(); +}); + +test('invalid emitted parts are rejected', async () => { + const machine = createAgentMachine({ + id: 'streaming-invalid-parts', + schemas: { + emitted: { + textPart: z.object({ delta: z.string().min(1) }), + }, + }, + context: () => ({ count: 0 }), + initial: 'writing', + states: { + writing: { + invoke: async (_args, enq) => { + enq.emit({ type: 'textPart', delta: '' }); + return { ok: true }; + }, + }, + }, + }); + + const run = await startSession(machine, { + store: createMemoryRunStore(), + }); + + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'writing', + status: 'error', + error: expect.objectContaining({ + message: expect.stringContaining("Invalid emitted part 'textPart'"), + }), + }) + ); +}); diff --git a/src/types.ts b/src/types.ts index 4082e00..4f6219d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,12 +23,20 @@ export type EventUnion> = { [K in keyof T & string]: { type: K } & EventPayload>; }[keyof T & string]; +export type EmittedUnion> = EventUnion; + export type TransitionEvent< TEvents extends Record, > = [keyof TEvents & string] extends [never] ? { type: string; [key: string]: unknown } : EventUnion; +export type EmittedPart = { type: string; [key: string]: unknown }; + +export interface InvokeEnqueue { + emit(part: EmittedPart): void; +} + // ─── Durable Session Vocabulary ─── export type { JournalEvent } from './runtime/events.js'; @@ -66,11 +74,12 @@ export interface StateConfig< > { type?: 'final' | 'choice'; paramsSchema?: StandardSchemaV1; + resultSchema?: StandardSchemaV1; invoke?: (args: { context: TContext; params: Record; signal?: AbortSignal; - }) => Promise; + }, enq: InvokeEnqueue) => Promise; onDone?: (args: { result: any; context: TContext }) => TransitionResult; on?: Record | ((args: { event: any; context: TContext }) => TransitionResult)>; events?: Record; @@ -176,6 +185,34 @@ export interface AgentMachine< ): AsyncGenerator>; } +export interface AgentRun< + TContext extends Record = Record, + TValue extends string = string, + TEvents extends Record = {}, +> { + readonly sessionId: string; + readonly status: AgentSnapshot['status']; + getSnapshot(): AgentSnapshot; + send(event: TransitionEvent): Promise; + on(type: string, handler: (event: unknown) => void): () => void; +} + +export interface SessionOptions< + TInput = unknown, + TSnapshot extends AgentSnapshot = AgentSnapshot, +> { + input?: TInput; + sessionId?: string; + store: import('./runtime/store.js').RunStore; +} + +export interface RestoreSessionOptions< + TSnapshot extends AgentSnapshot = AgentSnapshot, +> { + sessionId: string; + store: import('./runtime/store.js').RunStore; +} + // ─── Machine Config (internal) ─── export interface MachineConfig< @@ -189,6 +226,7 @@ export interface MachineConfig< input?: StandardSchemaV1; context?: StandardSchemaV1; events?: TEvents; + emitted?: Record; }; context: (input: TInput) => TContext; adapter?: AgentAdapter; diff --git a/src/utils.ts b/src/utils.ts index 1c8b73c..f6cd7c0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -46,10 +46,17 @@ export function resolveStateConfig( /** Loose state config for internal runtime use */ export type StateConfigAny = { type?: 'final' | 'choice'; - invoke?: (args: { context: Record; params: Record }) => Promise; + invoke?: ( + args: { + context: Record; + params: Record; + }, + enq: { emit(part: { type: string; [key: string]: unknown }): void } + ) => Promise; onDone?: (args: { result: unknown; context: Record }) => TransitionResult; on?: Record; context: Record }) => TransitionResult)>; output?: (args: { context: Record }) => unknown; + resultSchema?: StandardSchemaV1; model?: string; adapter?: { decide: (...args: unknown[]) => Promise }; prompt?: string | ((args: { context: Record; params: Record }) => string); @@ -166,3 +173,46 @@ export function findEventSchema( const events = config.schemas?.events as Record | undefined; return events?.[eventType]; } + +export function findEmittedSchema( + config: MachineConfig, + eventType: string +): StandardSchemaV1 | undefined { + const emitted = config.schemas?.emitted as + | Record + | undefined; + + return emitted?.[eventType]; +} + +export function formatSchemaIssues( + issues: ReadonlyArray<{ message: string }> +): string { + return issues.map((issue) => issue.message).join(', '); +} + +export function isDoneInvokeEventType( + stateValue: string, + eventType: string +): boolean { + return eventType === `xstate.done.invoke.${stateValue}`; +} + +export function isErrorInvokeEventType( + stateValue: string, + eventType: string +): boolean { + return eventType === `xstate.error.invoke.${stateValue}`; +} + +export function serializeError(error: unknown): unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} From ce8dce45203918f92da6989031257822a0a01e40 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 10 Apr 2026 13:36:18 -0400 Subject: [PATCH 16/50] WIP --- .gitignore | 2 + .vscode/launch.json | 4 +- examples/_run.ts | 211 +++++ examples/adapter.ts | 86 ++ examples/branching.ts | 132 ++++ examples/chatbot.ts | 162 ++++ examples/classify.ts | 65 ++ examples/customer-service-sim.ts | 134 ++++ examples/decide.ts | 83 ++ examples/email.ts | 250 ++++++ examples/hitl.ts | 121 +++ examples/index.ts | 21 + examples/joke.ts | 111 +++ examples/jugs.ts | 127 +++ examples/map-reduce.ts | 120 +++ examples/newspaper.ts | 180 +++++ examples/plan-and-execute.ts | 170 ++++ examples/raffle.ts | 116 +++ examples/react-agent.ts | 111 +++ examples/reflection.ts | 155 ++++ examples/river-crossing.ts | 172 ++++ examples/simple.ts | 66 ++ examples/subflow.ts | 139 ++++ examples/tool-calling.ts | 119 +++ examples/tutor.ts | 99 +++ examples_old/executor.ts | 2 +- examples_old/multi.ts | 2 +- package.json | 1 + pnpm-lock.yaml | 283 +++++++ readme.md | 16 + src/agent.test.ts | 41 +- src/ai-sdk/index.ts | 68 +- src/classify.ts | 71 +- src/decide.ts | 72 +- src/examples.test.ts | 747 ++++++++++++++++++ src/index.ts | 6 + src/invoke-events.test.ts | 51 ++ src/langgraph-equivalents/branching.test.ts | 76 ++ src/langgraph-equivalents/graph.test.ts | 118 +++ src/langgraph-equivalents/hitl.test.ts | 76 ++ src/langgraph-equivalents/map-reduce.test.ts | 79 ++ src/langgraph-equivalents/persistence.test.ts | 88 +++ .../plan-and-execute.test.ts | 35 + .../prebuilt-react.test.ts | 89 +++ src/langgraph-equivalents/reflection.test.ts | 30 + src/langgraph-equivalents/streaming.test.ts | 71 ++ src/langgraph-equivalents/subflow.test.ts | 101 +++ .../tool-calling.test.ts | 97 +++ src/machine.ts | 223 ++++-- src/prebuilt/react.ts | 225 ++++++ src/restore.test.ts | 5 +- src/runtime/emitter.ts | 9 - src/runtime/session.ts | 176 +++-- src/session-runtime.test.ts | 132 +++- src/streaming.test.ts | 155 +++- src/target-types.assert.ts | 204 +++++ src/types.ts | 48 +- src/utils.ts | 15 +- 58 files changed, 6165 insertions(+), 203 deletions(-) create mode 100644 examples/_run.ts create mode 100644 examples/adapter.ts create mode 100644 examples/branching.ts create mode 100644 examples/chatbot.ts create mode 100644 examples/classify.ts create mode 100644 examples/customer-service-sim.ts create mode 100644 examples/decide.ts create mode 100644 examples/email.ts create mode 100644 examples/hitl.ts create mode 100644 examples/index.ts create mode 100644 examples/joke.ts create mode 100644 examples/jugs.ts create mode 100644 examples/map-reduce.ts create mode 100644 examples/newspaper.ts create mode 100644 examples/plan-and-execute.ts create mode 100644 examples/raffle.ts create mode 100644 examples/react-agent.ts create mode 100644 examples/reflection.ts create mode 100644 examples/river-crossing.ts create mode 100644 examples/simple.ts create mode 100644 examples/subflow.ts create mode 100644 examples/tool-calling.ts create mode 100644 examples/tutor.ts create mode 100644 src/examples.test.ts create mode 100644 src/langgraph-equivalents/branching.test.ts create mode 100644 src/langgraph-equivalents/graph.test.ts create mode 100644 src/langgraph-equivalents/hitl.test.ts create mode 100644 src/langgraph-equivalents/map-reduce.test.ts create mode 100644 src/langgraph-equivalents/persistence.test.ts create mode 100644 src/langgraph-equivalents/plan-and-execute.test.ts create mode 100644 src/langgraph-equivalents/prebuilt-react.test.ts create mode 100644 src/langgraph-equivalents/reflection.test.ts create mode 100644 src/langgraph-equivalents/streaming.test.ts create mode 100644 src/langgraph-equivalents/subflow.test.ts create mode 100644 src/langgraph-equivalents/tool-calling.test.ts create mode 100644 src/prebuilt/react.ts create mode 100644 src/target-types.assert.ts diff --git a/.gitignore b/.gitignore index 323729b..a52881a 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,5 @@ dist/ .pnp.* .vscode/settings.json + +docs/superpowers diff --git a/.vscode/launch.json b/.vscode/launch.json index d6e2a9a..87a02fb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,8 +19,8 @@ "name": "Debug Current File", "program": "${file}", "cwd": "${workspaceFolder}", - "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/ts-node", - "runtimeArgs": ["--transpile-only"], + "runtimeExecutable": "node", + "runtimeArgs": ["--import", "tsx"], "outFiles": ["${workspaceFolder}/dist/**/*.js"], "sourceMaps": true, "smartStep": true, diff --git a/examples/_run.ts b/examples/_run.ts new file mode 100644 index 0000000..36f0b65 --- /dev/null +++ b/examples/_run.ts @@ -0,0 +1,211 @@ +import 'dotenv/config'; + +import { generateText, Output } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { createInterface } from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { z } from 'zod'; +import type { AgentAdapter, ExecuteResult, StandardSchemaV1 } from '../src/index.js'; + +export function isMain(moduleUrl: string): boolean { + const entry = process.argv[1]; + return !!entry && moduleUrl === pathToFileURL(entry).href; +} + +let bufferedLinesPromise: Promise | null = null; +let bufferedLineIndex = 0; + +async function getBufferedLines(): Promise { + if (!bufferedLinesPromise) { + bufferedLinesPromise = (async () => { + const chunks: string[] = []; + + for await (const chunk of input) { + chunks.push(String(chunk)); + } + + return chunks.join('').split(/\r?\n/); + })(); + } + + return bufferedLinesPromise; +} + +export async function prompt(label: string): Promise { + if (!input.isTTY) { + output.write(`${label}: `); + const lines = await getBufferedLines(); + const value = lines[bufferedLineIndex] ?? ''; + bufferedLineIndex += 1; + return value.trim(); + } + + const rl = createInterface({ input, output }); + try { + const value = await rl.question(`${label}: `); + return value.trim(); + } finally { + rl.close(); + } +} + +export function closePrompt(): void { + bufferedLinesPromise = null; + bufferedLineIndex = 0; +} + +export function createExampleModel( + model = 'openai/gpt-5.4-nano' +): Parameters[0]['model'] { + if (!process.env.OPENAI_API_KEY) { + throw new Error('OPENAI_API_KEY is required to run the examples.'); + } + + return openai(resolveOpenAiModel(model)); +} + +export function formatResult(result: ExecuteResult) { + if (result.status === 'done') { + return { + status: result.status, + value: result.state.value, + context: result.context, + output: result.output, + }; + } + + if (result.status === 'pending') { + return { + status: result.status, + value: result.value, + context: result.context, + events: Object.keys(result.events), + }; + } + + return { + status: result.status, + value: result.state.value, + error: result.error, + }; +} + +export function createOpenAiDecisionAdapter(): AgentAdapter { + return { + async decide({ model, prompt, options, reasoning }) { + const optionKeys = Object.keys(options); + + const allSchemaLess = Object.values(options).every((option) => !option.schema); + + if (allSchemaLess && !reasoning) { + const choiceResult = await generateText({ + model: createExampleModel(model), + system: [ + 'Choose exactly one option.', + ...Object.entries(options).map(([key, option]) => `${key}: ${option.description}`), + ].join('\n'), + prompt, + output: Output.choice({ + options: optionKeys, + }), + }); + + return { + choice: choiceResult.output, + data: {} as Record, + }; + } + + const decisionSchemas = optionKeys.map((key) => { + const option = options[key]!; + + return z.object({ + decision: z.literal(key), + data: option.schema ? toZodSchema(option.schema) : z.object({}), + ...(reasoning + ? { reasoning: z.string() } + : {}), + }); + }); + + const decisionSchema = + decisionSchemas.length === 1 + ? decisionSchemas[0]! + : z.union( + decisionSchemas as unknown as [ + z.ZodTypeAny, + z.ZodTypeAny, + ...z.ZodTypeAny[], + ] + ); + + const result = await generateText({ + model: createExampleModel(model), + system: [ + 'Choose exactly one option and return structured output.', + ...Object.entries(options).map(([key, option]) => `${key}: ${option.description}`), + ].join('\n'), + prompt, + output: Output.object({ + schema: decisionSchema, + }), + }); + const output = result.output as { + decision: string; + data: Record; + reasoning?: string; + }; + + return { + choice: output.decision, + data: output.data, + reasoning: output.reasoning, + }; + }, + }; +} + +export async function generateExampleObject(options: { + schema: StandardSchemaV1; + prompt: string; + system?: string; + model?: string; +}): Promise { + const result = await generateText({ + model: createExampleModel(options.model), + output: Output.object({ + schema: toZodSchema(options.schema), + }), + system: options.system, + prompt: options.prompt, + }); + + return result.output as T; +} + +export async function generateExampleText(options: { + prompt: string; + system?: string; + model?: string; +}): Promise { + const result = await generateText({ + model: createExampleModel(options.model), + system: options.system, + prompt: options.prompt, + }); + + return result.text.trim(); +} + +function resolveOpenAiModel(model: string): string { + return model.startsWith('openai/') ? model.slice('openai/'.length) : model; +} + +function toZodSchema(schema: StandardSchemaV1): z.ZodTypeAny { + if ('_zod' in schema || '_def' in schema) { + return schema as unknown as z.ZodTypeAny; + } + + return z.record(z.string(), z.unknown()); +} diff --git a/examples/adapter.ts b/examples/adapter.ts new file mode 100644 index 0000000..c3c2ee3 --- /dev/null +++ b/examples/adapter.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { + createAdapter, + createAgentMachine, + decide, + type AgentAdapter, +} from '../src/index.js'; +import { + closePrompt, + createOpenAiDecisionAdapter, + formatResult, + isMain, + prompt, +} from './_run.js'; + +export function createAdapterExample( + adapter: AgentAdapter = createAdapter({ + decide: createOpenAiDecisionAdapter().decide, + }) +) { + return createAgentMachine({ + id: 'adapter-example', + schemas: { + input: z.object({ message: z.string() }), + }, + context: (input) => ({ + message: input.message, + route: null as string | null, + confidence: null as number | null, + }), + adapter, + initial: 'route', + states: { + route: decide({ + model: 'openai/gpt-5.4-nano', + prompt: ({ context }) => [ + 'Route this support request.', + 'Return billing only when the request is clearly about invoices, refunds, or charges.', + 'Otherwise return general.', + '', + context.message, + ].join('\n'), + options: { + billing: { + description: 'Send the request to billing support.', + schema: z.object({ confidence: z.number().min(0).max(1) }), + }, + general: { + description: 'Handle the request in general support.', + schema: z.object({ confidence: z.number().min(0).max(1) }), + }, + }, + reasoning: false, + onDone: ({ result }) => ({ + target: 'done', + context: { + route: result.choice, + confidence: result.data.confidence, + }, + }), + }), + done: { + type: 'final', + output: ({ context }) => ({ + route: context.route, + confidence: context.confidence, + }), + }, + }, + }); +} + +async function main() { + try { + const message = await prompt('Message to route'); + const machine = createAdapterExample(); + + console.log(formatResult(await machine.execute(machine.getInitialState({ message })))); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/branching.ts b/examples/branching.ts new file mode 100644 index 0000000..ddaef9f --- /dev/null +++ b/examples/branching.ts @@ -0,0 +1,132 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + generateExampleText, + isMain, + prompt, +} from './_run.js'; + +const branchResultSchema = z.object({ + docs: z.string(), + issues: z.string(), + code: z.string(), +}); + +const summarySchema = z.object({ + summary: z.string(), +}); + +export function createBranchingExample( + options: { + analyzeDocs?: (topic: string) => Promise; + analyzeIssues?: (topic: string) => Promise; + analyzeCode?: (topic: string) => Promise; + summarize?: (parts: { + docs: string; + issues: string; + code: string; + }) => Promise>; + } = {} +) { + return createAgentMachine({ + id: 'branching-example', + schemas: { + input: z.object({ topic: z.string() }), + }, + context: (input) => ({ + topic: input.topic, + docs: null as string | null, + issues: null as string | null, + code: null as string | null, + summary: null as string | null, + }), + initial: 'analyzing', + states: { + analyzing: { + resultSchema: branchResultSchema, + invoke: async ({ context }) => { + const [docs, issues, code] = await Promise.all([ + (options.analyzeDocs + ?? ((topic) => + generateExampleText({ + system: 'You are a repository docs analyst. Be concise and concrete.', + prompt: `Summarize what the documentation angle should cover for this topic in 2 short sentences:\n\n${topic}`, + })))(context.topic), + (options.analyzeIssues + ?? ((topic) => + generateExampleText({ + system: 'You analyze likely issue patterns and risks. Be concise and concrete.', + prompt: `Summarize the likely issue and operational concerns for this topic in 2 short sentences:\n\n${topic}`, + })))(context.topic), + (options.analyzeCode + ?? ((topic) => + generateExampleText({ + system: 'You analyze code-level implementation concerns. Be concise and concrete.', + prompt: `Summarize the likely code architecture and implementation concerns for this topic in 2 short sentences:\n\n${topic}`, + })))(context.topic), + ]); + + return { docs, issues, code }; + }, + onDone: ({ result }) => ({ + target: 'summarizing', + context: result, + }), + }, + summarizing: { + resultSchema: summarySchema, + invoke: async ({ context }) => + (options.summarize + ?? (({ docs, issues, code }) => + generateExampleObject({ + schema: summarySchema, + system: 'You synthesize technical analysis into a concise summary.', + prompt: [ + 'Combine these three perspectives into a concise high-level summary.', + '', + `Docs:\n${docs}`, + '', + `Issues:\n${issues}`, + '', + `Code:\n${code}`, + ].join('\n'), + })))({ + docs: context.docs ?? '', + issues: context.issues ?? '', + code: context.code ?? '', + }), + onDone: ({ result }) => ({ + target: 'done', + context: { summary: result.summary }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + docs: context.docs, + issues: context.issues, + code: context.code, + summary: context.summary, + }), + }, + }, + }); +} + +async function main() { + try { + const topic = await prompt('Topic'); + const machine = createBranchingExample(); + const result = await machine.execute(machine.getInitialState({ topic })); + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/chatbot.ts b/examples/chatbot.ts new file mode 100644 index 0000000..b3a9616 --- /dev/null +++ b/examples/chatbot.ts @@ -0,0 +1,162 @@ +import { z } from 'zod'; +import { createAgentMachine, decide, type AgentAdapter } from '../src/index.js'; +import { + closePrompt, + createOpenAiDecisionAdapter, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const replySchema = z.object({ + response: z.string(), +}); + +export function createChatbotExample( + options: { + adapter?: AgentAdapter; + reply?: (transcript: string[]) => Promise>; + } = {} +) { + const adapter = + options.adapter ?? + (process.env.OPENAI_API_KEY ? createOpenAiDecisionAdapter() : undefined); + const reply = + options.reply ?? + ((transcript: string[]) => + generateExampleObject({ + schema: replySchema, + system: 'You are a concise, helpful assistant in a terminal chat.', + prompt: [ + 'Write the assistant reply for the conversation below.', + 'Keep it short and directly responsive.', + '', + transcript.join('\n'), + ].join('\n'), + })); + + return createAgentMachine({ + id: 'chatbot-example', + schemas: { + events: { + 'user.message': z.object({ message: z.string() }), + 'user.exit': z.object({}), + }, + }, + context: () => ({ + transcript: [] as string[], + lastUserMessage: null as string | null, + lastAssistantMessage: null as string | null, + ended: false, + }), + adapter, + initial: 'listening', + states: { + listening: { + on: { + 'user.message': ({ event, context }) => ({ + target: 'deciding', + context: { + lastUserMessage: event.message, + transcript: [...context.transcript, `User: ${event.message}`], + }, + }), + 'user.exit': { + target: 'done', + context: { ended: true }, + }, + }, + }, + deciding: decide({ + model: 'openai/gpt-5.4-nano', + prompt: ({ context }) => + [ + 'Decide whether the assistant should answer or end the conversation.', + 'End only when the user is clearly saying goodbye or asking to stop.', + '', + (context as { transcript: string[] }).transcript.join('\n'), + ].join('\n'), + options: { + respond: { description: 'Reply to the user and continue chatting.' }, + end: { description: 'End the conversation now.' }, + }, + onDone: ({ result }) => ({ + target: result.choice === 'end' ? 'done' : 'replying', + context: result.choice === 'end' ? { ended: true } : {}, + }), + }), + replying: { + resultSchema: replySchema, + invoke: async ({ context }) => reply(context.transcript), + onDone: ({ result, context }) => ({ + target: 'listening', + context: { + lastAssistantMessage: result.response, + transcript: [...context.transcript, `Assistant: ${result.response}`], + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + transcript: context.transcript, + ended: context.ended, + lastAssistantMessage: context.lastAssistantMessage, + }), + }, + }, + }); +} + +async function main() { + try { + const machine = createChatbotExample(); + let state = machine.getInitialState(); + let lastPrintedAssistantMessage: string | null = null; + + while (true) { + const result = await machine.execute(state); + + if (result.status === 'done') { + if ( + result.output && + typeof result.output === 'object' && + 'lastAssistantMessage' in result.output && + result.output.lastAssistantMessage && + result.output.lastAssistantMessage !== lastPrintedAssistantMessage + ) { + console.log(`Assistant: ${result.output.lastAssistantMessage}`); + } + console.log(formatResult(result)); + break; + } + + if (result.status !== 'pending') { + throw new Error('Chatbot example entered an unexpected error state.'); + } + + if ( + result.context.lastAssistantMessage && + result.context.lastAssistantMessage !== lastPrintedAssistantMessage + ) { + console.log(`Assistant: ${result.context.lastAssistantMessage}`); + lastPrintedAssistantMessage = result.context.lastAssistantMessage; + } + + const message = await prompt('User (blank to exit)'); + state = machine.transition( + result.state, + message + ? { type: 'user.message', message } + : { type: 'user.exit' } + ); + } + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/classify.ts b/examples/classify.ts new file mode 100644 index 0000000..d2befb2 --- /dev/null +++ b/examples/classify.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; +import { + createAgentMachine, + classify, + type AgentAdapter, +} from '../src/index.js'; +import { + closePrompt, + createOpenAiDecisionAdapter, + formatResult, + isMain, + prompt, +} from './_run.js'; + +export function createClassifyExample( + adapter: AgentAdapter = createOpenAiDecisionAdapter() +) { + return createAgentMachine({ + id: 'classify-example', + schemas: { + input: z.object({ request: z.string() }), + }, + context: (input) => ({ + request: input.request, + category: null as string | null, + }), + adapter, + initial: 'routing', + states: { + routing: classify({ + model: 'openai/gpt-5.4-nano', + prompt: ({ context }) => `Classify this support request:\n\n${context.request}`, + into: { + billing: { description: 'Payments, invoices, refunds, and charges.' }, + technical: { description: 'Bugs, outages, and product issues.' }, + general: { description: 'Everything else.' }, + }, + onDone: ({ result }) => ({ + target: 'done', + context: { category: result.category }, + }), + }), + done: { + // use params; category should alwyas be defined when entering + type: 'final', + output: ({ context }) => ({ category: context.category }), + }, + }, + }); +} + +async function main() { + try { + const request = await prompt('Support request'); + const machine = createClassifyExample(); + + console.log(formatResult(await machine.execute(machine.getInitialState({ request })))); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/customer-service-sim.ts b/examples/customer-service-sim.ts new file mode 100644 index 0000000..bd21acf --- /dev/null +++ b/examples/customer-service-sim.ts @@ -0,0 +1,134 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const serviceReplySchema = z.object({ + response: z.string(), +}); + +const customerReplySchema = z.object({ + response: z.string(), + done: z.boolean(), + outcome: z.string().nullable(), +}); + +type TranscriptContext = { + issue: string; + transcript: string[]; + turnCount: number; + maxTurns: number; + outcome: string | null; +}; + +export function createCustomerServiceSimExample( + options: { + serviceReply?: (context: TranscriptContext) => Promise>; + customerReply?: (context: TranscriptContext) => Promise>; + maxTurns?: number; + } = {} +) { + const serviceReply = + options.serviceReply ?? + ((context: TranscriptContext) => + generateExampleObject({ + schema: serviceReplySchema, + system: 'You are a customer support agent negotiating calmly and pragmatically.', + prompt: [ + `Issue: ${context.issue}`, + `Turn count: ${context.turnCount}`, + `Current outcome: ${context.outcome ?? 'none'}`, + '', + 'Transcript so far:', + context.transcript.join('\n'), + '', + 'Write the next support agent response in one short paragraph.', + ].join('\n'), + })); + const customerReply = + options.customerReply ?? + ((context: TranscriptContext) => + generateExampleObject({ + schema: customerReplySchema, + system: 'You are the customer in the support exchange. Stay realistic and concise.', + prompt: [ + `Original issue: ${context.issue}`, + `Turn count: ${context.turnCount}`, + '', + 'Transcript so far:', + context.transcript.join('\n'), + '', + 'Write the next customer reply. Set done=true only if the issue is resolved or the customer accepts the proposed outcome. Use outcome to summarize the result when done.', + ].join('\n'), + })); + + return createAgentMachine({ + id: 'customer-service-sim-example', + schemas: { + input: z.object({ issue: z.string() }), + }, + context: (input) => ({ + issue: input.issue, + transcript: [`Customer: ${input.issue}`], + turnCount: 0, + maxTurns: options.maxTurns ?? 4, + outcome: null as string | null, + }), + initial: 'service', + states: { + service: { + resultSchema: serviceReplySchema, + invoke: async ({ context }) => serviceReply(context), + onDone: ({ result, context }) => ({ + target: context.turnCount + 1 >= context.maxTurns ? 'done' : 'customer', + context: { + transcript: [...context.transcript, `Agent: ${result.response}`], + outcome: + context.turnCount + 1 >= context.maxTurns + ? 'max-turns-reached' + : context.outcome, + }, + }), + }, + customer: { + resultSchema: customerReplySchema, + invoke: async ({ context }) => customerReply(context), + onDone: ({ result, context }) => ({ + target: result.done ? 'done' : 'service', + context: { + transcript: [...context.transcript, `Customer: ${result.response}`], + turnCount: context.turnCount + 1, + outcome: result.outcome, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + transcript: context.transcript, + turnCount: context.turnCount, + outcome: context.outcome, + }), + }, + }, + }); +} + +async function main() { + try { + const issue = await prompt('Customer issue'); + const machine = createCustomerServiceSimExample(); + console.log(formatResult(await machine.execute(machine.getInitialState({ issue })))); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/decide.ts b/examples/decide.ts new file mode 100644 index 0000000..3288ae2 --- /dev/null +++ b/examples/decide.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; +import { + createAgentMachine, + decide, + type AgentAdapter, +} from '../src/index.js'; +import { + closePrompt, + createOpenAiDecisionAdapter, + formatResult, + isMain, + prompt, +} from './_run.js'; + +export function createDecideExample(adapter: AgentAdapter = createOpenAiDecisionAdapter()) { + return createAgentMachine({ + id: 'decide-example', + schemas: { + input: z.object({ request: z.string() }), + }, + context: (input) => ({ + request: input.request, + action: null as string | null, + payload: null as Record | null, + }), + adapter, + initial: 'triage', + states: { + triage: decide({ + model: 'openai/gpt-5.4-nano', + prompt: ({ context }) => [ + 'Choose the best next step for this support request.', + 'Prefer asking a single clarification question when key facts are missing.', + '', + `Request: ${context.request}`, + ].join('\n'), + options: { + reply: { + description: 'Reply directly to the customer.', + schema: z.object({ message: z.string() }), + }, + askForClarification: { + description: 'Ask one follow-up question before proceeding.', + schema: z.object({ question: z.string() }), + }, + escalate: { + description: 'Escalate to a human specialist.', + schema: z.object({ team: z.string() }), + }, + }, + onDone: ({ result }) => ({ + target: 'done', + context: { + action: result.choice, + payload: result.data, + }, + }), + }), + done: { + type: 'final', + output: ({ context }) => ({ + action: context.action, + payload: context.payload, + }), + }, + }, + }); +} + +async function main() { + try { + const request = await prompt('Support request'); + const machine = createDecideExample(); + + console.log(formatResult(await machine.execute(machine.getInitialState({ request })))); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/email.ts b/examples/email.ts new file mode 100644 index 0000000..1f33eb3 --- /dev/null +++ b/examples/email.ts @@ -0,0 +1,250 @@ +import { z } from 'zod'; +import { createAgentMachine, decide, type AgentAdapter } from '../src/index.js'; +import { + closePrompt, + createOpenAiDecisionAdapter, + formatResult, + generateExampleObject, + generateExampleText, + isMain, + prompt, +} from './_run.js'; + +const draftSchema = z.object({ + replyEmail: z.string(), +}); + +type EmailTools = { + lookupContactName: (email: string) => Promise; + lookupAvailability: () => Promise; + createSignature: (name: string) => Promise; +}; + +export function createEmailExample( + options: { + adapter?: AgentAdapter; + tools?: Partial; + compose?: ( + input: { + email: string; + instructions: string; + clarifications: string[]; + contactName: string; + availability: string[]; + signature: string; + } + ) => Promise>; + } = {} +) { + const adapter = + options.adapter ?? + (process.env.OPENAI_API_KEY ? createOpenAiDecisionAdapter() : undefined); + const tools: EmailTools = { + lookupContactName: + options.tools?.lookupContactName ?? + (async (email) => { + const result = await generateExampleObject({ + schema: z.object({ name: z.string() }), + system: 'Infer a plausible recipient/contact name from an email thread when possible.', + prompt: `Infer the recipient or contact name from this email. If unclear, return a reasonable professional placeholder.\n\n${email}`, + }); + + return result.name; + }), + lookupAvailability: + options.tools?.lookupAvailability ?? + (async () => { + const result = await generateExampleObject({ + schema: z.object({ + availability: z.array(z.string()).min(2).max(3), + }), + system: 'Produce plausible professional meeting slots.', + prompt: + 'Return 2 or 3 plausible meeting times for next week, written in a concise natural style.', + }); + + return result.availability; + }), + createSignature: + options.tools?.createSignature ?? + (async (name) => + generateExampleText({ + system: 'Write a concise professional email signature.', + prompt: `Write a short professional sign-off for the sender named ${name}.`, + })), + }; + const compose = + options.compose ?? + (({ + email, + instructions, + clarifications, + contactName, + availability, + signature, + }) => + generateExampleObject({ + schema: draftSchema, + system: 'You write concise professional email replies.', + prompt: [ + `Incoming email:\n${email}`, + '', + `Instructions:\n${instructions}`, + '', + `Contact name: ${contactName}`, + `Availability: ${availability.join(' | ')}`, + `Signature:\n${signature}`, + clarifications.length + ? `Clarifications:\n${clarifications.map((item) => `- ${item}`).join('\n')}` + : 'Clarifications: none', + '', + 'Draft the reply email.', + ].join('\n'), + })); + + return createAgentMachine({ + id: 'email-example', + schemas: { + input: z.object({ + email: z.string(), + instructions: z.string(), + }), + events: { + 'user.answer': z.object({ answer: z.string() }), + }, + }, + context: (input) => ({ + email: input.email, + instructions: input.instructions, + clarifications: [] as string[], + questions: [] as string[], + replyEmail: null as string | null, + }), + adapter, + initial: 'checking', + states: { + checking: decide({ + model: 'openai/gpt-5.4-nano', + prompt: ({ context }) => { // why is this Record instead of a specific type? + const emailContext = context as { + email: string; + instructions: string; + clarifications: string[]; + }; + + return [ + 'Decide whether there is enough information to draft the reply email.', + 'Choose askForClarification only if key scheduling or identity details are missing.', + '', + `Email: ${emailContext.email}`, + `Instructions: ${emailContext.instructions}`, + `Clarifications: ${emailContext.clarifications.join(' | ') || 'none'}`, + ].join('\n'); + }, + options: { + askForClarification: { + description: 'Ask one or more clarifying questions before drafting.', + schema: z.object({ + questions: z.array(z.string()).min(1), + }), + }, + draft: { + description: 'Draft the email reply now.', + }, + }, + onDone: ({ result, context }) => { + const emailContext = context as { clarifications: string[] }; + + return ({ + target: + result.choice === 'askForClarification' && + emailContext.clarifications.length === 0 + ? 'clarifying' + : 'drafting', + context: + result.choice === 'askForClarification' && + emailContext.clarifications.length === 0 + ? { questions: result.data.questions } + : { questions: [] }, + }); + }, + }), + clarifying: { + on: { + 'user.answer': ({ event, context }) => ({ + target: 'checking', + context: { + clarifications: [...context.clarifications, event.answer], + questions: [], + }, + }), + }, + }, + drafting: { + resultSchema: draftSchema, + invoke: async ({ context }) => { + const contactName = await tools.lookupContactName(context.email); + const availability = await tools.lookupAvailability(); + const signature = await tools.createSignature(contactName); + + return compose({ + email: context.email, + instructions: context.instructions, + clarifications: context.clarifications, + contactName, + availability, + signature, + }); + }, + onDone: ({ result }) => ({ + target: 'done', + context: { replyEmail: result.replyEmail }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + replyEmail: context.replyEmail, + clarifications: context.clarifications, + }), + }, + }, + }); +} + +async function main() { + try { + const email = await prompt('Incoming email'); + const instructions = await prompt('Instructions'); + const machine = createEmailExample(); + let state = machine.getInitialState({ email, instructions }); + + while (true) { + const result = await machine.execute(state); + + if (result.status === 'done') { + console.log(formatResult(result)); + break; + } + + if (result.status !== 'pending') { + throw new Error('Email example entered an unexpected error state.'); + } + + if (result.value === 'clarifying') { + console.log(result.context.questions.join('\n')); + const answer = await prompt('Clarification'); + state = machine.transition(result.state, { type: 'user.answer', answer }); + continue; + } + + state = result.state; + } + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/hitl.ts b/examples/hitl.ts new file mode 100644 index 0000000..31cd220 --- /dev/null +++ b/examples/hitl.ts @@ -0,0 +1,121 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const draftSchema = z.object({ + draft: z.string(), +}); + +export function createHitlExample( + draftReply: (args: { + task: string; + notes: string[]; + }) => Promise> = async ({ task, notes }) => { + return generateExampleObject({ + schema: draftSchema, + prompt: [ + `Task: ${task}`, + '', + 'Use the notes below to draft a concise response:', + ...notes.map((note, index) => `${index + 1}. ${note}`), + ].join('\n'), + }); + } +) { + return createAgentMachine({ + id: 'hitl-example', + schemas: { + input: z.object({ task: z.string() }), + events: { + 'user.message': z.object({ message: z.string() }), + 'user.approve': z.object({}), + 'user.cancel': z.object({}), + }, + }, + context: (input) => ({ + task: input.task, + notes: [] as string[], + draft: null as string | null, + }), + initial: 'gathering', + states: { + gathering: { + on: { + 'user.message': ({ context, event }) => ({ + context: { + notes: context.notes.concat(event.message), + }, + }), + 'user.approve': { target: 'drafting' }, + 'user.cancel': { target: 'cancelled' }, + }, + }, + drafting: { + resultSchema: draftSchema, + invoke: async ({ context }) => + draftReply({ + task: context.task, + notes: context.notes, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { draft: result.draft }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ draft: context.draft }), + }, + cancelled: { + type: 'final', + output: () => ({ cancelled: true }), + }, + }, + }); +} + +async function main() { + try { + const task = await prompt('Task'); + const machine = createHitlExample(); + let state = await machine.invoke(machine.getInitialState({ task })); + + while (state.status === 'pending') { + const message = await prompt('Add note, or type /approve or /cancel'); + + if (message === '/approve') { + state = machine.transition(state, { type: 'user.approve' }); + break; + } + + if (message === '/cancel') { + state = machine.transition(state, { type: 'user.cancel' }); + break; + } + + state = machine.transition(state, { + type: 'user.message', + message, + }); + console.log({ + status: state.status, + value: state.value, + context: state.context, + }); + } + + console.log(formatResult(await machine.execute(state))); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/index.ts b/examples/index.ts new file mode 100644 index 0000000..3917d73 --- /dev/null +++ b/examples/index.ts @@ -0,0 +1,21 @@ +export { createSimpleExample } from './simple.js'; +export { createHitlExample } from './hitl.js'; +export { createDecideExample } from './decide.js'; +export { createClassifyExample } from './classify.js'; +export { createAdapterExample } from './adapter.js'; +export { createChatbotExample } from './chatbot.js'; +export { createCustomerServiceSimExample } from './customer-service-sim.js'; +export { createEmailExample } from './email.js'; +export { createJokeExample } from './joke.js'; +export { createJugsExample } from './jugs.js'; +export { createMapReduceExample } from './map-reduce.js'; +export { createNewspaperExample } from './newspaper.js'; +export { createPlanAndExecuteExample } from './plan-and-execute.js'; +export { createRaffleExample } from './raffle.js'; +export { createReactAgentExample } from './react-agent.js'; +export { createReflectionExample } from './reflection.js'; +export { createRiverCrossingExample } from './river-crossing.js'; +export { createBranchingExample } from './branching.js'; +export { createSubflowExample } from './subflow.js'; +export { createToolCallingExample } from './tool-calling.js'; +export { createTutorExample } from './tutor.js'; diff --git a/examples/joke.ts b/examples/joke.ts new file mode 100644 index 0000000..413f864 --- /dev/null +++ b/examples/joke.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const jokeSchema = z.object({ + joke: z.string(), +}); + +const ratingSchema = z.object({ + rating: z.number().min(1).max(10), + explanation: z.string(), +}); + +export function createJokeExample( + options: { + tellJoke?: (topic: string) => Promise>; + rateJoke?: ( + topic: string, + joke: string + ) => Promise>; + } = {} +) { + const tellJoke = + options.tellJoke ?? + ((topic: string) => + generateExampleObject({ + schema: jokeSchema, + system: 'You write short, clean jokes.', + prompt: `Write one short joke about ${topic}.`, + })); + const rateJoke = + options.rateJoke ?? + ((topic: string, joke: string) => + generateExampleObject({ + schema: ratingSchema, + system: 'You are a joke critic. Be fair and concise.', + prompt: [ + `Topic: ${topic}`, + `Joke: ${joke}`, + '', + 'Rate the joke from 1 to 10 and explain briefly.', + ].join('\n'), + })); + + return createAgentMachine({ + id: 'joke-example', + schemas: { + input: z.object({ topic: z.string() }), + }, + context: (input) => ({ + topic: input.topic, + joke: null as string | null, + rating: null as number | null, + explanation: null as string | null, + accepted: false, + }), + initial: 'telling', + states: { + telling: { + resultSchema: jokeSchema, + invoke: async ({ context }) => tellJoke(context.topic), + onDone: ({ result }) => ({ + target: 'rating', + context: { joke: result.joke }, + }), + }, + rating: { + resultSchema: ratingSchema, + invoke: async ({ context }) => rateJoke(context.topic, context.joke ?? ''), + onDone: ({ result }) => ({ + target: 'done', + context: { + rating: result.rating, + explanation: result.explanation, + accepted: result.rating >= 7, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + topic: context.topic, + joke: context.joke, + rating: context.rating, + explanation: context.explanation, + accepted: context.accepted, + }), + }, + }, + }); +} + +async function main() { + try { + const topic = await prompt('Joke topic'); + const machine = createJokeExample(); + console.log(formatResult(await machine.execute(machine.getInitialState({ topic })))); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/jugs.ts b/examples/jugs.ts new file mode 100644 index 0000000..53fcf8f --- /dev/null +++ b/examples/jugs.ts @@ -0,0 +1,127 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { formatResult, isMain } from './_run.js'; + +const moveSchema = z.object({ + move: z + .enum(['fill5', 'pour5to3', 'empty3', 'done']) + .describe('The next move in the water jug puzzle'), + reasoning: z.string(), +}); + +const applySchema = z.object({ + jug3: z.number().int(), + jug5: z.number().int(), + step: z.string(), +}); + +function chooseWaterJugMove(jug3: number, jug5: number): z.infer { + const key = `${jug3},${jug5}`; + const plan: Record> = { + '0,0': { move: 'fill5', reasoning: 'Start by filling the larger jug.' }, + '0,5': { move: 'pour5to3', reasoning: 'Transfer water into the 3-gallon jug.' }, + '3,2': { move: 'empty3', reasoning: 'Empty the smaller jug to make room.' }, + '0,2': { move: 'pour5to3', reasoning: 'Move the remaining water into the 3-gallon jug.' }, + '2,0': { move: 'fill5', reasoning: 'Refill the 5-gallon jug.' }, + '2,5': { move: 'pour5to3', reasoning: 'Top off the 3-gallon jug to leave 4 gallons.' }, + '3,4': { move: 'done', reasoning: 'The 5-gallon jug now holds exactly 4 gallons.' }, + }; + + return plan[key] ?? { move: 'done', reasoning: 'No further move required.' }; +} + +function applyWaterJugMove( + jug3: number, + jug5: number, + move: z.infer['move'] +): z.infer { + switch (move) { + case 'fill5': + return { jug3, jug5: 5, step: 'Filled the 5-gallon jug.' }; + case 'pour5to3': { + const transfer = Math.min(3 - jug3, jug5); + return { + jug3: jug3 + transfer, + jug5: jug5 - transfer, + step: 'Poured from the 5-gallon jug into the 3-gallon jug.', + }; + } + case 'empty3': + return { jug3: 0, jug5, step: 'Emptied the 3-gallon jug.' }; + default: + return { jug3, jug5, step: 'Solved the puzzle.' }; + } +} + +export function createJugsExample() { + return createAgentMachine({ + id: 'jugs-example', + context: () => ({ + jug3: 0, + jug5: 0, + steps: [] as string[], + reasoning: [] as string[], + }), + initial: 'choosing', + states: { + choosing: { + resultSchema: moveSchema, + invoke: async ({ context }) => chooseWaterJugMove(context.jug3, context.jug5), + onDone: ({ result, context }) => { + const nextReasoning = [...context.reasoning, result.reasoning]; + + if (result.move === 'done') { + return { + target: 'done' as const, + context: { reasoning: nextReasoning }, + }; + } + + return { + target: 'applying' as const, + params: { move: result.move }, + context: { reasoning: nextReasoning }, + }; + }, + }, + applying: { + paramsSchema: z.object({ + move: moveSchema.shape.move.exclude(['done']), + }), + resultSchema: applySchema, + invoke: async ({ context, params }) => + applyWaterJugMove( + context.jug3, + context.jug5, + params.move as 'fill5' | 'pour5to3' | 'empty3' + ), + onDone: ({ result, context }) => ({ + target: 'choosing', + context: { + jug3: result.jug3, + jug5: result.jug5, + steps: [...context.steps, result.step], + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + jug3: context.jug3, + jug5: context.jug5, + steps: context.steps, + reasoning: context.reasoning, + }), + }, + }, + }); +} + +async function main() { + const machine = createJugsExample(); + console.log(formatResult(await machine.execute(machine.getInitialState()))); +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/map-reduce.ts b/examples/map-reduce.ts new file mode 100644 index 0000000..ab881fd --- /dev/null +++ b/examples/map-reduce.ts @@ -0,0 +1,120 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + generateExampleText, + isMain, + prompt, +} from './_run.js'; + +const subjectsSchema = z.object({ + subjects: z.array(z.string()), +}); + +const jokesSchema = z.object({ + jokes: z.array(z.string()), +}); + +const bestJokeSchema = z.object({ + bestJoke: z.string(), +}); + +export function createMapReduceExample( + options: { + planSubjects?: (topic: string) => Promise>; + writeJoke?: (subject: string) => Promise; + chooseBest?: (jokes: string[]) => Promise>; + } = {} +) { + return createAgentMachine({ + id: 'map-reduce-example', + schemas: { + input: z.object({ topic: z.string() }), + }, + context: (input) => ({ + topic: input.topic, + subjects: [] as string[], + jokes: [] as string[], + bestJoke: null as string | null, + }), + initial: 'planning', + states: { + planning: { + resultSchema: subjectsSchema, + invoke: async ({ context }) => + (options.planSubjects + ?? ((topic) => + generateExampleObject({ + schema: subjectsSchema, + system: 'You break a topic into a few concrete subtopics.', + prompt: `List 2 to 4 specific subtopics worth covering for: ${topic}`, + })))(context.topic), + onDone: ({ result }) => ({ + target: 'mapping', + context: { subjects: result.subjects }, + }), + }, + mapping: { + resultSchema: jokesSchema, + invoke: async ({ context }) => { + const jokes = await Promise.all( + context.subjects.map((subject) => + (options.writeJoke + ?? ((value) => + generateExampleText({ + system: 'You write one-line jokes.', + prompt: `Write one short joke about ${value}.`, + })))(subject) + ) + ); + + return { jokes }; + }, + onDone: ({ result }) => ({ + target: 'reducing', + context: { jokes: result.jokes }, + }), + }, + reducing: { + resultSchema: bestJokeSchema, + invoke: async ({ context }) => + (options.chooseBest + ?? ((jokes) => + generateExampleObject({ + schema: bestJokeSchema, + system: 'You pick the strongest joke from a list.', + prompt: ['Choose the best joke from this list:', ...jokes].join('\n'), + })))(context.jokes), + onDone: ({ result }) => ({ + target: 'done', + context: { bestJoke: result.bestJoke }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + subjects: context.subjects, + jokes: context.jokes, + bestJoke: context.bestJoke, + }), + }, + }, + }); +} + +async function main() { + try { + const topic = await prompt('Topic'); + const machine = createMapReduceExample(); + const result = await machine.execute(machine.getInitialState({ topic })); + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/newspaper.ts b/examples/newspaper.ts new file mode 100644 index 0000000..b312d1b --- /dev/null +++ b/examples/newspaper.ts @@ -0,0 +1,180 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const searchSchema = z.object({ + searchResults: z.array(z.string()), +}); + +const articleSchema = z.object({ + article: z.string(), +}); + +const critiqueSchema = z.object({ + critique: z.string().nullable(), +}); + +export function createNewspaperExample( + options: { + search?: (topic: string) => Promise>; + curate?: (topic: string, searchResults: string[]) => Promise>; + write?: (topic: string, searchResults: string[]) => Promise>; + critique?: (article: string, revisionCount: number) => Promise>; + revise?: (article: string, critique: string) => Promise>; + maxRevisions?: number; + } = {} +) { + const search = + options.search ?? + ((topic: string) => + generateExampleObject({ + schema: searchSchema, + system: 'You brainstorm plausible research leads for an article topic.', + prompt: `List 3 to 5 concise research leads or search angles for an article about ${topic}.`, + })); + const curate = + options.curate ?? + ((topic: string, searchResults: string[]) => + generateExampleObject({ + schema: searchSchema, + system: 'You curate research inputs for a focused article.', + prompt: [ + `Topic: ${topic}`, + 'Choose the best 2 or 3 research leads from the list below.', + ...searchResults.map((result) => `- ${result}`), + ].join('\n'), + })); + const write = + options.write ?? + ((topic: string, searchResults: string[]) => + generateExampleObject({ + schema: articleSchema, + system: 'You write short newspaper-style drafts in Markdown.', + prompt: [ + `Topic: ${topic}`, + 'Write a short article draft using these research leads:', + ...searchResults.map((result) => `- ${result}`), + ].join('\n'), + })); + const critique = + options.critique ?? + ((article: string, revisionCount: number) => + generateExampleObject({ + schema: critiqueSchema, + system: 'You critique article drafts. Return null when no further revision is needed.', + prompt: [ + `Revision count: ${revisionCount}`, + 'Review this article draft and either return one concise critique or null if it is ready.', + '', + article, + ].join('\n'), + })); + const revise = + options.revise ?? + ((article: string, notes: string) => + generateExampleObject({ + schema: articleSchema, + system: 'You revise article drafts while preserving the main facts.', + prompt: [ + 'Revise the article to address this critique:', + notes, + '', + article, + ].join('\n'), + })); + + return createAgentMachine({ + id: 'newspaper-example', + schemas: { + input: z.object({ topic: z.string() }), + }, + context: (input) => ({ + topic: input.topic, + searchResults: [] as string[], + article: null as string | null, + critique: null as string | null, + revisionCount: 0, + maxRevisions: options.maxRevisions ?? 2, + }), + initial: 'searching', + states: { + searching: { + resultSchema: searchSchema, + invoke: async ({ context }) => search(context.topic), + onDone: ({ result }) => ({ + target: 'curating', + context: { searchResults: result.searchResults }, + }), + }, + curating: { + resultSchema: searchSchema, + invoke: async ({ context }) => curate(context.topic, context.searchResults), + onDone: ({ result }) => ({ + target: 'writing', + context: { searchResults: result.searchResults }, + }), + }, + writing: { + resultSchema: articleSchema, + invoke: async ({ context }) => write(context.topic, context.searchResults), + onDone: ({ result }) => ({ + target: 'critiquing', + context: { article: result.article }, + }), + }, + critiquing: { + resultSchema: critiqueSchema, + invoke: async ({ context }) => + critique(context.article ?? '', context.revisionCount), + onDone: ({ result, context }) => ({ + target: + !result.critique || context.revisionCount >= context.maxRevisions + ? 'done' + : 'revising', + context: { critique: result.critique }, + }), + }, + revising: { + resultSchema: articleSchema, + invoke: async ({ context }) => + revise(context.article ?? '', context.critique ?? ''), + onDone: ({ result, context }) => ({ + target: 'critiquing', + context: { + article: result.article, + revisionCount: context.revisionCount + 1, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + topic: context.topic, + article: context.article, + revisionCount: context.revisionCount, + searchResults: context.searchResults, + }), + }, + }, + }); +} + +async function main() { + try { + const topic = await prompt('Newspaper topic'); + const machine = createNewspaperExample(); + console.log(formatResult(await machine.execute(machine.getInitialState({ topic })))); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/plan-and-execute.ts b/examples/plan-and-execute.ts new file mode 100644 index 0000000..658d673 --- /dev/null +++ b/examples/plan-and-execute.ts @@ -0,0 +1,170 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const planSchema = z.object({ + plan: z.array(z.string()).min(1).max(5), +}); + +const stepResultSchema = z.object({ + result: z.string(), +}); + +const finalAnswerSchema = z.object({ + answer: z.string(), +}); + +export function createPlanAndExecuteExample( + options: { + plan?: (goal: string) => Promise>; + executeStep?: (args: { + goal: string; + step: string; + priorResults: string[]; + }) => Promise>; + synthesize?: (args: { + goal: string; + plan: string[]; + stepResults: string[]; + }) => Promise>; + } = {} +) { + const planner = + options.plan ?? + ((goal: string) => + generateExampleObject({ + schema: planSchema, + system: 'You are a planner. Break goals into a short actionable sequence.', + prompt: `Create a short plan with 2 to 5 steps for this goal:\n\n${goal}`, + })); + const executeStep = + options.executeStep ?? + ((args: { goal: string; step: string; priorResults: string[] }) => + generateExampleObject({ + schema: stepResultSchema, + system: 'You execute one plan step at a time and report the result concisely.', + prompt: [ + `Goal: ${args.goal}`, + `Current step: ${args.step}`, + args.priorResults.length + ? `Prior results:\n${args.priorResults.map((result, index) => `${index + 1}. ${result}`).join('\n')}` + : 'Prior results: none', + '', + 'Execute the current step conceptually and return a concise result.', + ].join('\n'), + })); + const synthesize = + options.synthesize ?? + ((args: { goal: string; plan: string[]; stepResults: string[] }) => + generateExampleObject({ + schema: finalAnswerSchema, + system: 'You synthesize completed plan results into a final answer.', + prompt: [ + `Goal: ${args.goal}`, + '', + 'Plan:', + ...args.plan.map((step, index) => `${index + 1}. ${step}`), + '', + 'Step results:', + ...args.stepResults.map((result, index) => `${index + 1}. ${result}`), + '', + 'Write the final answer.', + ].join('\n'), + })); + + return createAgentMachine({ + id: 'plan-and-execute-example', + schemas: { + input: z.object({ goal: z.string() }), + }, + context: (input) => ({ + goal: input.goal, + plan: [] as string[], + stepResults: [] as string[], + answer: null as string | null, + }), + initial: 'planning', + states: { + planning: { + resultSchema: planSchema, + invoke: async ({ context }) => planner(context.goal), + onDone: ({ result }) => ({ + target: 'executing', + context: { plan: result.plan }, + params: { index: 0 } + }), + }, + executing: { + paramsSchema: z.object({ + index: z.number().int().min(0), + }), + resultSchema: stepResultSchema, + invoke: async ({ context, params }) => + executeStep({ + goal: context.goal, + step: context.plan[params.index] ?? '', + priorResults: context.stepResults, + }), + onDone: ({ result, context }) => { + const nextStepResults = [...context.stepResults, result.result]; + const nextIndex = nextStepResults.length; + + if (nextIndex < context.plan.length) { + return { + target: 'executing' as const, + context: { stepResults: nextStepResults }, + params: { index: nextIndex }, + }; + } + + return { + target: 'synthesizing' as const, + context: { stepResults: nextStepResults }, + }; + }, + }, + synthesizing: { + resultSchema: finalAnswerSchema, + invoke: async ({ context }) => + synthesize({ + goal: context.goal, + plan: context.plan, + stepResults: context.stepResults, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { answer: result.answer }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + goal: context.goal, + plan: context.plan, + stepResults: context.stepResults, + answer: context.answer, + }), + }, + }, + }); +} + +async function main() { + try { + const goal = await prompt('Goal'); + const machine = createPlanAndExecuteExample(); + console.log(formatResult(await machine.execute(machine.getInitialState({ goal })))); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/raffle.ts b/examples/raffle.ts new file mode 100644 index 0000000..4714e98 --- /dev/null +++ b/examples/raffle.ts @@ -0,0 +1,116 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const winnerSchema = z.object({ + winningEntry: z.string(), + firstRunnerUp: z.string(), + secondRunnerUp: z.string(), + explanation: z.string(), +}); + +export function createRaffleExample( + pickWinner: (entries: string[]) => Promise> = async ( + entries + ) => + generateExampleObject({ + schema: winnerSchema, + system: 'You are conducting a transparent demo raffle draw.', + prompt: [ + 'Choose one winner and two runners-up from the entries below.', + 'Do not invent names. Explain your selection briefly.', + ...entries.map((entry, index) => `${index + 1}. ${entry}`), + ].join('\n'), + }) +) { + return createAgentMachine({ + id: 'raffle-example', + schemas: { + events: { + 'user.entry': z.object({ entry: z.string() }), + 'user.draw': z.object({}), + }, + }, + context: () => ({ + entries: [] as string[], + winner: null as string | null, + firstRunnerUp: null as string | null, + secondRunnerUp: null as string | null, + explanation: null as string | null, + }), + initial: 'collecting', + states: { + collecting: { + on: { + 'user.entry': ({ event, context }) => ({ + context: { entries: [...context.entries, event.entry] }, + }), + 'user.draw': ({ context }) => ({ + target: context.entries.length >= 3 ? 'drawing' : 'collecting', + }), + }, + }, + drawing: { + resultSchema: winnerSchema, + invoke: async ({ context }) => pickWinner(context.entries), + onDone: ({ result }) => ({ + target: 'done', + context: { + winner: result.winningEntry, + firstRunnerUp: result.firstRunnerUp, + secondRunnerUp: result.secondRunnerUp, + explanation: result.explanation, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + entries: context.entries, + winner: context.winner, + firstRunnerUp: context.firstRunnerUp, + secondRunnerUp: context.secondRunnerUp, + explanation: context.explanation, + }), + }, + }, + }); +} + +async function main() { + try { + const machine = createRaffleExample(); + let state = machine.getInitialState(); + + while (true) { + const result = await machine.execute(state); + + if (result.status === 'done') { + console.log(formatResult(result)); + break; + } + + if (result.status !== 'pending') { + throw new Error('Raffle example entered an unexpected error state.'); + } + + const entry = await prompt('Entry (blank to draw)'); + state = machine.transition( + result.state, + entry ? { type: 'user.entry', entry } : { type: 'user.draw' } + ); + } + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/react-agent.ts b/examples/react-agent.ts new file mode 100644 index 0000000..ecdc3d1 --- /dev/null +++ b/examples/react-agent.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; +import { + createMemoryRunStore, + createReactAgent, + startSession, +} from '../src/index.js'; +import { + closePrompt, + generateExampleObject, + generateExampleText, + isMain, + prompt, +} from './_run.js'; + +const reactModelResultSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('tool'), + toolName: z.literal('search'), + input: z.object({ + query: z.string(), + }), + message: z.string().optional(), + }), + z.object({ + kind: z.literal('final'), + message: z.string(), + }), +]); + +export function createReactAgentExample(options: { + search?: (query: string) => Promise; + model?: (args: { + messages: Array<{ + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string; + name?: string; + }>; + }) => Promise>; +} = {}) { + return createReactAgent({ + prompt: 'You are a helpful assistant.', + tools: [ + { + name: 'search', + description: 'Searches the knowledge base.', + execute: async (input) => + (options.search + ?? ((query) => + generateExampleText({ + system: 'You are a concise search backend returning a short factual result snippet.', + prompt: `Return a short search result snippet for the query: ${query}`, + })))(String(input.query)), + }, + ], + model: + options.model + ?? (({ messages }) => + generateExampleObject({ + schema: reactModelResultSchema, + system: [ + 'You are a ReAct-style assistant.', + 'If you still need outside information, call the search tool.', + 'If the latest tool result is enough, answer directly with kind="final".', + ].join('\n'), + prompt: messages + .map((message) => `${message.role.toUpperCase()}: ${message.content}`) + .join('\n'), + })), + }); +} + +async function main() { + try { + const message = await prompt('User'); + const agent = createReactAgentExample(); + const run = await startSession(agent, { + store: createMemoryRunStore(), + input: { + messages: [{ role: 'user', content: message }], + }, + }); + + run.on('toolCall', (event) => { + const call = event as { toolName: string; input: { query: string } }; + console.log(`Calling ${call.toolName}(${call.input.query})`); + }); + run.on('toolResult', (event) => { + const result = event as { + toolName: string; + output: unknown; + }; + console.log(`${result.toolName} -> ${String(result.output)}`); + }); + + await new Promise((resolve, reject) => { + run.on('done', (event) => { + console.log((event as { output: unknown }).output); + resolve(); + }); + run.on('error', (event) => { + reject((event as { error: unknown }).error); + }); + }); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/reflection.ts b/examples/reflection.ts new file mode 100644 index 0000000..86abe8e --- /dev/null +++ b/examples/reflection.ts @@ -0,0 +1,155 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const draftSchema = z.object({ + draft: z.string(), +}); + +const feedbackSchema = z.object({ + feedback: z.string().nullable(), +}); + +export function createReflectionExample( + options: { + draft?: (task: string) => Promise>; + reflect?: (args: { + task: string; + draft: string; + revisionCount: number; + }) => Promise>; + revise?: (args: { + task: string; + draft: string; + feedback: string; + }) => Promise>; + maxRevisions?: number; + } = {} +) { + const draft = + options.draft ?? + ((task: string) => + generateExampleObject({ + schema: draftSchema, + system: 'You write concise first drafts.', + prompt: `Write a short draft for this task:\n\n${task}`, + })); + const reflect = + options.reflect ?? + ((args: { task: string; draft: string; revisionCount: number }) => + generateExampleObject({ + schema: feedbackSchema, + system: 'You critique drafts and return null when no more revision is needed.', + prompt: [ + `Task: ${args.task}`, + `Revision count: ${args.revisionCount}`, + '', + 'Draft:', + args.draft, + '', + 'Return one concise revision note, or null if the draft is already good enough.', + ].join('\n'), + })); + const revise = + options.revise ?? + ((args: { task: string; draft: string; feedback: string }) => + generateExampleObject({ + schema: draftSchema, + system: 'You revise drafts to address the provided feedback.', + prompt: [ + `Task: ${args.task}`, + `Feedback: ${args.feedback}`, + '', + 'Current draft:', + args.draft, + '', + 'Revise the draft.', + ].join('\n'), + })); + + return createAgentMachine({ + id: 'reflection-example', + schemas: { + input: z.object({ task: z.string() }), + }, + context: (input) => ({ + task: input.task, + draft: null as string | null, + feedback: null as string | null, + revisionCount: 0, + maxRevisions: options.maxRevisions ?? 2, + }), + initial: 'drafting', + states: { + drafting: { + resultSchema: draftSchema, + invoke: async ({ context }) => draft(context.task), + onDone: ({ result }) => ({ + target: 'reflecting', + context: { draft: result.draft }, + }), + }, + reflecting: { + resultSchema: feedbackSchema, + invoke: async ({ context }) => + reflect({ + task: context.task, + draft: context.draft ?? '', + revisionCount: context.revisionCount, + }), + onDone: ({ result, context }) => ({ + target: + !result.feedback || context.revisionCount >= context.maxRevisions + ? 'done' + : 'revising', + context: { feedback: result.feedback }, + }), + }, + revising: { + resultSchema: draftSchema, + invoke: async ({ context }) => + revise({ + task: context.task, + draft: context.draft ?? '', + feedback: context.feedback ?? '', + }), + onDone: ({ result, context }) => ({ + target: 'reflecting', + context: { + draft: result.draft, + revisionCount: context.revisionCount + 1, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + task: context.task, + draft: context.draft, + feedback: context.feedback, + revisionCount: context.revisionCount, + }), + }, + }, + }); +} + +async function main() { + try { + const task = await prompt('Task'); + const machine = createReflectionExample(); + console.log(formatResult(await machine.execute(machine.getInitialState({ task })))); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/river-crossing.ts b/examples/river-crossing.ts new file mode 100644 index 0000000..dd738bb --- /dev/null +++ b/examples/river-crossing.ts @@ -0,0 +1,172 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { formatResult, isMain } from './_run.js'; + +const bankItem = z.enum(['wolf', 'goat', 'cabbage']); + +const crossingMoveSchema = z.object({ + move: z.enum(['takeGoat', 'takeWolf', 'takeCabbage', 'returnEmpty', 'done']), + reasoning: z.string(), +}); + +const crossingStateSchema = z.object({ + leftBank: z.array(bankItem), + rightBank: z.array(bankItem), + farmerPosition: z.enum(['left', 'right']), + step: z.string(), +}); + +function chooseCrossingMove( + leftBank: string[], + rightBank: string[], + farmerPosition: 'left' | 'right' +): z.infer { + const key = `${farmerPosition}|${leftBank.sort().join(',')}|${rightBank.sort().join(',')}`; + const plan: Record> = { + 'left|cabbage,goat,wolf|': { + move: 'takeGoat', + reasoning: 'Move the goat first so it is not left with the cabbage.', + }, + 'right|cabbage,wolf|goat': { + move: 'returnEmpty', + reasoning: 'Return alone to ferry another item.', + }, + 'left|cabbage,wolf|goat': { + move: 'takeWolf', + reasoning: 'Take the wolf across while the goat waits safely alone.', + }, + 'right|cabbage|goat,wolf': { + move: 'takeGoat', + reasoning: 'Bring the goat back so the wolf is not left with it.', + }, + 'left|cabbage,goat|wolf': { + move: 'takeCabbage', + reasoning: 'Take the cabbage across now that the goat is with you.', + }, + 'right|goat|cabbage,wolf': { + move: 'returnEmpty', + reasoning: 'Return alone to fetch the goat.', + }, + 'left|goat|cabbage,wolf': { + move: 'takeGoat', + reasoning: 'Bring the goat across to complete the crossing.', + }, + 'right||cabbage,goat,wolf': { + move: 'done', + reasoning: 'Everyone is safely across.', + }, + }; + + return plan[key] ?? { move: 'done', reasoning: 'No further move required.' }; +} + +function moveItem( + leftBank: Array<'wolf' | 'goat' | 'cabbage'>, + rightBank: Array<'wolf' | 'goat' | 'cabbage'>, + farmerPosition: 'left' | 'right', + move: z.infer['move'] +): z.infer { + const fromLeft = farmerPosition === 'left'; + + if (move === 'returnEmpty') { + return { + leftBank, + rightBank, + farmerPosition: fromLeft ? 'right' : 'left', + step: 'The farmer crossed the river alone.', + }; + } + + const item = move.replace(/^take/, '').toLowerCase() as 'wolf' | 'goat' | 'cabbage'; + return { + leftBank: fromLeft + ? leftBank.filter((value) => value !== item) + : [...leftBank, item].sort() as Array<'wolf' | 'goat' | 'cabbage'>, + rightBank: fromLeft + ? [...rightBank, item].sort() as Array<'wolf' | 'goat' | 'cabbage'> + : rightBank.filter((value) => value !== item), + farmerPosition: fromLeft ? 'right' : 'left', + step: `The farmer took the ${item} across the river.`, + }; +} + +export function createRiverCrossingExample() { + return createAgentMachine({ + id: 'river-crossing-example', + context: () => ({ + leftBank: ['wolf', 'goat', 'cabbage'] as Array<'wolf' | 'goat' | 'cabbage'>, + rightBank: [] as Array<'wolf' | 'goat' | 'cabbage'>, + farmerPosition: 'left' as 'left' | 'right', + steps: [] as string[], + reasoning: [] as string[], + }), + initial: 'choosing', + states: { + choosing: { + resultSchema: crossingMoveSchema, + invoke: async ({ context }) => + chooseCrossingMove( + [...context.leftBank], + [...context.rightBank], + context.farmerPosition + ), + onDone: ({ result, context }) => { + const nextReasoning = [...context.reasoning, result.reasoning]; + + if (result.move === 'done') { + return { + target: 'done' as const, + context: { reasoning: nextReasoning }, + }; + } + + return { + target: 'moving' as const, + params: { move: result.move }, + context: { reasoning: nextReasoning }, + }; + }, + }, + moving: { + paramsSchema: z.object({ + move: crossingMoveSchema.shape.move.exclude(['done']), + }), + resultSchema: crossingStateSchema, + invoke: async ({ context, params }) => + moveItem( + [...context.leftBank], + [...context.rightBank], + context.farmerPosition, + params.move as 'takeGoat' | 'takeWolf' | 'takeCabbage' | 'returnEmpty' + ), + onDone: ({ result, context }) => ({ + target: 'choosing', + context: { + leftBank: result.leftBank, + rightBank: result.rightBank, + farmerPosition: result.farmerPosition, + steps: [...context.steps, result.step], + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + leftBank: context.leftBank, + rightBank: context.rightBank, + steps: context.steps, + reasoning: context.reasoning, + }), + }, + }, + }); +} + +async function main() { + const machine = createRiverCrossingExample(); + console.log(formatResult(await machine.execute(machine.getInitialState()))); +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/simple.ts b/examples/simple.ts new file mode 100644 index 0000000..5ab67bb --- /dev/null +++ b/examples/simple.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const summarySchema = z.object({ + summary: z.string(), +}); + +export function createSimpleExample( + summarize: (text: string) => Promise> = async ( + text + ) => { + return generateExampleObject({ + schema: summarySchema, + prompt: `Summarize this text in one sentence:\n\n${text}`, + }); + } +) { + return createAgentMachine({ + id: 'simple-example', + schemas: { + input: z.object({ text: z.string() }), + }, + context: (input) => ({ + text: input.text, + summary: null as string | null, + }), + initial: 'summarizing', + states: { + summarizing: { + resultSchema: summarySchema, + invoke: async ({ context }) => summarize(context.text), + onDone: ({ result }) => ({ + target: 'done', + context: { summary: result.summary }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ summary: context.summary }), + }, + }, + }); +} + +async function main() { + try { + const text = await prompt('Text to summarize'); + const machine = createSimpleExample(); + const result = await machine.execute(machine.getInitialState({ text })); + + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/subflow.ts b/examples/subflow.ts new file mode 100644 index 0000000..6afd439 --- /dev/null +++ b/examples/subflow.ts @@ -0,0 +1,139 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const researchSchema = z.object({ + bullets: z.array(z.string()), +}); + +const draftSchema = z.object({ + draft: z.string(), +}); + +export function createSubflowExample( + options: { + research?: (topic: string) => Promise>; + write?: (input: { + topic: string; + bullets: string[]; + }) => Promise>; + } = {} +) { + const childMachine = createAgentMachine({ + id: 'subflow-child', + schemas: { + input: z.object({ topic: z.string() }), + }, + context: (input) => ({ + topic: input.topic, + bullets: [] as string[], + }), + initial: 'researching', + states: { + researching: { + resultSchema: researchSchema, + invoke: async ({ context }) => + (options.research + ?? ((topic) => + generateExampleObject({ + schema: researchSchema, + system: 'You research a topic and return concise bullet points.', + prompt: `Return 2 to 4 concise research bullets about ${topic}.`, + })))(context.topic), + onDone: ({ result }) => ({ + target: 'done', + context: { bullets: result.bullets }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ bullets: context.bullets }), + }, + }, + }); + + return createAgentMachine({ + id: 'subflow-example', + schemas: { + input: z.object({ topic: z.string() }), + }, + context: (input) => ({ + topic: input.topic, + bullets: [] as string[], + draft: null as string | null, + }), + initial: 'researching', + states: { + researching: { + resultSchema: researchSchema, + invoke: async ({ context }) => { + const result = await childMachine.execute( + childMachine.getInitialState({ topic: context.topic }) + ); + + if (result.status !== 'done') { + throw new Error('Child machine did not finish'); + } + + return { + bullets: (result.output as { bullets: string[] }).bullets, + }; + }, + onDone: ({ result }) => ({ + target: 'writing', + context: { bullets: result.bullets }, + }), + }, + writing: { + resultSchema: draftSchema, + invoke: async ({ context }) => + (options.write + ?? (({ topic, bullets }) => + generateExampleObject({ + schema: draftSchema, + system: 'You turn research bullets into a short coherent draft.', + prompt: [ + `Topic: ${topic}`, + 'Use these bullets to write a short draft:', + ...bullets.map((bullet) => `- ${bullet}`), + ].join('\n'), + })))({ + topic: context.topic, + bullets: context.bullets, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { draft: result.draft }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + bullets: context.bullets, + draft: context.draft, + }), + }, + }, + }); +} + +async function main() { + try { + const topic = await prompt('Topic'); + const machine = createSubflowExample(); + const result = await machine.execute(machine.getInitialState({ topic })); + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/tool-calling.ts b/examples/tool-calling.ts new file mode 100644 index 0000000..cae6c45 --- /dev/null +++ b/examples/tool-calling.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from '../src/index.js'; +import { + closePrompt, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const forecastSchema = z.object({ + forecast: z.string(), +}); + +export function createToolCallingExample( + getWeather: (city: string) => Promise> = async ( + city + ) => + generateExampleObject({ + schema: forecastSchema, + system: 'You generate plausible demo weather forecasts.', + prompt: `Return a short weather forecast for ${city}.`, + }) +) { + return createAgentMachine({ + id: 'tool-calling-example', + schemas: { + input: z.object({ city: z.string() }), + emitted: { + toolCall: z.object({ + toolName: z.string(), + input: z.object({ city: z.string() }), + }), + toolResult: z.object({ + toolName: z.string(), + output: forecastSchema, + }), + }, + }, + context: (input) => ({ + city: input.city, + forecast: null as string | null, + }), + initial: 'checkingWeather', + states: { + checkingWeather: { + resultSchema: forecastSchema, + invoke: async ({ context }, enq) => { + enq.emit({ + type: 'toolCall', + toolName: 'getWeather', + input: { city: context.city }, + }); + + const output = await getWeather(context.city); + + enq.emit({ + type: 'toolResult', + toolName: 'getWeather', + output, + }); + + return output; + }, + onDone: ({ result }) => ({ + target: 'done', + context: { forecast: result.forecast }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ forecast: context.forecast }), + }, + }, + }); +} + +async function main() { + try { + const city = await prompt('City'); + const machine = createToolCallingExample(); + const run = await startSession(machine, { + store: createMemoryRunStore(), + input: { city }, + }); + + run.on('toolCall', (event) => { + const tool = event as { toolName: string; input: { city: string } }; + console.log(`Calling ${tool.toolName}(${tool.input.city})`); + }); + + run.on('toolResult', (event) => { + const result = event as { + toolName: string; + output: { forecast: string }; + }; + console.log(`${result.toolName} -> ${result.output.forecast}`); + }); + + await new Promise((resolve, reject) => { + run.on('done', (event) => { + console.log((event as { output: unknown }).output); + resolve(); + }); + run.on('error', (event) => { + reject((event as { error: unknown }).error); + }); + }); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/tutor.ts b/examples/tutor.ts new file mode 100644 index 0000000..7b12af8 --- /dev/null +++ b/examples/tutor.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const feedbackSchema = z.object({ + instruction: z.string(), +}); + +const responseSchema = z.object({ + response: z.string(), +}); + +export function createTutorExample( + options: { + teach?: (message: string) => Promise>; + respond?: (message: string) => Promise>; + } = {} +) { + const teach = + options.teach ?? + ((message: string) => + generateExampleObject({ + schema: feedbackSchema, + system: 'You are a Spanish tutor giving concise corrective feedback in English.', + prompt: `Give one short piece of coaching feedback for this learner message: ${message}`, + })); + const respond = + options.respond ?? + ((message: string) => + generateExampleObject({ + schema: responseSchema, + system: 'You are a friendly Spanish tutor. Reply in simple Spanish.', + prompt: `Respond to this learner message in simple Spanish and keep the conversation going: ${message}`, + })); + + return createAgentMachine({ + id: 'tutor-example', + schemas: { + input: z.object({ message: z.string() }), + }, + context: (input) => ({ + conversation: [`User: ${input.message}`], + feedback: null as string | null, + response: null as string | null, + }), + initial: 'teaching', + states: { + teaching: { + resultSchema: feedbackSchema, + invoke: async ({ context }) => + teach(context.conversation.at(-1)?.replace(/^User:\s*/, '') ?? ''), + onDone: ({ result }) => ({ + target: 'responding', + context: { feedback: result.instruction }, + }), + }, + responding: { + resultSchema: responseSchema, + invoke: async ({ context }) => + respond(context.conversation.at(-1)?.replace(/^User:\s*/, '') ?? ''), + onDone: ({ result, context }) => ({ + target: 'done', + context: { + response: result.response, + conversation: [...context.conversation, `Tutor: ${result.response}`], + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + conversation: context.conversation, + feedback: context.feedback, + response: context.response, + }), + }, + }, + }); +} + +async function main() { + try { + const message = await prompt('Say something in Spanish'); + const machine = createTutorExample(); + console.log(formatResult(await machine.execute(machine.getInitialState({ message })))); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples_old/executor.ts b/examples_old/executor.ts index b56e36c..bf56fbe 100644 --- a/examples_old/executor.ts +++ b/examples_old/executor.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { fromTerminal } from './helpers/helpers'; const agent = createAgent({ - model: openai('gpt-4o-mini'), + model: openai('gpt-5.4-nano'), events: { getTime: z.object({}).describe('Get the current time'), other: z.object({}).describe('Do something else'), diff --git a/examples_old/multi.ts b/examples_old/multi.ts index b82971b..1b5b74e 100644 --- a/examples_old/multi.ts +++ b/examples_old/multi.ts @@ -6,7 +6,7 @@ import { openai } from '@ai-sdk/openai'; const agent = createAgent({ name: 'multi', - model: openai('gpt-4o-mini'), + model: openai('gpt-5.4-nano'), events: { 'agent.respond': z.object({ response: z.string().describe('The response from the agent'), diff --git a/package.json b/package.json index a4b1c0e..88cb9a5 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@types/node": "^20.16.10", "dotenv": "^16.4.5", "tsdown": "^0.21.7", + "tsx": "^4.21.0", "typescript": "^5.6.2", "vitest": "^2.1.2", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 678ed1c..397027b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: tsdown: specifier: ^0.21.7 version: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.6.2 version: 5.9.3 @@ -168,138 +171,294 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==, tarball: https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz} engines: {node: '>=18'} @@ -777,6 +936,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz} + engines: {node: '>=18'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, tarball: https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz} engines: {node: '>=4'} @@ -1213,6 +1377,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==, tarball: https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz} + engines: {node: '>=18.0.0'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz} engines: {node: '>=14.17'} @@ -1549,72 +1718,150 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@inquirer/external-editor@1.0.3(@types/node@20.19.30)': dependencies: chardet: 2.1.1 @@ -1986,6 +2233,35 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + esprima@4.0.1: {} estree-walker@3.0.3: @@ -2377,6 +2653,13 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + typescript@5.9.3: {} unconfig-core@7.5.0: diff --git a/readme.md b/readme.md index dbca6f1..59970fe 100644 --- a/readme.md +++ b/readme.md @@ -7,4 +7,20 @@ Stately Agent is a flexible framework for building AI agents using state machine - Enabling custom **planning** abilities for agents to achieve specific goals based on state machine logic, observations, and feedback - First-class integration with the [Vercel AI SDK](https://sdk.vercel.ai/) to easily support multiple model providers, such as OpenAI, Anthropic, Google, Mistral, Groq, Perplexity, and more +## Examples + +The examples in [`examples/`](/Users/davidkpiano/Code/agent/examples) are intentionally small, run in the CLI, and use real OpenAI calls when `OPENAI_API_KEY` is set. + +Run them with `node --import tsx examples/.ts`. + +Each example demonstrates one concept: + +- [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts): the smallest `createAgentMachine(...)` flow with an `invoke` state that makes a real LLM call +- [`examples/hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts): a human-in-the-loop machine that pauses in a pending state, accepts typed events, and drafts with an LLM after approval +- [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts): choosing the next branch with structured branch-specific payloads +- [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts): routing into a fixed category set when you only need a label +- [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts): plugging in a custom adapter that still uses a real OpenAI model under the hood + +Use `classify(...)` when the result is just "what kind of thing is this?" Use `decide(...)` when the result is "what should happen next?" and the chosen branch may need structured data. + **Read the documentation: [stately.ai/docs/agents](https://stately.ai/docs/agents)** diff --git a/src/agent.test.ts b/src/agent.test.ts index 6bb8d7c..7c2eaaa 100644 --- a/src/agent.test.ts +++ b/src/agent.test.ts @@ -41,7 +41,7 @@ function createSimpleMachine() { states: { idle: { on: { - start: () => ({ target: 'running' }), + start: ({ target: 'running' }), }, }, running: { @@ -156,7 +156,6 @@ function createDecideMachine(adapter: AgentAdapter) { states: { classifying: decide({ model: 'test-model', - // context is Record here, not typed from context!! prompt: ({ context }) => `Classify: ${context.issue}`, options: { billing: { description: 'Billing issues' }, @@ -166,14 +165,12 @@ function createDecideMachine(adapter: AgentAdapter) { onDone: ({ result }) => ({ target: 'handling', context: { category: result.choice }, - params: { category: result.choice }, }), }), handling: { - paramsSchema: z.object({ category: z.string() }), resultSchema: z.object({ resolution: z.string() }), - invoke: async ({ context, params }) => ({ - resolution: `Handled ${params.category} issue`, + invoke: async ({ context }) => ({ + resolution: `Handled ${context.category} issue`, }), onDone: ({ result }) => ({ target: 'done', @@ -260,7 +257,8 @@ describe('getInitialState', () => { test('rejects invalid input', () => { const machine = createHitlMachine(); // Runtime validation catches invalid input (schemas.input validates) - expect(() => machine.getInitialState({ task: 123 })).toThrow(); + const invalidInput = { task: 123 } as unknown as { task: string }; + expect(() => machine.getInitialState(invalidInput)).toThrow(); }); test('resolves string initial', () => { @@ -780,13 +778,13 @@ describe('type inference', () => { }, }); - // @ts-expect-error — 'foo' not a valid context key createAgentMachine({ id: 't2', schemas: { events: { go: z.object({}) } }, context: () => ({ count: 0 }), initial: 'idle', states: { + // @ts-expect-error — 'foo' not a valid context key idle: { on: { go: () => ({ @@ -924,6 +922,33 @@ describe('type inference', () => { expect(s.context.count).toBe(5); }); + test('schemas.input alone drives context input typing', () => { + const machine = createAgentMachine({ + id: 't-input-only', + schemas: { + input: z.object({ message: z.string() }), + }, + context: (input) => { + input.message satisfies string; + // @ts-expect-error — 'nope' does not exist on input + input.nope; + return { message: input.message, count: 0 }; + }, + initial: 'idle', + states: { + idle: { + type: 'final', + }, + }, + }); + + machine.getInitialState({ message: 'hello' }); + if (false) { + // @ts-expect-error — message must be string + machine.getInitialState({ message: 123 }); + } + }); + // ─── schemas.events ─── test('transition events typed from schemas.events', () => { diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index 1215ee2..8ba186d 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -1,4 +1,4 @@ -import { generateObject } from 'ai'; +import { generateText, Output } from 'ai'; import { z } from 'zod'; import type { AgentAdapter, StandardSchemaV1 } from '../types.js'; @@ -10,57 +10,69 @@ import type { AgentAdapter, StandardSchemaV1 } from '../types.js'; export function createAiSdkAdapter(): AgentAdapter { return { async decide({ model, prompt, options, reasoning }) { - // Build the discriminated union schema for options const optionKeys = Object.keys(options); + const allSchemaLess = Object.values(options).every((option) => !option.schema); - // Build per-option schemas - const optionSchemas: Record = {}; + if (allSchemaLess && !reasoning) { + const optionDescriptions = Object.entries(options) + .map(([key, opt]) => `- ${key}: ${opt.description}`) + .join('\n'); + + const result = await generateText({ + model: resolveModel(model), + system: `You must choose exactly one of the following options:\n${optionDescriptions}`, + prompt, + output: Output.choice({ + options: optionKeys, + }), + }); + + return { + choice: result.output, + data: {}, + }; + } + + const optionSchemas: z.ZodTypeAny[] = []; for (const [key, opt] of Object.entries(options)) { - if (opt.schema) { - // Use the provided schema as the data shape - optionSchemas[key] = z.object({ - choice: z.literal(key), - data: toZodSchema(opt.schema), - ...(reasoning ? { reasoning: z.string().describe('Chain-of-thought reasoning for this decision') } : {}), - }); - } else { - optionSchemas[key] = z.object({ - choice: z.literal(key), - data: z.object({}), - ...(reasoning ? { reasoning: z.string().describe('Chain-of-thought reasoning for this decision') } : {}), - }); - } + optionSchemas.push( + z.object({ + decision: z.literal(key), + data: opt.schema ? toZodSchema(opt.schema) : z.object({}), + ...(reasoning ? { reasoning: z.string() } : {}), + }) + ); } - // Build the union schema - const schemas = optionKeys.map((k) => optionSchemas[k]!); + const schemas = optionSchemas; const schema = schemas.length === 1 ? schemas[0]! : z.union(schemas as [z.ZodType, z.ZodType, ...z.ZodType[]]); - // Build the system prompt with option descriptions const optionDescriptions = Object.entries(options) .map(([key, opt]) => `- ${key}: ${opt.description}`) .join('\n'); - const systemPrompt = `You must choose exactly one of the following options:\n${optionDescriptions}\n\nRespond with your choice and any required data.`; + const systemPrompt = `You must choose exactly one of the following options:\n${optionDescriptions}\n\nRespond with structured output containing the chosen decision and any required data.`; - const result = await generateObject({ + const result = await generateText({ model: resolveModel(model), system: systemPrompt, prompt, - schema, + output: Output.object({ + schema, + }), }); - const obj = result.object as { - choice: string; + const obj = result.output as { + decision: string; data: Record; reasoning?: string; }; return { - choice: obj.choice, + choice: obj.decision, data: obj.data ?? {}, reasoning: obj.reasoning, }; @@ -86,7 +98,7 @@ function toZodSchema(schema: StandardSchemaV1): z.ZodType { * Resolve a model string to an AI SDK model. * Supports the `provider/model` format via the AI SDK registry. */ -function resolveModel(model: string): Parameters[0]['model'] { +function resolveModel(model: string): Parameters[0]['model'] { // The AI SDK accepts model strings when using a provider registry. // For now, return as-is — users configure their provider registry externally. return model as any; diff --git a/src/classify.ts b/src/classify.ts index c4bfff1..d0ee89c 100644 --- a/src/classify.ts +++ b/src/classify.ts @@ -1,4 +1,34 @@ -import type { ClassifyConfig } from './types.js'; +import type { + AgentAdapter, + InvokeEnqueue, + StateConfig, + TransitionResult, +} from './types.js'; + +type TransitionTargetOf = T extends { target?: infer TTarget } + ? Extract + : never; + +type HandlerTargetOf = T extends (...args: any[]) => infer TResult + ? TransitionTargetOf + : TransitionTargetOf; + +type OnTargets = TOn extends Record + ? HandlerTargetOf + : never; + +type ClassifyStateConfig< + TContext extends Record, + TTarget extends string, + TParamsByTarget extends Record, +> = Pick< + StateConfig, + 'on' +> & { + __type: 'classify'; + __classifyConfig: Record; + __decideConfig: Record; +}; /** * Create a classification state. Sugar over `decide` for simple routing — @@ -6,12 +36,37 @@ import type { ClassifyConfig } from './types.js'; * * `result.category` is typed as a union of the `into` keys. * - * Note: context in prompt callback is untyped. For typed context, use - * inline `type: 'choice'` instead. */ export function classify< + TContext extends Record, const TCategories extends Record, ->(config: ClassifyConfig): any { + TParams extends Record = Record, + TTarget extends string = string, + TParamsByTarget extends Record = {}, +>( + config: { + model: string; + adapter?: AgentAdapter; + prompt: string | ((args: { context: TContext; params: TParams }) => string); + into: TCategories; + examples?: Array<{ input: string; category: keyof TCategories & string }>; + onDone: (args: { + result: { category: keyof TCategories & string }; + context: TContext; + }) => TransitionResult; + on?: Record< + string, + ( + args: { event: any; context: TContext }, + enq: InvokeEnqueue + ) => TransitionResult + >; + } +): ClassifyStateConfig< + TContext, + TTarget, + TParamsByTarget +> { const decideOptions: Record = {}; for (const [key, val] of Object.entries(config.into)) { decideOptions[key] = { description: val.description }; @@ -19,7 +74,7 @@ export function classify< return { __type: 'classify', - __classifyConfig: config, + __classifyConfig: config as unknown as Record, __decideConfig: { model: config.model, adapter: config.adapter, @@ -32,6 +87,10 @@ export function classify< }); }, }, - on: config.on, + on: config.on as StateConfig< + TContext, + TTarget, + TParamsByTarget + >['on'], }; } diff --git a/src/decide.ts b/src/decide.ts index 4fa8fc6..b4dbfb4 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -1,4 +1,35 @@ -import type { DecideConfig, StandardSchemaV1 } from './types.js'; +import type { + AgentAdapter, + DecideResultFor, + InvokeEnqueue, + StandardSchemaV1, + StateConfig, + TransitionResult, +} from './types.js'; + +type TransitionTargetOf = T extends { target?: infer TTarget } + ? Extract + : never; + +type HandlerTargetOf = T extends (...args: any[]) => infer TResult + ? TransitionTargetOf + : TransitionTargetOf; + +type OnTargets = TOn extends Record + ? HandlerTargetOf + : never; + +type DecideStateConfig< + TContext extends Record, + TTarget extends string, + TParamsByTarget extends Record, +> = Pick< + StateConfig, + 'on' +> & { + __type: 'decide'; + __decideConfig: Record; +}; /** * Create a decision state where an LLM picks from constrained options. @@ -6,18 +37,47 @@ import type { DecideConfig, StandardSchemaV1 } from './types.js'; * * The result type is a discriminated union — `result.choice` narrows `result.data`. * - * Note: context in prompt callback is untyped. For typed context, use - * inline `type: 'choice'` instead. */ export function decide< + TContext extends Record, const TOptions extends Record< string, { description: string; schema?: StandardSchemaV1 } >, ->(config: DecideConfig): any { + TParams extends Record = Record, + TTarget extends string = string, + TParamsByTarget extends Record = {}, +>( + config: { + model: string; + adapter?: AgentAdapter; + prompt: string | ((args: { context: TContext; params: TParams }) => string); + options: TOptions; + reasoning?: boolean; + onDone: (args: { + result: DecideResultFor; + context: TContext; + }) => TransitionResult; + on?: Record< + string, + ( + args: { event: any; context: TContext }, + enq: InvokeEnqueue + ) => TransitionResult + >; + } +): DecideStateConfig< + TContext, + TTarget, + TParamsByTarget +> { return { __type: 'decide', - __decideConfig: config, - on: config.on, + __decideConfig: config as unknown as Record, + on: config.on as StateConfig< + TContext, + TTarget, + TParamsByTarget + >['on'], }; } diff --git a/src/examples.test.ts b/src/examples.test.ts new file mode 100644 index 0000000..55829a1 --- /dev/null +++ b/src/examples.test.ts @@ -0,0 +1,747 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { + createChatbotExample, + createAdapterExample, + createBranchingExample, + createClassifyExample, + createCustomerServiceSimExample, + createDecideExample, + createEmailExample, + createHitlExample, + createJokeExample, + createJugsExample, + createMapReduceExample, + createNewspaperExample, + createPlanAndExecuteExample, + createRaffleExample, + createReactAgentExample, + createReflectionExample, + createRiverCrossingExample, + createSimpleExample, + createSubflowExample, + createToolCallingExample, + createTutorExample, +} from '../examples/index.js'; + +describe('curated examples', () => { + test('ships the canonical examples directory', () => { + const examplesDir = resolve(process.cwd(), 'examples'); + expect(existsSync(resolve(examplesDir, 'simple.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'hitl.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'decide.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'classify.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'adapter.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'branching.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'chatbot.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'customer-service-sim.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'email.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'joke.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'jugs.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'map-reduce.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'newspaper.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'plan-and-execute.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'raffle.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'react-agent.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'reflection.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'river-crossing.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'subflow.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'tool-calling.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'tutor.ts'))).toBe(true); + }); + + test('simple example runs to a final output', async () => { + const machine = createSimpleExample(async () => ({ + summary: 'A short summary.', + })); + const result = await machine.execute( + machine.getInitialState({ text: 'Longer source text.' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ summary: 'A short summary.' }); + } + }); + + test('hitl example exposes typed pending events', async () => { + const machine = createHitlExample(); + const result = await machine.execute( + machine.getInitialState({ task: 'Draft an answer' }) + ); + + expect(result.status).toBe('pending'); + if (result.status === 'pending') { + expect(result.value).toBe('gathering'); + expect(result.events['user.message']).toBeDefined(); + expect(result.events['user.approve']).toBeDefined(); + } + }); + + test('decide example chooses a branch and carries typed data', async () => { + const machine = createDecideExample({ + decide: async () => ({ + choice: 'askForClarification', + data: { question: 'Which order is affected?' }, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ + request: 'The customer says their invoice is wrong.', + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + action: 'askForClarification', + payload: { question: 'Which order is affected?' }, + }); + } + }); + + test('classify example reduces to a category only', async () => { + const machine = createClassifyExample({ + decide: async () => ({ + choice: 'billing', + data: {}, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ + request: 'I need help with a refund for my duplicate charge.', + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ category: 'billing' }); + } + }); + + test('adapter example uses the provided schema-aware adapter', async () => { + const machine = createAdapterExample({ + decide: async () => ({ + choice: 'billing', + data: { confidence: 0.9 }, + }), + }); + const result = await machine.execute(machine.getInitialState({ message: 'refund my last invoice' })); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + route: 'billing', + confidence: 0.9, + }); + } + }); + + test('branching example fans out plain async work and summarizes it', async () => { + const machine = createBranchingExample({ + analyzeDocs: async () => 'docs', + analyzeIssues: async () => 'issues', + analyzeCode: async () => 'code', + summarize: async ({ docs, issues, code }) => ({ + summary: `${docs}/${issues}/${code}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ topic: 'agents' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + docs: 'docs', + issues: 'issues', + code: 'code', + summary: 'docs/issues/code', + }); + } + }); + + test('decide example uses structured payloads while classify does not', async () => { + const decideMachine = createDecideExample({ + decide: async () => ({ + choice: 'reply', + data: { message: 'Hello there' }, + }), + }); + const classifyMachine = createClassifyExample({ + decide: async () => ({ + choice: 'general', + data: {}, + }), + }); + + const decideResult = await decideMachine.execute( + decideMachine.getInitialState({ + request: 'Please answer this support question.', + }) + ); + const classifyResult = await classifyMachine.execute( + classifyMachine.getInitialState({ + request: 'This is a general support question.', + }) + ); + + expect(decideResult.status).toBe('done'); + expect(classifyResult.status).toBe('done'); + + if (decideResult.status === 'done' && classifyResult.status === 'done') { + expect(decideResult.output).toEqual({ + action: 'reply', + payload: { message: 'Hello there' }, + }); + expect(classifyResult.output).toEqual({ category: 'general' }); + } + }); + + test('hitl example event schemas validate payloads', async () => { + const machine = createHitlExample(); + const pending = await machine.execute( + machine.getInitialState({ task: 'Draft an answer' }) + ); + + expect(pending.status).toBe('pending'); + if (pending.status === 'pending') { + const validation = pending.events['user.message']!['~standard'].validate({ + type: 'user.message', + message: 'Here is the missing detail', + }); + + expect(validation.issues).toBeUndefined(); + } + }); + + test('decide example uses schemas on branch payloads', async () => { + const machine = createDecideExample({ + decide: async () => ({ + choice: 'reply', + data: { message: 'Resolved' }, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ + request: 'Please respond to this support request.', + }) + ); + + expect(result.status).toBe('done'); + expect( + z + .object({ + action: z.string(), + payload: z.object({ message: z.string() }), + }) + .safeParse(result.status === 'done' ? result.output : null).success + ).toBe(true); + }); + + test('chatbot example accepts a user message and replies', async () => { + const machine = createChatbotExample({ + adapter: { + decide: async () => ({ choice: 'respond', data: {} }), + }, + reply: async () => ({ response: 'Assistant reply' }), + }); + + const pending = await machine.execute(machine.getInitialState()); + expect(pending.status).toBe('pending'); + + if (pending.status === 'pending') { + const next = machine.transition(pending.state, { + type: 'user.message', + message: 'Hello there', + }); + const result = await machine.execute(next); + + expect(result.status).toBe('pending'); + if (result.status === 'pending') { + expect(result.context.transcript).toEqual([ + 'User: Hello there', + 'Assistant: Assistant reply', + ]); + } + } + }); + + test('customer service sim example reaches a terminal outcome', async () => { + const machine = createCustomerServiceSimExample({ + serviceReply: async () => ({ response: 'We can help.' }), + customerReply: async () => ({ + response: 'Thanks, that works.', + done: true, + outcome: 'resolved', + }), + }); + + const result = await machine.execute( + machine.getInitialState({ issue: 'I want a refund.' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + transcript: [ + 'Customer: I want a refund.', + 'Agent: We can help.', + 'Customer: Thanks, that works.', + ], + turnCount: 1, + outcome: 'resolved', + }); + } + }); + + test('email example can pause for clarification and then draft using tools', async () => { + let checkCount = 0; + const machine = createEmailExample({ + adapter: { + decide: async () => { + checkCount += 1; + return checkCount === 1 + ? { + choice: 'askForClarification', + data: { questions: ['Which day should I offer?'] }, + } + : { choice: 'draft', data: {} }; + }, + }, + tools: { + lookupContactName: async () => 'Pat Lee', + lookupAvailability: async () => ['Friday at 1 PM'], + createSignature: async (name) => `Best,\n${name}`, + }, + compose: async ({ + email, + instructions, + clarifications, + contactName, + availability, + signature, + }) => ({ + replyEmail: [ + `Hi ${contactName},`, + '', + `Thanks for your note: "${email}"`, + instructions, + clarifications.join(' '), + `I am available ${availability.join(' or ')}.`, + '', + signature, + ] + .filter(Boolean) + .join('\n'), + }), + }); + + const first = await machine.execute( + machine.getInitialState({ + email: 'Can you meet next week?', + instructions: 'Reply with one specific slot.', + }) + ); + + expect(first.status).toBe('pending'); + if (first.status === 'pending') { + expect(first.context.questions).toEqual(['Which day should I offer?']); + + const next = machine.transition(first.state, { + type: 'user.answer', + answer: 'Offer Friday afternoon.', + }); + const done = await machine.execute(next); + + expect(done.status).toBe('done'); + if (done.status === 'done') { + expect( + z + .object({ + replyEmail: z.string(), + clarifications: z.array(z.string()), + }) + .safeParse(done.output).success + ).toBe(true); + } + } + }); + + test('joke example produces a rating and acceptance flag', async () => { + const machine = createJokeExample({ + tellJoke: async () => ({ joke: 'A short joke about ducks.' }), + rateJoke: async () => ({ rating: 9, explanation: 'It works.' }), + }); + + const result = await machine.execute( + machine.getInitialState({ topic: 'ducks' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + topic: 'ducks', + joke: 'A short joke about ducks.', + rating: 9, + explanation: 'It works.', + accepted: true, + }); + } + }); + + test('jugs example solves the 3 and 5 gallon puzzle', async () => { + const machine = createJugsExample(); + const result = await machine.execute(machine.getInitialState()); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + jug3: 3, + jug5: 4, + steps: [ + 'Filled the 5-gallon jug.', + 'Poured from the 5-gallon jug into the 3-gallon jug.', + 'Emptied the 3-gallon jug.', + 'Poured from the 5-gallon jug into the 3-gallon jug.', + 'Filled the 5-gallon jug.', + 'Poured from the 5-gallon jug into the 3-gallon jug.', + ], + reasoning: [ + 'Start by filling the larger jug.', + 'Transfer water into the 3-gallon jug.', + 'Empty the smaller jug to make room.', + 'Move the remaining water into the 3-gallon jug.', + 'Refill the 5-gallon jug.', + 'Top off the 3-gallon jug to leave 4 gallons.', + 'The 5-gallon jug now holds exactly 4 gallons.', + ], + }); + } + }); + + test('map-reduce example decomposes work items and reduces the result', async () => { + const machine = createMapReduceExample({ + planSubjects: async () => ({ + subjects: ['one', 'two'], + }), + writeJoke: async (subject) => `joke:${subject}`, + chooseBest: async (jokes) => ({ + bestJoke: jokes.at(-1) ?? '', + }), + }); + + const result = await machine.execute( + machine.getInitialState({ topic: 'agents' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + subjects: ['one', 'two'], + jokes: ['joke:one', 'joke:two'], + bestJoke: 'joke:two', + }); + } + }); + + test('subflow example composes a child machine inside a parent workflow', async () => { + const machine = createSubflowExample({ + research: async (topic) => ({ + bullets: [`fact about ${topic}`, `detail about ${topic}`], + }), + write: async ({ topic, bullets }) => ({ + draft: `${topic}: ${bullets.join(' / ')}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ topic: 'agents' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + bullets: ['fact about agents', 'detail about agents'], + draft: 'agents: fact about agents / detail about agents', + }); + } + }); + + test('tool-calling example emits live tool activity and completes with output', async () => { + const machine = createToolCallingExample(async (city) => ({ + forecast: `Rainy in ${city}`, + })); + + const { createMemoryRunStore, startSession } = await import('./index.js'); + const run = await startSession(machine, { + store: createMemoryRunStore(), + input: { city: 'New York' }, + }); + const events: string[] = []; + + run.on('toolCall', (event) => { + events.push(`call:${(event as { toolName: string }).toolName}`); + }); + run.on('toolResult', (event) => { + events.push(`result:${(event as { toolName: string }).toolName}`); + }); + + await new Promise((resolve, reject) => { + run.on('done', () => resolve()); + run.on('error', (event) => reject((event as { error: unknown }).error)); + }); + + expect(events).toEqual(['call:getWeather', 'result:getWeather']); + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + output: { forecast: 'Rainy in New York' }, + }) + ); + }); + + test('react agent example loops through a tool and returns a final answer', async () => { + const { createMemoryRunStore, startSession } = await import('./index.js'); + const agent = createReactAgentExample({ + search: async (query) => `result for ${query}`, + model: async ({ messages }) => { + const last = messages.at(-1); + + if (!last || last.role === 'user') { + return { + kind: 'tool' as const, + toolName: 'search', + input: { query: 'weather in sf' }, + message: 'Searching for weather in sf', + }; + } + + if (last.role === 'tool') { + return { + kind: 'final' as const, + message: `I found: ${last.content}`, + }; + } + + return { + kind: 'final' as const, + message: 'I could not complete the request.', + }; + }, + }); + const run = await startSession(agent, { + store: createMemoryRunStore(), + input: { + messages: [{ role: 'user', content: 'weather in sf' }], + }, + }); + const events: string[] = []; + + run.on('toolCall', (event) => { + events.push(`call:${(event as { toolName: string }).toolName}`); + }); + run.on('toolResult', (event) => { + events.push(`result:${(event as { toolName: string }).toolName}`); + }); + + await new Promise((resolve, reject) => { + run.on('done', () => resolve()); + run.on('error', (event) => reject((event as { error: unknown }).error)); + }); + + expect(events).toEqual(['call:search', 'result:search']); + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + output: expect.objectContaining({ + finalMessage: 'I found: result for weather in sf', + }), + }) + ); + }); + + test('newspaper example loops through critique and revision', async () => { + const machine = createNewspaperExample({ + search: async () => ({ searchResults: ['a', 'b', 'c'] }), + curate: async () => ({ searchResults: ['a', 'b'] }), + write: async () => ({ article: 'Draft article' }), + critique: async (_article, revisionCount) => ({ + critique: revisionCount === 0 ? 'Tighten the ending.' : null, + }), + revise: async (article, critique) => ({ + article: `${article} Revised: ${critique}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ topic: 'Robotics' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + topic: 'Robotics', + article: 'Draft article Revised: Tighten the ending.', + revisionCount: 1, + searchResults: ['a', 'b'], + }); + } + }); + + test('plan-and-execute example creates a plan, executes steps, and synthesizes', async () => { + const machine = createPlanAndExecuteExample({ + plan: async () => ({ + plan: ['one', 'two'], + }), + executeStep: async ({ step }) => ({ + result: `result:${step}`, + }), + synthesize: async ({ stepResults }) => ({ + answer: stepResults.join(' + '), + }), + }); + + const result = await machine.execute( + machine.getInitialState({ goal: 'ship a feature' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + goal: 'ship a feature', + plan: ['one', 'two'], + stepResults: ['result:one', 'result:two'], + answer: 'result:one + result:two', + }); + } + }); + + test('raffle example collects entries and reports a winner', async () => { + const machine = createRaffleExample(async (entries) => ({ + winningEntry: entries[1] ?? '', + firstRunnerUp: entries[0] ?? '', + secondRunnerUp: entries[2] ?? '', + explanation: 'Selected the second entry for the demo.', + })); + + const pending = await machine.execute(machine.getInitialState()); + expect(pending.status).toBe('pending'); + + if (pending.status === 'pending') { + let state = machine.transition(pending.state, { + type: 'user.entry', + entry: 'TypeScript', + }); + state = machine.transition(state, { + type: 'user.entry', + entry: 'Rust', + }); + state = machine.transition(state, { + type: 'user.entry', + entry: 'Go', + }); + state = machine.transition(state, { type: 'user.draw' }); + + const result = await machine.execute(state); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + entries: ['TypeScript', 'Rust', 'Go'], + winner: 'Rust', + firstRunnerUp: 'TypeScript', + secondRunnerUp: 'Go', + explanation: 'Selected the second entry for the demo.', + }); + } + } + }); + + test('reflection example loops through critique and revision until ready', async () => { + const machine = createReflectionExample({ + draft: async () => ({ + draft: 'Initial draft', + }), + reflect: async ({ revisionCount }) => ({ + feedback: revisionCount === 0 ? 'Clarify the main point.' : null, + }), + revise: async ({ draft, feedback }) => ({ + draft: `${draft} Revised: ${feedback}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ task: 'Explain event sourcing simply.' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + task: 'Explain event sourcing simply.', + draft: 'Initial draft Revised: Clarify the main point.', + feedback: null, + revisionCount: 1, + }); + } + }); + + test('river crossing example moves every item safely to the right bank', async () => { + const machine = createRiverCrossingExample(); + const result = await machine.execute(machine.getInitialState()); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + leftBank: [], + rightBank: ['cabbage', 'goat', 'wolf'], + steps: [ + 'The farmer took the goat across the river.', + 'The farmer crossed the river alone.', + 'The farmer took the wolf across the river.', + 'The farmer took the goat across the river.', + 'The farmer took the cabbage across the river.', + 'The farmer crossed the river alone.', + 'The farmer took the goat across the river.', + ], + reasoning: [ + 'Move the goat first so it is not left with the cabbage.', + 'Return alone to ferry another item.', + 'Take the wolf across while the goat waits safely alone.', + 'Bring the goat back so the wolf is not left with it.', + 'Take the cabbage across now that the goat is with you.', + 'Return alone to fetch the goat.', + 'Bring the goat across to complete the crossing.', + 'Everyone is safely across.', + ], + }); + } + }); + + test('tutor example gives feedback and a response', async () => { + const machine = createTutorExample({ + teach: async () => ({ instruction: 'Use a more complete sentence.' }), + respond: async () => ({ response: 'Claro, puedo ayudarte.' }), + }); + + const result = await machine.execute( + machine.getInitialState({ message: 'Yo necesito ayuda' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + conversation: [ + 'User: Yo necesito ayuda', + 'Tutor: Claro, puedo ayudarte.', + ], + feedback: 'Use a more complete sentence.', + response: 'Claro, puedo ayudarte.', + }); + } + }); +}); diff --git a/src/index.ts b/src/index.ts index f59582c..6692285 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,12 @@ export { createAgentMachine } from './machine.js'; // AI primitives export { decide } from './decide.js'; export { classify } from './classify.js'; +export { createReactAgent } from './prebuilt/react.js'; +export type { + ReactAgentMessage, + ReactAgentModelResult, + ReactTool, +} from './prebuilt/react.js'; // Adapter export { createAdapter } from './adapter.js'; diff --git a/src/invoke-events.test.ts b/src/invoke-events.test.ts index e70061a..a886ada 100644 --- a/src/invoke-events.test.ts +++ b/src/invoke-events.test.ts @@ -6,6 +6,18 @@ import { startSession, } from './index.js'; +function once( + run: { on(type: string, handler: (event: unknown) => void): () => void }, + type: string +) { + return new Promise((resolve) => { + const off = run.on(type, (event) => { + off(); + resolve(event as T); + }); + }); +} + test('invoke success is journaled as an internal machine event', async () => { const machine = createAgentMachine({ id: 'invoke-success', @@ -29,6 +41,7 @@ test('invoke success is journaled as an internal machine event', async () => { const store = createMemoryRunStore(); const run = await startSession(machine, { store }); + await once(run, 'done'); const journal = await store.loadEvents(run.sessionId); expect(run.getSnapshot()).toEqual( @@ -65,6 +78,7 @@ test('invoke failure is journaled as an internal machine event', async () => { const store = createMemoryRunStore(); const run = await startSession(machine, { store }); + await once(run, 'error'); const journal = await store.loadEvents(run.sessionId); expect(run.getSnapshot()).toEqual( @@ -84,3 +98,40 @@ test('invoke failure is journaled as an internal machine event', async () => { }), ]); }); + +test('invalid invoke results fail without journaling a done event', async () => { + const machine = createAgentMachine({ + id: 'invoke-invalid-result', + context: () => ({ count: 0 }), + initial: 'processing', + states: { + processing: { + resultSchema: z.object({ value: z.string() }), + invoke: async () => ({ value: 42 } as unknown as { value: string }), + }, + }, + }); + + const store = createMemoryRunStore(); + const run = await startSession(machine, { store }); + await once(run, 'error'); + const journal = await store.loadEvents(run.sessionId); + + expect(journal.map((event) => event.type)).toEqual([ + 'xstate.init', + 'xstate.error.invoke.processing', + ]); + expect(journal).not.toContainEqual( + expect.objectContaining({ + type: 'xstate.done.invoke.processing', + }) + ); + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + status: 'error', + error: expect.objectContaining({ + message: expect.stringContaining('Validation failed'), + }), + }) + ); +}); diff --git a/src/langgraph-equivalents/branching.test.ts b/src/langgraph-equivalents/branching.test.ts new file mode 100644 index 0000000..a06f91f --- /dev/null +++ b/src/langgraph-equivalents/branching.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { createAgentMachine } from '../index.js'; + +test('supports branching-style orchestration with plain async fan-out inside invoke', async () => { + const machine = createAgentMachine({ + id: 'langgraph-equivalent-branching', + schemas: { + input: z.object({ topic: z.string() }), + }, + context: (input) => ({ + topic: input.topic, + docs: null as string | null, + issues: null as string | null, + code: null as string | null, + summary: null as string | null, + }), + initial: 'analyzing', + states: { + analyzing: { + resultSchema: z.object({ + docs: z.string(), + issues: z.string(), + code: z.string(), + }), + invoke: async ({ context }) => { + const [docs, issues, code] = await Promise.all([ + Promise.resolve(`docs about ${context.topic}`), + Promise.resolve(`issues about ${context.topic}`), + Promise.resolve(`code about ${context.topic}`), + ]); + + return { docs, issues, code }; + }, + onDone: ({ result }) => ({ + target: 'summarizing', + context: result, + }), + }, + summarizing: { + // paramsschema could help here, the summary has lots of string | null + resultSchema: z.object({ summary: z.string() }), + invoke: async ({ context }) => ({ + summary: [context.docs, context.issues, context.code].join(' | '), + }), + onDone: ({ result }) => ({ + target: 'done', + context: { summary: result.summary }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + docs: context.docs, + issues: context.issues, + code: context.code, + summary: context.summary, + }), + }, + }, + }); + + const result = await machine.execute( + machine.getInitialState({ topic: 'agents' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + docs: 'docs about agents', + issues: 'issues about agents', + code: 'code about agents', + summary: 'docs about agents | issues about agents | code about agents', + }); + } +}); diff --git a/src/langgraph-equivalents/graph.test.ts b/src/langgraph-equivalents/graph.test.ts new file mode 100644 index 0000000..33b1b45 --- /dev/null +++ b/src/langgraph-equivalents/graph.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { createAgentMachine } from '../index.js'; + +test('supports multi-step workflow accumulation like a sequential state graph', async () => { + const machine = createAgentMachine({ + id: 'langgraph-equivalent-sequence', + context: () => ({ messages: [] as string[] }), + initial: 'node1', + states: { + node1: { + resultSchema: z.object({ messages: z.array(z.string()) }), + invoke: async () => ({ messages: ['from node1'] }), + onDone: ({ result, context }) => ({ + target: 'node2', + context: { messages: [...context.messages, ...result.messages] }, + }), + }, + node2: { + resultSchema: z.object({ messages: z.array(z.string()) }), + invoke: async () => ({ messages: ['from node2'] }), + onDone: ({ result, context }) => ({ + target: 'node3', + context: { messages: [...context.messages, ...result.messages] }, + }), + }, + node3: { + resultSchema: z.object({ messages: z.array(z.string()) }), + invoke: async () => ({ messages: ['from node3'] }), + onDone: ({ result, context }) => ({ + target: 'done', + context: { messages: [...context.messages, ...result.messages] }, + }), + }, + done: { + type: 'final', + output: ({ context }) => context, + }, + }, + }); + + const result = await machine.execute(machine.getInitialState()); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + messages: ['from node1', 'from node2', 'from node3'], + }); + } +}); + +test('supports conditional routing with explicit machine transitions', async () => { + const machine = createAgentMachine({ + id: 'langgraph-equivalent-routing', + schemas: { + input: z.object({ request: z.string() }), + }, + context: (input) => ({ + request: input.request, + route: null as string | null, + handledBy: null as string | null, + }), + initial: 'routeRequest', + states: { + routeRequest: { + resultSchema: z.object({ + route: z.enum(['billing', 'general']), + }), + invoke: async ({ context }) => { + const route = context.request.toLowerCase().includes('refund') + ? 'billing' + : 'general'; + + return { route } as const; + }, + onDone: ({ result }) => ({ + target: result.route, + context: { route: result.route }, + }), + }, + billing: { + resultSchema: z.object({ handledBy: z.literal('billing') }), + invoke: async () => ({ handledBy: 'billing' as const }), // why do we need to cast to const here? + onDone: ({ result }) => ({ + target: 'done', + context: { handledBy: result.handledBy }, + }), + }, + general: { + resultSchema: z.object({ handledBy: z.literal('general') }), + invoke: async () => ({ handledBy: 'general' as const }), + onDone: ({ result }) => ({ + target: 'done', + context: { handledBy: result.handledBy }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + route: context.route, + handledBy: context.handledBy, + }), + }, + }, + }); + + const result = await machine.execute( + machine.getInitialState({ request: 'I need a refund for my invoice.' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + route: 'billing', + handledBy: 'billing', + }); + } +}); diff --git a/src/langgraph-equivalents/hitl.test.ts b/src/langgraph-equivalents/hitl.test.ts new file mode 100644 index 0000000..3cf8f6e --- /dev/null +++ b/src/langgraph-equivalents/hitl.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { createAgentMachine } from '../index.js'; + +test('supports human-in-the-loop review with explicit pending states and external events', async () => { + const machine = createAgentMachine({ + id: 'langgraph-equivalent-hitl', + schemas: { + input: z.object({ task: z.string() }), + events: { + approve: z.object({}), + revise: z.object({ note: z.string() }), + }, + }, + context: (input) => ({ + task: input.task, + notes: [] as string[], + draft: null as string | null, + }), + initial: 'drafting', + states: { + drafting: { + resultSchema: z.object({ draft: z.string() }), + invoke: async ({ context }) => ({ + draft: `Draft for ${context.task}${context.notes.length ? ` (${context.notes.join(', ')})` : ''}`, + }), + onDone: ({ result }) => ({ + target: 'review', + context: { draft: result.draft }, + }), + }, + review: { + on: { + approve: { target: 'done' }, + revise: ({ event, context }) => ({ + target: 'drafting', + context: { notes: [...context.notes, event.note] }, + }), + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ draft: context.draft }), + }, + }, + }); + + const first = await machine.execute( + machine.getInitialState({ task: 'reply to customer' }) + ); + + expect(first.status).toBe('pending'); + if (first.status !== 'pending') return; + + expect(first.value).toBe('review'); + expect(first.context.draft).toContain('reply to customer'); + + const revised = machine.transition(first.state, { + type: 'revise', + note: 'make it shorter', + }); + const second = await machine.execute(revised); + + expect(second.status).toBe('pending'); + if (second.status !== 'pending') return; + + const approved = machine.transition(second.state, { type: 'approve' }); + const done = await machine.execute(approved); + + expect(done.status).toBe('done'); + if (done.status === 'done') { + expect(done.output).toEqual({ + draft: 'Draft for reply to customer (make it shorter)', + }); + } +}); diff --git a/src/langgraph-equivalents/map-reduce.test.ts b/src/langgraph-equivalents/map-reduce.test.ts new file mode 100644 index 0000000..34e0762 --- /dev/null +++ b/src/langgraph-equivalents/map-reduce.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { createAgentMachine } from '../index.js'; + +test('supports map-reduce style orchestration with dynamic work items inside invoke', async () => { + const machine = createAgentMachine({ + id: 'langgraph-equivalent-map-reduce', + schemas: { + input: z.object({ topic: z.string() }), + }, + context: (input) => ({ + topic: input.topic, + subjects: [] as string[], + jokes: [] as string[], + bestJoke: null as string | null, + }), + initial: 'planning', + states: { + planning: { + resultSchema: z.object({ subjects: z.array(z.string()) }), + invoke: async ({ context }) => ({ + subjects: [`${context.topic} basics`, `${context.topic} advanced`], + }), + onDone: ({ result }) => ({ + target: 'mapping', + context: { subjects: result.subjects }, + }), + }, + mapping: { + resultSchema: z.object({ jokes: z.array(z.string()) }), + invoke: async ({ context }) => { + const jokes = await Promise.all( + context.subjects.map(async (subject) => `joke about ${subject}`) + ); + + return { jokes }; + }, + onDone: ({ result }) => ({ + target: 'reducing', + context: { jokes: result.jokes }, + }), + }, + reducing: { + resultSchema: z.object({ bestJoke: z.string() }), + invoke: async ({ context }) => ({ + bestJoke: context.jokes[0] ?? '', + }), + onDone: ({ result }) => ({ + target: 'done', + context: { bestJoke: result.bestJoke }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + subjects: context.subjects, + jokes: context.jokes, + bestJoke: context.bestJoke, + }), + }, + }, + }); + + const result = await machine.execute( + machine.getInitialState({ topic: 'state machines' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + subjects: ['state machines basics', 'state machines advanced'], + jokes: [ + 'joke about state machines basics', + 'joke about state machines advanced', + ], + bestJoke: 'joke about state machines basics', + }); + } +}); diff --git a/src/langgraph-equivalents/persistence.test.ts b/src/langgraph-equivalents/persistence.test.ts new file mode 100644 index 0000000..be6f53f --- /dev/null +++ b/src/langgraph-equivalents/persistence.test.ts @@ -0,0 +1,88 @@ +import { expect, test, vi } from 'vitest'; +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + restoreSession, + startSession, +} from '../index.js'; + +test('persists and restores a long-running approval workflow', async () => { + const machine = createAgentMachine({ + id: 'langgraph-equivalent-persistence', + context: () => ({ + approved: false, + summary: null as string | null, + }), + initial: 'review', + states: { + review: { + on: { + approve: { + target: 'summarize', + context: { approved: true }, + }, + }, + }, + summarize: { + resultSchema: z.object({ summary: z.string() }), + invoke: async ({ context }) => ({ + summary: context.approved ? 'approved summary' : 'rejected summary', + }), + onDone: ({ result }) => ({ + target: 'done', + context: { summary: result.summary }, + }), + }, + done: { + type: 'final', + output: ({ context }) => context, + }, + }, + }); + + const baseStore = createMemoryRunStore(); + let snapshotWrites = 0; + const store = { + append: baseStore.append, + loadEvents: baseStore.loadEvents, + loadLatestSnapshot: baseStore.loadLatestSnapshot, + async saveSnapshot(snapshot: Awaited< + ReturnType + > extends infer TSaved + ? Exclude + : never) { + snapshotWrites += 1; + if (snapshotWrites === 1) { + await baseStore.saveSnapshot(snapshot); + } + }, + }; + + const liveRun = await startSession(machine, { store }); + await liveRun.send({ type: 'approve' }); + + const restoredRun = await restoreSession(machine, { + sessionId: liveRun.sessionId, + store, + }); + + await vi.waitFor(() => { + expect(restoredRun.getSnapshot()).toEqual(liveRun.getSnapshot()); + }); + + expect(restoredRun.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + context: { + approved: true, + summary: 'approved summary', + }, + output: { + approved: true, + summary: 'approved summary', + }, + }) + ); +}); diff --git a/src/langgraph-equivalents/plan-and-execute.test.ts b/src/langgraph-equivalents/plan-and-execute.test.ts new file mode 100644 index 0000000..781b646 --- /dev/null +++ b/src/langgraph-equivalents/plan-and-execute.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'vitest'; +import { createPlanAndExecuteExample } from '../../examples/plan-and-execute.js'; + +test('plan-and-execute workflow decomposes a goal and synthesizes a final answer', async () => { + const machine = createPlanAndExecuteExample({ + plan: async () => ({ + plan: ['inspect docs', 'inspect code', 'summarize findings'], + }), + executeStep: async ({ step }) => ({ + result: `done:${step}`, + }), + synthesize: async ({ stepResults }) => ({ + answer: stepResults.join(' | '), + }), + }); + + const result = await machine.execute( + machine.getInitialState({ goal: 'understand the repo' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + goal: 'understand the repo', + plan: ['inspect docs', 'inspect code', 'summarize findings'], + stepResults: [ + 'done:inspect docs', + 'done:inspect code', + 'done:summarize findings', + ], + answer: + 'done:inspect docs | done:inspect code | done:summarize findings', + }); + } +}); diff --git a/src/langgraph-equivalents/prebuilt-react.test.ts b/src/langgraph-equivalents/prebuilt-react.test.ts new file mode 100644 index 0000000..ec4cfb0 --- /dev/null +++ b/src/langgraph-equivalents/prebuilt-react.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from 'vitest'; +import { + createMemoryRunStore, + createReactAgent, + startSession, +} from '../index.js'; + +function once( + run: { on(type: string, handler: (event: unknown) => void): () => void }, + type: string +) { + return new Promise((resolve) => { + let off = () => {}; + off = run.on(type, (event) => { + off(); + resolve(event as T); + }); + }); +} + +test('prebuilt react agent loops through a tool call and returns a final answer', async () => { + const agent = createReactAgent({ + prompt: 'You are helpful.', + tools: [ + { + name: 'search', + description: 'Searches for a query', + execute: async (input) => `result for ${String(input.query)}`, + }, + ], + model: async ({ messages }) => { + const last = messages.at(-1); + + if (!last || last.role === 'user') { + return { + kind: 'tool' as const, + toolName: 'search', + input: { query: 'weather in sf' }, + message: 'I should search first.', + }; + } + + if (last.role === 'tool') { + return { + kind: 'final' as const, + message: `Answer based on: ${last.content}`, + }; + } + + throw new Error('Unexpected transcript state'); + }, + }); + + const run = await startSession(agent, { + store: createMemoryRunStore(), + input: { + messages: [{ role: 'user', content: 'What is the weather?' }], + }, + }); + const toolEvents: string[] = []; + + run.on('toolCall', (event) => { + toolEvents.push(`call:${(event as { toolName: string }).toolName}`); + }); + run.on('toolResult', (event) => { + toolEvents.push(`result:${(event as { toolName: string }).toolName}`); + }); + + await once(run, 'done'); + + expect(toolEvents).toEqual(['call:search', 'result:search']); + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { + finalMessage: 'Answer based on: result for weather in sf', + messages: [ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'What is the weather?' }, + { role: 'assistant', content: 'I should search first.' }, + { role: 'tool', name: 'search', content: 'result for weather in sf' }, + { role: 'assistant', content: 'Answer based on: result for weather in sf' }, + ], + steps: 2, + }, + }) + ); +}); diff --git a/src/langgraph-equivalents/reflection.test.ts b/src/langgraph-equivalents/reflection.test.ts new file mode 100644 index 0000000..f248a2f --- /dev/null +++ b/src/langgraph-equivalents/reflection.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'vitest'; +import { createReflectionExample } from '../../examples/reflection.js'; + +test('reflection workflow revises a draft until critique is cleared', async () => { + const machine = createReflectionExample({ + draft: async () => ({ + draft: 'Initial draft', + }), + reflect: async ({ revisionCount }) => ({ + feedback: revisionCount === 0 ? 'Add more detail.' : null, + }), + revise: async ({ draft, feedback }) => ({ + draft: `${draft} Revised: ${feedback}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ task: 'Write a short explanation.' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + task: 'Write a short explanation.', + draft: 'Initial draft Revised: Add more detail.', + feedback: null, + revisionCount: 1, + }); + } +}); diff --git a/src/langgraph-equivalents/streaming.test.ts b/src/langgraph-equivalents/streaming.test.ts new file mode 100644 index 0000000..779b3ad --- /dev/null +++ b/src/langgraph-equivalents/streaming.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from '../index.js'; + +function once( + run: { on(type: string, handler: (event: unknown) => void): () => void }, + type: string +) { + return new Promise((resolve) => { + let off = () => {}; + off = run.on(type, (event) => { + off(); + resolve(event as T); + }); + }); +} + +test('streams live invoke output while preserving durable state history', async () => { + const machine = createAgentMachine({ + id: 'langgraph-equivalent-streaming', + schemas: { + emitted: { + textPart: z.object({ delta: z.string() }), + }, + }, + context: () => ({ text: '' }), + initial: 'write', + states: { + write: { + resultSchema: z.object({ text: z.string() }), + invoke: async (_args, enq) => { + enq.emit({ type: 'textPart', delta: 'hello' }); + enq.emit({ type: 'textPart', delta: ' world' }); + return { text: 'hello world' }; + }, + onDone: ({ result }) => ({ + target: 'done', + context: { text: result.text }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ text: context.text }), + }, + }, + }); + + const run = await startSession(machine, { + store: createMemoryRunStore(), + }); + const liveParts: string[] = []; + + run.on('textPart', (part) => { + liveParts.push((part as { delta: string }).delta); + }); + + await once(run, 'done'); + + expect(liveParts).toEqual(['hello', ' world']); + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { text: 'hello world' }, + }) + ); +}); diff --git a/src/langgraph-equivalents/subflow.test.ts b/src/langgraph-equivalents/subflow.test.ts new file mode 100644 index 0000000..4da8014 --- /dev/null +++ b/src/langgraph-equivalents/subflow.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { createAgentMachine } from '../index.js'; + +test('supports subflow composition by executing a child machine inside a parent invoke', async () => { + const childMachine = createAgentMachine({ + id: 'child-research', + schemas: { + input: z.object({ topic: z.string() }), + }, + context: (input) => ({ + topic: input.topic, + bullets: [] as string[], + }), + initial: 'researching', + states: { + researching: { + resultSchema: z.object({ bullets: z.array(z.string()) }), + invoke: async ({ context }) => ({ + bullets: [`fact about ${context.topic}`, `another fact about ${context.topic}`], + }), + onDone: ({ result }) => ({ + target: 'done', + context: { bullets: result.bullets }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ bullets: context.bullets }), + }, + }, + }); + + const parentMachine = createAgentMachine({ + id: 'parent-writer', + schemas: { + input: z.object({ topic: z.string() }), + }, + context: (input) => ({ + topic: input.topic, + bullets: [] as string[], + draft: null as string | null, + }), + initial: 'researching', + states: { + researching: { + resultSchema: z.object({ bullets: z.array(z.string()) }), + invoke: async ({ context }) => { + const result = await childMachine.execute( + childMachine.getInitialState({ topic: context.topic }) + ); + + if (result.status !== 'done') { + throw new Error('Child machine did not finish'); + } + + return { + bullets: (result.output as { bullets: string[] }).bullets, + }; + }, + onDone: ({ result }) => ({ + target: 'writing', + context: { bullets: result.bullets }, + }), + }, + writing: { + resultSchema: z.object({ draft: z.string() }), + invoke: async ({ context }) => ({ + draft: `${context.topic}: ${context.bullets.join('; ')}`, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { draft: result.draft }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + bullets: context.bullets, + draft: context.draft, + }), + }, + }, + }); + + const result = await parentMachine.execute( + parentMachine.getInitialState({ topic: 'state machines' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + bullets: [ + 'fact about state machines', + 'another fact about state machines', + ], + draft: + 'state machines: fact about state machines; another fact about state machines', + }); + } +}); diff --git a/src/langgraph-equivalents/tool-calling.test.ts b/src/langgraph-equivalents/tool-calling.test.ts new file mode 100644 index 0000000..0d290b7 --- /dev/null +++ b/src/langgraph-equivalents/tool-calling.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from '../index.js'; + +function once( + run: { on(type: string, handler: (event: unknown) => void): () => void }, + type: string +) { + return new Promise((resolve) => { + let off = () => {}; + off = run.on(type, (event) => { + off(); + resolve(event as T); + }); + }); +} + +test('supports tool-call style invokes with live tool events and final output', async () => { + const machine = createAgentMachine({ + id: 'langgraph-equivalent-tool-calling', + schemas: { + emitted: { + toolCall: z.object({ + toolName: z.string(), + input: z.object({ city: z.string() }), + }), + toolResult: z.object({ + toolName: z.string(), + output: z.object({ forecast: z.string() }), + }), + }, + input: z.object({ city: z.string() }), + }, + context: (input) => ({ + city: input.city, + forecast: null as string | null, + }), + initial: 'checkingWeather', + states: { + checkingWeather: { + resultSchema: z.object({ forecast: z.string() }), + invoke: async ({ context }, enq) => { + enq.emit({ + type: 'toolCall', + toolName: 'getWeather', + input: { city: context.city }, + }); + + const output = { forecast: `Sunny in ${context.city}` }; + enq.emit({ + type: 'toolResult', + toolName: 'getWeather', + output, + }); + + return output; + }, + onDone: ({ result }) => ({ + target: 'done', + context: { forecast: result.forecast }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ forecast: context.forecast }), + }, + }, + }); + + const run = await startSession(machine, { + store: createMemoryRunStore(), + input: { city: 'Boston' }, + }); + const events: string[] = []; + + run.on('toolCall', (event) => { + events.push(`call:${(event as { toolName: string }).toolName}`); + }); + run.on('toolResult', (event) => { + events.push(`result:${(event as { toolName: string }).toolName}`); + }); + + await once(run, 'done'); + + expect(events).toEqual(['call:getWeather', 'result:getWeather']); + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { forecast: 'Sunny in Boston' }, + }) + ); +}); diff --git a/src/machine.ts b/src/machine.ts index 1c521f0..322699a 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -46,7 +46,10 @@ type StateNodeDef< TParams, TResult, TEvents, -> = { + TTarget extends string, + TParamsMap extends Record, +> = + | { type?: 'final' | 'choice'; paramsSchema?: StandardSchemaV1; resultSchema?: StandardSchemaV1; @@ -55,11 +58,11 @@ type StateNodeDef< params: NoInfer; signal?: AbortSignal; }, enq: { emit(part: EmittedPart): void }) => Promise>; - onDone?: (args: { result: OnDoneResult; context: TContext }) => TransitionResult; - on?: { [E in keyof TEvents & string]?: TransitionResult | ((args: { + onDone?: (args: { result: OnDoneResult; context: TContext }) => TransitionResult; + on?: { [E in keyof TEvents & string]?: TransitionResult | ((args: { event: EventFor; context: TContext; - }) => TransitionResult) }; + }, enq: { emit(part: EmittedPart): void }) => TransitionResult) }; events?: Record; output?: (args: { context: TContext }) => unknown; // choice-specific @@ -71,7 +74,13 @@ type StateNodeDef< // internal __type?: 'decide' | 'classify'; __decideConfig?: Record; -}; +} + | { + on?: StateConfigAny['on']; + __type: 'decide' | 'classify'; + __decideConfig: Record; + __classifyConfig?: Record; + }; type StatesMap< TContext extends Record, @@ -79,7 +88,14 @@ type StatesMap< TResultMap extends Record, TEvents, > = { - [K in keyof TParamsMap & keyof TResultMap]: StateNodeDef; + [K in keyof TParamsMap & keyof TResultMap]: StateNodeDef< + TContext, + TParamsMap[K], + TResultMap[K], + TEvents, + keyof TParamsMap & keyof TResultMap & string, + TParamsMap + >; }; // ─── Overload A: schemas.context present ─── @@ -248,10 +264,59 @@ export function createAgentMachine( state: AgentState, event: { type: string; [k: string]: unknown } ): AgentState { + return transitionWithEffects(state, event).next; + } + + function transitionWithEffects( + state: AgentState, + event: { type: string; [k: string]: unknown }, + onEmit?: (part: EmittedPart) => void + ): { next: AgentState; emitted: EmittedPart[] } { + const emitted: EmittedPart[] = []; + const enqueue = createEnqueue((part) => { + emitted.push(part); + onEmit?.(part); + }); const sc = resolveStateConfig(cfg, state.value); - const effectiveConfig = sc.__decideConfig - ? { ...sc, ...(sc.__decideConfig as Record) } - : sc; + const effectiveConfig = getEffectiveStateConfig(state.value); + + function applyResult( + result: TransitionResult, + status = state.status + ): AgentState { + if (result.target) { + return applyTransition(state, result); + } + + return { + ...state, + status, + context: result.context + ? { ...state.context, ...result.context } + : state.context, + }; + } + + function resolveHandlerResult( + handler: + | TransitionResult + | ((args: { + event: { type: string; [k: string]: unknown }; + context: Record; + }, enq: { emit(part: EmittedPart): void }) => TransitionResult), + status = state.status + ): { next: AgentState; emitted: EmittedPart[] } { + const result: TransitionResult = + typeof handler === 'function' + ? handler({ context: state.context, event }, enqueue) + : handler; + + return { + next: applyResult(result, status), + emitted, + }; + } + if (isDoneInvokeEventType(state.value, event.type)) { const result = 'output' in event ? event.output : undefined; const validatedResult = effectiveConfig.resultSchema @@ -264,66 +329,33 @@ export function createAgentMachine( context: state.context, }); - if (trans.target) { - return applyTransition(state, trans); - } - return { - ...state, - status: 'pending', - context: trans.context - ? { ...state.context, ...trans.context } - : state.context, + next: applyResult(trans, 'pending'), + emitted, }; } const internalHandler = sc.on?.[event.type]; if (internalHandler !== undefined) { - const result: TransitionResult = - typeof internalHandler === 'function' - ? internalHandler({ context: state.context, event }) - : internalHandler; - - if (result.target) { - return applyTransition(state, result); - } - - return { - ...state, - status: 'pending', - context: result.context - ? { ...state.context, ...result.context } - : state.context, - }; + return resolveHandlerResult(internalHandler, 'pending'); } - return { ...state, status: 'pending' }; + return { next: { ...state, status: 'pending' }, emitted }; } if (isErrorInvokeEventType(state.value, event.type)) { const internalHandler = sc.on?.[event.type]; if (internalHandler !== undefined) { - const result: TransitionResult = - typeof internalHandler === 'function' - ? internalHandler({ context: state.context, event }) - : internalHandler; - - if (result.target) { - return applyTransition(state, result); - } - - return { - ...state, - context: result.context - ? { ...state.context, ...result.context } - : state.context, - }; + return resolveHandlerResult(internalHandler); } return { - ...state, - status: 'error', - error: 'error' in event ? event.error : undefined, + next: { + ...state, + status: 'error', + error: 'error' in event ? event.error : undefined, + }, + emitted, }; } @@ -331,21 +363,7 @@ export function createAgentMachine( if (sc.on?.[event.type] !== undefined) { const handler = sc.on[event.type]!; - const result: TransitionResult = - typeof handler === 'function' - ? handler({ context: state.context, event }) - : handler; - - if (result.target) { - return applyTransition(state, result); - } - - return { - ...state, - context: result.context - ? { ...state.context, ...result.context } - : state.context, - }; + return resolveHandlerResult(handler); } throw new Error( @@ -353,6 +371,25 @@ export function createAgentMachine( ); } + function getEffectiveStateConfig(value: string): StateConfigAny { + const sc = resolveStateConfig(cfg, value); + return sc.__decideConfig + ? { ...sc, ...(sc.__decideConfig as Record) } + : sc; + } + + function validateReplayableResult( + value: string, + result: unknown + ): unknown { + const effectiveConfig = getEffectiveStateConfig(value); + if (!effectiveConfig.resultSchema) { + return result; + } + + return validateSchemaSync(effectiveConfig.resultSchema, result); + } + function validateEventPayload( value: string, event: { type: string } @@ -374,6 +411,17 @@ export function createAgentMachine( } } + function toInvokeErrorEvent( + state: AgentState, + error: unknown + ): JournalEvent { + return { + type: `xstate.error.invoke.${state.value}`, + error: serializeError(error), + at: Date.now(), + }; + } + function validateEmittedPart(part: EmittedPart): void { const schema = findEmittedSchema(cfg, part.type); if (!schema) { @@ -403,10 +451,7 @@ export function createAgentMachine( } async function createChoiceEvent(state: AgentState): Promise { - const sc = resolveStateConfig(cfg, state.value); - const dc = sc.__decideConfig - ? { ...sc, ...(sc.__decideConfig as Record) } - : sc; + const dc = getEffectiveStateConfig(state.value); const adapter = (dc as StateConfigAny).adapter ?? cfg.adapter; if (!adapter) { return { @@ -429,10 +474,11 @@ export function createAgentMachine( options: (dc as StateConfigAny).options!, reasoning: (dc as StateConfigAny).reasoning, }); + const validatedResult = validateReplayableResult(state.value, result); return { type: `xstate.done.invoke.${state.value}`, - output: result, + output: validatedResult, at: Date.now(), }; } catch (error) { @@ -457,10 +503,11 @@ export function createAgentMachine( }, createEnqueue(onEmit) ); + const validatedResult = validateReplayableResult(state.value, result); return { type: `xstate.done.invoke.${state.value}`, - output: result, + output: validatedResult, at: Date.now(), }; } catch (error) { @@ -492,6 +539,30 @@ export function createAgentMachine( return null; } + function resolveEffectTransition( + state: AgentState, + effectEvent: JournalEvent, + onEmit?: (part: EmittedPart) => void + ): { event: JournalEvent; next: AgentState } { + try { + return { + event: effectEvent, + next: transitionWithEffects(state, effectEvent, onEmit).next, + }; + } catch (error) { + if (isDoneInvokeEventType(state.value, effectEvent.type)) { + const errorEvent = toInvokeErrorEvent(state, error); + + return { + event: errorEvent, + next: transitionWithEffects(state, errorEvent, onEmit).next, + }; + } + + throw error; + } + } + async function invoke(state: AgentState): Promise { if (state.status === 'done' || state.status === 'error') { return state; @@ -508,7 +579,7 @@ export function createAgentMachine( const effectEvent = await getEffectEvent(state); if (effectEvent) { - return transition(state, effectEvent); + return resolveEffectTransition(state, effectEvent).next; } if (sc.on) { @@ -601,6 +672,8 @@ export function createAgentMachine( toSnapshot: toSnap, withRuntimeMetadata, getEffectEvent, + resolveEffectTransition, + transitionWithEffects, }, } as AgentMachine; } diff --git a/src/prebuilt/react.ts b/src/prebuilt/react.ts new file mode 100644 index 0000000..e4292d6 --- /dev/null +++ b/src/prebuilt/react.ts @@ -0,0 +1,225 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../machine.js'; +import type { AgentMachine, StandardSchemaV1 } from '../types.js'; + +const messageSchema = z.object({ + role: z.enum(['system', 'user', 'assistant', 'tool']), + content: z.string(), + name: z.string().optional(), +}); + +const toolCallSchema = z.object({ + kind: z.literal('tool'), + toolName: z.string(), + input: z.record(z.string(), z.unknown()), + message: z.string().optional(), +}); + +const finalAnswerSchema = z.object({ + kind: z.literal('final'), + message: z.string(), +}); + +const modelResultSchema = z.discriminatedUnion('kind', [ + toolCallSchema, + finalAnswerSchema, +]); + +export type ReactAgentMessage = z.infer; + +export type ReactTool = { + name: string; + description: string; + schema?: StandardSchemaV1; + execute: (input: Record) => Promise; +}; + +export type ReactAgentModelResult = z.infer; + +export function createReactAgent(options: { + prompt?: string; + maxSteps?: number; + tools?: ReactTool[]; + model: (args: { + messages: ReactAgentMessage[]; + tools: Array<{ + name: string; + description: string; + schema?: StandardSchemaV1; + }>; + }) => Promise; +}): AgentMachine< + { messages?: ReactAgentMessage[] }, + { + messages: ReactAgentMessage[]; + stepCount: number; + pendingToolCall: + | { toolName: string; input: Record } + | null; + } +> { + const tools = options.tools ?? []; + const maxSteps = options.maxSteps ?? 8; + const toolDefinitions = tools.map(({ name, description, schema }) => ({ + name, + description, + schema, + })); + const toolsByName = new Map(tools.map((tool) => [tool.name, tool])); + + function serializeToolOutput(output: unknown): string { + return typeof output === 'string' ? output : JSON.stringify(output); + } + + return createAgentMachine({ + id: 'prebuilt-react-agent', + schemas: { + input: z.object({ + messages: z.array(messageSchema).optional(), + }), + emitted: { + textPart: z.object({ delta: z.string() }), + toolCall: z.object({ + toolName: z.string(), + input: z.record(z.string(), z.unknown()), + }), + toolResult: z.object({ + toolName: z.string(), + output: z.unknown(), + }), + }, + }, + context: (input) => ({ + messages: [ + ...(options.prompt + ? ([{ role: 'system', content: options.prompt }] satisfies ReactAgentMessage[]) + : []), + ...(input.messages ?? []), + ], + stepCount: 0, + pendingToolCall: + null as { toolName: string; input: Record } | null, + }), + initial: 'agent', + states: { + agent: { + resultSchema: modelResultSchema, + invoke: async ({ context }, enq) => { + if (context.stepCount >= maxSteps) { + return { + kind: 'final' as const, + message: 'Stopped because the maximum step count was reached.', + }; + } + + const result = await options.model({ + messages: context.messages, + tools: toolDefinitions, + }); + + if (result.kind === 'final') { + enq.emit({ type: 'textPart', delta: result.message }); + } + + return result; + }, + onDone: ({ result, context }) => { + if (result.kind === 'final') { + return { + target: 'done' as const, + context: { + stepCount: context.stepCount + 1, + messages: [ + ...context.messages, + { role: 'assistant', content: result.message }, + ], + }, + }; + } + + return { + target: 'tool' as const, + context: { + stepCount: context.stepCount + 1, + pendingToolCall: { + toolName: result.toolName, + input: result.input, + }, + messages: [ + ...context.messages, + { + role: 'assistant', + content: + result.message + ?? `Calling tool ${result.toolName} with ${JSON.stringify(result.input)}`, + }, + ], + }, + params: { + toolName: result.toolName, + input: result.input, + }, + }; + }, + }, + tool: { + paramsSchema: z.object({ + toolName: z.string(), + input: z.record(z.string(), z.unknown()), + }), + resultSchema: z.object({ + toolName: z.string(), + output: z.unknown(), + }), + invoke: async ({ params }, enq) => { + const tool = toolsByName.get(params.toolName); + + if (!tool) { + throw new Error(`Tool '${params.toolName}' not found`); + } + + enq.emit({ + type: 'toolCall', + toolName: params.toolName, + input: params.input, + }); + + const output = await tool.execute(params.input); + + enq.emit({ + type: 'toolResult', + toolName: params.toolName, + output, + }); + + return { + toolName: params.toolName, + output, + }; + }, + onDone: ({ result, context }) => ({ + target: 'agent' as const, + context: { + pendingToolCall: null, + messages: [ + ...context.messages, + { + role: 'tool', + name: result.toolName, + content: serializeToolOutput(result.output), + }, + ], + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + messages: context.messages, + finalMessage: context.messages.at(-1)?.content ?? null, + steps: context.stepCount, + }), + }, + }, + }); +} diff --git a/src/restore.test.ts b/src/restore.test.ts index 7d9de1f..2dda9cf 100644 --- a/src/restore.test.ts +++ b/src/restore.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import { z } from 'zod'; import { createAgentMachine, @@ -69,6 +69,9 @@ test('restoreSession reconstructs from the latest snapshot plus replay tail', as sessionId: liveRun.sessionId, store, }); + await vi.waitFor(() => { + expect(restoredRun.getSnapshot()).toEqual(liveRun.getSnapshot()); + }); expect(restoredRun.getSnapshot()).toEqual(liveRun.getSnapshot()); }); diff --git a/src/runtime/emitter.ts b/src/runtime/emitter.ts index 1b83786..4e9f2d5 100644 --- a/src/runtime/emitter.ts +++ b/src/runtime/emitter.ts @@ -7,14 +7,9 @@ export interface RunEmitter { export function createRunEmitter(): RunEmitter { const listeners = new Map>(); - const history = new Map(); return { emit(type, event) { - const events = history.get(type) ?? []; - events.push(event); - history.set(type, events); - for (const handler of listeners.get(type) ?? []) { handler(event); } @@ -25,10 +20,6 @@ export function createRunEmitter(): RunEmitter { current.add(handler); listeners.set(type, current); - for (const event of history.get(type) ?? []) { - handler(event); - } - return () => { const active = listeners.get(type); if (!active) { diff --git a/src/runtime/session.ts b/src/runtime/session.ts index c47a0de..e916f1d 100644 --- a/src/runtime/session.ts +++ b/src/runtime/session.ts @@ -9,6 +9,7 @@ import type { RestoreSessionOptions, SessionOptions, } from '../types.js'; +import { isReservedInternalEventType } from '../utils.js'; type SnapshotRuntime = { sessionId: string; @@ -23,6 +24,16 @@ type RuntimeMachine = AgentMachine & { state: AgentState, onEmit?: (part: EmittedPart) => void ): Promise; + resolveEffectTransition( + state: AgentState, + effectEvent: JournalEvent, + onEmit?: (part: EmittedPart) => void + ): { event: JournalEvent; next: AgentState }; + transitionWithEffects( + state: AgentState, + event: { type: string; [key: string]: unknown }, + onEmit?: (part: EmittedPart) => void + ): { next: AgentState; emitted: EmittedPart[] }; }; }; @@ -35,8 +46,8 @@ type RunState = { function createSessionId(): string { if ( - typeof globalThis.crypto !== 'undefined' && - typeof globalThis.crypto.randomUUID === 'function' + typeof globalThis.crypto !== 'undefined' + && typeof globalThis.crypto.randomUUID === 'function' ) { return globalThis.crypto.randomUUID(); } @@ -69,6 +80,62 @@ function createRun( runState: RunState, emitter = createRunEmitter() ): AgentRun { + let releaseStart!: () => void; + let operation = new Promise((resolve) => { + releaseStart = resolve; + }); + let startScheduled = false; + let terminalEmitted = false; + + function emitPart(part: EmittedPart) { + emitter.emit('part', part); + emitter.emit(part.type, part); + } + + function enqueue(op: () => Promise): Promise { + const result = operation.then(op); + operation = result.then( + () => undefined, + () => undefined + ); + + return result; + } + + function emitTerminalIfNeeded() { + if (terminalEmitted) { + return; + } + + if (runState.snapshot.status === 'done') { + terminalEmitted = true; + emitter.emit('runtime', { + type: 'session.completed', + sessionId: runState.runtime.sessionId, + at: Date.now(), + }); + emitter.emit('done', { + output: runState.snapshot.output, + snapshot: runState.snapshot, + }); + return; + } + + if (runState.snapshot.status === 'error') { + terminalEmitted = true; + emitter.emit('runtime', { + type: 'session.failed', + sessionId: runState.runtime.sessionId, + error: runState.snapshot.error, + at: Date.now(), + }); + emitter.emit('error', { + error: runState.snapshot.error, + snapshot: runState.snapshot, + }); + } + } + async function persistSnapshot() { runState.snapshot = runtimeMachine.__runtime.toSnapshot( runState.current, @@ -86,8 +153,10 @@ function createRun( type: 'snapshot.persisted', sessionId: runState.runtime.sessionId, afterSequence: runState.lastSequence, + at: Date.now(), }); emitter.emit('state', runState.snapshot); + emitTerminalIfNeeded(); } async function appendMachineEvent(event: JournalEvent) { @@ -103,15 +172,19 @@ function createRun( while (runState.current.status === 'active') { const effectEvent = await runtimeMachine.__runtime.getEffectEvent( runState.current, - (part) => { - emitter.emit(part.type, part); - } + emitPart ); if (effectEvent) { - await appendMachineEvent(effectEvent); + const resolved = runtimeMachine.__runtime.resolveEffectTransition( + runState.current, + effectEvent, + emitPart + ); + + await appendMachineEvent(resolved.event); runState.current = runtimeMachine.__runtime.withRuntimeMetadata( - machine.transition(runState.current, effectEvent), + resolved.next, runState.runtime ); await persistSnapshot(); @@ -126,6 +199,20 @@ function createRun( } } + function scheduleStart() { + if (startScheduled) { + return; + } + + startScheduled = true; + void enqueue(async () => { + await settle(); + }); + queueMicrotask(() => { + releaseStart(); + }); + } + return { get sessionId() { return runState.runtime.sessionId; @@ -140,16 +227,28 @@ function createRun( }, async send(event) { - const journalEvent = toJournalEvent(event); - const next = machine.transition(runState.current, journalEvent); + if (isReservedInternalEventType(event.type)) { + throw new Error( + `Cannot send reserved internal event '${event.type}' to a session` + ); + } - await appendMachineEvent(journalEvent); - runState.current = runtimeMachine.__runtime.withRuntimeMetadata( - next, - runState.runtime - ); - await persistSnapshot(); - await settle(); + return enqueue(async () => { + const journalEvent = toJournalEvent(event); + const next = runtimeMachine.__runtime.transitionWithEffects( + runState.current, + journalEvent, + emitPart + ).next; + + await appendMachineEvent(journalEvent); + runState.current = runtimeMachine.__runtime.withRuntimeMetadata( + next, + runState.runtime + ); + await persistSnapshot(); + await settle(); + }); }, on(type, handler) { @@ -163,12 +262,14 @@ function createRun( /** @internal */ async __settle() { - await settle(); + await enqueue(async () => { + await settle(); + }); }, /** @internal */ - __emit(type: string, event: unknown) { - emitter.emit(type, event); + __scheduleStart() { + scheduleStart(); }, } as AgentRun; } @@ -198,7 +299,7 @@ export async function startSession( ) as AgentRun & { __persistCurrent(): Promise; __settle(): Promise; - __emit(type: string, event: unknown): void; + __scheduleStart(): void; }; const initEvent = { @@ -208,14 +309,9 @@ export async function startSession( } satisfies JournalEvent; const record = await options.store.append(runtime.sessionId, initEvent); runState.lastSequence = record.sequence; - run.__emit('machine.event', { ...initEvent, sequence: record.sequence }); - run.__emit('runtime', { - type: 'session.started', - sessionId: runtime.sessionId, - }); await run.__persistCurrent(); - await run.__settle(); + run.__scheduleStart(); return run; } @@ -258,21 +354,14 @@ export async function restoreSession( ) as AgentRun & { __persistCurrent(): Promise; __settle(): Promise; - __emit(type: string, event: unknown): void; + __scheduleStart(): void; }; - if (initEvent && !persisted) { - run.__emit('machine.event', initEvent); - } - const replayTail = await options.store.loadEvents( options.sessionId, runState.lastSequence ); - - if (!persisted) { - run.__emit('state', runState.snapshot); - } + let replayed = false; for (const event of replayTail) { runState.current = runtimeMachine.__runtime.withRuntimeMetadata( @@ -284,22 +373,13 @@ export async function restoreSession( runState.current, runState.runtime ); - run.__emit('machine.event', event); - run.__emit('state', runState.snapshot); + replayed = true; } - if (persisted) { - run.__emit('state', runState.snapshot); + if (!persisted || replayed) { + await run.__persistCurrent(); } - - run.__emit('runtime', { - type: 'session.restored', - sessionId: runState.runtime.sessionId, - afterSequence: runState.lastSequence, - }); - - await run.__persistCurrent(); - await run.__settle(); + run.__scheduleStart(); return run; } diff --git a/src/session-runtime.test.ts b/src/session-runtime.test.ts index f6efd81..10df0ff 100644 --- a/src/session-runtime.test.ts +++ b/src/session-runtime.test.ts @@ -1,11 +1,21 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; +import { z } from 'zod'; import { createAgentMachine, createMemoryRunStore, startSession, } from './index.js'; -test('startSession creates a session and persists xstate.init', async () => { +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + + return { promise, resolve }; +} + +test('startSession creates a session, persists xstate.init, and returns before start effects run', async () => { const machine = createAgentMachine({ id: 'session-runtime', context: () => ({ count: 0 }), @@ -33,16 +43,23 @@ test('startSession creates a session and persists xstate.init', async () => { const persisted = await store.loadLatestSnapshot(run.sessionId); expect(run.sessionId).toBe(snapshot.sessionId); - expect(run.status).toBe('pending'); expect(snapshot).toEqual( expect.objectContaining({ sessionId: run.sessionId, value: 'idle', - status: 'pending', + status: 'active', context: { count: 0 }, params: {}, }) ); + await vi.waitFor(() => { + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'idle', + status: 'pending', + }) + ); + }); expect(journal).toEqual([ expect.objectContaining({ sequence: 1, @@ -58,3 +75,110 @@ test('startSession creates a session and persists xstate.init', async () => { }) ); }); + +test('serializes concurrent sends so each event applies from the latest snapshot', async () => { + const gates = [deferred(), deferred()]; + let invocations = 0; + const machine = createAgentMachine({ + id: 'serialized-send', + schemas: { + events: { + increment: z.object({ amount: z.number() }), + }, + }, + context: () => ({ count: 0 }), + initial: 'ready', + states: { + ready: { + on: { + increment: ({ event, context }) => ({ + target: 'working', + context: { count: context.count + event.amount }, + }), + }, + }, + working: { + resultSchema: z.object({ count: z.number() }), + invoke: async ({ context }) => { + const gate = gates[invocations++]!; + await gate.promise; + return { count: context.count }; + }, + onDone: ({ result }) => ({ + target: 'ready', + context: { count: result.count }, + }), + }, + }, + }); + + const run = await startSession(machine, { store: createMemoryRunStore() }); + await vi.waitFor(() => { + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'ready', + status: 'pending', + }) + ); + }); + + const first = run.send({ type: 'increment', amount: 1 }); + const second = run.send({ type: 'increment', amount: 10 }); + + await vi.waitFor(() => { + expect(invocations).toBe(1); + }); + + gates[0]!.resolve(); + await first; + await vi.waitFor(() => { + expect(invocations).toBe(2); + }); + + gates[1]!.resolve(); + await second; + + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'ready', + status: 'pending', + context: { count: 11 }, + }) + ); +}); + +test('rejects reserved internal events from run.send', async () => { + const machine = createAgentMachine({ + id: 'reserved-events', + context: () => ({ count: 0 }), + initial: 'ready', + states: { + ready: { + on: { + go: { target: 'done' }, + }, + }, + done: { type: 'final' }, + }, + }); + + const run = await startSession(machine, { store: createMemoryRunStore() }); + await vi.waitFor(() => { + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'ready', + status: 'pending', + }) + ); + }); + + await expect(run.send({ type: 'xstate.init' })).rejects.toThrow( + /reserved internal event/i + ); + await expect( + run.send({ type: 'xstate.done.invoke.worker' }) + ).rejects.toThrow(/reserved internal event/i); + await expect( + run.send({ type: 'xstate.error.invoke.worker' }) + ).rejects.toThrow(/reserved internal event/i); +}); diff --git a/src/streaming.test.ts b/src/streaming.test.ts index 8ec5530..ec782f6 100644 --- a/src/streaming.test.ts +++ b/src/streaming.test.ts @@ -6,7 +6,20 @@ import { startSession, } from './index.js'; -test('emitted parts flow through the run-level API', async () => { +function once( + run: { on(type: string, handler: (event: unknown) => void): () => void }, + type: string +) { + return new Promise((resolve) => { + let off = () => {}; + off = run.on(type, (event) => { + off(); + resolve(event as T); + }); + }); +} + +test('returns a live run before initial invoke output and emits ephemeral parts', async () => { const machine = createAgentMachine({ id: 'streaming-parts', schemas: { @@ -40,12 +53,17 @@ test('emitted parts flow through the run-level API', async () => { const store = createMemoryRunStore(); const run = await startSession(machine, { store }); const parts: Array<{ type: string; delta: string }> = []; + const allParts: Array<{ type: string; delta: string }> = []; const states: string[] = []; const events: string[] = []; + const done = once<{ output: { text: string } }>(run, 'done'); const offPart = run.on('textPart', (part) => { parts.push(part as { type: string; delta: string }); }); + const offAnyPart = run.on('part', (part) => { + allParts.push(part as { type: string; delta: string }); + }); const offState = run.on('state', (snapshot) => { states.push((snapshot as { value: string }).value); }); @@ -53,20 +71,91 @@ test('emitted parts flow through the run-level API', async () => { events.push((event as { type: string }).type); }); + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'writing', + status: 'active', + }) + ); + + await done; + expect(parts).toEqual([ { type: 'textPart', delta: 'hel' }, { type: 'textPart', delta: 'lo' }, ]); - expect(states).toContain('writing'); - expect(states[states.length - 1]).toBe('done'); + expect(allParts).toEqual([ + { type: 'textPart', delta: 'hel' }, + { type: 'textPart', delta: 'lo' }, + ]); + expect(states.length).toBeGreaterThan(0); + expect(states.every((state) => state === 'done')).toBe(true); expect(events).toContain('xstate.done.invoke.writing'); expect(run.getSnapshot().output).toEqual({ text: 'hello' }); offPart(); + offAnyPart(); offState(); offEvent(); }); +test('does not replay prior events to late subscribers', async () => { + const machine = createAgentMachine({ + id: 'late-streaming-subscriber', + schemas: { + emitted: { + textPart: z.object({ delta: z.string() }), + }, + }, + context: () => ({ finalText: '' }), + initial: 'writing', + states: { + writing: { + resultSchema: z.object({ text: z.string() }), + invoke: async (_args, enq) => { + enq.emit({ type: 'textPart', delta: 'hel' }); + enq.emit({ type: 'textPart', delta: 'lo' }); + return { text: 'hello' }; + }, + onDone: ({ result }) => ({ + target: 'done', + context: { finalText: result.text }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ text: context.finalText }), + }, + }, + }); + + const run = await startSession(machine, { + store: createMemoryRunStore(), + }); + await once(run, 'done'); + + const lateParts: Array<{ type: string; delta: string }> = []; + const replayedStates: string[] = []; + const replayedEvents: string[] = []; + + run.on('textPart', (part) => { + lateParts.push(part as { type: string; delta: string }); + }); + run.on('state', (snapshot) => { + replayedStates.push((snapshot as { value: string }).value); + }); + run.on('machine.event', (event) => { + replayedEvents.push((event as { type: string }).type); + }); + run.on('done', () => { + replayedEvents.push('done'); + }); + + expect(lateParts).toEqual([]); + expect(replayedStates).toEqual([]); + expect(replayedEvents).toEqual([]); +}); + test('invalid emitted parts are rejected', async () => { const machine = createAgentMachine({ id: 'streaming-invalid-parts', @@ -90,6 +179,7 @@ test('invalid emitted parts are rejected', async () => { const run = await startSession(machine, { store: createMemoryRunStore(), }); + await once(run, 'error'); expect(run.getSnapshot()).toEqual( expect.objectContaining({ @@ -101,3 +191,62 @@ test('invalid emitted parts are rejected', async () => { }) ); }); + +test('transition handlers can emit live effects without journaling them', async () => { + const machine = createAgentMachine({ + id: 'transition-handler-emits', + schemas: { + emitted: { + textPart: z.object({ delta: z.string() }), + }, + events: { + send: z.object({}), + }, + }, + context: () => ({ sent: false }), + initial: 'ready', + states: { + ready: { + on: { + send: ({ context }, enq) => { + enq.emit({ type: 'textPart', delta: 'sending' }); + + return { + target: 'done', + context: { sent: !context.sent }, + }; + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => context, + }, + }, + }); + + const store = createMemoryRunStore(); + const run = await startSession(machine, { store }); + const parts: string[] = []; + + run.on('textPart', (part) => { + parts.push((part as { delta: string }).delta); + }); + + await run.send({ type: 'send' }); + + expect(parts).toEqual(['sending']); + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + context: { sent: true }, + }) + ); + + const journal = await store.loadEvents(run.sessionId); + expect(journal.map((event) => event.type)).toEqual([ + 'xstate.init', + 'send', + ]); +}); diff --git a/src/target-types.assert.ts b/src/target-types.assert.ts new file mode 100644 index 0000000..ee3e9a0 --- /dev/null +++ b/src/target-types.assert.ts @@ -0,0 +1,204 @@ +import { z } from 'zod'; +import { createAgentMachine } from './machine.js'; + +const machine = createAgentMachine({ + id: 'typed-targets', + context: () => ({ count: 0 }), + initial: 'idle', + states: { + idle: { + on: { + advance: () => ({ + target: 'done', + }), + }, + }, + done: { + type: 'final', + }, + }, + schemas: { + events: { + advance: z.object({ + type: z.literal('advance'), + }), + }, + }, +}); + +machine.transition(machine.getInitialState(), { type: 'advance' }); + +createAgentMachine({ + id: 'typed-target-params', + context: () => ({ count: 0 }), + initial: 'idle', + states: { + idle: { + on: { + advance: () => ({ + target: 'working', + params: { + index: 0, + }, + }), + }, + }, + working: { + paramsSchema: z.object({ + index: z.number(), + }), + }, + }, + schemas: { + events: { + advance: z.object({ + type: z.literal('advance'), + }), + }, + }, +}); + +createAgentMachine({ + id: 'missing-required-target-params', + context: () => ({ count: 0 }), + initial: 'idle', + states: { + // @ts-expect-error params should be required when the target has paramsSchema + idle: { + on: { + advance: () => ({ + target: 'working', + }), + }, + }, + working: { + paramsSchema: z.object({ + index: z.number(), + }), + }, + }, + schemas: { + events: { + advance: z.object({ + type: z.literal('advance'), + }), + }, + }, +}); + +createAgentMachine({ + id: 'invalid-target', + context: () => ({ count: 0 }), + initial: 'idle', + states: { + // @ts-expect-error invalid targets should be rejected at author time + idle: { + on: { + advance: () => ({ + target: 'missing', + }), + }, + }, + done: { + type: 'final', + }, + }, + schemas: { + events: { + advance: z.object({ + type: z.literal('advance'), + }), + }, + }, +}); + +createAgentMachine({ + id: 'unexpected-target-params', + context: () => ({ count: 0 }), + initial: 'idle', + states: { + idle: { + on: { + // @ts-expect-error params should be rejected when the target has no paramsSchema + advance: () => ({ + target: 'done', + params: { + anything: true, + }, + }), + }, + }, + done: { + type: 'final', + }, + }, + schemas: { + events: { + advance: z.object({ + type: z.literal('advance'), + }), + }, + }, +}); + +createAgentMachine({ + id: 'invalid-target-params', + context: () => ({ count: 0 }), + initial: 'idle', + states: { + idle: { + on: { + // @ts-expect-error target params should match the target state's params schema + advance: () => ({ + target: 'working', + params: { + wrong: true, + }, + }), + }, + }, + working: { + paramsSchema: z.object({ + index: z.number(), + }), + }, + }, + schemas: { + events: { + advance: z.object({ + type: z.literal('advance'), + }), + }, + }, +}); + +createAgentMachine({ + id: 'invalid-target-param-types', + context: () => ({ count: 0 }), + initial: 'idle', + states: { + idle: { + on: { + // @ts-expect-error target params should match the target param field types + advance: () => ({ + target: 'working', + params: { + index: 'hello', + }, + }), + }, + }, + working: { + paramsSchema: z.object({ + index: z.number(), + }), + }, + }, + schemas: { + events: { + advance: z.object({ + type: z.literal('advance'), + }), + }, + }, +}); diff --git a/src/types.ts b/src/types.ts index 4f6219d..961dc33 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,10 @@ export interface InvokeEnqueue { emit(part: EmittedPart): void; } +type IsExactlyUnknown = unknown extends T + ? ([T] extends [unknown] ? true : false) + : false; + // ─── Durable Session Vocabulary ─── export type { JournalEvent } from './runtime/events.js'; @@ -59,10 +63,32 @@ export interface AgentAdapter { // ─── Transition ─── -export interface TransitionResult< +export type TransitionResult< + TContext extends Record = Record, + TTarget extends string = string, + TParamsByTarget extends Record = {}, +> = + | { + target?: undefined; + context?: Partial; + params?: never; + } + | { + [K in TTarget]: { + target: K; + context?: Partial; + } & (K extends keyof TParamsByTarget + ? IsExactlyUnknown extends true + ? { params?: never } + : { params: TParamsByTarget[K] } + : { params?: never }) + }[TTarget]; + +export interface InitialTransitionResult< TContext extends Record = Record, + TTarget extends string = string, > { - target?: string; + target: TTarget; context?: Partial; params?: Record; } @@ -71,6 +97,8 @@ export interface TransitionResult< export interface StateConfig< TContext extends Record = Record, + TTarget extends string = string, + TParamsByTarget extends Record = {}, > { type?: 'final' | 'choice'; paramsSchema?: StandardSchemaV1; @@ -80,8 +108,8 @@ export interface StateConfig< params: Record; signal?: AbortSignal; }, enq: InvokeEnqueue) => Promise; - onDone?: (args: { result: any; context: TContext }) => TransitionResult; - on?: Record | ((args: { event: any; context: TContext }) => TransitionResult)>; + onDone?: (args: { result: any; context: TContext }) => TransitionResult; + on?: Record | ((args: { event: any; context: TContext }, enq: InvokeEnqueue) => TransitionResult)>; events?: Record; output?: (args: { context: TContext }) => unknown; // choice-specific @@ -252,14 +280,16 @@ export interface DecideConfig< TContext extends Record = Record, TParams extends Record = Record, TOptions extends Record = Record, + TTarget extends string = string, + TParamsByTarget extends Record = {}, > { model: string; adapter?: AgentAdapter; prompt: string | ((args: { context: TContext; params: TParams }) => string); options: TOptions; reasoning?: boolean; - onDone: (args: { result: DecideResultFor; context: TContext }) => TransitionResult; - on?: Record TransitionResult>; + onDone: (args: { result: DecideResultFor; context: TContext }) => TransitionResult; + on?: Record TransitionResult>; } // ─── Classify ─── @@ -268,14 +298,16 @@ export interface ClassifyConfig< TContext extends Record = Record, TParams extends Record = Record, TCategories extends Record = Record, + TTarget extends string = string, + TParamsByTarget extends Record = {}, > { model: string; adapter?: AgentAdapter; prompt: string | ((args: { context: TContext; params: TParams }) => string); into: TCategories; examples?: Array<{ input: string; category: keyof TCategories & string }>; - onDone: (args: { result: { category: keyof TCategories & string }; context: TContext }) => TransitionResult; - on?: Record TransitionResult>; + onDone: (args: { result: { category: keyof TCategories & string }; context: TContext }) => TransitionResult; + on?: Record TransitionResult>; } // ─── Trace ─── diff --git a/src/utils.ts b/src/utils.ts index f6cd7c0..8ac89c8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,6 @@ import type { AgentState, + InitialTransitionResult, MachineConfig, StandardSchemaResult, StandardSchemaV1, @@ -54,7 +55,7 @@ export type StateConfigAny = { enq: { emit(part: { type: string; [key: string]: unknown }): void } ) => Promise; onDone?: (args: { result: unknown; context: Record }) => TransitionResult; - on?: Record; context: Record }) => TransitionResult)>; + on?: Record; context: Record }, enq: { emit(part: { type: string; [key: string]: unknown }): void }) => TransitionResult)>; output?: (args: { context: Record }) => unknown; resultSchema?: StandardSchemaV1; model?: string; @@ -86,12 +87,12 @@ export function resolveInitial( | ((args: { context: Record; params: Record; - }) => TransitionResult), + }) => InitialTransitionResult), args: { context: Record; params: Record; } -): TransitionResult { +): InitialTransitionResult { if (typeof initial === 'string') { return { target: initial }; } @@ -205,6 +206,14 @@ export function isErrorInvokeEventType( return eventType === `xstate.error.invoke.${stateValue}`; } +export function isReservedInternalEventType(eventType: string): boolean { + return ( + eventType === 'xstate.init' + || eventType.startsWith('xstate.done.invoke.') + || eventType.startsWith('xstate.error.invoke.') + ); +} + export function serializeError(error: unknown): unknown { if (error instanceof Error) { return { From 186aefb76e8e39d8d14d53c7677881dc267b77d7 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 18 Apr 2026 14:33:16 -0400 Subject: [PATCH 17/50] feat: expand durable agent runtime and langgraph coverage --- ...04-08-langgraph-core-replacement-design.md | 21 +- examples/adapter.ts | 69 ++-- examples/branching.ts | 6 + examples/chatbot.ts | 42 ++- examples/classify.ts | 30 +- examples/customer-service-sim.ts | 5 + examples/decide.ts | 59 ++-- examples/email.ts | 94 ++--- examples/hitl.ts | 8 +- examples/index.ts | 3 + examples/joke.ts | 7 + examples/jugs.ts | 16 +- examples/map-reduce.ts | 5 + examples/multi-agent-network.ts | 334 ++++++++++++++++++ examples/newspaper.ts | 6 + examples/plan-and-execute.ts | 16 +- examples/raffle.ts | 7 + examples/react-agent.ts | 17 +- examples/reflection.ts | 6 + examples/rewoo.ts | 235 ++++++++++++ examples/river-crossing.ts | 16 +- examples/simple.ts | 1 + examples/subflow.ts | 7 +- examples/supervisor.ts | 252 +++++++++++++ examples/tool-calling.ts | 18 +- examples/tutor.ts | 5 + src/agent.test.ts | 275 +++++++++----- src/classify.ts | 139 +++----- src/decide.ts | 144 ++++---- src/examples.test.ts | 185 +++++++++- src/graph/index.ts | 2 +- src/index.ts | 9 +- src/invoke-events.test.ts | 13 +- .../multi-agent-network.test.ts | 60 ++++ .../prebuilt-react.test.ts | 13 +- src/langgraph-equivalents/rewoo.test.ts | 56 +++ src/langgraph-equivalents/streaming.test.ts | 11 +- src/langgraph-equivalents/supervisor.test.ts | 62 ++++ .../tool-calling.test.ts | 13 +- src/machine.ts | 175 +++++---- src/persistence.test.ts | 12 +- src/prebuilt/react.ts | 42 +-- src/runtime/session.ts | 94 ++++- src/session-runtime.test.ts | 2 +- src/session-types.test.ts | 2 +- src/stream-snapshot.test.ts | 12 +- src/streaming.test.ts | 39 +- src/target-types.assert.ts | 109 +++++- src/types.ts | 164 +++++---- src/utils.ts | 26 +- 50 files changed, 2229 insertions(+), 715 deletions(-) create mode 100644 examples/multi-agent-network.ts create mode 100644 examples/rewoo.ts create mode 100644 examples/supervisor.ts create mode 100644 src/langgraph-equivalents/multi-agent-network.test.ts create mode 100644 src/langgraph-equivalents/rewoo.test.ts create mode 100644 src/langgraph-equivalents/supervisor.test.ts diff --git a/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md b/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md index 0ff6aed..f30ecb3 100644 --- a/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md +++ b/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md @@ -125,10 +125,13 @@ interface AgentRun { getSnapshot(): AgentSnapshot; send(event: { type: string; [key: string]: unknown }): Promise; on(type: string, handler: (event: unknown) => void): () => void; - [Symbol.asyncIterator](): AsyncIterator; } ``` +An async-iterator surface is still useful, but it is additive. The emitter-style `on(...)` API is the required phase-1 contract. + +`on(...)` is a live listener only. It should not be treated as a history or replay API. Historical actor events belong to the journal/store layer. + ### Durable Execution Boundaries Phase 1 durability should exist at machine boundaries, not inside arbitrary user async code. @@ -268,15 +271,15 @@ type AgentSnapshot = { status: "active" | "done" | "error" | "pending"; createdAt: number; sessionId: string; + params: Record>; output?: unknown; error?: SerializedError; }; type PersistedSnapshot = { sessionId: string; - sequence: number; snapshot: AgentSnapshot; - lastJournalIndex: number; + afterSequence: number; createdAt: number; }; ``` @@ -294,7 +297,7 @@ with additional metadata such as: - optional `output` - optional `error` -The `sequence` field exists so storage can identify which snapshot is the latest persisted derivation and so replay can resume from a known journal offset. It should track journal position rather than inventing a separate semantic version. +The `afterSequence` field identifies the last replayable journal event already reflected in the snapshot, so replay can resume from a known journal offset without inventing a separate semantic version. ### Replay Model @@ -362,17 +365,21 @@ type RunEmitterEvent = Where `machine.event` refers to replayable actor events and `runtime` refers to derived lifecycle records useful for debugging and orchestration. +These event shapes describe what a live run may emit. They do not imply that late subscribers receive replayed history through `on(...)`. + Suggested runtime event family: ```ts type RuntimeEvent = - | { type: "state.entered"; value: string; at: number } - | { type: "transition.applied"; from: string; to: string; at: number } - | { type: "snapshot.saved"; sessionId: string; sequence: number; at: number } + | { type: "session.started"; sessionId: string; at: number } + | { type: "session.restored"; sessionId: string; afterSequence: number; at: number } + | { type: "snapshot.persisted"; sessionId: string; afterSequence: number; at: number } | { type: "session.completed"; sessionId: string; at: number } | { type: "session.failed"; sessionId: string; error: SerializedError; at: number }; ``` +Derived events such as `state.entered` and `transition.applied` are still useful for richer inspection, but they are not required for this phase. + ### Stream Parts For common model/tool streaming shapes, align with Vercel AI SDK-style part conventions where practical: diff --git a/examples/adapter.ts b/examples/adapter.ts index c3c2ee3..a88e6c1 100644 --- a/examples/adapter.ts +++ b/examples/adapter.ts @@ -3,6 +3,7 @@ import { createAdapter, createAgentMachine, decide, + decideResultSchema, type AgentAdapter, } from '../src/index.js'; import { @@ -18,47 +19,59 @@ export function createAdapterExample( decide: createOpenAiDecisionAdapter().decide, }) ) { + const routeOptions = { + billing: { + description: 'Send the request to billing support.', + schema: z.object({ confidence: z.number().min(0).max(1) }), + }, + general: { + description: 'Handle the request in general support.', + schema: z.object({ confidence: z.number().min(0).max(1) }), + }, + } as const; + return createAgentMachine({ id: 'adapter-example', schemas: { input: z.object({ message: z.string() }), + output: z.object({ + route: z.string().nullable(), + confidence: z.number().nullable(), + }), }, context: (input) => ({ message: input.message, route: null as string | null, confidence: null as number | null, }), - adapter, initial: 'route', states: { - route: decide({ - model: 'openai/gpt-5.4-nano', - prompt: ({ context }) => [ - 'Route this support request.', - 'Return billing only when the request is clearly about invoices, refunds, or charges.', - 'Otherwise return general.', - '', - context.message, - ].join('\n'), - options: { - billing: { - description: 'Send the request to billing support.', - schema: z.object({ confidence: z.number().min(0).max(1) }), - }, - general: { - description: 'Handle the request in general support.', - schema: z.object({ confidence: z.number().min(0).max(1) }), - }, + route: { + resultSchema: decideResultSchema(routeOptions), + invoke: async ({ context }) => + decide({ + adapter, + model: 'openai/gpt-5.4-nano', + prompt: [ + 'Route this support request.', + 'Return billing only when the request is clearly about invoices, refunds, or charges.', + 'Otherwise return general.', + '', + context.message, + ].join('\n'), + options: routeOptions, + reasoning: false, + }), + onDone: ({ result }) => { + return { + target: 'done', + context: { + route: result.choice, + confidence: result.data.confidence, + }, + }; }, - reasoning: false, - onDone: ({ result }) => ({ - target: 'done', - context: { - route: result.choice, - confidence: result.data.confidence, - }, - }), - }), + }, done: { type: 'final', output: ({ context }) => ({ diff --git a/examples/branching.ts b/examples/branching.ts index ddaef9f..d50d78d 100644 --- a/examples/branching.ts +++ b/examples/branching.ts @@ -35,6 +35,12 @@ export function createBranchingExample( id: 'branching-example', schemas: { input: z.object({ topic: z.string() }), + output: z.object({ + docs: z.string().nullable(), + issues: z.string().nullable(), + code: z.string().nullable(), + summary: z.string().nullable(), + }), }, context: (input) => ({ topic: input.topic, diff --git a/examples/chatbot.ts b/examples/chatbot.ts index b3a9616..ab390c1 100644 --- a/examples/chatbot.ts +++ b/examples/chatbot.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { createAgentMachine, decide, type AgentAdapter } from '../src/index.js'; +import { createAgentMachine, decide, decideResultSchema, type AgentAdapter } from '../src/index.js'; import { closePrompt, createOpenAiDecisionAdapter, @@ -19,6 +19,11 @@ export function createChatbotExample( reply?: (transcript: string[]) => Promise>; } = {} ) { + const decisionOptions = { + respond: { description: 'Reply to the user and continue chatting.' }, + end: { description: 'End the conversation now.' }, + } as const; + const adapter = options.adapter ?? (process.env.OPENAI_API_KEY ? createOpenAiDecisionAdapter() : undefined); @@ -39,6 +44,11 @@ export function createChatbotExample( return createAgentMachine({ id: 'chatbot-example', schemas: { + output: z.object({ + transcript: z.array(z.string()), + ended: z.boolean(), + lastAssistantMessage: z.string().nullable(), + }), events: { 'user.message': z.object({ message: z.string() }), 'user.exit': z.object({}), @@ -50,7 +60,6 @@ export function createChatbotExample( lastAssistantMessage: null as string | null, ended: false, }), - adapter, initial: 'listening', states: { listening: { @@ -68,24 +77,25 @@ export function createChatbotExample( }, }, }, - deciding: decide({ - model: 'openai/gpt-5.4-nano', - prompt: ({ context }) => - [ - 'Decide whether the assistant should answer or end the conversation.', - 'End only when the user is clearly saying goodbye or asking to stop.', - '', - (context as { transcript: string[] }).transcript.join('\n'), - ].join('\n'), - options: { - respond: { description: 'Reply to the user and continue chatting.' }, - end: { description: 'End the conversation now.' }, - }, + deciding: { + resultSchema: decideResultSchema(decisionOptions), + invoke: async ({ context }) => + decide({ + adapter, + model: 'openai/gpt-5.4-nano', + prompt: [ + 'Decide whether the assistant should answer or end the conversation.', + 'End only when the user is clearly saying goodbye or asking to stop.', + '', + context.transcript.join('\n'), + ].join('\n'), + options: decisionOptions, + }), onDone: ({ result }) => ({ target: result.choice === 'end' ? 'done' : 'replying', context: result.choice === 'end' ? { ended: true } : {}, }), - }), + }, replying: { resultSchema: replySchema, invoke: async ({ context }) => reply(context.transcript), diff --git a/examples/classify.ts b/examples/classify.ts index d2befb2..eb4eaab 100644 --- a/examples/classify.ts +++ b/examples/classify.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { createAgentMachine, classify, + classifyResultSchema, type AgentAdapter, } from '../src/index.js'; import { @@ -15,33 +16,40 @@ import { export function createClassifyExample( adapter: AgentAdapter = createOpenAiDecisionAdapter() ) { + const categories = { + billing: { description: 'Payments, invoices, refunds, and charges.' }, + technical: { description: 'Bugs, outages, and product issues.' }, + general: { description: 'Everything else.' }, + } as const; + return createAgentMachine({ id: 'classify-example', schemas: { input: z.object({ request: z.string() }), + output: z.object({ category: z.string().nullable() }), }, context: (input) => ({ request: input.request, category: null as string | null, }), - adapter, initial: 'routing', states: { - routing: classify({ - model: 'openai/gpt-5.4-nano', - prompt: ({ context }) => `Classify this support request:\n\n${context.request}`, - into: { - billing: { description: 'Payments, invoices, refunds, and charges.' }, - technical: { description: 'Bugs, outages, and product issues.' }, - general: { description: 'Everything else.' }, - }, + routing: { + resultSchema: classifyResultSchema(categories), + invoke: async ({ context }) => + classify({ + adapter, + model: 'openai/gpt-5.4-nano', + prompt: `Classify this support request:\n\n${context.request}`, + into: categories, + }), onDone: ({ result }) => ({ target: 'done', context: { category: result.category }, }), - }), + }, done: { - // use params; category should alwyas be defined when entering + // use input; category should always be defined when entering type: 'final', output: ({ context }) => ({ category: context.category }), }, diff --git a/examples/customer-service-sim.ts b/examples/customer-service-sim.ts index bd21acf..3bade57 100644 --- a/examples/customer-service-sim.ts +++ b/examples/customer-service-sim.ts @@ -71,6 +71,11 @@ export function createCustomerServiceSimExample( id: 'customer-service-sim-example', schemas: { input: z.object({ issue: z.string() }), + output: z.object({ + transcript: z.array(z.string()), + turnCount: z.number(), + outcome: z.string().nullable(), + }), }, context: (input) => ({ issue: input.issue, diff --git a/examples/decide.ts b/examples/decide.ts index 3288ae2..723a3ef 100644 --- a/examples/decide.ts +++ b/examples/decide.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { createAgentMachine, decide, + decideResultSchema, type AgentAdapter, } from '../src/index.js'; import { @@ -13,41 +14,51 @@ import { } from './_run.js'; export function createDecideExample(adapter: AgentAdapter = createOpenAiDecisionAdapter()) { + const triageOptions = { + reply: { + description: 'Reply directly to the customer.', + schema: z.object({ message: z.string() }), + }, + askForClarification: { + description: 'Ask one follow-up question before proceeding.', + schema: z.object({ question: z.string() }), + }, + escalate: { + description: 'Escalate to a human specialist.', + schema: z.object({ team: z.string() }), + }, + } as const; + return createAgentMachine({ id: 'decide-example', schemas: { input: z.object({ request: z.string() }), + output: z.object({ + action: z.string().nullable(), + payload: z.record(z.string(), z.unknown()).nullable(), + }), }, context: (input) => ({ request: input.request, action: null as string | null, payload: null as Record | null, }), - adapter, initial: 'triage', states: { - triage: decide({ - model: 'openai/gpt-5.4-nano', - prompt: ({ context }) => [ - 'Choose the best next step for this support request.', - 'Prefer asking a single clarification question when key facts are missing.', - '', - `Request: ${context.request}`, - ].join('\n'), - options: { - reply: { - description: 'Reply directly to the customer.', - schema: z.object({ message: z.string() }), - }, - askForClarification: { - description: 'Ask one follow-up question before proceeding.', - schema: z.object({ question: z.string() }), - }, - escalate: { - description: 'Escalate to a human specialist.', - schema: z.object({ team: z.string() }), - }, - }, + triage: { + resultSchema: decideResultSchema(triageOptions), + invoke: async ({ context }) => + decide({ + adapter, + model: 'openai/gpt-5.4-nano', + prompt: [ + 'Choose the best next step for this support request.', + 'Prefer asking a single clarification question when key facts are missing.', + '', + `Request: ${context.request}`, + ].join('\n'), + options: triageOptions, + }), onDone: ({ result }) => ({ target: 'done', context: { @@ -55,7 +66,7 @@ export function createDecideExample(adapter: AgentAdapter = createOpenAiDecision payload: result.data, }, }), - }), + }, done: { type: 'final', output: ({ context }) => ({ diff --git a/examples/email.ts b/examples/email.ts index 1f33eb3..053959a 100644 --- a/examples/email.ts +++ b/examples/email.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { createAgentMachine, decide, type AgentAdapter } from '../src/index.js'; +import { createAgentMachine, decide, decideResultSchema, type AgentAdapter } from '../src/index.js'; import { closePrompt, createOpenAiDecisionAdapter, @@ -36,6 +36,18 @@ export function createEmailExample( ) => Promise>; } = {} ) { + const checkingOptions = { + askForClarification: { + description: 'Ask one or more clarifying questions before drafting.', + schema: z.object({ + questions: z.array(z.string()).min(1), + }), + }, + draft: { + description: 'Draft the email reply now.', + }, + } as const; + const adapter = options.adapter ?? (process.env.OPENAI_API_KEY ? createOpenAiDecisionAdapter() : undefined); @@ -109,6 +121,10 @@ export function createEmailExample( email: z.string(), instructions: z.string(), }), + output: z.object({ + replyEmail: z.string().nullable(), + clarifications: z.array(z.string()), + }), events: { 'user.answer': z.object({ answer: z.string() }), }, @@ -120,55 +136,41 @@ export function createEmailExample( questions: [] as string[], replyEmail: null as string | null, }), - adapter, initial: 'checking', states: { - checking: decide({ - model: 'openai/gpt-5.4-nano', - prompt: ({ context }) => { // why is this Record instead of a specific type? - const emailContext = context as { - email: string; - instructions: string; - clarifications: string[]; - }; - - return [ - 'Decide whether there is enough information to draft the reply email.', - 'Choose askForClarification only if key scheduling or identity details are missing.', - '', - `Email: ${emailContext.email}`, - `Instructions: ${emailContext.instructions}`, - `Clarifications: ${emailContext.clarifications.join(' | ') || 'none'}`, - ].join('\n'); - }, - options: { - askForClarification: { - description: 'Ask one or more clarifying questions before drafting.', - schema: z.object({ - questions: z.array(z.string()).min(1), - }), - }, - draft: { - description: 'Draft the email reply now.', - }, - }, + checking: { + resultSchema: decideResultSchema(checkingOptions), + invoke: async ({ context }) => + decide({ + adapter, + model: 'openai/gpt-5.4-nano', + prompt: [ + 'Decide whether there is enough information to draft the reply email.', + 'Choose askForClarification only if key scheduling or identity details are missing.', + '', + `Email: ${context.email}`, + `Instructions: ${context.instructions}`, + `Clarifications: ${context.clarifications.join(' | ') || 'none'}`, + ].join('\n'), + options: checkingOptions, + }), onDone: ({ result, context }) => { - const emailContext = context as { clarifications: string[] }; - - return ({ - target: - result.choice === 'askForClarification' && - emailContext.clarifications.length === 0 - ? 'clarifying' - : 'drafting', - context: - result.choice === 'askForClarification' && - emailContext.clarifications.length === 0 - ? { questions: result.data.questions } - : { questions: [] }, - }); + if ( + result.choice === 'askForClarification' + && context.clarifications.length === 0 + ) { + return { + target: 'clarifying', + context: { questions: result.data.questions }, + }; + } + + return { + target: 'drafting', + context: { questions: [] }, + }; }, - }), + }, clarifying: { on: { 'user.answer': ({ event, context }) => ({ diff --git a/examples/hitl.ts b/examples/hitl.ts index 31cd220..1074b87 100644 --- a/examples/hitl.ts +++ b/examples/hitl.ts @@ -32,6 +32,10 @@ export function createHitlExample( id: 'hitl-example', schemas: { input: z.object({ task: z.string() }), + output: z.object({ + draft: z.string().nullable().optional(), + cancelled: z.literal(true).optional(), + }), events: { 'user.message': z.object({ message: z.string() }), 'user.approve': z.object({}), @@ -70,11 +74,11 @@ export function createHitlExample( }, done: { type: 'final', - output: ({ context }) => ({ draft: context.draft }), + output: ({ context }) => ({ draft: context.draft ?? null }), }, cancelled: { type: 'final', - output: () => ({ cancelled: true }), + output: () => ({ cancelled: true as const }), }, }, }); diff --git a/examples/index.ts b/examples/index.ts index 3917d73..ce34ad0 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -9,13 +9,16 @@ export { createEmailExample } from './email.js'; export { createJokeExample } from './joke.js'; export { createJugsExample } from './jugs.js'; export { createMapReduceExample } from './map-reduce.js'; +export { createMultiAgentNetworkExample } from './multi-agent-network.js'; export { createNewspaperExample } from './newspaper.js'; export { createPlanAndExecuteExample } from './plan-and-execute.js'; export { createRaffleExample } from './raffle.js'; export { createReactAgentExample } from './react-agent.js'; +export { createRewooExample } from './rewoo.js'; export { createReflectionExample } from './reflection.js'; export { createRiverCrossingExample } from './river-crossing.js'; export { createBranchingExample } from './branching.js'; export { createSubflowExample } from './subflow.js'; +export { createSupervisorExample } from './supervisor.js'; export { createToolCallingExample } from './tool-calling.js'; export { createTutorExample } from './tutor.js'; diff --git a/examples/joke.ts b/examples/joke.ts index 413f864..fc4bdaf 100644 --- a/examples/joke.ts +++ b/examples/joke.ts @@ -52,6 +52,13 @@ export function createJokeExample( id: 'joke-example', schemas: { input: z.object({ topic: z.string() }), + output: z.object({ + topic: z.string(), + joke: z.string().nullable(), + rating: z.number().nullable(), + explanation: z.string().nullable(), + accepted: z.boolean(), + }), }, context: (input) => ({ topic: input.topic, diff --git a/examples/jugs.ts b/examples/jugs.ts index 53fcf8f..ae68c7e 100644 --- a/examples/jugs.ts +++ b/examples/jugs.ts @@ -56,6 +56,14 @@ function applyWaterJugMove( export function createJugsExample() { return createAgentMachine({ id: 'jugs-example', + schemas: { + output: z.object({ + jug3: z.number(), + jug5: z.number(), + steps: z.array(z.string()), + reasoning: z.array(z.string()), + }), + }, context: () => ({ jug3: 0, jug5: 0, @@ -79,21 +87,21 @@ export function createJugsExample() { return { target: 'applying' as const, - params: { move: result.move }, + input: { move: result.move }, context: { reasoning: nextReasoning }, }; }, }, applying: { - paramsSchema: z.object({ + inputSchema: z.object({ move: moveSchema.shape.move.exclude(['done']), }), resultSchema: applySchema, - invoke: async ({ context, params }) => + invoke: async ({ context, input }) => applyWaterJugMove( context.jug3, context.jug5, - params.move as 'fill5' | 'pour5to3' | 'empty3' + input.move as 'fill5' | 'pour5to3' | 'empty3' ), onDone: ({ result, context }) => ({ target: 'choosing', diff --git a/examples/map-reduce.ts b/examples/map-reduce.ts index ab881fd..2f6c255 100644 --- a/examples/map-reduce.ts +++ b/examples/map-reduce.ts @@ -32,6 +32,11 @@ export function createMapReduceExample( id: 'map-reduce-example', schemas: { input: z.object({ topic: z.string() }), + output: z.object({ + subjects: z.array(z.string()), + jokes: z.array(z.string()), + bestJoke: z.string().nullable(), + }), }, context: (input) => ({ topic: input.topic, diff --git a/examples/multi-agent-network.ts b/examples/multi-agent-network.ts new file mode 100644 index 0000000..65c0501 --- /dev/null +++ b/examples/multi-agent-network.ts @@ -0,0 +1,334 @@ +import { z } from 'zod'; +import { + createAgentMachine, + decide, + decideResultSchema, + type AgentAdapter, +} from '../src/index.js'; +import { + closePrompt, + createOpenAiDecisionAdapter, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const researchParamsSchema = z.object({ + focus: z.string(), +}); + +const writeParamsSchema = z.object({ + angle: z.string(), +}); + +const researchNotesSchema = z.object({ + notes: z.array(z.string()).min(2).max(5), +}); + +const researchHandoffSchema = z.object({ + notes: z.array(z.string()).min(2).max(5), + handoff: z.string(), +}); + +const draftSchema = z.object({ + draft: z.string(), +}); + +const draftHandoffSchema = z.object({ + draft: z.string(), + handoff: z.string(), +}); + +export function createMultiAgentNetworkExample( + options: { + adapter?: AgentAdapter; + research?: (args: { + topic: string; + focus: string; + }) => Promise>; + write?: (args: { + topic: string; + notes: string[]; + angle: string; + }) => Promise>; + } = {} +) { + const coordinatorOptions = { + research: { + description: 'Send the task to the research specialist.', + schema: researchParamsSchema, + }, + write: { + description: 'Send the task to the writing specialist.', + schema: writeParamsSchema, + }, + finalize: { + description: 'Stop the network and return the current result.', + }, + } as const; + + const adapter = options.adapter ?? createOpenAiDecisionAdapter(); + + const research = + options.research ?? + ((args: { topic: string; focus: string }) => + generateExampleObject({ + schema: researchNotesSchema, + system: 'You are a research specialist. Return concise notes only.', + prompt: [ + `Topic: ${args.topic}`, + `Focus: ${args.focus}`, + '', + 'Return 2 to 5 concise research notes that help another specialist continue the task.', + ].join('\n'), + })); + + const write = + options.write ?? + ((args: { topic: string; notes: string[]; angle: string }) => + generateExampleObject({ + schema: draftSchema, + system: 'You are a writing specialist. Turn notes into a concise draft.', + prompt: [ + `Topic: ${args.topic}`, + `Angle: ${args.angle}`, + '', + 'Notes:', + ...args.notes.map((note) => `- ${note}`), + '', + 'Write a short specialist draft.', + ].join('\n'), + })); + + const researchAgent = createAgentMachine({ + id: 'network-research-agent', + schemas: { + input: z.object({ + topic: z.string(), + focus: z.string(), + }), + output: z.object({ + notes: z.array(z.string()), + }), + }, + context: (input) => ({ + topic: input.topic, + focus: input.focus, + notes: [] as string[], + }), + initial: 'researching', + states: { + researching: { + resultSchema: researchNotesSchema, + invoke: async ({ context }) => + research({ + topic: context.topic, + focus: context.focus, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { notes: result.notes }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + notes: context.notes, + }), + }, + }, + }); + + const writerAgent = createAgentMachine({ + id: 'network-writer-agent', + schemas: { + input: z.object({ + topic: z.string(), + notes: z.array(z.string()), + angle: z.string(), + }), + output: z.object({ + draft: z.string(), + }), + }, + context: (input) => ({ + topic: input.topic, + notes: input.notes, + angle: input.angle, + draft: null as string | null, + }), + initial: 'writing', + states: { + writing: { + resultSchema: draftSchema, + invoke: async ({ context }) => + write({ + topic: context.topic, + notes: context.notes, + angle: context.angle, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { draft: result.draft }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + draft: context.draft ?? '', + }), + }, + }, + }); + + return createAgentMachine({ + id: 'multi-agent-network-example', + schemas: { + input: z.object({ topic: z.string() }), + output: z.object({ + topic: z.string(), + notes: z.array(z.string()), + draft: z.string().nullable(), + handoffs: z.array(z.string()), + }), + }, + context: (input) => ({ + topic: input.topic, + notes: [] as string[], + draft: null as string | null, + handoffs: [] as string[], + }), + initial: 'coordinating', + states: { + coordinating: { + resultSchema: decideResultSchema(coordinatorOptions), + invoke: async ({ context }) => + decide({ + adapter, + model: 'openai/gpt-5.4-nano', + prompt: [ + 'You are a coordinator deciding which specialist should act next.', + 'Route to research when the task needs more facts.', + 'Route to writing when there are enough notes to draft.', + 'Finalize only when a usable draft already exists.', + '', + `Topic: ${context.topic}`, + context.notes.length + ? `Notes:\n${context.notes.map((note) => `- ${note}`).join('\n')}` + : 'Notes: none yet', + context.draft ? `Current draft:\n${context.draft}` : 'Current draft: none yet', + context.handoffs.length + ? `Prior handoffs:\n${context.handoffs.map((handoff, index) => `${index + 1}. ${handoff}`).join('\n')}` + : 'Prior handoffs: none', + ].join('\n'), + options: coordinatorOptions, + }), + onDone: ({ result }) => { + if (result.choice === 'research') { + return { + target: 'researching', + input: { + focus: result.data.focus ?? 'gather the most useful supporting facts', + }, + }; + } + + if (result.choice === 'write') { + return { + target: 'writing', + input: { + angle: result.data.angle ?? 'produce the clearest concise draft', + }, + }; + } + + return { + target: 'done', + }; + }, + }, + researching: { + inputSchema: researchParamsSchema, + resultSchema: researchHandoffSchema, + invoke: async ({ context, input }) => { + const result = await researchAgent.execute( + researchAgent.getInitialState({ + topic: context.topic, + focus: input.focus, + }) + ); + + if (result.status !== 'done') { + throw new Error('Research agent did not finish'); + } + + return { + notes: result.output.notes, + handoff: `researcher:${input.focus}`, + }; + }, + onDone: ({ result, context }) => ({ + target: 'coordinating', + context: { + notes: result.notes, + handoffs: [...context.handoffs, result.handoff], + }, + }), + }, + writing: { + inputSchema: writeParamsSchema, + resultSchema: draftHandoffSchema, + invoke: async ({ context, input }) => { + const result = await writerAgent.execute( + writerAgent.getInitialState({ + topic: context.topic, + notes: context.notes, + angle: input.angle, + }) + ); + + if (result.status !== 'done') { + throw new Error('Writer agent did not finish'); + } + + return { + draft: result.output.draft, + handoff: `writer:${input.angle}`, + }; + }, + onDone: ({ result, context }) => ({ + target: 'coordinating', + context: { + draft: result.draft, + handoffs: [...context.handoffs, result.handoff], + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + topic: context.topic, + notes: context.notes, + draft: context.draft, + handoffs: context.handoffs, + }), + }, + }, + }); +} + +async function main() { + try { + const topic = await prompt('Topic'); + const machine = createMultiAgentNetworkExample(); + const result = await machine.execute(machine.getInitialState({ topic })); + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/newspaper.ts b/examples/newspaper.ts index b312d1b..ec74eb3 100644 --- a/examples/newspaper.ts +++ b/examples/newspaper.ts @@ -93,6 +93,12 @@ export function createNewspaperExample( id: 'newspaper-example', schemas: { input: z.object({ topic: z.string() }), + output: z.object({ + topic: z.string(), + article: z.string().nullable(), + revisionCount: z.number(), + searchResults: z.array(z.string()), + }), }, context: (input) => ({ topic: input.topic, diff --git a/examples/plan-and-execute.ts b/examples/plan-and-execute.ts index 658d673..86f7d08 100644 --- a/examples/plan-and-execute.ts +++ b/examples/plan-and-execute.ts @@ -82,6 +82,12 @@ export function createPlanAndExecuteExample( id: 'plan-and-execute-example', schemas: { input: z.object({ goal: z.string() }), + output: z.object({ + goal: z.string(), + plan: z.array(z.string()), + stepResults: z.array(z.string()), + answer: z.string().nullable(), + }), }, context: (input) => ({ goal: input.goal, @@ -97,18 +103,18 @@ export function createPlanAndExecuteExample( onDone: ({ result }) => ({ target: 'executing', context: { plan: result.plan }, - params: { index: 0 } + input: { index: 0 } }), }, executing: { - paramsSchema: z.object({ + inputSchema: z.object({ index: z.number().int().min(0), }), resultSchema: stepResultSchema, - invoke: async ({ context, params }) => + invoke: async ({ context, input }) => executeStep({ goal: context.goal, - step: context.plan[params.index] ?? '', + step: context.plan[input.index] ?? '', priorResults: context.stepResults, }), onDone: ({ result, context }) => { @@ -119,7 +125,7 @@ export function createPlanAndExecuteExample( return { target: 'executing' as const, context: { stepResults: nextStepResults }, - params: { index: nextIndex }, + input: { index: nextIndex }, }; } diff --git a/examples/raffle.ts b/examples/raffle.ts index 4714e98..b649860 100644 --- a/examples/raffle.ts +++ b/examples/raffle.ts @@ -32,6 +32,13 @@ export function createRaffleExample( return createAgentMachine({ id: 'raffle-example', schemas: { + output: z.object({ + entries: z.array(z.string()), + winner: z.string().nullable(), + firstRunnerUp: z.string().nullable(), + secondRunnerUp: z.string().nullable(), + explanation: z.string().nullable(), + }), events: { 'user.entry': z.object({ entry: z.string() }), 'user.draw': z.object({}), diff --git a/examples/react-agent.ts b/examples/react-agent.ts index ecdc3d1..b38b391 100644 --- a/examples/react-agent.ts +++ b/examples/react-agent.ts @@ -81,24 +81,19 @@ async function main() { }); run.on('toolCall', (event) => { - const call = event as { toolName: string; input: { query: string } }; - console.log(`Calling ${call.toolName}(${call.input.query})`); + console.log(`Calling ${event.toolName}(${event.input.query})`); }); run.on('toolResult', (event) => { - const result = event as { - toolName: string; - output: unknown; - }; - console.log(`${result.toolName} -> ${String(result.output)}`); + console.log(`${event.toolName} -> ${String(event.output)}`); }); await new Promise((resolve, reject) => { - run.on('done', (event) => { - console.log((event as { output: unknown }).output); + run.onDone((event) => { + console.log(event.output); resolve(); }); - run.on('error', (event) => { - reject((event as { error: unknown }).error); + run.onError((event) => { + reject(event.error); }); }); } finally { diff --git a/examples/reflection.ts b/examples/reflection.ts index 86abe8e..529faa9 100644 --- a/examples/reflection.ts +++ b/examples/reflection.ts @@ -77,6 +77,12 @@ export function createReflectionExample( id: 'reflection-example', schemas: { input: z.object({ task: z.string() }), + output: z.object({ + task: z.string(), + draft: z.string().nullable(), + feedback: z.string().nullable(), + revisionCount: z.number(), + }), }, context: (input) => ({ task: input.task, diff --git a/examples/rewoo.ts b/examples/rewoo.ts new file mode 100644 index 0000000..fba6b82 --- /dev/null +++ b/examples/rewoo.ts @@ -0,0 +1,235 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const rewooPlanSchema = z.object({ + steps: z + .array( + z.object({ + id: z.string().regex(/^E\d+$/), + instruction: z.string(), + input: z.string(), + }) + ) + .min(1) + .max(5), +}); + +const rewooStepResultSchema = z.object({ + result: z.string(), +}); + +const rewooAnswerSchema = z.object({ + answer: z.string(), +}); + +type RewooPlan = z.infer; +type RewooStep = RewooPlan['steps'][number]; + +function resolveStepInput( + template: string, + resultsById: Record +): string { + return template.replace(/#(E\d+)/g, (_match, id: string) => resultsById[id] ?? ''); +} + +export function createRewooExample( + options: { + plan?: (objective: string) => Promise; + executeStep?: (args: { + objective: string; + step: RewooStep; + resolvedInput: string; + resultsById: Record; + }) => Promise>; + solve?: (args: { + objective: string; + steps: RewooPlan['steps']; + resultsById: Record; + }) => Promise>; + } = {} +) { + const plan = + options.plan ?? + ((objective: string) => + generateExampleObject({ + schema: rewooPlanSchema, + system: [ + 'You are a ReWOO-style planner.', + 'Produce a short sequence of executable steps.', + 'Each step must have an id like E1, E2, E3.', + 'Later step inputs may reference earlier outputs using #E1, #E2, etc.', + ].join('\n'), + prompt: `Create a compact executable plan for this objective:\n\n${objective}`, + })); + + const executeStep = + options.executeStep ?? + ((args: { + objective: string; + step: RewooStep; + resolvedInput: string; + resultsById: Record; + }) => + generateExampleObject({ + schema: rewooStepResultSchema, + system: 'You execute one specialist step at a time and return a concise result.', + prompt: [ + `Objective: ${args.objective}`, + `Step id: ${args.step.id}`, + `Instruction: ${args.step.instruction}`, + `Resolved input: ${args.resolvedInput}`, + Object.keys(args.resultsById).length + ? `Prior results:\n${Object.entries(args.resultsById) + .map(([id, value]) => `${id}: ${value}`) + .join('\n')}` + : 'Prior results: none', + ].join('\n'), + })); + + const solve = + options.solve ?? + ((args: { + objective: string; + steps: RewooPlan['steps']; + resultsById: Record; + }) => + generateExampleObject({ + schema: rewooAnswerSchema, + system: 'You synthesize completed step results into a direct final answer.', + prompt: [ + `Objective: ${args.objective}`, + '', + 'Completed steps:', + ...args.steps.map((step) => `${step.id}. ${step.instruction}`), + '', + 'Results:', + ...Object.entries(args.resultsById).map(([id, value]) => `${id}: ${value}`), + '', + 'Write the final answer.', + ].join('\n'), + })); + + return createAgentMachine({ + id: 'rewoo-example', + schemas: { + input: z.object({ objective: z.string() }), + output: z.object({ + objective: z.string(), + steps: rewooPlanSchema.shape.steps, + resultsById: z.record(z.string(), z.string()), + answer: z.string().nullable(), + }), + }, + context: (input) => ({ + objective: input.objective, + steps: [] as RewooPlan['steps'], + resultsById: {} as Record, + answer: null as string | null, + }), + initial: 'planning', + states: { + planning: { + resultSchema: rewooPlanSchema, + invoke: async ({ context }) => plan(context.objective), + onDone: ({ result }) => ({ + target: 'executing', + context: { steps: result.steps }, + input: { index: 0 }, + }), + }, + executing: { + inputSchema: z.object({ + index: z.number().int().min(0), + }), + resultSchema: z.object({ + stepId: z.string(), + result: z.string(), + }), + invoke: async ({ context, input }) => { + const step = context.steps[input.index]; + + if (!step) { + throw new Error(`Missing step at index ${input.index}`); + } + + const resolvedInput = resolveStepInput(step.input, context.resultsById); + const outcome = await executeStep({ + objective: context.objective, + step, + resolvedInput, + resultsById: context.resultsById, + }); + + return { + stepId: step.id, + result: outcome.result, + }; + }, + onDone: ({ result, context }) => { + const nextResultsById = { + ...context.resultsById, + [result.stepId]: result.result, + }; + const nextIndex = Object.keys(nextResultsById).length; + + if (nextIndex < context.steps.length) { + return { + target: 'executing', + context: { resultsById: nextResultsById }, + input: { index: nextIndex }, + }; + } + + return { + target: 'solving', + context: { resultsById: nextResultsById }, + }; + }, + }, + solving: { + resultSchema: rewooAnswerSchema, + invoke: async ({ context }) => + solve({ + objective: context.objective, + steps: context.steps, + resultsById: context.resultsById, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { answer: result.answer }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + objective: context.objective, + steps: context.steps, + resultsById: context.resultsById, + answer: context.answer, + }), + }, + }, + }); +} + +async function main() { + try { + const objective = await prompt('Objective'); + const machine = createRewooExample(); + const result = await machine.execute(machine.getInitialState({ objective })); + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/river-crossing.ts b/examples/river-crossing.ts index dd738bb..c628267 100644 --- a/examples/river-crossing.ts +++ b/examples/river-crossing.ts @@ -93,6 +93,14 @@ function moveItem( export function createRiverCrossingExample() { return createAgentMachine({ id: 'river-crossing-example', + schemas: { + output: z.object({ + leftBank: z.array(bankItem), + rightBank: z.array(bankItem), + steps: z.array(z.string()), + reasoning: z.array(z.string()), + }), + }, context: () => ({ leftBank: ['wolf', 'goat', 'cabbage'] as Array<'wolf' | 'goat' | 'cabbage'>, rightBank: [] as Array<'wolf' | 'goat' | 'cabbage'>, @@ -122,22 +130,22 @@ export function createRiverCrossingExample() { return { target: 'moving' as const, - params: { move: result.move }, + input: { move: result.move }, context: { reasoning: nextReasoning }, }; }, }, moving: { - paramsSchema: z.object({ + inputSchema: z.object({ move: crossingMoveSchema.shape.move.exclude(['done']), }), resultSchema: crossingStateSchema, - invoke: async ({ context, params }) => + invoke: async ({ context, input }) => moveItem( [...context.leftBank], [...context.rightBank], context.farmerPosition, - params.move as 'takeGoat' | 'takeWolf' | 'takeCabbage' | 'returnEmpty' + input.move as 'takeGoat' | 'takeWolf' | 'takeCabbage' | 'returnEmpty' ), onDone: ({ result, context }) => ({ target: 'choosing', diff --git a/examples/simple.ts b/examples/simple.ts index 5ab67bb..812fb22 100644 --- a/examples/simple.ts +++ b/examples/simple.ts @@ -26,6 +26,7 @@ export function createSimpleExample( id: 'simple-example', schemas: { input: z.object({ text: z.string() }), + output: z.object({ summary: z.string().nullable() }), }, context: (input) => ({ text: input.text, diff --git a/examples/subflow.ts b/examples/subflow.ts index 6afd439..5946a3a 100644 --- a/examples/subflow.ts +++ b/examples/subflow.ts @@ -29,6 +29,7 @@ export function createSubflowExample( id: 'subflow-child', schemas: { input: z.object({ topic: z.string() }), + output: z.object({ bullets: z.array(z.string()) }), }, context: (input) => ({ topic: input.topic, @@ -62,6 +63,10 @@ export function createSubflowExample( id: 'subflow-example', schemas: { input: z.object({ topic: z.string() }), + output: z.object({ + bullets: z.array(z.string()), + draft: z.string().nullable(), + }), }, context: (input) => ({ topic: input.topic, @@ -82,7 +87,7 @@ export function createSubflowExample( } return { - bullets: (result.output as { bullets: string[] }).bullets, + bullets: result.output.bullets, }; }, onDone: ({ result }) => ({ diff --git a/examples/supervisor.ts b/examples/supervisor.ts new file mode 100644 index 0000000..cdbf1e2 --- /dev/null +++ b/examples/supervisor.ts @@ -0,0 +1,252 @@ +import { z } from 'zod'; +import { + createAgentMachine, + decide, + decideResultSchema, + type AgentAdapter, +} from '../src/index.js'; +import { + closePrompt, + createOpenAiDecisionAdapter, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const handlingParamsSchema = z.object({ + attempt: z.number().int().min(1), + instruction: z.string().nullable().optional(), +}); + +const workerResultSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('resolved'), + response: z.string(), + }), + z.object({ + status: z.literal('blocked'), + issue: z.string(), + }), +]); + +const supervisorOptions = { + retry: { + description: 'Retry the worker with a concrete instruction for the next attempt.', + schema: z.object({ + instruction: z.string(), + }), + }, + escalate: { + description: 'Escalate the task to a human or specialist owner.', + schema: z.object({ + reason: z.string(), + }), + }, +} as const; + +export function createSupervisorExample( + options: { + adapter?: AgentAdapter; + handle?: (args: { + request: string; + attempt: number; + instruction: string | null; + priorIssues: string[]; + }) => Promise>; + maxAttempts?: number; + } = {} +) { + const adapter = options.adapter ?? createOpenAiDecisionAdapter(); + const maxAttempts = options.maxAttempts ?? 2; + const handle = + options.handle ?? + ((args: { + request: string; + attempt: number; + instruction: string | null; + priorIssues: string[]; + }) => + generateExampleObject({ + schema: workerResultSchema, + system: [ + 'You are an operations worker handling a support request.', + 'Resolve the request when you have enough information.', + 'Return status="blocked" with a concise issue when the request cannot be completed yet.', + ].join('\n'), + prompt: [ + `Request: ${args.request}`, + `Attempt: ${args.attempt}`, + args.instruction + ? `Supervisor instruction: ${args.instruction}` + : 'Supervisor instruction: none', + args.priorIssues.length + ? `Prior issues:\n${args.priorIssues.map((issue, index) => `${index + 1}. ${issue}`).join('\n')}` + : 'Prior issues: none', + ].join('\n'), + })); + + return createAgentMachine({ + id: 'supervisor-example', + schemas: { + input: z.object({ + request: z.string(), + }), + output: z.object({ + request: z.string(), + status: z.enum(['resolved', 'escalated']), + resolution: z.string().nullable(), + escalationReason: z.string().nullable(), + attemptCount: z.number().int().min(0), + history: z.array(z.string()), + }), + }, + context: (input) => ({ + request: input.request, + attemptCount: 0, + latestIssue: null as string | null, + resolution: null as string | null, + escalationReason: null as string | null, + history: [] as string[], + priorIssues: [] as string[], + }), + initial: ({ context }) => ({ + target: 'handling', + input: { + attempt: 1, + instruction: null, + }, + context, + }), + states: { + handling: { + inputSchema: handlingParamsSchema, + resultSchema: workerResultSchema, + invoke: async ({ context, input }) => + handle({ + request: context.request, + attempt: input.attempt, + instruction: input.instruction ?? null, + priorIssues: context.priorIssues, + }), + onDone: ({ result, context, }) => { + const nextAttemptCount = context.attemptCount + 1; + + if (result.status === 'resolved') { + return { + target: 'done', + context: { + attemptCount: nextAttemptCount, + resolution: result.response, + history: [ + ...context.history, + `worker:${nextAttemptCount}:resolved:${result.response}`, + ], + }, + }; + } + + return { + target: 'supervising', + context: { + attemptCount: nextAttemptCount, + latestIssue: result.issue, + priorIssues: [...context.priorIssues, result.issue], + history: [ + ...context.history, + `worker:${nextAttemptCount}:blocked:${result.issue}`, + ], + }, + }; + }, + }, + supervising: { + resultSchema: decideResultSchema(supervisorOptions), + invoke: async ({ context }) => + decide({ + adapter, + model: 'openai/gpt-5.4-nano', + prompt: [ + 'You supervise a worker that may need retries or escalation.', + `Max attempts: ${maxAttempts}`, + `Completed attempts: ${context.attemptCount}`, + '', + `Request: ${context.request}`, + `Latest issue: ${context.latestIssue ?? 'none'}`, + context.history.length + ? `History:\n${context.history.map((entry, index) => `${index + 1}. ${entry}`).join('\n')}` + : 'History: none', + '', + context.attemptCount >= maxAttempts + ? 'You should normally escalate because the worker has reached the attempt limit.' + : 'Retry only if a concrete next instruction could unblock the worker.', + ].join('\n'), + options: supervisorOptions, + }), + onDone: ({ result, context }) => { + if (result.choice === 'retry') { + const instruction = + result.data.instruction + ?? 'Retry once with a more concrete plan and any available context.'; + + return { + target: 'handling', + context: { + history: [ + ...context.history, + `supervisor:retry:${instruction}`, + ], + }, + input: { + attempt: context.attemptCount + 1, + instruction, + }, + }; + } + + const reason = + result.data.reason + ?? `Escalated after ${context.attemptCount} unsuccessful attempts.`; + + return { + target: 'done', + context: { + escalationReason: reason, + history: [ + ...context.history, + `supervisor:escalate:${reason}`, + ], + }, + }; + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + request: context.request, + status: context.resolution ? ('resolved' as const) : ('escalated' as const), + resolution: context.resolution, + escalationReason: context.escalationReason, + attemptCount: context.attemptCount, + history: context.history, + }), + }, + }, + }); +} + +async function main() { + try { + const request = await prompt('Request'); + const machine = createSupervisorExample(); + console.log( + formatResult(await machine.execute(machine.getInitialState({ request }))) + ); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/tool-calling.ts b/examples/tool-calling.ts index cae6c45..8472aae 100644 --- a/examples/tool-calling.ts +++ b/examples/tool-calling.ts @@ -29,6 +29,7 @@ export function createToolCallingExample( id: 'tool-calling-example', schemas: { input: z.object({ city: z.string() }), + output: z.object({ forecast: z.string().nullable() }), emitted: { toolCall: z.object({ toolName: z.string(), @@ -88,25 +89,20 @@ async function main() { }); run.on('toolCall', (event) => { - const tool = event as { toolName: string; input: { city: string } }; - console.log(`Calling ${tool.toolName}(${tool.input.city})`); + console.log(`Calling ${event.toolName}(${event.input.city})`); }); run.on('toolResult', (event) => { - const result = event as { - toolName: string; - output: { forecast: string }; - }; - console.log(`${result.toolName} -> ${result.output.forecast}`); + console.log(`${event.toolName} -> ${event.output.forecast}`); }); await new Promise((resolve, reject) => { - run.on('done', (event) => { - console.log((event as { output: unknown }).output); + run.onDone((event) => { + console.log(event.output); resolve(); }); - run.on('error', (event) => { - reject((event as { error: unknown }).error); + run.onError((event) => { + reject(event.error); }); }); } finally { diff --git a/examples/tutor.ts b/examples/tutor.ts index 7b12af8..16193e3 100644 --- a/examples/tutor.ts +++ b/examples/tutor.ts @@ -43,6 +43,11 @@ export function createTutorExample( id: 'tutor-example', schemas: { input: z.object({ message: z.string() }), + output: z.object({ + conversation: z.array(z.string()), + feedback: z.string().nullable(), + response: z.string().nullable(), + }), }, context: (input) => ({ conversation: [`User: ${input.message}`], diff --git a/src/agent.test.ts b/src/agent.test.ts index 7c2eaaa..7813732 100644 --- a/src/agent.test.ts +++ b/src/agent.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test, vi } from 'vitest'; import { z } from 'zod'; import { - createAgentMachine, - decide, classify, + classifyResultSchema, + createAgentMachine, createAdapter, + decide, + decideResultSchema, } from './index.js'; import type { AgentAdapter } from './types.js'; @@ -31,6 +33,12 @@ function mockAdapter( }; } +const choiceResultSchema = z.object({ + choice: z.string(), + data: z.record(z.string(), z.unknown()), + reasoning: z.string().optional(), +}); + // ─── Simple machine (no schemas — inferred from context) ─── function createSimpleMachine() { @@ -144,6 +152,12 @@ function createHitlMachine() { // ─── Decide machine ─── function createDecideMachine(adapter: AgentAdapter) { + const options = { + billing: { description: 'Billing issues' }, + technical: { description: 'Technical issues' }, + general: { description: 'General inquiries' }, + } as const; + return createAgentMachine({ id: 'decider', context: () => ({ @@ -151,22 +165,22 @@ function createDecideMachine(adapter: AgentAdapter) { category: null as string | null, resolution: null as string | null, }), - adapter, initial: 'classifying', states: { - classifying: decide({ - model: 'test-model', - prompt: ({ context }) => `Classify: ${context.issue}`, - options: { - billing: { description: 'Billing issues' }, - technical: { description: 'Technical issues' }, - general: { description: 'General inquiries' }, - }, + classifying: { + resultSchema: decideResultSchema(options), + invoke: async ({ context }) => + decide({ + adapter, + model: 'test-model', + prompt: `Classify: ${context.issue}`, + options, + }), onDone: ({ result }) => ({ target: 'handling', context: { category: result.choice }, }), - }), + }, handling: { resultSchema: z.object({ resolution: z.string() }), invoke: async ({ context }) => ({ @@ -191,28 +205,34 @@ function createDecideMachine(adapter: AgentAdapter) { // ─── Classify machine ─── function createClassifyMachine(adapter: AgentAdapter) { + const categories = { + billing: { description: 'Billing, payments, refunds' }, + technical: { description: 'Technical issues, bugs' }, + general: { description: 'General inquiries' }, + } as const; + return createAgentMachine({ id: 'classifier', context: () => ({ issue: 'I want my money back', category: null as string | null, }), - adapter, initial: 'classifyIntent', states: { - classifyIntent: classify({ - model: 'test-model', - prompt: ({ context }) => `Classify: "${context.issue}"`, - into: { - billing: { description: 'Billing, payments, refunds' }, - technical: { description: 'Technical issues, bugs' }, - general: { description: 'General inquiries' }, - }, + classifyIntent: { + resultSchema: classifyResultSchema(categories), + invoke: async ({ context }) => + classify({ + adapter, + model: 'test-model', + prompt: `Classify: "${context.issue}"`, + into: categories, + }), onDone: ({ result }) => ({ target: 'done', context: { category: result.category }, }), - }), + }, done: { type: 'final', output: ({ context }) => ({ category: context.category }), @@ -333,12 +353,15 @@ describe('invoke', () => { context: () => ({}), initial: 'deciding', states: { - deciding: decide({ - model: 'test', - prompt: 'test', - options: { a: { description: 'A' } }, + deciding: { + invoke: async () => + decide({ + model: 'test', + prompt: 'test', + options: { a: { description: 'A' } }, + }), onDone: () => ({ target: 'done' }), - }), + }, done: { type: 'final' }, }, }); @@ -492,19 +515,26 @@ describe('decide', () => { const spy = vi.fn().mockResolvedValue({ choice: 'a', data: {} }); const machine = createAgentMachine({ id: 'dtest', - context: () => ({ topic: 'cats' }), - adapter: { decide: spy }, + context: () => ({ topic: 'cats', choice: null as string | null }), initial: 'choosing', states: { - choosing: decide({ - model: 'my-model', - prompt: ({ context }) => `About ${context.topic}`, - options: { a: { description: 'A' }, b: { description: 'B' } }, + choosing: { + resultSchema: decideResultSchema({ + a: { description: 'A' }, + b: { description: 'B' }, + }), + invoke: async ({ context }) => + decide({ + adapter: { decide: spy }, + model: 'my-model', + prompt: `About ${context.topic}`, + options: { a: { description: 'A' }, b: { description: 'B' } }, + }), onDone: ({ result }) => ({ target: 'done', context: { choice: result.choice }, }), - }), + }, done: { type: 'final' }, }, }); @@ -518,19 +548,28 @@ describe('decide', () => { const machine = createAgentMachine({ id: 'override', context: () => ({ choice: null as string | null }), - adapter: mockAdapter([{ choice: 'machine' }]), initial: 'choosing', states: { - choosing: decide({ - model: 'test', - adapter: mockAdapter([{ choice: 'state' }]), - prompt: 'pick', - options: { s: { description: 'S' }, m: { description: 'M' } }, + choosing: { + resultSchema: decideResultSchema({ + state: { description: 'State' }, + machine: { description: 'Machine' }, + }), + invoke: async () => + decide({ + adapter: mockAdapter([{ choice: 'state' }]), + model: 'test', + prompt: 'pick', + options: { + state: { description: 'State' }, + machine: { description: 'Machine' }, + }, + }), onDone: ({ result }) => ({ target: 'done', context: { choice: result.choice }, }), - }), + }, done: { type: 'final' }, }, }); @@ -542,34 +581,46 @@ describe('decide', () => { const machine = createAgentMachine({ id: 'data', context: () => ({ items: null as string[] | null }), - adapter: { - decide: async () => ({ - choice: 'withData', - data: { items: ['a', 'b'] }, - }), - }, initial: 'choosing', states: { - choosing: decide({ - model: 'test', - prompt: 'pick', - options: { + choosing: { + resultSchema: decideResultSchema({ withData: { description: 'Has data', schema: z.object({ items: z.array(z.string()) }), }, withoutData: { description: 'No data' }, - }, - onDone: ({ result }) => ({ - target: 'done', - context: { - items: - result.choice === 'withData' - ? result.data.items - : null, - }, }), - }), + invoke: async () => + decide({ + adapter: { + decide: async () => ({ + choice: 'withData', + data: { items: ['a', 'b'] }, + }), + }, + model: 'test', + prompt: 'pick', + options: { + withData: { + description: 'Has data', + schema: z.object({ items: z.array(z.string()) }), + }, + withoutData: { description: 'No data' }, + }, + }), + onDone: ({ result }) => { + return { + target: 'done', + context: { + items: + result.choice === 'withData' + ? (result.data.items ?? null) + : null, + }, + }; + }, + }, done: { type: 'final' }, }, }); @@ -589,6 +640,7 @@ describe('type: choice', () => { states: { routing: { type: 'choice', + resultSchema: choiceResultSchema, model: 'test-model', prompt: ({ context }) => `Route: ${context.issue}`, // context typed ✓ options: { @@ -628,6 +680,7 @@ describe('type: choice', () => { states: { choosing: { type: 'choice', + resultSchema: choiceResultSchema, model: 'test', prompt: 'pick', options: { a: { description: 'A' } }, @@ -784,9 +837,9 @@ describe('type inference', () => { context: () => ({ count: 0 }), initial: 'idle', states: { - // @ts-expect-error — 'foo' not a valid context key idle: { on: { + // @ts-expect-error — 'foo' not a valid context key go: () => ({ target: 'idle', context: { foo: 'bar' }, @@ -850,6 +903,11 @@ describe('type inference', () => { test('context typed in output', () => { const machine = createAgentMachine({ id: 't', + schemas: { + output: z.object({ + score: z.number(), + }), + }, context: () => ({ score: 100 }), initial: 'done', states: { @@ -1014,41 +1072,41 @@ describe('type inference', () => { ).toThrow(); }); - // ─── paramsSchema per state ─── + // ─── inputSchema per state ─── - test('params typed per state from paramsSchema', async () => { + test('input typed per state from inputSchema', async () => { const machine = createAgentMachine({ id: 't', context: () => ({ result: '' }), initial: 'a', states: { a: { - paramsSchema: z.object({ count: z.number() }), + inputSchema: z.object({ count: z.number() }), resultSchema: z.object({ doubled: z.number() }), - invoke: async ({ params }) => { - params.count satisfies number; + invoke: async ({ input }) => { + input.count satisfies number; // @ts-expect-error — count is number not string - params.count satisfies string; - // @ts-expect-error — 'name' not on a's params - params.name; - return { doubled: params.count * 2 }; + input.count satisfies string; + // @ts-expect-error — 'name' not on a's input + input.name; + return { doubled: input.count * 2 }; }, onDone: ({ result }) => ({ target: 'b', - params: { name: 'hello' }, + input: { name: 'hello' }, context: { result: String(result.doubled) }, }), }, b: { - paramsSchema: z.object({ name: z.string() }), + inputSchema: z.object({ name: z.string() }), resultSchema: z.object({ greeting: z.string() }), - invoke: async ({ params }) => { - params.name satisfies string; + invoke: async ({ input }) => { + input.name satisfies string; // @ts-expect-error — name is string not number - params.name satisfies number; - // @ts-expect-error — 'count' not on b's params - params.count; - return { greeting: `hi ${params.name}` }; + input.name satisfies number; + // @ts-expect-error — 'count' not on b's input + input.count; + return { greeting: `hi ${input.name}` }; }, onDone: ({ result }) => ({ target: 'done', @@ -1064,21 +1122,21 @@ describe('type inference', () => { let state = machine.resolveState({ ...machine.getInitialState(), - params: { a: { count: 21 } }, + input: { a: { count: 21 } }, }); const r = await machine.execute(state); expect(r.status === 'done' && r.output).toEqual({ result: 'hi hello' }); }); - test('no paramsSchema → params is Record', () => { + test('no inputSchema → input is Record', () => { createAgentMachine({ id: 't', context: () => ({}), initial: 'idle', states: { idle: { - invoke: async ({ params }) => { - params satisfies Record; + invoke: async ({ input }) => { + input satisfies Record; return {}; }, }, @@ -1098,6 +1156,7 @@ describe('type inference', () => { states: { choosing: { type: 'choice', + resultSchema: choiceResultSchema, model: 'test', prompt: ({ context }) => { context.topic satisfies string; @@ -1188,7 +1247,7 @@ describe('type inference', () => { }); }); - test('no resultSchema → onDone result is any', () => { + test('no resultSchema → onDone result is inferred from invoke', () => { createAgentMachine({ id: 't', context: () => ({}), @@ -1197,10 +1256,9 @@ describe('type inference', () => { work: { invoke: async () => ({ anything: true }), onDone: ({ result }) => { - // Without resultSchema, result is ChoiceResult (default) - result.choice satisfies string; - // @ts-expect-error — 'whatever' not on ChoiceResult - result.whatever; + result.anything satisfies boolean; + // @ts-expect-error — 'choice' does not exist on invoke result + result.choice; return { target: 'done' }; }, }, @@ -1209,6 +1267,45 @@ describe('type inference', () => { }); }); + test('final output is inferred through execute and snapshots', async () => { + const machine = createAgentMachine({ + id: 'typed-output', + schemas: { + output: z.object({ + count: z.number(), + label: z.string(), + }), + }, + context: () => ({ count: 2 }), + initial: 'done', + states: { + done: { + type: 'final', + output: ({ context }) => ({ + count: context.count, + label: `count:${context.count}`, + }), + }, + }, + }); + + const runResult = await machine.execute(machine.getInitialState()); + if (runResult.status === 'done') { + runResult.output.count satisfies number; + runResult.output.label satisfies string; + // @ts-expect-error output property should be typed + runResult.output.missing; + } + + const snapshot = machine.resolveState(machine.getInitialState()); + snapshot.output satisfies + | { + count: number; + label: string; + } + | undefined; + }); + // ─── events typed in on handlers ─── test('on handler event typed from schemas.events', () => { @@ -1292,7 +1389,7 @@ describe('edge cases', () => { const machine = createSimpleMachine(); const done = { value: 'done', - params: {}, + input: {}, context: { count: 1 }, status: 'done', output: { result: 1 }, diff --git a/src/classify.ts b/src/classify.ts index d0ee89c..12f2c0e 100644 --- a/src/classify.ts +++ b/src/classify.ts @@ -1,96 +1,67 @@ +import { z } from 'zod'; +import { decide } from './decide.js'; import type { - AgentAdapter, - InvokeEnqueue, - StateConfig, - TransitionResult, + ClassifyOptions, + ClassifyResultFor, + StandardSchemaV1, } from './types.js'; -type TransitionTargetOf = T extends { target?: infer TTarget } - ? Extract - : never; +export async function classify< + const TCategories extends Record, +>( + options: ClassifyOptions +): Promise> { + const result = await decide({ + adapter: options.adapter, + model: options.model, + prompt: buildClassificationPrompt(options.prompt, options.examples), + options: options.into, + reasoning: options.reasoning, + }); -type HandlerTargetOf = T extends (...args: any[]) => infer TResult - ? TransitionTargetOf - : TransitionTargetOf; + return { + category: result.choice as keyof TCategories & string, + }; +} -type OnTargets = TOn extends Record - ? HandlerTargetOf - : never; +function buildClassificationPrompt( + prompt: string, + examples: Array<{ input: string; category: string }> | undefined +): string { + if (!examples?.length) { + return prompt; + } -type ClassifyStateConfig< - TContext extends Record, - TTarget extends string, - TParamsByTarget extends Record, -> = Pick< - StateConfig, - 'on' -> & { - __type: 'classify'; - __classifyConfig: Record; - __decideConfig: Record; -}; + return [ + prompt, + '', + 'Examples:', + ...examples.map((example) => `${example.category}: ${example.input}`), + ].join('\n'); +} -/** - * Create a classification state. Sugar over `decide` for simple routing — - * categories with descriptions, no per-option schemas. - * - * `result.category` is typed as a union of the `into` keys. - * - */ -export function classify< - TContext extends Record, +export function classifyResultSchema< const TCategories extends Record, - TParams extends Record = Record, - TTarget extends string = string, - TParamsByTarget extends Record = {}, >( - config: { - model: string; - adapter?: AgentAdapter; - prompt: string | ((args: { context: TContext; params: TParams }) => string); - into: TCategories; - examples?: Array<{ input: string; category: keyof TCategories & string }>; - onDone: (args: { - result: { category: keyof TCategories & string }; - context: TContext; - }) => TransitionResult; - on?: Record< - string, - ( - args: { event: any; context: TContext }, - enq: InvokeEnqueue - ) => TransitionResult - >; - } -): ClassifyStateConfig< - TContext, - TTarget, - TParamsByTarget -> { - const decideOptions: Record = {}; - for (const [key, val] of Object.entries(config.into)) { - decideOptions[key] = { description: val.description }; + into: TCategories +): StandardSchemaV1> { + const categories = Object.keys(into); + if (categories.length === 0) { + throw new Error('classifyResultSchema requires at least one category'); } - return { - __type: 'classify', - __classifyConfig: config as unknown as Record, - __decideConfig: { - model: config.model, - adapter: config.adapter, - prompt: config.prompt, - options: decideOptions, - onDone: ({ result, context }: any) => { - return config.onDone({ - result: { category: result.choice }, - context, - }); - }, - }, - on: config.on as StateConfig< - TContext, - TTarget, - TParamsByTarget - >['on'], - }; + const categorySchema = + categories.length === 1 + ? z.literal(categories[0]!) + : z.union( + categories.map((category) => z.literal(category)) as [ + z.ZodLiteral, + z.ZodLiteral, + ...z.ZodLiteral[], + ] + ); + + return z.object({ + category: categorySchema, + }) as unknown as StandardSchemaV1>; } diff --git a/src/decide.ts b/src/decide.ts index b4dbfb4..62cec7d 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -1,83 +1,87 @@ +import { z } from 'zod'; +import { validateSchemaSync } from './utils.js'; import type { AgentAdapter, + DecideOptions, DecideResultFor, - InvokeEnqueue, StandardSchemaV1, - StateConfig, - TransitionResult, } from './types.js'; -type TransitionTargetOf = T extends { target?: infer TTarget } - ? Extract - : never; +export async function decide< + const TOptions extends Record, +>( + options: DecideOptions +): Promise> { + const adapter = requireAdapter(options.adapter, 'decide()'); + const result = await adapter.decide({ + model: options.model, + prompt: options.prompt, + options: options.options, + reasoning: options.reasoning, + }); + + const chosen = options.options[result.choice]; + if (!chosen) { + throw new Error( + `Adapter returned unknown decision '${result.choice}' for model '${options.model}'` + ); + } + + const data = chosen.schema + ? validateSchemaSync(chosen.schema, result.data) + : {}; -type HandlerTargetOf = T extends (...args: any[]) => infer TResult - ? TransitionTargetOf - : TransitionTargetOf; + return { + choice: result.choice, + data, + reasoning: result.reasoning, + } as DecideResultFor; +} -type OnTargets = TOn extends Record - ? HandlerTargetOf - : never; +export function requireAdapter( + adapter: AgentAdapter | undefined, + label: string +): AgentAdapter { + if (!adapter) { + throw new Error(`No adapter configured for ${label}`); + } -type DecideStateConfig< - TContext extends Record, - TTarget extends string, - TParamsByTarget extends Record, -> = Pick< - StateConfig, - 'on' -> & { - __type: 'decide'; - __decideConfig: Record; -}; + return adapter; +} -/** - * Create a decision state where an LLM picks from constrained options. - * Each option has a description and optional schema for structured data. - * - * The result type is a discriminated union — `result.choice` narrows `result.data`. - * - */ -export function decide< - TContext extends Record, - const TOptions extends Record< - string, - { description: string; schema?: StandardSchemaV1 } - >, - TParams extends Record = Record, - TTarget extends string = string, - TParamsByTarget extends Record = {}, +export function decideResultSchema< + const TOptions extends Record, >( - config: { - model: string; - adapter?: AgentAdapter; - prompt: string | ((args: { context: TContext; params: TParams }) => string); - options: TOptions; - reasoning?: boolean; - onDone: (args: { - result: DecideResultFor; - context: TContext; - }) => TransitionResult; - on?: Record< - string, - ( - args: { event: any; context: TContext }, - enq: InvokeEnqueue - ) => TransitionResult - >; + options: TOptions, + config: { reasoning?: boolean } = {} +): StandardSchemaV1> { + const schemas = Object.entries(options).map(([choice, option]) => + z.object({ + choice: z.literal(choice), + data: option.schema ? toZodSchema(option.schema) : z.object({}), + ...(config.reasoning ? { reasoning: z.string().optional() } : {}), + }) + ); + + if (schemas.length === 0) { + throw new Error('decideResultSchema requires at least one option'); } -): DecideStateConfig< - TContext, - TTarget, - TParamsByTarget -> { - return { - __type: 'decide', - __decideConfig: config as unknown as Record, - on: config.on as StateConfig< - TContext, - TTarget, - TParamsByTarget - >['on'], - }; + + return (schemas.length === 1 + ? schemas[0]! + : z.union( + schemas as unknown as [ + z.ZodTypeAny, + z.ZodTypeAny, + ...z.ZodTypeAny[], + ] + )) as unknown as StandardSchemaV1>; +} + +function toZodSchema(schema: StandardSchemaV1): z.ZodTypeAny { + if ('_zod' in schema || '_def' in schema) { + return schema as unknown as z.ZodTypeAny; + } + + return z.record(z.string(), z.unknown()); } diff --git a/src/examples.test.ts b/src/examples.test.ts index 55829a1..532dc14 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -15,14 +15,17 @@ import { createJokeExample, createJugsExample, createMapReduceExample, + createMultiAgentNetworkExample, createNewspaperExample, createPlanAndExecuteExample, createRaffleExample, createReactAgentExample, + createRewooExample, createReflectionExample, createRiverCrossingExample, createSimpleExample, createSubflowExample, + createSupervisorExample, createToolCallingExample, createTutorExample, } from '../examples/index.js'; @@ -42,13 +45,16 @@ describe('curated examples', () => { expect(existsSync(resolve(examplesDir, 'joke.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'jugs.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'map-reduce.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'multi-agent-network.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'newspaper.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'plan-and-execute.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'raffle.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'react-agent.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'rewoo.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'reflection.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'river-crossing.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'subflow.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'supervisor.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'tool-calling.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'tutor.ts'))).toBe(true); }); @@ -475,6 +481,64 @@ describe('curated examples', () => { } }); + test('multi-agent network example coordinates specialist handoffs through a supervisor state', async () => { + let step = 0; + + const machine = createMultiAgentNetworkExample({ + adapter: { + decide: async () => { + step += 1; + + if (step === 1) { + return { + choice: 'research', + data: { focus: 'collect technical notes' }, + }; + } + + if (step === 2) { + return { + choice: 'write', + data: { angle: 'produce a short memo' }, + }; + } + + return { + choice: 'finalize', + data: {}, + }; + }, + }, + research: async ({ topic, focus }) => ({ + notes: [`${topic}:${focus}:a`, `${topic}:${focus}:b`], + }), + write: async ({ topic, notes, angle }) => ({ + draft: `${topic} | ${angle} | ${notes.join(' / ')}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ topic: 'durable agents' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + topic: 'durable agents', + notes: [ + 'durable agents:collect technical notes:a', + 'durable agents:collect technical notes:b', + ], + draft: + 'durable agents | produce a short memo | durable agents:collect technical notes:a / durable agents:collect technical notes:b', + handoffs: [ + 'researcher:collect technical notes', + 'writer:produce a short memo', + ], + }); + } + }); + test('tool-calling example emits live tool activity and completes with output', async () => { const machine = createToolCallingExample(async (city) => ({ forecast: `Rainy in ${city}`, @@ -488,15 +552,15 @@ describe('curated examples', () => { const events: string[] = []; run.on('toolCall', (event) => { - events.push(`call:${(event as { toolName: string }).toolName}`); + events.push(`call:${event.toolName}`); }); run.on('toolResult', (event) => { - events.push(`result:${(event as { toolName: string }).toolName}`); + events.push(`result:${event.toolName}`); }); await new Promise((resolve, reject) => { - run.on('done', () => resolve()); - run.on('error', (event) => reject((event as { error: unknown }).error)); + run.onDone(() => resolve()); + run.onError((event) => reject(event.error)); }); expect(events).toEqual(['call:getWeather', 'result:getWeather']); @@ -545,15 +609,15 @@ describe('curated examples', () => { const events: string[] = []; run.on('toolCall', (event) => { - events.push(`call:${(event as { toolName: string }).toolName}`); + events.push(`call:${event.toolName}`); }); run.on('toolResult', (event) => { - events.push(`result:${(event as { toolName: string }).toolName}`); + events.push(`result:${event.toolName}`); }); await new Promise((resolve, reject) => { - run.on('done', () => resolve()); - run.on('error', (event) => reject((event as { error: unknown }).error)); + run.onDone(() => resolve()); + run.onError((event) => reject(event.error)); }); expect(events).toEqual(['call:search', 'result:search']); @@ -566,6 +630,111 @@ describe('curated examples', () => { ); }); + test('rewoo example plans named steps, executes them with references, and solves the objective', async () => { + const machine = createRewooExample({ + plan: async () => ({ + steps: [ + { + id: 'E1', + instruction: 'Collect a fact', + input: 'LangGraphJS', + }, + { + id: 'E2', + instruction: 'Summarize the fact', + input: 'Use #E1 in one concise sentence', + }, + ], + }), + executeStep: async ({ step, resolvedInput }) => ({ + result: `${step.id}:${resolvedInput}`, + }), + solve: async ({ resultsById }) => ({ + answer: `${resultsById.E1} | ${resultsById.E2}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ objective: 'understand the repo' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + objective: 'understand the repo', + steps: [ + { + id: 'E1', + instruction: 'Collect a fact', + input: 'LangGraphJS', + }, + { + id: 'E2', + instruction: 'Summarize the fact', + input: 'Use #E1 in one concise sentence', + }, + ], + resultsById: { + E1: 'E1:LangGraphJS', + E2: 'E2:Use E1:LangGraphJS in one concise sentence', + }, + answer: 'E1:LangGraphJS | E2:Use E1:LangGraphJS in one concise sentence', + }); + } + }); + + test('supervisor example retries a blocked worker and can still resolve the request', async () => { + let decisions = 0; + + const machine = createSupervisorExample({ + adapter: { + decide: async () => { + decisions += 1; + + return { + choice: decisions === 1 ? 'retry' : 'escalate', + data: + decisions === 1 + ? { instruction: 'Retry using the customer email on file.' } + : { reason: 'Escalate to billing.' }, + }; + }, + }, + handle: async ({ attempt, instruction }) => + attempt === 1 + ? { + status: 'blocked' as const, + issue: 'Missing account identifier.', + } + : { + status: 'resolved' as const, + response: `Resolved after retry: ${instruction}`, + }, + }); + + const result = await machine.execute( + machine.getInitialState({ + request: 'Fix the duplicate subscription charge.', + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + request: 'Fix the duplicate subscription charge.', + status: 'resolved', + resolution: 'Resolved after retry: Retry using the customer email on file.', + escalationReason: null, + attemptCount: 2, + history: [ + 'worker:1:blocked:Missing account identifier.', + 'supervisor:retry:Retry using the customer email on file.', + 'worker:2:resolved:Resolved after retry: Retry using the customer email on file.', + ], + }); + } + }); + test('newspaper example loops through critique and revision', async () => { const machine = createNewspaperExample({ search: async () => ({ searchResults: ['a', 'b', 'c'] }), diff --git a/src/graph/index.ts b/src/graph/index.ts index 5e5554e..7d09dfd 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -2,7 +2,7 @@ import type { AgentMachine } from '../types.js'; export interface GraphNode { id: string; - type: 'state' | 'decide' | 'classify' | 'final'; + type: 'state' | 'choice' | 'final'; } export interface GraphEdge { diff --git a/src/index.ts b/src/index.ts index 6692285..e56ee3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ // Core export { createAgentMachine } from './machine.js'; +export { decide, decideResultSchema, requireAdapter } from './decide.js'; +export { classify, classifyResultSchema } from './classify.js'; // AI primitives -export { decide } from './decide.js'; -export { classify } from './classify.js'; export { createReactAgent } from './prebuilt/react.js'; export type { ReactAgentMessage, @@ -23,8 +23,9 @@ export type { AgentRun, AgentSnapshot, AgentState, - ClassifyConfig, - DecideConfig, + ClassifyOptions, + ClassifyResultFor, + DecideOptions, DecideResultFor, EmittedPart, EmittedUnion, diff --git a/src/invoke-events.test.ts b/src/invoke-events.test.ts index a886ada..0fe92df 100644 --- a/src/invoke-events.test.ts +++ b/src/invoke-events.test.ts @@ -7,13 +7,12 @@ import { } from './index.js'; function once( - run: { on(type: string, handler: (event: unknown) => void): () => void }, - type: string + subscribe: (handler: (event: T) => void) => () => void ) { return new Promise((resolve) => { - const off = run.on(type, (event) => { + const off = subscribe((event) => { off(); - resolve(event as T); + resolve(event); }); }); } @@ -41,7 +40,7 @@ test('invoke success is journaled as an internal machine event', async () => { const store = createMemoryRunStore(); const run = await startSession(machine, { store }); - await once(run, 'done'); + await once(run.onDone.bind(run)); const journal = await store.loadEvents(run.sessionId); expect(run.getSnapshot()).toEqual( @@ -78,7 +77,7 @@ test('invoke failure is journaled as an internal machine event', async () => { const store = createMemoryRunStore(); const run = await startSession(machine, { store }); - await once(run, 'error'); + await once(run.onError.bind(run)); const journal = await store.loadEvents(run.sessionId); expect(run.getSnapshot()).toEqual( @@ -114,7 +113,7 @@ test('invalid invoke results fail without journaling a done event', async () => const store = createMemoryRunStore(); const run = await startSession(machine, { store }); - await once(run, 'error'); + await once(run.onError.bind(run)); const journal = await store.loadEvents(run.sessionId); expect(journal.map((event) => event.type)).toEqual([ diff --git a/src/langgraph-equivalents/multi-agent-network.test.ts b/src/langgraph-equivalents/multi-agent-network.test.ts new file mode 100644 index 0000000..5b1c50c --- /dev/null +++ b/src/langgraph-equivalents/multi-agent-network.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'vitest'; +import { createMultiAgentNetworkExample } from '../../examples/multi-agent-network.js'; + +test('multi-agent network coordinates specialist handoffs until a final draft is ready', async () => { + let step = 0; + + const machine = createMultiAgentNetworkExample({ + adapter: { + decide: async () => { + step += 1; + + if (step === 1) { + return { + choice: 'research', + data: { focus: 'collect architecture notes' }, + }; + } + + if (step === 2) { + return { + choice: 'write', + data: { angle: 'turn notes into an executive summary' }, + }; + } + + return { + choice: 'finalize', + data: {}, + }; + }, + }, + research: async ({ topic, focus }) => ({ + notes: [`${topic}:${focus}:1`, `${topic}:${focus}:2`], + }), + write: async ({ topic, notes, angle }) => ({ + draft: `${topic} | ${angle} | ${notes.join(' / ')}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ topic: 'agent runtimes' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + topic: 'agent runtimes', + notes: [ + 'agent runtimes:collect architecture notes:1', + 'agent runtimes:collect architecture notes:2', + ], + draft: + 'agent runtimes | turn notes into an executive summary | agent runtimes:collect architecture notes:1 / agent runtimes:collect architecture notes:2', + handoffs: [ + 'researcher:collect architecture notes', + 'writer:turn notes into an executive summary', + ], + }); + } +}); diff --git a/src/langgraph-equivalents/prebuilt-react.test.ts b/src/langgraph-equivalents/prebuilt-react.test.ts index ec4cfb0..d08435a 100644 --- a/src/langgraph-equivalents/prebuilt-react.test.ts +++ b/src/langgraph-equivalents/prebuilt-react.test.ts @@ -6,14 +6,13 @@ import { } from '../index.js'; function once( - run: { on(type: string, handler: (event: unknown) => void): () => void }, - type: string + subscribe: (handler: (event: T) => void) => () => void ) { return new Promise((resolve) => { let off = () => {}; - off = run.on(type, (event) => { + off = subscribe((event) => { off(); - resolve(event as T); + resolve(event); }); }); } @@ -60,13 +59,13 @@ test('prebuilt react agent loops through a tool call and returns a final answer' const toolEvents: string[] = []; run.on('toolCall', (event) => { - toolEvents.push(`call:${(event as { toolName: string }).toolName}`); + toolEvents.push(`call:${event.toolName}`); }); run.on('toolResult', (event) => { - toolEvents.push(`result:${(event as { toolName: string }).toolName}`); + toolEvents.push(`result:${event.toolName}`); }); - await once(run, 'done'); + await once(run.onDone.bind(run)); expect(toolEvents).toEqual(['call:search', 'result:search']); expect(run.getSnapshot()).toEqual( diff --git a/src/langgraph-equivalents/rewoo.test.ts b/src/langgraph-equivalents/rewoo.test.ts new file mode 100644 index 0000000..3ee9509 --- /dev/null +++ b/src/langgraph-equivalents/rewoo.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from 'vitest'; +import { createRewooExample } from '../../examples/rewoo.js'; + +test('rewoo workflow plans named steps, resolves references, and synthesizes a final answer', async () => { + const machine = createRewooExample({ + plan: async () => ({ + steps: [ + { + id: 'E1', + instruction: 'Find the framework', + input: 'LangGraphJS runtime', + }, + { + id: 'E2', + instruction: 'Summarize the finding', + input: 'Use #E1 to produce a concise takeaway', + }, + ], + }), + executeStep: async ({ step, resolvedInput }) => ({ + result: `${step.id}:${resolvedInput}`, + }), + solve: async ({ resultsById }) => ({ + answer: `${resultsById.E1} | ${resultsById.E2}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ objective: 'understand the runtime' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + objective: 'understand the runtime', + steps: [ + { + id: 'E1', + instruction: 'Find the framework', + input: 'LangGraphJS runtime', + }, + { + id: 'E2', + instruction: 'Summarize the finding', + input: 'Use #E1 to produce a concise takeaway', + }, + ], + resultsById: { + E1: 'E1:LangGraphJS runtime', + E2: 'E2:Use E1:LangGraphJS runtime to produce a concise takeaway', + }, + answer: + 'E1:LangGraphJS runtime | E2:Use E1:LangGraphJS runtime to produce a concise takeaway', + }); + } +}); diff --git a/src/langgraph-equivalents/streaming.test.ts b/src/langgraph-equivalents/streaming.test.ts index 779b3ad..348488a 100644 --- a/src/langgraph-equivalents/streaming.test.ts +++ b/src/langgraph-equivalents/streaming.test.ts @@ -7,14 +7,13 @@ import { } from '../index.js'; function once( - run: { on(type: string, handler: (event: unknown) => void): () => void }, - type: string + subscribe: (handler: (event: T) => void) => () => void ) { return new Promise((resolve) => { let off = () => {}; - off = run.on(type, (event) => { + off = subscribe((event) => { off(); - resolve(event as T); + resolve(event); }); }); } @@ -55,10 +54,10 @@ test('streams live invoke output while preserving durable state history', async const liveParts: string[] = []; run.on('textPart', (part) => { - liveParts.push((part as { delta: string }).delta); + liveParts.push(part.delta); }); - await once(run, 'done'); + await once(run.onDone.bind(run)); expect(liveParts).toEqual(['hello', ' world']); expect(run.getSnapshot()).toEqual( diff --git a/src/langgraph-equivalents/supervisor.test.ts b/src/langgraph-equivalents/supervisor.test.ts new file mode 100644 index 0000000..e2d71f9 --- /dev/null +++ b/src/langgraph-equivalents/supervisor.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from 'vitest'; +import { createSupervisorExample } from '../../examples/supervisor.js'; + +test('supervisor workflow retries a blocked worker and escalates when repeated attempts fail', async () => { + let decisions = 0; + + const machine = createSupervisorExample({ + adapter: { + decide: async () => { + decisions += 1; + + if (decisions === 1) { + return { + choice: 'retry', + data: { + instruction: 'Retry using the customer email already on file.', + }, + }; + } + + return { + choice: 'escalate', + data: { + reason: 'Escalate to billing because the request still lacks a verified account match.', + }, + }; + }, + }, + handle: async ({ attempt, instruction }) => ({ + status: 'blocked', + issue: + attempt === 1 + ? 'Missing account identifier.' + : `Still blocked after retry: ${instruction}`, + }), + maxAttempts: 2, + }); + + const result = await machine.execute( + machine.getInitialState({ + request: 'Refund the duplicate annual subscription charge.', + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + request: 'Refund the duplicate annual subscription charge.', + status: 'escalated', + resolution: null, + escalationReason: + 'Escalate to billing because the request still lacks a verified account match.', + attemptCount: 2, + history: [ + 'worker:1:blocked:Missing account identifier.', + 'supervisor:retry:Retry using the customer email already on file.', + 'worker:2:blocked:Still blocked after retry: Retry using the customer email already on file.', + 'supervisor:escalate:Escalate to billing because the request still lacks a verified account match.', + ], + }); + } +}); diff --git a/src/langgraph-equivalents/tool-calling.test.ts b/src/langgraph-equivalents/tool-calling.test.ts index 0d290b7..2a54431 100644 --- a/src/langgraph-equivalents/tool-calling.test.ts +++ b/src/langgraph-equivalents/tool-calling.test.ts @@ -7,14 +7,13 @@ import { } from '../index.js'; function once( - run: { on(type: string, handler: (event: unknown) => void): () => void }, - type: string + subscribe: (handler: (event: T) => void) => () => void ) { return new Promise((resolve) => { let off = () => {}; - off = run.on(type, (event) => { + off = subscribe((event) => { off(); - resolve(event as T); + resolve(event); }); }); } @@ -78,13 +77,13 @@ test('supports tool-call style invokes with live tool events and final output', const events: string[] = []; run.on('toolCall', (event) => { - events.push(`call:${(event as { toolName: string }).toolName}`); + events.push(`call:${event.toolName}`); }); run.on('toolResult', (event) => { - events.push(`result:${(event as { toolName: string }).toolName}`); + events.push(`result:${event.toolName}`); }); - await once(run, 'done'); + await once(run.onDone.bind(run)); expect(events).toEqual(['call:getWeather', 'result:getWeather']); expect(run.getSnapshot()).toEqual( diff --git a/src/machine.ts b/src/machine.ts index 322699a..fa2942b 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -17,7 +17,7 @@ import { findEventSchema, formatSchemaIssues, getAvailableEvents, - getParams, + getInput, isDoneInvokeEventType, isErrorInvokeEventType, resolveInitial, @@ -28,73 +28,61 @@ import { import type { StateConfigAny } from './utils.js'; // ─── Type helpers ─── - -type FallbackAny = unknown extends T ? any : T; - -/** Choice result shape — always the same for type: 'choice' */ -type ChoiceResult = { choice: string; data: Record; reasoning?: string }; - -/** Result type for onDone: typed from resultSchema when present */ -type OnDoneResult = unknown extends TResult ? ChoiceResult : NoInfer; +/** Result type for onDone: typed from invoke return or resultSchema when present */ +type OnDoneResult = NoInfer; type EventFor = E extends keyof TEvents & string ? { type: E } & EventPayload> : { type: E & string; [k: string]: unknown }; type StateNodeDef< + TState, TContext extends Record, - TParams, + TInput, TResult, TEvents, TTarget extends string, - TParamsMap extends Record, -> = - | { + TInputMap extends Record, + TOutput, +> = { type?: 'final' | 'choice'; - paramsSchema?: StandardSchemaV1; + inputSchema?: StandardSchemaV1; resultSchema?: StandardSchemaV1; invoke?: (args: { context: TContext; - params: NoInfer; + input: NoInfer; signal?: AbortSignal; - }, enq: { emit(part: EmittedPart): void }) => Promise>; - onDone?: (args: { result: OnDoneResult; context: TContext }) => TransitionResult; - on?: { [E in keyof TEvents & string]?: TransitionResult | ((args: { + }, enq: { emit(part: EmittedPart): void }) => Promise; + onDone?: (args: { result: OnDoneResult; context: TContext }) => TransitionResult; + on?: { [E in keyof TEvents & string]?: TransitionResult | ((args: { event: EventFor; context: TContext; - }, enq: { emit(part: EmittedPart): void }) => TransitionResult) }; + }, enq: { emit(part: EmittedPart): void }) => TransitionResult) }; events?: Record; - output?: (args: { context: TContext }) => unknown; - // choice-specific + output?: (args: { context: TContext }) => NoInfer; model?: string; adapter?: import('./types.js').AgentAdapter; - prompt?: string | ((args: { context: TContext; params: NoInfer }) => string); + prompt?: string | ((args: { context: TContext; input: NoInfer }) => string); options?: Record; reasoning?: boolean; - // internal - __type?: 'decide' | 'classify'; - __decideConfig?: Record; -} - | { - on?: StateConfigAny['on']; - __type: 'decide' | 'classify'; - __decideConfig: Record; - __classifyConfig?: Record; - }; +}; type StatesMap< TContext extends Record, - TParamsMap extends Record, + TInputMap extends Record, TResultMap extends Record, + TOutput, TEvents, > = { - [K in keyof TParamsMap & keyof TResultMap]: StateNodeDef< + [K in keyof TInputMap & keyof TResultMap]: StateNodeDef< + unknown, TContext, - TParamsMap[K], + TInputMap[K], TResultMap[K], TEvents, - keyof TParamsMap & keyof TResultMap & string, - TParamsMap + keyof TInputMap & keyof TResultMap & string, + TInputMap, + TOutput >; }; @@ -103,82 +91,91 @@ export function createAgentMachine< TInput, TContext extends Record, const TEvents extends Record, - const TParamsMap extends Record, + const TInputMap extends Record, TResultMap extends Record, + const TEmitted extends Record, + TOutput = unknown, >(config: { id: string; schemas: { context: StandardSchemaV1; input?: StandardSchemaV1; events?: TEvents; - emitted?: Record; + emitted?: TEmitted; + output?: StandardSchemaV1; }; context: (input: NoInfer) => NoInfer; adapter?: import('./types.js').AgentAdapter; initial: - | (keyof TParamsMap & keyof TResultMap & string) + | (keyof TInputMap & keyof TResultMap & string) | ((args: { context: NoInfer }) => { - target: keyof TParamsMap & keyof TResultMap & string; - params?: Record; + target: keyof TInputMap & keyof TResultMap & string; + input?: Record; }); - states: StatesMap, TParamsMap, TResultMap, TEvents>; -}): AgentMachine>; + states: StatesMap, TInputMap, TResultMap, TOutput, TEvents>; +}): AgentMachine, TOutput, TEmitted>; // ─── Overload B: no schemas.context ─── export function createAgentMachine< TInput, TContext extends Record, const TEvents extends Record, - const TParamsMap extends Record, + const TInputMap extends Record, TResultMap extends Record, + const TEmitted extends Record, + TOutput = unknown, >(config: { id: string; schemas: { input: StandardSchemaV1; context?: never; events?: TEvents; - emitted?: Record; + emitted?: TEmitted; + output?: StandardSchemaV1; }; context: (input: NoInfer) => TContext; adapter?: import('./types.js').AgentAdapter; initial: - | (keyof TParamsMap & keyof TResultMap & string) + | (keyof TInputMap & keyof TResultMap & string) | ((args: { context: TContext }) => { - target: keyof TParamsMap & keyof TResultMap & string; - params?: Record; + target: keyof TInputMap & keyof TResultMap & string; + input?: Record; }); - states: StatesMap; -}): AgentMachine>; + states: StatesMap; +}): AgentMachine, TOutput, TEmitted>; // ─── Overload C: no schemas.input or schemas.context ─── export function createAgentMachine< TContext extends Record, const TEvents extends Record, - const TParamsMap extends Record, + const TInputMap extends Record, TResultMap extends Record, + const TEmitted extends Record, + TOutput = unknown, >(config: { id: string; schemas?: { input?: never; context?: never; events?: TEvents; - emitted?: Record; + emitted?: TEmitted; + output?: StandardSchemaV1; }; context: (...args: any[]) => TContext; adapter?: import('./types.js').AgentAdapter; initial: - | (keyof TParamsMap & keyof TResultMap & string) + | (keyof TInputMap & keyof TResultMap & string) | ((args: { context: TContext }) => { - target: keyof TParamsMap & keyof TResultMap & string; - params?: Record; + target: keyof TInputMap & keyof TResultMap & string; + input?: Record; }); - states: StatesMap; -}): AgentMachine>; + states: StatesMap; +}): AgentMachine, TOutput, TEmitted>; // ─── Implementation ─── export function createAgentMachine( - machineConfig: MachineConfig + machineConfig: MachineConfig ): AgentMachine { const cfg = machineConfig as MachineConfig; @@ -224,7 +221,7 @@ export function createAgentMachine( } const context = cfg.context(validatedInput); - const init = resolveInitial(cfg.initial, { context, params: {} }); + const init = resolveInitial(cfg.initial, { context, input: {} }); if (!init.target) { throw new Error('Initial transition must specify a target state'); @@ -234,14 +231,14 @@ export function createAgentMachine( value: init.target, context: init.context ? { ...context, ...init.context } : context, status: 'active', - params: init.params ? { [init.target]: init.params } : {}, + input: init.input ? { [init.target]: init.input } : {}, }; } function resolveState(raw: { value: string; context: Record; - params?: Record>; + input?: Record>; sessionId?: string; createdAt?: number; status?: AgentState['status']; @@ -252,7 +249,7 @@ export function createAgentMachine( value: raw.value, context: raw.context, status: raw.status ?? 'active', - params: raw.params ?? {}, + input: raw.input ?? {}, sessionId: raw.sessionId, createdAt: raw.createdAt, output: raw.output, @@ -278,8 +275,6 @@ export function createAgentMachine( onEmit?.(part); }); const sc = resolveStateConfig(cfg, state.value); - const effectiveConfig = getEffectiveStateConfig(state.value); - function applyResult( result: TransitionResult, status = state.status @@ -319,12 +314,12 @@ export function createAgentMachine( if (isDoneInvokeEventType(state.value, event.type)) { const result = 'output' in event ? event.output : undefined; - const validatedResult = effectiveConfig.resultSchema - ? validateSchemaSync(effectiveConfig.resultSchema, result) + const validatedResult = sc.resultSchema + ? validateSchemaSync(sc.resultSchema, result) : result; - if (effectiveConfig.onDone) { - const trans = effectiveConfig.onDone({ + if (sc.onDone) { + const trans = sc.onDone({ result: validatedResult, context: state.context, }); @@ -371,23 +366,16 @@ export function createAgentMachine( ); } - function getEffectiveStateConfig(value: string): StateConfigAny { - const sc = resolveStateConfig(cfg, value); - return sc.__decideConfig - ? { ...sc, ...(sc.__decideConfig as Record) } - : sc; - } - function validateReplayableResult( value: string, result: unknown ): unknown { - const effectiveConfig = getEffectiveStateConfig(value); - if (!effectiveConfig.resultSchema) { + const sc = resolveStateConfig(cfg, value); + if (!sc.resultSchema) { return result; } - return validateSchemaSync(effectiveConfig.resultSchema, result); + return validateSchemaSync(sc.resultSchema, result); } function validateEventPayload( @@ -451,8 +439,8 @@ export function createAgentMachine( } async function createChoiceEvent(state: AgentState): Promise { - const dc = getEffectiveStateConfig(state.value); - const adapter = (dc as StateConfigAny).adapter ?? cfg.adapter; + const sc = resolveStateConfig(cfg, state.value); + const adapter = sc.adapter ?? cfg.adapter; if (!adapter) { return { type: `xstate.error.invoke.${state.value}`, @@ -461,18 +449,18 @@ export function createAgentMachine( }; } - const params = getParams(state.value, state.params); + const input = getInput(state.value, state.input); const prompt = - typeof dc.prompt === 'function' - ? dc.prompt({ context: state.context, params }) - : dc.prompt; + typeof sc.prompt === 'function' + ? sc.prompt({ context: state.context, input }) + : sc.prompt; try { const result = await adapter.decide({ - model: (dc as StateConfigAny).model!, + model: sc.model!, prompt: prompt as string, - options: (dc as StateConfigAny).options!, - reasoning: (dc as StateConfigAny).reasoning, + options: sc.options!, + reasoning: sc.reasoning, }); const validatedResult = validateReplayableResult(state.value, result); @@ -499,7 +487,7 @@ export function createAgentMachine( const result = await sc.invoke!( { context: state.context, - params: getParams(state.value, state.params), + input: getInput(state.value, state.input), }, createEnqueue(onEmit) ); @@ -528,7 +516,7 @@ export function createAgentMachine( } const sc = resolveStateConfig(cfg, state.value); - if (sc.type === 'choice' || sc.__decideConfig) { + if (sc.type === 'choice') { return createChoiceEvent(state); } @@ -571,9 +559,12 @@ export function createAgentMachine( const sc = resolveStateConfig(cfg, state.value); if (sc.type === 'final') { - const output = sc.output + const rawOutput = sc.output ? sc.output({ context: state.context }) : undefined; + const output = cfg.schemas?.output + ? validateSchemaSync(cfg.schemas.output, rawOutput) + : rawOutput; return { ...state, status: 'done', output }; } @@ -654,7 +645,7 @@ export function createAgentMachine( status: s.status, sessionId: runtime.sessionId, createdAt: runtime.createdAt, - params: s.params, + input: s.input, output: s.output, error: s.error, }; diff --git a/src/persistence.test.ts b/src/persistence.test.ts index 406ef40..ae505e7 100644 --- a/src/persistence.test.ts +++ b/src/persistence.test.ts @@ -51,7 +51,7 @@ test('loads the most replay-advanced saved snapshot', async () => { status: 'active', createdAt: 100, sessionId: 'session-1', - params: { + input: { idle: { count: 1 }, }, }, @@ -67,7 +67,7 @@ test('loads the most replay-advanced saved snapshot', async () => { status: 'done', createdAt: 300, sessionId: 'session-1', - params: { + input: { done: { count: 2 }, }, output: { count: 2 }, @@ -84,7 +84,7 @@ test('loads the most replay-advanced saved snapshot', async () => { status: 'done', createdAt: 300, sessionId: 'session-1', - params: { + input: { done: { count: 2 }, }, output: { count: 2 }, @@ -105,7 +105,7 @@ test('loads the most replay-advanced snapshot even if saved earlier', async () = status: 'done', createdAt: 500, sessionId: 'session-1', - params: { done: { count: 5 } }, + input: { done: { count: 5 } }, }, createdAt: 500, }); @@ -119,7 +119,7 @@ test('loads the most replay-advanced snapshot even if saved earlier', async () = status: 'active', createdAt: 200, sessionId: 'session-1', - params: { review: { count: 2 } }, + input: { review: { count: 2 } }, }, createdAt: 200, }); @@ -133,7 +133,7 @@ test('loads the most replay-advanced snapshot even if saved earlier', async () = status: 'done', createdAt: 500, sessionId: 'session-1', - params: { done: { count: 5 } }, + input: { done: { count: 5 } }, }, createdAt: 500, }); diff --git a/src/prebuilt/react.ts b/src/prebuilt/react.ts index e4292d6..5e64924 100644 --- a/src/prebuilt/react.ts +++ b/src/prebuilt/react.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { createAgentMachine } from '../machine.js'; -import type { AgentMachine, StandardSchemaV1 } from '../types.js'; +import type { StandardSchemaV1 } from '../types.js'; const messageSchema = z.object({ role: z.enum(['system', 'user', 'assistant', 'tool']), @@ -48,16 +48,7 @@ export function createReactAgent(options: { schema?: StandardSchemaV1; }>; }) => Promise; -}): AgentMachine< - { messages?: ReactAgentMessage[] }, - { - messages: ReactAgentMessage[]; - stepCount: number; - pendingToolCall: - | { toolName: string; input: Record } - | null; - } -> { +}) { const tools = options.tools ?? []; const maxSteps = options.maxSteps ?? 8; const toolDefinitions = tools.map(({ name, description, schema }) => ({ @@ -131,7 +122,10 @@ export function createReactAgent(options: { stepCount: context.stepCount + 1, messages: [ ...context.messages, - { role: 'assistant', content: result.message }, + { + role: 'assistant', + content: result.message, + } satisfies ReactAgentMessage, ], }, }; @@ -152,10 +146,10 @@ export function createReactAgent(options: { content: result.message ?? `Calling tool ${result.toolName} with ${JSON.stringify(result.input)}`, - }, + } satisfies ReactAgentMessage, ], }, - params: { + input: { toolName: result.toolName, input: result.input, }, @@ -163,7 +157,7 @@ export function createReactAgent(options: { }, }, tool: { - paramsSchema: z.object({ + inputSchema: z.object({ toolName: z.string(), input: z.record(z.string(), z.unknown()), }), @@ -171,29 +165,29 @@ export function createReactAgent(options: { toolName: z.string(), output: z.unknown(), }), - invoke: async ({ params }, enq) => { - const tool = toolsByName.get(params.toolName); + invoke: async ({ input }, enq) => { + const tool = toolsByName.get(input.toolName); if (!tool) { - throw new Error(`Tool '${params.toolName}' not found`); + throw new Error(`Tool '${input.toolName}' not found`); } enq.emit({ type: 'toolCall', - toolName: params.toolName, - input: params.input, + toolName: input.toolName, + input: input.input, }); - const output = await tool.execute(params.input); + const output = await tool.execute(input.input); enq.emit({ type: 'toolResult', - toolName: params.toolName, + toolName: input.toolName, output, }); return { - toolName: params.toolName, + toolName: input.toolName, output, }; }, @@ -207,7 +201,7 @@ export function createReactAgent(options: { role: 'tool', name: result.toolName, content: serializeToolOutput(result.output), - }, + } satisfies ReactAgentMessage, ], }, }), diff --git a/src/runtime/session.ts b/src/runtime/session.ts index e916f1d..bb208f5 100644 --- a/src/runtime/session.ts +++ b/src/runtime/session.ts @@ -11,6 +11,15 @@ import type { } from '../types.js'; import { isReservedInternalEventType } from '../utils.js'; +const RESERVED_PUBLIC_ON_TYPES = new Set([ + 'part', + 'done', + 'error', + 'state', + 'machine.event', + 'runtime', +]); + type SnapshotRuntime = { sessionId: string; createdAt: number; @@ -74,12 +83,12 @@ function toJournalEvent( } function createRun( - machine: AgentMachine, + machine: AgentMachine, store: SessionOptions['store'], runtimeMachine: RuntimeMachine, runState: RunState, emitter = createRunEmitter() -): AgentRun { +): AgentRun { let releaseStart!: () => void; let operation = new Promise((resolve) => { releaseStart = resolve; @@ -252,7 +261,33 @@ function createRun( }, on(type, handler) { - return emitter.on(type, handler); + if (RESERVED_PUBLIC_ON_TYPES.has(type)) { + throw new Error( + `'${type}' is not an emitted event subscription. Use a dedicated run method instead.` + ); + } + + return emitter.on(type, handler as (event: unknown) => void); + }, + + onEmitted(handler) { + return emitter.on('part', handler as (event: unknown) => void); + }, + + onDone(handler) { + return emitter.on('done', handler as (event: unknown) => void); + }, + + onError(handler) { + return emitter.on('error', handler as (event: unknown) => void); + }, + + onSnapshot(handler) { + return emitter.on('state', handler as (event: unknown) => void); + }, + + onMachineEvent(handler) { + return emitter.on('machine.event', handler as (event: unknown) => void); }, /** @internal */ @@ -271,15 +306,24 @@ function createRun( __scheduleStart() { scheduleStart(); }, - } as AgentRun; + } as AgentRun; } -export async function startSession( - machine: AgentMachine, - options: SessionOptions -): Promise { +export async function startSession< + TInput, + TContext extends Record, + TEvents extends Record, + TStates extends Record, + TOutput, + TEmitted extends Record, +>( + machine: AgentMachine, + options: SessionOptions +): Promise> { const runtimeMachine = asRuntimeMachine(machine); - const initialState = machine.getInitialState(options.input); + const initialState = (machine as AgentMachine).getInitialState( + options.input as TInput + ) as AgentState; const runtime = { sessionId: options.sessionId ?? createSessionId(), createdAt: Date.now(), @@ -296,7 +340,7 @@ export async function startSession( options.store, runtimeMachine, runState - ) as AgentRun & { + ) as AgentRun & { __persistCurrent(): Promise; __settle(): Promise; __scheduleStart(): void; @@ -316,10 +360,17 @@ export async function startSession( return run; } -export async function restoreSession( - machine: AgentMachine, +export async function restoreSession< + TInput, + TContext extends Record, + TEvents extends Record, + TStates extends Record, + TOutput, + TEmitted extends Record, +>( + machine: AgentMachine, options: RestoreSessionOptions -): Promise { +): Promise> { const runtimeMachine = asRuntimeMachine(machine); const persisted = await options.store.loadLatestSnapshot(options.sessionId); const allEvents = await options.store.loadEvents(options.sessionId); @@ -336,8 +387,14 @@ export async function restoreSession( createdAt: persisted?.snapshot.createdAt ?? initEvent?.at ?? Date.now(), }; const initialState = persisted - ? machine.resolveState(persisted.snapshot) - : machine.getInitialState(initEvent?.input); + ? (machine.resolveState( + persisted.snapshot as AgentSnapshot + ) as AgentState) + : ((machine as AgentMachine).getInitialState(initEvent?.input) as AgentState< + TContext, + keyof TStates & string, + TOutput + >); const runState: RunState = { current: runtimeMachine.__runtime.withRuntimeMetadata(initialState, runtime), snapshot: @@ -351,7 +408,7 @@ export async function restoreSession( options.store, runtimeMachine, runState - ) as AgentRun & { + ) as AgentRun & { __persistCurrent(): Promise; __settle(): Promise; __scheduleStart(): void; @@ -365,7 +422,10 @@ export async function restoreSession( for (const event of replayTail) { runState.current = runtimeMachine.__runtime.withRuntimeMetadata( - machine.transition(runState.current, event), + machine.transition( + runState.current as AgentState, + event as unknown as import('../types.js').TransitionEvent + ) as AgentState, runState.runtime ); runState.lastSequence = event.sequence; diff --git a/src/session-runtime.test.ts b/src/session-runtime.test.ts index 10df0ff..7b7d292 100644 --- a/src/session-runtime.test.ts +++ b/src/session-runtime.test.ts @@ -49,7 +49,7 @@ test('startSession creates a session, persists xstate.init, and returns before s value: 'idle', status: 'active', context: { count: 0 }, - params: {}, + input: {}, }) ); await vi.waitFor(() => { diff --git a/src/session-types.test.ts b/src/session-types.test.ts index c1cf2ac..ea4b8a7 100644 --- a/src/session-types.test.ts +++ b/src/session-types.test.ts @@ -8,7 +8,7 @@ test('AgentSnapshot includes durable session fields', () => { status: 'active', createdAt: 123, sessionId: 'session-1', - params: {}, + input: {}, }; expect(snapshot.sessionId).toBe('session-1'); diff --git a/src/stream-snapshot.test.ts b/src/stream-snapshot.test.ts index 91157f8..22248d5 100644 --- a/src/stream-snapshot.test.ts +++ b/src/stream-snapshot.test.ts @@ -6,7 +6,7 @@ const machine = createAgentMachine({ context: () => ({}), initial: () => ({ target: 'done', - params: { step: 1 }, + input: { step: 1 }, }), states: { done: { @@ -31,7 +31,7 @@ test('stream emits durable snapshots with stable session metadata', async () => expect(snaps.length).toBeGreaterThanOrEqual(2); expect(new Set(snaps.map((snap) => snap.sessionId)).size).toBe(1); expect(new Set(snaps.map((snap) => snap.createdAt)).size).toBe(1); - expect(snaps[0]!.params).toEqual({ done: { step: 1 } }); + expect(snaps[0]!.input).toEqual({ done: { step: 1 } }); expect(snaps[0]).toEqual( expect.objectContaining({ sessionId: expect.any(String), @@ -39,7 +39,7 @@ test('stream emits durable snapshots with stable session metadata', async () => value: 'done', context: {}, status: 'active', - params: { done: { step: 1 } }, + input: { done: { step: 1 } }, }) ); expect(snaps[snaps.length - 1]).toEqual( @@ -49,7 +49,7 @@ test('stream emits durable snapshots with stable session metadata', async () => value: 'done', context: {}, status: 'done', - params: { done: { step: 1 } }, + input: { done: { step: 1 } }, output: { ok: true }, }) ); @@ -62,10 +62,10 @@ test('snapshot roundtrips through resolveState without losing identity', async ( expect(restored.sessionId).toBe(emitted[0]!.sessionId); expect(restored.createdAt).toBe(emitted[0]!.createdAt); - expect(restored.params).toEqual(emitted[0]!.params); + expect(restored.input).toEqual(emitted[0]!.input); expect(rerun[0]!.sessionId).toBe(emitted[0]!.sessionId); expect(rerun[0]!.createdAt).toBe(emitted[0]!.createdAt); - expect(rerun[0]!.params).toEqual(emitted[0]!.params); + expect(rerun[0]!.input).toEqual(emitted[0]!.input); }); test('fresh machine executions on the same raw state get distinct session ids', async () => { diff --git a/src/streaming.test.ts b/src/streaming.test.ts index ec782f6..3bdf276 100644 --- a/src/streaming.test.ts +++ b/src/streaming.test.ts @@ -7,14 +7,13 @@ import { } from './index.js'; function once( - run: { on(type: string, handler: (event: unknown) => void): () => void }, - type: string + subscribe: (handler: (event: T) => void) => () => void ) { return new Promise((resolve) => { let off = () => {}; - off = run.on(type, (event) => { + off = subscribe((event) => { off(); - resolve(event as T); + resolve(event); }); }); } @@ -56,19 +55,19 @@ test('returns a live run before initial invoke output and emits ephemeral parts' const allParts: Array<{ type: string; delta: string }> = []; const states: string[] = []; const events: string[] = []; - const done = once<{ output: { text: string } }>(run, 'done'); + const done = once(run.onDone.bind(run)); const offPart = run.on('textPart', (part) => { parts.push(part as { type: string; delta: string }); }); - const offAnyPart = run.on('part', (part) => { - allParts.push(part as { type: string; delta: string }); + const offAnyPart = run.onEmitted((part) => { + allParts.push(part); }); - const offState = run.on('state', (snapshot) => { - states.push((snapshot as { value: string }).value); + const offState = run.onSnapshot((snapshot) => { + states.push(snapshot.value); }); - const offEvent = run.on('machine.event', (event) => { - events.push((event as { type: string }).type); + const offEvent = run.onMachineEvent((event) => { + events.push(event.type); }); expect(run.getSnapshot()).toEqual( @@ -132,22 +131,22 @@ test('does not replay prior events to late subscribers', async () => { const run = await startSession(machine, { store: createMemoryRunStore(), }); - await once(run, 'done'); + await once(run.onDone.bind(run)); const lateParts: Array<{ type: string; delta: string }> = []; const replayedStates: string[] = []; const replayedEvents: string[] = []; run.on('textPart', (part) => { - lateParts.push(part as { type: string; delta: string }); + lateParts.push(part); }); - run.on('state', (snapshot) => { - replayedStates.push((snapshot as { value: string }).value); + run.onSnapshot((snapshot) => { + replayedStates.push(snapshot.value); }); - run.on('machine.event', (event) => { - replayedEvents.push((event as { type: string }).type); + run.onMachineEvent((event) => { + replayedEvents.push(event.type); }); - run.on('done', () => { + run.onDone(() => { replayedEvents.push('done'); }); @@ -179,7 +178,7 @@ test('invalid emitted parts are rejected', async () => { const run = await startSession(machine, { store: createMemoryRunStore(), }); - await once(run, 'error'); + await once(run.onError.bind(run)); expect(run.getSnapshot()).toEqual( expect.objectContaining({ @@ -230,7 +229,7 @@ test('transition handlers can emit live effects without journaling them', async const parts: string[] = []; run.on('textPart', (part) => { - parts.push((part as { delta: string }).delta); + parts.push(part.delta); }); await run.send({ type: 'send' }); diff --git a/src/target-types.assert.ts b/src/target-types.assert.ts index ee3e9a0..80fde55 100644 --- a/src/target-types.assert.ts +++ b/src/target-types.assert.ts @@ -29,7 +29,7 @@ const machine = createAgentMachine({ machine.transition(machine.getInitialState(), { type: 'advance' }); createAgentMachine({ - id: 'typed-target-params', + id: 'typed-target-input', context: () => ({ count: 0 }), initial: 'idle', states: { @@ -37,14 +37,14 @@ createAgentMachine({ on: { advance: () => ({ target: 'working', - params: { + input: { index: 0, }, }), }, }, working: { - paramsSchema: z.object({ + inputSchema: z.object({ index: z.number(), }), }, @@ -58,21 +58,96 @@ createAgentMachine({ }, }); +const typedMachine = createAgentMachine({ + id: 'typed-surface', + schemas: { + input: z.object({ + task: z.string(), + }), + events: { + submit: z.object({ + value: z.number(), + }), + }, + output: z.object({ + task: z.string(), + total: z.number(), + }), + }, + context: (input) => ({ + task: input.task, + total: 0, + }), + initial: 'idle', + states: { + idle: { + on: { + submit: ({ event }) => { + event.value satisfies number; + // @ts-expect-error invalid event payload property + event.missing; + return { + target: 'done', + context: { total: event.value }, + }; + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + task: context.task, + total: context.total, + }), + }, + }, +}); + +typedMachine.getInitialState({ task: 'ship it' }); +// @ts-expect-error missing required input +typedMachine.getInitialState(); +// @ts-expect-error wrong input type +typedMachine.getInitialState({ task: 42 }); + +const typedState = typedMachine.getInitialState({ task: 'infer state values' }); +typedState.value satisfies 'idle' | 'done'; +// @ts-expect-error invalid state literal +typedState.value satisfies 'missing'; + +typedMachine.transition(typedState, { type: 'submit', value: 1 }); +// @ts-expect-error invalid event type +typedMachine.transition(typedState, { type: 'missing' }); +// @ts-expect-error invalid event payload +typedMachine.transition(typedState, { type: 'submit', value: 'nope' }); + +void (async () => { + const result = await typedMachine.execute( + typedMachine.transition(typedState, { type: 'submit', value: 2 }) + ); + + if (result.status === 'done') { + result.output.total satisfies number; + result.output.task satisfies string; + // @ts-expect-error no missing output property + result.output.missing; + } +})(); + createAgentMachine({ - id: 'missing-required-target-params', + id: 'missing-required-target-input', context: () => ({ count: 0 }), initial: 'idle', states: { - // @ts-expect-error params should be required when the target has paramsSchema idle: { on: { + // @ts-expect-error input should be required when the target has inputSchema advance: () => ({ target: 'working', }), }, }, working: { - paramsSchema: z.object({ + inputSchema: z.object({ index: z.number(), }), }, @@ -91,9 +166,9 @@ createAgentMachine({ context: () => ({ count: 0 }), initial: 'idle', states: { - // @ts-expect-error invalid targets should be rejected at author time idle: { on: { + // @ts-expect-error invalid targets should be rejected at author time advance: () => ({ target: 'missing', }), @@ -113,16 +188,16 @@ createAgentMachine({ }); createAgentMachine({ - id: 'unexpected-target-params', + id: 'unexpected-target-input', context: () => ({ count: 0 }), initial: 'idle', states: { idle: { on: { - // @ts-expect-error params should be rejected when the target has no paramsSchema + // @ts-expect-error input should be rejected when the target has no inputSchema advance: () => ({ target: 'done', - params: { + input: { anything: true, }, }), @@ -142,23 +217,23 @@ createAgentMachine({ }); createAgentMachine({ - id: 'invalid-target-params', + id: 'invalid-target-input', context: () => ({ count: 0 }), initial: 'idle', states: { idle: { on: { - // @ts-expect-error target params should match the target state's params schema + // @ts-expect-error target input should match the target state's input schema advance: () => ({ target: 'working', - params: { + input: { wrong: true, }, }), }, }, working: { - paramsSchema: z.object({ + inputSchema: z.object({ index: z.number(), }), }, @@ -179,17 +254,17 @@ createAgentMachine({ states: { idle: { on: { - // @ts-expect-error target params should match the target param field types + // @ts-expect-error target input should match the target input field types advance: () => ({ target: 'working', - params: { + input: { index: 'hello', }, }), }, }, working: { - paramsSchema: z.object({ + inputSchema: z.object({ index: z.number(), }), }, diff --git a/src/types.ts b/src/types.ts index 961dc33..ec0b612 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,22 +66,22 @@ export interface AgentAdapter { export type TransitionResult< TContext extends Record = Record, TTarget extends string = string, - TParamsByTarget extends Record = {}, + TInputByTarget extends Record = {}, > = | { target?: undefined; context?: Partial; - params?: never; + input?: never; } | { [K in TTarget]: { target: K; context?: Partial; - } & (K extends keyof TParamsByTarget - ? IsExactlyUnknown extends true - ? { params?: never } - : { params: TParamsByTarget[K] } - : { params?: never }) + } & (K extends keyof TInputByTarget + ? IsExactlyUnknown extends true + ? { input?: never } + : { input: TInputByTarget[K] } + : { input?: never }) }[TTarget]; export interface InitialTransitionResult< @@ -90,7 +90,7 @@ export interface InitialTransitionResult< > { target: TTarget; context?: Partial; - params?: Record; + input?: Record; } // ─── State Config ─── @@ -98,44 +98,53 @@ export interface InitialTransitionResult< export interface StateConfig< TContext extends Record = Record, TTarget extends string = string, - TParamsByTarget extends Record = {}, + TInputByTarget extends Record = {}, > { type?: 'final' | 'choice'; - paramsSchema?: StandardSchemaV1; + inputSchema?: StandardSchemaV1; resultSchema?: StandardSchemaV1; invoke?: (args: { context: TContext; - params: Record; + input: Record; signal?: AbortSignal; }, enq: InvokeEnqueue) => Promise; - onDone?: (args: { result: any; context: TContext }) => TransitionResult; - on?: Record | ((args: { event: any; context: TContext }, enq: InvokeEnqueue) => TransitionResult)>; + onDone?: (args: { result: any; context: TContext }) => TransitionResult; + on?: Record | ((args: { event: any; context: TContext }, enq: InvokeEnqueue) => TransitionResult)>; events?: Record; output?: (args: { context: TContext }) => unknown; // choice-specific model?: string; adapter?: AgentAdapter; - prompt?: string | ((args: { context: TContext; params: Record }) => string); + prompt?: string | ((args: { context: TContext; input: Record }) => string); options?: Record; reasoning?: boolean; - /** @internal */ __type?: 'decide' | 'classify'; - /** @internal */ __decideConfig?: any; - /** @internal */ __classifyConfig?: any; } +type OutputForState = TState extends { + output: (...args: any[]) => infer TOutput; +} + ? TOutput + : never; + +export type OutputForStates> = + [OutputForState] extends [never] + ? unknown + : OutputForState; + // ─── Agent State (POJO) ─── export interface AgentState< TContext extends Record = Record, TValue extends string = string, + TOutput = unknown, > { value: TValue; context: TContext; status: 'active' | 'pending' | 'done' | 'error'; - params: Record>; + input: Record>; sessionId?: string; createdAt?: number; - output?: unknown; + output?: TOutput; error?: unknown; } @@ -145,24 +154,26 @@ export type ExecuteResult< TContext extends Record = Record, TValue extends string = string, TEvents extends Record = {}, + TOutput = unknown, > = - | { status: 'done'; state: AgentState; output: unknown; context: TContext } - | { status: 'pending'; state: AgentState; value: TValue; events: Record; context: TContext } - | { status: 'error'; state: AgentState; error: unknown }; + | { status: 'done'; state: AgentState; output: TOutput; context: TContext } + | { status: 'pending'; state: AgentState; value: TValue; events: Record; context: TContext } + | { status: 'error'; state: AgentState; error: unknown }; // ─── Snapshot ─── export interface AgentSnapshot< TContext extends Record = Record, TValue extends string = string, + TOutput = unknown, > { value: TValue; context: TContext; status: AgentState['status']; createdAt: number; sessionId: string; - params: Record>; - output?: unknown; + input: Record>; + output?: TOutput; error?: unknown; } @@ -173,56 +184,88 @@ export interface AgentMachine< TContext extends Record = Record, TEvents extends Record = {}, TStates extends Record = Record>, + TOutput = OutputForStates, + TEmitted extends Record = {}, > { readonly id: string; getInitialState( ...args: unknown extends TInput ? [input?: TInput] : [input: TInput] - ): AgentState; + ): AgentState; resolveState( raw: - | AgentSnapshot + | AgentSnapshot | { value: string; context: TContext; - params?: Record>; + input?: Record>; sessionId?: string; createdAt?: number; status?: AgentState['status']; - output?: unknown; + output?: TOutput; error?: unknown; } - ): AgentState; + ): AgentState; transition( - state: AgentState, + state: AgentState, event: TransitionEvent - ): AgentState; + ): AgentState; invoke( - state: AgentState - ): Promise>; + state: AgentState + ): Promise>; execute( - state: AgentState - ): Promise>; + state: AgentState + ): Promise>; stream( - state: AgentState - ): AsyncGenerator>; + state: AgentState + ): AsyncGenerator>; } export interface AgentRun< TContext extends Record = Record, TValue extends string = string, TEvents extends Record = {}, + TOutput = unknown, + TEmitted extends Record = {}, > { readonly sessionId: string; - readonly status: AgentSnapshot['status']; - getSnapshot(): AgentSnapshot; + readonly status: AgentSnapshot['status']; + getSnapshot(): AgentSnapshot; send(event: TransitionEvent): Promise; - on(type: string, handler: (event: unknown) => void): () => void; + on( + type: TKey, + handler: (event: { type: TKey } & EventPayload>) => void + ): () => void; + onEmitted( + handler: (event: EmittedUnion) => void + ): () => void; + onDone( + handler: (event: { + output: TOutput; + snapshot: AgentSnapshot; + }) => void + ): () => void; + onError( + handler: (event: { + error: unknown; + snapshot: AgentSnapshot; + }) => void + ): () => void; + onSnapshot( + handler: (snapshot: AgentSnapshot) => void + ): () => void; + onMachineEvent( + handler: ( + event: import('./runtime/store.js').JournalEventRecord< + import('./runtime/events.js').JournalEvent + > + ) => void + ): () => void; } export interface SessionOptions< @@ -247,25 +290,25 @@ export interface MachineConfig< TInput = unknown, TContext extends Record = Record, TEvents extends Record = {}, - TStates extends Record> = Record>, + TStates extends Record = Record>, + TEmitted extends Record = {}, > { id: string; schemas?: { input?: StandardSchemaV1; context?: StandardSchemaV1; events?: TEvents; - emitted?: Record; + emitted?: TEmitted; + output?: StandardSchemaV1; }; context: (input: TInput) => TContext; adapter?: AgentAdapter; initial: | (keyof TStates & string) - | ((args: { context: TContext }) => { target: keyof TStates & string; params?: Record }); + | ((args: { context: TContext }) => { target: keyof TStates & string; input?: Record }); states: TStates; } -// ─── Decide ─── - export type DecideResultFor< TOptions extends Record, > = { @@ -276,38 +319,31 @@ export type DecideResultFor< }; }[keyof TOptions & string]; -export interface DecideConfig< - TContext extends Record = Record, - TParams extends Record = Record, +export interface DecideOptions< TOptions extends Record = Record, - TTarget extends string = string, - TParamsByTarget extends Record = {}, > { - model: string; adapter?: AgentAdapter; - prompt: string | ((args: { context: TContext; params: TParams }) => string); + model: string; + prompt: string; options: TOptions; reasoning?: boolean; - onDone: (args: { result: DecideResultFor; context: TContext }) => TransitionResult; - on?: Record TransitionResult>; } -// ─── Classify ─── +export interface ClassifyResultFor< + TCategories extends Record = Record, +> { + category: keyof TCategories & string; +} -export interface ClassifyConfig< - TContext extends Record = Record, - TParams extends Record = Record, +export interface ClassifyOptions< TCategories extends Record = Record, - TTarget extends string = string, - TParamsByTarget extends Record = {}, > { - model: string; adapter?: AgentAdapter; - prompt: string | ((args: { context: TContext; params: TParams }) => string); + model: string; + prompt: string; into: TCategories; examples?: Array<{ input: string; category: keyof TCategories & string }>; - onDone: (args: { result: { category: keyof TCategories & string }; context: TContext }) => TransitionResult; - on?: Record TransitionResult>; + reasoning?: boolean; } // ─── Trace ─── diff --git a/src/utils.ts b/src/utils.ts index 8ac89c8..2665900 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -50,7 +50,7 @@ export type StateConfigAny = { invoke?: ( args: { context: Record; - params: Record; + input: Record; }, enq: { emit(part: { type: string; [key: string]: unknown }): void } ) => Promise; @@ -60,22 +60,20 @@ export type StateConfigAny = { resultSchema?: StandardSchemaV1; model?: string; adapter?: { decide: (...args: unknown[]) => Promise }; - prompt?: string | ((args: { context: Record; params: Record }) => string); + prompt?: string | ((args: { context: Record; input: Record }) => string); options?: Record; reasoning?: boolean; events?: Record; - __type?: string; - __decideConfig?: Record; }; /** - * Get the params for the current state. + * Get the input for the current state. */ -export function getParams( +export function getInput( value: string, - params: Record> + input: Record> ): Record { - return params[value] ?? {}; + return input[value] ?? {}; } /** @@ -86,11 +84,11 @@ export function resolveInitial( | string | ((args: { context: Record; - params: Record; + input: Record; }) => InitialTransitionResult), args: { context: Record; - params: Record; + input: Record; } ): InitialTransitionResult { if (typeof initial === 'string') { @@ -116,10 +114,10 @@ export function applyTransition( newState.value = transition.target; newState.status = 'active'; - if (transition.params) { - newState.params = { - ...state.params, - [transition.target]: transition.params, + if (transition.input) { + newState.input = { + ...state.input, + [transition.target]: transition.input, }; } } From ccc71e0f02864525bcb46408fc889dcba319a94e Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 18 Apr 2026 15:51:30 -0400 Subject: [PATCH 18/50] feat: add stately graph export and workflow examples --- ...04-08-langgraph-core-replacement-design.md | 2 +- examples/cloudflare-durable-object.ts | 147 ++++++ examples/index.ts | 14 + examples/persistence.ts | 132 +++++ .../react-agent-from-scratch.ts | 14 +- examples/react-agent.ts | 4 +- examples/sql-agent.ts | 279 +++++++++++ package.json | 3 +- pnpm-lock.yaml | 36 +- src/examples.test.ts | 148 ++++++ src/graph/index.test.ts | 175 +++++++ src/graph/index.ts | 456 +++++++++++++++++- src/index.ts | 8 - .../prebuilt-react.test.ts | 6 +- src/langgraph-equivalents/sql-agent.test.ts | 107 ++++ src/machine.ts | 1 + src/types.ts | 2 + 17 files changed, 1491 insertions(+), 43 deletions(-) create mode 100644 examples/cloudflare-durable-object.ts create mode 100644 examples/persistence.ts rename src/prebuilt/react.ts => examples/react-agent-from-scratch.ts (94%) create mode 100644 examples/sql-agent.ts create mode 100644 src/graph/index.test.ts create mode 100644 src/langgraph-equivalents/sql-agent.test.ts diff --git a/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md b/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md index f30ecb3..a304dee 100644 --- a/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md +++ b/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md @@ -271,7 +271,7 @@ type AgentSnapshot = { status: "active" | "done" | "error" | "pending"; createdAt: number; sessionId: string; - params: Record>; + input: Record>; output?: unknown; error?: SerializedError; }; diff --git a/examples/cloudflare-durable-object.ts b/examples/cloudflare-durable-object.ts new file mode 100644 index 0000000..5ff0527 --- /dev/null +++ b/examples/cloudflare-durable-object.ts @@ -0,0 +1,147 @@ +import { + createPersistenceExample, +} from './persistence.js'; +import { + restoreSession, + startSession, + type AgentSnapshot, + type JournalEvent, + type JournalEventRecord, + type PersistedSnapshot, + type RunStore, +} from '../src/index.js'; + +export interface DurableObjectStorageLike { + get(key: string): Promise; + put(key: string, value: T): Promise; +} + +export interface DurableObjectStateLike { + storage: DurableObjectStorageLike; +} + +export function createDurableObjectRunStore( + storage: DurableObjectStorageLike +): RunStore { + return { + async append(sessionId, event) { + const key = journalKey(sessionId); + const current = (await storage.get(key)) ?? []; + const sequence = + current.length === 0 + ? 1 + : current[current.length - 1]!.sequence + 1; + + await storage.put(key, [...current, { ...event, sequence }]); + return { sequence }; + }, + + async loadEvents(sessionId, afterSequence = 0) { + const current = + (await storage.get[]>( + journalKey(sessionId) + )) ?? []; + + return current + .filter((event) => event.sequence > afterSequence) + .sort((a, b) => a.sequence - b.sequence); + }, + + async loadLatestSnapshot(sessionId) { + const snapshots = + (await storage.get[]>( + snapshotsKey(sessionId) + )) ?? []; + + return ( + [...snapshots].sort( + (a, b) => + a.afterSequence - b.afterSequence || a.createdAt - b.createdAt + ).at(-1) ?? null + ); + }, + + async saveSnapshot(snapshot) { + const key = snapshotsKey(snapshot.sessionId); + const current = + (await storage.get[]>(key)) ?? []; + + await storage.put(key, [...current, snapshot]); + }, + }; +} + +export class AgentSessionDurableObject { + private readonly store: RunStore; + + constructor(private readonly state: DurableObjectStateLike) { + this.store = createDurableObjectRunStore(state.storage); + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + const machine = createPersistenceExample(async ({ request, approved }) => ({ + summary: `${request} :: approved=${String(approved)}`, + })); + + if (request.method === 'POST' && url.pathname === '/start') { + const body = await request.json() as { request: string }; + const run = await startSession(machine, { + store: this.store, + input: { request: body.request }, + }); + + return Response.json({ + sessionId: run.sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'POST' && url.pathname === '/approve') { + const sessionId = requiredSessionId(url); + const run = await restoreSession(machine, { + store: this.store, + sessionId, + }); + + await run.send({ type: 'approve' }); + + return Response.json({ + sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'GET' && url.pathname === '/status') { + const sessionId = requiredSessionId(url); + const run = await restoreSession(machine, { + store: this.store, + sessionId, + }); + + return Response.json({ + sessionId, + snapshot: run.getSnapshot(), + }); + } + + return new Response('Not found', { status: 404 }); + } +} + +function requiredSessionId(url: URL): string { + const sessionId = url.searchParams.get('sessionId'); + if (!sessionId) { + throw new Error('Missing sessionId'); + } + + return sessionId; +} + +function journalKey(sessionId: string): string { + return `sessions/${sessionId}/journal`; +} + +function snapshotsKey(sessionId: string): string { + return `sessions/${sessionId}/snapshots`; +} diff --git a/examples/index.ts b/examples/index.ts index ce34ad0..8f611f9 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -1,9 +1,16 @@ export { createSimpleExample } from './simple.js'; +export { createSqlAgentExample } from './sql-agent.js'; export { createHitlExample } from './hitl.js'; export { createDecideExample } from './decide.js'; export { createClassifyExample } from './classify.js'; export { createAdapterExample } from './adapter.js'; export { createChatbotExample } from './chatbot.js'; +export { + AgentSessionDurableObject, + createDurableObjectRunStore, + type DurableObjectStateLike, + type DurableObjectStorageLike, +} from './cloudflare-durable-object.js'; export { createCustomerServiceSimExample } from './customer-service-sim.js'; export { createEmailExample } from './email.js'; export { createJokeExample } from './joke.js'; @@ -12,8 +19,15 @@ export { createMapReduceExample } from './map-reduce.js'; export { createMultiAgentNetworkExample } from './multi-agent-network.js'; export { createNewspaperExample } from './newspaper.js'; export { createPlanAndExecuteExample } from './plan-and-execute.js'; +export { createPersistenceExample, runPersistenceExample } from './persistence.js'; export { createRaffleExample } from './raffle.js'; export { createReactAgentExample } from './react-agent.js'; +export { + createReactAgentFromScratch, + type ReactAgentMessage, + type ReactAgentModelResult, + type ReactTool, +} from './react-agent-from-scratch.js'; export { createRewooExample } from './rewoo.js'; export { createReflectionExample } from './reflection.js'; export { createRiverCrossingExample } from './river-crossing.js'; diff --git a/examples/persistence.ts b/examples/persistence.ts new file mode 100644 index 0000000..b74ac48 --- /dev/null +++ b/examples/persistence.ts @@ -0,0 +1,132 @@ +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + restoreSession, + startSession, +} from '../src/index.js'; +import { + closePrompt, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const summarySchema = z.object({ + summary: z.string(), +}); + +export function createPersistenceExample( + summarize: (args: { + request: string; + approved: boolean; + }) => Promise> = async (args) => + generateExampleObject({ + schema: summarySchema, + system: 'You summarize approved requests in one concise sentence.', + prompt: [ + `Request: ${args.request}`, + `Approved: ${String(args.approved)}`, + '', + 'Write a short summary.', + ].join('\n'), + }) +) { + return createAgentMachine({ + id: 'persistence-example', + schemas: { + input: z.object({ + request: z.string(), + }), + output: z.object({ + request: z.string(), + approved: z.boolean(), + summary: z.string().nullable(), + }), + events: { + approve: z.object({}), + }, + }, + context: (input) => ({ + request: input.request, + approved: false, + summary: null as string | null, + }), + initial: 'review', + states: { + review: { + on: { + approve: { + target: 'summarizing', + context: { approved: true }, + }, + }, + }, + summarizing: { + resultSchema: summarySchema, + invoke: async ({ context }) => + summarize({ + request: context.request, + approved: context.approved, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { summary: result.summary }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + request: context.request, + approved: context.approved, + summary: context.summary, + }), + }, + }, + }); +} + +export async function runPersistenceExample( + input: { request: string }, + options: { + summarize?: (args: { + request: string; + approved: boolean; + }) => Promise>; + } = {} +) { + const machine = createPersistenceExample(options.summarize); + const store = createMemoryRunStore(); + + const liveRun = await startSession(machine, { + store, + input, + }); + + await liveRun.send({ type: 'approve' }); + + const restoredRun = await restoreSession(machine, { + sessionId: liveRun.sessionId, + store, + }); + + return { + sessionId: liveRun.sessionId, + liveSnapshot: liveRun.getSnapshot(), + restoredSnapshot: restoredRun.getSnapshot(), + }; +} + +async function main() { + try { + const request = await prompt('Request'); + const result = await runPersistenceExample({ request }); + console.log(result); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/src/prebuilt/react.ts b/examples/react-agent-from-scratch.ts similarity index 94% rename from src/prebuilt/react.ts rename to examples/react-agent-from-scratch.ts index 5e64924..bd82736 100644 --- a/src/prebuilt/react.ts +++ b/examples/react-agent-from-scratch.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -import { createAgentMachine } from '../machine.js'; -import type { StandardSchemaV1 } from '../types.js'; +import { createAgentMachine, type StandardSchemaV1 } from '../src/index.js'; const messageSchema = z.object({ role: z.enum(['system', 'user', 'assistant', 'tool']), @@ -25,6 +24,12 @@ const modelResultSchema = z.discriminatedUnion('kind', [ finalAnswerSchema, ]); +const reactOutputSchema = z.object({ + messages: z.array(messageSchema), + finalMessage: z.string().nullable(), + steps: z.number().int().min(0), +}); + export type ReactAgentMessage = z.infer; export type ReactTool = { @@ -36,7 +41,7 @@ export type ReactTool = { export type ReactAgentModelResult = z.infer; -export function createReactAgent(options: { +export function createReactAgentFromScratch(options: { prompt?: string; maxSteps?: number; tools?: ReactTool[]; @@ -63,11 +68,12 @@ export function createReactAgent(options: { } return createAgentMachine({ - id: 'prebuilt-react-agent', + id: 'react-agent-from-scratch', schemas: { input: z.object({ messages: z.array(messageSchema).optional(), }), + output: reactOutputSchema, emitted: { textPart: z.object({ delta: z.string() }), toolCall: z.object({ diff --git a/examples/react-agent.ts b/examples/react-agent.ts index b38b391..0d15e45 100644 --- a/examples/react-agent.ts +++ b/examples/react-agent.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; import { createMemoryRunStore, - createReactAgent, startSession, } from '../src/index.js'; +import { createReactAgentFromScratch } from './react-agent-from-scratch.js'; import { closePrompt, generateExampleObject, @@ -37,7 +37,7 @@ export function createReactAgentExample(options: { }>; }) => Promise>; } = {}) { - return createReactAgent({ + return createReactAgentFromScratch({ prompt: 'You are a helpful assistant.', tools: [ { diff --git a/examples/sql-agent.ts b/examples/sql-agent.ts new file mode 100644 index 0000000..14776b9 --- /dev/null +++ b/examples/sql-agent.ts @@ -0,0 +1,279 @@ +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + decide, + decideResultSchema, + startSession, + type AgentAdapter, +} from '../src/index.js'; +import { + closePrompt, + createOpenAiDecisionAdapter, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const sqlValueSchema = z.union([z.string(), z.number(), z.null()]); +const sqlRowsSchema = z.array(z.record(z.string(), sqlValueSchema)); + +const planningOptions = { + query: { + description: 'Write or revise a SQL query that should help answer the question.', + schema: z.object({ + query: z.string(), + }), + }, + answer: { + description: 'Return the final answer once the available query results are sufficient.', + schema: z.object({ + answer: z.string(), + }), + }, +} as const; + +const queryExecutionSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('success'), + query: z.string(), + rows: sqlRowsSchema, + }), + z.object({ + status: z.literal('error'), + query: z.string(), + error: z.string(), + }), +]); + +export function createSqlAgentExample( + options: { + adapter?: AgentAdapter; + executeQuery?: (args: { + question: string; + schema: string; + query: string; + queryHistory: string[]; + }) => Promise< + | { status: 'success'; rows: z.infer } + | { status: 'error'; error: string } + >; + } = {} +) { + const adapter = options.adapter ?? createOpenAiDecisionAdapter(); + const executeQuery = + options.executeQuery ?? + ((args: { + question: string; + schema: string; + query: string; + queryHistory: string[]; + }) => + generateExampleObject({ + schema: z.discriminatedUnion('status', [ + z.object({ + status: z.literal('success'), + rows: sqlRowsSchema, + }), + z.object({ + status: z.literal('error'), + error: z.string(), + }), + ]), + system: [ + 'You simulate a SQL database tool for demos.', + 'Return status="success" with concise rows when the query is plausible.', + 'Return status="error" with a short SQL/tool error when the query is invalid.', + ].join('\n'), + prompt: [ + `Question: ${args.question}`, + `Schema: ${args.schema}`, + `Query: ${args.query}`, + args.queryHistory.length + ? `Prior queries:\n${args.queryHistory.map((query, index) => `${index + 1}. ${query}`).join('\n')}` + : 'Prior queries: none', + ].join('\n'), + })); + + return createAgentMachine({ + id: 'sql-agent-example', + schemas: { + input: z.object({ + question: z.string(), + schema: z.string(), + }), + emitted: { + toolCall: z.object({ + toolName: z.literal('sqlDb'), + input: z.object({ + query: z.string(), + }), + }), + toolResult: z.object({ + toolName: z.literal('sqlDb'), + output: queryExecutionSchema, + }), + }, + output: z.object({ + question: z.string(), + schema: z.string(), + answer: z.string().nullable(), + latestRows: sqlRowsSchema.nullable(), + latestError: z.string().nullable(), + queryHistory: z.array(z.string()), + }), + }, + context: (input) => ({ + question: input.question, + schema: input.schema, + answer: null as string | null, + latestRows: null as z.infer | null, + latestError: null as string | null, + queryHistory: [] as string[], + }), + initial: 'planning', + states: { + planning: { + resultSchema: decideResultSchema(planningOptions), + invoke: async ({ context }) => + decide({ + adapter, + model: 'openai/gpt-5.4-nano', + prompt: [ + 'You are a SQL agent deciding whether to query the database again or answer.', + 'Query when you still need database evidence or when the last query failed.', + 'Answer only when the current rows are enough to respond directly.', + '', + `Question: ${context.question}`, + `Schema: ${context.schema}`, + context.queryHistory.length + ? `Previous queries:\n${context.queryHistory.map((query, index) => `${index + 1}. ${query}`).join('\n')}` + : 'Previous queries: none', + context.latestError + ? `Latest error: ${context.latestError}` + : 'Latest error: none', + context.latestRows + ? `Latest rows:\n${JSON.stringify(context.latestRows, null, 2)}` + : 'Latest rows: none', + ].join('\n'), + options: planningOptions, + }), + onDone: ({ result }) => { + if (result.choice === 'query') { + return { + target: 'querying', + input: { + query: result.data.query, + }, + }; + } + + return { + target: 'done', + context: { + answer: result.data.answer, + }, + }; + }, + }, + querying: { + inputSchema: z.object({ + query: z.string(), + }), + resultSchema: queryExecutionSchema, + invoke: async ({ context, input }, enq) => { + enq.emit({ + type: 'toolCall', + toolName: 'sqlDb', + input, + }); + + const output = await executeQuery({ + question: context.question, + schema: context.schema, + query: input.query, + queryHistory: context.queryHistory, + }); + + const resolvedOutput = + output.status === 'success' + ? { + status: 'success' as const, + query: input.query, + rows: output.rows, + } + : { + status: 'error' as const, + query: input.query, + error: output.error, + }; + + enq.emit({ + type: 'toolResult', + toolName: 'sqlDb', + output: resolvedOutput, + }); + + return resolvedOutput; + }, + onDone: ({ result, context }) => ({ + target: 'planning', + context: { + queryHistory: [ + ...context.queryHistory, + result.query, + ], + latestRows: result.status === 'success' ? result.rows : null, + latestError: result.status === 'error' ? result.error : null, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + question: context.question, + schema: context.schema, + answer: context.answer, + latestRows: context.latestRows, + latestError: context.latestError, + queryHistory: context.queryHistory, + }), + }, + }, + }); +} + +async function main() { + try { + const question = await prompt('Question'); + const schema = await prompt('Schema'); + const machine = createSqlAgentExample(); + const run = await startSession(machine, { + store: createMemoryRunStore(), + input: { question, schema }, + }); + + run.on('toolCall', (event) => { + console.log(`Calling ${event.toolName}(${event.input.query})`); + }); + run.on('toolResult', (event) => { + console.log(`${event.toolName} -> ${JSON.stringify(event.output)}`); + }); + + await new Promise((resolve, reject) => { + run.onDone((event) => { + console.log(event.output); + resolve(); + }); + run.onError((event) => { + reject(event.error); + }); + }); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/package.json b/package.json index 88cb9a5..f4af6b2 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,6 @@ "dotenv": "^16.4.5", "tsdown": "^0.21.7", "tsx": "^4.21.0", - "typescript": "^5.6.2", "vitest": "^2.1.2", "zod": "^4.3.6" }, @@ -95,7 +94,9 @@ "access": "public" }, "dependencies": { + "@statelyai/graph": "^0.11.0", "ai": "^6.0.67", + "typescript": "^5.6.2", "xstate": "^5.26.0" }, "packageManager": "pnpm@10.28.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 397027b..7e25004 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: dependencies: + '@statelyai/graph': + specifier: ^0.11.0 + version: 0.11.0(zod@4.3.6) ai: specifier: ^6.0.67 version: 6.0.67(zod@4.3.6) + typescript: + specifier: ^5.6.2 + version: 5.9.3 xstate: specifier: ^5.26.0 version: 5.26.0 @@ -36,9 +42,6 @@ importers: tsx: specifier: ^4.21.0 version: 4.21.0 - typescript: - specifier: ^5.6.2 - version: 5.9.3 vitest: specifier: ^2.1.2 version: 2.1.9(@types/node@20.19.30) @@ -754,6 +757,29 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, tarball: https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz} + '@statelyai/graph@0.11.0': + resolution: {integrity: sha512-qm1AmeXTQu6wrA5o4bXIlic4BDWLJCnNisHoqDan7Qa/UWi3yGg10W8xobItql+qvnVcoghJN1ZmR3cUFeHV7A==, tarball: https://registry.npmjs.org/@statelyai/graph/-/graph-0.11.0.tgz} + peerDependencies: + cytoscape: ^3.0.0 + d3-force: ^3.0.0 + dotparser: ^1.0.0 + elkjs: ^0.9.0 || ^0.10.0 || ^0.11.0 + fast-xml-parser: ^5.0.0 + zod: ^4.0.0 + peerDependenciesMeta: + cytoscape: + optional: true + d3-force: + optional: true + dotparser: + optional: true + elkjs: + optional: true + fast-xml-parser: + optional: true + zod: + optional: true + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==, tarball: https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz} @@ -2055,6 +2081,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@statelyai/graph@0.11.0(zod@4.3.6)': + optionalDependencies: + zod: 4.3.6 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 diff --git a/src/examples.test.ts b/src/examples.test.ts index 532dc14..8481d1d 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -5,6 +5,7 @@ import { resolve } from 'node:path'; import { createChatbotExample, + createDurableObjectRunStore, createAdapterExample, createBranchingExample, createClassifyExample, @@ -17,6 +18,7 @@ import { createMapReduceExample, createMultiAgentNetworkExample, createNewspaperExample, + runPersistenceExample, createPlanAndExecuteExample, createRaffleExample, createReactAgentExample, @@ -24,6 +26,7 @@ import { createReflectionExample, createRiverCrossingExample, createSimpleExample, + createSqlAgentExample, createSubflowExample, createSupervisorExample, createToolCallingExample, @@ -34,12 +37,14 @@ describe('curated examples', () => { test('ships the canonical examples directory', () => { const examplesDir = resolve(process.cwd(), 'examples'); expect(existsSync(resolve(examplesDir, 'simple.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'sql-agent.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'hitl.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'decide.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'classify.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'adapter.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'branching.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'chatbot.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'cloudflare-durable-object.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'customer-service-sim.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'email.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'joke.ts'))).toBe(true); @@ -47,9 +52,11 @@ describe('curated examples', () => { expect(existsSync(resolve(examplesDir, 'map-reduce.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'multi-agent-network.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'newspaper.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'persistence.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'plan-and-execute.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'raffle.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'react-agent.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'react-agent-from-scratch.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'rewoo.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'reflection.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'river-crossing.ts'))).toBe(true); @@ -73,6 +80,75 @@ describe('curated examples', () => { } }); + test('persistence example restores a durable session to the same final snapshot', async () => { + const result = await runPersistenceExample( + { request: 'Approve the annual budget summary.' }, + { + summarize: async ({ request, approved }) => ({ + summary: `${request} :: approved=${String(approved)}`, + }), + } + ); + + expect(result.liveSnapshot).toEqual(result.restoredSnapshot); + expect(result.liveSnapshot).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { + request: 'Approve the annual budget summary.', + approved: true, + summary: 'Approve the annual budget summary. :: approved=true', + }, + }) + ); + }); + + test('cloudflare durable object example store persists journal and snapshots', async () => { + const storage = new Map(); + const store = createDurableObjectRunStore({ + async get(key) { + return storage.get(key) as never; + }, + async put(key, value) { + storage.set(key, value); + }, + }); + + await store.append('session-1', { + type: 'xstate.init', + at: 1, + }); + await store.append('session-1', { + type: 'approve', + at: 2, + }); + await store.saveSnapshot({ + sessionId: 'session-1', + afterSequence: 2, + snapshot: { + value: 'done', + context: {}, + status: 'done', + createdAt: 2, + sessionId: 'session-1', + input: {}, + }, + createdAt: 2, + }); + + await expect(store.loadEvents('session-1')).resolves.toEqual([ + expect.objectContaining({ sequence: 1, type: 'xstate.init' }), + expect.objectContaining({ sequence: 2, type: 'approve' }), + ]); + await expect(store.loadLatestSnapshot('session-1')).resolves.toEqual( + expect.objectContaining({ + sessionId: 'session-1', + afterSequence: 2, + }) + ); + }); + test('hitl example exposes typed pending events', async () => { const machine = createHitlExample(); const result = await machine.execute( @@ -571,6 +647,78 @@ describe('curated examples', () => { ); }); + test('sql-agent example retries after a bad query and then answers from rows', async () => { + let decisions = 0; + + const machine = createSqlAgentExample({ + adapter: { + decide: async () => { + decisions += 1; + + if (decisions === 1) { + return { + choice: 'query', + data: { + query: 'SELECT total FROM invoices WHERE customer = "Acme"', + }, + }; + } + + if (decisions === 2) { + return { + choice: 'query', + data: { + query: "SELECT customer, total FROM invoices WHERE customer = 'Acme'", + }, + }; + } + + return { + choice: 'answer', + data: { + answer: 'Acme has one invoice total of 42.', + }, + }; + }, + }, + executeQuery: async ({ query }) => { + if (query.includes('"Acme"')) { + return { + status: 'error' as const, + error: 'SQL syntax error near double quotes.', + }; + } + + return { + status: 'success' as const, + rows: [{ customer: 'Acme', total: 42 }], + }; + }, + }); + + const result = await machine.execute( + machine.getInitialState({ + question: 'What is Acme owed?', + schema: 'invoices(customer text, total integer)', + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + question: 'What is Acme owed?', + schema: 'invoices(customer text, total integer)', + answer: 'Acme has one invoice total of 42.', + latestRows: [{ customer: 'Acme', total: 42 }], + latestError: null, + queryHistory: [ + 'SELECT total FROM invoices WHERE customer = "Acme"', + "SELECT customer, total FROM invoices WHERE customer = 'Acme'", + ], + }); + } + }); + test('react agent example loops through a tool and returns a final answer', async () => { const { createMemoryRunStore, startSession } = await import('./index.js'); const agent = createReactAgentExample({ diff --git a/src/graph/index.test.ts b/src/graph/index.test.ts new file mode 100644 index 0000000..117b84c --- /dev/null +++ b/src/graph/index.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { createAgentMachine } from '../index.js'; +import { toGraph, toMermaid } from './index.js'; + +test('exports finite states and transition edges as Stately graph JSON', () => { + const machine = createAgentMachine({ + id: 'graph-export', + schemas: { + events: { + submit: z.object({ + type: z.literal('submit'), + count: z.number(), + }), + }, + }, + context: () => ({ + total: 0, + }), + initial: 'idle', + states: { + idle: { + on: { + submit: ({ event }) => { + if (event.count > 0) { + return { + target: 'working', + context: { total: event.count }, + input: { index: event.count }, + }; + } + + return { + target: 'done', + }; + }, + }, + }, + working: { + inputSchema: z.object({ + index: z.number(), + }), + resultSchema: z.object({ + ok: z.boolean(), + }), + invoke: async () => ({ ok: true }), + onDone: () => ({ + target: 'done', + }), + }, + done: { + type: 'final', + output: ({ context }) => context, + }, + }, + }); + + expect(toGraph(machine)).toEqual({ + id: 'graph-export', + type: 'directed', + initialNodeId: 'idle', + data: undefined, + nodes: [ + { type: 'node', id: 'idle', label: 'idle', data: { type: 'state' } }, + { type: 'node', id: 'working', label: 'working', data: { type: 'state' } }, + { type: 'node', id: 'done', label: 'done', data: { type: 'final' } }, + ], + edges: [ + { + type: 'edge', + id: 'idle:submit:0', + sourceId: 'idle', + targetId: 'working', + label: 'submit [event.count > 0]', + data: { + event: 'submit', + guard: { type: 'event.count > 0' }, + actions: { + context: true, + input: true, + }, + }, + }, + { + type: 'edge', + id: 'idle:submit:1', + sourceId: 'idle', + targetId: 'done', + label: 'submit', + data: { + event: 'submit', + }, + }, + { + type: 'edge', + id: 'working:done:2', + sourceId: 'working', + targetId: 'done', + label: 'done', + data: { + event: 'done', + }, + }, + ], + }); +}); + +test('exports a mermaid state diagram from the Stately graph data', () => { + const machine = createAgentMachine({ + id: 'mermaid-export', + context: () => ({}), + initial: 'idle', + states: { + idle: { + on: { + finish: { target: 'done' }, + }, + }, + done: { + type: 'final', + }, + }, + }); + + expect(toMermaid(machine)).toContain('idle --> done: finish'); +}); + +test('infers guards from conditional-expression transition branches', () => { + const machine = createAgentMachine({ + id: 'conditional-export', + schemas: { + events: { + choose: z.object({ + type: z.literal('choose'), + ok: z.boolean(), + }), + }, + }, + context: () => ({}), + initial: 'idle', + states: { + idle: { + on: { + choose: ({ event }) => + event.ok + ? { target: 'accepted' } + : { target: 'rejected' }, + }, + }, + accepted: { + type: 'final', + }, + rejected: { + type: 'final', + }, + }, + }); + + expect(toGraph(machine).edges).toEqual([ + expect.objectContaining({ + sourceId: 'idle', + targetId: 'accepted', + data: expect.objectContaining({ + guard: { type: 'event.ok' }, + }), + }), + expect.objectContaining({ + sourceId: 'idle', + targetId: 'rejected', + data: expect.objectContaining({ + guard: { type: '!(event.ok)' }, + }), + }), + ]); +}); diff --git a/src/graph/index.ts b/src/graph/index.ts index 7d09dfd..78e3dba 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -1,33 +1,447 @@ -import type { AgentMachine } from '../types.js'; +import { + createGraph, + type EdgeConfig, + type Graph as StatelyGraph, + type GraphEdge as StatelyGraphEdge, + type GraphNode as StatelyGraphNode, + type NodeConfig, +} from '@statelyai/graph'; +import ts from 'typescript'; +import type { + AgentMachine, + MachineConfig, + StateConfig, + TransitionResult, +} from '../types.js'; -export interface GraphNode { - id: string; +export interface AgentGraphNodeData { type: 'state' | 'choice' | 'final'; } -export interface GraphEdge { - source: string; - target: string; - label?: string; +export interface AgentGraphEdgeData { + event?: string; + guard?: { + type: string; + }; + actions?: { + context?: boolean; + input?: boolean; + }; } -export interface Graph { - nodes: GraphNode[]; - edges: GraphEdge[]; -} +export interface AgentGraph + extends StatelyGraph {} +export interface AgentGraphNode + extends StatelyGraphNode {} +export interface AgentGraphEdge + extends StatelyGraphEdge {} + +type InternalMachine = AgentMachine & { + __config?: MachineConfig; +}; + +type EdgeCandidate = { + target: string; + guard?: string; + hasContext?: boolean; + hasInput?: boolean; +}; /** - * Convert an agent machine to a graph representation. - * TODO: implement AST analysis for edge extraction + * Convert an agent machine to a Stately graph-compatible plain JSON object. + * + * Finite states come directly from the authored `states` object. Edges are + * inferred from static transition objects and transition handler ASTs. */ -export function toGraph(_machine: AgentMachine): Graph { - throw new Error('toGraph is not yet implemented'); +export function toGraph(machine: AgentMachine): AgentGraph { + const config = (machine as InternalMachine).__config; + if (!config) { + throw new Error('Machine config metadata is unavailable for graph export'); + } + + const nodes: Array> = Object.entries( + config.states + ).map(([id, state]) => ({ + id, + label: id, + data: { + type: getNodeType(state as StateConfig), + }, + })); + + const edges: Array> = []; + for (const [sourceId, state] of Object.entries(config.states)) { + const stateConfig = state as StateConfig; + + if (stateConfig.onDone) { + edges.push( + ...getTransitionEdges({ + sourceId, + event: 'done', + transition: stateConfig.onDone, + ordinalOffset: edges.length, + }) + ); + } + + if (!stateConfig.on) { + continue; + } + + for (const [event, transition] of Object.entries(stateConfig.on)) { + edges.push( + ...getTransitionEdges({ + sourceId, + event, + transition, + ordinalOffset: edges.length, + }) + ); + } + } + + return createGraph({ + id: machine.id, + initialNodeId: + typeof config.initial === 'string' ? config.initial : undefined, + nodes, + edges, + }); } -/** - * Convert an agent machine to a Mermaid stateDiagram-v2 string. - * TODO: implement - */ -export function toMermaid(_machine: AgentMachine): string { - throw new Error('toMermaid is not yet implemented'); +export function toMermaid(machine: AgentMachine): string { + const graph = toGraph(machine); + const lines = ['stateDiagram-v2']; + + if (graph.initialNodeId) { + lines.push(` [*] --> ${graph.initialNodeId}`); + } + + for (const node of graph.nodes) { + if (node.data.type === 'final') { + lines.push(` ${node.id} --> [*]`); + } + } + + for (const edge of graph.edges) { + lines.push( + ` ${edge.sourceId} --> ${edge.targetId}${ + edge.label ? `: ${edge.label}` : '' + }` + ); + } + + return lines.join('\n'); +} + +function getNodeType(state: StateConfig): AgentGraphNodeData['type'] { + if (state.type === 'final') { + return 'final'; + } + + if (state.type === 'choice') { + return 'choice'; + } + + return 'state'; +} + +function getTransitionEdges(args: { + sourceId: string; + event: string; + transition: unknown; + ordinalOffset: number; +}): Array> { + const candidates = + typeof args.transition === 'function' + ? analyzeTransitionFunction(args.transition) + : analyzeTransitionObject(args.transition); + + return candidates.map((candidate, index) => ({ + id: `${args.sourceId}:${args.event}:${args.ordinalOffset + index}`, + sourceId: args.sourceId, + targetId: candidate.target, + label: getEdgeLabel(args.event, candidate.guard), + data: { + event: args.event, + ...(candidate.guard + ? { + guard: { + type: candidate.guard, + }, + } + : {}), + ...((candidate.hasContext || candidate.hasInput) + ? { + actions: { + ...(candidate.hasContext ? { context: true } : {}), + ...(candidate.hasInput ? { input: true } : {}), + }, + } + : {}), + }, + })); +} + +function analyzeTransitionObject(transition: unknown): EdgeCandidate[] { + const target = + transition && typeof transition === 'object' + ? (transition as TransitionResult).target + : undefined; + + if ( + transition + && typeof transition === 'object' + && 'target' in transition + && typeof target === 'string' + ) { + return [ + { + target, + hasContext: 'context' in transition, + hasInput: 'input' in transition, + }, + ]; + } + + return []; +} + +function analyzeTransitionFunction(fn: Function): EdgeCandidate[] { + const source = fn.toString(); + const file = ts.createSourceFile( + 'transition.ts', + `const __transition = ${source};`, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const transitionFunction = findTransitionFunction(file); + + if (!transitionFunction) { + return []; + } + + const candidates: EdgeCandidate[] = []; + const ancestors: ts.Node[] = []; + + function visit(node: ts.Node) { + if (node !== transitionFunction && isFunctionLike(node)) { + return; + } + + if ( + ts.isArrowFunction(node) + && !ts.isBlock(node.body) + && ts.isExpression(node.body) + ) { + candidates.push( + ...analyzeTransitionExpression( + node.body, + findGuardForReturnLike(node, ancestors, file), + file + ) + ); + } + + if (ts.isReturnStatement(node) && node.expression) { + candidates.push( + ...analyzeTransitionExpression( + node.expression, + findGuardForReturnLike(node, ancestors, file), + file + ) + ); + } + + ancestors.push(node); + ts.forEachChild(node, visit); + ancestors.pop(); + } + + visit(transitionFunction); + + return candidates; +} + +function findTransitionFunction(file: ts.SourceFile): ts.FunctionLike | undefined { + let transitionFunction: ts.FunctionLike | undefined; + + function visit(node: ts.Node) { + if ( + ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) + && node.name.text === '__transition' + && node.initializer + && isFunctionLike(node.initializer) + ) { + transitionFunction = node.initializer; + return; + } + + if (!transitionFunction) { + ts.forEachChild(node, visit); + } + } + + visit(file); + return transitionFunction; +} + +function isFunctionLike(node: ts.Node): node is ts.FunctionLike { + return ( + ts.isArrowFunction(node) + || ts.isFunctionExpression(node) + || ts.isFunctionDeclaration(node) + || ts.isMethodDeclaration(node) + ); +} + +function analyzeTransitionExpression( + expression: ts.Expression, + guard: string | undefined, + file: ts.SourceFile +): EdgeCandidate[] { + let current = expression; + + while (ts.isParenthesizedExpression(current)) { + current = current.expression; + } + + if (ts.isConditionalExpression(current)) { + const condition = current.condition.getText(file); + + return [ + ...analyzeTransitionExpression( + current.whenTrue, + combineGuards(guard, condition), + file + ), + ...analyzeTransitionExpression( + current.whenFalse, + combineGuards(guard, `!(${condition})`), + file + ), + ]; + } + + const object = unwrapParenthesizedObject(current); + const target = object ? getStringProperty(object, 'target') : undefined; + if (!target) { + return []; + } + + return [ + { + target, + guard, + hasContext: object ? hasProperty(object, 'context') : false, + hasInput: object ? hasProperty(object, 'input') : false, + }, + ]; +} + +function unwrapParenthesizedObject( + expression: ts.Expression +): ts.ObjectLiteralExpression | undefined { + let current = expression; + + while (ts.isParenthesizedExpression(current)) { + current = current.expression; + } + + return ts.isObjectLiteralExpression(current) ? current : undefined; +} + +function getStringProperty( + object: ts.ObjectLiteralExpression, + name: string +): string | undefined { + const property = object.properties.find((candidate) => { + return ( + ts.isPropertyAssignment(candidate) + && ts.isIdentifier(candidate.name) + && candidate.name.text === name + ); + }); + + if (!property || !ts.isPropertyAssignment(property)) { + return undefined; + } + + const initializer = property.initializer; + return ts.isStringLiteralLike(initializer) ? initializer.text : undefined; +} + +function hasProperty(object: ts.ObjectLiteralExpression, name: string): boolean { + return object.properties.some((candidate) => { + return ( + ts.isPropertyAssignment(candidate) + && ts.isIdentifier(candidate.name) + && candidate.name.text === name + ); + }); +} + +function findGuardForReturnLike( + returnNode: ts.Node, + ancestors: ts.Node[], + file: ts.SourceFile +): string | undefined { + for (let index = ancestors.length - 1; index >= 0; index -= 1) { + const ancestor = ancestors[index]; + if (!ancestor || !ts.isIfStatement(ancestor)) { + continue; + } + + if (containsNode(ancestor.thenStatement, returnNode)) { + return ancestor.expression.getText(file); + } + + if (ancestor.elseStatement && containsNode(ancestor.elseStatement, returnNode)) { + return `!(${ancestor.expression.getText(file)})`; + } + } + + return undefined; +} + +function containsNode(parent: ts.Node, child: ts.Node): boolean { + if (parent === child) { + return true; + } + + let found = false; + function visit(node: ts.Node) { + if (node === child) { + found = true; + return; + } + + if (!found) { + ts.forEachChild(node, visit); + } + } + + visit(parent); + return found; +} + +function getEdgeLabel(event: string, guard: string | undefined): string { + if (!guard) { + return event; + } + + return `${event} [${guard}]`; +} + +function combineGuards( + outer: string | undefined, + inner: string +): string { + if (!outer) { + return inner; + } + + return `(${outer}) && (${inner})`; } diff --git a/src/index.ts b/src/index.ts index e56ee3d..12162ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,14 +3,6 @@ export { createAgentMachine } from './machine.js'; export { decide, decideResultSchema, requireAdapter } from './decide.js'; export { classify, classifyResultSchema } from './classify.js'; -// AI primitives -export { createReactAgent } from './prebuilt/react.js'; -export type { - ReactAgentMessage, - ReactAgentModelResult, - ReactTool, -} from './prebuilt/react.js'; - // Adapter export { createAdapter } from './adapter.js'; export { createMemoryRunStore } from './runtime/memory-store.js'; diff --git a/src/langgraph-equivalents/prebuilt-react.test.ts b/src/langgraph-equivalents/prebuilt-react.test.ts index d08435a..de52d4a 100644 --- a/src/langgraph-equivalents/prebuilt-react.test.ts +++ b/src/langgraph-equivalents/prebuilt-react.test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; import { createMemoryRunStore, - createReactAgent, startSession, } from '../index.js'; +import { createReactAgentFromScratch } from '../../examples/react-agent-from-scratch.js'; function once( subscribe: (handler: (event: T) => void) => () => void @@ -17,8 +17,8 @@ function once( }); } -test('prebuilt react agent loops through a tool call and returns a final answer', async () => { - const agent = createReactAgent({ +test('react agent from scratch loops through a tool call and returns a final answer', async () => { + const agent = createReactAgentFromScratch({ prompt: 'You are helpful.', tools: [ { diff --git a/src/langgraph-equivalents/sql-agent.test.ts b/src/langgraph-equivalents/sql-agent.test.ts new file mode 100644 index 0000000..9100120 --- /dev/null +++ b/src/langgraph-equivalents/sql-agent.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from 'vitest'; +import { createMemoryRunStore, startSession } from '../index.js'; +import { createSqlAgentExample } from '../../examples/sql-agent.js'; + +function once( + subscribe: (handler: (event: T) => void) => () => void +) { + return new Promise((resolve) => { + let off = () => {}; + off = subscribe((event) => { + off(); + resolve(event); + }); + }); +} + +test('sql-agent workflow retries after a bad query and answers once rows are available', async () => { + let decisions = 0; + + const machine = createSqlAgentExample({ + adapter: { + decide: async () => { + decisions += 1; + + if (decisions === 1) { + return { + choice: 'query', + data: { + query: 'SELECT total FROM invoices WHERE customer = "Acme"', + }, + }; + } + + if (decisions === 2) { + return { + choice: 'query', + data: { + query: 'SELECT customer, total FROM invoices WHERE customer = \'Acme\'', + }, + }; + } + + return { + choice: 'answer', + data: { + answer: 'Acme has one invoice total of 42.', + }, + }; + }, + }, + executeQuery: async ({ query }) => { + if (query.includes('"Acme"')) { + return { + status: 'error' as const, + error: 'SQL syntax error near double quotes.', + }; + } + + return { + status: 'success' as const, + rows: [{ customer: 'Acme', total: 42 }], + }; + }, + }); + + const run = await startSession(machine, { + store: createMemoryRunStore(), + input: { + question: 'What is Acme owed?', + schema: 'invoices(customer text, total integer)', + }, + }); + const events: string[] = []; + + run.on('toolCall', (event) => { + events.push(`call:${event.input.query}`); + }); + run.on('toolResult', (event) => { + events.push(`result:${event.output.status}`); + }); + + await once(run.onDone.bind(run)); + + expect(events).toEqual([ + 'call:SELECT total FROM invoices WHERE customer = "Acme"', + 'result:error', + "call:SELECT customer, total FROM invoices WHERE customer = 'Acme'", + 'result:success', + ]); + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { + question: 'What is Acme owed?', + schema: 'invoices(customer text, total integer)', + answer: 'Acme has one invoice total of 42.', + latestRows: [{ customer: 'Acme', total: 42 }], + latestError: null, + queryHistory: [ + 'SELECT total FROM invoices WHERE customer = "Acme"', + "SELECT customer, total FROM invoices WHERE customer = 'Acme'", + ], + }, + }) + ); +}); diff --git a/src/machine.ts b/src/machine.ts index fa2942b..5371bc0 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -653,6 +653,7 @@ export function createAgentMachine( return { id: cfg.id, + __config: cfg, getInitialState, resolveState, transition, diff --git a/src/types.ts b/src/types.ts index ec0b612..b6809f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,6 +188,8 @@ export interface AgentMachine< TEmitted extends Record = {}, > { readonly id: string; + /** @internal */ + readonly __config?: unknown; getInitialState( ...args: unknown extends TInput ? [input?: TInput] : [input: TInput] From e513614bd5748c0d1826bfe3f82dfdce5010109d Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Mon, 20 Apr 2026 13:16:23 -0400 Subject: [PATCH 19/50] feat: add agent machine conversion CLI --- package.json | 1 + scripts/agent-convert.ts | 212 +++++++++++++++++++++++++++++++++++++++ src/graph/index.test.ts | 5 +- src/graph/index.ts | 91 +++++++++++++---- src/xstate/index.test.ts | 109 ++++++++++++++++++++ src/xstate/index.ts | 184 +++++++++++++++++++++++++++++++-- 6 files changed, 573 insertions(+), 29 deletions(-) create mode 100644 scripts/agent-convert.ts create mode 100644 src/xstate/index.test.ts diff --git a/package.json b/package.json index f4af6b2..990cbd6 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "dist" ], "scripts": { + "agent:convert": "tsx scripts/agent-convert.ts", "build": "tsdown", "lint": "tsc --noEmit", "test": "vitest", diff --git a/scripts/agent-convert.ts b/scripts/agent-convert.ts new file mode 100644 index 0000000..3849d4f --- /dev/null +++ b/scripts/agent-convert.ts @@ -0,0 +1,212 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { toMermaid } from '../src/graph/index.js'; +import type { AgentMachine } from '../src/index.js'; +import { toXStateMachine } from '../src/xstate/index.js'; + +type Format = 'mermaid' | 'xstate'; + +interface CliOptions { + file?: string; + format: Format; + exportName?: string; + factoryName?: string; + outFile?: string; + help: boolean; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + + if (options.help || !options.file) { + printHelp(); + process.exit(options.help ? 0 : 1); + } + + const machine = await loadMachine(options); + const output = + options.format === 'mermaid' + ? toMermaid(machine) + : `${JSON.stringify(toXStateMachine(machine), null, 2)}\n`; + + if (options.outFile) { + await writeFile(resolve(options.outFile), output); + return; + } + + process.stdout.write(output.endsWith('\n') ? output : `${output}\n`); +} + +function parseArgs(args: string[]): CliOptions { + const options: CliOptions = { + format: 'mermaid', + help: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + + if (arg === '--help' || arg === '-h') { + options.help = true; + continue; + } + + if (arg === '--format' || arg === '-f') { + options.format = parseFormat(requiredValue(args, index, arg)); + index += 1; + continue; + } + + if (arg.startsWith('--format=')) { + options.format = parseFormat(arg.slice('--format='.length)); + continue; + } + + if (arg === '--export' || arg === '-e') { + options.exportName = requiredValue(args, index, arg); + index += 1; + continue; + } + + if (arg.startsWith('--export=')) { + options.exportName = arg.slice('--export='.length); + continue; + } + + if (arg === '--factory') { + options.factoryName = requiredValue(args, index, arg); + index += 1; + continue; + } + + if (arg.startsWith('--factory=')) { + options.factoryName = arg.slice('--factory='.length); + continue; + } + + if (arg === '--out' || arg === '-o') { + options.outFile = requiredValue(args, index, arg); + index += 1; + continue; + } + + if (arg.startsWith('--out=')) { + options.outFile = arg.slice('--out='.length); + continue; + } + + if (arg.startsWith('-')) { + throw new Error(`Unknown option: ${arg}`); + } + + if (options.file) { + throw new Error(`Unexpected positional argument: ${arg}`); + } + + options.file = arg; + } + + return options; +} + +function parseFormat(value: string): Format { + if (value === 'mermaid' || value === 'xstate') { + return value; + } + + throw new Error(`Unsupported format '${value}'. Use 'mermaid' or 'xstate'.`); +} + +function requiredValue(args: string[], index: number, option: string): string { + const value = args[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`Missing value for ${option}`); + } + + return value; +} + +async function loadMachine(options: CliOptions): Promise { + const fileUrl = pathToFileURL(resolve(options.file!)).href; + const mod = await import(fileUrl) as Record; + + if (options.factoryName) { + const factory = mod[options.factoryName]; + if (typeof factory !== 'function') { + throw new Error(`Export '${options.factoryName}' is not a function.`); + } + + const machine = await factory(); + return assertAgentMachine(machine, `factory '${options.factoryName}'`); + } + + if (options.exportName) { + return assertAgentMachine( + mod[options.exportName], + `export '${options.exportName}'` + ); + } + + for (const candidate of [mod.default, mod.machine]) { + if (isAgentMachine(candidate)) { + return candidate; + } + } + + const namedMachines = Object.entries(mod).filter(([, value]) => + isAgentMachine(value) + ); + if (namedMachines.length === 1) { + return namedMachines[0]![1] as AgentMachine; + } + + throw new Error( + [ + 'Could not find an agent machine export.', + 'Export a machine as default or named `machine`, or pass `--export `.', + 'For zero-arg factory exports, pass `--factory `.', + ].join(' ') + ); +} + +function assertAgentMachine(value: unknown, label: string): AgentMachine { + if (!isAgentMachine(value)) { + throw new Error(`${label} did not return an agent machine.`); + } + + return value; +} + +function isAgentMachine(value: unknown): value is AgentMachine { + return ( + !!value + && typeof value === 'object' + && typeof (value as AgentMachine).id === 'string' + && typeof (value as AgentMachine).getInitialState === 'function' + && typeof (value as AgentMachine).transition === 'function' + && typeof (value as AgentMachine).execute === 'function' + ); +} + +function printHelp() { + process.stdout.write(`Usage: + pnpm agent:convert [--format mermaid|xstate] + +Options: + -f, --format Output format. Defaults to mermaid. + -e, --export Named export containing an agent machine. + --factory Named zero-arg factory that returns an agent machine. + -o, --out Write output to a file instead of stdout. + -h, --help Show this help. + +Examples: + pnpm agent:convert ./examples/simple.ts --factory createSimpleExample + pnpm agent:convert ./examples/simple.ts --factory createSimpleExample --format xstate +`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/src/graph/index.test.ts b/src/graph/index.test.ts index 117b84c..b8e7444 100644 --- a/src/graph/index.test.ts +++ b/src/graph/index.test.ts @@ -122,7 +122,10 @@ test('exports a mermaid state diagram from the Stately graph data', () => { }, }); - expect(toMermaid(machine)).toContain('idle --> done: finish'); + expect(toMermaid(machine)).toBe(`stateDiagram-v2 + [*] --> idle + idle --> done : finish + done --> [*]`); }); test('infers guards from conditional-expression transition branches', () => { diff --git a/src/graph/index.ts b/src/graph/index.ts index 78e3dba..8a78aba 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -6,6 +6,13 @@ import { type GraphNode as StatelyGraphNode, type NodeConfig, } from '@statelyai/graph'; +import { + toMermaidState, + type MermaidStateGraph, + type StateEdgeData, + type StateGraphData, + type StateNodeData, +} from '@statelyai/graph/mermaid'; import ts from 'typescript'; import type { AgentMachine, @@ -110,28 +117,7 @@ export function toGraph(machine: AgentMachine): AgentGraph { } export function toMermaid(machine: AgentMachine): string { - const graph = toGraph(machine); - const lines = ['stateDiagram-v2']; - - if (graph.initialNodeId) { - lines.push(` [*] --> ${graph.initialNodeId}`); - } - - for (const node of graph.nodes) { - if (node.data.type === 'final') { - lines.push(` ${node.id} --> [*]`); - } - } - - for (const edge of graph.edges) { - lines.push( - ` ${edge.sourceId} --> ${edge.targetId}${ - edge.label ? `: ${edge.label}` : '' - }` - ); - } - - return lines.join('\n'); + return toMermaidState(toMermaidStateGraph(toGraph(machine))); } function getNodeType(state: StateConfig): AgentGraphNodeData['type'] { @@ -146,6 +132,67 @@ function getNodeType(state: StateConfig): AgentGraphNodeData['type'] { return 'state'; } +function toMermaidStateGraph(graph: AgentGraph): MermaidStateGraph { + const nodes: Array> = graph.nodes.map((node) => ({ + id: node.id, + label: node.label, + data: { + ...(node.data.type === 'choice' ? { stateType: 'choice' as const } : {}), + }, + })); + + const edges: Array> = graph.edges.map((edge) => ({ + id: edge.id, + sourceId: edge.sourceId, + targetId: edge.targetId, + label: edge.label ?? undefined, + data: {}, + })); + + if (graph.initialNodeId) { + const startId = `${graph.id}.__start`; + nodes.push({ + id: startId, + data: { isStart: true }, + }); + edges.unshift({ + id: `${startId}:initial`, + sourceId: startId, + targetId: graph.initialNodeId, + data: {}, + }); + } + + for (const node of graph.nodes) { + if (node.data.type !== 'final') { + continue; + } + + const endId = `${node.id}.__end`; + nodes.push({ + id: endId, + data: { isEnd: true }, + }); + edges.push({ + id: `${node.id}:final`, + sourceId: node.id, + targetId: endId, + data: {}, + }); + } + + return createGraph({ + id: graph.id, + type: graph.type, + initialNodeId: graph.initialNodeId ?? undefined, + data: { + diagramType: 'stateDiagram', + }, + nodes, + edges, + }); +} + function getTransitionEdges(args: { sourceId: string; event: string; diff --git a/src/xstate/index.test.ts b/src/xstate/index.test.ts new file mode 100644 index 0000000..8e7fe97 --- /dev/null +++ b/src/xstate/index.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from 'vitest'; +import { z } from 'zod'; +import { createAgentMachine } from '../index.js'; +import { toXStateMachine } from './index.js'; + +test('exports a serializable XState config for visualization', () => { + const machine = createAgentMachine({ + id: 'xstate-export', + schemas: { + events: { + submit: z.object({ + type: z.literal('submit'), + count: z.number(), + }), + }, + }, + context: () => ({ total: 0 }), + initial: 'idle', + states: { + idle: { + on: { + submit: ({ event }) => { + if (event.count > 0) { + return { + target: 'working', + context: { total: event.count }, + input: { index: event.count }, + }; + } + + return { target: 'done' }; + }, + }, + }, + working: { + inputSchema: z.object({ + index: z.number(), + }), + resultSchema: z.object({ + ok: z.boolean(), + }), + invoke: async () => ({ ok: true }), + onDone: () => ({ + target: 'done', + }), + }, + done: { + type: 'final', + }, + }, + }); + + expect(toXStateMachine(machine)).toEqual({ + id: 'xstate-export', + initial: 'idle', + states: { + idle: { + on: { + submit: [ + { + target: 'working', + guard: { type: 'event.count > 0' }, + actions: ['assignContext', 'assignInput'], + meta: { + agent: { + event: 'submit', + updates: { + context: true, + input: true, + }, + }, + }, + }, + { + target: 'done', + meta: { + agent: { + event: 'submit', + }, + }, + }, + ], + }, + }, + working: { + invoke: { + id: 'invoke.working', + src: 'invoke.working', + onDone: { + target: 'done', + meta: { + agent: { + event: 'done', + }, + }, + }, + }, + meta: { + agent: { + invoke: true, + }, + }, + }, + done: { + type: 'final', + }, + }, + }); +}); diff --git a/src/xstate/index.ts b/src/xstate/index.ts index 0bd2c66..6c112c2 100644 --- a/src/xstate/index.ts +++ b/src/xstate/index.ts @@ -1,10 +1,182 @@ -import type { AgentMachine } from '../types.js'; +import { toGraph, type AgentGraph, type AgentGraphEdge } from '../graph/index.js'; +import type { AgentMachine, MachineConfig, StateConfig } from '../types.js'; + +export interface XStateMachineConfig { + id: string; + initial?: string; + states: Record; +} + +export interface XStateStateConfig { + type?: 'final'; + on?: Record; + invoke?: { + id: string; + src: string; + onDone?: XStateTransitionConfig | XStateTransitionConfig[]; + }; + onDone?: XStateTransitionConfig | XStateTransitionConfig[]; + meta?: { + agent?: { + type?: 'choice'; + invoke?: boolean; + }; + }; +} + +export interface XStateTransitionConfig { + target: string; + guard?: { + type: string; + }; + actions?: string[]; + meta?: { + agent?: { + event?: string; + updates?: { + context?: boolean; + input?: boolean; + }; + }; + }; +} + +type InternalMachine = AgentMachine & { + __config?: MachineConfig; +}; /** - * Convert an agent machine to an XState machine definition - * for visualization in the Stately Editor. - * TODO: implement + * Convert an agent machine to a serializable XState machine config for + * visualization. Runtime behavior is still driven by the agent machine. */ -export function toXStateMachine(_machine: AgentMachine): unknown { - throw new Error('toXStateMachine is not yet implemented'); +export function toXStateMachine(machine: AgentMachine): XStateMachineConfig { + const config = (machine as InternalMachine).__config; + if (!config) { + throw new Error('Machine config metadata is unavailable for XState export'); + } + + const graph = toGraph(machine); + const states: Record = {}; + + for (const [stateId, state] of Object.entries(config.states)) { + const stateConfig = state as StateConfig; + const xstateState: XStateStateConfig = {}; + + if (stateConfig.type === 'final') { + xstateState.type = 'final'; + } + + const meta: NonNullable['agent'] = {}; + if (stateConfig.type === 'choice') { + meta.type = 'choice'; + } + + if (stateConfig.invoke) { + meta.invoke = true; + xstateState.invoke = { + id: `invoke.${stateId}`, + src: `invoke.${stateId}`, + }; + } + + const regularEdges = graph.edges.filter((edge) => + edge.sourceId === stateId + && edge.data.event !== 'done' + ); + + for (const [event, edges] of groupEdgesByEvent(regularEdges)) { + const formatted = formatTransitions(edges); + if (!formatted) { + continue; + } + + xstateState.on ??= {}; + xstateState.on[event] = formatted; + } + + if (stateConfig.onDone) { + const doneEdges = graph.edges.filter((edge) => + edge.sourceId === stateId + && edge.data.event === 'done' + ); + + const formattedDone = formatTransitions(doneEdges); + if (formattedDone) { + if (xstateState.invoke) { + xstateState.invoke.onDone = formattedDone; + } else { + xstateState.onDone = formattedDone; + } + } + } + + if (Object.keys(meta).length > 0) { + xstateState.meta = { agent: meta }; + } + + states[stateId] = xstateState; + } + + return { + id: machine.id, + ...(typeof graph.initialNodeId === 'string' + ? { initial: graph.initialNodeId } + : {}), + states, + }; +} + +function groupEdgesByEvent( + edges: AgentGraph['edges'] +): Map { + const grouped = new Map(); + + for (const edge of edges) { + const event = edge.data.event; + if (!event) { + continue; + } + + grouped.set(event, [...(grouped.get(event) ?? []), edge]); + } + + return grouped; +} + +function formatTransitions( + edges: AgentGraphEdge[] +): XStateTransitionConfig | XStateTransitionConfig[] | undefined { + const transitions = edges.map(formatTransition); + + if (transitions.length === 0) { + return undefined; + } + + return transitions.length === 1 ? transitions[0]! : transitions; +} + +function formatTransition(edge: AgentGraphEdge): XStateTransitionConfig { + const actions = [ + ...(edge.data.actions?.context ? ['assignContext'] : []), + ...(edge.data.actions?.input ? ['assignInput'] : []), + ]; + + return { + target: edge.targetId, + ...(edge.data.guard ? { guard: edge.data.guard } : {}), + ...(actions.length > 0 ? { actions } : {}), + meta: { + agent: { + event: edge.data.event, + ...(edge.data.actions + ? { + updates: { + ...(edge.data.actions.context ? { context: true } : {}), + ...(edge.data.actions.input ? { input: true } : {}), + }, + } + : {}), + }, + }, + }; } From dd82cf9956a4adfdb90e4487bb4affd0f7bafac2 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 23 Apr 2026 11:02:14 -0400 Subject: [PATCH 20/50] feat: improve graph analysis and retry workflows --- examples/error-retry.ts | 134 ++++ examples/index.ts | 1 + readme.md | 5 + src/agent-convert-cli.test.ts | 62 ++ src/examples.test.ts | 25 + src/fixtures/converter-machine.ts | 43 ++ src/graph/index.test.ts | 118 +++- src/graph/index.ts | 622 +++++++++++++----- src/langgraph-equivalents/error-retry.test.ts | 50 ++ src/xstate/index.test.ts | 3 +- src/xstate/index.ts | 4 +- 11 files changed, 894 insertions(+), 173 deletions(-) create mode 100644 examples/error-retry.ts create mode 100644 src/agent-convert-cli.test.ts create mode 100644 src/fixtures/converter-machine.ts create mode 100644 src/langgraph-equivalents/error-retry.test.ts diff --git a/examples/error-retry.ts b/examples/error-retry.ts new file mode 100644 index 0000000..dba62e9 --- /dev/null +++ b/examples/error-retry.ts @@ -0,0 +1,134 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const answerSchema = z.object({ + answer: z.string(), +}); + +export function createErrorRetryExample( + answer: (args: { + question: string; + attempt: number; + }) => Promise> = async ({ question, attempt }) => + generateExampleObject({ + schema: answerSchema, + system: 'Answer the user question in one concise paragraph.', + prompt: [ + `Attempt: ${attempt}`, + '', + `Question: ${question}`, + ].join('\n'), + }), + maxAttempts = 3 +) { + return createAgentMachine({ + id: 'error-retry-example', + schemas: { + input: z.object({ + question: z.string(), + }), + events: { + 'xstate.error.invoke.answering': z.object({ + type: z.literal('xstate.error.invoke.answering'), + error: z.unknown().optional(), + at: z.number().optional(), + }), + }, + output: z.object({ + answer: z.string().nullable(), + attempts: z.number().int().min(1), + errors: z.array(z.string()), + }), + }, + context: (input) => ({ + question: input.question, + answer: null as string | null, + attempt: 1, + errors: [] as string[], + }), + initial: 'answering', + states: { + answering: { + resultSchema: answerSchema, + invoke: async ({ context }) => + answer({ + question: context.question, + attempt: context.attempt, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { + answer: result.answer, + }, + }), + on: { + 'xstate.error.invoke.answering': ({ event, context }) => { + const errors = [...context.errors, formatError(event.error)]; + + if (context.attempt >= maxAttempts) { + return { + target: 'failed', + context: { errors }, + }; + } + + return { + target: 'answering', + context: { + attempt: context.attempt + 1, + errors, + }, + }; + }, + }, + }, + failed: { + type: 'final', + output: ({ context }) => ({ + answer: context.answer, + attempts: context.attempt, + errors: context.errors, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + answer: context.answer, + attempts: context.attempt, + errors: context.errors, + }), + }, + }, + }); +} + +function formatError(error: unknown): string { + if (error && typeof error === 'object' && 'message' in error) { + return String((error as { message: unknown }).message); + } + + return String(error); +} + +async function main() { + try { + const question = await prompt('Question'); + const machine = createErrorRetryExample(); + const result = await machine.execute(machine.getInitialState({ question })); + + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/index.ts b/examples/index.ts index 8f611f9..4205092 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -13,6 +13,7 @@ export { } from './cloudflare-durable-object.js'; export { createCustomerServiceSimExample } from './customer-service-sim.js'; export { createEmailExample } from './email.js'; +export { createErrorRetryExample } from './error-retry.js'; export { createJokeExample } from './joke.js'; export { createJugsExample } from './jugs.js'; export { createMapReduceExample } from './map-reduce.js'; diff --git a/readme.md b/readme.md index 59970fe..1d7d8a6 100644 --- a/readme.md +++ b/readme.md @@ -9,14 +9,19 @@ Stately Agent is a flexible framework for building AI agents using state machine ## Examples + + The examples in [`examples/`](/Users/davidkpiano/Code/agent/examples) are intentionally small, run in the CLI, and use real OpenAI calls when `OPENAI_API_KEY` is set. Run them with `node --import tsx examples/.ts`. +Convert a machine file to diagram output with `pnpm agent:convert --format mermaid` or `pnpm agent:convert --format xstate`. + Each example demonstrates one concept: - [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts): the smallest `createAgentMachine(...)` flow with an `invoke` state that makes a real LLM call - [`examples/hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts): a human-in-the-loop machine that pauses in a pending state, accepts typed events, and drafts with an LLM after approval +- [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts): retrying transient invoke failures through explicit internal error events - [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts): choosing the next branch with structured branch-specific payloads - [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts): routing into a fixed category set when you only need a label - [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts): plugging in a custom adapter that still uses a real OpenAI model under the hood diff --git a/src/agent-convert-cli.test.ts b/src/agent-convert-cli.test.ts new file mode 100644 index 0000000..bc573b7 --- /dev/null +++ b/src/agent-convert-cli.test.ts @@ -0,0 +1,62 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { expect, test } from 'vitest'; + +const execFileAsync = promisify(execFile); + +test('agent:convert writes Mermaid and XState output from machine files', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'agent-convert-')); + const fixture = resolve('src/fixtures/converter-machine.ts'); + + const mermaidFile = join(tmp, 'default.mmd'); + await runConvert([fixture, '--format', 'mermaid', '--out', mermaidFile]); + await expect(readFile(mermaidFile, 'utf8')).resolves.toBe(`stateDiagram-v2 + [*] --> idle + idle --> done : submit [event.ok] + idle --> rejected : submit [!(event.ok)] + rejected --> [*] + done --> [*]`); + + const namedXStateFile = join(tmp, 'named.json'); + await runConvert([ + fixture, + '--export', + 'namedMachine', + '--format', + 'xstate', + '--out', + namedXStateFile, + ]); + const namedXState = JSON.parse(await readFile(namedXStateFile, 'utf8')) as { + id: string; + initial: string; + states: Record; + }; + expect(namedXState.id).toBe('named-converter-machine'); + expect(namedXState.initial).toBe('idle'); + expect(Object.keys(namedXState.states)).toEqual(['idle', 'rejected', 'done']); + + const factoryXStateFile = join(tmp, 'factory.json'); + await runConvert([ + fixture, + '--factory', + 'createFixtureMachine', + '--format', + 'xstate', + '--out', + factoryXStateFile, + ]); + const factoryXState = JSON.parse(await readFile(factoryXStateFile, 'utf8')) as { + id: string; + }; + expect(factoryXState.id).toBe('factory-converter-machine'); +}); + +async function runConvert(args: string[]) { + await execFileAsync('pnpm', ['agent:convert', ...args], { + cwd: resolve('.'), + }); +} diff --git a/src/examples.test.ts b/src/examples.test.ts index 8481d1d..5650652 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -12,6 +12,7 @@ import { createCustomerServiceSimExample, createDecideExample, createEmailExample, + createErrorRetryExample, createHitlExample, createJokeExample, createJugsExample, @@ -47,6 +48,7 @@ describe('curated examples', () => { expect(existsSync(resolve(examplesDir, 'cloudflare-durable-object.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'customer-service-sim.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'email.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'error-retry.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'joke.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'jugs.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'map-reduce.ts'))).toBe(true); @@ -224,6 +226,29 @@ describe('curated examples', () => { } }); + test('error retry example recovers from transient invoke failures', async () => { + const machine = createErrorRetryExample(async ({ attempt }) => { + if (attempt === 1) { + throw new Error('temporary outage'); + } + + return { answer: 'Recovered answer.' }; + }); + + const result = await machine.execute( + machine.getInitialState({ question: 'Can this retry?' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + answer: 'Recovered answer.', + attempts: 2, + errors: ['temporary outage'], + }); + } + }); + test('branching example fans out plain async work and summarizes it', async () => { const machine = createBranchingExample({ analyzeDocs: async () => 'docs', diff --git a/src/fixtures/converter-machine.ts b/src/fixtures/converter-machine.ts new file mode 100644 index 0000000..c94fea0 --- /dev/null +++ b/src/fixtures/converter-machine.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../index.js'; + +export const namedMachine = createFixtureMachine('named-converter-machine'); + +export default createFixtureMachine('default-converter-machine'); + +export function createFixtureMachine(id = 'factory-converter-machine') { + return createAgentMachine({ + id, + schemas: { + events: { + submit: z.object({ + type: z.literal('submit'), + ok: z.boolean(), + }), + }, + }, + context: () => ({ + approved: false, + }), + initial: 'idle', + states: { + idle: { + on: { + submit: ({ event }) => + event.ok + ? { + target: 'done', + context: { approved: true }, + } + : { target: 'rejected' }, + }, + }, + rejected: { + type: 'final', + }, + done: { + type: 'final', + }, + }, + }); +} diff --git a/src/graph/index.test.ts b/src/graph/index.test.ts index b8e7444..ba3c897 100644 --- a/src/graph/index.test.ts +++ b/src/graph/index.test.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { createAgentMachine } from '../index.js'; import { toGraph, toMermaid } from './index.js'; +declare function unknownTransition(): { target: 'done' }; + test('exports finite states and transition edges as Stately graph JSON', () => { const machine = createAgentMachine({ id: 'graph-export', @@ -74,6 +76,7 @@ test('exports finite states and transition edges as Stately graph JSON', () => { label: 'submit [event.count > 0]', data: { event: 'submit', + source: 'event', guard: { type: 'event.count > 0' }, actions: { context: true, @@ -86,25 +89,132 @@ test('exports finite states and transition edges as Stately graph JSON', () => { id: 'idle:submit:1', sourceId: 'idle', targetId: 'done', - label: 'submit', + label: 'submit [!(event.count > 0)]', data: { event: 'submit', + source: 'event', + guard: { type: '!(event.count > 0)' }, }, }, { type: 'edge', - id: 'working:done:2', + id: 'working:done.invoke.working:2', sourceId: 'working', targetId: 'done', - label: 'done', + label: 'done.invoke.working', data: { - event: 'done', + event: 'done.invoke.working', + source: 'invoke.done', }, }, ], }); }); +test('infers switch, early-return, and helper-call transition branches', () => { + const machine = createAgentMachine({ + id: 'ast-rich-export', + schemas: { + events: { + route: z.object({ + type: z.literal('route'), + kind: z.enum(['a', 'b', 'c']), + urgent: z.boolean(), + }), + }, + }, + context: () => ({}), + initial: 'idle', + states: { + idle: { + on: { + route: ({ event }) => { + const toA = () => ({ target: 'a' as const }); + + if (event.urgent) { + return toA(); + } + + switch (event.kind) { + case 'b': + return { target: 'b' as const }; + case 'c': + return { target: 'c' as const }; + default: + return { target: 'fallback' as const }; + } + }, + }, + }, + a: { type: 'final' }, + b: { type: 'final' }, + c: { type: 'final' }, + fallback: { type: 'final' }, + }, + }); + + expect(toGraph(machine).edges).toEqual([ + expect.objectContaining({ + targetId: 'a', + data: expect.objectContaining({ + guard: { type: 'event.urgent' }, + }), + }), + expect.objectContaining({ + targetId: 'b', + data: expect.objectContaining({ + guard: { type: '(!(event.urgent)) && (event.kind === "b")' }, + }), + }), + expect.objectContaining({ + targetId: 'c', + data: expect.objectContaining({ + guard: { type: '(!(event.urgent)) && (event.kind === "c")' }, + }), + }), + expect.objectContaining({ + targetId: 'fallback', + data: expect.objectContaining({ + guard: { + type: '(!(event.urgent)) && (!(event.kind === "b") && !(event.kind === "c"))', + }, + }), + }), + ]); +}); + +test('reports graph warnings for unsupported transition analysis', () => { + const machine = createAgentMachine({ + id: 'ast-warning-export', + schemas: { + events: { + go: z.object({ type: z.literal('go') }), + }, + }, + context: () => ({}), + initial: 'idle', + states: { + idle: { + on: { + go: () => { + return unknownTransition(); + }, + }, + }, + done: { type: 'final' }, + }, + }); + + expect(toGraph(machine).data?.warnings).toEqual([ + { + state: 'idle', + event: 'go', + message: + 'Unsupported helper call: unknownTransition() is not statically resolvable.', + }, + ]); +}); + test('exports a mermaid state diagram from the Stately graph data', () => { const machine = createAgentMachine({ id: 'mermaid-export', diff --git a/src/graph/index.ts b/src/graph/index.ts index 8a78aba..4a7dfe4 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -27,6 +27,7 @@ export interface AgentGraphNodeData { export interface AgentGraphEdgeData { event?: string; + source?: 'event' | 'invoke.done'; guard?: { type: string; }; @@ -36,8 +37,18 @@ export interface AgentGraphEdgeData { }; } +export interface AgentGraphData { + warnings?: AgentGraphWarning[]; +} + +export interface AgentGraphWarning { + state: string; + event: string; + message: string; +} + export interface AgentGraph - extends StatelyGraph {} + extends StatelyGraph {} export interface AgentGraphNode extends StatelyGraphNode {} export interface AgentGraphEdge @@ -54,6 +65,22 @@ type EdgeCandidate = { hasInput?: boolean; }; +type AnalysisResult = { + candidates: EdgeCandidate[]; + warnings: string[]; +}; + +type BlockAnalysis = AnalysisResult & { + exits: boolean; +}; + +type AnalyzableFunction = + | ts.ArrowFunction + | ts.FunctionExpression + | ts.FunctionDeclaration; + +type HelperMap = Map; + /** * Convert an agent machine to a Stately graph-compatible plain JSON object. * @@ -77,18 +104,21 @@ export function toGraph(machine: AgentMachine): AgentGraph { })); const edges: Array> = []; + const warnings: AgentGraphWarning[] = []; for (const [sourceId, state] of Object.entries(config.states)) { const stateConfig = state as StateConfig; if (stateConfig.onDone) { - edges.push( - ...getTransitionEdges({ - sourceId, - event: 'done', - transition: stateConfig.onDone, - ordinalOffset: edges.length, - }) - ); + const event = `done.invoke.${sourceId}`; + const result = getTransitionEdges({ + sourceId, + event, + source: 'invoke.done', + transition: stateConfig.onDone, + ordinalOffset: edges.length, + }); + edges.push(...result.edges); + warnings.push(...formatWarnings(sourceId, event, result.warnings)); } if (!stateConfig.on) { @@ -96,21 +126,23 @@ export function toGraph(machine: AgentMachine): AgentGraph { } for (const [event, transition] of Object.entries(stateConfig.on)) { - edges.push( - ...getTransitionEdges({ - sourceId, - event, - transition, - ordinalOffset: edges.length, - }) - ); + const result = getTransitionEdges({ + sourceId, + event, + source: 'event', + transition, + ordinalOffset: edges.length, + }); + edges.push(...result.edges); + warnings.push(...formatWarnings(sourceId, event, result.warnings)); } } - return createGraph({ + return createGraph({ id: machine.id, initialNodeId: typeof config.initial === 'string' ? config.initial : undefined, + ...(warnings.length > 0 ? { data: { warnings } } : {}), nodes, edges, }); @@ -196,41 +228,49 @@ function toMermaidStateGraph(graph: AgentGraph): MermaidStateGraph { function getTransitionEdges(args: { sourceId: string; event: string; + source: NonNullable; transition: unknown; ordinalOffset: number; -}): Array> { - const candidates = +}): { + edges: Array>; + warnings: string[]; +} { + const result = typeof args.transition === 'function' ? analyzeTransitionFunction(args.transition) : analyzeTransitionObject(args.transition); - return candidates.map((candidate, index) => ({ - id: `${args.sourceId}:${args.event}:${args.ordinalOffset + index}`, - sourceId: args.sourceId, - targetId: candidate.target, - label: getEdgeLabel(args.event, candidate.guard), - data: { - event: args.event, - ...(candidate.guard - ? { - guard: { - type: candidate.guard, - }, - } - : {}), - ...((candidate.hasContext || candidate.hasInput) - ? { - actions: { - ...(candidate.hasContext ? { context: true } : {}), - ...(candidate.hasInput ? { input: true } : {}), - }, - } - : {}), - }, - })); + return { + edges: result.candidates.map((candidate, index) => ({ + id: `${args.sourceId}:${args.event}:${args.ordinalOffset + index}`, + sourceId: args.sourceId, + targetId: candidate.target, + label: getEdgeLabel(args.event, candidate.guard), + data: { + event: args.event, + source: args.source, + ...(candidate.guard + ? { + guard: { + type: candidate.guard, + }, + } + : {}), + ...((candidate.hasContext || candidate.hasInput) + ? { + actions: { + ...(candidate.hasContext ? { context: true } : {}), + ...(candidate.hasInput ? { input: true } : {}), + }, + } + : {}), + }, + })), + warnings: result.warnings, + }; } -function analyzeTransitionObject(transition: unknown): EdgeCandidate[] { +function analyzeTransitionObject(transition: unknown): AnalysisResult { const target = transition && typeof transition === 'object' ? (transition as TransitionResult).target @@ -242,19 +282,20 @@ function analyzeTransitionObject(transition: unknown): EdgeCandidate[] { && 'target' in transition && typeof target === 'string' ) { - return [ - { + return { + candidates: [{ target, hasContext: 'context' in transition, hasInput: 'input' in transition, - }, - ]; + }], + warnings: [], + }; } - return []; + return { candidates: [], warnings: [] }; } -function analyzeTransitionFunction(fn: Function): EdgeCandidate[] { +function analyzeTransitionFunction(fn: Function): AnalysisResult { const source = fn.toString(); const file = ts.createSourceFile( 'transition.ts', @@ -266,53 +307,40 @@ function analyzeTransitionFunction(fn: Function): EdgeCandidate[] { const transitionFunction = findTransitionFunction(file); if (!transitionFunction) { - return []; + return { + candidates: [], + warnings: ['Unable to parse transition function.'], + }; } - const candidates: EdgeCandidate[] = []; - const ancestors: ts.Node[] = []; + const helpers = collectHelpers(transitionFunction); - function visit(node: ts.Node) { - if (node !== transitionFunction && isFunctionLike(node)) { - return; - } - - if ( - ts.isArrowFunction(node) - && !ts.isBlock(node.body) - && ts.isExpression(node.body) - ) { - candidates.push( - ...analyzeTransitionExpression( - node.body, - findGuardForReturnLike(node, ancestors, file), - file - ) - ); - } - - if (ts.isReturnStatement(node) && node.expression) { - candidates.push( - ...analyzeTransitionExpression( - node.expression, - findGuardForReturnLike(node, ancestors, file), - file - ) - ); - } - - ancestors.push(node); - ts.forEachChild(node, visit); - ancestors.pop(); + if (ts.isArrowFunction(transitionFunction) && !ts.isBlock(transitionFunction.body)) { + return analyzeTransitionExpression( + transitionFunction.body, + [], + file, + helpers + ); } - visit(transitionFunction); + if (transitionFunction.body && ts.isBlock(transitionFunction.body)) { + return analyzeStatements( + transitionFunction.body.statements, + [], + file, + helpers + ); + } - return candidates; + return { + candidates: [], + warnings: ['Unsupported transition function body.'], + }; } -function findTransitionFunction(file: ts.SourceFile): ts.FunctionLike | undefined { - let transitionFunction: ts.FunctionLike | undefined; +function findTransitionFunction(file: ts.SourceFile): AnalyzableFunction | undefined { + let transitionFunction: AnalyzableFunction | undefined; function visit(node: ts.Node) { if ( @@ -320,7 +348,7 @@ function findTransitionFunction(file: ts.SourceFile): ts.FunctionLike | undefine && ts.isIdentifier(node.name) && node.name.text === '__transition' && node.initializer - && isFunctionLike(node.initializer) + && isAnalyzableFunction(node.initializer) ) { transitionFunction = node.initializer; return; @@ -335,57 +363,352 @@ function findTransitionFunction(file: ts.SourceFile): ts.FunctionLike | undefine return transitionFunction; } -function isFunctionLike(node: ts.Node): node is ts.FunctionLike { +function isAnalyzableFunction(node: ts.Node): node is AnalyzableFunction { return ( ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) - || ts.isMethodDeclaration(node) ); } -function analyzeTransitionExpression( - expression: ts.Expression, - guard: string | undefined, - file: ts.SourceFile -): EdgeCandidate[] { - let current = expression; +function collectHelpers(fn: AnalyzableFunction): HelperMap { + const helpers: HelperMap = new Map(); + if (!fn.body || !ts.isBlock(fn.body)) { + return helpers; + } - while (ts.isParenthesizedExpression(current)) { - current = current.expression; + for (const statement of fn.body.statements) { + if ( + ts.isFunctionDeclaration(statement) + && statement.name + && statement.body + ) { + helpers.set(statement.name.text, statement); + continue; + } + + if (!ts.isVariableStatement(statement)) { + continue; + } + + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) { + continue; + } + + const initializer = unwrapParenthesized(declaration.initializer); + if ( + isAnalyzableFunction(initializer) + || ts.isObjectLiteralExpression(initializer) + || ts.isConditionalExpression(initializer) + ) { + helpers.set(declaration.name.text, initializer); + } + } + } + + return helpers; +} + +function analyzeStatements( + statements: ts.NodeArray, + guards: string[], + file: ts.SourceFile, + helpers: HelperMap +): BlockAnalysis { + const candidates: EdgeCandidate[] = []; + const warnings: string[] = []; + const fallthroughGuards = [...guards]; + + for (const statement of statements) { + const result = analyzeStatement(statement, fallthroughGuards, file, helpers); + candidates.push(...result.candidates); + warnings.push(...result.warnings); + + if (result.exits) { + return { candidates, warnings, exits: true }; + } + + if ( + ts.isIfStatement(statement) + && isReturnOnlyBranch(statement.thenStatement) + && !statement.elseStatement + ) { + fallthroughGuards.push(`!(${statement.expression.getText(file)})`); + } + } + + return { candidates, warnings, exits: false }; +} + +function analyzeStatement( + statement: ts.Statement, + guards: string[], + file: ts.SourceFile, + helpers: HelperMap +): BlockAnalysis { + if (ts.isReturnStatement(statement)) { + if (!statement.expression) { + return { + candidates: [], + warnings: ['Return statement has no transition object.'], + exits: true, + }; + } + + const result = analyzeTransitionExpression( + statement.expression, + guards, + file, + helpers + ); + + return { + candidates: result.candidates, + warnings: + result.candidates.length === 0 && result.warnings.length === 0 + ? [ + `Unsupported transition return expression: ${statement.expression.getText(file)}`, + ] + : result.warnings, + exits: true, + }; + } + + if (ts.isIfStatement(statement)) { + return analyzeIfStatement(statement, guards, file, helpers); + } + + if (ts.isSwitchStatement(statement)) { + return analyzeSwitchStatement(statement, guards, file, helpers); + } + + if ( + ts.isVariableStatement(statement) + || ts.isFunctionDeclaration(statement) + || ts.isEmptyStatement(statement) + ) { + return { candidates: [], warnings: [], exits: false }; + } + + return { + candidates: [], + warnings: [`Unsupported transition statement: ${statement.getText(file)}`], + exits: false, + }; +} + +function analyzeIfStatement( + statement: ts.IfStatement, + guards: string[], + file: ts.SourceFile, + helpers: HelperMap +): BlockAnalysis { + const condition = statement.expression.getText(file); + const thenResult = analyzeBranch( + statement.thenStatement, + [...guards, condition], + file, + helpers + ); + const elseResult = statement.elseStatement + ? analyzeBranch( + statement.elseStatement, + [...guards, `!(${condition})`], + file, + helpers + ) + : emptyBlockAnalysis(); + + return { + candidates: [...thenResult.candidates, ...elseResult.candidates], + warnings: [...thenResult.warnings, ...elseResult.warnings], + exits: thenResult.exits && !!statement.elseStatement && elseResult.exits, + }; +} + +function analyzeSwitchStatement( + statement: ts.SwitchStatement, + guards: string[], + file: ts.SourceFile, + helpers: HelperMap +): BlockAnalysis { + const candidates: EdgeCandidate[] = []; + const warnings: string[] = []; + const expression = statement.expression.getText(file); + const caseGuards: string[] = []; + let allClausesExit = statement.caseBlock.clauses.length > 0; + + for (const clause of statement.caseBlock.clauses) { + const clauseGuard = ts.isCaseClause(clause) + ? `${expression} === ${clause.expression.getText(file)}` + : caseGuards.length > 0 + ? caseGuards.map((guard) => `!(${guard})`).join(' && ') + : undefined; + + if (clauseGuard) { + caseGuards.push(clauseGuard); + } + + const result = analyzeStatements( + clause.statements, + clauseGuard ? [...guards, clauseGuard] : guards, + file, + helpers + ); + candidates.push(...result.candidates); + warnings.push(...result.warnings); + allClausesExit = allClausesExit && result.exits; + } + + return { + candidates, + warnings, + exits: allClausesExit, + }; +} + +function analyzeBranch( + statement: ts.Statement, + guards: string[], + file: ts.SourceFile, + helpers: HelperMap +): BlockAnalysis { + if (ts.isBlock(statement)) { + return analyzeStatements(statement.statements, guards, file, helpers); } + return analyzeStatement(statement, guards, file, helpers); +} + +function emptyBlockAnalysis(): BlockAnalysis { + return { + candidates: [], + warnings: [], + exits: false, + }; +} + +function isReturnOnlyBranch(statement: ts.Statement): boolean { + if (ts.isReturnStatement(statement)) { + return true; + } + + return ( + ts.isBlock(statement) + && statement.statements.length === 1 + && !!statement.statements[0] + && ts.isReturnStatement(statement.statements[0]) + ); +} + +function analyzeTransitionExpression( + expression: ts.Expression, + guards: string[], + file: ts.SourceFile, + helpers: HelperMap +): AnalysisResult { + const current = unwrapParenthesized(expression); + if (ts.isConditionalExpression(current)) { const condition = current.condition.getText(file); - return [ - ...analyzeTransitionExpression( + return mergeAnalysis([ + analyzeTransitionExpression( current.whenTrue, - combineGuards(guard, condition), - file + [...guards, condition], + file, + helpers ), - ...analyzeTransitionExpression( + analyzeTransitionExpression( current.whenFalse, - combineGuards(guard, `!(${condition})`), - file + [...guards, `!(${condition})`], + file, + helpers ), - ]; + ]); + } + + if ( + ts.isCallExpression(current) + && ts.isIdentifier(current.expression) + && current.arguments.length === 0 + ) { + const helper = helpers.get(current.expression.text); + if (!helper) { + return { + candidates: [], + warnings: [ + `Unsupported helper call: ${current.expression.text}() is not statically resolvable.`, + ], + }; + } + + return analyzeHelper(helper, guards, file, helpers); } const object = unwrapParenthesizedObject(current); const target = object ? getStringProperty(object, 'target') : undefined; if (!target) { - return []; + return { candidates: [], warnings: [] }; } - return [ - { + return { + candidates: [{ target, - guard, + guard: combineGuardList(guards), hasContext: object ? hasProperty(object, 'context') : false, hasInput: object ? hasProperty(object, 'input') : false, - }, - ]; + }], + warnings: [], + }; +} + +function analyzeHelper( + helper: AnalyzableFunction | ts.Expression, + guards: string[], + file: ts.SourceFile, + helpers: HelperMap +): AnalysisResult { + if (isAnalyzableFunction(helper)) { + if (ts.isArrowFunction(helper) && !ts.isBlock(helper.body)) { + return analyzeTransitionExpression(helper.body, guards, file, helpers); + } + + if (helper.body && ts.isBlock(helper.body)) { + const result = analyzeStatements(helper.body.statements, guards, file, helpers); + return { + candidates: result.candidates, + warnings: result.warnings, + }; + } + } + + if (ts.isExpression(helper)) { + return analyzeTransitionExpression(helper, guards, file, helpers); + } + + return { + candidates: [], + warnings: ['Unsupported helper body.'], + }; +} + +function mergeAnalysis(results: AnalysisResult[]): AnalysisResult { + return { + candidates: results.flatMap((result) => result.candidates), + warnings: results.flatMap((result) => result.warnings), + }; +} + +function unwrapParenthesized(expression: T): ts.Expression { + let current: ts.Expression = expression; + + while (ts.isParenthesizedExpression(current)) { + current = current.expression; + } + + return current; } function unwrapParenthesizedObject( @@ -430,50 +753,6 @@ function hasProperty(object: ts.ObjectLiteralExpression, name: string): boolean }); } -function findGuardForReturnLike( - returnNode: ts.Node, - ancestors: ts.Node[], - file: ts.SourceFile -): string | undefined { - for (let index = ancestors.length - 1; index >= 0; index -= 1) { - const ancestor = ancestors[index]; - if (!ancestor || !ts.isIfStatement(ancestor)) { - continue; - } - - if (containsNode(ancestor.thenStatement, returnNode)) { - return ancestor.expression.getText(file); - } - - if (ancestor.elseStatement && containsNode(ancestor.elseStatement, returnNode)) { - return `!(${ancestor.expression.getText(file)})`; - } - } - - return undefined; -} - -function containsNode(parent: ts.Node, child: ts.Node): boolean { - if (parent === child) { - return true; - } - - let found = false; - function visit(node: ts.Node) { - if (node === child) { - found = true; - return; - } - - if (!found) { - ts.forEachChild(node, visit); - } - } - - visit(parent); - return found; -} - function getEdgeLabel(event: string, guard: string | undefined): string { if (!guard) { return event; @@ -482,13 +761,24 @@ function getEdgeLabel(event: string, guard: string | undefined): string { return `${event} [${guard}]`; } -function combineGuards( - outer: string | undefined, - inner: string -): string { - if (!outer) { - return inner; +function combineGuardList(guards: string[]): string | undefined { + if (guards.length === 0) { + return undefined; } - return `(${outer}) && (${inner})`; + return guards + .map((guard) => guards.length === 1 ? guard : `(${guard})`) + .join(' && '); +} + +function formatWarnings( + state: string, + event: string, + warnings: string[] +): AgentGraphWarning[] { + return warnings.map((message) => ({ + state, + event, + message, + })); } diff --git a/src/langgraph-equivalents/error-retry.test.ts b/src/langgraph-equivalents/error-retry.test.ts new file mode 100644 index 0000000..a149c64 --- /dev/null +++ b/src/langgraph-equivalents/error-retry.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from 'vitest'; +import { createErrorRetryExample } from '../../examples/index.js'; + +test('retries failed invoke work through explicit internal error events', async () => { + let attempts = 0; + const machine = createErrorRetryExample(async ({ attempt }) => { + attempts += 1; + + if (attempt < 3) { + throw new Error(`temporary failure ${attempt}`); + } + + return { + answer: `answered on attempt ${attempt}`, + }; + }); + + const result = await machine.execute( + machine.getInitialState({ question: 'What is durable retry?' }) + ); + + expect(attempts).toBe(3); + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + answer: 'answered on attempt 3', + attempts: 3, + errors: ['temporary failure 1', 'temporary failure 2'], + }); + } +}); + +test('fails after the configured retry budget is exhausted', async () => { + const machine = createErrorRetryExample(async ({ attempt }) => { + throw new Error(`still down ${attempt}`); + }, 2); + + const result = await machine.execute( + machine.getInitialState({ question: 'Will this recover?' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + answer: null, + attempts: 2, + errors: ['still down 1', 'still down 2'], + }); + } +}); diff --git a/src/xstate/index.test.ts b/src/xstate/index.test.ts index 8e7fe97..16609a4 100644 --- a/src/xstate/index.test.ts +++ b/src/xstate/index.test.ts @@ -73,6 +73,7 @@ test('exports a serializable XState config for visualization', () => { }, { target: 'done', + guard: { type: '!(event.count > 0)' }, meta: { agent: { event: 'submit', @@ -90,7 +91,7 @@ test('exports a serializable XState config for visualization', () => { target: 'done', meta: { agent: { - event: 'done', + event: 'done.invoke.working', }, }, }, diff --git a/src/xstate/index.ts b/src/xstate/index.ts index 6c112c2..b9a1266 100644 --- a/src/xstate/index.ts +++ b/src/xstate/index.ts @@ -81,7 +81,7 @@ export function toXStateMachine(machine: AgentMachine): XStateMachineConfig { const regularEdges = graph.edges.filter((edge) => edge.sourceId === stateId - && edge.data.event !== 'done' + && edge.data.source !== 'invoke.done' ); for (const [event, edges] of groupEdgesByEvent(regularEdges)) { @@ -97,7 +97,7 @@ export function toXStateMachine(machine: AgentMachine): XStateMachineConfig { if (stateConfig.onDone) { const doneEdges = graph.edges.filter((edge) => edge.sourceId === stateId - && edge.data.event === 'done' + && edge.data.source === 'invoke.done' ); const formattedDone = formatTransitions(doneEdges); From 7b5eea0d4528696cb03ee071ce3d970fd56c0c7c Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 23 Apr 2026 11:10:38 -0400 Subject: [PATCH 21/50] feat: surface conversion warnings and durable retry restore --- readme.md | 2 +- scripts/agent-convert.ts | 17 ++++- src/agent-convert-cli.test.ts | 16 ++++- src/fixtures/converter-machine.ts | 25 +++++++ src/langgraph-equivalents/error-retry.test.ts | 69 ++++++++++++++++++- 5 files changed, 125 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index 1d7d8a6..9cefd31 100644 --- a/readme.md +++ b/readme.md @@ -15,7 +15,7 @@ The examples in [`examples/`](/Users/davidkpiano/Code/agent/examples) are intent Run them with `node --import tsx examples/.ts`. -Convert a machine file to diagram output with `pnpm agent:convert --format mermaid` or `pnpm agent:convert --format xstate`. +Convert a machine file to diagram output with `pnpm agent:convert --format mermaid` or `pnpm agent:convert --format xstate`. Static analysis warnings are printed to stderr. Each example demonstrates one concept: diff --git a/scripts/agent-convert.ts b/scripts/agent-convert.ts index 3849d4f..bcfc1a3 100644 --- a/scripts/agent-convert.ts +++ b/scripts/agent-convert.ts @@ -1,7 +1,7 @@ import { writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { toMermaid } from '../src/graph/index.js'; +import { toGraph, toMermaid, type AgentGraphWarning } from '../src/graph/index.js'; import type { AgentMachine } from '../src/index.js'; import { toXStateMachine } from '../src/xstate/index.js'; @@ -25,6 +25,9 @@ async function main() { } const machine = await loadMachine(options); + const graph = toGraph(machine); + writeWarnings(graph.data?.warnings ?? []); + const output = options.format === 'mermaid' ? toMermaid(machine) @@ -38,6 +41,18 @@ async function main() { process.stdout.write(output.endsWith('\n') ? output : `${output}\n`); } +function writeWarnings(warnings: AgentGraphWarning[]) { + for (const warning of warnings) { + process.stderr.write( + [ + '[agent:convert]', + `${warning.state} on ${warning.event}:`, + warning.message, + ].join(' ') + '\n' + ); + } +} + function parseArgs(args: string[]): CliOptions { const options: CliOptions = { format: 'mermaid', diff --git a/src/agent-convert-cli.test.ts b/src/agent-convert-cli.test.ts index bc573b7..6812250 100644 --- a/src/agent-convert-cli.test.ts +++ b/src/agent-convert-cli.test.ts @@ -53,10 +53,24 @@ test('agent:convert writes Mermaid and XState output from machine files', async id: string; }; expect(factoryXState.id).toBe('factory-converter-machine'); + + const warningFile = join(tmp, 'warning.mmd'); + const warningResult = await runConvert([ + fixture, + '--export', + 'warningMachine', + '--format', + 'mermaid', + '--out', + warningFile, + ]); + expect(warningResult.stderr).toContain( + '[agent:convert] idle on go: Unsupported helper call: unknownTransition() is not statically resolvable.' + ); }); async function runConvert(args: string[]) { - await execFileAsync('pnpm', ['agent:convert', ...args], { + return execFileAsync('pnpm', ['agent:convert', ...args], { cwd: resolve('.'), }); } diff --git a/src/fixtures/converter-machine.ts b/src/fixtures/converter-machine.ts index c94fea0..47cfb05 100644 --- a/src/fixtures/converter-machine.ts +++ b/src/fixtures/converter-machine.ts @@ -1,8 +1,33 @@ import { z } from 'zod'; import { createAgentMachine } from '../index.js'; +declare function unknownTransition(): { target: 'done' }; + export const namedMachine = createFixtureMachine('named-converter-machine'); +export const warningMachine = createAgentMachine({ + id: 'warning-converter-machine', + schemas: { + events: { + go: z.object({ + type: z.literal('go'), + }), + }, + }, + context: () => ({}), + initial: 'idle', + states: { + idle: { + on: { + go: () => unknownTransition(), + }, + }, + done: { + type: 'final', + }, + }, +}); + export default createFixtureMachine('default-converter-machine'); export function createFixtureMachine(id = 'factory-converter-machine') { diff --git a/src/langgraph-equivalents/error-retry.test.ts b/src/langgraph-equivalents/error-retry.test.ts index a149c64..8472fd0 100644 --- a/src/langgraph-equivalents/error-retry.test.ts +++ b/src/langgraph-equivalents/error-retry.test.ts @@ -1,5 +1,6 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import { createErrorRetryExample } from '../../examples/index.js'; +import { createMemoryRunStore, restoreSession } from '../index.js'; test('retries failed invoke work through explicit internal error events', async () => { let attempts = 0; @@ -48,3 +49,69 @@ test('fails after the configured retry budget is exhausted', async () => { }); } }); + +test('restores a durable retry snapshot and continues from the next attempt', async () => { + const sessionId = 'durable-retry-session'; + const machine = createErrorRetryExample(async ({ attempt }) => ({ + answer: `restored attempt ${attempt}`, + })); + const store = createMemoryRunStore(); + const input = { question: 'Can retry survive restore?' }; + const initial = machine.getInitialState(input); + const retryState = machine.transition(initial, { + type: 'xstate.error.invoke.answering', + error: { message: 'network reset' }, + at: 2, + }); + + await store.append(sessionId, { + type: 'xstate.init', + input, + at: 1, + }); + await store.append(sessionId, { + type: 'xstate.error.invoke.answering', + error: { message: 'network reset' }, + at: 2, + }); + await store.saveSnapshot({ + sessionId, + afterSequence: 2, + snapshot: { + value: retryState.value, + context: retryState.context, + status: retryState.status, + input: retryState.input, + createdAt: 1, + sessionId, + }, + createdAt: 2, + }); + + const restored = await restoreSession(machine, { + sessionId, + store, + }); + + await vi.waitFor(() => { + expect(restored.getSnapshot().status).toBe('done'); + }); + + expect(restored.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + context: { + question: 'Can retry survive restore?', + answer: 'restored attempt 2', + attempt: 2, + errors: ['network reset'], + }, + output: { + answer: 'restored attempt 2', + attempts: 2, + errors: ['network reset'], + }, + }) + ); +}); From 118eadf4b8f28c95ac067967906961ad0a1a1ffe Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 23 Apr 2026 11:55:25 -0400 Subject: [PATCH 22/50] feat: clarify xstate visualization and conditional subflows --- examples/conditional-subflow.ts | 206 ++++++++++++++++++ examples/index.ts | 1 + readme.md | 1 + scripts/agent-convert.ts | 4 +- src/agent-convert-cli.test.ts | 8 + src/examples.test.ts | 27 +++ .../conditional-subflow.test.ts | 51 +++++ src/xstate/index.test.ts | 12 +- src/xstate/index.ts | 27 ++- 9 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 examples/conditional-subflow.ts create mode 100644 src/langgraph-equivalents/conditional-subflow.test.ts diff --git a/examples/conditional-subflow.ts b/examples/conditional-subflow.ts new file mode 100644 index 0000000..52793f0 --- /dev/null +++ b/examples/conditional-subflow.ts @@ -0,0 +1,206 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const modeSchema = z.enum(['research', 'draft']); + +const researchSchema = z.object({ + bullets: z.array(z.string()), +}); + +const draftSchema = z.object({ + draft: z.string(), +}); + +export function createConditionalSubflowExample( + options: { + research?: (topic: string) => Promise>; + draft?: (args: { + topic: string; + bullets: string[]; + }) => Promise>; + } = {} +) { + const researchMachine = createAgentMachine({ + id: 'conditional-subflow-research', + schemas: { + input: z.object({ topic: z.string() }), + output: researchSchema, + }, + context: (input) => ({ + topic: input.topic, + bullets: [] as string[], + }), + initial: 'researching', + states: { + researching: { + resultSchema: researchSchema, + invoke: async ({ context }) => + (options.research + ?? ((topic) => + generateExampleObject({ + schema: researchSchema, + system: 'Return concise research bullets.', + prompt: `Return 2 to 4 bullets about ${topic}.`, + })))(context.topic), + onDone: ({ result }) => ({ + target: 'done', + context: { bullets: result.bullets }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ bullets: context.bullets }), + }, + }, + }); + + const draftMachine = createAgentMachine({ + id: 'conditional-subflow-draft', + schemas: { + input: z.object({ + topic: z.string(), + bullets: z.array(z.string()), + }), + output: draftSchema, + }, + context: (input) => ({ + topic: input.topic, + bullets: input.bullets, + draft: null as string | null, + }), + initial: 'drafting', + states: { + drafting: { + resultSchema: draftSchema, + invoke: async ({ context }) => + (options.draft + ?? (({ topic, bullets }) => + generateExampleObject({ + schema: draftSchema, + system: 'Turn bullets into a short draft.', + prompt: [ + `Topic: ${topic}`, + 'Bullets:', + ...bullets.map((bullet) => `- ${bullet}`), + ].join('\n'), + })))({ + topic: context.topic, + bullets: context.bullets, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { draft: result.draft }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ draft: context.draft ?? '' }), + }, + }, + }); + + return createAgentMachine({ + id: 'conditional-subflow-example', + schemas: { + input: z.object({ + topic: z.string(), + mode: modeSchema, + bullets: z.array(z.string()).optional(), + }), + output: z.object({ + mode: modeSchema, + bullets: z.array(z.string()), + draft: z.string().nullable(), + }), + }, + context: (input) => ({ + topic: input.topic, + mode: input.mode, + bullets: input.bullets ?? [], + draft: null as string | null, + }), + initial: ({ context }) => + context.mode === 'research' + ? { target: 'researching' } + : { target: 'drafting', input: { bullets: context.bullets } }, + states: { + researching: { + resultSchema: researchSchema, + invoke: async ({ context }) => { + const result = await researchMachine.execute( + researchMachine.getInitialState({ topic: context.topic }) + ); + + if (result.status !== 'done') { + throw new Error('Research subflow did not finish'); + } + + return result.output; + }, + onDone: ({ result }) => ({ + target: 'done', + context: { bullets: result.bullets }, + }), + }, + drafting: { + inputSchema: z.object({ + bullets: z.array(z.string()), + }), + resultSchema: draftSchema, + invoke: async ({ context, input }) => { + const result = await draftMachine.execute( + draftMachine.getInitialState({ + topic: context.topic, + bullets: input.bullets, + }) + ); + + if (result.status !== 'done') { + throw new Error('Draft subflow did not finish'); + } + + return result.output; + }, + onDone: ({ result }) => ({ + target: 'done', + context: { draft: result.draft }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + mode: context.mode, + bullets: context.bullets, + draft: context.draft, + }), + }, + }, + }); +} + +async function main() { + try { + const topic = await prompt('Topic'); + const modeInput = await prompt('Mode (research/draft)'); + const mode = modeInput === 'draft' ? 'draft' : 'research'; + const machine = createConditionalSubflowExample(); + const result = await machine.execute( + machine.getInitialState({ topic, mode }) + ); + + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/index.ts b/examples/index.ts index 4205092..c652d41 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -5,6 +5,7 @@ export { createDecideExample } from './decide.js'; export { createClassifyExample } from './classify.js'; export { createAdapterExample } from './adapter.js'; export { createChatbotExample } from './chatbot.js'; +export { createConditionalSubflowExample } from './conditional-subflow.js'; export { AgentSessionDurableObject, createDurableObjectRunStore, diff --git a/readme.md b/readme.md index 9cefd31..32ea42e 100644 --- a/readme.md +++ b/readme.md @@ -22,6 +22,7 @@ Each example demonstrates one concept: - [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts): the smallest `createAgentMachine(...)` flow with an `invoke` state that makes a real LLM call - [`examples/hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts): a human-in-the-loop machine that pauses in a pending state, accepts typed events, and drafts with an LLM after approval - [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts): retrying transient invoke failures through explicit internal error events +- [`examples/conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts): routing directly into one of multiple child subflows from parent input - [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts): choosing the next branch with structured branch-specific payloads - [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts): routing into a fixed category set when you only need a label - [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts): plugging in a custom adapter that still uses a real OpenAI model under the hood diff --git a/scripts/agent-convert.ts b/scripts/agent-convert.ts index bcfc1a3..ef5773d 100644 --- a/scripts/agent-convert.ts +++ b/scripts/agent-convert.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { toGraph, toMermaid, type AgentGraphWarning } from '../src/graph/index.js'; import type { AgentMachine } from '../src/index.js'; -import { toXStateMachine } from '../src/xstate/index.js'; +import { toXStateVisualization } from '../src/xstate/index.js'; type Format = 'mermaid' | 'xstate'; @@ -31,7 +31,7 @@ async function main() { const output = options.format === 'mermaid' ? toMermaid(machine) - : `${JSON.stringify(toXStateMachine(machine), null, 2)}\n`; + : `${JSON.stringify(toXStateVisualization(machine), null, 2)}\n`; if (options.outFile) { await writeFile(resolve(options.outFile), output); diff --git a/src/agent-convert-cli.test.ts b/src/agent-convert-cli.test.ts index 6812250..081108b 100644 --- a/src/agent-convert-cli.test.ts +++ b/src/agent-convert-cli.test.ts @@ -37,6 +37,14 @@ test('agent:convert writes Mermaid and XState output from machine files', async }; expect(namedXState.id).toBe('named-converter-machine'); expect(namedXState.initial).toBe('idle'); + expect(namedXState).toMatchObject({ + meta: { + agent: { + format: '@statelyai/agent/xstate-visualization', + runnable: false, + }, + }, + }); expect(Object.keys(namedXState.states)).toEqual(['idle', 'rejected', 'done']); const factoryXStateFile = join(tmp, 'factory.json'); diff --git a/src/examples.test.ts b/src/examples.test.ts index 5650652..da10335 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -9,6 +9,7 @@ import { createAdapterExample, createBranchingExample, createClassifyExample, + createConditionalSubflowExample, createCustomerServiceSimExample, createDecideExample, createEmailExample, @@ -46,6 +47,7 @@ describe('curated examples', () => { expect(existsSync(resolve(examplesDir, 'branching.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'chatbot.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'cloudflare-durable-object.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'conditional-subflow.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'customer-service-sim.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'email.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'error-retry.ts'))).toBe(true); @@ -249,6 +251,31 @@ describe('curated examples', () => { } }); + test('conditional subflow example routes directly into the requested child flow', async () => { + const machine = createConditionalSubflowExample({ + draft: async ({ topic, bullets }) => ({ + draft: `${topic}: ${bullets.join(', ')}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ + topic: 'state machines', + mode: 'draft', + bullets: ['deterministic', 'resumable'], + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + mode: 'draft', + bullets: ['deterministic', 'resumable'], + draft: 'state machines: deterministic, resumable', + }); + } + }); + test('branching example fans out plain async work and summarizes it', async () => { const machine = createBranchingExample({ analyzeDocs: async () => 'docs', diff --git a/src/langgraph-equivalents/conditional-subflow.test.ts b/src/langgraph-equivalents/conditional-subflow.test.ts new file mode 100644 index 0000000..7b0a155 --- /dev/null +++ b/src/langgraph-equivalents/conditional-subflow.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from 'vitest'; +import { createConditionalSubflowExample } from '../../examples/index.js'; + +test('conditionally enters the research subflow from parent input', async () => { + const machine = createConditionalSubflowExample({ + research: async (topic) => ({ + bullets: [`${topic}:fact-1`, `${topic}:fact-2`], + }), + }); + + const result = await machine.execute( + machine.getInitialState({ + topic: 'agent graphs', + mode: 'research', + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + mode: 'research', + bullets: ['agent graphs:fact-1', 'agent graphs:fact-2'], + draft: null, + }); + } +}); + +test('conditionally enters the draft subflow with parent-provided input', async () => { + const machine = createConditionalSubflowExample({ + draft: async ({ topic, bullets }) => ({ + draft: `${topic}: ${bullets.join(' / ')}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ + topic: 'agent graphs', + mode: 'draft', + bullets: ['known fact', 'second fact'], + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + mode: 'draft', + bullets: ['known fact', 'second fact'], + draft: 'agent graphs: known fact / second fact', + }); + } +}); diff --git a/src/xstate/index.test.ts b/src/xstate/index.test.ts index 16609a4..5b00afe 100644 --- a/src/xstate/index.test.ts +++ b/src/xstate/index.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { z } from 'zod'; import { createAgentMachine } from '../index.js'; -import { toXStateMachine } from './index.js'; +import { toXStateMachine, toXStateVisualization } from './index.js'; test('exports a serializable XState config for visualization', () => { const machine = createAgentMachine({ @@ -50,9 +50,16 @@ test('exports a serializable XState config for visualization', () => { }, }); - expect(toXStateMachine(machine)).toEqual({ + expect(toXStateVisualization(machine)).toEqual({ id: 'xstate-export', initial: 'idle', + meta: { + agent: { + format: '@statelyai/agent/xstate-visualization', + runnable: false, + note: 'Generated for visualization. Runtime semantics remain in the agent machine.', + }, + }, states: { idle: { on: { @@ -107,4 +114,5 @@ test('exports a serializable XState config for visualization', () => { }, }, }); + expect(toXStateMachine(machine)).toEqual(toXStateVisualization(machine)); }); diff --git a/src/xstate/index.ts b/src/xstate/index.ts index b9a1266..14311df 100644 --- a/src/xstate/index.ts +++ b/src/xstate/index.ts @@ -4,6 +4,13 @@ import type { AgentMachine, MachineConfig, StateConfig } from '../types.js'; export interface XStateMachineConfig { id: string; initial?: string; + meta: { + agent: { + format: '@statelyai/agent/xstate-visualization'; + runnable: false; + note: string; + }; + }; states: Record; } @@ -46,10 +53,11 @@ type InternalMachine = AgentMachine & { }; /** - * Convert an agent machine to a serializable XState machine config for - * visualization. Runtime behavior is still driven by the agent machine. + * Convert an agent machine to a serializable XState-like machine config for + * visualization. Guards, actions, and invokes are symbolic metadata, so this + * object is not a runnable replacement for the agent machine. */ -export function toXStateMachine(machine: AgentMachine): XStateMachineConfig { +export function toXStateVisualization(machine: AgentMachine): XStateMachineConfig { const config = (machine as InternalMachine).__config; if (!config) { throw new Error('Machine config metadata is unavailable for XState export'); @@ -122,10 +130,23 @@ export function toXStateMachine(machine: AgentMachine): XStateMachineConfig { ...(typeof graph.initialNodeId === 'string' ? { initial: graph.initialNodeId } : {}), + meta: { + agent: { + format: '@statelyai/agent/xstate-visualization', + runnable: false, + note: 'Generated for visualization. Runtime semantics remain in the agent machine.', + }, + }, states, }; } +/** + * @deprecated Use `toXStateVisualization(...)` to make the visualization-only + * contract explicit. + */ +export const toXStateMachine = toXStateVisualization; + function groupEdgesByEvent( edges: AgentGraph['edges'] ): Map { From 5672cc33057bc41a40457ebd3fc553c3a5c53932 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 24 Apr 2026 07:32:46 -0400 Subject: [PATCH 23/50] feat: add durable workflow parity and node baseline --- .github/actions/ci-setup/action.yml | 2 +- .github/workflows/release.yml | 2 +- .node-version | 1 + .nvmrc | 1 + examples/cloudflare-durable-network.ts | 105 ++++++ examples/index.ts | 5 + examples/persistent-multi-agent-network.ts | 111 ++++++ examples/tool-calling.ts | 44 ++- package.json | 3 + readme.md | 6 +- scripts/agent-convert.ts | 6 +- src/examples.test.ts | 156 +++++++- src/graph/index.test.ts | 61 ++- src/graph/index.ts | 346 +++++++++++++++--- .../persistent-multi-agent-network.test.ts | 63 ++++ .../tool-calling.test.ts | 29 +- 16 files changed, 867 insertions(+), 74 deletions(-) create mode 100644 .node-version create mode 100644 .nvmrc create mode 100644 examples/cloudflare-durable-network.ts create mode 100644 examples/persistent-multi-agent-network.ts create mode 100644 src/langgraph-equivalents/persistent-multi-agent-network.test.ts diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index ddc9555..51fcd0d 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -6,7 +6,7 @@ runs: - uses: pnpm/action-setup@v2 - uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.18.0 - name: install pnpm run: npm i pnpm@latest -g diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57c57f8..b6f87ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: setup node.js uses: actions/setup-node@v3 with: - node-version: 20 + node-version: 22.18.0 - name: install pnpm run: npm i pnpm@latest -g - name: setup pnpm config diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..91d5f6f --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22.18.0 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..91d5f6f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.18.0 diff --git a/examples/cloudflare-durable-network.ts b/examples/cloudflare-durable-network.ts new file mode 100644 index 0000000..bca3d33 --- /dev/null +++ b/examples/cloudflare-durable-network.ts @@ -0,0 +1,105 @@ +import { + restoreSession, + startSession, + type AgentSnapshot, +} from '../src/index.js'; +import { + createDurableObjectRunStore, + type DurableObjectStateLike, +} from './cloudflare-durable-object.js'; +import { createMultiAgentNetworkExample } from './multi-agent-network.js'; + +export class AgentNetworkDurableObject { + private readonly store; + + constructor(private readonly state: DurableObjectStateLike) { + this.store = createDurableObjectRunStore(state.storage); + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + const machine = createMultiAgentNetworkExample({ + adapter: { + decide: async ({ prompt }) => { + if (!prompt.includes('Notes: none yet')) { + if (!prompt.includes('Current draft: none yet')) { + return { choice: 'finalize', data: {} }; + } + + return { + choice: 'write', + data: { angle: 'turn the current notes into a concise summary' }, + }; + } + + return { + choice: 'research', + data: { focus: 'collect the strongest supporting facts' }, + }; + }, + }, + research: async ({ topic, focus }) => ({ + notes: [`${topic}:${focus}:1`, `${topic}:${focus}:2`], + }), + write: async ({ topic, notes, angle }) => ({ + draft: `${topic} | ${angle} | ${notes.join(' / ')}`, + }), + }); + + if (request.method === 'POST' && url.pathname === '/start') { + const body = await request.json() as { topic: string }; + const run = await startSession(machine, { + store: this.store, + input: { topic: body.topic }, + }); + + return Response.json({ + sessionId: run.sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'POST' && url.pathname === '/resume') { + const sessionId = requiredSessionId(url); + const run = await restoreSession(machine, { + sessionId, + store: this.store, + }); + const snapshot = await waitForTerminalSnapshot(run.getSnapshot, 1000); + + return Response.json({ + sessionId, + snapshot, + }); + } + + return new Response('Not found', { status: 404 }); + } +} + +async function waitForTerminalSnapshot( + getSnapshot: () => AgentSnapshot, + timeoutMs: number +) { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const snapshot = getSnapshot(); + if (snapshot.status === 'done' || snapshot.status === 'error') { + return snapshot; + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + return getSnapshot(); +} + +function requiredSessionId(url: URL): string { + const sessionId = url.searchParams.get('sessionId'); + if (!sessionId) { + throw new Error('Missing sessionId'); + } + + return sessionId; +} diff --git a/examples/index.ts b/examples/index.ts index c652d41..d43e598 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -12,6 +12,7 @@ export { type DurableObjectStateLike, type DurableObjectStorageLike, } from './cloudflare-durable-object.js'; +export { AgentNetworkDurableObject } from './cloudflare-durable-network.js'; export { createCustomerServiceSimExample } from './customer-service-sim.js'; export { createEmailExample } from './email.js'; export { createErrorRetryExample } from './error-retry.js'; @@ -22,6 +23,10 @@ export { createMultiAgentNetworkExample } from './multi-agent-network.js'; export { createNewspaperExample } from './newspaper.js'; export { createPlanAndExecuteExample } from './plan-and-execute.js'; export { createPersistenceExample, runPersistenceExample } from './persistence.js'; +export { + createPersistentMultiAgentNetworkExample, + runPersistentMultiAgentNetworkExample, +} from './persistent-multi-agent-network.js'; export { createRaffleExample } from './raffle.js'; export { createReactAgentExample } from './react-agent.js'; export { diff --git a/examples/persistent-multi-agent-network.ts b/examples/persistent-multi-agent-network.ts new file mode 100644 index 0000000..f3f12b6 --- /dev/null +++ b/examples/persistent-multi-agent-network.ts @@ -0,0 +1,111 @@ +import { + createMemoryRunStore, + restoreSession, + startSession, + type PersistedSnapshot, +} from '../src/index.js'; +import { createMultiAgentNetworkExample } from './multi-agent-network.js'; + +type NetworkOptions = Parameters[0]; + +export function createPersistentMultiAgentNetworkExample( + options: NetworkOptions = {} +) { + return createMultiAgentNetworkExample(options); +} + +export async function runPersistentMultiAgentNetworkExample( + input: { topic: string }, + options: NetworkOptions = {} +) { + const machine = createPersistentMultiAgentNetworkExample(options); + const baseStore = createMemoryRunStore(); + let persistedHandoffSnapshot = false; + + const store = { + append: baseStore.append, + loadEvents: baseStore.loadEvents, + loadLatestSnapshot: baseStore.loadLatestSnapshot, + async saveSnapshot( + snapshot: PersistedSnapshot + ) { + const handoffs = + ((snapshot.snapshot.context as { handoffs?: string[] }).handoffs ?? []); + + if (!persistedHandoffSnapshot && handoffs.length === 1) { + persistedHandoffSnapshot = true; + await baseStore.saveSnapshot(snapshot); + return; + } + + if (!persistedHandoffSnapshot && handoffs.length === 0) { + await baseStore.saveSnapshot(snapshot); + } + }, + }; + + const liveRun = await startSession(machine, { + store, + input, + }); + + await waitForTerminal(() => liveRun.getSnapshot().status); + + const restoredRun = await restoreSession(machine, { + sessionId: liveRun.sessionId, + store, + }); + + await waitForMatch( + () => restoredRun.getSnapshot(), + () => liveRun.getSnapshot() + ); + + return { + sessionId: liveRun.sessionId, + liveSnapshot: liveRun.getSnapshot(), + restoredSnapshot: restoredRun.getSnapshot(), + }; +} + +function expectTerminal(status: string) { + if (status !== 'done' && status !== 'error') { + throw new Error(`Snapshot is not terminal yet: ${status}`); + } +} + +async function waitForTerminal( + getStatus: () => string, + timeoutMs = 1000 +) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + expectTerminal(getStatus()); + return; + } catch {} + + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expectTerminal(getStatus()); +} + +async function waitForMatch( + getActual: () => T, + getExpected: () => T, + timeoutMs = 1000 +) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (JSON.stringify(getActual()) === JSON.stringify(getExpected())) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + if (JSON.stringify(getActual()) !== JSON.stringify(getExpected())) { + throw new Error('Snapshots did not converge before timeout.'); + } +} diff --git a/examples/tool-calling.ts b/examples/tool-calling.ts index 8472aae..719fcfb 100644 --- a/examples/tool-calling.ts +++ b/examples/tool-calling.ts @@ -15,15 +15,37 @@ const forecastSchema = z.object({ forecast: z.string(), }); +const toolProgressSchema = z.object({ + toolName: z.string(), + message: z.string(), + step: z.number().int().min(1), +}); + export function createToolCallingExample( - getWeather: (city: string) => Promise> = async ( - city - ) => - generateExampleObject({ + getWeather: ( + city: string, + emitProgress: (event: z.infer) => void + ) => Promise> = async ( + city, + emitProgress + ) => { + emitProgress({ + toolName: 'getWeather', + message: `Looking up current conditions for ${city}.`, + step: 1, + }); + emitProgress({ + toolName: 'getWeather', + message: `Formatting the forecast for ${city}.`, + step: 2, + }); + + return generateExampleObject({ schema: forecastSchema, system: 'You generate plausible demo weather forecasts.', prompt: `Return a short weather forecast for ${city}.`, - }) + }); + } ) { return createAgentMachine({ id: 'tool-calling-example', @@ -35,6 +57,7 @@ export function createToolCallingExample( toolName: z.string(), input: z.object({ city: z.string() }), }), + toolProgress: toolProgressSchema, toolResult: z.object({ toolName: z.string(), output: forecastSchema, @@ -56,7 +79,12 @@ export function createToolCallingExample( input: { city: context.city }, }); - const output = await getWeather(context.city); + const output = await getWeather(context.city, (progress) => { + enq.emit({ + type: 'toolProgress', + ...progress, + }); + }); enq.emit({ type: 'toolResult', @@ -92,6 +120,10 @@ async function main() { console.log(`Calling ${event.toolName}(${event.input.city})`); }); + run.on('toolProgress', (event) => { + console.log(`${event.toolName} [${event.step}] ${event.message}`); + }); + run.on('toolResult', (event) => { console.log(`${event.toolName} -> ${event.output.forecast}`); }); diff --git a/package.json b/package.json index 990cbd6..1db8271 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,9 @@ ], "author": "David Khourshid ", "license": "MIT", + "engines": { + "node": ">=22.18.0" + }, "devDependencies": { "@ai-sdk/openai": "^3.0.25", "@changesets/changelog-github": "^0.5.0", diff --git a/readme.md b/readme.md index 32ea42e..33c0838 100644 --- a/readme.md +++ b/readme.md @@ -15,7 +15,7 @@ The examples in [`examples/`](/Users/davidkpiano/Code/agent/examples) are intent Run them with `node --import tsx examples/.ts`. -Convert a machine file to diagram output with `pnpm agent:convert --format mermaid` or `pnpm agent:convert --format xstate`. Static analysis warnings are printed to stderr. +Convert a machine file to diagram output with `pnpm agent:convert --format mermaid` or `pnpm agent:convert --format xstate`. Static analysis warnings are printed to stderr. For programmatic access, use `analyzeGraph(...)` from `@statelyai/agent/graph`; warnings are returned explicitly instead of being hidden in graph metadata. Each example demonstrates one concept: @@ -23,6 +23,10 @@ Each example demonstrates one concept: - [`examples/hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts): a human-in-the-loop machine that pauses in a pending state, accepts typed events, and drafts with an LLM after approval - [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts): retrying transient invoke failures through explicit internal error events - [`examples/conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts): routing directly into one of multiple child subflows from parent input +- [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts): tool invocation with emitted `toolCall`, incremental `toolProgress`, and final `toolResult` events +- [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): persisting snapshots and restoring a session in plain runtime code +- [`examples/persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts): durable multi-agent handoffs that restore from a persisted mid-network snapshot +- [`examples/cloudflare-durable-network.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-network.ts): a Cloudflare Durable Object runner that starts and resumes a persisted multi-agent network - [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts): choosing the next branch with structured branch-specific payloads - [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts): routing into a fixed category set when you only need a label - [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts): plugging in a custom adapter that still uses a real OpenAI model under the hood diff --git a/scripts/agent-convert.ts b/scripts/agent-convert.ts index ef5773d..4142c29 100644 --- a/scripts/agent-convert.ts +++ b/scripts/agent-convert.ts @@ -1,7 +1,7 @@ import { writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { toGraph, toMermaid, type AgentGraphWarning } from '../src/graph/index.js'; +import { analyzeGraph, toMermaid, type AgentGraphWarning } from '../src/graph/index.js'; import type { AgentMachine } from '../src/index.js'; import { toXStateVisualization } from '../src/xstate/index.js'; @@ -25,8 +25,8 @@ async function main() { } const machine = await loadMachine(options); - const graph = toGraph(machine); - writeWarnings(graph.data?.warnings ?? []); + const analysis = analyzeGraph(machine); + writeWarnings(analysis.warnings); const output = options.format === 'mermaid' diff --git a/src/examples.test.ts b/src/examples.test.ts index da10335..65512aa 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -5,6 +5,7 @@ import { resolve } from 'node:path'; import { createChatbotExample, + AgentNetworkDurableObject, createDurableObjectRunStore, createAdapterExample, createBranchingExample, @@ -21,6 +22,7 @@ import { createMultiAgentNetworkExample, createNewspaperExample, runPersistenceExample, + runPersistentMultiAgentNetworkExample, createPlanAndExecuteExample, createRaffleExample, createReactAgentExample, @@ -47,6 +49,7 @@ describe('curated examples', () => { expect(existsSync(resolve(examplesDir, 'branching.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'chatbot.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'cloudflare-durable-object.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'cloudflare-durable-network.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'conditional-subflow.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'customer-service-sim.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'email.ts'))).toBe(true); @@ -57,6 +60,7 @@ describe('curated examples', () => { expect(existsSync(resolve(examplesDir, 'multi-agent-network.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'newspaper.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'persistence.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'persistent-multi-agent-network.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'plan-and-execute.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'raffle.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'react-agent.ts'))).toBe(true); @@ -153,6 +157,70 @@ describe('curated examples', () => { ); }); + test('cloudflare durable network example restores and settles a network run', async () => { + const storage = new Map(); + const firstInstance = new AgentNetworkDurableObject({ + storage: { + async get(key) { + return storage.get(key) as never; + }, + async put(key, value) { + storage.set(key, value); + }, + }, + }); + + const startResponse = await firstInstance.fetch( + new Request('https://example.com/start', { + method: 'POST', + body: JSON.stringify({ topic: 'durable networks' }), + }) + ); + const started = await startResponse.json() as { + sessionId: string; + snapshot: { status: string }; + }; + + const resumedInstance = new AgentNetworkDurableObject({ + storage: { + async get(key) { + return storage.get(key) as never; + }, + async put(key, value) { + storage.set(key, value); + }, + }, + }); + const resumeResponse = await resumedInstance.fetch( + new Request( + `https://example.com/resume?sessionId=${started.sessionId}`, + { method: 'POST' } + ) + ); + const resumed = await resumeResponse.json() as { + sessionId: string; + snapshot: { + status: string; + output: { + topic: string; + handoffs: string[]; + }; + }; + }; + + expect(started.sessionId).toBe(resumed.sessionId); + expect(resumed.snapshot.status).toBe('done'); + expect(resumed.snapshot.output).toEqual( + expect.objectContaining({ + topic: 'durable networks', + handoffs: [ + 'researcher:collect the strongest supporting facts', + 'writer:turn the current notes into a concise summary', + ], + }) + ); + }); + test('hitl example exposes typed pending events', async () => { const machine = createHitlExample(); const result = await machine.execute( @@ -276,6 +344,65 @@ describe('curated examples', () => { } }); + test('persistent multi-agent network example restores from a mid-handoff snapshot', async () => { + let step = 0; + const result = await runPersistentMultiAgentNetworkExample( + { topic: 'resumable coordination' }, + { + adapter: { + decide: async () => { + step += 1; + + if (step === 1) { + return { + choice: 'research', + data: { focus: 'collect durable coordination notes' }, + }; + } + + if (step === 2) { + return { + choice: 'write', + data: { angle: 'produce the final coordination memo' }, + }; + } + + return { + choice: 'finalize', + data: {}, + }; + }, + }, + research: async ({ topic, focus }) => ({ + notes: [`${topic}:${focus}:a`, `${topic}:${focus}:b`], + }), + write: async ({ topic, notes, angle }) => ({ + draft: `${topic} | ${angle} | ${notes.join(' / ')}`, + }), + } + ); + + expect(result.restoredSnapshot).toEqual(result.liveSnapshot); + expect(result.liveSnapshot).toEqual( + expect.objectContaining({ + value: 'done', + output: { + topic: 'resumable coordination', + notes: [ + 'resumable coordination:collect durable coordination notes:a', + 'resumable coordination:collect durable coordination notes:b', + ], + draft: + 'resumable coordination | produce the final coordination memo | resumable coordination:collect durable coordination notes:a / resumable coordination:collect durable coordination notes:b', + handoffs: [ + 'researcher:collect durable coordination notes', + 'writer:produce the final coordination memo', + ], + }, + }) + ); + }); + test('branching example fans out plain async work and summarizes it', async () => { const machine = createBranchingExample({ analyzeDocs: async () => 'docs', @@ -668,9 +795,22 @@ describe('curated examples', () => { }); test('tool-calling example emits live tool activity and completes with output', async () => { - const machine = createToolCallingExample(async (city) => ({ - forecast: `Rainy in ${city}`, - })); + const machine = createToolCallingExample(async (city, emitProgress) => { + emitProgress({ + toolName: 'getWeather', + message: `Checking radar for ${city}`, + step: 1, + }); + emitProgress({ + toolName: 'getWeather', + message: `Preparing forecast for ${city}`, + step: 2, + }); + + return { + forecast: `Rainy in ${city}`, + }; + }); const { createMemoryRunStore, startSession } = await import('./index.js'); const run = await startSession(machine, { @@ -682,6 +822,9 @@ describe('curated examples', () => { run.on('toolCall', (event) => { events.push(`call:${event.toolName}`); }); + run.on('toolProgress', (event) => { + events.push(`progress:${event.toolName}:${event.step}`); + }); run.on('toolResult', (event) => { events.push(`result:${event.toolName}`); }); @@ -691,7 +834,12 @@ describe('curated examples', () => { run.onError((event) => reject(event.error)); }); - expect(events).toEqual(['call:getWeather', 'result:getWeather']); + expect(events).toEqual([ + 'call:getWeather', + 'progress:getWeather:1', + 'progress:getWeather:2', + 'result:getWeather', + ]); expect(run.getSnapshot()).toEqual( expect.objectContaining({ output: { forecast: 'Rainy in New York' }, diff --git a/src/graph/index.test.ts b/src/graph/index.test.ts index ba3c897..b9b7090 100644 --- a/src/graph/index.test.ts +++ b/src/graph/index.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { z } from 'zod'; import { createAgentMachine } from '../index.js'; -import { toGraph, toMermaid } from './index.js'; +import { analyzeGraph, toGraph, toMermaid } from './index.js'; declare function unknownTransition(): { target: 'done' }; @@ -205,7 +205,7 @@ test('reports graph warnings for unsupported transition analysis', () => { }, }); - expect(toGraph(machine).data?.warnings).toEqual([ + expect(analyzeGraph(machine).warnings).toEqual([ { state: 'idle', event: 'go', @@ -213,6 +213,63 @@ test('reports graph warnings for unsupported transition analysis', () => { 'Unsupported helper call: unknownTransition() is not statically resolvable.', }, ]); + expect(toGraph(machine).data).toBeUndefined(); +}); + +test('resolves simple helper calls with arguments in guards and targets', () => { + const machine = createAgentMachine({ + id: 'helper-args-export', + schemas: { + events: { + choose: z.object({ + type: z.literal('choose'), + kind: z.enum(['approved', 'rejected']), + }), + }, + }, + context: () => ({}), + initial: 'idle', + states: { + idle: { + on: { + choose: ({ event }) => { + function goTo( + target: 'approved' | 'rejected', + reason: string + ) { + return { + target, + context: { reason }, + }; + } + + return event.kind === 'approved' + ? goTo('approved', 'explicit approval path') + : goTo('rejected', 'explicit rejection path'); + }, + }, + }, + approved: { type: 'final' }, + rejected: { type: 'final' }, + }, + }); + + expect(toGraph(machine).edges).toEqual([ + expect.objectContaining({ + targetId: 'approved', + data: expect.objectContaining({ + guard: { type: 'event.kind === "approved"' }, + actions: { context: true }, + }), + }), + expect.objectContaining({ + targetId: 'rejected', + data: expect.objectContaining({ + guard: { type: '!(event.kind === "approved")' }, + actions: { context: true }, + }), + }), + ]); }); test('exports a mermaid state diagram from the Stately graph data', () => { diff --git a/src/graph/index.ts b/src/graph/index.ts index 4a7dfe4..842a1f5 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -38,7 +38,6 @@ export interface AgentGraphEdgeData { } export interface AgentGraphData { - warnings?: AgentGraphWarning[]; } export interface AgentGraphWarning { @@ -47,6 +46,11 @@ export interface AgentGraphWarning { message: string; } +export interface AgentGraphAnalysis { + graph: AgentGraph; + warnings: AgentGraphWarning[]; +} + export interface AgentGraph extends StatelyGraph {} export interface AgentGraphNode @@ -80,6 +84,8 @@ type AnalyzableFunction = | ts.FunctionDeclaration; type HelperMap = Map; +type BindingMap = Map; +const printer = ts.createPrinter({ removeComments: true }); /** * Convert an agent machine to a Stately graph-compatible plain JSON object. @@ -88,6 +94,10 @@ type HelperMap = Map; * inferred from static transition objects and transition handler ASTs. */ export function toGraph(machine: AgentMachine): AgentGraph { + return analyzeGraph(machine).graph; +} + +export function analyzeGraph(machine: AgentMachine): AgentGraphAnalysis { const config = (machine as InternalMachine).__config; if (!config) { throw new Error('Machine config metadata is unavailable for graph export'); @@ -138,14 +148,18 @@ export function toGraph(machine: AgentMachine): AgentGraph { } } - return createGraph({ + const graph = createGraph({ id: machine.id, initialNodeId: typeof config.initial === 'string' ? config.initial : undefined, - ...(warnings.length > 0 ? { data: { warnings } } : {}), nodes, edges, }); + + return { + graph, + warnings, + }; } export function toMermaid(machine: AgentMachine): string { @@ -296,7 +310,9 @@ function analyzeTransitionObject(transition: unknown): AnalysisResult { } function analyzeTransitionFunction(fn: Function): AnalysisResult { - const source = fn.toString(); + const source = fn + .toString() + .replace(/__name\([^)]*\);?/g, ''); const file = ts.createSourceFile( 'transition.ts', `const __transition = ${source};`, @@ -320,7 +336,8 @@ function analyzeTransitionFunction(fn: Function): AnalysisResult { transitionFunction.body, [], file, - helpers + helpers, + new Map() ); } @@ -329,7 +346,8 @@ function analyzeTransitionFunction(fn: Function): AnalysisResult { transitionFunction.body.statements, [], file, - helpers + helpers, + new Map() ); } @@ -377,36 +395,39 @@ function collectHelpers(fn: AnalyzableFunction): HelperMap { return helpers; } - for (const statement of fn.body.statements) { + function visit(node: ts.Node) { if ( - ts.isFunctionDeclaration(statement) - && statement.name - && statement.body + node !== fn.body + && isAnalyzableFunction(node) ) { - helpers.set(statement.name.text, statement); - continue; + return; } - if (!ts.isVariableStatement(statement)) { - continue; + if (ts.isFunctionDeclaration(node) && node.name && node.body) { + helpers.set(node.name.text, node); + return; } - for (const declaration of statement.declarationList.declarations) { - if (!ts.isIdentifier(declaration.name) || !declaration.initializer) { - continue; + if (ts.isVariableDeclaration(node)) { + if (!ts.isIdentifier(node.name) || !node.initializer) { + return; } - const initializer = unwrapParenthesized(declaration.initializer); + const initializer = unwrapParenthesized(node.initializer); if ( isAnalyzableFunction(initializer) || ts.isObjectLiteralExpression(initializer) || ts.isConditionalExpression(initializer) ) { - helpers.set(declaration.name.text, initializer); + helpers.set(node.name.text, initializer); } } + + ts.forEachChild(node, visit); } + visit(fn.body); + return helpers; } @@ -414,14 +435,21 @@ function analyzeStatements( statements: ts.NodeArray, guards: string[], file: ts.SourceFile, - helpers: HelperMap + helpers: HelperMap, + bindings: BindingMap ): BlockAnalysis { const candidates: EdgeCandidate[] = []; const warnings: string[] = []; const fallthroughGuards = [...guards]; for (const statement of statements) { - const result = analyzeStatement(statement, fallthroughGuards, file, helpers); + const result = analyzeStatement( + statement, + fallthroughGuards, + file, + helpers, + bindings + ); candidates.push(...result.candidates); warnings.push(...result.warnings); @@ -434,7 +462,9 @@ function analyzeStatements( && isReturnOnlyBranch(statement.thenStatement) && !statement.elseStatement ) { - fallthroughGuards.push(`!(${statement.expression.getText(file)})`); + fallthroughGuards.push( + `!(${renderExpressionText(statement.expression, file, bindings)})` + ); } } @@ -445,7 +475,8 @@ function analyzeStatement( statement: ts.Statement, guards: string[], file: ts.SourceFile, - helpers: HelperMap + helpers: HelperMap, + bindings: BindingMap ): BlockAnalysis { if (ts.isReturnStatement(statement)) { if (!statement.expression) { @@ -460,7 +491,8 @@ function analyzeStatement( statement.expression, guards, file, - helpers + helpers, + bindings ); return { @@ -476,11 +508,11 @@ function analyzeStatement( } if (ts.isIfStatement(statement)) { - return analyzeIfStatement(statement, guards, file, helpers); + return analyzeIfStatement(statement, guards, file, helpers, bindings); } if (ts.isSwitchStatement(statement)) { - return analyzeSwitchStatement(statement, guards, file, helpers); + return analyzeSwitchStatement(statement, guards, file, helpers, bindings); } if ( @@ -502,21 +534,24 @@ function analyzeIfStatement( statement: ts.IfStatement, guards: string[], file: ts.SourceFile, - helpers: HelperMap + helpers: HelperMap, + bindings: BindingMap ): BlockAnalysis { - const condition = statement.expression.getText(file); + const condition = renderExpressionText(statement.expression, file, bindings); const thenResult = analyzeBranch( statement.thenStatement, [...guards, condition], file, - helpers + helpers, + bindings ); const elseResult = statement.elseStatement ? analyzeBranch( statement.elseStatement, [...guards, `!(${condition})`], file, - helpers + helpers, + bindings ) : emptyBlockAnalysis(); @@ -531,11 +566,12 @@ function analyzeSwitchStatement( statement: ts.SwitchStatement, guards: string[], file: ts.SourceFile, - helpers: HelperMap + helpers: HelperMap, + bindings: BindingMap ): BlockAnalysis { const candidates: EdgeCandidate[] = []; const warnings: string[] = []; - const expression = statement.expression.getText(file); + const expression = renderExpressionText(statement.expression, file, bindings); const caseGuards: string[] = []; let allClausesExit = statement.caseBlock.clauses.length > 0; @@ -554,7 +590,8 @@ function analyzeSwitchStatement( clause.statements, clauseGuard ? [...guards, clauseGuard] : guards, file, - helpers + helpers, + bindings ); candidates.push(...result.candidates); warnings.push(...result.warnings); @@ -572,13 +609,14 @@ function analyzeBranch( statement: ts.Statement, guards: string[], file: ts.SourceFile, - helpers: HelperMap + helpers: HelperMap, + bindings: BindingMap ): BlockAnalysis { if (ts.isBlock(statement)) { - return analyzeStatements(statement.statements, guards, file, helpers); + return analyzeStatements(statement.statements, guards, file, helpers, bindings); } - return analyzeStatement(statement, guards, file, helpers); + return analyzeStatement(statement, guards, file, helpers, bindings); } function emptyBlockAnalysis(): BlockAnalysis { @@ -606,25 +644,28 @@ function analyzeTransitionExpression( expression: ts.Expression, guards: string[], file: ts.SourceFile, - helpers: HelperMap + helpers: HelperMap, + bindings: BindingMap ): AnalysisResult { const current = unwrapParenthesized(expression); if (ts.isConditionalExpression(current)) { - const condition = current.condition.getText(file); + const condition = renderExpressionText(current.condition, file, bindings); return mergeAnalysis([ analyzeTransitionExpression( current.whenTrue, [...guards, condition], file, - helpers + helpers, + bindings ), analyzeTransitionExpression( current.whenFalse, [...guards, `!(${condition})`], file, - helpers + helpers, + bindings ), ]); } @@ -632,23 +673,34 @@ function analyzeTransitionExpression( if ( ts.isCallExpression(current) && ts.isIdentifier(current.expression) - && current.arguments.length === 0 ) { - const helper = helpers.get(current.expression.text); + const fallbackHelper = findHelperByName(file, current.expression.text); + const helper = + helpers.get(current.expression.text) + ?? fallbackHelper; if (!helper) { return { candidates: [], warnings: [ - `Unsupported helper call: ${current.expression.text}() is not statically resolvable.`, + `Unsupported helper call: ${current.expression.text}(${current.arguments.map((arg) => renderExpressionText(arg, file, bindings)).join(', ')}) is not statically resolvable.`, ], }; } - return analyzeHelper(helper, guards, file, helpers); + return analyzeHelper( + helper, + current.arguments, + guards, + file, + helpers, + bindings + ); } const object = unwrapParenthesizedObject(current); - const target = object ? getStringProperty(object, 'target') : undefined; + const target = object + ? getStringProperty(object, 'target', file, bindings) + : undefined; if (!target) { return { candidates: [], warnings: [] }; } @@ -666,17 +718,41 @@ function analyzeTransitionExpression( function analyzeHelper( helper: AnalyzableFunction | ts.Expression, + args: ts.NodeArray, guards: string[], file: ts.SourceFile, - helpers: HelperMap + helpers: HelperMap, + bindings: BindingMap ): AnalysisResult { if (isAnalyzableFunction(helper)) { + const helperBindings = createBindings(helper, args, bindings); + if (!helperBindings) { + return { + candidates: [], + warnings: [ + `Unsupported helper call: argument count for ${getHelperName(helper)}(...) could not be matched.`, + ], + }; + } + if (ts.isArrowFunction(helper) && !ts.isBlock(helper.body)) { - return analyzeTransitionExpression(helper.body, guards, file, helpers); + return analyzeTransitionExpression( + helper.body, + guards, + file, + helpers, + helperBindings + ); } if (helper.body && ts.isBlock(helper.body)) { - const result = analyzeStatements(helper.body.statements, guards, file, helpers); + const result = analyzeStatements( + helper.body.statements, + guards, + file, + helpers, + helperBindings + ); return { candidates: result.candidates, warnings: result.warnings, @@ -685,7 +761,14 @@ function analyzeHelper( } if (ts.isExpression(helper)) { - return analyzeTransitionExpression(helper, guards, file, helpers); + if (args.length > 0) { + return { + candidates: [], + warnings: ['Unsupported helper call: non-function helper cannot accept arguments.'], + }; + } + + return analyzeTransitionExpression(helper, guards, file, helpers, bindings); } return { @@ -711,6 +794,50 @@ function unwrapParenthesized(expression: T): ts.Express return current; } +function findHelperByName( + file: ts.SourceFile, + name: string +): AnalyzableFunction | ts.Expression | undefined { + let helper: AnalyzableFunction | ts.Expression | undefined; + + function visit(node: ts.Node) { + if (helper) { + return; + } + + if ( + ts.isFunctionDeclaration(node) + && node.name?.text === name + && node.body + ) { + helper = node; + return; + } + + if (ts.isVariableDeclaration(node)) { + if (!ts.isIdentifier(node.name) || node.name.text !== name || !node.initializer) { + ts.forEachChild(node, visit); + return; + } + + const initializer = unwrapParenthesized(node.initializer); + if ( + isAnalyzableFunction(initializer) + || ts.isObjectLiteralExpression(initializer) + || ts.isConditionalExpression(initializer) + ) { + helper = initializer; + return; + } + } + + ts.forEachChild(node, visit); + } + + visit(file); + return helper; +} + function unwrapParenthesizedObject( expression: ts.Expression ): ts.ObjectLiteralExpression | undefined { @@ -725,28 +852,44 @@ function unwrapParenthesizedObject( function getStringProperty( object: ts.ObjectLiteralExpression, - name: string + name: string, + file: ts.SourceFile, + bindings: BindingMap ): string | undefined { const property = object.properties.find((candidate) => { return ( - ts.isPropertyAssignment(candidate) + (ts.isPropertyAssignment(candidate) + || ts.isShorthandPropertyAssignment(candidate)) && ts.isIdentifier(candidate.name) && candidate.name.text === name ); }); - if (!property || !ts.isPropertyAssignment(property)) { + if (!property) { return undefined; } - const initializer = property.initializer; - return ts.isStringLiteralLike(initializer) ? initializer.text : undefined; + if (ts.isPropertyAssignment(property)) { + return resolveStringExpression(property.initializer, file, bindings); + } + + if (ts.isShorthandPropertyAssignment(property)) { + const binding = bindings.get(property.name.text); + if (!binding) { + return property.name.text; + } + + return resolveStringExpression(binding, file, bindings); + } + + return undefined; } function hasProperty(object: ts.ObjectLiteralExpression, name: string): boolean { return object.properties.some((candidate) => { return ( - ts.isPropertyAssignment(candidate) + (ts.isPropertyAssignment(candidate) + || ts.isShorthandPropertyAssignment(candidate)) && ts.isIdentifier(candidate.name) && candidate.name.text === name ); @@ -761,6 +904,99 @@ function getEdgeLabel(event: string, guard: string | undefined): string { return `${event} [${guard}]`; } +function createBindings( + helper: AnalyzableFunction, + args: ts.NodeArray, + parentBindings: BindingMap +): BindingMap | null { + if (args.length > helper.parameters.length) { + return null; + } + + const bindings = new Map(parentBindings); + helper.parameters.forEach((parameter, index) => { + if (!ts.isIdentifier(parameter.name)) { + return; + } + + const arg = args[index]; + if (arg) { + bindings.set(parameter.name.text, substituteExpression(arg, parentBindings)); + } + }); + + return bindings; +} + +function getHelperName(helper: AnalyzableFunction): string { + if (helper.name) { + return helper.name.text; + } + + return 'helper'; +} + +function resolveStringExpression( + expression: ts.Expression, + file: ts.SourceFile, + bindings: BindingMap +): string | undefined { + const current = substituteExpression(unwrapParenthesized(expression), bindings); + + if (ts.isStringLiteralLike(current)) { + return current.text; + } + + if (ts.isNoSubstitutionTemplateLiteral(current)) { + return current.text; + } + + if (ts.isIdentifier(current)) { + return current.text; + } + + const rendered = renderExpressionText(current, file, bindings); + return /^["'`](.*)["'`]$/s.test(rendered) + ? rendered.slice(1, -1) + : undefined; +} + +function renderExpressionText( + expression: ts.Expression, + file: ts.SourceFile, + bindings: BindingMap +): string { + const substituted = substituteExpression(unwrapParenthesized(expression), bindings); + return printer.printNode(ts.EmitHint.Unspecified, substituted, file); +} + +function substituteExpression( + expression: ts.Expression, + bindings: BindingMap +): ts.Expression { + if (bindings.size === 0) { + return expression; + } + + const transformed = ts.transform(expression, [ + (context) => { + const visit: ts.Visitor = (node) => { + if (ts.isIdentifier(node) && bindings.has(node.text)) { + return substituteExpression(bindings.get(node.text)!, bindings); + } + + return ts.visitEachChild(node, visit, context); + }; + + return (node) => ts.visitNode(node, visit) as ts.Expression; + }, + ]); + + const substituted = transformed.transformed[0] as ts.Expression; + transformed.dispose(); + return substituted; +} + function combineGuardList(guards: string[]): string | undefined { if (guards.length === 0) { return undefined; diff --git a/src/langgraph-equivalents/persistent-multi-agent-network.test.ts b/src/langgraph-equivalents/persistent-multi-agent-network.test.ts new file mode 100644 index 0000000..863d993 --- /dev/null +++ b/src/langgraph-equivalents/persistent-multi-agent-network.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from 'vitest'; +import { runPersistentMultiAgentNetworkExample } from '../../examples/index.js'; + +test('restores a multi-agent handoff workflow from a persisted mid-handoff snapshot', async () => { + let step = 0; + + const result = await runPersistentMultiAgentNetworkExample( + { topic: 'durable agent handoffs' }, + { + adapter: { + decide: async () => { + step += 1; + + if (step === 1) { + return { + choice: 'research', + data: { focus: 'collect the most durable architecture notes' }, + }; + } + + if (step === 2) { + return { + choice: 'write', + data: { angle: 'summarize the handoff-ready findings' }, + }; + } + + return { + choice: 'finalize', + data: {}, + }; + }, + }, + research: async ({ topic, focus }) => ({ + notes: [`${topic}:${focus}:1`, `${topic}:${focus}:2`], + }), + write: async ({ topic, notes, angle }) => ({ + draft: `${topic} | ${angle} | ${notes.join(' / ')}`, + }), + } + ); + + expect(result.restoredSnapshot).toEqual(result.liveSnapshot); + expect(result.restoredSnapshot).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { + topic: 'durable agent handoffs', + notes: [ + 'durable agent handoffs:collect the most durable architecture notes:1', + 'durable agent handoffs:collect the most durable architecture notes:2', + ], + draft: + 'durable agent handoffs | summarize the handoff-ready findings | durable agent handoffs:collect the most durable architecture notes:1 / durable agent handoffs:collect the most durable architecture notes:2', + handoffs: [ + 'researcher:collect the most durable architecture notes', + 'writer:summarize the handoff-ready findings', + ], + }, + }) + ); +}); diff --git a/src/langgraph-equivalents/tool-calling.test.ts b/src/langgraph-equivalents/tool-calling.test.ts index 2a54431..6107041 100644 --- a/src/langgraph-equivalents/tool-calling.test.ts +++ b/src/langgraph-equivalents/tool-calling.test.ts @@ -27,6 +27,11 @@ test('supports tool-call style invokes with live tool events and final output', toolName: z.string(), input: z.object({ city: z.string() }), }), + toolProgress: z.object({ + toolName: z.string(), + message: z.string(), + step: z.number().int().min(1), + }), toolResult: z.object({ toolName: z.string(), output: z.object({ forecast: z.string() }), @@ -49,6 +54,20 @@ test('supports tool-call style invokes with live tool events and final output', input: { city: context.city }, }); + enq.emit({ + type: 'toolProgress', + toolName: 'getWeather', + message: `Fetching weather for ${context.city}`, + step: 1, + }); + + enq.emit({ + type: 'toolProgress', + toolName: 'getWeather', + message: `Formatting response for ${context.city}`, + step: 2, + }); + const output = { forecast: `Sunny in ${context.city}` }; enq.emit({ type: 'toolResult', @@ -79,13 +98,21 @@ test('supports tool-call style invokes with live tool events and final output', run.on('toolCall', (event) => { events.push(`call:${event.toolName}`); }); + run.on('toolProgress', (event) => { + events.push(`progress:${event.toolName}:${event.step}`); + }); run.on('toolResult', (event) => { events.push(`result:${event.toolName}`); }); await once(run.onDone.bind(run)); - expect(events).toEqual(['call:getWeather', 'result:getWeather']); + expect(events).toEqual([ + 'call:getWeather', + 'progress:getWeather:1', + 'progress:getWeather:2', + 'result:getWeather', + ]); expect(run.getSnapshot()).toEqual( expect.objectContaining({ value: 'done', From e7ef00a39fae37515ca059fb5ca46b1a0c218901 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 24 Apr 2026 08:16:24 -0400 Subject: [PATCH 24/50] feat: add durable supervisor and streaming examples --- examples/index.ts | 8 + examples/persistent-streaming.ts | 138 ++++++++++++++++++ examples/persistent-supervisor.ts | 117 +++++++++++++++ readme.md | 2 + src/examples.test.ts | 89 +++++++++++ .../persistent-streaming.test.ts | 26 ++++ .../persistent-supervisor.test.ts | 63 ++++++++ 7 files changed, 443 insertions(+) create mode 100644 examples/persistent-streaming.ts create mode 100644 examples/persistent-supervisor.ts create mode 100644 src/langgraph-equivalents/persistent-streaming.test.ts create mode 100644 src/langgraph-equivalents/persistent-supervisor.test.ts diff --git a/examples/index.ts b/examples/index.ts index d43e598..7b5217e 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -27,6 +27,14 @@ export { createPersistentMultiAgentNetworkExample, runPersistentMultiAgentNetworkExample, } from './persistent-multi-agent-network.js'; +export { + createPersistentStreamingExample, + runPersistentStreamingExample, +} from './persistent-streaming.js'; +export { + createPersistentSupervisorExample, + runPersistentSupervisorExample, +} from './persistent-supervisor.js'; export { createRaffleExample } from './raffle.js'; export { createReactAgentExample } from './react-agent.js'; export { diff --git a/examples/persistent-streaming.ts b/examples/persistent-streaming.ts new file mode 100644 index 0000000..41545d6 --- /dev/null +++ b/examples/persistent-streaming.ts @@ -0,0 +1,138 @@ +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + restoreSession, + startSession, +} from '../src/index.js'; + +const textSchema = z.object({ + text: z.string(), +}); + +const textPartSchema = z.object({ + delta: z.string(), +}); + +export function createPersistentStreamingExample( + writeText: (emitPart: (delta: string) => void) => Promise> = (() => { + const chunks = ['hel', 'lo']; + let cursor = 0; + let attempts = 0; + + return async (emitPart) => { + attempts += 1; + + if (attempts === 1) { + emitPart(chunks[cursor++]!); + await new Promise(() => {}); + } + + while (cursor < chunks.length) { + emitPart(chunks[cursor++]!); + } + + return { text: chunks.join('') }; + }; + })() +) { + return createAgentMachine({ + id: 'persistent-streaming-example', + schemas: { + output: textSchema, + emitted: { + textPart: textPartSchema, + }, + }, + context: () => ({ + finalText: '', + }), + initial: 'writing', + states: { + writing: { + resultSchema: textSchema, + invoke: async (_args, enq) => + writeText((delta) => { + enq.emit({ type: 'textPart', delta }); + }), + onDone: ({ result }) => ({ + target: 'done', + context: { finalText: result.text }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ text: context.finalText }), + }, + }, + }); +} + +export async function runPersistentStreamingExample( + writeText?: (emitPart: (delta: string) => void) => Promise> +) { + const machine = createPersistentStreamingExample(writeText); + const store = createMemoryRunStore(); + const initialRun = await startSession(machine, { store }); + const initialParts: string[] = []; + + initialRun.on('textPart', (event) => { + initialParts.push(event.delta); + }); + + await waitFor( + () => initialParts.length >= 1 && initialRun.getSnapshot().status === 'active' + ); + + const restoredRun = await restoreSession(machine, { + sessionId: initialRun.sessionId, + store, + }); + const restoredParts: string[] = []; + + restoredRun.on('textPart', (event) => { + restoredParts.push(event.delta); + }); + + await once(restoredRun.onDone.bind(restoredRun)); + + return { + sessionId: initialRun.sessionId, + initialParts, + restoredParts, + initialSnapshot: initialRun.getSnapshot(), + restoredSnapshot: restoredRun.getSnapshot(), + journal: await store.loadEvents(initialRun.sessionId), + }; +} + +function once( + subscribe: (handler: (event: T) => void) => () => void +) { + return new Promise((resolve) => { + let off = () => {}; + off = subscribe((event) => { + off(); + resolve(event); + }); + }); +} + +async function waitFor( + predicate: () => boolean, + timeoutMs = 1000 +) { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + if (predicate()) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + if (!predicate()) { + throw new Error('Condition did not become true before timeout.'); + } +} diff --git a/examples/persistent-supervisor.ts b/examples/persistent-supervisor.ts new file mode 100644 index 0000000..9bbd343 --- /dev/null +++ b/examples/persistent-supervisor.ts @@ -0,0 +1,117 @@ +import { + createMemoryRunStore, + restoreSession, + startSession, + type PersistedSnapshot, +} from '../src/index.js'; +import { createSupervisorExample } from './supervisor.js'; + +type SupervisorOptions = Parameters[0]; + +export function createPersistentSupervisorExample( + options: SupervisorOptions = {} +) { + return createSupervisorExample(options); +} + +export async function runPersistentSupervisorExample( + input: { request: string }, + options: SupervisorOptions = {} +) { + const machine = createPersistentSupervisorExample(options); + const baseStore = createMemoryRunStore(); + let persistedRetryHandoff = false; + + const store = { + append: baseStore.append, + loadEvents: baseStore.loadEvents, + loadLatestSnapshot: baseStore.loadLatestSnapshot, + async saveSnapshot(snapshot: PersistedSnapshot) { + const context = snapshot.snapshot.context as { + attemptCount?: number; + history?: string[]; + }; + const history = context.history ?? []; + + if ( + !persistedRetryHandoff + && snapshot.snapshot.value === 'handling' + && context.attemptCount === 1 + && history.some((entry) => entry.startsWith('supervisor:retry:')) + ) { + persistedRetryHandoff = true; + await baseStore.saveSnapshot(snapshot); + return; + } + + if (!persistedRetryHandoff) { + await baseStore.saveSnapshot(snapshot); + } + }, + }; + + const liveRun = await startSession(machine, { + store, + input, + }); + + await waitForTerminal(() => liveRun.getSnapshot().status); + + const restoredRun = await restoreSession(machine, { + sessionId: liveRun.sessionId, + store, + }); + + await waitForMatch( + () => restoredRun.getSnapshot(), + () => liveRun.getSnapshot() + ); + + return { + sessionId: liveRun.sessionId, + liveSnapshot: liveRun.getSnapshot(), + restoredSnapshot: restoredRun.getSnapshot(), + }; +} + +function expectTerminal(status: string) { + if (status !== 'done' && status !== 'error') { + throw new Error(`Snapshot is not terminal yet: ${status}`); + } +} + +async function waitForTerminal( + getStatus: () => string, + timeoutMs = 1000 +) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + expectTerminal(getStatus()); + return; + } catch {} + + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expectTerminal(getStatus()); +} + +async function waitForMatch( + getActual: () => T, + getExpected: () => T, + timeoutMs = 1000 +) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (JSON.stringify(getActual()) === JSON.stringify(getExpected())) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + if (JSON.stringify(getActual()) !== JSON.stringify(getExpected())) { + throw new Error('Snapshots did not converge before timeout.'); + } +} diff --git a/readme.md b/readme.md index 33c0838..1c15c23 100644 --- a/readme.md +++ b/readme.md @@ -26,6 +26,8 @@ Each example demonstrates one concept: - [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts): tool invocation with emitted `toolCall`, incremental `toolProgress`, and final `toolResult` events - [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): persisting snapshots and restoring a session in plain runtime code - [`examples/persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts): durable multi-agent handoffs that restore from a persisted mid-network snapshot +- [`examples/persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts): restore a crashed streaming invoke and continue with only new live emitted parts +- [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts): durable supervisor routing that restores after a persisted retry handoff - [`examples/cloudflare-durable-network.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-network.ts): a Cloudflare Durable Object runner that starts and resumes a persisted multi-agent network - [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts): choosing the next branch with structured branch-specific payloads - [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts): routing into a fixed category set when you only need a label diff --git a/src/examples.test.ts b/src/examples.test.ts index 65512aa..c9c247c 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -23,6 +23,8 @@ import { createNewspaperExample, runPersistenceExample, runPersistentMultiAgentNetworkExample, + runPersistentStreamingExample, + runPersistentSupervisorExample, createPlanAndExecuteExample, createRaffleExample, createReactAgentExample, @@ -61,6 +63,8 @@ describe('curated examples', () => { expect(existsSync(resolve(examplesDir, 'newspaper.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'persistence.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'persistent-multi-agent-network.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'persistent-streaming.ts'))).toBe(true); + expect(existsSync(resolve(examplesDir, 'persistent-supervisor.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'plan-and-execute.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'raffle.ts'))).toBe(true); expect(existsSync(resolve(examplesDir, 'react-agent.ts'))).toBe(true); @@ -403,6 +407,91 @@ describe('curated examples', () => { ); }); + test('persistent supervisor example restores from a persisted retry handoff', async () => { + let decisions = 0; + + const result = await runPersistentSupervisorExample( + { request: 'Reverse the duplicate subscription charge.' }, + { + adapter: { + decide: async () => { + decisions += 1; + + if (decisions === 1) { + return { + choice: 'retry', + data: { + instruction: 'Retry using the verified billing email on file.', + }, + }; + } + + return { + choice: 'escalate', + data: { + reason: 'Escalate to billing because the account is still ambiguous.', + }, + }; + }, + }, + handle: async ({ attempt, instruction }) => ({ + status: 'blocked' as const, + issue: + attempt === 1 + ? 'Missing account identifier.' + : `Still blocked after retry: ${instruction}`, + }), + maxAttempts: 2, + } + ); + + expect(result.liveSnapshot).toEqual(result.restoredSnapshot); + expect(result.liveSnapshot).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { + request: 'Reverse the duplicate subscription charge.', + status: 'escalated', + resolution: null, + escalationReason: + 'Escalate to billing because the account is still ambiguous.', + attemptCount: 2, + history: [ + 'worker:1:blocked:Missing account identifier.', + 'supervisor:retry:Retry using the verified billing email on file.', + 'worker:2:blocked:Still blocked after retry: Retry using the verified billing email on file.', + 'supervisor:escalate:Escalate to billing because the account is still ambiguous.', + ], + }, + }) + ); + }); + + test('persistent streaming example resumes with only new live parts after restore', async () => { + const result = await runPersistentStreamingExample(); + + expect(result.initialParts).toEqual(['hel']); + expect(result.restoredParts).toEqual(['lo']); + expect(result.initialSnapshot).toEqual( + expect.objectContaining({ + value: 'writing', + status: 'active', + }) + ); + expect(result.restoredSnapshot).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { text: 'hello' }, + }) + ); + expect(result.journal.map((event) => event.type)).toEqual([ + 'xstate.init', + 'xstate.done.invoke.writing', + ]); + }); + test('branching example fans out plain async work and summarizes it', async () => { const machine = createBranchingExample({ analyzeDocs: async () => 'docs', diff --git a/src/langgraph-equivalents/persistent-streaming.test.ts b/src/langgraph-equivalents/persistent-streaming.test.ts new file mode 100644 index 0000000..386e92e --- /dev/null +++ b/src/langgraph-equivalents/persistent-streaming.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'vitest'; +import { runPersistentStreamingExample } from '../../examples/index.js'; + +test('restores a streaming workflow without replaying stale emitted parts', async () => { + const result = await runPersistentStreamingExample(); + + expect(result.initialParts).toEqual(['hel']); + expect(result.restoredParts).toEqual(['lo']); + expect(result.initialSnapshot).toEqual( + expect.objectContaining({ + value: 'writing', + status: 'active', + }) + ); + expect(result.restoredSnapshot).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { text: 'hello' }, + }) + ); + expect(result.journal.map((event) => event.type)).toEqual([ + 'xstate.init', + 'xstate.done.invoke.writing', + ]); +}); diff --git a/src/langgraph-equivalents/persistent-supervisor.test.ts b/src/langgraph-equivalents/persistent-supervisor.test.ts new file mode 100644 index 0000000..4885808 --- /dev/null +++ b/src/langgraph-equivalents/persistent-supervisor.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from 'vitest'; +import { runPersistentSupervisorExample } from '../../examples/index.js'; + +test('restores a supervisor handoff workflow from a persisted retry snapshot', async () => { + let decisions = 0; + + const result = await runPersistentSupervisorExample( + { request: 'Reverse the duplicate subscription charge.' }, + { + adapter: { + decide: async () => { + decisions += 1; + + if (decisions === 1) { + return { + choice: 'retry', + data: { + instruction: 'Retry using the verified billing email on file.', + }, + }; + } + + return { + choice: 'escalate', + data: { + reason: 'Escalate to billing because the account is still ambiguous.', + }, + }; + }, + }, + handle: async ({ attempt, instruction }) => ({ + status: 'blocked', + issue: + attempt === 1 + ? 'Missing account identifier.' + : `Still blocked after retry: ${instruction}`, + }), + maxAttempts: 2, + } + ); + + expect(result.restoredSnapshot).toEqual(result.liveSnapshot); + expect(result.restoredSnapshot).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { + request: 'Reverse the duplicate subscription charge.', + status: 'escalated', + resolution: null, + escalationReason: + 'Escalate to billing because the account is still ambiguous.', + attemptCount: 2, + history: [ + 'worker:1:blocked:Missing account identifier.', + 'supervisor:retry:Retry using the verified billing email on file.', + 'worker:2:blocked:Still blocked after retry: Retry using the verified billing email on file.', + 'supervisor:escalate:Escalate to billing because the account is still ambiguous.', + ], + }, + }) + ); +}); From b26a554f4d974d7c7fb8ae9476a0e1f51ef0d340 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 24 Apr 2026 08:32:15 -0400 Subject: [PATCH 25/50] feat: add http session example --- examples/http-session.ts | 68 +++++++++++++++++++++ examples/index.ts | 1 + readme.md | 1 + src/examples.test.ts | 124 ++++++++++++++++++++++++++------------- src/graph/index.test.ts | 60 +++++++++++++++++++ 5 files changed, 214 insertions(+), 40 deletions(-) create mode 100644 examples/http-session.ts diff --git a/examples/http-session.ts b/examples/http-session.ts new file mode 100644 index 0000000..c355654 --- /dev/null +++ b/examples/http-session.ts @@ -0,0 +1,68 @@ +import { + createMemoryRunStore, + restoreSession, + startSession, + type RunStore, +} from '../src/index.js'; +import { createPersistenceExample } from './persistence.js'; + +export interface SessionHttpHandlerOptions { + store?: RunStore; + summarize?: Parameters[0]; +} + +export function createPersistenceSessionHttpHandler( + options: SessionHttpHandlerOptions = {} +) { + const store = options.store ?? createMemoryRunStore(); + const machine = createPersistenceExample(options.summarize); + + return async function handle(request: Request): Promise { + const url = new URL(request.url); + const match = url.pathname.match(/^\/sessions(?:\/([^/]+)(?:\/events)?)?$/); + const sessionId = match?.[1]; + const isEventRoute = url.pathname.endsWith('/events'); + + if (request.method === 'POST' && url.pathname === '/sessions') { + const body = await request.json() as { request: string }; + const run = await startSession(machine, { + store, + input: { request: body.request }, + }); + + return Response.json({ + sessionId: run.sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'GET' && sessionId && !isEventRoute) { + const run = await restoreSession(machine, { + sessionId, + store, + }); + + return Response.json({ + sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'POST' && sessionId && isEventRoute) { + const event = await request.json() as { type: 'approve' }; + const run = await restoreSession(machine, { + sessionId, + store, + }); + + await run.send(event); + + return Response.json({ + sessionId, + snapshot: run.getSnapshot(), + }); + } + + return new Response('Not found', { status: 404 }); + }; +} diff --git a/examples/index.ts b/examples/index.ts index 7b5217e..cbd239d 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -1,6 +1,7 @@ export { createSimpleExample } from './simple.js'; export { createSqlAgentExample } from './sql-agent.js'; export { createHitlExample } from './hitl.js'; +export { createPersistenceSessionHttpHandler } from './http-session.js'; export { createDecideExample } from './decide.js'; export { createClassifyExample } from './classify.js'; export { createAdapterExample } from './adapter.js'; diff --git a/readme.md b/readme.md index 1c15c23..2346575 100644 --- a/readme.md +++ b/readme.md @@ -25,6 +25,7 @@ Each example demonstrates one concept: - [`examples/conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts): routing directly into one of multiple child subflows from parent input - [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts): tool invocation with emitted `toolCall`, incremental `toolProgress`, and final `toolResult` events - [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): persisting snapshots and restoring a session in plain runtime code +- [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): runner-agnostic HTTP transport for starting sessions, sending events, and reading snapshots over `Request`/`Response` - [`examples/persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts): durable multi-agent handoffs that restore from a persisted mid-network snapshot - [`examples/persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts): restore a crashed streaming invoke and continue with only new live emitted parts - [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts): durable supervisor routing that restores after a persisted retry handoff diff --git a/src/examples.test.ts b/src/examples.test.ts index c9c247c..80b8d78 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; import { createChatbotExample, @@ -16,6 +14,7 @@ import { createEmailExample, createErrorRetryExample, createHitlExample, + createPersistenceSessionHttpHandler, createJokeExample, createJugsExample, createMapReduceExample, @@ -40,44 +39,6 @@ import { } from '../examples/index.js'; describe('curated examples', () => { - test('ships the canonical examples directory', () => { - const examplesDir = resolve(process.cwd(), 'examples'); - expect(existsSync(resolve(examplesDir, 'simple.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'sql-agent.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'hitl.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'decide.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'classify.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'adapter.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'branching.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'chatbot.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'cloudflare-durable-object.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'cloudflare-durable-network.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'conditional-subflow.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'customer-service-sim.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'email.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'error-retry.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'joke.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'jugs.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'map-reduce.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'multi-agent-network.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'newspaper.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'persistence.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'persistent-multi-agent-network.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'persistent-streaming.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'persistent-supervisor.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'plan-and-execute.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'raffle.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'react-agent.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'react-agent-from-scratch.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'rewoo.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'reflection.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'river-crossing.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'subflow.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'supervisor.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'tool-calling.ts'))).toBe(true); - expect(existsSync(resolve(examplesDir, 'tutor.ts'))).toBe(true); - }); - test('simple example runs to a final output', async () => { const machine = createSimpleExample(async () => ({ summary: 'A short summary.', @@ -161,6 +122,89 @@ describe('curated examples', () => { ); }); + test('http session example exposes start, send, and status over Request/Response', async () => { + const handle = createPersistenceSessionHttpHandler({ + summarize: async ({ request, approved }) => ({ + summary: `${request} :: approved=${String(approved)}`, + }), + }); + + const startResponse = await handle( + new Request('https://agent.test/sessions', { + method: 'POST', + body: JSON.stringify({ + request: 'Approve the annual budget summary.', + }), + headers: { + 'content-type': 'application/json', + }, + }) + ); + const startBody = await startResponse.json() as { + sessionId: string; + snapshot: { value: string; status: string }; + }; + + expect(startBody.snapshot).toEqual( + expect.objectContaining({ + value: 'review', + status: 'active', + }) + ); + + const sendResponse = await handle( + new Request(`https://agent.test/sessions/${startBody.sessionId}/events`, { + method: 'POST', + body: JSON.stringify({ type: 'approve' }), + headers: { + 'content-type': 'application/json', + }, + }) + ); + const sendBody = await sendResponse.json() as { + snapshot: { + value: string; + status: string; + output: { + request: string; + approved: boolean; + summary: string; + }; + }; + }; + + expect(sendBody.snapshot).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { + request: 'Approve the annual budget summary.', + approved: true, + summary: 'Approve the annual budget summary. :: approved=true', + }, + }) + ); + + const statusResponse = await handle( + new Request(`https://agent.test/sessions/${startBody.sessionId}`, { + method: 'GET', + }) + ); + const statusBody = await statusResponse.json() as { + snapshot: { + value: string; + status: string; + output: { + request: string; + approved: boolean; + summary: string; + }; + }; + }; + + expect(statusBody.snapshot).toEqual(sendBody.snapshot); + }); + test('cloudflare durable network example restores and settles a network run', async () => { const storage = new Map(); const firstInstance = new AgentNetworkDurableObject({ diff --git a/src/graph/index.test.ts b/src/graph/index.test.ts index b9b7090..6afbb11 100644 --- a/src/graph/index.test.ts +++ b/src/graph/index.test.ts @@ -272,6 +272,66 @@ test('resolves simple helper calls with arguments in guards and targets', () => ]); }); +test('resolves one-level helper forwarding with substituted arguments', () => { + const machine = createAgentMachine({ + id: 'helper-forwarding-export', + schemas: { + events: { + choose: z.object({ + type: z.literal('choose'), + kind: z.enum(['approved', 'rejected']), + }), + }, + }, + context: () => ({}), + initial: 'idle', + states: { + idle: { + on: { + choose: ({ event }) => { + function goTo( + target: 'approved' | 'rejected', + reason: string + ) { + return { + target, + context: { reason }, + }; + } + + function route(kind: 'approved' | 'rejected') { + return goTo(kind, `routed:${kind}`); + } + + return event.kind === 'approved' + ? route('approved') + : route('rejected'); + }, + }, + }, + approved: { type: 'final' }, + rejected: { type: 'final' }, + }, + }); + + expect(toGraph(machine).edges).toEqual([ + expect.objectContaining({ + targetId: 'approved', + data: expect.objectContaining({ + guard: { type: 'event.kind === "approved"' }, + actions: { context: true }, + }), + }), + expect.objectContaining({ + targetId: 'rejected', + data: expect.objectContaining({ + guard: { type: '!(event.kind === "approved")' }, + actions: { context: true }, + }), + }), + ]); +}); + test('exports a mermaid state diagram from the Stately graph data', () => { const machine = createAgentMachine({ id: 'mermaid-export', From 72260b623406c616c7c92615dc075c09d049eed4 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 25 Apr 2026 11:49:08 -0400 Subject: [PATCH 26/50] feat: add durable http streaming example --- examples/http-streaming-session.ts | 262 +++++++++++++++++++++++++++++ examples/index.ts | 1 + readme.md | 1 + src/examples.test.ts | 111 ++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 examples/http-streaming-session.ts diff --git a/examples/http-streaming-session.ts b/examples/http-streaming-session.ts new file mode 100644 index 0000000..9e6185c --- /dev/null +++ b/examples/http-streaming-session.ts @@ -0,0 +1,262 @@ +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + restoreSession, + startSession, + type AgentRun, + type RunStore, +} from '../src/index.js'; + +const streamingInputSchema = z.object({ + streamId: z.string(), + text: z.string(), +}); + +const streamingOutputSchema = z.object({ + text: z.string(), +}); + +const textPartSchema = z.object({ + delta: z.string(), +}); + +type StreamingRun = AgentRun< + { streamId: string; text: string; finalText: string }, + string, + {}, + { text: string }, + { textPart: typeof textPartSchema } +>; + +export interface StreamingSessionHttpController { + handle(request: Request): Promise; + advance(streamId: string): void; + dropActiveSession(sessionId: string): void; +} + +export function createStreamingSessionHttpController(options: { + store?: RunStore; +} = {}): StreamingSessionHttpController { + const store = options.store ?? createMemoryRunStore(); + const streamer = createDurableChunkStreamer(); + const machine = createAgentMachine({ + id: 'http-streaming-session-example', + schemas: { + input: streamingInputSchema, + output: streamingOutputSchema, + emitted: { + textPart: textPartSchema, + }, + }, + context: (input) => ({ + streamId: input.streamId, + text: input.text, + finalText: '', + }), + initial: 'writing', + states: { + writing: { + resultSchema: streamingOutputSchema, + invoke: async ({ context }, enq) => + streamer.streamText(context.streamId, context.text, (delta) => { + enq.emit({ type: 'textPart', delta }); + }), + onDone: ({ result }) => ({ + target: 'done', + context: { finalText: result.text }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + text: context.finalText, + }), + }, + }, + }); + const activeRuns = new Map(); + + function trackRun(sessionId: string, run: StreamingRun) { + activeRuns.set(sessionId, run); + run.onDone(() => { + activeRuns.delete(sessionId); + }); + run.onError(() => { + activeRuns.delete(sessionId); + }); + return run; + } + + async function getRun(sessionId: string): Promise { + const existing = activeRuns.get(sessionId); + if (existing) { + return existing; + } + + const restored = await restoreSession(machine, { + sessionId, + store, + }) as StreamingRun; + + return trackRun(sessionId, restored); + } + + return { + advance(streamId) { + streamer.advance(streamId); + }, + + dropActiveSession(sessionId) { + activeRuns.delete(sessionId); + }, + + async handle(request) { + const url = new URL(request.url); + const match = url.pathname.match(/^\/sessions\/([^/]+)(?:\/stream)?$/); + const sessionId = match?.[1]; + const isStreamRoute = url.pathname.endsWith('/stream'); + + if (request.method === 'POST' && url.pathname === '/sessions') { + const body = await request.json() as z.infer; + const run = await startSession(machine, { + store, + input: body, + }) as StreamingRun; + trackRun(run.sessionId, run); + + return Response.json({ + sessionId: run.sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'GET' && sessionId && !isStreamRoute) { + const run = await getRun(sessionId); + return Response.json({ + sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'GET' && sessionId && isStreamRoute) { + const run = await getRun(sessionId); + let cleanup = () => {}; + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + const write = (event: string, data: unknown) => { + controller.enqueue( + encoder.encode( + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + ) + ); + }; + + if (run.getSnapshot().status === 'done') { + write('done', run.getSnapshot().output); + controller.close(); + return; + } + + if (run.getSnapshot().status === 'error') { + write('error', { error: String(run.getSnapshot().error) }); + controller.close(); + return; + } + + const offPart = run.on('textPart', (event) => { + write('textPart', event); + }); + const offDone = run.onDone((event) => { + write('done', event.output); + cleanup(); + controller.close(); + }); + const offError = run.onError((event) => { + write('error', { error: String(event.error) }); + cleanup(); + controller.close(); + }); + + cleanup = () => { + offPart(); + offDone(); + offError(); + }; + }, + cancel() { + // Subscribers are ephemeral transport clients, not run ownership. + // Closing the stream should detach listeners but leave the run alive. + cleanup(); + }, + }); + + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + }, + }); + } + + return new Response('Not found', { status: 404 }); + }, + }; +} + +function createDurableChunkStreamer() { + const cursors = new Map(); + const invocations = new Map(); + const waiters = new Map void>>(); + + return { + advance(streamId: string) { + const current = waiters.get(streamId) ?? []; + waiters.set(streamId, []); + for (const resolve of current) { + resolve(); + } + }, + + async streamText( + streamId: string, + text: string, + emit: (delta: string) => void + ) { + const chunks = splitIntoChunks(text); + const invocation = (invocations.get(streamId) ?? 0) + 1; + invocations.set(streamId, invocation); + let cursor = cursors.get(streamId) ?? 0; + + while (cursor < chunks.length) { + if (invocation < (invocations.get(streamId) ?? 0)) { + await new Promise(() => {}); + } + + await new Promise((resolve) => { + waiters.set(streamId, [...(waiters.get(streamId) ?? []), resolve]); + }); + + if (invocation < (invocations.get(streamId) ?? 0)) { + await new Promise(() => {}); + } + + emit(chunks[cursor]!); + cursor += 1; + cursors.set(streamId, cursor); + } + + return { text }; + }, + }; +} + +function splitIntoChunks(text: string): string[] { + if (text.length <= 3) { + return [text]; + } + + return [text.slice(0, 3), text.slice(3)]; +} diff --git a/examples/index.ts b/examples/index.ts index cbd239d..5971975 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -2,6 +2,7 @@ export { createSimpleExample } from './simple.js'; export { createSqlAgentExample } from './sql-agent.js'; export { createHitlExample } from './hitl.js'; export { createPersistenceSessionHttpHandler } from './http-session.js'; +export { createStreamingSessionHttpController } from './http-streaming-session.js'; export { createDecideExample } from './decide.js'; export { createClassifyExample } from './classify.js'; export { createAdapterExample } from './adapter.js'; diff --git a/readme.md b/readme.md index 2346575..2ee9e36 100644 --- a/readme.md +++ b/readme.md @@ -26,6 +26,7 @@ Each example demonstrates one concept: - [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts): tool invocation with emitted `toolCall`, incremental `toolProgress`, and final `toolResult` events - [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): persisting snapshots and restoring a session in plain runtime code - [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): runner-agnostic HTTP transport for starting sessions, sending events, and reading snapshots over `Request`/`Response` +- [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts): durable SSE transport that reconnects to a restored run and emits only new streaming parts - [`examples/persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts): durable multi-agent handoffs that restore from a persisted mid-network snapshot - [`examples/persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts): restore a crashed streaming invoke and continue with only new live emitted parts - [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts): durable supervisor routing that restores after a persisted retry handoff diff --git a/src/examples.test.ts b/src/examples.test.ts index 80b8d78..a7ab323 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -15,6 +15,7 @@ import { createErrorRetryExample, createHitlExample, createPersistenceSessionHttpHandler, + createStreamingSessionHttpController, createJokeExample, createJugsExample, createMapReduceExample, @@ -38,6 +39,38 @@ import { createTutorExample, } from '../examples/index.js'; +function createSseReader(response: Response) { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + return { + async next(): Promise<{ event: string; data: unknown }> { + while (true) { + const match = buffer.match(/^event: ([^\n]+)\ndata: ([^\n]+)\n\n/); + if (match) { + buffer = buffer.slice(match[0].length); + return { + event: match[1]!, + data: JSON.parse(match[2]!), + }; + } + + const chunk = await reader.read(); + if (chunk.done) { + throw new Error('SSE stream closed before the next event was available.'); + } + + buffer += decoder.decode(chunk.value, { stream: true }); + } + }, + + async cancel() { + await reader.cancel(); + }, + }; +} + describe('curated examples', () => { test('simple example runs to a final output', async () => { const machine = createSimpleExample(async () => ({ @@ -205,6 +238,84 @@ describe('curated examples', () => { expect(statusBody.snapshot).toEqual(sendBody.snapshot); }); + test('http streaming session example reconnects with only new SSE parts after restore', async () => { + const controller = createStreamingSessionHttpController(); + + const startResponse = await controller.handle( + new Request('https://agent.test/sessions', { + method: 'POST', + body: JSON.stringify({ + streamId: 'stream-1', + text: 'hello', + }), + headers: { + 'content-type': 'application/json', + }, + }) + ); + const startBody = await startResponse.json() as { + sessionId: string; + }; + + const firstStreamResponse = await controller.handle( + new Request(`https://agent.test/sessions/${startBody.sessionId}/stream`) + ); + const firstReader = createSseReader(firstStreamResponse); + + controller.advance('stream-1'); + + await expect(firstReader.next()).resolves.toEqual({ + event: 'textPart', + data: { + type: 'textPart', + delta: 'hel', + }, + }); + + await firstReader.cancel(); + controller.dropActiveSession(startBody.sessionId); + + const secondStreamResponse = await controller.handle( + new Request(`https://agent.test/sessions/${startBody.sessionId}/stream`) + ); + const secondReader = createSseReader(secondStreamResponse); + + controller.advance('stream-1'); + + await expect(secondReader.next()).resolves.toEqual({ + event: 'textPart', + data: { + type: 'textPart', + delta: 'lo', + }, + }); + await expect(secondReader.next()).resolves.toEqual({ + event: 'done', + data: { + text: 'hello', + }, + }); + + const statusResponse = await controller.handle( + new Request(`https://agent.test/sessions/${startBody.sessionId}`) + ); + const statusBody = await statusResponse.json() as { + snapshot: { + value: string; + status: string; + output: { text: string }; + }; + }; + + expect(statusBody.snapshot).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { text: 'hello' }, + }) + ); + }); + test('cloudflare durable network example restores and settles a network run', async () => { const storage = new Map(); const firstInstance = new AgentNetworkDurableObject({ From 23910f13b31fd507323e45b5d0a29b38ab8c7a85 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 25 Apr 2026 11:57:51 -0400 Subject: [PATCH 27/50] feat: add parity matrix and core workflow examples --- docs/langgraph-parity.md | 80 +++++++++++ examples/chatbot-messages.ts | 133 +++++++++++++++++ examples/index.ts | 2 + examples/rag.ts | 134 ++++++++++++++++++ readme.md | 2 + src/examples.test.ts | 63 ++++++++ .../chatbot-messages.test.ts | 47 ++++++ src/langgraph-equivalents/rag.test.ts | 33 +++++ 8 files changed, 494 insertions(+) create mode 100644 docs/langgraph-parity.md create mode 100644 examples/chatbot-messages.ts create mode 100644 examples/rag.ts create mode 100644 src/langgraph-equivalents/chatbot-messages.test.ts create mode 100644 src/langgraph-equivalents/rag.test.ts diff --git a/docs/langgraph-parity.md b/docs/langgraph-parity.md new file mode 100644 index 0000000..00f04f1 --- /dev/null +++ b/docs/langgraph-parity.md @@ -0,0 +1,80 @@ +# LangGraphJS Parity + +## Scope + +This document tracks where `@statelyai/agent` currently matches the practical end result of `langchain-ai/langgraphjs` for core workflow/runtime behavior. + +It is intentionally scoped to: + +- core orchestration concepts +- durable session behavior +- streaming/runtime transport behavior +- runnable examples and tests in this repo + +It is intentionally not scoped to: + +- LangGraph Platform deployment features +- LangGraph Studio +- LangGraph UI / framework SDK packages +- checkpoint backend packages as separate published adapters + +## External reference + +As of April 25, 2026, the upstream `langgraphjs` repo exposes: + +- core packages under [`libs/`](https://github.com/langchain-ai/langgraphjs/tree/main/libs), including `langgraph`, `langgraph-core`, checkpoint packages, supervisor/swarm helpers, SDKs, and UI packages +- runnable examples under [`examples/`](https://github.com/langchain-ai/langgraphjs/tree/main/examples), including quickstart, plan-and-execute, reflection, rewoo, SQL agent, multi-agent, chatbots, RAG, and UI transport examples + +The parity target here is the core graph/runtime layer, not the whole surrounding product/package ecosystem. + +## Matrix + + + +| LangGraphJS concept | Status | Agent equivalent | +| --- | --- | --- | +| Branching / conditional routing | Covered | [`examples/branching.ts`](/Users/davidkpiano/Code/agent/examples/branching.ts), [`src/langgraph-equivalents/branching.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/branching.test.ts) | +| Subgraphs / nested flows | Covered | [`examples/subflow.ts`](/Users/davidkpiano/Code/agent/examples/subflow.ts), [`examples/conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts), [`src/langgraph-equivalents/subflow.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/subflow.test.ts), [`src/langgraph-equivalents/conditional-subflow.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/conditional-subflow.test.ts) | +| Human-in-the-loop / approval gate | Covered | [`examples/hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts), [`src/langgraph-equivalents/hitl.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/hitl.test.ts) | +| Durable sessions / restore from snapshots + events | Covered | [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts), [`src/langgraph-equivalents/persistence.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistence.test.ts) | +| Streaming emitted parts | Covered | [`examples/persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts), [`src/langgraph-equivalents/streaming.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/streaming.test.ts), [`src/langgraph-equivalents/persistent-streaming.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistent-streaming.test.ts) | +| Tool calling with intermediate progress | Covered | [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts), [`src/langgraph-equivalents/tool-calling.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/tool-calling.test.ts) | +| Retry loops / explicit recovery | Covered | [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts), [`src/langgraph-equivalents/error-retry.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/error-retry.test.ts) | +| Plan-and-execute | Covered | [`examples/plan-and-execute.ts`](/Users/davidkpiano/Code/agent/examples/plan-and-execute.ts), [`src/langgraph-equivalents/plan-and-execute.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/plan-and-execute.test.ts) | +| Map-reduce style workflows | Covered | [`examples/map-reduce.ts`](/Users/davidkpiano/Code/agent/examples/map-reduce.ts), [`src/langgraph-equivalents/map-reduce.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/map-reduce.test.ts) | +| Reflection loop | Covered | [`examples/reflection.ts`](/Users/davidkpiano/Code/agent/examples/reflection.ts), [`src/langgraph-equivalents/reflection.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/reflection.test.ts) | +| ReWOO-style planner / worker decomposition | Covered | [`examples/rewoo.ts`](/Users/davidkpiano/Code/agent/examples/rewoo.ts), [`src/langgraph-equivalents/rewoo.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/rewoo.test.ts) | +| Supervisor routing | Covered | [`examples/supervisor.ts`](/Users/davidkpiano/Code/agent/examples/supervisor.ts), [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts), [`src/langgraph-equivalents/supervisor.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/supervisor.test.ts), [`src/langgraph-equivalents/persistent-supervisor.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistent-supervisor.test.ts) | +| Multi-agent handoffs | Covered | [`examples/multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/multi-agent-network.ts), [`examples/persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts), [`src/langgraph-equivalents/multi-agent-network.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/multi-agent-network.test.ts), [`src/langgraph-equivalents/persistent-multi-agent-network.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistent-multi-agent-network.test.ts) | +| SQL/tool-heavy agent workflow | Covered | [`examples/sql-agent.ts`](/Users/davidkpiano/Code/agent/examples/sql-agent.ts), [`src/langgraph-equivalents/sql-agent.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/sql-agent.test.ts) | +| ReAct-style agent | Covered | [`examples/react-agent-from-scratch.ts`](/Users/davidkpiano/Code/agent/examples/react-agent-from-scratch.ts), [`examples/react-agent.ts`](/Users/davidkpiano/Code/agent/examples/react-agent.ts), [`src/langgraph-equivalents/prebuilt-react.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/prebuilt-react.test.ts) | +| Message-centric chatbot state | Covered | [`examples/chatbot-messages.ts`](/Users/davidkpiano/Code/agent/examples/chatbot-messages.ts), [`src/langgraph-equivalents/chatbot-messages.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/chatbot-messages.test.ts) | +| Retrieval-augmented generation | Covered | [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts), [`src/langgraph-equivalents/rag.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/rag.test.ts) | +| HTTP session transport | Covered | [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts), [`src/examples.test.ts`](/Users/davidkpiano/Code/agent/src/examples.test.ts) | +| Durable HTTP streaming transport / reconnect | Covered | [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts), [`src/examples.test.ts`](/Users/davidkpiano/Code/agent/src/examples.test.ts) | +| Graph export / visualization support | Covered | [`src/graph/index.ts`](/Users/davidkpiano/Code/agent/src/graph/index.ts), [`src/xstate/index.ts`](/Users/davidkpiano/Code/agent/src/xstate/index.ts), [`src/langgraph-equivalents/graph.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/graph.test.ts) | + +## Intentional differences + +These are currently deliberate, not gaps: + +- Logic stays pure: `(state, event) -> { nextState, effects }`. +- Emitted events are live runtime effects, not durable journal entries. +- Durable behavior is based on first-class snapshot + event persistence rather than in-memory graph execution with optional add-ons. +- `run.on(...)` is reserved for emitted events only; terminal/runtime hooks use dedicated methods like `run.onDone(...)`. +- Parallelism is expected to be expressed in plain JavaScript where possible, rather than forcing a dedicated graph primitive when `Promise.all(...)` is enough. + +## Still missing or intentionally out of scope + +These are the main areas not yet covered by a first-class parity example: + +- swarm-specific helper APIs comparable to `libs/langgraph-swarm` +- published checkpoint backends as separate installable packages +- UI framework transport examples comparable to `examples/ui-react`, `examples/ui-svelte`, etc. +- platform-only features such as threads, cron jobs, Studio, and deployment APIs + +## Recommended next wave + +1. Decide whether swarm/supervisor helper packages should exist as additive libraries or remain plain examples. +2. Decide whether storage adapters should stay example-level or become installable packages. +3. Only after that, consider UI transport helpers if package surface matters beyond examples. diff --git a/examples/chatbot-messages.ts b/examples/chatbot-messages.ts new file mode 100644 index 0000000..85ab4e8 --- /dev/null +++ b/examples/chatbot-messages.ts @@ -0,0 +1,133 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const messageSchema = z.object({ + role: z.enum(['user', 'assistant']), + content: z.string(), +}); + +const replySchema = z.object({ + message: messageSchema, +}); + +export function createChatbotMessagesExample( + reply: (messages: Array>) => Promise> = (messages) => + generateExampleObject({ + schema: replySchema, + system: 'You are a concise assistant in a terminal chat.', + prompt: [ + 'Write the next assistant message for this conversation.', + '', + ...messages.map((message) => `${message.role}: ${message.content}`), + ].join('\n'), + }) +) { + return createAgentMachine({ + id: 'chatbot-messages-example', + schemas: { + output: z.object({ + messages: z.array(messageSchema), + finalMessage: messageSchema.nullable(), + }), + events: { + 'messages.user': z.object({ + message: messageSchema.extend({ + role: z.literal('user'), + }), + }), + 'messages.end': z.object({}), + }, + }, + context: () => ({ + messages: [] as Array>, + finalMessage: null as z.infer | null, + ended: false, + }), + initial: 'waitingForUser', + states: { + waitingForUser: { + on: { + 'messages.user': ({ event, context }) => ({ + target: 'replying', + context: { + messages: [...context.messages, event.message], + }, + }), + 'messages.end': { + target: 'done', + context: { ended: true }, + }, + }, + }, + replying: { + resultSchema: replySchema, + invoke: async ({ context }) => reply(context.messages), + onDone: ({ result, context }) => ({ + target: 'waitingForUser', + context: { + messages: [...context.messages, result.message], + finalMessage: result.message, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + messages: context.messages, + finalMessage: context.finalMessage, + }), + }, + }, + }); +} + +async function main() { + try { + const machine = createChatbotMessagesExample(); + let state = machine.getInitialState(); + let lastPrintedAssistantMessage: string | null = null; + + while (true) { + const result = await machine.execute(state); + + if (result.status === 'done') { + break; + } + + if (result.status !== 'pending') { + throw new Error('Chatbot messages example entered an unexpected error state.'); + } + + if ( + result.context.finalMessage?.role === 'assistant' + && result.context.finalMessage.content !== lastPrintedAssistantMessage + ) { + console.log(`Assistant: ${result.context.finalMessage.content}`); + lastPrintedAssistantMessage = result.context.finalMessage.content; + } + + const content = await prompt('User (blank to exit)'); + state = machine.transition( + result.state, + content + ? { + type: 'messages.user', + message: { role: 'user', content }, + } + : { type: 'messages.end' } + ); + } + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/index.ts b/examples/index.ts index 5971975..652ebf9 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -7,6 +7,7 @@ export { createDecideExample } from './decide.js'; export { createClassifyExample } from './classify.js'; export { createAdapterExample } from './adapter.js'; export { createChatbotExample } from './chatbot.js'; +export { createChatbotMessagesExample } from './chatbot-messages.js'; export { createConditionalSubflowExample } from './conditional-subflow.js'; export { AgentSessionDurableObject, @@ -38,6 +39,7 @@ export { runPersistentSupervisorExample, } from './persistent-supervisor.js'; export { createRaffleExample } from './raffle.js'; +export { createRagExample } from './rag.js'; export { createReactAgentExample } from './react-agent.js'; export { createReactAgentFromScratch, diff --git a/examples/rag.ts b/examples/rag.ts new file mode 100644 index 0000000..abd9772 --- /dev/null +++ b/examples/rag.ts @@ -0,0 +1,134 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const retrievedDocumentSchema = z.object({ + id: z.string(), + content: z.string(), +}); + +const retrievedDocumentsSchema = z.object({ + documents: z.array(retrievedDocumentSchema), +}); + +const answerSchema = z.object({ + answer: z.string(), +}); + +export function createRagExample( + options: { + retrieve?: (question: string) => Promise>; + answer?: (args: { + question: string; + documents: Array>; + }) => Promise>; + } = {} +) { + const retrieve = + options.retrieve ?? + ((question: string) => + Promise.resolve({ + documents: [ + { + id: 'doc-1', + content: `Context about: ${question}`, + }, + { + id: 'doc-2', + content: `Additional supporting detail for: ${question}`, + }, + ], + })); + + const answer = + options.answer ?? + ((args: { + question: string; + documents: Array>; + }) => + generateExampleObject({ + schema: answerSchema, + system: 'Answer the question using only the retrieved documents.', + prompt: [ + `Question: ${args.question}`, + '', + 'Documents:', + ...args.documents.map((document) => `- [${document.id}] ${document.content}`), + ].join('\n'), + })); + + return createAgentMachine({ + id: 'rag-example', + schemas: { + input: z.object({ + question: z.string(), + }), + output: z.object({ + question: z.string(), + documents: z.array(retrievedDocumentSchema), + answer: z.string().nullable(), + }), + }, + context: (input) => ({ + question: input.question, + documents: [] as Array>, + answer: null as string | null, + }), + initial: 'retrieving', + states: { + retrieving: { + resultSchema: retrievedDocumentsSchema, + invoke: async ({ context }) => retrieve(context.question), + onDone: ({ result }) => ({ + target: 'answering', + context: { documents: result.documents }, + }), + }, + answering: { + resultSchema: answerSchema, + invoke: async ({ context }) => + answer({ + question: context.question, + documents: context.documents, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { answer: result.answer }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + question: context.question, + documents: context.documents, + answer: context.answer, + }), + }, + }, + }); +} + +async function main() { + try { + const question = await prompt('Question'); + const machine = createRagExample(); + const result = await machine.execute( + machine.getInitialState({ question }) + ); + + if (result.status === 'done') { + console.log(result.output); + } + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/readme.md b/readme.md index 2ee9e36..4a445fd 100644 --- a/readme.md +++ b/readme.md @@ -22,7 +22,9 @@ Each example demonstrates one concept: - [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts): the smallest `createAgentMachine(...)` flow with an `invoke` state that makes a real LLM call - [`examples/hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts): a human-in-the-loop machine that pauses in a pending state, accepts typed events, and drafts with an LLM after approval - [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts): retrying transient invoke failures through explicit internal error events +- [`examples/chatbot-messages.ts`](/Users/davidkpiano/Code/agent/examples/chatbot-messages.ts): message-centric chat state with structured `{ role, content }` accumulation across turns - [`examples/conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts): routing directly into one of multiple child subflows from parent input +- [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts): retrieval-augmented generation with explicit retrieve and answer states - [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts): tool invocation with emitted `toolCall`, incremental `toolProgress`, and final `toolResult` events - [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): persisting snapshots and restoring a session in plain runtime code - [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): runner-agnostic HTTP transport for starting sessions, sending events, and reading snapshots over `Request`/`Response` diff --git a/src/examples.test.ts b/src/examples.test.ts index a7ab323..bb160a3 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -11,6 +11,7 @@ import { createConditionalSubflowExample, createCustomerServiceSimExample, createDecideExample, + createChatbotMessagesExample, createEmailExample, createErrorRetryExample, createHitlExample, @@ -27,6 +28,7 @@ import { runPersistentSupervisorExample, createPlanAndExecuteExample, createRaffleExample, + createRagExample, createReactAgentExample, createRewooExample, createReflectionExample, @@ -86,6 +88,67 @@ describe('curated examples', () => { } }); + test('chatbot messages example accumulates structured conversation turns', async () => { + const machine = createChatbotMessagesExample(async (messages) => ({ + message: { + role: 'assistant', + content: `Replying to: ${messages.at(-1)?.content ?? ''}`, + }, + })); + + const afterUserMessage = machine.transition(machine.getInitialState(), { + type: 'messages.user', + message: { + role: 'user', + content: 'Hello there', + }, + }); + const result = await machine.execute(afterUserMessage); + + expect(result.status).toBe('pending'); + if (result.status === 'pending') { + expect(result.context.messages).toEqual([ + { role: 'user', content: 'Hello there' }, + { role: 'assistant', content: 'Replying to: Hello there' }, + ]); + expect(result.context.finalMessage).toEqual({ + role: 'assistant', + content: 'Replying to: Hello there', + }); + } + }); + + test('rag example retrieves context and produces a grounded answer', async () => { + const machine = createRagExample({ + retrieve: async (question) => ({ + documents: [ + { id: 'doc-1', content: `${question} :: first fact` }, + { id: 'doc-2', content: `${question} :: second fact` }, + ], + }), + answer: async ({ question, documents }) => ({ + answer: `${question} => ${documents.map((document) => document.content).join(' | ')}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ question: 'What is LangGraph?' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + question: 'What is LangGraph?', + documents: [ + { id: 'doc-1', content: 'What is LangGraph? :: first fact' }, + { id: 'doc-2', content: 'What is LangGraph? :: second fact' }, + ], + answer: + 'What is LangGraph? => What is LangGraph? :: first fact | What is LangGraph? :: second fact', + }); + } + }); + test('persistence example restores a durable session to the same final snapshot', async () => { const result = await runPersistenceExample( { request: 'Approve the annual budget summary.' }, diff --git a/src/langgraph-equivalents/chatbot-messages.test.ts b/src/langgraph-equivalents/chatbot-messages.test.ts new file mode 100644 index 0000000..602445f --- /dev/null +++ b/src/langgraph-equivalents/chatbot-messages.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from 'vitest'; +import { createChatbotMessagesExample } from '../../examples/index.js'; + +test('message-centric chatbot workflow accumulates structured messages across turns', async () => { + const machine = createChatbotMessagesExample(async (messages) => ({ + message: { + role: 'assistant', + content: `Replying to: ${messages.at(-1)?.content ?? ''}`, + }, + })); + + const afterFirstTurn = machine.transition(machine.getInitialState(), { + type: 'messages.user', + message: { + role: 'user', + content: 'Hello there', + }, + }); + const firstResult = await machine.execute(afterFirstTurn); + + expect(firstResult.status).toBe('pending'); + if (firstResult.status === 'pending') { + expect(firstResult.context.messages).toEqual([ + { role: 'user', content: 'Hello there' }, + { role: 'assistant', content: 'Replying to: Hello there' }, + ]); + + const afterSecondTurn = machine.transition(firstResult.state, { + type: 'messages.user', + message: { + role: 'user', + content: 'Can you expand on that?', + }, + }); + const secondResult = await machine.execute(afterSecondTurn); + + expect(secondResult.status).toBe('pending'); + if (secondResult.status === 'pending') { + expect(secondResult.context.messages).toEqual([ + { role: 'user', content: 'Hello there' }, + { role: 'assistant', content: 'Replying to: Hello there' }, + { role: 'user', content: 'Can you expand on that?' }, + { role: 'assistant', content: 'Replying to: Can you expand on that?' }, + ]); + } + } +}); diff --git a/src/langgraph-equivalents/rag.test.ts b/src/langgraph-equivalents/rag.test.ts new file mode 100644 index 0000000..c896759 --- /dev/null +++ b/src/langgraph-equivalents/rag.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'vitest'; +import { createRagExample } from '../../examples/index.js'; + +test('rag workflow retrieves documents and synthesizes a grounded answer', async () => { + const machine = createRagExample({ + retrieve: async (question) => ({ + documents: [ + { id: 'doc-1', content: `${question} :: first fact` }, + { id: 'doc-2', content: `${question} :: second fact` }, + ], + }), + answer: async ({ question, documents }) => ({ + answer: `${question} => ${documents.map((document) => document.content).join(' | ')}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ question: 'What is LangGraph?' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + question: 'What is LangGraph?', + documents: [ + { id: 'doc-1', content: 'What is LangGraph? :: first fact' }, + { id: 'doc-2', content: 'What is LangGraph? :: second fact' }, + ], + answer: + 'What is LangGraph? => What is LangGraph? :: first fact | What is LangGraph? :: second fact', + }); + } +}); From 59161ce74d083bde4d040514a91c2e4a5647f847 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 25 Apr 2026 12:24:58 -0400 Subject: [PATCH 28/50] feat: add ai sdk and cloudflare agents examples --- examples/ai-sdk.ts | 166 +++++ examples/cloudflare-agents.ts | 191 +++++ examples/index.ts | 6 + package.json | 1 + pnpm-lock.yaml | 1325 ++++++++++++++++++++++++++++++++- readme.md | 4 +- src/ai-sdk/index.test.ts | 82 ++ src/ai-sdk/index.ts | 40 +- src/examples.test.ts | 75 ++ 9 files changed, 1875 insertions(+), 15 deletions(-) create mode 100644 examples/ai-sdk.ts create mode 100644 examples/cloudflare-agents.ts create mode 100644 src/ai-sdk/index.test.ts diff --git a/examples/ai-sdk.ts b/examples/ai-sdk.ts new file mode 100644 index 0000000..c60f730 --- /dev/null +++ b/examples/ai-sdk.ts @@ -0,0 +1,166 @@ +import { generateText, Output } from 'ai'; +import { z } from 'zod'; +import { + createAgentMachine, + decide, + decideResultSchema, + type AgentAdapter, +} from '../src/index.js'; +import { createAiSdkAdapter } from '../src/ai-sdk/index.js'; +import { + closePrompt, + createExampleModel, + formatResult, + isMain, + prompt, +} from './_run.js'; + +const routeOptions = { + billing: { + description: 'Handle invoices, refunds, subscription charges, and payment issues.', + schema: z.object({ + confidence: z.number().min(0).max(1), + }), + }, + support: { + description: 'Handle product usage questions and troubleshooting requests.', + schema: z.object({ + confidence: z.number().min(0).max(1), + }), + }, +} as const; + +const replySchema = z.object({ + subject: z.string(), + body: z.string(), +}); + +type Route = keyof typeof routeOptions; + +export function createAiSdkExample(options: { + adapter?: AgentAdapter; + draftReply?: (args: { + route: Route; + confidence: number; + message: string; + }) => Promise>; +} = {}) { + const adapter = + options.adapter ?? + createAiSdkAdapter({ + resolveModel: (model) => createExampleModel(model), + }); + + const draftReply = + options.draftReply ?? + (async ({ + route, + confidence, + message, + }: { + route: Route; + confidence: number; + message: string; + }) => { + const result = await generateText({ + model: createExampleModel('openai/gpt-5.4-nano'), + system: [ + 'Draft a concise support email.', + `Route: ${route}`, + `Classifier confidence: ${confidence.toFixed(2)}`, + 'Return structured output with a subject and body.', + ].join('\n'), + prompt: message, + output: Output.object({ + schema: replySchema, + }), + }); + + return result.output as z.infer; + }); + + return createAgentMachine({ + id: 'ai-sdk-example', + schemas: { + input: z.object({ message: z.string() }), + output: z.object({ + route: z.enum(['billing', 'support']).nullable(), + confidence: z.number().nullable(), + subject: z.string().nullable(), + body: z.string().nullable(), + }), + }, + context: (input) => ({ + message: input.message, + route: null as Route | null, + confidence: null as number | null, + subject: null as string | null, + body: null as string | null, + }), + initial: 'route', + states: { + route: { + resultSchema: decideResultSchema(routeOptions), + invoke: async ({ context }) => + decide({ + adapter, + model: 'openai/gpt-5.4-nano', + prompt: [ + 'Route this inbound customer message.', + '', + context.message, + ].join('\n'), + options: routeOptions, + }), + onDone: ({ result }) => ({ + target: 'drafting', + context: { + route: result.choice, + confidence: result.data.confidence, + }, + }), + }, + drafting: { + resultSchema: replySchema, + invoke: async ({ context }) => + draftReply({ + route: context.route ?? 'support', + confidence: context.confidence ?? 0, + message: context.message, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { + subject: result.subject, + body: result.body, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + route: context.route, + confidence: context.confidence, + subject: context.subject, + body: context.body, + }), + }, + }, + }); +} + +async function main() { + try { + const message = await prompt('Customer message'); + const machine = createAiSdkExample(); + const result = await machine.execute(machine.getInitialState({ message })); + + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/cloudflare-agents.ts b/examples/cloudflare-agents.ts new file mode 100644 index 0000000..8994231 --- /dev/null +++ b/examples/cloudflare-agents.ts @@ -0,0 +1,191 @@ +import { + restoreSession, + startSession, + type JournalEventRecord, + type PersistedSnapshot, + type RunStore, +} from '../src/index.js'; +import { createPersistenceExample } from './persistence.js'; + +type SessionEntry = { + events: JournalEventRecord[]; + snapshot: PersistedSnapshot | null; +}; + +export type CloudflareAgentRunStoreState = { + sessions: Record; +}; + +export interface CloudflareAgentsExampleArtifacts { + ReviewWorkflowAgent: new (...args: any[]) => { + onRequest(request: Request): Promise; + }; + worker: { + fetch(request: Request, env: Record): Promise; + }; +} + +export function createCloudflareAgentRunStore(options: { + getState: () => CloudflareAgentRunStoreState; + setState: ( + nextState: CloudflareAgentRunStoreState + ) => void | Promise; +}): RunStore { + return { + async append(sessionId, event) { + const currentState = options.getState(); + const currentSession = currentState.sessions[sessionId] ?? { + events: [], + snapshot: null, + }; + const sequence = currentSession.events.length + 1; + const nextSession: SessionEntry = { + ...currentSession, + events: [...currentSession.events, { ...event, sequence }], + }; + + await options.setState({ + ...currentState, + sessions: { + ...currentState.sessions, + [sessionId]: nextSession, + }, + }); + + return { sequence }; + }, + + async loadEvents(sessionId, afterSequence = 0) { + return ( + options.getState().sessions[sessionId]?.events.filter( + (event) => event.sequence > afterSequence + ) ?? [] + ); + }, + + async loadLatestSnapshot(sessionId) { + return options.getState().sessions[sessionId]?.snapshot ?? null; + }, + + async saveSnapshot(snapshot) { + const currentState = options.getState(); + const currentSession = currentState.sessions[snapshot.sessionId] ?? { + events: [], + snapshot: null, + }; + + await options.setState({ + ...currentState, + sessions: { + ...currentState.sessions, + [snapshot.sessionId]: { + ...currentSession, + snapshot, + }, + }, + }); + }, + }; +} + +/** + * Cloudflare's `agents` package imports `cloudflare:` modules, so this example + * keeps that import lazy to stay loadable in plain Node. In a real Worker, + * move the `agents` imports to top-level imports. + */ +export async function createCloudflareAgentsExample(): Promise { + const { Agent, routeAgentRequest } = await import('agents'); + const machine = createPersistenceExample(); + + class ReviewWorkflowAgent extends Agent< + Record, + CloudflareAgentRunStoreState + > { + initialState: CloudflareAgentRunStoreState = { + sessions: {}, + }; + + private getStore(): RunStore { + return createCloudflareAgentRunStore({ + getState: () => this.state ?? this.initialState, + setState: (nextState) => this.setState(nextState), + }); + } + + async onRequest(request: Request): Promise { + const url = new URL(request.url); + + if (request.method === 'POST' && url.pathname.endsWith('/start')) { + const body = await request.json() as { request: string }; + const run = await startSession(machine, { + store: this.getStore(), + input: { + request: body.request, + }, + }); + + return Response.json({ + sessionId: run.sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'POST' && url.pathname.endsWith('/events')) { + const body = await request.json() as { + sessionId: string; + event: { type: 'approve' }; + }; + const run = await restoreSession(machine, { + sessionId: body.sessionId, + store: this.getStore(), + }); + + await run.send(body.event); + + return Response.json({ + sessionId: body.sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'GET' && url.pathname.endsWith('/snapshot')) { + const sessionId = requiredSessionId(url); + const run = await restoreSession(machine, { + sessionId, + store: this.getStore(), + }); + + return Response.json({ + sessionId, + snapshot: run.getSnapshot(), + }); + } + + return new Response('Not found', { status: 404 }); + } + } + + const worker = { + async fetch(request: Request, env: Record) { + return ( + await routeAgentRequest(request, env, { + prefix: '/agents', + }) + ) ?? new Response('Not found', { status: 404 }); + }, + }; + + return { + ReviewWorkflowAgent, + worker, + } satisfies CloudflareAgentsExampleArtifacts; +} + +function requiredSessionId(url: URL): string { + const sessionId = url.searchParams.get('sessionId'); + if (!sessionId) { + throw new Error('Missing sessionId'); + } + + return sessionId; +} diff --git a/examples/index.ts b/examples/index.ts index 652ebf9..789af5e 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -6,9 +6,15 @@ export { createStreamingSessionHttpController } from './http-streaming-session.j export { createDecideExample } from './decide.js'; export { createClassifyExample } from './classify.js'; export { createAdapterExample } from './adapter.js'; +export { createAiSdkExample } from './ai-sdk.js'; export { createChatbotExample } from './chatbot.js'; export { createChatbotMessagesExample } from './chatbot-messages.js'; export { createConditionalSubflowExample } from './conditional-subflow.js'; +export { + createCloudflareAgentRunStore, + createCloudflareAgentsExample, + type CloudflareAgentRunStoreState, +} from './cloudflare-agents.js'; export { AgentSessionDurableObject, createDurableObjectRunStore, diff --git a/package.json b/package.json index 1db8271..42e66f7 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.9", "@types/node": "^20.16.10", + "agents": "0.11.5", "dotenv": "^16.4.5", "tsdown": "^0.21.7", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e25004..4053207 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@types/node': specifier: ^20.16.10 version: 20.19.30 + agents: + specifier: 0.11.5 + version: 0.11.5(@babel/core@7.29.0)(@babel/runtime@7.28.6)(@cloudflare/workers-types@4.20260424.1)(ai@6.0.67(zod@4.3.6))(react@19.2.5)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(vite@5.4.21(@types/node@20.19.30))(zod@4.3.6) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -73,31 +76,149 @@ packages: resolution: {integrity: sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.7.tgz} engines: {node: '>=18'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==, tarball: https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==, tarball: https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==, tarball: https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz} + engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0-rc.3': resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==, tarball: https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==, tarball: https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==, tarball: https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==, tarball: https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==, tarball: https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==, tarball: https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==, tarball: https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==, tarball: https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==, tarball: https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==, tarball: https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==, tarball: https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==, tarball: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0-rc.3': resolution: {integrity: sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==, tarball: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==, tarball: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.0-rc.3': resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==, tarball: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==, tarball: https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==, tarball: https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==, tarball: https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@8.0.0-rc.3': resolution: {integrity: sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==, tarball: https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==, tarball: https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==, tarball: https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime-corejs3@7.29.2': + resolution: {integrity: sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==, tarball: https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==, tarball: https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.0-rc.3': resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==, tarball: https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==, tarball: https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz} + '@changesets/apply-release-plan@7.0.14': resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==, tarball: https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.14.tgz} @@ -159,6 +280,9 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==, tarball: https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz} + '@cloudflare/workers-types@4.20260424.1': + resolution: {integrity: sha512-0DLJ9yEk1KKzPbqop80Gw/P1wkKKzawmipULiJWdBXIBCoMvE0OVWms3IrL/Q/G7tfmPop9yF4XlZ69k9JLYng==, tarball: https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260424.1.tgz} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==, tarball: https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz} @@ -462,6 +586,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==, tarball: https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==, tarball: https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz} engines: {node: '>=18'} @@ -474,6 +604,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==, tarball: https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==, tarball: https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, tarball: https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz} engines: {node: '>=6.0.0'} @@ -490,6 +623,16 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==, tarball: https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==, tarball: https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@1.1.2': resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz} peerDependencies: @@ -613,6 +756,23 @@ packages: cpu: [x64] os: [win32] + '@rolldown/plugin-babel@0.2.3': + resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==, tarball: https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.3.tgz} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + '@rolldown/pluginutils@1.0.0-rc.12': resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==, tarball: https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz} @@ -828,12 +988,54 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==, tarball: https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==, tarball: https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz} + engines: {node: '>= 0.6'} + + agents@0.11.5: + resolution: {integrity: sha512-1wPkA7OOfEdR4GKwaBmqdnZkOxutN2mCsolVU4ekg5QxrTLnC9Vz9LyZPcGqV2ldyfpUY7R73AUqtig5iYRLvQ==, tarball: https://registry.npmjs.org/agents/-/agents-0.11.5.tgz} + hasBin: true + peerDependencies: + '@cloudflare/ai-chat': '>=0.0.8 <1.0.0' + '@cloudflare/codemode': '>=0.0.7 <1.0.0' + '@tanstack/ai': '>=0.10.2 <1.0.0' + '@x402/core': ^2.0.0 + '@x402/evm': ^2.0.0 + ai: ^6.0.0 + react: ^19.0.0 + vite: '>=6.0.0 <9.0.0' + zod: ^4.0.0 + peerDependenciesMeta: + '@cloudflare/ai-chat': + optional: true + '@cloudflare/codemode': + optional: true + '@tanstack/ai': + optional: true + '@x402/core': + optional: true + '@x402/evm': + optional: true + vite: + optional: true + ai@6.0.67: resolution: {integrity: sha512-xBnTcByHCj3OcG6V8G1s6zvSEqK0Bdiu+IEXYcpGrve1iGFFRgcrKeZtr/WAW/7gupnSvBbDF24BEv1OOfqi1g==, tarball: https://registry.npmjs.org/ai/-/ai-6.0.67.tgz} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==, tarball: https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==, tarball: https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==, tarball: https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz} engines: {node: '>=6'} @@ -842,6 +1044,14 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, tarball: https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==, tarball: https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==, tarball: https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz} + engines: {node: '>=12'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==, tarball: https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz} engines: {node: '>=14'} @@ -864,6 +1074,11 @@ packages: resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==, tarball: https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz} engines: {node: '>=20.19.0'} + baseline-browser-mapping@2.10.21: + resolution: {integrity: sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==, tarball: https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz} + engines: {node: '>=6.0.0'} + hasBin: true + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==, tarball: https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz} engines: {node: '>=4'} @@ -871,10 +1086,23 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==, tarball: https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==, tarball: https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz} + engines: {node: '>=18'} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, tarball: https://registry.npmjs.org/braces/-/braces-3.0.3.tgz} engines: {node: '>=8'} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==, tarball: https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==, tarball: https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==, tarball: https://registry.npmjs.org/cac/-/cac-6.7.14.tgz} engines: {node: '>=8'} @@ -883,6 +1111,17 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==, tarball: https://registry.npmjs.org/cac/-/cac-7.0.0.tgz} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==, tarball: https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==, tarball: https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001790: + resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==, tarball: https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, tarball: https://registry.npmjs.org/chai/-/chai-5.3.3.tgz} engines: {node: '>=18'} @@ -898,6 +1137,40 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==, tarball: https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz} engines: {node: '>=8'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==, tarball: https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz} + engines: {node: '>=20'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==, tarball: https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==, tarball: https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, tarball: https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==, tarball: https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, tarball: https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz} + engines: {node: '>= 0.6'} + + core-js-pure@3.49.0: + resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==, tarball: https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==, tarball: https://registry.npmjs.org/cors/-/cors-2.8.6.tgz} + engines: {node: '>= 0.10'} + + cron-schedule@6.0.0: + resolution: {integrity: sha512-BoZaseYGXOo5j5HUwTaegIog3JJbuH4BbrY9A1ArLjXpy+RWb3mV28F/9Gv1dDA7E2L8kngWva4NWisnLTyfgQ==, tarball: https://registry.npmjs.org/cron-schedule/-/cron-schedule-6.0.0.tgz} + engines: {node: '>=20'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz} engines: {node: '>= 8'} @@ -921,6 +1194,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==, tarball: https://registry.npmjs.org/defu/-/defu-6.1.4.tgz} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==, tarball: https://registry.npmjs.org/depd/-/depd-2.0.0.tgz} + engines: {node: '>= 0.8'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==, tarball: https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz} engines: {node: '>=8'} @@ -946,17 +1223,46 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==, tarball: https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz} + + electron-to-chromium@1.5.344: + resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==, tarball: https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==, tarball: https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==, tarball: https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==, tarball: https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz} + engines: {node: '>= 0.8'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==, tarball: https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz} engines: {node: '>=8.6'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==, tarball: https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==, tarball: https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==, tarball: https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==, tarball: https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz} engines: {node: '>=12'} @@ -967,6 +1273,13 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, tarball: https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==, tarball: https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, tarball: https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz} engines: {node: '>=4'} @@ -975,21 +1288,48 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, tarball: https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==, tarball: https://registry.npmjs.org/etag/-/etag-1.8.1.tgz} + engines: {node: '>= 0.6'} + + event-target-polyfill@0.0.4: + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==, tarball: https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==, tarball: https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz} engines: {node: '>=18.0.0'} + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==, tarball: https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==, tarball: https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz} engines: {node: '>=12.0.0'} + express-rate-limit@8.4.0: + resolution: {integrity: sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==, tarball: https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.0.tgz} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==, tarball: https://registry.npmjs.org/express/-/express-5.2.1.tgz} + engines: {node: '>= 18'} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==, tarball: https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, tarball: https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, tarball: https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz} engines: {node: '>=8.6.0'} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==, tarball: https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==, tarball: https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz} @@ -1006,10 +1346,22 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==, tarball: https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==, tarball: https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==, tarball: https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz} engines: {node: '>=8'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, tarball: https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==, tarball: https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz} + engines: {node: '>= 0.8'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==, tarball: https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz} engines: {node: '>=6 <7 || >=8'} @@ -1023,6 +1375,29 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, tarball: https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, tarball: https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, tarball: https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==, tarball: https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==, tarball: https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==, tarball: https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz} + engines: {node: '>= 0.4'} + get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==, tarball: https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz} @@ -1034,12 +1409,32 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==, tarball: https://registry.npmjs.org/globby/-/globby-11.1.0.tgz} engines: {node: '>=10'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==, tarball: https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==, tarball: https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz} + engines: {node: '>= 0.4'} + + hono@4.12.15: + resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==, tarball: https://registry.npmjs.org/hono/-/hono-4.12.15.tgz} + engines: {node: '>=16.9.0'} + hookable@6.1.0: resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==, tarball: https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz} + engines: {node: '>= 0.8'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==, tarball: https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz} hasBin: true @@ -1056,6 +1451,17 @@ packages: resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==, tarball: https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.2.5.tgz} engines: {node: '>=20.19.0'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==, tarball: https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==, tarball: https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==, tarball: https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz} + engines: {node: '>= 0.10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, tarball: https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz} engines: {node: '>=0.10.0'} @@ -1068,6 +1474,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, tarball: https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==, tarball: https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==, tarball: https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz} engines: {node: '>=4'} @@ -1079,6 +1488,15 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz} + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==, tarball: https://registry.npmjs.org/jose/-/jose-6.2.2.tgz} + + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==, tarball: https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==, tarball: https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz} hasBin: true @@ -1092,9 +1510,20 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==, tarball: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==, tarball: https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==, tarball: https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, tarball: https://registry.npmjs.org/json5/-/json5-2.2.3.tgz} + engines: {node: '>=6'} + hasBin: true + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==, tarball: https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz} @@ -1108,9 +1537,24 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==, tarball: https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, tarball: https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==, tarball: https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==, tarball: https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==, tarball: https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, tarball: https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz} engines: {node: '>= 8'} @@ -1119,6 +1563,25 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, tarball: https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, tarball: https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==, tarball: https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, tarball: https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==, tarball: https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz} + engines: {node: '>=18'} + + mimetext@3.0.28: + resolution: {integrity: sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g==, tarball: https://registry.npmjs.org/mimetext/-/mimetext-3.0.28.tgz} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, tarball: https://registry.npmjs.org/mri/-/mri-1.2.0.tgz} engines: {node: '>=4'} @@ -1131,6 +1594,15 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.9: + resolution: {integrity: sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz} + engines: {node: ^18 || >=20} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==, tarball: https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz} + engines: {node: '>= 0.6'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==, tarball: https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz} engines: {node: 4.x || >=6.0.0} @@ -1140,9 +1612,27 @@ packages: encoding: optional: true + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==, tarball: https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==, tarball: https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==, tarball: https://registry.npmjs.org/obug/-/obug-2.1.1.tgz} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==, tarball: https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, tarball: https://registry.npmjs.org/once/-/once-1.4.0.tgz} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==, tarball: https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz} @@ -1169,6 +1659,23 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==, tarball: https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==, tarball: https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz} + engines: {node: '>= 0.8'} + + partyserver@0.4.1: + resolution: {integrity: sha512-StSs0oY8RmTxjGNil7VbCG4gnTN+4rYX20fiUIItAxTPpr/5rPDZT6PIvMROkk9M1Gn7GzE1wuQXwhxceaGhXA==, tarball: https://registry.npmjs.org/partyserver/-/partyserver-0.4.1.tgz} + peerDependencies: + '@cloudflare/workers-types': ^4.20240729.0 + + partysocket@1.1.18: + resolution: {integrity: sha512-SyuvH9VavWOSa14v6dYdp3yfSUDII4BQB1+TkGOFBkjfZKjnDBiba4fhdhwBlqGBkqw4ea3gTA1DYhSffX24Wg==, tarball: https://registry.npmjs.org/partysocket/-/partysocket-1.1.18.tgz} + peerDependencies: + react: '>=17' + peerDependenciesMeta: + react: + optional: true + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} engines: {node: '>=8'} @@ -1177,6 +1684,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, tarball: https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz} engines: {node: '>=8'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==, tarball: https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==, tarball: https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz} engines: {node: '>=8'} @@ -1210,6 +1720,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==, tarball: https://registry.npmjs.org/pify/-/pify-4.0.1.tgz} engines: {node: '>=6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==, tarball: https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz} + engines: {node: '>=16.20.0'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz} engines: {node: ^10 || ^12 || >=14} @@ -1219,6 +1733,14 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==, tarball: https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz} + engines: {node: '>= 0.10'} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==, tarball: https://registry.npmjs.org/qs/-/qs-6.15.1.tgz} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==, tarball: https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz} @@ -1228,10 +1750,26 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, tarball: https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==, tarball: https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==, tarball: https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz} + engines: {node: '>= 0.10'} + + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==, tarball: https://registry.npmjs.org/react/-/react-19.2.5.tgz} + engines: {node: '>=0.10.0'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==, tarball: https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz} engines: {node: '>=6'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==, tarball: https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==, tarball: https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz} engines: {node: '>=8'} @@ -1272,12 +1810,20 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==, tarball: https://registry.npmjs.org/router/-/router-2.2.0.tgz} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, tarball: https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz} safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, tarball: https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==, tarball: https://registry.npmjs.org/semver/-/semver-6.3.1.tgz} + hasBin: true + semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==, tarball: https://registry.npmjs.org/semver/-/semver-7.7.3.tgz} engines: {node: '>=10'} @@ -1288,6 +1834,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==, tarball: https://registry.npmjs.org/send/-/send-1.2.1.tgz} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==, tarball: https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==, tarball: https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, tarball: https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz} engines: {node: '>=8'} @@ -1296,6 +1853,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, tarball: https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==, tarball: https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==, tarball: https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==, tarball: https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==, tarball: https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, tarball: https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz} @@ -1320,13 +1893,25 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, tarball: https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==, tarball: https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==, tarball: https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==, tarball: https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz} + engines: {node: '>=18'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, tarball: https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==, tarball: https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==, tarball: https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz} engines: {node: '>=4'} @@ -1365,6 +1950,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, tarball: https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==, tarball: https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz} + engines: {node: '>=0.6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==, tarball: https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz} @@ -1408,6 +1997,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==, tarball: https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz} + engines: {node: '>= 0.6'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz} engines: {node: '>=14.17'} @@ -1423,6 +2016,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==, tarball: https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz} engines: {node: '>= 4.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==, tarball: https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz} + engines: {node: '>= 0.8'} + unrun@0.2.34: resolution: {integrity: sha512-LyaghRBR++r7svhDK6tnDz2XaYHWdneBOA0jbS8wnRsHerI9MFljX4fIiTgbbNbEVzZ0C9P1OjWLLe1OqoaaEw==, tarball: https://registry.npmjs.org/unrun/-/unrun-0.2.34.tgz} engines: {node: '>=20.19.0'} @@ -1433,6 +2030,16 @@ packages: synckit: optional: true + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==, tarball: https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, tarball: https://registry.npmjs.org/vary/-/vary-1.1.2.tgz} + engines: {node: '>= 0.8'} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==, tarball: https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz} engines: {node: ^18.0.0 || >=20.0.0} @@ -1510,9 +2117,36 @@ packages: engines: {node: '>=8'} hasBin: true + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==, tarball: https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz} + xstate@5.26.0: resolution: {integrity: sha512-Fvi9VBoqHgsGYLU2NTag8xDTWtKqUC0+ue7EAhBNBb06wf620QEy05upBaEI1VLMzIn63zugLV8nHb69ZUWYAA==, tarball: https://registry.npmjs.org/xstate/-/xstate-5.26.0.tgz} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, tarball: https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==, tarball: https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==, tarball: https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==, tarball: https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==, tarball: https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==, tarball: https://registry.npmjs.org/zod/-/zod-4.3.6.tgz} @@ -1542,6 +2176,42 @@ snapshots: dependencies: json-schema: 0.4.0 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/generator@8.0.0-rc.3': dependencies: '@babel/parser': 8.0.0-rc.3 @@ -1551,21 +2221,151 @@ snapshots: '@types/jsesc': 2.5.1 jsesc: 3.1.0 - '@babel/helper-string-parser@8.0.0-rc.3': {} + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 - '@babel/helper-validator-identifier@8.0.0-rc.3': {} + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 - '@babel/parser@8.0.0-rc.3': + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-string-parser@8.0.0-rc.3': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-identifier@8.0.0-rc.3': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/parser@8.0.0-rc.3': dependencies: '@babel/types': 8.0.0-rc.3 + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime-corejs3@7.29.2': + dependencies: + core-js-pure: 3.49.0 + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@8.0.0-rc.3': dependencies: '@babel/helper-string-parser': 8.0.0-rc.3 '@babel/helper-validator-identifier': 8.0.0-rc.3 + '@cfworker/json-schema@4.1.1': {} + '@changesets/apply-release-plan@7.0.14': dependencies: '@changesets/config': 3.1.2 @@ -1725,6 +2525,8 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@cloudflare/workers-types@4.20260424.1': {} + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -1888,6 +2690,10 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@hono/node-server@1.19.14(hono@4.12.15)': + dependencies: + hono: 4.12.15 + '@inquirer/external-editor@1.0.3(@types/node@20.19.30)': dependencies: chardet: 2.1.1 @@ -1900,6 +2706,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1925,6 +2736,30 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.15) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.4.0(express@5.2.1) + hono: 4.12.15 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: '@emnapi/core': 1.9.1 @@ -2002,6 +2837,15 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': optional: true + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(@babel/runtime@7.28.6)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(vite@5.4.21(@types/node@20.19.30))': + dependencies: + '@babel/core': 7.29.0 + picomatch: 4.0.4 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + optionalDependencies: + '@babel/runtime': 7.28.6 + vite: 5.4.21(@types/node@20.19.30) + '@rolldown/pluginutils@1.0.0-rc.12': {} '@rollup/rollup-android-arm-eabi@4.57.1': @@ -2142,6 +2986,36 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + agents@0.11.5(@babel/core@7.29.0)(@babel/runtime@7.28.6)(@cloudflare/workers-types@4.20260424.1)(ai@6.0.67(zod@4.3.6))(react@19.2.5)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(vite@5.4.21(@types/node@20.19.30))(zod@4.3.6): + dependencies: + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@cfworker/json-schema': 4.1.1 + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.28.6)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(vite@5.4.21(@types/node@20.19.30)) + ai: 6.0.67(zod@4.3.6) + cron-schedule: 6.0.0 + mimetext: 3.0.28 + nanoid: 5.1.9 + partyserver: 0.4.1(@cloudflare/workers-types@4.20260424.1) + partysocket: 1.1.18(react@19.2.5) + react: 19.2.5 + yargs: 18.0.0 + zod: 4.3.6 + optionalDependencies: + vite: 5.4.21(@types/node@20.19.30) + transitivePeerDependencies: + - '@babel/core' + - '@babel/plugin-transform-runtime' + - '@babel/runtime' + - '@cloudflare/workers-types' + - rolldown + - supports-color + ai@6.0.67(zod@4.3.6): dependencies: '@ai-sdk/gateway': 3.0.32(zod@4.3.6) @@ -2150,10 +3024,25 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 4.3.6 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + + ansi-styles@6.2.3: {} + ansis@4.2.0: {} argparse@1.0.10: @@ -2172,20 +3061,58 @@ snapshots: estree-walker: 3.0.3 pathe: 2.0.3 + baseline-browser-mapping@2.10.21: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 birpc@4.0.0: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + braces@3.0.3: dependencies: fill-range: 7.1.1 + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.21 + caniuse-lite: 1.0.30001790 + electron-to-chromium: 1.5.344 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bytes@3.1.2: {} + cac@6.7.14: {} cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001790: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -2200,6 +3127,31 @@ snapshots: ci-info@3.9.0: {} + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + core-js-pure@3.49.0: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cron-schedule@6.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2216,6 +3168,8 @@ snapshots: defu@6.1.4: {} + depd@2.0.0: {} + detect-indent@6.1.0: {} dir-glob@3.0.1: @@ -2228,15 +3182,37 @@ snapshots: dts-resolver@2.1.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.344: {} + + emoji-regex@10.6.0: {} + empathic@2.0.0: {} + encodeurl@2.0.0: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -2292,18 +3268,70 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} + + escape-html@1.0.3: {} + esprima@4.0.1: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + + event-target-polyfill@0.0.4: {} + eventsource-parser@3.0.6: {} + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + expect-type@1.3.0: {} + express-rate-limit@8.4.0(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extendable-error@0.1.7: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2312,6 +3340,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-uri@3.1.0: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -2324,11 +3354,26 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 path-exists: 4.0.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -2344,6 +3389,32 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -2361,10 +3432,28 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hono@4.12.15: {} + hookable@6.1.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + human-id@4.1.3: {} iconv-lite@0.7.2: @@ -2375,6 +3464,12 @@ snapshots: import-without-cache@0.2.5: {} + inherits@2.0.4: {} + + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -2383,6 +3478,8 @@ snapshots: is-number@7.0.0: {} + is-promise@4.0.0: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -2391,6 +3488,12 @@ snapshots: isexe@2.0.0: {} + jose@6.2.2: {} + + js-base64@3.7.8: {} + + js-tokens@4.0.0: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -2402,8 +3505,14 @@ snapshots: jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json5@2.2.3: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -2416,10 +3525,20 @@ snapshots: loupe@3.2.1: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2427,18 +3546,55 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimetext@3.0.28: + dependencies: + '@babel/runtime': 7.28.6 + '@babel/runtime-corejs3': 7.29.2 + js-base64: 3.7.8 + mime-types: 2.1.35 + mri@1.2.0: {} ms@2.1.3: {} nanoid@3.3.11: {} + nanoid@5.1.9: {} + + negotiator@1.0.0: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-releases@2.0.38: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + outdent@0.5.0: {} p-filter@2.1.0: @@ -2461,10 +3617,25 @@ snapshots: dependencies: quansync: 0.2.11 + parseurl@1.3.3: {} + + partyserver@0.4.1(@cloudflare/workers-types@4.20260424.1): + dependencies: + '@cloudflare/workers-types': 4.20260424.1 + nanoid: 5.1.9 + + partysocket@1.1.18(react@19.2.5): + dependencies: + event-target-polyfill: 0.0.4 + optionalDependencies: + react: 19.2.5 + path-exists@4.0.0: {} path-key@3.1.1: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@1.1.2: {} @@ -2483,6 +3654,8 @@ snapshots: pify@4.0.1: {} + pkce-challenge@5.0.1: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2491,12 +3664,32 @@ snapshots: prettier@2.8.8: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} quansync@1.0.0: {} queue-microtask@1.2.3: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + react@19.2.5: {} + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -2504,6 +3697,8 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -2583,22 +3778,89 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 safer-buffer@2.1.2: {} + semver@6.3.1: {} + semver@7.7.3: {} semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -2616,12 +3878,24 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} term-size@2.2.1: {} @@ -2647,6 +3921,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tr46@0.0.3: {} tree-kill@1.2.2: {} @@ -2690,6 +3966,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.9.3: {} unconfig-core@7.5.0: @@ -2701,6 +3983,8 @@ snapshots: universalify@0.1.2: {} + unpipe@1.0.0: {} + unrun@0.2.34(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): dependencies: rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) @@ -2708,6 +3992,14 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + vary@1.1.2: {} + vite-node@2.1.9(@types/node@20.19.30): dependencies: cac: 6.7.14 @@ -2786,6 +4078,33 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + xstate@5.26.0: {} + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@4.3.6: {} diff --git a/readme.md b/readme.md index 4a445fd..c7f208d 100644 --- a/readme.md +++ b/readme.md @@ -11,7 +11,7 @@ Stately Agent is a flexible framework for building AI agents using state machine -The examples in [`examples/`](/Users/davidkpiano/Code/agent/examples) are intentionally small, run in the CLI, and use real OpenAI calls when `OPENAI_API_KEY` is set. +The examples in [`examples/`](/Users/davidkpiano/Code/agent/examples) are intentionally small. Most run in the CLI and use real OpenAI calls when `OPENAI_API_KEY` is set. Runtime-specific examples call out extra environment requirements inline. Run them with `node --import tsx examples/.ts`. @@ -26,9 +26,11 @@ Each example demonstrates one concept: - [`examples/conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts): routing directly into one of multiple child subflows from parent input - [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts): retrieval-augmented generation with explicit retrieve and answer states - [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts): tool invocation with emitted `toolCall`, incremental `toolProgress`, and final `toolResult` events +- [`examples/ai-sdk.ts`](/Users/davidkpiano/Code/agent/examples/ai-sdk.ts): AI SDK v6 integration using `createAiSdkAdapter(...)` for routing and `generateText(..., { output: Output.object(...) })` for structured drafting - [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): persisting snapshots and restoring a session in plain runtime code - [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): runner-agnostic HTTP transport for starting sessions, sending events, and reading snapshots over `Request`/`Response` - [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts): durable SSE transport that reconnects to a restored run and emits only new streaming parts +- [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): integrating a persisted agent-machine session store into Cloudflare Agents `onRequest()` routing; requires the Cloudflare Workers runtime - [`examples/persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts): durable multi-agent handoffs that restore from a persisted mid-network snapshot - [`examples/persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts): restore a crashed streaming invoke and continue with only new live emitted parts - [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts): durable supervisor routing that restores after a persisted retry handoff diff --git a/src/ai-sdk/index.test.ts b/src/ai-sdk/index.test.ts new file mode 100644 index 0000000..45c6669 --- /dev/null +++ b/src/ai-sdk/index.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { createAiSdkAdapter } from './index.js'; + +describe('createAiSdkAdapter', () => { + test('resolves schema-less choices with a custom model resolver', async () => { + const seen: Array<{ model: unknown; prompt: unknown }> = []; + const adapter = createAiSdkAdapter({ + resolveModel: (model) => ({ providerResolved: model }) as never, + generateText: async (options) => { + seen.push({ + model: options.model, + prompt: options.prompt, + }); + + return { + output: 'billing', + } as never; + }, + }); + + const result = await adapter.decide({ + model: 'openai/gpt-5.4-nano', + prompt: 'Refund request for last month.', + options: { + billing: { description: 'Billing support' }, + general: { description: 'General support' }, + }, + }); + + expect(result).toEqual({ + choice: 'billing', + data: {}, + }); + expect(seen).toEqual([ + { + model: { providerResolved: 'openai/gpt-5.4-nano' }, + prompt: 'Refund request for last month.', + }, + ]); + }); + + test('returns structured decision payloads for schema-backed options', async () => { + const adapter = createAiSdkAdapter({ + generateText: async () => + ({ + output: { + decision: 'research', + data: { + query: 'latest cloudflare agents docs', + }, + reasoning: 'Need the newest API details.', + }, + }) as never, + }); + + const result = await adapter.decide({ + model: 'openai/gpt-5.4-nano', + prompt: 'Find the current Cloudflare Agents docs.', + reasoning: true, + options: { + research: { + description: 'Do external research first.', + schema: z.object({ + query: z.string(), + }), + }, + answer: { + description: 'Answer directly.', + }, + }, + }); + + expect(result).toEqual({ + choice: 'research', + data: { + query: 'latest cloudflare agents docs', + }, + reasoning: 'Need the newest API details.', + }); + }); +}); diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index 8ba186d..d5cb592 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -2,12 +2,24 @@ import { generateText, Output } from 'ai'; import { z } from 'zod'; import type { AgentAdapter, StandardSchemaV1 } from '../types.js'; +type AiSdkGenerateText = typeof generateText; +type AiSdkModel = Parameters[0]['model']; + +export interface CreateAiSdkAdapterOptions { + resolveModel?: (model: string) => AiSdkModel; + generateText?: AiSdkGenerateText; +} + /** * Create an adapter that uses the Vercel AI SDK for decide/classify. - * Model strings like 'anthropic/claude-sonnet-4.5' are resolved via the - * AI SDK's model registry. + * By default, model strings are passed straight through to the AI SDK. + * For provider helpers such as `openai(...)`, pass `resolveModel`. */ -export function createAiSdkAdapter(): AgentAdapter { +export function createAiSdkAdapter( + config: CreateAiSdkAdapterOptions = {} +): AgentAdapter { + const generate = config.generateText ?? generateText; + return { async decide({ model, prompt, options, reasoning }) { const optionKeys = Object.keys(options); @@ -18,8 +30,8 @@ export function createAiSdkAdapter(): AgentAdapter { .map(([key, opt]) => `- ${key}: ${opt.description}`) .join('\n'); - const result = await generateText({ - model: resolveModel(model), + const result = await generate({ + model: resolveModel(model, config.resolveModel), system: `You must choose exactly one of the following options:\n${optionDescriptions}`, prompt, output: Output.choice({ @@ -56,8 +68,8 @@ export function createAiSdkAdapter(): AgentAdapter { const systemPrompt = `You must choose exactly one of the following options:\n${optionDescriptions}\n\nRespond with structured output containing the chosen decision and any required data.`; - const result = await generateText({ - model: resolveModel(model), + const result = await generate({ + model: resolveModel(model, config.resolveModel), system: systemPrompt, prompt, output: Output.object({ @@ -96,10 +108,16 @@ function toZodSchema(schema: StandardSchemaV1): z.ZodType { /** * Resolve a model string to an AI SDK model. - * Supports the `provider/model` format via the AI SDK registry. + * Supports custom resolution when users prefer provider helpers such as + * `openai('gpt-5.4-nano')`. */ -function resolveModel(model: string): Parameters[0]['model'] { - // The AI SDK accepts model strings when using a provider registry. - // For now, return as-is — users configure their provider registry externally. +function resolveModel( + model: string, + resolver?: (model: string) => AiSdkModel +): AiSdkModel { + if (resolver) { + return resolver(model); + } + return model as any; } diff --git a/src/examples.test.ts b/src/examples.test.ts index bb160a3..4fa6c8e 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; +import { restoreSession, startSession } from './index.js'; import { + createAiSdkExample, createChatbotExample, AgentNetworkDurableObject, + createCloudflareAgentRunStore, createDurableObjectRunStore, createAdapterExample, createBranchingExample, @@ -15,6 +18,7 @@ import { createEmailExample, createErrorRetryExample, createHitlExample, + createPersistenceExample, createPersistenceSessionHttpHandler, createStreamingSessionHttpController, createJokeExample, @@ -88,6 +92,35 @@ describe('curated examples', () => { } }); + test('ai sdk example routes and drafts a structured reply', async () => { + const machine = createAiSdkExample({ + adapter: { + decide: async () => ({ + choice: 'billing', + data: { confidence: 0.93 }, + }), + }, + draftReply: async ({ route, confidence, message }) => ({ + subject: `${route.toUpperCase()} reply`, + body: `${message} :: ${confidence.toFixed(2)}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ message: 'Please refund invoice 123.' }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + route: 'billing', + confidence: 0.93, + subject: 'BILLING reply', + body: 'Please refund invoice 123. :: 0.93', + }); + } + }); + test('chatbot messages example accumulates structured conversation turns', async () => { const machine = createChatbotMessagesExample(async (messages) => ({ message: { @@ -218,6 +251,48 @@ describe('curated examples', () => { ); }); + test('cloudflare agents example store persists durable sessions in synced state', async () => { + let state = { + sessions: {}, + }; + const store = createCloudflareAgentRunStore({ + getState: () => state, + setState: async (nextState) => { + state = nextState; + }, + }); + const machine = createPersistenceExample(async ({ request, approved }) => ({ + summary: `${request} :: approved=${String(approved)}`, + })); + + const run = await startSession(machine, { + store, + input: { + request: 'Approve the Cloudflare rollout.', + }, + }); + + await run.send({ type: 'approve' }); + + const restored = await restoreSession(machine, { + sessionId: run.sessionId, + store, + }); + + expect(restored.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { + request: 'Approve the Cloudflare rollout.', + approved: true, + summary: 'Approve the Cloudflare rollout. :: approved=true', + }, + }) + ); + expect(Object.keys(state.sessions)).toEqual([run.sessionId]); + }); + test('http session example exposes start, send, and status over Request/Response', async () => { const handle = createPersistenceSessionHttpHandler({ summarize: async ({ request, approved }) => ({ From 0f974b29e3954f76667c81fdb432ea5a160941d0 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 26 Apr 2026 13:25:50 -0400 Subject: [PATCH 29/50] feat: add next app router example --- examples/index.ts | 8 ++ examples/next-app-router.ts | 125 ++++++++++++++++++++++++++++++ readme.md | 19 +++++ src/agent-convert-cli.test.ts | 2 +- src/examples.test.ts | 138 ++++++++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 examples/next-app-router.ts diff --git a/examples/index.ts b/examples/index.ts index 789af5e..2fab2ea 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -30,6 +30,14 @@ export { createJugsExample } from './jugs.js'; export { createMapReduceExample } from './map-reduce.js'; export { createMultiAgentNetworkExample } from './multi-agent-network.js'; export { createNewspaperExample } from './newspaper.js'; +export { + createNextReviewRouteHandlers, + createNextStreamingRouteHandlers, + dynamic as nextAppRouterDynamic, + maxDuration as nextAppRouterMaxDuration, + runtime as nextAppRouterRuntime, + type NextRouteContext, +} from './next-app-router.js'; export { createPlanAndExecuteExample } from './plan-and-execute.js'; export { createPersistenceExample, runPersistenceExample } from './persistence.js'; export { diff --git a/examples/next-app-router.ts b/examples/next-app-router.ts new file mode 100644 index 0000000..c80fa98 --- /dev/null +++ b/examples/next-app-router.ts @@ -0,0 +1,125 @@ +import { type RunStore } from '../src/index.js'; +import { + createPersistenceSessionHttpHandler, + type SessionHttpHandlerOptions, +} from './http-session.js'; +import { + createStreamingSessionHttpController, + type StreamingSessionHttpController, +} from './http-streaming-session.js'; + +/** + * Suggested route-segment config for Next.js App Router route handlers that + * host long-lived agent sessions and streaming responses. + */ +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 30; + +export interface NextRouteContext> { + params: Promise | TParams; +} + +export interface NextReviewRouteHandlers { + sessions: { + POST(request: Request): Promise; + }; + session: { + GET( + request: Request, + context: NextRouteContext<{ sessionId: string }> + ): Promise; + }; + events: { + POST( + request: Request, + context: NextRouteContext<{ sessionId: string }> + ): Promise; + }; +} + +export interface NextStreamingRouteHandlers { + sessions: { + POST(request: Request): Promise; + }; + session: { + GET( + request: Request, + context: NextRouteContext<{ sessionId: string }> + ): Promise; + }; + stream: { + GET( + request: Request, + context: NextRouteContext<{ sessionId: string }> + ): Promise; + }; + advance(streamId: string): void; + dropActiveSession(sessionId: string): void; +} + +export function createNextReviewRouteHandlers( + options: SessionHttpHandlerOptions = {} +): NextReviewRouteHandlers { + const handle = createPersistenceSessionHttpHandler(options); + + return { + sessions: { + POST(request) { + return handle(rewritePath(request, '/sessions')); + }, + }, + session: { + async GET(request, context) { + const { sessionId } = await context.params; + return handle(rewritePath(request, `/sessions/${sessionId}`)); + }, + }, + events: { + async POST(request, context) { + const { sessionId } = await context.params; + return handle(rewritePath(request, `/sessions/${sessionId}/events`)); + }, + }, + }; +} + +export function createNextStreamingRouteHandlers(options: { + store?: RunStore; +} = {}): NextStreamingRouteHandlers { + const controller = createStreamingSessionHttpController(options); + + return { + sessions: { + POST(request) { + return controller.handle(rewritePath(request, '/sessions')); + }, + }, + session: { + async GET(request, context) { + const { sessionId } = await context.params; + return controller.handle(rewritePath(request, `/sessions/${sessionId}`)); + }, + }, + stream: { + async GET(request, context) { + const { sessionId } = await context.params; + return controller.handle( + rewritePath(request, `/sessions/${sessionId}/stream`) + ); + }, + }, + advance(streamId) { + controller.advance(streamId); + }, + dropActiveSession(sessionId) { + controller.dropActiveSession(sessionId); + }, + }; +} + +function rewritePath(request: Request, pathname: string): Request { + const url = new URL(request.url); + url.pathname = pathname; + return new Request(url, request); +} diff --git a/readme.md b/readme.md index c7f208d..9a65a5c 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,7 @@ Each example demonstrates one concept: - [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): persisting snapshots and restoring a session in plain runtime code - [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): runner-agnostic HTTP transport for starting sessions, sending events, and reading snapshots over `Request`/`Response` - [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts): durable SSE transport that reconnects to a restored run and emits only new streaming parts +- [`examples/next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts): Next.js App Router wrappers around the generic HTTP and SSE session transports, including `runtime`, `dynamic`, and `maxDuration` route config values - [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): integrating a persisted agent-machine session store into Cloudflare Agents `onRequest()` routing; requires the Cloudflare Workers runtime - [`examples/persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts): durable multi-agent handoffs that restore from a persisted mid-network snapshot - [`examples/persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts): restore a crashed streaming invoke and continue with only new live emitted parts @@ -41,4 +42,22 @@ Each example demonstrates one concept: Use `classify(...)` when the result is just "what kind of thing is this?" Use `decide(...)` when the result is "what should happen next?" and the chosen branch may need structured data. +## Persistence Adapters + + + +Storage adapters are intentionally bring-your-own. Implement the `RunStore` contract with four methods: + +- `append(sessionId, event)` +- `loadEvents(sessionId, afterSequence?)` +- `loadLatestSnapshot(sessionId)` +- `saveSnapshot(snapshot)` + +Use these examples as templates for your storage layer: + +- [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): the smallest durable session flow with an in-memory store +- [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): the Request/Response transport shape around a `RunStore` +- [`examples/cloudflare-durable-object.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-object.ts): Durable Object-backed event and snapshot persistence +- [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): syncing a `RunStore` into Cloudflare Agents state + **Read the documentation: [stately.ai/docs/agents](https://stately.ai/docs/agents)** diff --git a/src/agent-convert-cli.test.ts b/src/agent-convert-cli.test.ts index 081108b..a048738 100644 --- a/src/agent-convert-cli.test.ts +++ b/src/agent-convert-cli.test.ts @@ -75,7 +75,7 @@ test('agent:convert writes Mermaid and XState output from machine files', async expect(warningResult.stderr).toContain( '[agent:convert] idle on go: Unsupported helper call: unknownTransition() is not statically resolvable.' ); -}); +}, 20000); async function runConvert(args: string[]) { return execFileAsync('pnpm', ['agent:convert', ...args], { diff --git a/src/examples.test.ts b/src/examples.test.ts index 4fa6c8e..640dc95 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -26,6 +26,8 @@ import { createMapReduceExample, createMultiAgentNetworkExample, createNewspaperExample, + createNextReviewRouteHandlers, + createNextStreamingRouteHandlers, runPersistenceExample, runPersistentMultiAgentNetworkExample, runPersistentStreamingExample, @@ -454,6 +456,142 @@ describe('curated examples', () => { ); }); + test('next app router review example adapts Request/Response handlers to dynamic route params', async () => { + const routes = createNextReviewRouteHandlers({ + summarize: async ({ request, approved }) => ({ + summary: `${request} :: approved=${String(approved)}`, + }), + }); + + const startResponse = await routes.sessions.POST( + new Request('https://agent.test/api/agent', { + method: 'POST', + body: JSON.stringify({ + request: 'Approve the quarterly report.', + }), + headers: { + 'content-type': 'application/json', + }, + }) + ); + const startBody = await startResponse.json() as { + sessionId: string; + snapshot: { value: string; status: string }; + }; + + expect(startBody.snapshot).toEqual( + expect.objectContaining({ + value: 'review', + status: 'active', + }) + ); + + const sendResponse = await routes.events.POST( + new Request(`https://agent.test/api/agent/${startBody.sessionId}/events`, { + method: 'POST', + body: JSON.stringify({ type: 'approve' }), + headers: { + 'content-type': 'application/json', + }, + }), + { + params: Promise.resolve({ + sessionId: startBody.sessionId, + }), + } + ); + const sendBody = await sendResponse.json() as { + snapshot: { + value: string; + status: string; + output: { + request: string; + approved: boolean; + summary: string; + }; + }; + }; + + expect(sendBody.snapshot).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { + request: 'Approve the quarterly report.', + approved: true, + summary: 'Approve the quarterly report. :: approved=true', + }, + }) + ); + }); + + test('next app router streaming example reconnects with only new streamed parts', async () => { + const routes = createNextStreamingRouteHandlers(); + + const startResponse = await routes.sessions.POST( + new Request('https://agent.test/api/agent', { + method: 'POST', + body: JSON.stringify({ + streamId: 'next-stream-1', + text: 'hello', + }), + headers: { + 'content-type': 'application/json', + }, + }) + ); + const startBody = await startResponse.json() as { sessionId: string }; + + const firstStreamResponse = await routes.stream.GET( + new Request(`https://agent.test/api/agent/${startBody.sessionId}/stream`), + { + params: Promise.resolve({ + sessionId: startBody.sessionId, + }), + } + ); + const firstReader = createSseReader(firstStreamResponse); + + routes.advance('next-stream-1'); + + await expect(firstReader.next()).resolves.toEqual({ + event: 'textPart', + data: { + type: 'textPart', + delta: 'hel', + }, + }); + + await firstReader.cancel(); + routes.dropActiveSession(startBody.sessionId); + + const secondStreamResponse = await routes.stream.GET( + new Request(`https://agent.test/api/agent/${startBody.sessionId}/stream`), + { + params: Promise.resolve({ + sessionId: startBody.sessionId, + }), + } + ); + const secondReader = createSseReader(secondStreamResponse); + + routes.advance('next-stream-1'); + + await expect(secondReader.next()).resolves.toEqual({ + event: 'textPart', + data: { + type: 'textPart', + delta: 'lo', + }, + }); + await expect(secondReader.next()).resolves.toEqual({ + event: 'done', + data: { + text: 'hello', + }, + }); + }); + test('cloudflare durable network example restores and settles a network run', async () => { const storage = new Map(); const firstInstance = new AgentNetworkDurableObject({ From 4224da7dfed9c0f7bf3c9e13979c688697a74bcb Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 26 Apr 2026 13:34:24 -0400 Subject: [PATCH 30/50] feat: add next ai sdk ui example --- examples/index.ts | 4 + examples/next-ai-sdk-ui.ts | 214 +++++++++++++++++++++++++++++++++++++ readme.md | 1 + src/examples.test.ts | 48 +++++++++ 4 files changed, 267 insertions(+) create mode 100644 examples/next-ai-sdk-ui.ts diff --git a/examples/index.ts b/examples/index.ts index 2fab2ea..14bc9bf 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -30,6 +30,10 @@ export { createJugsExample } from './jugs.js'; export { createMapReduceExample } from './map-reduce.js'; export { createMultiAgentNetworkExample } from './multi-agent-network.js'; export { createNewspaperExample } from './newspaper.js'; +export { + createNextAiSdkUiRoute, + type AgentUiMessage, +} from './next-ai-sdk-ui.js'; export { createNextReviewRouteHandlers, createNextStreamingRouteHandlers, diff --git a/examples/next-ai-sdk-ui.ts b/examples/next-ai-sdk-ui.ts new file mode 100644 index 0000000..8fab1d7 --- /dev/null +++ b/examples/next-ai-sdk-ui.ts @@ -0,0 +1,214 @@ +import { + convertToModelMessages, + createUIMessageStream, + createUIMessageStreamResponse, + streamText, + type UIMessage, +} from 'ai'; +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from '../src/index.js'; +import { createExampleModel } from './_run.js'; + +const uiMessagesSchema = z.object({ + messages: z.array(z.custom()), +}); + +const streamedTextSchema = z.object({ + text: z.string(), +}); + +const notificationSchema = z.object({ + message: z.string(), + level: z.enum(['info', 'warning', 'error']), +}); + +const sourceSchema = z.object({ + id: z.string(), + url: z.string().url(), + title: z.string(), +}); + +export type AgentUiMessage = UIMessage< + unknown, + { + notification: z.infer; + } +>; + +export function createNextAiSdkUiRoute(options: { + streamReply?: (args: { + messages: UIMessage[]; + onDelta: (delta: string) => void; + }) => Promise>; +} = {}) { + const streamReply = + options.streamReply ?? + (async ({ + messages, + onDelta, + }: { + messages: UIMessage[]; + onDelta: (delta: string) => void; + }) => { + const result = streamText({ + model: createExampleModel('openai/gpt-5.4-nano'), + messages: await convertToModelMessages(messages), + }); + + for await (const delta of result.textStream) { + onDelta(delta); + } + + return { + text: await result.text, + }; + }); + + const machine = createAgentMachine({ + id: 'next-ai-sdk-ui-example', + schemas: { + input: uiMessagesSchema, + output: streamedTextSchema, + emitted: { + notification: notificationSchema, + source: sourceSchema, + textPart: z.object({ + delta: z.string(), + }), + }, + events: { + begin: z.object({}), + }, + }, + context: (input) => ({ + messages: input.messages, + finalText: '', + }), + initial: 'ready', + states: { + ready: { + on: { + begin: { + target: 'drafting', + }, + }, + }, + drafting: { + resultSchema: streamedTextSchema, + invoke: async ({ context }, enq) => { + enq.emit({ + type: 'notification', + message: 'Drafting reply...', + level: 'info', + }); + enq.emit({ + type: 'source', + id: 'agent-docs', + url: 'https://stately.ai/docs/agents', + title: 'Stately Agent documentation', + }); + + return streamReply({ + messages: context.messages, + onDelta: (delta) => { + enq.emit({ + type: 'textPart', + delta, + }); + }, + }); + }, + onDone: ({ result }) => ({ + target: 'done', + context: { + finalText: result.text, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + text: context.finalText, + }), + }, + }, + }); + + return { + async POST(request: Request): Promise { + const { messages } = uiMessagesSchema.parse(await request.json()); + + const stream = createUIMessageStream({ + originalMessages: messages, + execute: async ({ writer }) => { + const run = await startSession(machine, { + store: createMemoryRunStore(), + input: { messages }, + }); + + const textId = 'assistant-response'; + let textStarted = false; + + const offNotification = run.on('notification', (event) => { + writer.write({ + type: 'data-notification', + data: { + message: event.message, + level: event.level, + }, + transient: true, + }); + }); + const offSource = run.on('source', (event) => { + writer.write(({ + type: 'source', + value: { + type: 'source', + sourceType: 'url', + id: event.id, + url: event.url, + title: event.title, + }, + } as unknown) as never); + }); + const offTextPart = run.on('textPart', (event) => { + if (!textStarted) { + writer.write({ + type: 'text-start', + id: textId, + }); + textStarted = true; + } + + writer.write({ + type: 'text-delta', + id: textId, + delta: event.delta, + }); + }); + + try { + await run.send({ type: 'begin' }); + } finally { + offNotification(); + offSource(); + offTextPart(); + } + + if (textStarted) { + writer.write({ + type: 'text-end', + id: textId, + }); + } + }, + }); + + return createUIMessageStreamResponse({ stream }); + }, + }; +} diff --git a/readme.md b/readme.md index 9a65a5c..9bc4e32 100644 --- a/readme.md +++ b/readme.md @@ -31,6 +31,7 @@ Each example demonstrates one concept: - [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): runner-agnostic HTTP transport for starting sessions, sending events, and reading snapshots over `Request`/`Response` - [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts): durable SSE transport that reconnects to a restored run and emits only new streaming parts - [`examples/next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts): Next.js App Router wrappers around the generic HTTP and SSE session transports, including `runtime`, `dynamic`, and `maxDuration` route config values +- [`examples/next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts): a Next.js App Router chat route that accepts `UIMessage[]` and streams AI SDK UI message parts from machine-emitted notifications, sources, and text deltas - [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): integrating a persisted agent-machine session store into Cloudflare Agents `onRequest()` routing; requires the Cloudflare Workers runtime - [`examples/persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts): durable multi-agent handoffs that restore from a persisted mid-network snapshot - [`examples/persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts): restore a crashed streaming invoke and continue with only new live emitted parts diff --git a/src/examples.test.ts b/src/examples.test.ts index 640dc95..cef587c 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -26,6 +26,7 @@ import { createMapReduceExample, createMultiAgentNetworkExample, createNewspaperExample, + createNextAiSdkUiRoute, createNextReviewRouteHandlers, createNextStreamingRouteHandlers, runPersistenceExample, @@ -592,6 +593,53 @@ describe('curated examples', () => { }); }); + test('next ai sdk ui route streams UI message parts from machine emissions', async () => { + const route = createNextAiSdkUiRoute({ + streamReply: async ({ messages, onDelta }) => { + expect(messages.at(-1)).toMatchObject({ + role: 'user', + }); + onDelta('Hel'); + onDelta('lo'); + return { text: 'Hello' }; + }, + }); + + const response = await route.POST( + new Request('https://agent.test/api/chat', { + method: 'POST', + body: JSON.stringify({ + messages: [ + { + id: 'user-1', + role: 'user', + parts: [ + { + type: 'text', + text: 'Say hello.', + }, + ], + }, + ], + }), + headers: { + 'content-type': 'application/json', + }, + }) + ); + const body = await response.text(); + + expect(response.headers.get('x-vercel-ai-ui-message-stream')).toBe('v1'); + expect(body).toContain('"type":"data-notification"'); + expect(body).toContain('"message":"Drafting reply..."'); + expect(body).toContain('"type":"source"'); + expect(body).toContain('"title":"Stately Agent documentation"'); + expect(body).toContain('"type":"text-start"'); + expect(body).toContain('"delta":"Hel"'); + expect(body).toContain('"delta":"lo"'); + expect(body).toContain('"type":"text-end"'); + }); + test('cloudflare durable network example restores and settles a network run', async () => { const storage = new Map(); const firstInstance = new AgentNetworkDurableObject({ From be277fbf3e2275433abbee94c0be598fd790f7c9 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Mon, 27 Apr 2026 05:02:52 -0400 Subject: [PATCH 31/50] feat: reorganize and modernize examples --- docs/crewai-parity.md | 59 +++++ examples/README.md | 77 ++++++ examples/_run.ts | 83 ++++++- examples/apps/cloudflare-agents/README.md | 10 + examples/apps/cloudflare-agents/src/index.ts | 14 ++ .../src/review-workflow-agent.ts | 85 +++++++ examples/apps/next/README.md | 18 ++ examples/apps/next/app/api/chat/route.ts | 9 + .../[sessionId]/events/route.ts | 9 + .../api/review-sessions/[sessionId]/route.ts | 9 + .../next/app/api/review-sessions/route.ts | 9 + .../api/stream-sessions/[sessionId]/route.ts | 9 + .../[sessionId]/stream/route.ts | 9 + .../next/app/api/stream-sessions/route.ts | 9 + examples/apps/next/lib/routes.ts | 16 ++ examples/chatbot-messages.ts | 39 +-- examples/chatbot.ts | 58 +++-- examples/content-creator-flow.ts | 141 +++++++++++ examples/email-auto-responder-flow.ts | 228 ++++++++++++++++++ examples/email.ts | 42 ++-- examples/hitl.ts | 48 ++-- examples/index.ts | 54 +++-- examples/lead-score-flow.ts | 209 ++++++++++++++++ examples/meeting-assistant-flow.ts | 152 ++++++++++++ examples/raffle.ts | 33 ++- examples/react-agent.ts | 12 +- examples/self-evaluation-loop-flow.ts | 133 ++++++++++ examples/sql-agent.ts | 12 +- examples/tool-calling.ts | 12 +- examples/write-a-book-flow.ts | 199 +++++++++++++++ readme.md | 34 +-- .../content-creator-flow.test.ts | 30 +++ .../email-auto-responder-flow.test.ts | 41 ++++ .../lead-score-flow.test.ts | 61 +++++ .../meeting-assistant-flow.test.ts | 41 ++++ .../self-evaluation-loop-flow.test.ts | 39 +++ .../write-a-book-flow.test.ts | 44 ++++ src/examples.test.ts | 58 +++++ 38 files changed, 1997 insertions(+), 148 deletions(-) create mode 100644 docs/crewai-parity.md create mode 100644 examples/README.md create mode 100644 examples/apps/cloudflare-agents/README.md create mode 100644 examples/apps/cloudflare-agents/src/index.ts create mode 100644 examples/apps/cloudflare-agents/src/review-workflow-agent.ts create mode 100644 examples/apps/next/README.md create mode 100644 examples/apps/next/app/api/chat/route.ts create mode 100644 examples/apps/next/app/api/review-sessions/[sessionId]/events/route.ts create mode 100644 examples/apps/next/app/api/review-sessions/[sessionId]/route.ts create mode 100644 examples/apps/next/app/api/review-sessions/route.ts create mode 100644 examples/apps/next/app/api/stream-sessions/[sessionId]/route.ts create mode 100644 examples/apps/next/app/api/stream-sessions/[sessionId]/stream/route.ts create mode 100644 examples/apps/next/app/api/stream-sessions/route.ts create mode 100644 examples/apps/next/lib/routes.ts create mode 100644 examples/content-creator-flow.ts create mode 100644 examples/email-auto-responder-flow.ts create mode 100644 examples/lead-score-flow.ts create mode 100644 examples/meeting-assistant-flow.ts create mode 100644 examples/self-evaluation-loop-flow.ts create mode 100644 examples/write-a-book-flow.ts create mode 100644 src/crewai-equivalents/content-creator-flow.test.ts create mode 100644 src/crewai-equivalents/email-auto-responder-flow.test.ts create mode 100644 src/crewai-equivalents/lead-score-flow.test.ts create mode 100644 src/crewai-equivalents/meeting-assistant-flow.test.ts create mode 100644 src/crewai-equivalents/self-evaluation-loop-flow.test.ts create mode 100644 src/crewai-equivalents/write-a-book-flow.test.ts diff --git a/docs/crewai-parity.md b/docs/crewai-parity.md new file mode 100644 index 0000000..3514e24 --- /dev/null +++ b/docs/crewai-parity.md @@ -0,0 +1,59 @@ +# CrewAI Flows Parity + +## Scope + +This document tracks where `@statelyai/agent` covers the practical workflow patterns shown in the official `crewAIInc/crewAI-examples` Flows directory as of April 26, 2026. + +It is intentionally scoped to: + +- runnable workflow patterns +- state/routing/runtime behavior +- human-in-the-loop and iteration behavior +- examples and tests in this repo + +It is intentionally not scoped to: + +- CrewAI-specific decorators and class APIs +- CrewAI Enterprise triggers/integrations as products +- Python-only configuration formats + +## External reference + +CrewAI’s official examples repo currently lists these Flow examples: + +- Content Creator Flow +- Email Auto Responder Flow +- Lead Score Flow +- Meeting Assistant Flow +- Self Evaluation Loop Flow +- Write a Book with Flows + +Primary sources: + +- [CrewAI examples index](https://docs.crewai.com/en/examples/example) +- [CrewAI Flows docs](https://docs.crewai.com/en/concepts/flows) +- [CrewAI examples repo](https://github.com/crewAIInc/crewAI-examples) + +## Matrix + + + +| CrewAI Flow example | Status | Agent equivalent | +| --- | --- | --- | +| Content Creator Flow | Covered | [`examples/content-creator-flow.ts`](/Users/davidkpiano/Code/agent/examples/content-creator-flow.ts), [`src/crewai-equivalents/content-creator-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/content-creator-flow.test.ts) | +| Email Auto Responder Flow | Covered | [`examples/email-auto-responder-flow.ts`](/Users/davidkpiano/Code/agent/examples/email-auto-responder-flow.ts), [`src/crewai-equivalents/email-auto-responder-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/email-auto-responder-flow.test.ts) | +| Lead Score Flow | Covered | [`examples/lead-score-flow.ts`](/Users/davidkpiano/Code/agent/examples/lead-score-flow.ts), [`src/crewai-equivalents/lead-score-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/lead-score-flow.test.ts) | +| Meeting Assistant Flow | Covered | [`examples/meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts), [`src/crewai-equivalents/meeting-assistant-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/meeting-assistant-flow.test.ts) | +| Self Evaluation Loop Flow | Covered | [`examples/self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts), [`src/crewai-equivalents/self-evaluation-loop-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/self-evaluation-loop-flow.test.ts) | +| Write a Book with Flows | Covered | [`examples/write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts), [`src/crewai-equivalents/write-a-book-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/write-a-book-flow.test.ts) | + +## Notes + +- CrewAI’s `content_creator_flow/` directory in the current examples repo clone is empty, so that equivalence is based on the current official descriptions: multi-format content routing across blog, LinkedIn, and research outputs. +- Several of these patterns overlap with existing generic examples here, but they are still represented as CrewAI-named examples so the parity surface is explicit instead of inferred. + +## Differences + +- Logic remains explicit state-machine logic instead of CrewAI decorator-based method routing. +- Durable sessions are modeled through first-class snapshots and event journals rather than framework-managed persistence hidden behind class methods. +- Fan-out is expressed in plain JavaScript `Promise.all(...)` inside invokes where that is simpler than introducing framework-specific branching primitives. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..da9b6bb --- /dev/null +++ b/examples/README.md @@ -0,0 +1,77 @@ +# Examples + + + +This directory is organized by what a developer is trying to do, not by the underlying primitive. + +## Start Here + +- Building an app route: [`apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next) or [`apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents) +- Adding durable sessions: [`persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts) and [`http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts) +- Streaming text or tool progress: [`next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts), [`http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts), and [`tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts) +- Studying orchestration patterns: start in `Workflow Examples` + +## App-Shaped Examples + +These are the best starting points when you want code that already looks like a real app: + +- [`apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next): copy-paste Next.js App Router routes +- [`apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents): copy-paste Cloudflare Agents Worker layout +- [`next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts): AI SDK UI route helper +- [`next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts): App Router session route helpers +- [`cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): Node-safe Cloudflare Agents helper version + +## Workflow Examples + +These focus on real orchestration patterns: + +- Session-first interactive workflows +- Durable restore and transport patterns +- Multi-step planning, routing, and handoff flows + +- [`persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts) +- [`persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts) +- [`persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts) +- [`persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts) +- [`content-creator-flow.ts`](/Users/davidkpiano/Code/agent/examples/content-creator-flow.ts) +- [`email-auto-responder-flow.ts`](/Users/davidkpiano/Code/agent/examples/email-auto-responder-flow.ts) +- [`lead-score-flow.ts`](/Users/davidkpiano/Code/agent/examples/lead-score-flow.ts) +- [`meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts) +- [`self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts) +- [`write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts) +- [`plan-and-execute.ts`](/Users/davidkpiano/Code/agent/examples/plan-and-execute.ts) +- [`reflection.ts`](/Users/davidkpiano/Code/agent/examples/reflection.ts) +- [`rewoo.ts`](/Users/davidkpiano/Code/agent/examples/rewoo.ts) +- [`rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts) +- [`sql-agent.ts`](/Users/davidkpiano/Code/agent/examples/sql-agent.ts) + +## Runtime / Transport Examples + +- [`http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts) +- [`http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts) +- [`cloudflare-durable-object.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-object.ts) +- [`cloudflare-durable-network.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-network.ts) + +## Reference / Concept Examples + +These are smaller building-block examples: + +- One-shot machine execution: [`simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts), [`decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts), [`classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts) +- Interactive session lifecycle: [`chatbot.ts`](/Users/davidkpiano/Code/agent/examples/chatbot.ts), [`chatbot-messages.ts`](/Users/davidkpiano/Code/agent/examples/chatbot-messages.ts), [`hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts), [`raffle.ts`](/Users/davidkpiano/Code/agent/examples/raffle.ts) + +- [`simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts) +- [`decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts) +- [`classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts) +- [`adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts) +- [`tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts) +- [`hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts) +- [`branching.ts`](/Users/davidkpiano/Code/agent/examples/branching.ts) +- [`subflow.ts`](/Users/davidkpiano/Code/agent/examples/subflow.ts) +- [`conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts) + +## Parity Tracking + +- [`../docs/langgraph-parity.md`](/Users/davidkpiano/Code/agent/docs/langgraph-parity.md) +- [`../docs/crewai-parity.md`](/Users/davidkpiano/Code/agent/docs/crewai-parity.md) + +The parity docs track end-result coverage. The files here are the runnable equivalents. diff --git a/examples/_run.ts b/examples/_run.ts index 36f0b65..5e62dc1 100644 --- a/examples/_run.ts +++ b/examples/_run.ts @@ -6,7 +6,13 @@ import { createInterface } from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; import { pathToFileURL } from 'node:url'; import { z } from 'zod'; -import type { AgentAdapter, ExecuteResult, StandardSchemaV1 } from '../src/index.js'; +import type { + AgentAdapter, + AgentRun, + AgentSnapshot, + ExecuteResult, + StandardSchemaV1, +} from '../src/index.js'; export function isMain(moduleUrl: string): boolean { const entry = process.argv[1]; @@ -91,6 +97,81 @@ export function formatResult(result: ExecuteResult) { }; } +export function waitForRunDone< + TContext extends Record, + TValue extends string, + TEvents extends Record, + TOutput, + TEmitted extends Record, +>( + run: AgentRun +): Promise<{ + output: TOutput; + snapshot: AgentSnapshot; +}> { + return new Promise((resolve, reject) => { + const offDone = run.onDone((event) => { + offDone(); + offError(); + resolve(event); + }); + const offError = run.onError((event) => { + offDone(); + offError(); + reject(event.error); + }); + }); +} + +export function waitForRunSnapshot< + TContext extends Record, + TValue extends string, + TEvents extends Record, + TOutput, + TEmitted extends Record, +>( + run: AgentRun, + predicate: ( + snapshot: AgentSnapshot + ) => boolean, + timeoutMs = 1000 +): Promise> { + const current = run.getSnapshot(); + if (predicate(current)) { + return Promise.resolve(current); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('Run snapshot did not reach the expected state in time.')); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + offSnapshot(); + offDone(); + offError(); + }; + + const check = (snapshot: AgentSnapshot) => { + if (predicate(snapshot)) { + cleanup(); + resolve(snapshot); + } + }; + + const offSnapshot = run.onSnapshot(check); + const offDone = run.onDone((event) => { + check(event.snapshot); + }); + const offError = run.onError((event) => { + cleanup(); + reject(event.error); + }); + }); +} + export function createOpenAiDecisionAdapter(): AgentAdapter { return { async decide({ model, prompt, options, reasoning }) { diff --git a/examples/apps/cloudflare-agents/README.md b/examples/apps/cloudflare-agents/README.md new file mode 100644 index 0000000..ca2cc9c --- /dev/null +++ b/examples/apps/cloudflare-agents/README.md @@ -0,0 +1,10 @@ +# Cloudflare Agents Worker Example + +These files show the Cloudflare Agents integration in a real Worker layout with top-level `agents` imports, instead of the Node-safe lazy import used in [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts). + +Included files: + +- `src/review-workflow-agent.ts`: the Agent class that owns the durable review workflow +- `src/index.ts`: the Worker entrypoint that delegates requests through `routeAgentRequest(...)` + +Use this layout when you want a copy-paste starting point for a real Cloudflare Agents app. diff --git a/examples/apps/cloudflare-agents/src/index.ts b/examples/apps/cloudflare-agents/src/index.ts new file mode 100644 index 0000000..190e940 --- /dev/null +++ b/examples/apps/cloudflare-agents/src/index.ts @@ -0,0 +1,14 @@ +import { routeAgentRequest } from 'agents'; +import { ReviewWorkflowAgent } from './review-workflow-agent.js'; + +export { ReviewWorkflowAgent }; + +export default { + async fetch(request: Request, env: Record) { + return ( + await routeAgentRequest(request, env, { + prefix: '/agents', + }) + ) ?? new Response('Not found', { status: 404 }); + }, +}; diff --git a/examples/apps/cloudflare-agents/src/review-workflow-agent.ts b/examples/apps/cloudflare-agents/src/review-workflow-agent.ts new file mode 100644 index 0000000..5fe1ec7 --- /dev/null +++ b/examples/apps/cloudflare-agents/src/review-workflow-agent.ts @@ -0,0 +1,85 @@ +import { Agent } from 'agents'; +import { restoreSession, startSession, type RunStore } from '../../../../src/index.js'; +import { + createCloudflareAgentRunStore, + type CloudflareAgentRunStoreState, +} from '../../../cloudflare-agents.js'; +import { createPersistenceExample } from '../../../persistence.js'; + +export class ReviewWorkflowAgent extends Agent< + Record, + CloudflareAgentRunStoreState +> { + initialState: CloudflareAgentRunStoreState = { + sessions: {}, + }; + + private getStore(): RunStore { + return createCloudflareAgentRunStore({ + getState: () => this.state ?? this.initialState, + setState: (nextState) => this.setState(nextState), + }); + } + + async onRequest(request: Request): Promise { + const url = new URL(request.url); + const machine = createPersistenceExample(); + + if (request.method === 'POST' && url.pathname.endsWith('/start')) { + const body = await request.json() as { request: string }; + const run = await startSession(machine, { + store: this.getStore(), + input: { + request: body.request, + }, + }); + + return Response.json({ + sessionId: run.sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'POST' && url.pathname.endsWith('/events')) { + const body = await request.json() as { + sessionId: string; + event: { type: 'approve' }; + }; + const run = await restoreSession(machine, { + sessionId: body.sessionId, + store: this.getStore(), + }); + + await run.send(body.event); + + return Response.json({ + sessionId: body.sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'GET' && url.pathname.endsWith('/snapshot')) { + const sessionId = requiredSessionId(url); + const run = await restoreSession(machine, { + sessionId, + store: this.getStore(), + }); + + return Response.json({ + sessionId, + snapshot: run.getSnapshot(), + }); + } + + return new Response('Not found', { status: 404 }); + } +} + +function requiredSessionId(url: URL): string { + const sessionId = url.searchParams.get('sessionId'); + if (!sessionId) { + throw new Error('Missing sessionId'); + } + + return sessionId; +} diff --git a/examples/apps/next/README.md b/examples/apps/next/README.md new file mode 100644 index 0000000..bc1ad10 --- /dev/null +++ b/examples/apps/next/README.md @@ -0,0 +1,18 @@ +# Next App Router Examples + +These files show the same `@statelyai/agent` examples in a shape you can drop directly into a Next.js App Router project. + +Included routes: + +- `app/api/chat/route.ts`: AI SDK UI message streaming route +- `app/api/review-sessions/route.ts`: start a durable review session +- `app/api/review-sessions/[sessionId]/route.ts`: fetch a review session snapshot +- `app/api/review-sessions/[sessionId]/events/route.ts`: send events to a review session +- `app/api/stream-sessions/route.ts`: start a streaming session +- `app/api/stream-sessions/[sessionId]/route.ts`: fetch a streaming session snapshot +- `app/api/stream-sessions/[sessionId]/stream/route.ts`: consume the streaming SSE response + +The route handlers are backed by: + +- [`examples/next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts) +- [`examples/next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts) diff --git a/examples/apps/next/app/api/chat/route.ts b/examples/apps/next/app/api/chat/route.ts new file mode 100644 index 0000000..0cc706e --- /dev/null +++ b/examples/apps/next/app/api/chat/route.ts @@ -0,0 +1,9 @@ +import { + chatRoute, + dynamic, + maxDuration, + runtime, +} from '../../../lib/routes.js'; + +export { runtime, dynamic, maxDuration }; +export const POST = chatRoute.POST; diff --git a/examples/apps/next/app/api/review-sessions/[sessionId]/events/route.ts b/examples/apps/next/app/api/review-sessions/[sessionId]/events/route.ts new file mode 100644 index 0000000..3234862 --- /dev/null +++ b/examples/apps/next/app/api/review-sessions/[sessionId]/events/route.ts @@ -0,0 +1,9 @@ +import { + dynamic, + maxDuration, + reviewRoutes, + runtime, +} from '../../../../../lib/routes.js'; + +export { runtime, dynamic, maxDuration }; +export const POST = reviewRoutes.events.POST; diff --git a/examples/apps/next/app/api/review-sessions/[sessionId]/route.ts b/examples/apps/next/app/api/review-sessions/[sessionId]/route.ts new file mode 100644 index 0000000..0f98e4b --- /dev/null +++ b/examples/apps/next/app/api/review-sessions/[sessionId]/route.ts @@ -0,0 +1,9 @@ +import { + dynamic, + maxDuration, + reviewRoutes, + runtime, +} from '../../../../lib/routes.js'; + +export { runtime, dynamic, maxDuration }; +export const GET = reviewRoutes.session.GET; diff --git a/examples/apps/next/app/api/review-sessions/route.ts b/examples/apps/next/app/api/review-sessions/route.ts new file mode 100644 index 0000000..30f3729 --- /dev/null +++ b/examples/apps/next/app/api/review-sessions/route.ts @@ -0,0 +1,9 @@ +import { + dynamic, + maxDuration, + reviewRoutes, + runtime, +} from '../../../lib/routes.js'; + +export { runtime, dynamic, maxDuration }; +export const POST = reviewRoutes.sessions.POST; diff --git a/examples/apps/next/app/api/stream-sessions/[sessionId]/route.ts b/examples/apps/next/app/api/stream-sessions/[sessionId]/route.ts new file mode 100644 index 0000000..bce7ee0 --- /dev/null +++ b/examples/apps/next/app/api/stream-sessions/[sessionId]/route.ts @@ -0,0 +1,9 @@ +import { + dynamic, + maxDuration, + runtime, + streamingRoutes, +} from '../../../../lib/routes.js'; + +export { runtime, dynamic, maxDuration }; +export const GET = streamingRoutes.session.GET; diff --git a/examples/apps/next/app/api/stream-sessions/[sessionId]/stream/route.ts b/examples/apps/next/app/api/stream-sessions/[sessionId]/stream/route.ts new file mode 100644 index 0000000..89ceefe --- /dev/null +++ b/examples/apps/next/app/api/stream-sessions/[sessionId]/stream/route.ts @@ -0,0 +1,9 @@ +import { + dynamic, + maxDuration, + runtime, + streamingRoutes, +} from '../../../../../lib/routes.js'; + +export { runtime, dynamic, maxDuration }; +export const GET = streamingRoutes.stream.GET; diff --git a/examples/apps/next/app/api/stream-sessions/route.ts b/examples/apps/next/app/api/stream-sessions/route.ts new file mode 100644 index 0000000..296725f --- /dev/null +++ b/examples/apps/next/app/api/stream-sessions/route.ts @@ -0,0 +1,9 @@ +import { + dynamic, + maxDuration, + runtime, + streamingRoutes, +} from '../../../lib/routes.js'; + +export { runtime, dynamic, maxDuration }; +export const POST = streamingRoutes.sessions.POST; diff --git a/examples/apps/next/lib/routes.ts b/examples/apps/next/lib/routes.ts new file mode 100644 index 0000000..348f3ec --- /dev/null +++ b/examples/apps/next/lib/routes.ts @@ -0,0 +1,16 @@ +import { + createNextReviewRouteHandlers, + createNextStreamingRouteHandlers, + dynamic as nextDynamic, + maxDuration as nextMaxDuration, + runtime as nextRuntime, +} from '../../../next-app-router.js'; +import { createNextAiSdkUiRoute } from '../../../next-ai-sdk-ui.js'; + +export const runtime = nextRuntime; +export const dynamic = nextDynamic; +export const maxDuration = nextMaxDuration; + +export const reviewRoutes = createNextReviewRouteHandlers(); +export const streamingRoutes = createNextStreamingRouteHandlers(); +export const chatRoute = createNextAiSdkUiRoute(); diff --git a/examples/chatbot-messages.ts b/examples/chatbot-messages.ts index 85ab4e8..bb1c977 100644 --- a/examples/chatbot-messages.ts +++ b/examples/chatbot-messages.ts @@ -1,10 +1,15 @@ import { z } from 'zod'; -import { createAgentMachine } from '../src/index.js'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from '../src/index.js'; import { closePrompt, generateExampleObject, isMain, prompt, + waitForRunSnapshot, } from './_run.js'; const messageSchema = z.object({ @@ -90,31 +95,37 @@ export function createChatbotMessagesExample( async function main() { try { const machine = createChatbotMessagesExample(); - let state = machine.getInitialState(); + const run = await startSession(machine, { + store: createMemoryRunStore(), + }); let lastPrintedAssistantMessage: string | null = null; while (true) { - const result = await machine.execute(state); + const snapshot = await waitForRunSnapshot( + run, + (nextSnapshot) => nextSnapshot.status !== 'active' + ); - if (result.status === 'done') { + if (snapshot.status === 'done') { + console.log({ + status: snapshot.status, + value: snapshot.value, + context: snapshot.context, + output: snapshot.output, + }); break; } - if (result.status !== 'pending') { - throw new Error('Chatbot messages example entered an unexpected error state.'); - } - if ( - result.context.finalMessage?.role === 'assistant' - && result.context.finalMessage.content !== lastPrintedAssistantMessage + snapshot.context.finalMessage?.role === 'assistant' + && snapshot.context.finalMessage.content !== lastPrintedAssistantMessage ) { - console.log(`Assistant: ${result.context.finalMessage.content}`); - lastPrintedAssistantMessage = result.context.finalMessage.content; + console.log(`Assistant: ${snapshot.context.finalMessage.content}`); + lastPrintedAssistantMessage = snapshot.context.finalMessage.content; } const content = await prompt('User (blank to exit)'); - state = machine.transition( - result.state, + await run.send( content ? { type: 'messages.user', diff --git a/examples/chatbot.ts b/examples/chatbot.ts index ab390c1..8f53f30 100644 --- a/examples/chatbot.ts +++ b/examples/chatbot.ts @@ -1,12 +1,19 @@ import { z } from 'zod'; -import { createAgentMachine, decide, decideResultSchema, type AgentAdapter } from '../src/index.js'; +import { + createAgentMachine, + createMemoryRunStore, + decide, + decideResultSchema, + startSession, + type AgentAdapter, +} from '../src/index.js'; import { closePrompt, createOpenAiDecisionAdapter, - formatResult, generateExampleObject, isMain, prompt, + waitForRunSnapshot, } from './_run.js'; const replySchema = z.object({ @@ -122,41 +129,46 @@ export function createChatbotExample( async function main() { try { const machine = createChatbotExample(); - let state = machine.getInitialState(); + const run = await startSession(machine, { + store: createMemoryRunStore(), + }); let lastPrintedAssistantMessage: string | null = null; while (true) { - const result = await machine.execute(state); + const snapshot = await waitForRunSnapshot( + run, + (nextSnapshot) => nextSnapshot.status !== 'active' + ); - if (result.status === 'done') { + if (snapshot.status === 'done') { if ( - result.output && - typeof result.output === 'object' && - 'lastAssistantMessage' in result.output && - result.output.lastAssistantMessage && - result.output.lastAssistantMessage !== lastPrintedAssistantMessage + snapshot.output && + typeof snapshot.output === 'object' && + 'lastAssistantMessage' in snapshot.output && + snapshot.output.lastAssistantMessage && + snapshot.output.lastAssistantMessage !== lastPrintedAssistantMessage ) { - console.log(`Assistant: ${result.output.lastAssistantMessage}`); + console.log(`Assistant: ${snapshot.output.lastAssistantMessage}`); } - console.log(formatResult(result)); + console.log({ + status: snapshot.status, + value: snapshot.value, + context: snapshot.context, + output: snapshot.output, + }); break; } - if (result.status !== 'pending') { - throw new Error('Chatbot example entered an unexpected error state.'); - } - - if ( - result.context.lastAssistantMessage && - result.context.lastAssistantMessage !== lastPrintedAssistantMessage + if ( + snapshot.context.lastAssistantMessage && + snapshot.context.lastAssistantMessage !== lastPrintedAssistantMessage ) { - console.log(`Assistant: ${result.context.lastAssistantMessage}`); - lastPrintedAssistantMessage = result.context.lastAssistantMessage; + console.log(`Assistant: ${snapshot.context.lastAssistantMessage}`); + lastPrintedAssistantMessage = snapshot.context.lastAssistantMessage; } const message = await prompt('User (blank to exit)'); - state = machine.transition( - result.state, + await run.send( message ? { type: 'user.message', message } : { type: 'user.exit' } diff --git a/examples/content-creator-flow.ts b/examples/content-creator-flow.ts new file mode 100644 index 0000000..efc483d --- /dev/null +++ b/examples/content-creator-flow.ts @@ -0,0 +1,141 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const routeSchema = z.object({ + route: z.enum(['blog', 'linkedin', 'research']), +}); + +const contentSchema = z.object({ + title: z.string(), + body: z.string(), +}); + +type ContentRoute = z.infer['route']; + +export function createContentCreatorFlowExample(options: { + routeRequest?: (request: string) => Promise>; + createBlog?: (request: string) => Promise>; + createLinkedInPost?: (request: string) => Promise>; + createResearchReport?: (request: string) => Promise>; +} = {}) { + const routeRequest = + options.routeRequest ?? + ((request: string) => + generateExampleObject({ + schema: routeSchema, + system: + 'Route content requests to blog, linkedin, or research. Choose research for analysis-heavy requests, linkedin for short professional posts, and blog for longer educational pieces.', + prompt: request, + })); + + const createBlog = + options.createBlog ?? + ((request: string) => + generateExampleObject({ + schema: contentSchema, + system: 'Write a concise professional blog post.', + prompt: request, + })); + + const createLinkedInPost = + options.createLinkedInPost ?? + ((request: string) => + generateExampleObject({ + schema: contentSchema, + system: 'Write a concise professional LinkedIn post.', + prompt: request, + })); + + const createResearchReport = + options.createResearchReport ?? + ((request: string) => + generateExampleObject({ + schema: contentSchema, + system: 'Write a concise research-style briefing with findings and implications.', + prompt: request, + })); + + return createAgentMachine({ + id: 'content-creator-flow-example', + schemas: { + input: z.object({ + request: z.string(), + }), + output: z.object({ + route: z.enum(['blog', 'linkedin', 'research']).nullable(), + title: z.string().nullable(), + body: z.string().nullable(), + }), + }, + context: (input) => ({ + request: input.request, + route: null as ContentRoute | null, + title: null as string | null, + body: null as string | null, + }), + initial: 'routing', + states: { + routing: { + resultSchema: routeSchema, + invoke: async ({ context }) => routeRequest(context.request), + onDone: ({ result }) => ({ + target: 'creating', + context: { + route: result.route, + }, + }), + }, + creating: { + resultSchema: contentSchema, + invoke: async ({ context }) => { + switch (context.route) { + case 'linkedin': + return createLinkedInPost(context.request); + case 'research': + return createResearchReport(context.request); + case 'blog': + default: + return createBlog(context.request); + } + }, + onDone: ({ result }) => ({ + target: 'done', + context: { + title: result.title, + body: result.body, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + route: context.route, + title: context.title, + body: context.body, + }), + }, + }, + }); +} + +async function main() { + try { + const request = await prompt('Content request'); + const machine = createContentCreatorFlowExample(); + const result = await machine.execute(machine.getInitialState({ request })); + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/email-auto-responder-flow.ts b/examples/email-auto-responder-flow.ts new file mode 100644 index 0000000..8ab30ea --- /dev/null +++ b/examples/email-auto-responder-flow.ts @@ -0,0 +1,228 @@ +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + restoreSession, + startSession, + type RunStore, +} from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const incomingEmailSchema = z.object({ + id: z.string(), + subject: z.string(), + body: z.string(), + sender: z.string(), +}); + +const draftResponseSchema = z.object({ + draft: z.string(), +}); + +type IncomingEmail = z.infer; + +export function createEmailAutoResponderFlowExample( + createDraft: (email: IncomingEmail) => Promise> = ( + email + ) => + generateExampleObject({ + schema: draftResponseSchema, + system: 'Write a concise professional email reply draft.', + prompt: [ + `Sender: ${email.sender}`, + `Subject: ${email.subject}`, + '', + email.body, + ].join('\n'), + }) +) { + return createAgentMachine({ + id: 'email-auto-responder-flow-example', + schemas: { + input: z.object({}), + output: z.object({ + processedIds: z.array(z.string()), + drafts: z.record(z.string(), z.string()), + }), + events: { + 'emails.received': z.object({ + emails: z.array(incomingEmailSchema), + }), + stop: z.object({}), + }, + }, + context: () => ({ + queue: [] as IncomingEmail[], + currentEmail: null as IncomingEmail | null, + processedIds: [] as string[], + drafts: {} as Record, + }), + initial: 'waiting', + states: { + waiting: { + on: { + 'emails.received': ({ context, event }) => { + const nextQueue = [...context.queue, ...event.emails].filter( + (email) => + !context.processedIds.includes(email.id) + && email.id !== context.currentEmail?.id + ); + const [currentEmail, ...queue] = nextQueue; + + if (!currentEmail) { + return { + context: { + queue, + }, + }; + } + + return { + target: 'drafting', + context: { + currentEmail, + queue, + }, + }; + }, + stop: { + target: 'done', + }, + }, + }, + drafting: { + on: { + 'emails.received': ({ context, event }) => ({ + context: { + queue: [...context.queue, ...event.emails].filter( + (email) => + !context.processedIds.includes(email.id) + && email.id !== context.currentEmail?.id + ), + }, + }), + stop: { + target: 'done', + }, + }, + resultSchema: draftResponseSchema, + invoke: async ({ context }) => createDraft(context.currentEmail!), + onDone: ({ result, context }) => { + const currentEmail = context.currentEmail!; + const processedIds = [...context.processedIds, currentEmail.id]; + const drafts = { + ...context.drafts, + [currentEmail.id]: result.draft, + }; + const [nextEmail, ...queue] = context.queue; + + if (nextEmail) { + return { + target: 'drafting', + context: { + currentEmail: nextEmail, + queue, + processedIds, + drafts, + }, + }; + } + + return { + target: 'waiting', + context: { + currentEmail: null, + queue: [], + processedIds, + drafts, + }, + }; + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + processedIds: context.processedIds, + drafts: context.drafts, + }), + }, + }, + }); +} + +export async function runEmailAutoResponderFlowExample( + emails: IncomingEmail[], + options: { + createDraft?: (email: IncomingEmail) => Promise>; + store?: RunStore; + } = {} +) { + const machine = createEmailAutoResponderFlowExample(options.createDraft); + const store = options.store ?? createMemoryRunStore(); + const run = await startSession(machine, { + store, + input: {}, + }); + + await run.send({ + type: 'emails.received', + emails, + }); + + return { + sessionId: run.sessionId, + snapshot: run.getSnapshot(), + restoredSnapshot: ( + await restoreSession(machine, { + sessionId: run.sessionId, + store, + }) + ).getSnapshot(), + }; +} + +async function main() { + try { + const sender = await prompt('Sender'); + const subject = await prompt('Subject'); + const body = await prompt('Body'); + const result = await runEmailAutoResponderFlowExample([ + { + id: 'email-1', + sender, + subject, + body, + }, + ]); + + console.log(formatResult({ + status: + result.snapshot.status === 'done' + ? 'done' + : result.snapshot.status === 'error' + ? 'error' + : 'pending', + state: { + value: result.snapshot.value, + context: result.snapshot.context, + status: result.snapshot.status, + input: result.snapshot.input, + }, + output: result.snapshot.output, + context: result.snapshot.context, + error: result.snapshot.error, + } as never)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/email.ts b/examples/email.ts index 053959a..972228b 100644 --- a/examples/email.ts +++ b/examples/email.ts @@ -1,13 +1,20 @@ import { z } from 'zod'; -import { createAgentMachine, decide, decideResultSchema, type AgentAdapter } from '../src/index.js'; +import { + createAgentMachine, + createMemoryRunStore, + decide, + decideResultSchema, + startSession, + type AgentAdapter, +} from '../src/index.js'; import { closePrompt, createOpenAiDecisionAdapter, - formatResult, generateExampleObject, generateExampleText, isMain, prompt, + waitForRunSnapshot, } from './_run.js'; const draftSchema = z.object({ @@ -219,28 +226,35 @@ async function main() { const email = await prompt('Incoming email'); const instructions = await prompt('Instructions'); const machine = createEmailExample(); - let state = machine.getInitialState({ email, instructions }); + const run = await startSession(machine, { + store: createMemoryRunStore(), + input: { email, instructions }, + }); while (true) { - const result = await machine.execute(state); + const snapshot = await waitForRunSnapshot( + run, + (nextSnapshot) => nextSnapshot.status !== 'active' + ); - if (result.status === 'done') { - console.log(formatResult(result)); + if (snapshot.status === 'done') { + console.log({ + status: snapshot.status, + value: snapshot.value, + context: snapshot.context, + output: snapshot.output, + }); break; } - if (result.status !== 'pending') { - throw new Error('Email example entered an unexpected error state.'); - } - - if (result.value === 'clarifying') { - console.log(result.context.questions.join('\n')); + if (snapshot.value === 'clarifying') { + console.log(snapshot.context.questions.join('\n')); const answer = await prompt('Clarification'); - state = machine.transition(result.state, { type: 'user.answer', answer }); + await run.send({ type: 'user.answer', answer }); continue; } - state = result.state; + throw new Error('Email example entered an unexpected pending state.'); } } finally { closePrompt(); diff --git a/examples/hitl.ts b/examples/hitl.ts index 1074b87..7e88077 100644 --- a/examples/hitl.ts +++ b/examples/hitl.ts @@ -1,11 +1,15 @@ import { z } from 'zod'; -import { createAgentMachine } from '../src/index.js'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from '../src/index.js'; import { closePrompt, - formatResult, generateExampleObject, isMain, prompt, + waitForRunSnapshot, } from './_run.js'; const draftSchema = z.object({ @@ -88,33 +92,49 @@ async function main() { try { const task = await prompt('Task'); const machine = createHitlExample(); - let state = await machine.invoke(machine.getInitialState({ task })); + const run = await startSession(machine, { + store: createMemoryRunStore(), + input: { task }, + }); + + while (true) { + const snapshot = await waitForRunSnapshot( + run, + (nextSnapshot) => nextSnapshot.status !== 'active' + ); + + if (snapshot.status === 'done') { + console.log({ + status: snapshot.status, + value: snapshot.value, + context: snapshot.context, + output: snapshot.output, + }); + break; + } - while (state.status === 'pending') { const message = await prompt('Add note, or type /approve or /cancel'); if (message === '/approve') { - state = machine.transition(state, { type: 'user.approve' }); - break; + await run.send({ type: 'user.approve' }); + continue; } if (message === '/cancel') { - state = machine.transition(state, { type: 'user.cancel' }); - break; + await run.send({ type: 'user.cancel' }); + continue; } - state = machine.transition(state, { + await run.send({ type: 'user.message', message, }); console.log({ - status: state.status, - value: state.value, - context: state.context, + status: run.getSnapshot().status, + value: run.getSnapshot().value, + context: run.getSnapshot().context, }); } - - console.log(formatResult(await machine.execute(state))); } finally { closePrompt(); } diff --git a/examples/index.ts b/examples/index.ts index 14bc9bf..f698113 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -1,15 +1,7 @@ -export { createSimpleExample } from './simple.js'; -export { createSqlAgentExample } from './sql-agent.js'; -export { createHitlExample } from './hitl.js'; +// Runtime and deployment examples export { createPersistenceSessionHttpHandler } from './http-session.js'; export { createStreamingSessionHttpController } from './http-streaming-session.js'; -export { createDecideExample } from './decide.js'; -export { createClassifyExample } from './classify.js'; -export { createAdapterExample } from './adapter.js'; export { createAiSdkExample } from './ai-sdk.js'; -export { createChatbotExample } from './chatbot.js'; -export { createChatbotMessagesExample } from './chatbot-messages.js'; -export { createConditionalSubflowExample } from './conditional-subflow.js'; export { createCloudflareAgentRunStore, createCloudflareAgentsExample, @@ -22,14 +14,6 @@ export { type DurableObjectStorageLike, } from './cloudflare-durable-object.js'; export { AgentNetworkDurableObject } from './cloudflare-durable-network.js'; -export { createCustomerServiceSimExample } from './customer-service-sim.js'; -export { createEmailExample } from './email.js'; -export { createErrorRetryExample } from './error-retry.js'; -export { createJokeExample } from './joke.js'; -export { createJugsExample } from './jugs.js'; -export { createMapReduceExample } from './map-reduce.js'; -export { createMultiAgentNetworkExample } from './multi-agent-network.js'; -export { createNewspaperExample } from './newspaper.js'; export { createNextAiSdkUiRoute, type AgentUiMessage, @@ -42,7 +26,6 @@ export { runtime as nextAppRouterRuntime, type NextRouteContext, } from './next-app-router.js'; -export { createPlanAndExecuteExample } from './plan-and-execute.js'; export { createPersistenceExample, runPersistenceExample } from './persistence.js'; export { createPersistentMultiAgentNetworkExample, @@ -56,6 +39,18 @@ export { createPersistentSupervisorExample, runPersistentSupervisorExample, } from './persistent-supervisor.js'; + +// Workflow examples +export { createContentCreatorFlowExample } from './content-creator-flow.js'; +export { + createEmailAutoResponderFlowExample, + runEmailAutoResponderFlowExample, +} from './email-auto-responder-flow.js'; +export { createErrorRetryExample } from './error-retry.js'; +export { createLeadScoreFlowExample } from './lead-score-flow.js'; +export { createMeetingAssistantFlowExample } from './meeting-assistant-flow.js'; +export { createMultiAgentNetworkExample } from './multi-agent-network.js'; +export { createPlanAndExecuteExample } from './plan-and-execute.js'; export { createRaffleExample } from './raffle.js'; export { createRagExample } from './rag.js'; export { createReactAgentExample } from './react-agent.js'; @@ -67,9 +62,28 @@ export { } from './react-agent-from-scratch.js'; export { createRewooExample } from './rewoo.js'; export { createReflectionExample } from './reflection.js'; -export { createRiverCrossingExample } from './river-crossing.js'; +export { createSelfEvaluationLoopFlowExample } from './self-evaluation-loop-flow.js'; +export { createSupervisorExample } from './supervisor.js'; +export { createWriteABookFlowExample } from './write-a-book-flow.js'; +export { createSqlAgentExample } from './sql-agent.js'; + +// Reference and concept examples +export { createAdapterExample } from './adapter.js'; export { createBranchingExample } from './branching.js'; +export { createChatbotExample } from './chatbot.js'; +export { createChatbotMessagesExample } from './chatbot-messages.js'; +export { createClassifyExample } from './classify.js'; +export { createConditionalSubflowExample } from './conditional-subflow.js'; +export { createCustomerServiceSimExample } from './customer-service-sim.js'; +export { createDecideExample } from './decide.js'; +export { createEmailExample } from './email.js'; +export { createHitlExample } from './hitl.js'; +export { createJokeExample } from './joke.js'; +export { createJugsExample } from './jugs.js'; +export { createMapReduceExample } from './map-reduce.js'; +export { createNewspaperExample } from './newspaper.js'; +export { createRiverCrossingExample } from './river-crossing.js'; +export { createSimpleExample } from './simple.js'; export { createSubflowExample } from './subflow.js'; -export { createSupervisorExample } from './supervisor.js'; export { createToolCallingExample } from './tool-calling.js'; export { createTutorExample } from './tutor.js'; diff --git a/examples/lead-score-flow.ts b/examples/lead-score-flow.ts new file mode 100644 index 0000000..459f643 --- /dev/null +++ b/examples/lead-score-flow.ts @@ -0,0 +1,209 @@ +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from '../src/index.js'; +import { + closePrompt, + isMain, + prompt, + waitForRunSnapshot, +} from './_run.js'; + +const leadSchema = z.object({ + id: z.string(), + company: z.string(), + contact: z.string(), +}); + +const scoredLeadSchema = leadSchema.extend({ + score: z.number().min(0).max(100), + rationale: z.string(), +}); + +const scoringSchema = z.object({ + scoredLeads: z.array(scoredLeadSchema), +}); + +const emailDraftSchema = z.object({ + leadId: z.string(), + draft: z.string(), +}); + +const emailBatchSchema = z.object({ + drafts: z.array(emailDraftSchema), +}); + +type Lead = z.infer; + +export function createLeadScoreFlowExample(options: { + scoreLeads?: (args: { + leads: Lead[]; + reviewNote: string | null; + }) => Promise>; + writeEmails?: (leads: z.infer[]) => Promise>; +} = {}) { + const scoreLeads = + options.scoreLeads ?? + (async ({ leads, reviewNote }) => ({ + scoredLeads: leads + .map((lead, index) => ({ + ...lead, + score: Math.max(0, 90 - index * 10 - (reviewNote ? 5 : 0)), + rationale: reviewNote + ? `Adjusted after review: ${reviewNote}` + : `Initial score for ${lead.company}`, + })) + .sort((a, b) => b.score - a.score), + })); + + const writeEmails = + options.writeEmails ?? + (async (leads) => ({ + drafts: leads.map((lead) => ({ + leadId: lead.id, + draft: `Hi ${lead.contact}, I would love to talk about ${lead.company}.`, + })), + })); + + return createAgentMachine({ + id: 'lead-score-flow-example', + schemas: { + input: z.object({ + leads: z.array(leadSchema), + }), + output: z.object({ + scoredLeads: z.array(scoredLeadSchema), + topLeads: z.array(scoredLeadSchema), + emailDrafts: z.array(emailDraftSchema), + reviewCount: z.number(), + }), + events: { + 'review.approve': z.object({}), + 'review.requestChanges': z.object({ + note: z.string(), + }), + }, + }, + context: (input) => ({ + leads: input.leads, + scoredLeads: [] as z.infer[], + topLeads: [] as z.infer[], + emailDrafts: [] as z.infer[], + reviewNote: null as string | null, + reviewCount: 0, + }), + initial: 'scoring', + states: { + scoring: { + resultSchema: scoringSchema, + invoke: async ({ context }) => + scoreLeads({ + leads: context.leads, + reviewNote: context.reviewNote, + }), + onDone: ({ result, context }) => ({ + target: 'reviewing', + context: { + scoredLeads: result.scoredLeads, + topLeads: result.scoredLeads.slice(0, 3), + reviewNote: null, + reviewCount: context.reviewCount + 1, + }, + }), + }, + reviewing: { + on: { + 'review.approve': { + target: 'writing', + }, + 'review.requestChanges': ({ event }) => ({ + target: 'scoring', + context: { + reviewNote: event.note, + }, + }), + }, + }, + writing: { + resultSchema: emailBatchSchema, + invoke: async ({ context }) => writeEmails(context.scoredLeads), + onDone: ({ result }) => ({ + target: 'done', + context: { + emailDrafts: result.drafts, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + scoredLeads: context.scoredLeads, + topLeads: context.topLeads, + emailDrafts: context.emailDrafts, + reviewCount: context.reviewCount, + }), + }, + }, + }); +} + +async function main() { + try { + const companies = (await prompt('Comma-separated company names')) + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const machine = createLeadScoreFlowExample(); + const run = await startSession(machine, { + store: createMemoryRunStore(), + input: { + leads: companies.map((company, index) => ({ + id: `lead-${index + 1}`, + company, + contact: `Contact ${index + 1}`, + })), + }, + }); + + while (true) { + const snapshot = await waitForRunSnapshot( + run, + (nextSnapshot) => nextSnapshot.status !== 'active' + ); + + if (snapshot.status === 'done') { + console.log({ + status: snapshot.status, + value: snapshot.value, + context: snapshot.context, + output: snapshot.output, + }); + break; + } + + if (snapshot.value === 'reviewing') { + console.log(snapshot.context.topLeads); + const answer = await prompt('Type /approve or provide a review note'); + await run.send( + answer === '/approve' + ? { type: 'review.approve' } + : { + type: 'review.requestChanges', + note: answer, + } + ); + continue; + } + + throw new Error('Lead score flow entered an unexpected pending state.'); + } + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/meeting-assistant-flow.ts b/examples/meeting-assistant-flow.ts new file mode 100644 index 0000000..b55e26b --- /dev/null +++ b/examples/meeting-assistant-flow.ts @@ -0,0 +1,152 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const taskSchema = z.object({ + title: z.string(), + owner: z.string(), +}); + +const extractionSchema = z.object({ + summary: z.string(), + tasks: z.array(taskSchema), +}); + +const fanOutSchema = z.object({ + trelloCardIds: z.array(z.string()), + csvPath: z.string(), + slackMessageId: z.string(), +}); + +export function createMeetingAssistantFlowExample(options: { + extractTasks?: (notes: string) => Promise>; + addTasksToTrello?: (tasks: z.infer[]) => Promise<{ trelloCardIds: string[] }>; + saveTasksToCsv?: (tasks: z.infer[]) => Promise<{ csvPath: string }>; + sendSlackNotification?: (args: { + summary: string; + tasks: z.infer[]; + }) => Promise<{ slackMessageId: string }>; +} = {}) { + const extractTasks = + options.extractTasks ?? + ((notes: string) => + generateExampleObject({ + schema: extractionSchema, + system: 'Extract a concise meeting summary and explicit action items.', + prompt: notes, + })); + + const addTasksToTrello = + options.addTasksToTrello ?? + (async (tasks) => ({ + trelloCardIds: tasks.map((_, index) => `card-${index + 1}`), + })); + + const saveTasksToCsv = + options.saveTasksToCsv ?? + (async () => ({ + csvPath: 'new_tasks.csv', + })); + + const sendSlackNotification = + options.sendSlackNotification ?? + (async () => ({ + slackMessageId: 'slack-message-1', + })); + + return createAgentMachine({ + id: 'meeting-assistant-flow-example', + schemas: { + input: z.object({ + notes: z.string(), + }), + output: z.object({ + summary: z.string().nullable(), + tasks: z.array(taskSchema), + trelloCardIds: z.array(z.string()), + csvPath: z.string().nullable(), + slackMessageId: z.string().nullable(), + }), + }, + context: (input) => ({ + notes: input.notes, + summary: null as string | null, + tasks: [] as z.infer[], + trelloCardIds: [] as string[], + csvPath: null as string | null, + slackMessageId: null as string | null, + }), + initial: 'extracting', + states: { + extracting: { + resultSchema: extractionSchema, + invoke: async ({ context }) => extractTasks(context.notes), + onDone: ({ result }) => ({ + target: 'dispatching', + context: { + summary: result.summary, + tasks: result.tasks, + }, + }), + }, + dispatching: { + resultSchema: fanOutSchema, + invoke: async ({ context }) => { + const [trello, csv, slack] = await Promise.all([ + addTasksToTrello(context.tasks), + saveTasksToCsv(context.tasks), + sendSlackNotification({ + summary: context.summary ?? '', + tasks: context.tasks, + }), + ]); + + return { + trelloCardIds: trello.trelloCardIds, + csvPath: csv.csvPath, + slackMessageId: slack.slackMessageId, + }; + }, + onDone: ({ result }) => ({ + target: 'done', + context: { + trelloCardIds: result.trelloCardIds, + csvPath: result.csvPath, + slackMessageId: result.slackMessageId, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + summary: context.summary, + tasks: context.tasks, + trelloCardIds: context.trelloCardIds, + csvPath: context.csvPath, + slackMessageId: context.slackMessageId, + }), + }, + }, + }); +} + +async function main() { + try { + const notes = await prompt('Meeting notes'); + const machine = createMeetingAssistantFlowExample(); + const result = await machine.execute(machine.getInitialState({ notes })); + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/raffle.ts b/examples/raffle.ts index b649860..f02b3a8 100644 --- a/examples/raffle.ts +++ b/examples/raffle.ts @@ -1,11 +1,15 @@ import { z } from 'zod'; -import { createAgentMachine } from '../src/index.js'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from '../src/index.js'; import { closePrompt, - formatResult, generateExampleObject, isMain, prompt, + waitForRunSnapshot, } from './_run.js'; const winnerSchema = z.object({ @@ -93,23 +97,28 @@ export function createRaffleExample( async function main() { try { const machine = createRaffleExample(); - let state = machine.getInitialState(); + const run = await startSession(machine, { + store: createMemoryRunStore(), + }); while (true) { - const result = await machine.execute(state); + const snapshot = await waitForRunSnapshot( + run, + (nextSnapshot) => nextSnapshot.status !== 'active' + ); - if (result.status === 'done') { - console.log(formatResult(result)); + if (snapshot.status === 'done') { + console.log({ + status: snapshot.status, + value: snapshot.value, + context: snapshot.context, + output: snapshot.output, + }); break; } - if (result.status !== 'pending') { - throw new Error('Raffle example entered an unexpected error state.'); - } - const entry = await prompt('Entry (blank to draw)'); - state = machine.transition( - result.state, + await run.send( entry ? { type: 'user.entry', entry } : { type: 'user.draw' } ); } diff --git a/examples/react-agent.ts b/examples/react-agent.ts index 0d15e45..8e20a84 100644 --- a/examples/react-agent.ts +++ b/examples/react-agent.ts @@ -10,6 +10,7 @@ import { generateExampleText, isMain, prompt, + waitForRunDone, } from './_run.js'; const reactModelResultSchema = z.discriminatedUnion('kind', [ @@ -87,15 +88,8 @@ async function main() { console.log(`${event.toolName} -> ${String(event.output)}`); }); - await new Promise((resolve, reject) => { - run.onDone((event) => { - console.log(event.output); - resolve(); - }); - run.onError((event) => { - reject(event.error); - }); - }); + const done = await waitForRunDone(run); + console.log(done.output); } finally { closePrompt(); } diff --git a/examples/self-evaluation-loop-flow.ts b/examples/self-evaluation-loop-flow.ts new file mode 100644 index 0000000..1827ddb --- /dev/null +++ b/examples/self-evaluation-loop-flow.ts @@ -0,0 +1,133 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + isMain, + prompt, +} from './_run.js'; + +const postSchema = z.object({ + post: z.string(), +}); + +const evaluationSchema = z.object({ + valid: z.boolean(), + feedback: z.string().nullable(), +}); + +export function createSelfEvaluationLoopFlowExample(options: { + generatePost?: (args: { + topic: string; + feedback: string | null; + attempt: number; + }) => Promise>; + evaluatePost?: (post: string) => Promise>; + maxAttempts?: number; +} = {}) { + const generatePost = + options.generatePost ?? + ((args: { topic: string; feedback: string | null; attempt: number }) => + generateExampleObject({ + schema: postSchema, + system: 'Write a playful X post in a Shakespearean tone with no emojis and under 280 characters.', + prompt: [ + `Topic: ${args.topic}`, + `Attempt: ${args.attempt}`, + args.feedback ? `Feedback to address: ${args.feedback}` : 'Feedback: none', + ].join('\n'), + })); + + const evaluatePost = + options.evaluatePost ?? + ((post: string) => + generateExampleObject({ + schema: evaluationSchema, + system: + 'Validate whether the X post is under 280 characters, uses no emojis, and stays playful. Return feedback only when it should be revised.', + prompt: post, + })); + + return createAgentMachine({ + id: 'self-evaluation-loop-flow-example', + schemas: { + input: z.object({ + topic: z.string(), + }), + output: z.object({ + post: z.string().nullable(), + valid: z.boolean(), + feedback: z.string().nullable(), + attempt: z.number(), + }), + }, + context: (input) => ({ + topic: input.topic, + post: null as string | null, + valid: false, + feedback: null as string | null, + attempt: 1, + maxAttempts: options.maxAttempts ?? 3, + }), + initial: 'generating', + states: { + generating: { + resultSchema: postSchema, + invoke: async ({ context }) => + generatePost({ + topic: context.topic, + feedback: context.feedback, + attempt: context.attempt, + }), + onDone: ({ result }) => ({ + target: 'evaluating', + context: { + post: result.post, + }, + }), + }, + evaluating: { + resultSchema: evaluationSchema, + invoke: async ({ context }) => evaluatePost(context.post ?? ''), + onDone: ({ result, context }) => ({ + target: + result.valid || context.attempt >= context.maxAttempts + ? 'done' + : 'generating', + context: { + valid: result.valid, + feedback: result.feedback, + attempt: result.valid + ? context.attempt + : context.attempt + 1, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + post: context.post, + valid: context.valid, + feedback: context.feedback, + attempt: context.attempt, + }), + }, + }, + }); +} + +async function main() { + try { + const topic = await prompt('Topic'); + const machine = createSelfEvaluationLoopFlowExample(); + const result = await machine.execute(machine.getInitialState({ topic })); + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/sql-agent.ts b/examples/sql-agent.ts index 14776b9..d48b0c6 100644 --- a/examples/sql-agent.ts +++ b/examples/sql-agent.ts @@ -13,6 +13,7 @@ import { generateExampleObject, isMain, prompt, + waitForRunDone, } from './_run.js'; const sqlValueSchema = z.union([z.string(), z.number(), z.null()]); @@ -260,15 +261,8 @@ async function main() { console.log(`${event.toolName} -> ${JSON.stringify(event.output)}`); }); - await new Promise((resolve, reject) => { - run.onDone((event) => { - console.log(event.output); - resolve(); - }); - run.onError((event) => { - reject(event.error); - }); - }); + const done = await waitForRunDone(run); + console.log(done.output); } finally { closePrompt(); } diff --git a/examples/tool-calling.ts b/examples/tool-calling.ts index 719fcfb..36e2d12 100644 --- a/examples/tool-calling.ts +++ b/examples/tool-calling.ts @@ -9,6 +9,7 @@ import { generateExampleObject, isMain, prompt, + waitForRunDone, } from './_run.js'; const forecastSchema = z.object({ @@ -128,15 +129,8 @@ async function main() { console.log(`${event.toolName} -> ${event.output.forecast}`); }); - await new Promise((resolve, reject) => { - run.onDone((event) => { - console.log(event.output); - resolve(); - }); - run.onError((event) => { - reject(event.error); - }); - }); + const done = await waitForRunDone(run); + console.log(done.output); } finally { closePrompt(); } diff --git a/examples/write-a-book-flow.ts b/examples/write-a-book-flow.ts new file mode 100644 index 0000000..d22742d --- /dev/null +++ b/examples/write-a-book-flow.ts @@ -0,0 +1,199 @@ +import { z } from 'zod'; +import { createAgentMachine } from '../src/index.js'; +import { + closePrompt, + formatResult, + generateExampleObject, + generateExampleText, + isMain, + prompt, +} from './_run.js'; + +const chapterOutlineSchema = z.object({ + title: z.string(), + brief: z.string(), +}); + +const outlineSchema = z.object({ + title: z.string(), + chapters: z.array(chapterOutlineSchema), +}); + +const chapterSchema = z.object({ + title: z.string(), + content: z.string(), +}); + +const chapterBatchSchema = z.object({ + chapters: z.array(chapterSchema), +}); + +const manuscriptSchema = z.object({ + manuscript: z.string(), +}); + +type ChapterOutline = z.infer; + +export function createWriteABookFlowExample(options: { + createOutline?: (args: { + topic: string; + goal: string; + }) => Promise>; + writeChapter?: (args: { + title: string; + brief: string; + goal: string; + topic: string; + }) => Promise>; + compileManuscript?: (args: { + title: string; + chapters: z.infer[]; + }) => Promise>; +} = {}) { + const createOutline = + options.createOutline ?? + ((args: { topic: string; goal: string }) => + generateExampleObject({ + schema: outlineSchema, + system: 'Create a concise non-fiction book outline.', + prompt: [`Topic: ${args.topic}`, `Goal: ${args.goal}`].join('\n'), + })); + + const writeChapter = + options.writeChapter ?? + ((args: { + title: string; + brief: string; + goal: string; + topic: string; + }) => + generateExampleObject({ + schema: chapterSchema, + system: 'Write a concise but coherent book chapter.', + prompt: [ + `Book topic: ${args.topic}`, + `Book goal: ${args.goal}`, + `Chapter title: ${args.title}`, + `Chapter brief: ${args.brief}`, + ].join('\n'), + })); + + const compileManuscript = + options.compileManuscript ?? + ((args: { title: string; chapters: z.infer[] }) => + generateExampleObject({ + schema: manuscriptSchema, + system: 'Compile chapters into a single clean markdown manuscript.', + prompt: [ + `Title: ${args.title}`, + '', + ...args.chapters.map( + (chapter) => `## ${chapter.title}\n\n${chapter.content}` + ), + ].join('\n'), + })); + + return createAgentMachine({ + id: 'write-a-book-flow-example', + schemas: { + input: z.object({ + topic: z.string(), + goal: z.string(), + }), + output: z.object({ + title: z.string().nullable(), + outline: z.array(chapterOutlineSchema), + chapters: z.array(chapterSchema), + manuscript: z.string().nullable(), + }), + }, + context: (input) => ({ + topic: input.topic, + goal: input.goal, + title: null as string | null, + outline: [] as ChapterOutline[], + chapters: [] as z.infer[], + manuscript: null as string | null, + }), + initial: 'outlining', + states: { + outlining: { + resultSchema: outlineSchema, + invoke: async ({ context }) => + createOutline({ + topic: context.topic, + goal: context.goal, + }), + onDone: ({ result }) => ({ + target: 'writing', + context: { + title: result.title, + outline: result.chapters, + }, + }), + }, + writing: { + resultSchema: chapterBatchSchema, + invoke: async ({ context }) => { + const chapters = await Promise.all( + context.outline.map((chapter) => + writeChapter({ + title: chapter.title, + brief: chapter.brief, + goal: context.goal, + topic: context.topic, + }) + ) + ); + + return { chapters }; + }, + onDone: ({ result }) => ({ + target: 'compiling', + context: { + chapters: result.chapters, + }, + }), + }, + compiling: { + resultSchema: manuscriptSchema, + invoke: async ({ context }) => + compileManuscript({ + title: context.title ?? 'Untitled Book', + chapters: context.chapters, + }), + onDone: ({ result }) => ({ + target: 'done', + context: { + manuscript: result.manuscript, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + title: context.title, + outline: context.outline, + chapters: context.chapters, + manuscript: context.manuscript, + }), + }, + }, + }); +} + +async function main() { + try { + const topic = await prompt('Book topic'); + const goal = await prompt('Book goal'); + const machine = createWriteABookFlowExample(); + const result = await machine.execute(machine.getInitialState({ topic, goal })); + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/readme.md b/readme.md index 9bc4e32..6e977ce 100644 --- a/readme.md +++ b/readme.md @@ -13,36 +13,24 @@ Stately Agent is a flexible framework for building AI agents using state machine The examples in [`examples/`](/Users/davidkpiano/Code/agent/examples) are intentionally small. Most run in the CLI and use real OpenAI calls when `OPENAI_API_KEY` is set. Runtime-specific examples call out extra environment requirements inline. +If you want examples grouped by intent instead of a flat list, start with [`examples/README.md`](/Users/davidkpiano/Code/agent/examples/README.md). It separates app-shaped examples, workflow examples, runtime integrations, and lower-level reference examples. + Run them with `node --import tsx examples/.ts`. Convert a machine file to diagram output with `pnpm agent:convert --format mermaid` or `pnpm agent:convert --format xstate`. Static analysis warnings are printed to stderr. For programmatic access, use `analyzeGraph(...)` from `@statelyai/agent/graph`; warnings are returned explicitly instead of being hidden in graph metadata. -Each example demonstrates one concept: - -- [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts): the smallest `createAgentMachine(...)` flow with an `invoke` state that makes a real LLM call -- [`examples/hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts): a human-in-the-loop machine that pauses in a pending state, accepts typed events, and drafts with an LLM after approval -- [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts): retrying transient invoke failures through explicit internal error events -- [`examples/chatbot-messages.ts`](/Users/davidkpiano/Code/agent/examples/chatbot-messages.ts): message-centric chat state with structured `{ role, content }` accumulation across turns -- [`examples/conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts): routing directly into one of multiple child subflows from parent input -- [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts): retrieval-augmented generation with explicit retrieve and answer states -- [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts): tool invocation with emitted `toolCall`, incremental `toolProgress`, and final `toolResult` events -- [`examples/ai-sdk.ts`](/Users/davidkpiano/Code/agent/examples/ai-sdk.ts): AI SDK v6 integration using `createAiSdkAdapter(...)` for routing and `generateText(..., { output: Output.object(...) })` for structured drafting -- [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): persisting snapshots and restoring a session in plain runtime code -- [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): runner-agnostic HTTP transport for starting sessions, sending events, and reading snapshots over `Request`/`Response` -- [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts): durable SSE transport that reconnects to a restored run and emits only new streaming parts -- [`examples/next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts): Next.js App Router wrappers around the generic HTTP and SSE session transports, including `runtime`, `dynamic`, and `maxDuration` route config values -- [`examples/next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts): a Next.js App Router chat route that accepts `UIMessage[]` and streams AI SDK UI message parts from machine-emitted notifications, sources, and text deltas -- [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): integrating a persisted agent-machine session store into Cloudflare Agents `onRequest()` routing; requires the Cloudflare Workers runtime -- [`examples/persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts): durable multi-agent handoffs that restore from a persisted mid-network snapshot -- [`examples/persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts): restore a crashed streaming invoke and continue with only new live emitted parts -- [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts): durable supervisor routing that restores after a persisted retry handoff -- [`examples/cloudflare-durable-network.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-network.ts): a Cloudflare Durable Object runner that starts and resumes a persisted multi-agent network -- [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts): choosing the next branch with structured branch-specific payloads -- [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts): routing into a fixed category set when you only need a label -- [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts): plugging in a custom adapter that still uses a real OpenAI model under the hood +Start here: + +- App-shaped integrations: [`examples/apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next), [`examples/apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents), [`examples/next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts), [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts) +- Durable sessions and transports: [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts), [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts), [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts) +- Core workflow patterns: [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts), [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts), [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts), [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts) +- CrewAI-style equivalents: [`examples/content-creator-flow.ts`](/Users/davidkpiano/Code/agent/examples/content-creator-flow.ts), [`examples/email-auto-responder-flow.ts`](/Users/davidkpiano/Code/agent/examples/email-auto-responder-flow.ts), [`examples/lead-score-flow.ts`](/Users/davidkpiano/Code/agent/examples/lead-score-flow.ts), [`examples/meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts), [`examples/self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts), [`examples/write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts) +- Reference examples: [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts), [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts), [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts), [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts) Use `classify(...)` when the result is just "what kind of thing is this?" Use `decide(...)` when the result is "what should happen next?" and the chosen branch may need structured data. +CrewAI Flow parity is tracked in [`docs/crewai-parity.md`](/Users/davidkpiano/Code/agent/docs/crewai-parity.md), the same way LangGraph parity is tracked separately. + ## Persistence Adapters diff --git a/src/crewai-equivalents/content-creator-flow.test.ts b/src/crewai-equivalents/content-creator-flow.test.ts new file mode 100644 index 0000000..fd4ab29 --- /dev/null +++ b/src/crewai-equivalents/content-creator-flow.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'vitest'; +import { createContentCreatorFlowExample } from '../../examples/index.js'; + +describe('CrewAI content creator flow equivalent', () => { + test('routes a request and generates specialized content', async () => { + const machine = createContentCreatorFlowExample({ + routeRequest: async () => ({ route: 'linkedin' }), + createLinkedInPost: async (request) => ({ + title: 'LinkedIn launch post', + body: `LinkedIn: ${request}`, + }), + }); + + const result = await machine.execute( + machine.getInitialState({ + request: 'Announce our AI workflow launch in a short professional post.', + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + route: 'linkedin', + title: 'LinkedIn launch post', + body: + 'LinkedIn: Announce our AI workflow launch in a short professional post.', + }); + } + }); +}); diff --git a/src/crewai-equivalents/email-auto-responder-flow.test.ts b/src/crewai-equivalents/email-auto-responder-flow.test.ts new file mode 100644 index 0000000..accd299 --- /dev/null +++ b/src/crewai-equivalents/email-auto-responder-flow.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest'; +import { runEmailAutoResponderFlowExample } from '../../examples/index.js'; + +describe('CrewAI email auto responder flow equivalent', () => { + test('processes new emails and restores the same durable snapshot', async () => { + const result = await runEmailAutoResponderFlowExample( + [ + { + id: 'email-1', + sender: 'buyer@example.com', + subject: 'Pricing question', + body: 'Can you send pricing details?', + }, + { + id: 'email-2', + sender: 'founder@example.com', + subject: 'Partnership', + body: 'Interested in discussing a partnership.', + }, + ], + { + createDraft: async (email) => ({ + draft: `Draft for ${email.subject}`, + }), + } + ); + + expect(result.snapshot).toEqual(result.restoredSnapshot); + expect(result.snapshot).toEqual( + expect.objectContaining({ + value: 'waiting', + status: 'pending', + }) + ); + expect(result.snapshot.context.processedIds).toEqual(['email-1', 'email-2']); + expect(result.snapshot.context.drafts).toEqual({ + 'email-1': 'Draft for Pricing question', + 'email-2': 'Draft for Partnership', + }); + }); +}); diff --git a/src/crewai-equivalents/lead-score-flow.test.ts b/src/crewai-equivalents/lead-score-flow.test.ts new file mode 100644 index 0000000..8462101 --- /dev/null +++ b/src/crewai-equivalents/lead-score-flow.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'vitest'; +import { createLeadScoreFlowExample } from '../../examples/index.js'; + +describe('CrewAI lead score flow equivalent', () => { + test('supports human review before generating outreach emails', async () => { + const machine = createLeadScoreFlowExample({ + scoreLeads: async ({ leads, reviewNote }) => ({ + scoredLeads: leads.map((lead, index) => ({ + ...lead, + score: 100 - index * 10 - (reviewNote ? 3 : 0), + rationale: reviewNote ?? 'initial', + })), + }), + writeEmails: async (leads) => ({ + drafts: leads.map((lead) => ({ + leadId: lead.id, + draft: `Email for ${lead.company}`, + })), + }), + }); + + const initial = machine.getInitialState({ + leads: [ + { id: 'lead-1', company: 'Acme', contact: 'Ana' }, + { id: 'lead-2', company: 'Beta', contact: 'Ben' }, + { id: 'lead-3', company: 'Gamma', contact: 'Gia' }, + ], + }); + const firstPass = await machine.execute(initial); + expect(firstPass.status).toBe('pending'); + if (firstPass.status !== 'pending') { + return; + } + + const rescored = machine.transition(firstPass.state, { + type: 'review.requestChanges', + note: 'Prefer companies already asking for demos.', + }); + const secondPass = await machine.execute(rescored); + expect(secondPass.status).toBe('pending'); + if (secondPass.status !== 'pending') { + return; + } + + const approved = machine.transition(secondPass.state, { + type: 'review.approve', + }); + const finalResult = await machine.execute(approved); + + expect(finalResult.status).toBe('done'); + if (finalResult.status === 'done') { + expect(finalResult.output.reviewCount).toBe(2); + expect(finalResult.output.topLeads).toHaveLength(3); + expect(finalResult.output.emailDrafts).toEqual([ + { leadId: 'lead-1', draft: 'Email for Acme' }, + { leadId: 'lead-2', draft: 'Email for Beta' }, + { leadId: 'lead-3', draft: 'Email for Gamma' }, + ]); + } + }); +}); diff --git a/src/crewai-equivalents/meeting-assistant-flow.test.ts b/src/crewai-equivalents/meeting-assistant-flow.test.ts new file mode 100644 index 0000000..c9a87f6 --- /dev/null +++ b/src/crewai-equivalents/meeting-assistant-flow.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest'; +import { createMeetingAssistantFlowExample } from '../../examples/index.js'; + +describe('CrewAI meeting assistant flow equivalent', () => { + test('fans one meeting summary into multiple side effects', async () => { + const machine = createMeetingAssistantFlowExample({ + extractTasks: async () => ({ + summary: 'Agreed on launch scope and follow-ups.', + tasks: [ + { title: 'Send launch checklist', owner: 'Ana' }, + { title: 'Prepare customer email', owner: 'Ben' }, + ], + }), + addTasksToTrello: async (tasks) => ({ + trelloCardIds: tasks.map((_, index) => `card-${index + 1}`), + }), + saveTasksToCsv: async () => ({ csvPath: 'new_tasks.csv' }), + sendSlackNotification: async () => ({ slackMessageId: 'slack-123' }), + }); + + const result = await machine.execute( + machine.getInitialState({ + notes: 'Meeting notes go here.', + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output).toEqual({ + summary: 'Agreed on launch scope and follow-ups.', + tasks: [ + { title: 'Send launch checklist', owner: 'Ana' }, + { title: 'Prepare customer email', owner: 'Ben' }, + ], + trelloCardIds: ['card-1', 'card-2'], + csvPath: 'new_tasks.csv', + slackMessageId: 'slack-123', + }); + } + }); +}); diff --git a/src/crewai-equivalents/self-evaluation-loop-flow.test.ts b/src/crewai-equivalents/self-evaluation-loop-flow.test.ts new file mode 100644 index 0000000..4bbb51d --- /dev/null +++ b/src/crewai-equivalents/self-evaluation-loop-flow.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest'; +import { createSelfEvaluationLoopFlowExample } from '../../examples/index.js'; + +describe('CrewAI self evaluation loop equivalent', () => { + test('iterates until the generated post passes evaluation', async () => { + const attempts: string[] = []; + const machine = createSelfEvaluationLoopFlowExample({ + generatePost: async ({ feedback, attempt }) => { + const post = + attempt === 1 + ? 'A very long post with too much detail and maybe an emoji :)' + : `Refined post after: ${feedback}`; + attempts.push(post); + return { post }; + }, + evaluatePost: async (post) => + post.includes('Refined') + ? { valid: true, feedback: null } + : { + valid: false, + feedback: 'Shorten it and remove emoji-like punctuation.', + }, + }); + + const result = await machine.execute( + machine.getInitialState({ + topic: 'Flying cars', + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output.valid).toBe(true); + expect(result.output.attempt).toBe(2); + expect(attempts).toHaveLength(2); + expect(result.output.post).toContain('Refined post after'); + } + }); +}); diff --git a/src/crewai-equivalents/write-a-book-flow.test.ts b/src/crewai-equivalents/write-a-book-flow.test.ts new file mode 100644 index 0000000..6ee2c5c --- /dev/null +++ b/src/crewai-equivalents/write-a-book-flow.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'vitest'; +import { createWriteABookFlowExample } from '../../examples/index.js'; + +describe('CrewAI write a book flow equivalent', () => { + test('outlines a book, writes chapters in parallel, and compiles a manuscript', async () => { + const machine = createWriteABookFlowExample({ + createOutline: async () => ({ + title: 'The Workflow Book', + chapters: [ + { title: 'Chapter 1', brief: 'Introduction' }, + { title: 'Chapter 2', brief: 'Execution' }, + ], + }), + writeChapter: async ({ title, brief }) => ({ + title, + content: `${title}: ${brief}`, + }), + compileManuscript: async ({ title, chapters }) => ({ + manuscript: [ + `# ${title}`, + ...chapters.map((chapter) => `## ${chapter.title}\n${chapter.content}`), + ].join('\n\n'), + }), + }); + + const result = await machine.execute( + machine.getInitialState({ + topic: 'Workflow systems', + goal: 'Teach developers how to build durable AI workflows.', + }) + ); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.output.title).toBe('The Workflow Book'); + expect(result.output.outline).toHaveLength(2); + expect(result.output.chapters).toEqual([ + { title: 'Chapter 1', content: 'Chapter 1: Introduction' }, + { title: 'Chapter 2', content: 'Chapter 2: Execution' }, + ]); + expect(result.output.manuscript).toContain('# The Workflow Book'); + } + }); +}); diff --git a/src/examples.test.ts b/src/examples.test.ts index cef587c..fc1efd7 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -593,6 +593,64 @@ describe('curated examples', () => { }); }); + test('next app-shaped route files import cleanly', async () => { + const [ + routesModule, + chatRouteModule, + reviewSessionsRouteModule, + reviewSessionRouteModule, + reviewEventsRouteModule, + streamingSessionsRouteModule, + streamingSessionRouteModule, + streamingStreamRouteModule, + ] = await Promise.all([ + import(new URL('../examples/apps/next/lib/routes.ts', import.meta.url).href), + import(new URL('../examples/apps/next/app/api/chat/route.ts', import.meta.url).href), + import( + new URL('../examples/apps/next/app/api/review-sessions/route.ts', import.meta.url).href + ), + import( + new URL( + '../examples/apps/next/app/api/review-sessions/[sessionId]/route.ts', + import.meta.url + ).href + ), + import( + new URL( + '../examples/apps/next/app/api/review-sessions/[sessionId]/events/route.ts', + import.meta.url + ).href + ), + import( + new URL('../examples/apps/next/app/api/stream-sessions/route.ts', import.meta.url).href + ), + import( + new URL( + '../examples/apps/next/app/api/stream-sessions/[sessionId]/route.ts', + import.meta.url + ).href + ), + import( + new URL( + '../examples/apps/next/app/api/stream-sessions/[sessionId]/stream/route.ts', + import.meta.url + ).href + ), + ]); + + expect(routesModule.runtime).toBe('nodejs'); + expect(typeof routesModule.chatRoute.POST).toBe('function'); + expect(typeof routesModule.reviewRoutes.sessions.POST).toBe('function'); + expect(typeof routesModule.streamingRoutes.stream.GET).toBe('function'); + expect(typeof chatRouteModule.POST).toBe('function'); + expect(typeof reviewSessionsRouteModule.POST).toBe('function'); + expect(typeof reviewSessionRouteModule.GET).toBe('function'); + expect(typeof reviewEventsRouteModule.POST).toBe('function'); + expect(typeof streamingSessionsRouteModule.POST).toBe('function'); + expect(typeof streamingSessionRouteModule.GET).toBe('function'); + expect(typeof streamingStreamRouteModule.GET).toBe('function'); + }); + test('next ai sdk ui route streams UI message parts from machine emissions', async () => { const route = createNextAiSdkUiRoute({ streamReply: async ({ messages, onDelta }) => { From 4bde82c2a8ca17a3ce2c4dcc7dd2c4beb2778fb4 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Tue, 5 May 2026 11:04:05 -0400 Subject: [PATCH 32/50] feat: add runtime adapter subpaths --- examples/README.md | 6 +- examples/_run.ts | 78 +------ .../src/review-workflow-agent.ts | 2 +- examples/cloudflare-agents.ts | 79 +------ examples/cloudflare-durable-object.ts | 87 +------- examples/http-session.ts | 61 +---- examples/http-streaming-session.ts | 132 +---------- examples/next-app-router.ts | 19 +- package.json | 40 ++++ readme.md | 21 +- src/cloudflare/index.test.ts | 88 ++++++++ src/cloudflare/index.ts | 147 ++++++++++++ src/http/index.test.ts | 157 +++++++++++++ src/http/index.ts | 209 ++++++++++++++++++ src/index.ts | 1 + src/next/index.test.ts | 89 ++++++++ src/next/index.ts | 81 +++++++ src/runtime/index.test.ts | 64 ++++++ src/runtime/index.ts | 80 +++++++ tsdown.config.ts | 4 + 20 files changed, 1019 insertions(+), 426 deletions(-) create mode 100644 src/cloudflare/index.test.ts create mode 100644 src/cloudflare/index.ts create mode 100644 src/http/index.test.ts create mode 100644 src/http/index.ts create mode 100644 src/next/index.test.ts create mode 100644 src/next/index.ts create mode 100644 src/runtime/index.test.ts create mode 100644 src/runtime/index.ts diff --git a/examples/README.md b/examples/README.md index da9b6bb..ed4b3f3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,8 +18,8 @@ These are the best starting points when you want code that already looks like a - [`apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next): copy-paste Next.js App Router routes - [`apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents): copy-paste Cloudflare Agents Worker layout - [`next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts): AI SDK UI route helper -- [`next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts): App Router session route helpers -- [`cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): Node-safe Cloudflare Agents helper version +- [`next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts): App Router session routes backed by `@statelyai/agent/next` and `@statelyai/agent/http` +- [`cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): Node-safe Cloudflare Agents example backed by `@statelyai/agent/cloudflare` ## Workflow Examples @@ -52,6 +52,8 @@ These focus on real orchestration patterns: - [`cloudflare-durable-object.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-object.ts) - [`cloudflare-durable-network.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-network.ts) +The reusable pieces behind these examples are exported from `@statelyai/agent/http`, `@statelyai/agent/next`, and `@statelyai/agent/cloudflare`. + ## Reference / Concept Examples These are smaller building-block examples: diff --git a/examples/_run.ts b/examples/_run.ts index 5e62dc1..d3abce0 100644 --- a/examples/_run.ts +++ b/examples/_run.ts @@ -8,11 +8,10 @@ import { pathToFileURL } from 'node:url'; import { z } from 'zod'; import type { AgentAdapter, - AgentRun, - AgentSnapshot, ExecuteResult, StandardSchemaV1, } from '../src/index.js'; +export { waitForRunDone, waitForRunSnapshot } from '../src/runtime/index.js'; export function isMain(moduleUrl: string): boolean { const entry = process.argv[1]; @@ -97,81 +96,6 @@ export function formatResult(result: ExecuteResult) { }; } -export function waitForRunDone< - TContext extends Record, - TValue extends string, - TEvents extends Record, - TOutput, - TEmitted extends Record, ->( - run: AgentRun -): Promise<{ - output: TOutput; - snapshot: AgentSnapshot; -}> { - return new Promise((resolve, reject) => { - const offDone = run.onDone((event) => { - offDone(); - offError(); - resolve(event); - }); - const offError = run.onError((event) => { - offDone(); - offError(); - reject(event.error); - }); - }); -} - -export function waitForRunSnapshot< - TContext extends Record, - TValue extends string, - TEvents extends Record, - TOutput, - TEmitted extends Record, ->( - run: AgentRun, - predicate: ( - snapshot: AgentSnapshot - ) => boolean, - timeoutMs = 1000 -): Promise> { - const current = run.getSnapshot(); - if (predicate(current)) { - return Promise.resolve(current); - } - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - reject(new Error('Run snapshot did not reach the expected state in time.')); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timeout); - offSnapshot(); - offDone(); - offError(); - }; - - const check = (snapshot: AgentSnapshot) => { - if (predicate(snapshot)) { - cleanup(); - resolve(snapshot); - } - }; - - const offSnapshot = run.onSnapshot(check); - const offDone = run.onDone((event) => { - check(event.snapshot); - }); - const offError = run.onError((event) => { - cleanup(); - reject(event.error); - }); - }); -} - export function createOpenAiDecisionAdapter(): AgentAdapter { return { async decide({ model, prompt, options, reasoning }) { diff --git a/examples/apps/cloudflare-agents/src/review-workflow-agent.ts b/examples/apps/cloudflare-agents/src/review-workflow-agent.ts index 5fe1ec7..d64d72c 100644 --- a/examples/apps/cloudflare-agents/src/review-workflow-agent.ts +++ b/examples/apps/cloudflare-agents/src/review-workflow-agent.ts @@ -3,7 +3,7 @@ import { restoreSession, startSession, type RunStore } from '../../../../src/ind import { createCloudflareAgentRunStore, type CloudflareAgentRunStoreState, -} from '../../../cloudflare-agents.js'; +} from '../../../../src/cloudflare/index.js'; import { createPersistenceExample } from '../../../persistence.js'; export class ReviewWorkflowAgent extends Agent< diff --git a/examples/cloudflare-agents.ts b/examples/cloudflare-agents.ts index 8994231..2b6cbdc 100644 --- a/examples/cloudflare-agents.ts +++ b/examples/cloudflare-agents.ts @@ -1,19 +1,17 @@ import { restoreSession, startSession, - type JournalEventRecord, - type PersistedSnapshot, type RunStore, } from '../src/index.js'; +import { + createCloudflareAgentRunStore, + type CloudflareAgentRunStoreState, +} from '../src/cloudflare/index.js'; import { createPersistenceExample } from './persistence.js'; -type SessionEntry = { - events: JournalEventRecord[]; - snapshot: PersistedSnapshot | null; -}; - -export type CloudflareAgentRunStoreState = { - sessions: Record; +export { + createCloudflareAgentRunStore, + type CloudflareAgentRunStoreState, }; export interface CloudflareAgentsExampleArtifacts { @@ -25,69 +23,6 @@ export interface CloudflareAgentsExampleArtifacts { }; } -export function createCloudflareAgentRunStore(options: { - getState: () => CloudflareAgentRunStoreState; - setState: ( - nextState: CloudflareAgentRunStoreState - ) => void | Promise; -}): RunStore { - return { - async append(sessionId, event) { - const currentState = options.getState(); - const currentSession = currentState.sessions[sessionId] ?? { - events: [], - snapshot: null, - }; - const sequence = currentSession.events.length + 1; - const nextSession: SessionEntry = { - ...currentSession, - events: [...currentSession.events, { ...event, sequence }], - }; - - await options.setState({ - ...currentState, - sessions: { - ...currentState.sessions, - [sessionId]: nextSession, - }, - }); - - return { sequence }; - }, - - async loadEvents(sessionId, afterSequence = 0) { - return ( - options.getState().sessions[sessionId]?.events.filter( - (event) => event.sequence > afterSequence - ) ?? [] - ); - }, - - async loadLatestSnapshot(sessionId) { - return options.getState().sessions[sessionId]?.snapshot ?? null; - }, - - async saveSnapshot(snapshot) { - const currentState = options.getState(); - const currentSession = currentState.sessions[snapshot.sessionId] ?? { - events: [], - snapshot: null, - }; - - await options.setState({ - ...currentState, - sessions: { - ...currentState.sessions, - [snapshot.sessionId]: { - ...currentSession, - snapshot, - }, - }, - }); - }, - }; -} - /** * Cloudflare's `agents` package imports `cloudflare:` modules, so this example * keeps that import lazy to stay loadable in plain Node. In a real Worker, diff --git a/examples/cloudflare-durable-object.ts b/examples/cloudflare-durable-object.ts index 5ff0527..23a040b 100644 --- a/examples/cloudflare-durable-object.ts +++ b/examples/cloudflare-durable-object.ts @@ -1,75 +1,20 @@ -import { - createPersistenceExample, -} from './persistence.js'; import { restoreSession, startSession, - type AgentSnapshot, - type JournalEvent, - type JournalEventRecord, - type PersistedSnapshot, type RunStore, } from '../src/index.js'; - -export interface DurableObjectStorageLike { - get(key: string): Promise; - put(key: string, value: T): Promise; -} - -export interface DurableObjectStateLike { - storage: DurableObjectStorageLike; -} - -export function createDurableObjectRunStore( - storage: DurableObjectStorageLike -): RunStore { - return { - async append(sessionId, event) { - const key = journalKey(sessionId); - const current = (await storage.get(key)) ?? []; - const sequence = - current.length === 0 - ? 1 - : current[current.length - 1]!.sequence + 1; - - await storage.put(key, [...current, { ...event, sequence }]); - return { sequence }; - }, - - async loadEvents(sessionId, afterSequence = 0) { - const current = - (await storage.get[]>( - journalKey(sessionId) - )) ?? []; - - return current - .filter((event) => event.sequence > afterSequence) - .sort((a, b) => a.sequence - b.sequence); - }, - - async loadLatestSnapshot(sessionId) { - const snapshots = - (await storage.get[]>( - snapshotsKey(sessionId) - )) ?? []; - - return ( - [...snapshots].sort( - (a, b) => - a.afterSequence - b.afterSequence || a.createdAt - b.createdAt - ).at(-1) ?? null - ); - }, - - async saveSnapshot(snapshot) { - const key = snapshotsKey(snapshot.sessionId); - const current = - (await storage.get[]>(key)) ?? []; - - await storage.put(key, [...current, snapshot]); - }, - }; -} +import { + createDurableObjectRunStore, + type DurableObjectStateLike, + type DurableObjectStorageLike, +} from '../src/cloudflare/index.js'; +import { createPersistenceExample } from './persistence.js'; + +export { + createDurableObjectRunStore, + type DurableObjectStateLike, + type DurableObjectStorageLike, +}; export class AgentSessionDurableObject { private readonly store: RunStore; @@ -137,11 +82,3 @@ function requiredSessionId(url: URL): string { return sessionId; } - -function journalKey(sessionId: string): string { - return `sessions/${sessionId}/journal`; -} - -function snapshotsKey(sessionId: string): string { - return `sessions/${sessionId}/snapshots`; -} diff --git a/examples/http-session.ts b/examples/http-session.ts index c355654..f765193 100644 --- a/examples/http-session.ts +++ b/examples/http-session.ts @@ -1,9 +1,5 @@ -import { - createMemoryRunStore, - restoreSession, - startSession, - type RunStore, -} from '../src/index.js'; +import { createSessionHttpHandler } from '../src/http/index.js'; +import { type RunStore } from '../src/index.js'; import { createPersistenceExample } from './persistence.js'; export interface SessionHttpHandlerOptions { @@ -14,55 +10,8 @@ export interface SessionHttpHandlerOptions { export function createPersistenceSessionHttpHandler( options: SessionHttpHandlerOptions = {} ) { - const store = options.store ?? createMemoryRunStore(); const machine = createPersistenceExample(options.summarize); - - return async function handle(request: Request): Promise { - const url = new URL(request.url); - const match = url.pathname.match(/^\/sessions(?:\/([^/]+)(?:\/events)?)?$/); - const sessionId = match?.[1]; - const isEventRoute = url.pathname.endsWith('/events'); - - if (request.method === 'POST' && url.pathname === '/sessions') { - const body = await request.json() as { request: string }; - const run = await startSession(machine, { - store, - input: { request: body.request }, - }); - - return Response.json({ - sessionId: run.sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'GET' && sessionId && !isEventRoute) { - const run = await restoreSession(machine, { - sessionId, - store, - }); - - return Response.json({ - sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'POST' && sessionId && isEventRoute) { - const event = await request.json() as { type: 'approve' }; - const run = await restoreSession(machine, { - sessionId, - store, - }); - - await run.send(event); - - return Response.json({ - sessionId, - snapshot: run.getSnapshot(), - }); - } - - return new Response('Not found', { status: 404 }); - }; + return createSessionHttpHandler(machine, { + store: options.store, + }); } diff --git a/examples/http-streaming-session.ts b/examples/http-streaming-session.ts index 9e6185c..736ba97 100644 --- a/examples/http-streaming-session.ts +++ b/examples/http-streaming-session.ts @@ -1,10 +1,8 @@ import { z } from 'zod'; +import { createSessionHttpController } from '../src/http/index.js'; import { createAgentMachine, createMemoryRunStore, - restoreSession, - startSession, - type AgentRun, type RunStore, } from '../src/index.js'; @@ -21,14 +19,6 @@ const textPartSchema = z.object({ delta: z.string(), }); -type StreamingRun = AgentRun< - { streamId: string; text: string; finalText: string }, - string, - {}, - { text: string }, - { textPart: typeof textPartSchema } ->; - export interface StreamingSessionHttpController { handle(request: Request): Promise; advance(streamId: string): void; @@ -75,32 +65,7 @@ export function createStreamingSessionHttpController(options: { }, }, }); - const activeRuns = new Map(); - - function trackRun(sessionId: string, run: StreamingRun) { - activeRuns.set(sessionId, run); - run.onDone(() => { - activeRuns.delete(sessionId); - }); - run.onError(() => { - activeRuns.delete(sessionId); - }); - return run; - } - - async function getRun(sessionId: string): Promise { - const existing = activeRuns.get(sessionId); - if (existing) { - return existing; - } - - const restored = await restoreSession(machine, { - sessionId, - store, - }) as StreamingRun; - - return trackRun(sessionId, restored); - } + const controller = createSessionHttpController(machine, { store }); return { advance(streamId) { @@ -108,100 +73,11 @@ export function createStreamingSessionHttpController(options: { }, dropActiveSession(sessionId) { - activeRuns.delete(sessionId); + controller.dropActiveSession(sessionId); }, async handle(request) { - const url = new URL(request.url); - const match = url.pathname.match(/^\/sessions\/([^/]+)(?:\/stream)?$/); - const sessionId = match?.[1]; - const isStreamRoute = url.pathname.endsWith('/stream'); - - if (request.method === 'POST' && url.pathname === '/sessions') { - const body = await request.json() as z.infer; - const run = await startSession(machine, { - store, - input: body, - }) as StreamingRun; - trackRun(run.sessionId, run); - - return Response.json({ - sessionId: run.sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'GET' && sessionId && !isStreamRoute) { - const run = await getRun(sessionId); - return Response.json({ - sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'GET' && sessionId && isStreamRoute) { - const run = await getRun(sessionId); - let cleanup = () => {}; - - const stream = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - const write = (event: string, data: unknown) => { - controller.enqueue( - encoder.encode( - `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` - ) - ); - }; - - if (run.getSnapshot().status === 'done') { - write('done', run.getSnapshot().output); - controller.close(); - return; - } - - if (run.getSnapshot().status === 'error') { - write('error', { error: String(run.getSnapshot().error) }); - controller.close(); - return; - } - - const offPart = run.on('textPart', (event) => { - write('textPart', event); - }); - const offDone = run.onDone((event) => { - write('done', event.output); - cleanup(); - controller.close(); - }); - const offError = run.onError((event) => { - write('error', { error: String(event.error) }); - cleanup(); - controller.close(); - }); - - cleanup = () => { - offPart(); - offDone(); - offError(); - }; - }, - cancel() { - // Subscribers are ephemeral transport clients, not run ownership. - // Closing the stream should detach listeners but leave the run alive. - cleanup(); - }, - }); - - return new Response(stream, { - headers: { - 'content-type': 'text/event-stream', - 'cache-control': 'no-cache', - }, - }); - } - - return new Response('Not found', { status: 404 }); + return controller.handle(request); }, }; } diff --git a/examples/next-app-router.ts b/examples/next-app-router.ts index c80fa98..5a9e826 100644 --- a/examples/next-app-router.ts +++ b/examples/next-app-router.ts @@ -1,4 +1,11 @@ import { type RunStore } from '../src/index.js'; +import type { NextRouteContext } from '../src/next/index.js'; +export { + dynamic, + maxDuration, + runtime, +} from '../src/next/index.js'; +export type { NextRouteContext } from '../src/next/index.js'; import { createPersistenceSessionHttpHandler, type SessionHttpHandlerOptions, @@ -8,18 +15,6 @@ import { type StreamingSessionHttpController, } from './http-streaming-session.js'; -/** - * Suggested route-segment config for Next.js App Router route handlers that - * host long-lived agent sessions and streaming responses. - */ -export const runtime = 'nodejs'; -export const dynamic = 'force-dynamic'; -export const maxDuration = 30; - -export interface NextRouteContext> { - params: Promise | TParams; -} - export interface NextReviewRouteHandlers { sessions: { POST(request: Request): Promise; diff --git a/package.json b/package.json index 42e66f7..4457cd0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,16 @@ "default": "./dist/ai-sdk.cjs" } }, + "./cloudflare": { + "import": { + "types": "./dist/cloudflare.d.mts", + "default": "./dist/cloudflare.mjs" + }, + "require": { + "types": "./dist/cloudflare.d.cts", + "default": "./dist/cloudflare.cjs" + } + }, "./graph": { "import": { "types": "./dist/graph.d.mts", @@ -37,6 +47,36 @@ "default": "./dist/graph.cjs" } }, + "./http": { + "import": { + "types": "./dist/http.d.mts", + "default": "./dist/http.mjs" + }, + "require": { + "types": "./dist/http.d.cts", + "default": "./dist/http.cjs" + } + }, + "./next": { + "import": { + "types": "./dist/next.d.mts", + "default": "./dist/next.mjs" + }, + "require": { + "types": "./dist/next.d.cts", + "default": "./dist/next.cjs" + } + }, + "./runtime": { + "import": { + "types": "./dist/runtime.d.mts", + "default": "./dist/runtime.mjs" + }, + "require": { + "types": "./dist/runtime.d.cts", + "default": "./dist/runtime.cjs" + } + }, "./xstate": { "import": { "types": "./dist/xstate.d.mts", diff --git a/readme.md b/readme.md index 6e977ce..e597b31 100644 --- a/readme.md +++ b/readme.md @@ -31,6 +31,21 @@ Use `classify(...)` when the result is just "what kind of thing is this?" Use `d CrewAI Flow parity is tracked in [`docs/crewai-parity.md`](/Users/davidkpiano/Code/agent/docs/crewai-parity.md), the same way LangGraph parity is tracked separately. +## Runtime Adapters + + + +The core package exports session helpers from `@statelyai/agent` and `@statelyai/agent/runtime`: + +- `waitForRunDone(run)`: await terminal success or reject on session error +- `waitForRunSnapshot(run, predicate)`: await the next snapshot that matches a predicate + +Use the framework adapters when a machine needs to run inside an app runtime: + +- `@statelyai/agent/http`: `createSessionHttpController(...)`, `createSessionHttpHandler(...)`, and `createRunSseResponse(...)` +- `@statelyai/agent/next`: `createNextSessionRouteHandlers(...)` plus App Router config exports +- `@statelyai/agent/cloudflare`: `createDurableObjectRunStore(...)` and `createCloudflareAgentRunStore(...)` + ## Persistence Adapters @@ -45,8 +60,8 @@ Storage adapters are intentionally bring-your-own. Implement the `RunStore` cont Use these examples as templates for your storage layer: - [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): the smallest durable session flow with an in-memory store -- [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): the Request/Response transport shape around a `RunStore` -- [`examples/cloudflare-durable-object.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-object.ts): Durable Object-backed event and snapshot persistence -- [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): syncing a `RunStore` into Cloudflare Agents state +- [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): the Request/Response transport shape around `@statelyai/agent/http` +- [`examples/cloudflare-durable-object.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-object.ts): Durable Object-backed event and snapshot persistence with `@statelyai/agent/cloudflare` +- [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): syncing a `RunStore` into Cloudflare Agents state with `@statelyai/agent/cloudflare` **Read the documentation: [stately.ai/docs/agents](https://stately.ai/docs/agents)** diff --git a/src/cloudflare/index.test.ts b/src/cloudflare/index.test.ts new file mode 100644 index 0000000..67f0010 --- /dev/null +++ b/src/cloudflare/index.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'vitest'; +import { + createCloudflareAgentRunStore, + createDurableObjectRunStore, + type CloudflareAgentRunStoreState, +} from './index.js'; + +describe('cloudflare adapter', () => { + test('creates a Durable Object RunStore', async () => { + const storage = new Map(); + const store = createDurableObjectRunStore({ + async get(key) { + return storage.get(key) as never; + }, + async put(key, value) { + storage.set(key, value); + }, + }); + + await store.append('session-1', { type: 'start', at: 1 }); + await store.saveSnapshot({ + sessionId: 'session-1', + afterSequence: 1, + createdAt: 2, + snapshot: { + sessionId: 'session-1', + createdAt: 2, + value: 'done', + status: 'done', + context: {}, + input: {}, + }, + }); + + await expect(store.loadEvents('session-1')).resolves.toEqual([ + { + type: 'start', + at: 1, + sequence: 1, + }, + ]); + await expect(store.loadLatestSnapshot('session-1')).resolves.toEqual( + expect.objectContaining({ + afterSequence: 1, + }) + ); + }); + + test('creates a Cloudflare Agents state-backed RunStore', async () => { + let state: CloudflareAgentRunStoreState = { + sessions: {}, + }; + const store = createCloudflareAgentRunStore({ + getState: () => state, + setState: (nextState) => { + state = nextState; + }, + }); + + await store.append('session-1', { type: 'approve', at: 1 }); + await store.saveSnapshot({ + sessionId: 'session-1', + afterSequence: 1, + createdAt: 2, + snapshot: { + sessionId: 'session-1', + createdAt: 2, + value: 'done', + status: 'done', + context: {}, + input: {}, + }, + }); + + await expect(store.loadEvents('session-1')).resolves.toEqual([ + { + type: 'approve', + at: 1, + sequence: 1, + }, + ]); + await expect(store.loadLatestSnapshot('session-1')).resolves.toEqual( + expect.objectContaining({ + afterSequence: 1, + }) + ); + }); +}); diff --git a/src/cloudflare/index.ts b/src/cloudflare/index.ts new file mode 100644 index 0000000..008f0bc --- /dev/null +++ b/src/cloudflare/index.ts @@ -0,0 +1,147 @@ +import type { + AgentSnapshot, + JournalEvent, + JournalEventRecord, + PersistedSnapshot, + RunStore, +} from '../types.js'; + +export interface DurableObjectStorageLike { + get(key: string): Promise; + put(key: string, value: T): Promise; +} + +export interface DurableObjectStateLike { + storage: DurableObjectStorageLike; +} + +export function createDurableObjectRunStore( + storage: DurableObjectStorageLike +): RunStore { + return { + async append(sessionId, event) { + const key = journalKey(sessionId); + const current = (await storage.get(key)) ?? []; + const sequence = + current.length === 0 + ? 1 + : current[current.length - 1]!.sequence + 1; + + await storage.put(key, [...current, { ...event, sequence }]); + return { sequence }; + }, + + async loadEvents(sessionId, afterSequence = 0) { + const current = + (await storage.get[]>( + journalKey(sessionId) + )) ?? []; + + return current + .filter((event) => event.sequence > afterSequence) + .sort((a, b) => a.sequence - b.sequence); + }, + + async loadLatestSnapshot(sessionId) { + const snapshots = + (await storage.get[]>( + snapshotsKey(sessionId) + )) ?? []; + + return ( + [...snapshots].sort( + (a, b) => + a.afterSequence - b.afterSequence || a.createdAt - b.createdAt + ).at(-1) ?? null + ); + }, + + async saveSnapshot(snapshot) { + const key = snapshotsKey(snapshot.sessionId); + const current = + (await storage.get[]>(key)) ?? []; + + await storage.put(key, [...current, snapshot]); + }, + }; +} + +type SessionEntry = { + events: JournalEventRecord[]; + snapshot: PersistedSnapshot | null; +}; + +export type CloudflareAgentRunStoreState = { + sessions: Record; +}; + +export function createCloudflareAgentRunStore(options: { + getState: () => CloudflareAgentRunStoreState; + setState: ( + nextState: CloudflareAgentRunStoreState + ) => void | Promise; +}): RunStore { + return { + async append(sessionId, event) { + const currentState = options.getState(); + const currentSession = currentState.sessions[sessionId] ?? { + events: [], + snapshot: null, + }; + const sequence = currentSession.events.length + 1; + const nextSession: SessionEntry = { + ...currentSession, + events: [...currentSession.events, { ...event, sequence }], + }; + + await options.setState({ + ...currentState, + sessions: { + ...currentState.sessions, + [sessionId]: nextSession, + }, + }); + + return { sequence }; + }, + + async loadEvents(sessionId, afterSequence = 0) { + return ( + options.getState().sessions[sessionId]?.events.filter( + (event) => event.sequence > afterSequence + ) ?? [] + ); + }, + + async loadLatestSnapshot(sessionId) { + return options.getState().sessions[sessionId]?.snapshot ?? null; + }, + + async saveSnapshot(snapshot) { + const currentState = options.getState(); + const currentSession = currentState.sessions[snapshot.sessionId] ?? { + events: [], + snapshot: null, + }; + + await options.setState({ + ...currentState, + sessions: { + ...currentState.sessions, + [snapshot.sessionId]: { + ...currentSession, + snapshot, + }, + }, + }); + }, + }; +} + +function journalKey(sessionId: string): string { + return `sessions/${sessionId}/journal`; +} + +function snapshotsKey(sessionId: string): string { + return `sessions/${sessionId}/snapshots`; +} diff --git a/src/http/index.test.ts b/src/http/index.test.ts new file mode 100644 index 0000000..5ddbf5f --- /dev/null +++ b/src/http/index.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { createAgentMachine } from '../index.js'; +import { createSessionHttpController } from './index.js'; + +function createSseReader(response: Response) { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + return { + async next(): Promise<{ event: string; data: unknown }> { + while (true) { + const match = buffer.match(/^event: ([^\n]+)\ndata: ([^\n]+)\n\n/); + if (match) { + buffer = buffer.slice(match[0].length); + return { + event: match[1]!, + data: JSON.parse(match[2]!), + }; + } + + const chunk = await reader.read(); + if (chunk.done) { + throw new Error('SSE stream closed before the next event was available.'); + } + + buffer += decoder.decode(chunk.value, { stream: true }); + } + }, + + async cancel() { + await reader.cancel(); + }, + }; +} + +describe('http adapter', () => { + test('starts sessions, sends events, reads snapshots, and streams emitted events', async () => { + const machine = createAgentMachine({ + id: 'http-adapter-test', + schemas: { + input: z.object({ + text: z.string(), + }), + events: { + begin: z.object({}), + }, + emitted: { + textPart: z.object({ + delta: z.string(), + }), + }, + }, + context: (input) => ({ + text: input.text, + finalText: '', + }), + initial: 'waiting', + states: { + waiting: { + on: { + begin: { + target: 'writing', + }, + }, + }, + writing: { + resultSchema: z.object({ + text: z.string(), + }), + invoke: async ({ context }, enq) => { + enq.emit({ type: 'textPart', delta: context.text }); + return { text: context.text }; + }, + onDone: ({ result }) => ({ + target: 'done', + context: { + finalText: result.text, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + text: context.finalText, + }), + }, + }, + }); + const controller = createSessionHttpController(machine); + + const startResponse = await controller.handle( + new Request('https://agent.test/sessions', { + method: 'POST', + body: JSON.stringify({ text: 'hello' }), + }) + ); + const startBody = await startResponse.json() as { + sessionId: string; + snapshot: { value: string; status: string }; + }; + + expect(startBody.snapshot).toEqual( + expect.objectContaining({ + value: 'waiting', + status: 'active', + }) + ); + + const streamResponse = await controller.handle( + new Request(`https://agent.test/sessions/${startBody.sessionId}/stream`) + ); + const reader = createSseReader(streamResponse); + + const sendPromise = controller.handle( + new Request(`https://agent.test/sessions/${startBody.sessionId}/events`, { + method: 'POST', + body: JSON.stringify({ type: 'begin' }), + }) + ); + + await expect(reader.next()).resolves.toEqual({ + event: 'textPart', + data: { + type: 'textPart', + delta: 'hello', + }, + }); + await expect(reader.next()).resolves.toEqual({ + event: 'done', + data: { + text: 'hello', + }, + }); + + const sendResponse = await sendPromise; + expect(sendResponse.status).toBe(200); + + const statusResponse = await controller.handle( + new Request(`https://agent.test/sessions/${startBody.sessionId}`) + ); + const statusBody = await statusResponse.json() as { + snapshot: { value: string; status: string; output: unknown }; + }; + + expect(statusBody.snapshot).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + output: { + text: 'hello', + }, + }) + ); + }); +}); diff --git a/src/http/index.ts b/src/http/index.ts new file mode 100644 index 0000000..c1a0ee9 --- /dev/null +++ b/src/http/index.ts @@ -0,0 +1,209 @@ +import { + createMemoryRunStore, +} from '../runtime/memory-store.js'; +import { + restoreSession, + startSession, +} from '../runtime/session.js'; +import type { + AgentMachine, + AgentRun, + RunStore, + TransitionEvent, +} from '../types.js'; + +type AnyMachine = AgentMachine; +type RunFor = + TMachine extends AgentMachine< + any, + infer TContext, + infer TEvents, + infer TStates, + infer TOutput, + infer TEmitted + > + ? AgentRun + : AgentRun; + +type InputFor = + TMachine extends AgentMachine + ? TInput + : unknown; + +type EventsFor = + TMachine extends AgentMachine + ? TEvents + : {}; + +export interface SessionHttpController { + handle(request: Request): Promise; + getRun(sessionId: string): Promise>; + dropActiveSession(sessionId: string): void; +} + +export interface SessionHttpControllerOptions { + store?: RunStore; + parseInput?: (request: Request) => Promise>; + parseEvent?: ( + request: Request + ) => Promise>>; +} + +export function createSessionHttpController( + machine: TMachine, + options: SessionHttpControllerOptions = {} +): SessionHttpController { + const store = options.store ?? createMemoryRunStore(); + const activeRuns = new Map>(); + const parseInput = + options.parseInput ?? ((request) => request.json() as Promise>); + const parseEvent = + options.parseEvent ?? + ((request) => request.json() as Promise>>); + + function trackRun(run: RunFor): RunFor { + activeRuns.set(run.sessionId, run); + run.onDone(() => { + activeRuns.delete(run.sessionId); + }); + run.onError(() => { + activeRuns.delete(run.sessionId); + }); + return run; + } + + async function getRun(sessionId: string): Promise> { + const existing = activeRuns.get(sessionId); + if (existing) { + return existing; + } + + const restored = await restoreSession(machine, { + sessionId, + store, + }) as RunFor; + + return trackRun(restored); + } + + return { + getRun, + + dropActiveSession(sessionId) { + activeRuns.delete(sessionId); + }, + + async handle(request) { + const url = new URL(request.url); + const match = url.pathname.match(/^\/sessions(?:\/([^/]+)(?:\/(events|stream))?)?$/); + const sessionId = match?.[1]; + const childRoute = match?.[2]; + + if (request.method === 'POST' && url.pathname === '/sessions') { + const run = await startSession(machine, { + store, + input: await parseInput(request), + }) as RunFor; + + trackRun(run); + + return Response.json({ + sessionId: run.sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'GET' && sessionId && !childRoute) { + const run = await getRun(sessionId); + + return Response.json({ + sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'POST' && sessionId && childRoute === 'events') { + const run = await getRun(sessionId); + await run.send(await parseEvent(request)); + + return Response.json({ + sessionId, + snapshot: run.getSnapshot(), + }); + } + + if (request.method === 'GET' && sessionId && childRoute === 'stream') { + return createRunSseResponse(await getRun(sessionId)); + } + + return new Response('Not found', { status: 404 }); + }, + }; +} + +export function createSessionHttpHandler( + machine: TMachine, + options: SessionHttpControllerOptions = {} +): (request: Request) => Promise { + const controller = createSessionHttpController(machine, options); + return (request) => controller.handle(request); +} + +export function createRunSseResponse( + run: AgentRun +): Response { + let cleanup = () => {}; + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + const write = (event: string, data: unknown) => { + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + ); + }; + + if (run.getSnapshot().status === 'done') { + write('done', run.getSnapshot().output); + controller.close(); + return; + } + + if (run.getSnapshot().status === 'error') { + write('error', { error: String(run.getSnapshot().error) }); + controller.close(); + return; + } + + const offEmitted = run.onEmitted((event) => { + write(event.type, event); + }); + const offDone = run.onDone((event) => { + write('done', event.output); + cleanup(); + controller.close(); + }); + const offError = run.onError((event) => { + write('error', { error: String(event.error) }); + cleanup(); + controller.close(); + }); + + cleanup = () => { + offEmitted(); + offDone(); + offError(); + }; + }, + cancel() { + cleanup(); + }, + }); + + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + }, + }); +} diff --git a/src/index.ts b/src/index.ts index 12162ba..b28d902 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export { classify, classifyResultSchema } from './classify.js'; export { createAdapter } from './adapter.js'; export { createMemoryRunStore } from './runtime/memory-store.js'; export { restoreSession, startSession } from './runtime/session.js'; +export { waitForRunDone, waitForRunSnapshot } from './runtime/index.js'; // Types export type { diff --git a/src/next/index.test.ts b/src/next/index.test.ts new file mode 100644 index 0000000..37984f4 --- /dev/null +++ b/src/next/index.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { createAgentMachine } from '../index.js'; +import { + createNextSessionRouteHandlers, + dynamic, + maxDuration, + runtime, +} from './index.js'; + +describe('next adapter', () => { + test('adapts generic session handlers to App Router route params', async () => { + const machine = createAgentMachine({ + id: 'next-adapter-test', + schemas: { + input: z.object({ + request: z.string(), + }), + events: { + approve: z.object({}), + }, + }, + context: (input) => ({ + request: input.request, + approved: false, + }), + initial: 'review', + states: { + review: { + on: { + approve: { + target: 'done', + context: { + approved: true, + }, + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + request: context.request, + approved: context.approved, + }), + }, + }, + }); + const routes = createNextSessionRouteHandlers(machine); + + expect(runtime).toBe('nodejs'); + expect(dynamic).toBe('force-dynamic'); + expect(maxDuration).toBe(30); + + const startResponse = await routes.sessions.POST( + new Request('https://agent.test/api/sessions', { + method: 'POST', + body: JSON.stringify({ request: 'Ship it.' }), + }) + ); + const startBody = await startResponse.json() as { + sessionId: string; + }; + + const sendResponse = await routes.events.POST( + new Request(`https://agent.test/api/sessions/${startBody.sessionId}/events`, { + method: 'POST', + body: JSON.stringify({ type: 'approve' }), + }), + { + params: Promise.resolve({ + sessionId: startBody.sessionId, + }), + } + ); + const sendBody = await sendResponse.json() as { + snapshot: { value: string; output: unknown }; + }; + + expect(sendBody.snapshot).toEqual( + expect.objectContaining({ + value: 'done', + output: { + request: 'Ship it.', + approved: true, + }, + }) + ); + }); +}); diff --git a/src/next/index.ts b/src/next/index.ts new file mode 100644 index 0000000..6fa6431 --- /dev/null +++ b/src/next/index.ts @@ -0,0 +1,81 @@ +import { + createSessionHttpController, + type SessionHttpController, + type SessionHttpControllerOptions, +} from '../http/index.js'; +import type { AgentMachine } from '../types.js'; + +type AnyMachine = AgentMachine; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 30; + +export interface NextRouteContext> { + params: Promise | TParams; +} + +export interface NextSessionRouteHandlers { + sessions: { + POST(request: Request): Promise; + }; + session: { + GET( + request: Request, + context: NextRouteContext<{ sessionId: string }> + ): Promise; + }; + events: { + POST( + request: Request, + context: NextRouteContext<{ sessionId: string }> + ): Promise; + }; + stream: { + GET( + request: Request, + context: NextRouteContext<{ sessionId: string }> + ): Promise; + }; + controller: SessionHttpController; +} + +export function createNextSessionRouteHandlers( + machine: TMachine, + options: SessionHttpControllerOptions = {} +): NextSessionRouteHandlers { + const controller = createSessionHttpController(machine, options); + + return { + sessions: { + POST(request) { + return controller.handle(rewritePath(request, '/sessions')); + }, + }, + session: { + async GET(request, context) { + const { sessionId } = await context.params; + return controller.handle(rewritePath(request, `/sessions/${sessionId}`)); + }, + }, + events: { + async POST(request, context) { + const { sessionId } = await context.params; + return controller.handle(rewritePath(request, `/sessions/${sessionId}/events`)); + }, + }, + stream: { + async GET(request, context) { + const { sessionId } = await context.params; + return controller.handle(rewritePath(request, `/sessions/${sessionId}/stream`)); + }, + }, + controller, + }; +} + +function rewritePath(request: Request, pathname: string): Request { + const url = new URL(request.url); + url.pathname = pathname; + return new Request(url, request); +} diff --git a/src/runtime/index.test.ts b/src/runtime/index.test.ts new file mode 100644 index 0000000..10f1569 --- /dev/null +++ b/src/runtime/index.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { + createAgentMachine, + createMemoryRunStore, + startSession, +} from '../index.js'; +import { waitForRunDone, waitForRunSnapshot } from './index.js'; + +describe('runtime helpers', () => { + test('waitForRunSnapshot and waitForRunDone observe session lifecycle', async () => { + const machine = createAgentMachine({ + id: 'runtime-helper-test', + schemas: { + events: { + finish: z.object({ value: z.string() }), + }, + }, + context: () => ({ + value: null as string | null, + }), + initial: 'waiting', + states: { + waiting: { + on: { + finish: ({ event }) => ({ + target: 'done', + context: { + value: event.value, + }, + }), + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + value: context.value, + }), + }, + }, + }); + + const run = await startSession(machine, { + store: createMemoryRunStore(), + }); + const waiting = await waitForRunSnapshot( + run, + (snapshot) => snapshot.status === 'pending' + ); + + expect(waiting.value).toBe('waiting'); + + const donePromise = waitForRunDone(run); + await run.send({ type: 'finish', value: 'ok' }); + + await expect(donePromise).resolves.toEqual( + expect.objectContaining({ + output: { + value: 'ok', + }, + }) + ); + }); +}); diff --git a/src/runtime/index.ts b/src/runtime/index.ts new file mode 100644 index 0000000..0233285 --- /dev/null +++ b/src/runtime/index.ts @@ -0,0 +1,80 @@ +import type { + AgentRun, + AgentSnapshot, + StandardSchemaV1, +} from '../types.js'; + +export function waitForRunDone< + TContext extends Record, + TValue extends string, + TEvents extends Record, + TOutput, + TEmitted extends Record, +>( + run: AgentRun +): Promise<{ + output: TOutput; + snapshot: AgentSnapshot; +}> { + return new Promise((resolve, reject) => { + const offDone = run.onDone((event) => { + offDone(); + offError(); + resolve(event); + }); + const offError = run.onError((event) => { + offDone(); + offError(); + reject(event.error); + }); + }); +} + +export function waitForRunSnapshot< + TContext extends Record, + TValue extends string, + TEvents extends Record, + TOutput, + TEmitted extends Record, +>( + run: AgentRun, + predicate: ( + snapshot: AgentSnapshot + ) => boolean, + timeoutMs = 1000 +): Promise> { + const current = run.getSnapshot(); + if (predicate(current)) { + return Promise.resolve(current); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('Run snapshot did not reach the expected state in time.')); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + offSnapshot(); + offDone(); + offError(); + }; + + const check = (snapshot: AgentSnapshot) => { + if (predicate(snapshot)) { + cleanup(); + resolve(snapshot); + } + }; + + const offSnapshot = run.onSnapshot(check); + const offDone = run.onDone((event) => { + check(event.snapshot); + }); + const offError = run.onError((event) => { + cleanup(); + reject(event.error); + }); + }); +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 333bb5f..05fe1c3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -4,7 +4,11 @@ export default defineConfig({ entry: { index: 'src/index.ts', 'ai-sdk': 'src/ai-sdk/index.ts', + cloudflare: 'src/cloudflare/index.ts', graph: 'src/graph/index.ts', + http: 'src/http/index.ts', + next: 'src/next/index.ts', + runtime: 'src/runtime/index.ts', xstate: 'src/xstate/index.ts', }, format: ['esm', 'cjs'], From 858ce6f4166dabaed332421cfe3453959dc71245 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 15 May 2026 00:10:46 +0100 Subject: [PATCH 33/50] Add first-class messages and always transitions --- .changeset/sharp-messages-always.md | 9 + examples/README.md | 1 + examples/_run.ts | 2 + examples/chatbot-messages.ts | 36 ++- examples/hitl.ts | 23 +- examples/index.ts | 1 + examples/spec-agent-loop.ts | 274 ++++++++++++++++++ readme.md | 2 +- src/agent.test.ts | 84 +++++- src/cloudflare/index.test.ts | 2 + src/examples.test.ts | 3 +- src/graph/index.test.ts | 36 +++ src/graph/index.ts | 27 +- src/index.ts | 7 + .../chatbot-messages.test.ts | 4 +- src/langgraph-equivalents/error-retry.test.ts | 1 + src/machine.ts | 68 ++++- src/persistence.test.ts | 6 + src/session-runtime.test.ts | 46 +++ src/session-types.test.ts | 1 + src/types.ts | 27 +- src/utils.ts | 42 ++- src/xstate/index.test.ts | 34 +++ src/xstate/index.ts | 17 ++ 24 files changed, 696 insertions(+), 57 deletions(-) create mode 100644 .changeset/sharp-messages-always.md create mode 100644 examples/spec-agent-loop.ts diff --git a/.changeset/sharp-messages-always.md b/.changeset/sharp-messages-always.md new file mode 100644 index 0000000..31172bf --- /dev/null +++ b/.changeset/sharp-messages-always.md @@ -0,0 +1,9 @@ +--- +"@statelyai/agent": minor +--- + +Add first-class session messages and deterministic always transitions. + +Agent states and snapshots now carry `messages` alongside `context`. State hooks receive messages, transition results can replace messages, and helper functions are exported for appending user, assistant, and system messages. + +Machines can now define `always` transitions for deterministic eventless routing. Runtime sessions journal these transitions as internal events so persistence and restore remain replayable. diff --git a/examples/README.md b/examples/README.md index ed4b3f3..724fc56 100644 --- a/examples/README.md +++ b/examples/README.md @@ -38,6 +38,7 @@ These focus on real orchestration patterns: - [`lead-score-flow.ts`](/Users/davidkpiano/Code/agent/examples/lead-score-flow.ts) - [`meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts) - [`self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts) +- [`spec-agent-loop.ts`](/Users/davidkpiano/Code/agent/examples/spec-agent-loop.ts) - [`write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts) - [`plan-and-execute.ts`](/Users/davidkpiano/Code/agent/examples/plan-and-execute.ts) - [`reflection.ts`](/Users/davidkpiano/Code/agent/examples/reflection.ts) diff --git a/examples/_run.ts b/examples/_run.ts index d3abce0..510f2af 100644 --- a/examples/_run.ts +++ b/examples/_run.ts @@ -76,6 +76,7 @@ export function formatResult(result: ExecuteResult) { status: result.status, value: result.state.value, context: result.context, + messages: result.messages, output: result.output, }; } @@ -85,6 +86,7 @@ export function formatResult(result: ExecuteResult) { status: result.status, value: result.value, context: result.context, + messages: result.messages, events: Object.keys(result.events), }; } diff --git a/examples/chatbot-messages.ts b/examples/chatbot-messages.ts index bb1c977..7c5cd80 100644 --- a/examples/chatbot-messages.ts +++ b/examples/chatbot-messages.ts @@ -3,6 +3,7 @@ import { createAgentMachine, createMemoryRunStore, startSession, + type AgentMessage, } from '../src/index.js'; import { closePrompt, @@ -13,7 +14,7 @@ import { } from './_run.js'; const messageSchema = z.object({ - role: z.enum(['user', 'assistant']), + role: z.string(), content: z.string(), }); @@ -22,7 +23,7 @@ const replySchema = z.object({ }); export function createChatbotMessagesExample( - reply: (messages: Array>) => Promise> = (messages) => + reply: (messages: AgentMessage[]) => Promise> = (messages) => generateExampleObject({ schema: replySchema, system: 'You are a concise assistant in a terminal chat.', @@ -50,19 +51,17 @@ export function createChatbotMessagesExample( }, }, context: () => ({ - messages: [] as Array>, finalMessage: null as z.infer | null, ended: false, }), + messages: [], initial: 'waitingForUser', states: { waitingForUser: { on: { - 'messages.user': ({ event, context }) => ({ + 'messages.user': ({ event, messages }) => ({ target: 'replying', - context: { - messages: [...context.messages, event.message], - }, + messages: messages.concat(event.message), }), 'messages.end': { target: 'done', @@ -72,19 +71,19 @@ export function createChatbotMessagesExample( }, replying: { resultSchema: replySchema, - invoke: async ({ context }) => reply(context.messages), - onDone: ({ result, context }) => ({ + invoke: async ({ messages }) => reply(messages), + onDone: ({ result, messages }) => ({ target: 'waitingForUser', + messages: messages.concat(result.message), context: { - messages: [...context.messages, result.message], finalMessage: result.message, }, }), }, done: { type: 'final', - output: ({ context }) => ({ - messages: context.messages, + output: ({ context, messages }) => ({ + messages, finalMessage: context.finalMessage, }), }, @@ -111,17 +110,22 @@ async function main() { status: snapshot.status, value: snapshot.value, context: snapshot.context, + messages: snapshot.messages, output: snapshot.output, }); break; } + const finalMessage = snapshot.context.finalMessage as + | z.infer + | null; + if ( - snapshot.context.finalMessage?.role === 'assistant' - && snapshot.context.finalMessage.content !== lastPrintedAssistantMessage + finalMessage?.role === 'assistant' + && finalMessage.content !== lastPrintedAssistantMessage ) { - console.log(`Assistant: ${snapshot.context.finalMessage.content}`); - lastPrintedAssistantMessage = snapshot.context.finalMessage.content; + console.log(`Assistant: ${finalMessage.content}`); + lastPrintedAssistantMessage = finalMessage.content; } const content = await prompt('User (blank to exit)'); diff --git a/examples/hitl.ts b/examples/hitl.ts index 7e88077..919d894 100644 --- a/examples/hitl.ts +++ b/examples/hitl.ts @@ -3,6 +3,7 @@ import { createAgentMachine, createMemoryRunStore, startSession, + type AgentMessage, } from '../src/index.js'; import { closePrompt, @@ -19,15 +20,15 @@ const draftSchema = z.object({ export function createHitlExample( draftReply: (args: { task: string; - notes: string[]; - }) => Promise> = async ({ task, notes }) => { + messages: AgentMessage[]; + }) => Promise> = async ({ task, messages }) => { return generateExampleObject({ schema: draftSchema, prompt: [ `Task: ${task}`, '', 'Use the notes below to draft a concise response:', - ...notes.map((note, index) => `${index + 1}. ${note}`), + ...messages.map((message, index) => `${index + 1}. ${message.content}`), ].join('\n'), }); } @@ -48,17 +49,15 @@ export function createHitlExample( }, context: (input) => ({ task: input.task, - notes: [] as string[], draft: null as string | null, }), + messages: [], initial: 'gathering', states: { gathering: { on: { - 'user.message': ({ context, event }) => ({ - context: { - notes: context.notes.concat(event.message), - }, + 'user.message': ({ messages, event }) => ({ + messages: messages.concat({ role: 'user', content: event.message }), }), 'user.approve': { target: 'drafting' }, 'user.cancel': { target: 'cancelled' }, @@ -66,13 +65,14 @@ export function createHitlExample( }, drafting: { resultSchema: draftSchema, - invoke: async ({ context }) => + invoke: async ({ context, messages }) => draftReply({ task: context.task, - notes: context.notes, + messages, }), - onDone: ({ result }) => ({ + onDone: ({ result, messages }) => ({ target: 'done', + messages: messages.concat({ role: 'assistant', content: result.draft }), context: { draft: result.draft }, }), }, @@ -108,6 +108,7 @@ async function main() { status: snapshot.status, value: snapshot.value, context: snapshot.context, + messages: snapshot.messages, output: snapshot.output, }); break; diff --git a/examples/index.ts b/examples/index.ts index f698113..d0b02a4 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -63,6 +63,7 @@ export { export { createRewooExample } from './rewoo.js'; export { createReflectionExample } from './reflection.js'; export { createSelfEvaluationLoopFlowExample } from './self-evaluation-loop-flow.js'; +export { createSpecAgentLoopExample } from './spec-agent-loop.js'; export { createSupervisorExample } from './supervisor.js'; export { createWriteABookFlowExample } from './write-a-book-flow.js'; export { createSqlAgentExample } from './sql-agent.js'; diff --git a/examples/spec-agent-loop.ts b/examples/spec-agent-loop.ts new file mode 100644 index 0000000..8d86de9 --- /dev/null +++ b/examples/spec-agent-loop.ts @@ -0,0 +1,274 @@ +import { z } from 'zod'; +import { + appendMessages, + assistantMessage, + createAgentMachine, + createMemoryRunStore, + startSession, + userMessage, +} from '../src/index.js'; +import { + closePrompt, + generateExampleText, + isMain, + prompt, + waitForRunSnapshot, +} from './_run.js'; + +const generationSchema = z.object({ + rawText: z.string(), + specYaml: z.string(), + questions: z.array(z.string()), + status: z.enum(['needs_user', 'complete']), +}); + +const validationSchema = z.object({ + ok: z.boolean(), + errors: z.array(z.string()), +}); + +type Generation = z.infer; + +export function createSpecAgentLoopExample( + options: { + generate?: (args: { + specName: string; + messages: Array<{ role: string; content: string }>; + }) => Promise; + validate?: (yaml: string) => z.infer; + maxRepairTurns?: number; + } = {} +) { + const generate = + options.generate ?? + (({ specName, messages }) => + generateExampleText({ + system: [ + 'Write a small YAML spec.', + 'Respond exactly with , , and tags.', + 'Use complete only when the YAML has no __HOLE__ markers.', + ].join('\n'), + prompt: [ + `Spec name: ${specName}`, + '', + ...messages.map((message) => `${message.role}: ${message.content}`), + ].join('\n'), + })); + + const validate = + options.validate ?? + ((yaml: string) => { + const errors: string[] = []; + if (!yaml.trim()) errors.push('Missing YAML'); + if (/__HOLE__|TODO|TBD|UNKNOWN/i.test(yaml)) errors.push('YAML has holes'); + if (!/^name:/m.test(yaml)) errors.push('Missing name'); + return { ok: errors.length === 0, errors }; + }); + + return createAgentMachine({ + id: 'spec-agent-loop-example', + schemas: { + input: z.object({ + specName: z.string(), + prompt: z.string(), + }), + events: { + 'user.answer': z.object({ answer: z.string() }), + 'user.accept': z.object({}), + 'user.quit': z.object({}), + }, + output: z.object({ + specYaml: z.string(), + accepted: z.boolean(), + }), + }, + context: (input) => ({ + specName: input.specName, + specYaml: '', + questions: [] as string[], + status: 'needs_user' as Generation['status'], + validation: { ok: false, errors: [] as string[] }, + repairTurns: 0, + maxRepairTurns: options.maxRepairTurns ?? 3, + accepted: false, + }), + messages: (input) => [ + userMessage(`Create an initial spec from this prompt:\n\n${input.prompt}`), + ], + initial: 'generating', + states: { + generating: { + resultSchema: generationSchema, + invoke: async ({ context, messages }) => + parseTaggedResponse( + await generate({ + specName: context.specName, + messages, + }) + ), + onDone: ({ result, messages }) => ({ + target: result.specYaml ? 'validating' : 'repairing', + context: { + specYaml: result.specYaml, + questions: result.questions, + status: result.status, + }, + messages: appendMessages(messages, assistantMessage(result.rawText)), + }), + }, + validating: { + resultSchema: validationSchema, + invoke: async ({ context }) => validate(context.specYaml), + onDone: ({ result }) => ({ + target: 'routing', + context: { validation: result }, + }), + }, + routing: { + always: ({ context, messages }) => { + if (context.validation.ok && context.status === 'complete') { + return { target: 'awaitingAcceptance' }; + } + + if (!context.validation.ok && context.status === 'complete') { + return { + target: + context.repairTurns < context.maxRepairTurns + ? 'generating' + : 'awaitingUser', + context: { repairTurns: context.repairTurns + 1 }, + messages: appendMessages( + messages, + userMessage( + [ + 'You marked the spec complete, but deterministic validation failed.', + ...context.validation.errors.map((error) => `- ${error}`), + ].join('\n') + ) + ), + }; + } + + return { target: 'awaitingUser' }; + }, + }, + repairing: { + always: ({ context, messages }) => ({ + target: + context.repairTurns < context.maxRepairTurns + ? 'generating' + : 'awaitingUser', + context: { repairTurns: context.repairTurns + 1 }, + messages: appendMessages( + messages, + userMessage('Return the full YAML spec using the required tags.') + ), + }), + }, + awaitingUser: { + on: { + 'user.answer': ({ event, messages }) => ({ + target: 'generating', + context: { repairTurns: 0 }, + messages: appendMessages( + messages, + userMessage(`User answered/refined:\n\n${event.answer}`) + ), + }), + 'user.quit': { target: 'done' }, + }, + }, + awaitingAcceptance: { + on: { + 'user.accept': { + target: 'done', + context: { accepted: true }, + }, + 'user.answer': ({ event, messages }) => ({ + target: 'generating', + messages: appendMessages( + messages, + userMessage(`Spec validates, but refine:\n\n${event.answer}`) + ), + }), + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + specYaml: context.specYaml, + accepted: context.accepted, + }), + }, + }, + }); +} + +function parseTaggedResponse(text: string): Generation { + const specYaml = text.match(/([\s\S]*?)<\/SPEC_YAML>/)?.[1]?.trim() ?? ''; + const questionText = text.match(/([\s\S]*?)<\/QUESTIONS>/)?.[1]?.trim() ?? ''; + const statusRaw = text.match(/([\s\S]*?)<\/STATUS>/)?.[1]?.trim(); + + return { + rawText: text.trim(), + specYaml, + questions: questionText + .split('\n') + .map((line) => line.replace(/^[-*\d. )]+/, '').trim()) + .filter(Boolean), + status: statusRaw === 'complete' ? 'complete' : 'needs_user', + }; +} + +async function main() { + try { + const specName = await prompt('Spec name'); + const initialPrompt = await prompt('Describe the spec'); + const machine = createSpecAgentLoopExample(); + const run = await startSession(machine, { + store: createMemoryRunStore(), + input: { specName, prompt: initialPrompt }, + }); + + while (true) { + const snapshot = await waitForRunSnapshot( + run, + (nextSnapshot) => nextSnapshot.status !== 'active' + ); + + if (snapshot.status === 'done') { + console.log(snapshot.output); + break; + } + + console.log({ + value: snapshot.value, + validation: snapshot.context.validation, + questions: snapshot.context.questions, + }); + + if (snapshot.value === 'awaitingAcceptance') { + const answer = await prompt('Accept? [Y/n]'); + await run.send( + !answer || /^y(es)?$/i.test(answer) + ? { type: 'user.accept' } + : { type: 'user.answer', answer } + ); + continue; + } + + const answer = await prompt('Answer, refine, or /quit'); + await run.send( + answer === '/quit' + ? { type: 'user.quit' } + : { type: 'user.answer', answer } + ); + } + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/readme.md b/readme.md index e597b31..8193cc0 100644 --- a/readme.md +++ b/readme.md @@ -23,7 +23,7 @@ Start here: - App-shaped integrations: [`examples/apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next), [`examples/apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents), [`examples/next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts), [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts) - Durable sessions and transports: [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts), [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts), [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts) -- Core workflow patterns: [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts), [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts), [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts), [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts) +- Core workflow patterns: [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts), [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts), [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts), [`examples/spec-agent-loop.ts`](/Users/davidkpiano/Code/agent/examples/spec-agent-loop.ts), [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts) - CrewAI-style equivalents: [`examples/content-creator-flow.ts`](/Users/davidkpiano/Code/agent/examples/content-creator-flow.ts), [`examples/email-auto-responder-flow.ts`](/Users/davidkpiano/Code/agent/examples/email-auto-responder-flow.ts), [`examples/lead-score-flow.ts`](/Users/davidkpiano/Code/agent/examples/lead-score-flow.ts), [`examples/meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts), [`examples/self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts), [`examples/write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts) - Reference examples: [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts), [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts), [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts), [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts) diff --git a/src/agent.test.ts b/src/agent.test.ts index 7813732..1a24258 100644 --- a/src/agent.test.ts +++ b/src/agent.test.ts @@ -701,6 +701,83 @@ describe('type: choice', () => { }); }); +describe('messages and always', () => { + test('messages are passed through invoke, onDone, always, and output', async () => { + const machine = createAgentMachine({ + id: 'messages-always', + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ + messages: z.array(z.object({ role: z.string(), content: z.string() })), + attempts: z.number(), + }), + }, + context: () => ({ + attempts: 0, + accepted: false, + }), + messages: (input) => [{ role: 'user', content: input.prompt }], + initial: 'generating', + states: { + generating: { + resultSchema: z.object({ text: z.string() }), + invoke: async ({ messages }) => ({ + text: `reply to ${messages.at(-1)?.content}`, + }), + onDone: ({ result, context, messages }) => ({ + target: 'checking', + context: { attempts: context.attempts + 1 }, + messages: messages.concat({ + role: 'assistant', + content: result.text, + }), + }), + }, + checking: { + always: ({ context, messages }) => + context.attempts >= 2 + ? { + target: 'done', + context: { accepted: true }, + messages: messages.concat({ + role: 'system', + content: 'accepted', + }), + } + : { + target: 'generating', + messages: messages.concat({ + role: 'user', + content: 'repair', + }), + }, + }, + done: { + type: 'final', + output: ({ context, messages }) => ({ + messages, + attempts: context.attempts, + }), + }, + }, + }); + + const result = await machine.execute(machine.getInitialState({ prompt: 'draft' })); + + expect(result.status).toBe('done'); + if (result.status === 'done') { + expect(result.messages.map((message) => message.content)).toEqual([ + 'draft', + 'reply to draft', + 'repair', + 'reply to repair', + 'accepted', + ]); + expect(result.output.attempts).toBe(2); + } + }); +}); + describe('classify', () => { test('result has typed category', async () => { const machine = createClassifyMachine( @@ -1388,12 +1465,13 @@ describe('edge cases', () => { test('done state returns as-is', async () => { const machine = createSimpleMachine(); const done = { - value: 'done', + value: 'done' as const, input: {}, context: { count: 1 }, - status: 'done', + messages: [], + status: 'done' as const, output: { result: 1 }, - } as const; + }; expect(await machine.invoke(done)).toEqual(done); }); }); diff --git a/src/cloudflare/index.test.ts b/src/cloudflare/index.test.ts index 67f0010..18b8e5f 100644 --- a/src/cloudflare/index.test.ts +++ b/src/cloudflare/index.test.ts @@ -28,6 +28,7 @@ describe('cloudflare adapter', () => { value: 'done', status: 'done', context: {}, + messages: [], input: {}, }, }); @@ -68,6 +69,7 @@ describe('cloudflare adapter', () => { value: 'done', status: 'done', context: {}, + messages: [], input: {}, }, }); diff --git a/src/examples.test.ts b/src/examples.test.ts index fc1efd7..cdad249 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -143,7 +143,7 @@ describe('curated examples', () => { expect(result.status).toBe('pending'); if (result.status === 'pending') { - expect(result.context.messages).toEqual([ + expect(result.messages).toEqual([ { role: 'user', content: 'Hello there' }, { role: 'assistant', content: 'Replying to: Hello there' }, ]); @@ -234,6 +234,7 @@ describe('curated examples', () => { snapshot: { value: 'done', context: {}, + messages: [], status: 'done', createdAt: 2, sessionId: 'session-1', diff --git a/src/graph/index.test.ts b/src/graph/index.test.ts index 6afbb11..d65d2c9 100644 --- a/src/graph/index.test.ts +++ b/src/graph/index.test.ts @@ -111,6 +111,42 @@ test('exports finite states and transition edges as Stately graph JSON', () => { }); }); +test('exports always transitions and message updates', () => { + const machine = createAgentMachine({ + id: 'always-graph', + context: () => ({}), + initial: 'checking', + states: { + checking: { + always: ({ messages }) => ({ + target: 'done', + messages: messages.concat({ role: 'assistant', content: 'ok' }), + }), + }, + done: { + type: 'final', + }, + }, + }); + + expect(toGraph(machine).edges).toEqual([ + { + type: 'edge', + id: 'checking::0', + sourceId: 'checking', + targetId: 'done', + label: 'always', + data: { + event: '', + source: 'always', + actions: { + messages: true, + }, + }, + }, + ]); +}); + test('infers switch, early-return, and helper-call transition branches', () => { const machine = createAgentMachine({ id: 'ast-rich-export', diff --git a/src/graph/index.ts b/src/graph/index.ts index 842a1f5..f9b4cab 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -27,13 +27,14 @@ export interface AgentGraphNodeData { export interface AgentGraphEdgeData { event?: string; - source?: 'event' | 'invoke.done'; + source?: 'event' | 'invoke.done' | 'always'; guard?: { type: string; }; actions?: { context?: boolean; input?: boolean; + messages?: boolean; }; } @@ -67,6 +68,7 @@ type EdgeCandidate = { guard?: string; hasContext?: boolean; hasInput?: boolean; + hasMessages?: boolean; }; type AnalysisResult = { @@ -131,6 +133,19 @@ export function analyzeGraph(machine: AgentMachine): AgentGraphAnalysis { warnings.push(...formatWarnings(sourceId, event, result.warnings)); } + if (stateConfig.always) { + const event = ''; + const result = getTransitionEdges({ + sourceId, + event, + source: 'always', + transition: stateConfig.always, + ordinalOffset: edges.length, + }); + edges.push(...result.edges); + warnings.push(...formatWarnings(sourceId, 'always', result.warnings)); + } + if (!stateConfig.on) { continue; } @@ -270,11 +285,12 @@ function getTransitionEdges(args: { }, } : {}), - ...((candidate.hasContext || candidate.hasInput) + ...((candidate.hasContext || candidate.hasInput || candidate.hasMessages) ? { actions: { ...(candidate.hasContext ? { context: true } : {}), ...(candidate.hasInput ? { input: true } : {}), + ...(candidate.hasMessages ? { messages: true } : {}), }, } : {}), @@ -301,6 +317,7 @@ function analyzeTransitionObject(transition: unknown): AnalysisResult { target, hasContext: 'context' in transition, hasInput: 'input' in transition, + hasMessages: 'messages' in transition, }], warnings: [], }; @@ -711,6 +728,7 @@ function analyzeTransitionExpression( guard: combineGuardList(guards), hasContext: object ? hasProperty(object, 'context') : false, hasInput: object ? hasProperty(object, 'input') : false, + hasMessages: object ? hasProperty(object, 'messages') : false, }], warnings: [], }; @@ -897,11 +915,12 @@ function hasProperty(object: ts.ObjectLiteralExpression, name: string): boolean } function getEdgeLabel(event: string, guard: string | undefined): string { + const label = event || 'always'; if (!guard) { - return event; + return label; } - return `${event} [${guard}]`; + return `${label} [${guard}]`; } function createBindings( diff --git a/src/index.ts b/src/index.ts index b28d902..4df7514 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,11 +8,18 @@ export { createAdapter } from './adapter.js'; export { createMemoryRunStore } from './runtime/memory-store.js'; export { restoreSession, startSession } from './runtime/session.js'; export { waitForRunDone, waitForRunSnapshot } from './runtime/index.js'; +export { + appendMessages, + assistantMessage, + systemMessage, + userMessage, +} from './utils.js'; // Types export type { AgentAdapter, AgentMachine, + AgentMessage, AgentRun, AgentSnapshot, AgentState, diff --git a/src/langgraph-equivalents/chatbot-messages.test.ts b/src/langgraph-equivalents/chatbot-messages.test.ts index 602445f..1174b1c 100644 --- a/src/langgraph-equivalents/chatbot-messages.test.ts +++ b/src/langgraph-equivalents/chatbot-messages.test.ts @@ -20,7 +20,7 @@ test('message-centric chatbot workflow accumulates structured messages across tu expect(firstResult.status).toBe('pending'); if (firstResult.status === 'pending') { - expect(firstResult.context.messages).toEqual([ + expect(firstResult.messages).toEqual([ { role: 'user', content: 'Hello there' }, { role: 'assistant', content: 'Replying to: Hello there' }, ]); @@ -36,7 +36,7 @@ test('message-centric chatbot workflow accumulates structured messages across tu expect(secondResult.status).toBe('pending'); if (secondResult.status === 'pending') { - expect(secondResult.context.messages).toEqual([ + expect(secondResult.messages).toEqual([ { role: 'user', content: 'Hello there' }, { role: 'assistant', content: 'Replying to: Hello there' }, { role: 'user', content: 'Can you expand on that?' }, diff --git a/src/langgraph-equivalents/error-retry.test.ts b/src/langgraph-equivalents/error-retry.test.ts index 8472fd0..d595a37 100644 --- a/src/langgraph-equivalents/error-retry.test.ts +++ b/src/langgraph-equivalents/error-retry.test.ts @@ -80,6 +80,7 @@ test('restores a durable retry snapshot and continues from the next attempt', as snapshot: { value: retryState.value, context: retryState.context, + messages: retryState.messages, status: retryState.status, input: retryState.input, createdAt: 1, diff --git a/src/machine.ts b/src/machine.ts index 5371bc0..01d85bf 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -1,5 +1,6 @@ import type { AgentMachine, + AgentMessage, AgentSnapshot, AgentState, EmittedPart, @@ -18,6 +19,7 @@ import { formatSchemaIssues, getAvailableEvents, getInput, + isAlwaysEventType, isDoneInvokeEventType, isErrorInvokeEventType, resolveInitial, @@ -50,19 +52,26 @@ type StateNodeDef< resultSchema?: StandardSchemaV1; invoke?: (args: { context: TContext; + messages: AgentMessage[]; input: NoInfer; signal?: AbortSignal; }, enq: { emit(part: EmittedPart): void }) => Promise; - onDone?: (args: { result: OnDoneResult; context: TContext }) => TransitionResult; + onDone?: (args: { result: OnDoneResult; context: TContext; messages: AgentMessage[] }) => TransitionResult; + always?: TransitionResult | ((args: { + context: TContext; + messages: AgentMessage[]; + input: NoInfer; + }, enq: { emit(part: EmittedPart): void }) => TransitionResult); on?: { [E in keyof TEvents & string]?: TransitionResult | ((args: { event: EventFor; context: TContext; + messages: AgentMessage[]; }, enq: { emit(part: EmittedPart): void }) => TransitionResult) }; events?: Record; - output?: (args: { context: TContext }) => NoInfer; + output?: (args: { context: TContext; messages: AgentMessage[] }) => NoInfer; model?: string; adapter?: import('./types.js').AgentAdapter; - prompt?: string | ((args: { context: TContext; input: NoInfer }) => string); + prompt?: string | ((args: { context: TContext; messages: AgentMessage[]; input: NoInfer }) => string); options?: Record; reasoning?: boolean; }; @@ -105,6 +114,7 @@ export function createAgentMachine< output?: StandardSchemaV1; }; context: (input: NoInfer) => NoInfer; + messages?: AgentMessage[] | ((input: NoInfer) => AgentMessage[]); adapter?: import('./types.js').AgentAdapter; initial: | (keyof TInputMap & keyof TResultMap & string) @@ -134,6 +144,7 @@ export function createAgentMachine< output?: StandardSchemaV1; }; context: (input: NoInfer) => TContext; + messages?: AgentMessage[] | ((input: NoInfer) => AgentMessage[]); adapter?: import('./types.js').AgentAdapter; initial: | (keyof TInputMap & keyof TResultMap & string) @@ -162,6 +173,7 @@ export function createAgentMachine< output?: StandardSchemaV1; }; context: (...args: any[]) => TContext; + messages?: AgentMessage[] | ((input: unknown) => AgentMessage[]); adapter?: import('./types.js').AgentAdapter; initial: | (keyof TInputMap & keyof TResultMap & string) @@ -221,6 +233,10 @@ export function createAgentMachine( } const context = cfg.context(validatedInput); + const messages = + typeof cfg.messages === 'function' + ? cfg.messages(validatedInput) + : cfg.messages ?? []; const init = resolveInitial(cfg.initial, { context, input: {} }); if (!init.target) { @@ -230,6 +246,7 @@ export function createAgentMachine( return { value: init.target, context: init.context ? { ...context, ...init.context } : context, + messages: init.messages ?? messages, status: 'active', input: init.input ? { [init.target]: init.input } : {}, }; @@ -238,6 +255,7 @@ export function createAgentMachine( function resolveState(raw: { value: string; context: Record; + messages?: AgentMessage[]; input?: Record>; sessionId?: string; createdAt?: number; @@ -248,6 +266,7 @@ export function createAgentMachine( return { value: raw.value, context: raw.context, + messages: raw.messages ?? [], status: raw.status ?? 'active', input: raw.input ?? {}, sessionId: raw.sessionId, @@ -289,6 +308,7 @@ export function createAgentMachine( context: result.context ? { ...state.context, ...result.context } : state.context, + messages: result.messages ?? state.messages, }; } @@ -296,14 +316,21 @@ export function createAgentMachine( handler: | TransitionResult | ((args: { - event: { type: string; [k: string]: unknown }; - context: Record; - }, enq: { emit(part: EmittedPart): void }) => TransitionResult), + event: { type: string; [k: string]: unknown }; + context: Record; + messages: AgentMessage[]; + input: Record; + }, enq: { emit(part: EmittedPart): void }) => TransitionResult), status = state.status ): { next: AgentState; emitted: EmittedPart[] } { const result: TransitionResult = typeof handler === 'function' - ? handler({ context: state.context, event }, enqueue) + ? handler({ + context: state.context, + messages: state.messages, + input: getInput(state.value, state.input), + event, + }, enqueue) : handler; return { @@ -322,6 +349,7 @@ export function createAgentMachine( const trans = sc.onDone({ result: validatedResult, context: state.context, + messages: state.messages, }); return { @@ -338,6 +366,17 @@ export function createAgentMachine( return { next: { ...state, status: 'pending' }, emitted }; } + if (isAlwaysEventType(state.value, event.type)) { + if (!sc.always) { + throw new Error(`No always transition in state '${state.value}'`); + } + + return resolveHandlerResult( + sc.always, + state.status + ); + } + if (isErrorInvokeEventType(state.value, event.type)) { const internalHandler = sc.on?.[event.type]; if (internalHandler !== undefined) { @@ -452,7 +491,7 @@ export function createAgentMachine( const input = getInput(state.value, state.input); const prompt = typeof sc.prompt === 'function' - ? sc.prompt({ context: state.context, input }) + ? sc.prompt({ context: state.context, messages: state.messages, input }) : sc.prompt; try { @@ -487,6 +526,7 @@ export function createAgentMachine( const result = await sc.invoke!( { context: state.context, + messages: state.messages, input: getInput(state.value, state.input), }, createEnqueue(onEmit) @@ -516,6 +556,13 @@ export function createAgentMachine( } const sc = resolveStateConfig(cfg, state.value); + if (sc.always) { + return { + type: `xstate.always.${state.value}`, + at: Date.now(), + }; + } + if (sc.type === 'choice') { return createChoiceEvent(state); } @@ -560,7 +607,7 @@ export function createAgentMachine( if (sc.type === 'final') { const rawOutput = sc.output - ? sc.output({ context: state.context }) + ? sc.output({ context: state.context, messages: state.messages }) : undefined; const output = cfg.schemas?.output ? validateSchemaSync(cfg.schemas.output, rawOutput) @@ -597,6 +644,7 @@ export function createAgentMachine( state: current, output: current.output, context: current.context, + messages: current.messages, }; case 'pending': return { @@ -605,6 +653,7 @@ export function createAgentMachine( value: current.value, events: getAvailableEvents(cfg, current.value), context: current.context, + messages: current.messages, }; case 'error': return { @@ -642,6 +691,7 @@ export function createAgentMachine( return { value: s.value, context: s.context, + messages: s.messages, status: s.status, sessionId: runtime.sessionId, createdAt: runtime.createdAt, diff --git a/src/persistence.test.ts b/src/persistence.test.ts index ae505e7..7cd2f17 100644 --- a/src/persistence.test.ts +++ b/src/persistence.test.ts @@ -48,6 +48,7 @@ test('loads the most replay-advanced saved snapshot', async () => { snapshot: { value: 'idle', context: { count: 1 }, + messages: [], status: 'active', createdAt: 100, sessionId: 'session-1', @@ -64,6 +65,7 @@ test('loads the most replay-advanced saved snapshot', async () => { snapshot: { value: 'done', context: { count: 2 }, + messages: [], status: 'done', createdAt: 300, sessionId: 'session-1', @@ -81,6 +83,7 @@ test('loads the most replay-advanced saved snapshot', async () => { snapshot: { value: 'done', context: { count: 2 }, + messages: [], status: 'done', createdAt: 300, sessionId: 'session-1', @@ -102,6 +105,7 @@ test('loads the most replay-advanced snapshot even if saved earlier', async () = snapshot: { value: 'done', context: { count: 5 }, + messages: [], status: 'done', createdAt: 500, sessionId: 'session-1', @@ -116,6 +120,7 @@ test('loads the most replay-advanced snapshot even if saved earlier', async () = snapshot: { value: 'review', context: { count: 2 }, + messages: [], status: 'active', createdAt: 200, sessionId: 'session-1', @@ -130,6 +135,7 @@ test('loads the most replay-advanced snapshot even if saved earlier', async () = snapshot: { value: 'done', context: { count: 5 }, + messages: [], status: 'done', createdAt: 500, sessionId: 'session-1', diff --git a/src/session-runtime.test.ts b/src/session-runtime.test.ts index 7b7d292..c8daed0 100644 --- a/src/session-runtime.test.ts +++ b/src/session-runtime.test.ts @@ -147,6 +147,49 @@ test('serializes concurrent sends so each event applies from the latest snapshot ); }); +test('journals always transitions and persists messages', async () => { + const machine = createAgentMachine({ + id: 'always-session', + context: () => ({ ready: false }), + messages: () => [{ role: 'user', content: 'start' }], + initial: 'checking', + states: { + checking: { + always: ({ messages }) => ({ + target: 'done', + context: { ready: true }, + messages: messages.concat({ role: 'assistant', content: 'done' }), + }), + }, + done: { + type: 'final', + output: ({ context, messages }) => ({ ...context, messages }), + }, + }, + }); + const store = createMemoryRunStore(); + const run = await startSession(machine, { store }); + + await vi.waitFor(() => { + expect(run.getSnapshot()).toEqual( + expect.objectContaining({ + value: 'done', + status: 'done', + context: { ready: true }, + messages: [ + { role: 'user', content: 'start' }, + { role: 'assistant', content: 'done' }, + ], + }) + ); + }); + + await expect(store.loadEvents(run.sessionId)).resolves.toEqual([ + expect.objectContaining({ sequence: 1, type: 'xstate.init' }), + expect.objectContaining({ sequence: 2, type: 'xstate.always.checking' }), + ]); +}); + test('rejects reserved internal events from run.send', async () => { const machine = createAgentMachine({ id: 'reserved-events', @@ -181,4 +224,7 @@ test('rejects reserved internal events from run.send', async () => { await expect( run.send({ type: 'xstate.error.invoke.worker' }) ).rejects.toThrow(/reserved internal event/i); + await expect( + run.send({ type: 'xstate.always.ready' }) + ).rejects.toThrow(/reserved internal event/i); }); diff --git a/src/session-types.test.ts b/src/session-types.test.ts index ea4b8a7..af88cc5 100644 --- a/src/session-types.test.ts +++ b/src/session-types.test.ts @@ -5,6 +5,7 @@ test('AgentSnapshot includes durable session fields', () => { const snapshot: AgentSnapshot<{ count: number }, 'idle'> = { value: 'idle', context: { count: 1 }, + messages: [], status: 'active', createdAt: 123, sessionId: 'session-1', diff --git a/src/types.ts b/src/types.ts index b6809f9..2967dbe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,12 @@ export type TransitionEvent< export type EmittedPart = { type: string; [key: string]: unknown }; +export type AgentMessage = { + role: string; + content: string; + [key: string]: unknown; +}; + export interface InvokeEnqueue { emit(part: EmittedPart): void; } @@ -71,12 +77,14 @@ export type TransitionResult< | { target?: undefined; context?: Partial; + messages?: AgentMessage[]; input?: never; } | { [K in TTarget]: { target: K; context?: Partial; + messages?: AgentMessage[]; } & (K extends keyof TInputByTarget ? IsExactlyUnknown extends true ? { input?: never } @@ -90,6 +98,7 @@ export interface InitialTransitionResult< > { target: TTarget; context?: Partial; + messages?: AgentMessage[]; input?: Record; } @@ -105,17 +114,19 @@ export interface StateConfig< resultSchema?: StandardSchemaV1; invoke?: (args: { context: TContext; + messages: AgentMessage[]; input: Record; signal?: AbortSignal; }, enq: InvokeEnqueue) => Promise; - onDone?: (args: { result: any; context: TContext }) => TransitionResult; - on?: Record | ((args: { event: any; context: TContext }, enq: InvokeEnqueue) => TransitionResult)>; + onDone?: (args: { result: any; context: TContext; messages: AgentMessage[] }) => TransitionResult; + always?: TransitionResult | ((args: { context: TContext; messages: AgentMessage[]; input: Record }, enq: InvokeEnqueue) => TransitionResult); + on?: Record | ((args: { event: any; context: TContext; messages: AgentMessage[] }, enq: InvokeEnqueue) => TransitionResult)>; events?: Record; - output?: (args: { context: TContext }) => unknown; + output?: (args: { context: TContext; messages: AgentMessage[] }) => unknown; // choice-specific model?: string; adapter?: AgentAdapter; - prompt?: string | ((args: { context: TContext; input: Record }) => string); + prompt?: string | ((args: { context: TContext; messages: AgentMessage[]; input: Record }) => string); options?: Record; reasoning?: boolean; } @@ -140,6 +151,7 @@ export interface AgentState< > { value: TValue; context: TContext; + messages: AgentMessage[]; status: 'active' | 'pending' | 'done' | 'error'; input: Record>; sessionId?: string; @@ -156,8 +168,8 @@ export type ExecuteResult< TEvents extends Record = {}, TOutput = unknown, > = - | { status: 'done'; state: AgentState; output: TOutput; context: TContext } - | { status: 'pending'; state: AgentState; value: TValue; events: Record; context: TContext } + | { status: 'done'; state: AgentState; output: TOutput; context: TContext; messages: AgentMessage[] } + | { status: 'pending'; state: AgentState; value: TValue; events: Record; context: TContext; messages: AgentMessage[] } | { status: 'error'; state: AgentState; error: unknown }; // ─── Snapshot ─── @@ -169,6 +181,7 @@ export interface AgentSnapshot< > { value: TValue; context: TContext; + messages: AgentMessage[]; status: AgentState['status']; createdAt: number; sessionId: string; @@ -201,6 +214,7 @@ export interface AgentMachine< | { value: string; context: TContext; + messages?: AgentMessage[]; input?: Record>; sessionId?: string; createdAt?: number; @@ -304,6 +318,7 @@ export interface MachineConfig< output?: StandardSchemaV1; }; context: (input: TInput) => TContext; + messages?: AgentMessage[] | ((input: TInput) => AgentMessage[]); adapter?: AgentAdapter; initial: | (keyof TStates & string) diff --git a/src/utils.ts b/src/utils.ts index 2665900..b357bae 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import type { + AgentMessage, AgentState, InitialTransitionResult, MachineConfig, @@ -50,17 +51,19 @@ export type StateConfigAny = { invoke?: ( args: { context: Record; + messages: AgentMessage[]; input: Record; }, enq: { emit(part: { type: string; [key: string]: unknown }): void } ) => Promise; - onDone?: (args: { result: unknown; context: Record }) => TransitionResult; - on?: Record; context: Record }, enq: { emit(part: { type: string; [key: string]: unknown }): void }) => TransitionResult)>; - output?: (args: { context: Record }) => unknown; + onDone?: (args: { result: unknown; context: Record; messages: AgentMessage[] }) => TransitionResult; + always?: TransitionResult | ((args: { context: Record; messages: AgentMessage[]; input: Record }, enq: { emit(part: { type: string; [key: string]: unknown }): void }) => TransitionResult); + on?: Record; context: Record; messages: AgentMessage[] }, enq: { emit(part: { type: string; [key: string]: unknown }): void }) => TransitionResult)>; + output?: (args: { context: Record; messages: AgentMessage[] }) => unknown; resultSchema?: StandardSchemaV1; model?: string; adapter?: { decide: (...args: unknown[]) => Promise }; - prompt?: string | ((args: { context: Record; input: Record }) => string); + prompt?: string | ((args: { context: Record; messages: AgentMessage[]; input: Record }) => string); options?: Record; reasoning?: boolean; events?: Record; @@ -110,6 +113,10 @@ export function applyTransition( newState.context = { ...state.context, ...transition.context }; } + if (transition.messages) { + newState.messages = transition.messages; + } + if (transition.target) { newState.value = transition.target; newState.status = 'active'; @@ -204,11 +211,19 @@ export function isErrorInvokeEventType( return eventType === `xstate.error.invoke.${stateValue}`; } +export function isAlwaysEventType( + stateValue: string, + eventType: string +): boolean { + return eventType === `xstate.always.${stateValue}`; +} + export function isReservedInternalEventType(eventType: string): boolean { return ( eventType === 'xstate.init' || eventType.startsWith('xstate.done.invoke.') || eventType.startsWith('xstate.error.invoke.') + || eventType.startsWith('xstate.always.') ); } @@ -223,3 +238,22 @@ export function serializeError(error: unknown): unknown { return error; } + +export function appendMessages( + messages: readonly AgentMessage[], + ...nextMessages: AgentMessage[] +): AgentMessage[] { + return messages.concat(nextMessages); +} + +export function userMessage(content: string, extras: Record = {}): AgentMessage { + return { role: 'user', content, ...extras }; +} + +export function assistantMessage(content: string, extras: Record = {}): AgentMessage { + return { role: 'assistant', content, ...extras }; +} + +export function systemMessage(content: string, extras: Record = {}): AgentMessage { + return { role: 'system', content, ...extras }; +} diff --git a/src/xstate/index.test.ts b/src/xstate/index.test.ts index 5b00afe..af49f28 100644 --- a/src/xstate/index.test.ts +++ b/src/xstate/index.test.ts @@ -116,3 +116,37 @@ test('exports a serializable XState config for visualization', () => { }); expect(toXStateMachine(machine)).toEqual(toXStateVisualization(machine)); }); + +test('exports always transitions for visualization', () => { + const machine = createAgentMachine({ + id: 'xstate-always', + context: () => ({}), + initial: 'checking', + states: { + checking: { + always: ({ messages }) => ({ + target: 'done', + messages: messages.concat({ role: 'assistant', content: 'ok' }), + }), + }, + done: { + type: 'final', + }, + }, + }); + + expect(toXStateVisualization(machine).states.checking).toEqual({ + always: { + target: 'done', + actions: ['assignMessages'], + meta: { + agent: { + event: '', + updates: { + messages: true, + }, + }, + }, + }, + }); +}); diff --git a/src/xstate/index.ts b/src/xstate/index.ts index 14311df..9d9b40c 100644 --- a/src/xstate/index.ts +++ b/src/xstate/index.ts @@ -17,6 +17,7 @@ export interface XStateMachineConfig { export interface XStateStateConfig { type?: 'final'; on?: Record; + always?: XStateTransitionConfig | XStateTransitionConfig[]; invoke?: { id: string; src: string; @@ -43,6 +44,7 @@ export interface XStateTransitionConfig { updates?: { context?: boolean; input?: boolean; + messages?: boolean; }; }; }; @@ -90,6 +92,7 @@ export function toXStateVisualization(machine: AgentMachine): XStateMachineConfi const regularEdges = graph.edges.filter((edge) => edge.sourceId === stateId && edge.data.source !== 'invoke.done' + && edge.data.source !== 'always' ); for (const [event, edges] of groupEdgesByEvent(regularEdges)) { @@ -102,6 +105,18 @@ export function toXStateVisualization(machine: AgentMachine): XStateMachineConfi xstateState.on[event] = formatted; } + if (stateConfig.always) { + const alwaysEdges = graph.edges.filter((edge) => + edge.sourceId === stateId + && edge.data.source === 'always' + ); + + const formattedAlways = formatTransitions(alwaysEdges); + if (formattedAlways) { + xstateState.always = formattedAlways; + } + } + if (stateConfig.onDone) { const doneEdges = graph.edges.filter((edge) => edge.sourceId === stateId @@ -180,6 +195,7 @@ function formatTransition(edge: AgentGraphEdge): XStateTransitionConfig { const actions = [ ...(edge.data.actions?.context ? ['assignContext'] : []), ...(edge.data.actions?.input ? ['assignInput'] : []), + ...(edge.data.actions?.messages ? ['assignMessages'] : []), ]; return { @@ -194,6 +210,7 @@ function formatTransition(edge: AgentGraphEdge): XStateTransitionConfig { updates: { ...(edge.data.actions.context ? { context: true } : {}), ...(edge.data.actions.input ? { input: true } : {}), + ...(edge.data.actions.messages ? { messages: true } : {}), }, } : {}), From c169a3dfb12dc57d2c37a7fd8aca9f7cf42ab908 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 17 May 2026 21:09:49 +0100 Subject: [PATCH 34/50] Add generative state schemas --- examples/README.md | 1 + examples/_run.ts | 28 +- examples/adapter.ts | 15 +- examples/ai-sdk.ts | 24 +- examples/branching.ts | 12 +- examples/chatbot-messages.ts | 8 +- examples/chatbot.ts | 20 +- examples/classify.ts | 10 +- examples/conditional-subflow.ts | 27 +- examples/content-creator-flow.ts | 14 +- examples/customer-service-sim.ts | 16 +- examples/decide.ts | 12 +- examples/email-auto-responder-flow.ts | 6 +- examples/email.ts | 18 +- examples/error-retry.ts | 6 +- examples/hitl.ts | 8 +- examples/http-streaming-session.ts | 6 +- examples/index.ts | 5 + examples/joke.ts | 63 +- examples/jugs.ts | 23 +- examples/lead-score-flow.ts | 14 +- examples/map-reduce.ts | 18 +- examples/meeting-assistant-flow.ts | 18 +- examples/multi-agent-network.ts | 46 +- examples/newspaper.ts | 32 +- examples/next-ai-sdk-ui.ts | 6 +- examples/persistence.ts | 6 +- examples/persistent-streaming.ts | 6 +- examples/plan-and-execute.ts | 21 +- examples/raffle.ts | 12 +- examples/rag.ts | 52 +- examples/react-agent-from-scratch.ts | 33 +- examples/reflection.ts | 20 +- examples/rewoo.ts | 23 +- examples/river-crossing.ts | 25 +- examples/self-evaluation-loop-flow.ts | 18 +- examples/simple.ts | 23 +- examples/spec-agent-loop.ts | 20 +- examples/sql-agent.ts | 27 +- examples/subflow.ts | 18 +- examples/supervisor.ts | 31 +- examples/tool-calling.ts | 6 +- examples/tutor.ts | 14 +- examples/workflow-guardrails.ts | 363 +++++++++++ examples/write-a-book-flow.ts | 20 +- readme.md | 2 +- src/adapter.ts | 2 +- src/agent.test.ts | 564 ++++++++++++++---- src/ai-sdk/index.test.ts | 24 +- src/ai-sdk/index.ts | 36 +- src/decide.ts | 6 +- src/examples.test.ts | 76 ++- src/graph/index.test.ts | 7 +- src/http/index.test.ts | 8 +- src/index.ts | 6 + src/invoke-events.test.ts | 8 +- src/langgraph-equivalents/branching.test.ts | 14 +- src/langgraph-equivalents/graph.test.ts | 40 +- src/langgraph-equivalents/hitl.test.ts | 6 +- src/langgraph-equivalents/map-reduce.test.ts | 18 +- src/langgraph-equivalents/persistence.test.ts | 6 +- src/langgraph-equivalents/rag.test.ts | 11 +- src/langgraph-equivalents/streaming.test.ts | 6 +- src/langgraph-equivalents/subflow.test.ts | 18 +- .../tool-calling.test.ts | 6 +- src/machine.ts | 302 +++++++--- src/restore.test.ts | 6 +- src/session-runtime.test.ts | 6 +- src/streaming.test.ts | 12 +- src/target-types.assert.ts | 20 +- src/types.ts | 77 ++- src/utils.ts | 48 +- src/xstate/index.test.ts | 7 +- src/xstate/index.ts | 4 - 74 files changed, 1799 insertions(+), 741 deletions(-) create mode 100644 examples/workflow-guardrails.ts diff --git a/examples/README.md b/examples/README.md index 724fc56..09f186e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -39,6 +39,7 @@ These focus on real orchestration patterns: - [`meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts) - [`self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts) - [`spec-agent-loop.ts`](/Users/davidkpiano/Code/agent/examples/spec-agent-loop.ts) +- [`workflow-guardrails.ts`](/Users/davidkpiano/Code/agent/examples/workflow-guardrails.ts) - [`write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts) - [`plan-and-execute.ts`](/Users/davidkpiano/Code/agent/examples/plan-and-execute.ts) - [`reflection.ts`](/Users/davidkpiano/Code/agent/examples/reflection.ts) diff --git a/examples/_run.ts b/examples/_run.ts index 510f2af..f632bf0 100644 --- a/examples/_run.ts +++ b/examples/_run.ts @@ -8,6 +8,7 @@ import { pathToFileURL } from 'node:url'; import { z } from 'zod'; import type { AgentAdapter, + DecideAdapter, ExecuteResult, StandardSchemaV1, } from '../src/index.js'; @@ -98,7 +99,7 @@ export function formatResult(result: ExecuteResult) { }; } -export function createOpenAiDecisionAdapter(): AgentAdapter { +export function createOpenAiDecisionAdapter(): DecideAdapter { return { async decide({ model, prompt, options, reasoning }) { const optionKeys = Object.keys(options); @@ -106,7 +107,7 @@ export function createOpenAiDecisionAdapter(): AgentAdapter { const allSchemaLess = Object.values(options).every((option) => !option.schema); if (allSchemaLess && !reasoning) { - const choiceResult = await generateText({ + const choiceResult = await generateText({ model: createExampleModel(model), system: [ 'Choose exactly one option.', @@ -173,6 +174,29 @@ export function createOpenAiDecisionAdapter(): AgentAdapter { }; } +export function createOpenAiGenerationAdapter(): AgentAdapter { + return { + async generateText({ model, system, prompt, messages, outputSchema }) { + const result = await generateText({ + model: createExampleModel(model), + system, + prompt, + messages: messages as any, + ...(outputSchema + ? { + output: Output.object({ + schema: toZodSchema(outputSchema), + }), + } + : {}), + }); + + const output = result as { output?: unknown; text?: string }; + return output.output ?? output.text ?? result; + }, + }; +} + export async function generateExampleObject(options: { schema: StandardSchemaV1; prompt: string; diff --git a/examples/adapter.ts b/examples/adapter.ts index a88e6c1..02f368a 100644 --- a/examples/adapter.ts +++ b/examples/adapter.ts @@ -1,10 +1,9 @@ import { z } from 'zod'; import { - createAdapter, createAgentMachine, decide, decideResultSchema, - type AgentAdapter, + type DecideAdapter, } from '../src/index.js'; import { closePrompt, @@ -15,9 +14,7 @@ import { } from './_run.js'; export function createAdapterExample( - adapter: AgentAdapter = createAdapter({ - decide: createOpenAiDecisionAdapter().decide, - }) + adapter: DecideAdapter = createOpenAiDecisionAdapter() ) { const routeOptions = { billing: { @@ -47,7 +44,7 @@ export function createAdapterExample( initial: 'route', states: { route: { - resultSchema: decideResultSchema(routeOptions), + schemas: { output: decideResultSchema(routeOptions) }, invoke: async ({ context }) => decide({ adapter, @@ -62,12 +59,12 @@ export function createAdapterExample( options: routeOptions, reasoning: false, }), - onDone: ({ result }) => { + onDone: ({ output }) => { return { target: 'done', context: { - route: result.choice, - confidence: result.data.confidence, + route: output.choice, + confidence: output.data.confidence, }, }; }, diff --git a/examples/ai-sdk.ts b/examples/ai-sdk.ts index c60f730..b83b170 100644 --- a/examples/ai-sdk.ts +++ b/examples/ai-sdk.ts @@ -4,9 +4,9 @@ import { createAgentMachine, decide, decideResultSchema, - type AgentAdapter, + type DecideAdapter, } from '../src/index.js'; -import { createAiSdkAdapter } from '../src/ai-sdk/index.js'; +import { createAiSdkDecisionAdapter } from '../src/ai-sdk/index.js'; import { closePrompt, createExampleModel, @@ -38,7 +38,7 @@ const replySchema = z.object({ type Route = keyof typeof routeOptions; export function createAiSdkExample(options: { - adapter?: AgentAdapter; + adapter?: DecideAdapter; draftReply?: (args: { route: Route; confidence: number; @@ -47,7 +47,7 @@ export function createAiSdkExample(options: { } = {}) { const adapter = options.adapter ?? - createAiSdkAdapter({ + createAiSdkDecisionAdapter({ resolveModel: (model) => createExampleModel(model), }); @@ -100,7 +100,7 @@ export function createAiSdkExample(options: { initial: 'route', states: { route: { - resultSchema: decideResultSchema(routeOptions), + schemas: { output: decideResultSchema(routeOptions) }, invoke: async ({ context }) => decide({ adapter, @@ -112,27 +112,27 @@ export function createAiSdkExample(options: { ].join('\n'), options: routeOptions, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'drafting', context: { - route: result.choice, - confidence: result.data.confidence, + route: output.choice, + confidence: output.data.confidence, }, }), }, drafting: { - resultSchema: replySchema, + schemas: { output: replySchema }, invoke: async ({ context }) => draftReply({ route: context.route ?? 'support', confidence: context.confidence ?? 0, message: context.message, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', context: { - subject: result.subject, - body: result.body, + subject: output.subject, + body: output.body, }, }), }, diff --git a/examples/branching.ts b/examples/branching.ts index d50d78d..3f89c88 100644 --- a/examples/branching.ts +++ b/examples/branching.ts @@ -52,7 +52,7 @@ export function createBranchingExample( initial: 'analyzing', states: { analyzing: { - resultSchema: branchResultSchema, + schemas: { output: branchResultSchema }, invoke: async ({ context }) => { const [docs, issues, code] = await Promise.all([ (options.analyzeDocs @@ -77,13 +77,13 @@ export function createBranchingExample( return { docs, issues, code }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'summarizing', - context: result, + context: output, }), }, summarizing: { - resultSchema: summarySchema, + schemas: { output: summarySchema }, invoke: async ({ context }) => (options.summarize ?? (({ docs, issues, code }) => @@ -104,9 +104,9 @@ export function createBranchingExample( issues: context.issues ?? '', code: context.code ?? '', }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { summary: result.summary }, + context: { summary: output.summary }, }), }, done: { diff --git a/examples/chatbot-messages.ts b/examples/chatbot-messages.ts index 7c5cd80..fa6e2ca 100644 --- a/examples/chatbot-messages.ts +++ b/examples/chatbot-messages.ts @@ -70,13 +70,13 @@ export function createChatbotMessagesExample( }, }, replying: { - resultSchema: replySchema, + schemas: { output: replySchema }, invoke: async ({ messages }) => reply(messages), - onDone: ({ result, messages }) => ({ + onDone: ({ output, messages }) => ({ target: 'waitingForUser', - messages: messages.concat(result.message), + messages: messages.concat(output.message), context: { - finalMessage: result.message, + finalMessage: output.message, }, }), }, diff --git a/examples/chatbot.ts b/examples/chatbot.ts index 8f53f30..b062664 100644 --- a/examples/chatbot.ts +++ b/examples/chatbot.ts @@ -5,7 +5,7 @@ import { decide, decideResultSchema, startSession, - type AgentAdapter, + type DecideAdapter, } from '../src/index.js'; import { closePrompt, @@ -22,7 +22,7 @@ const replySchema = z.object({ export function createChatbotExample( options: { - adapter?: AgentAdapter; + adapter?: DecideAdapter; reply?: (transcript: string[]) => Promise>; } = {} ) { @@ -85,7 +85,7 @@ export function createChatbotExample( }, }, deciding: { - resultSchema: decideResultSchema(decisionOptions), + schemas: { output: decideResultSchema(decisionOptions) }, invoke: async ({ context }) => decide({ adapter, @@ -98,19 +98,19 @@ export function createChatbotExample( ].join('\n'), options: decisionOptions, }), - onDone: ({ result }) => ({ - target: result.choice === 'end' ? 'done' : 'replying', - context: result.choice === 'end' ? { ended: true } : {}, + onDone: ({ output }) => ({ + target: output.choice === 'end' ? 'done' : 'replying', + context: output.choice === 'end' ? { ended: true } : {}, }), }, replying: { - resultSchema: replySchema, + schemas: { output: replySchema }, invoke: async ({ context }) => reply(context.transcript), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'listening', context: { - lastAssistantMessage: result.response, - transcript: [...context.transcript, `Assistant: ${result.response}`], + lastAssistantMessage: output.response, + transcript: [...context.transcript, `Assistant: ${output.response}`], }, }), }, diff --git a/examples/classify.ts b/examples/classify.ts index eb4eaab..dcbc96a 100644 --- a/examples/classify.ts +++ b/examples/classify.ts @@ -3,7 +3,7 @@ import { createAgentMachine, classify, classifyResultSchema, - type AgentAdapter, + type DecideAdapter, } from '../src/index.js'; import { closePrompt, @@ -14,7 +14,7 @@ import { } from './_run.js'; export function createClassifyExample( - adapter: AgentAdapter = createOpenAiDecisionAdapter() + adapter: DecideAdapter = createOpenAiDecisionAdapter() ) { const categories = { billing: { description: 'Payments, invoices, refunds, and charges.' }, @@ -35,7 +35,7 @@ export function createClassifyExample( initial: 'routing', states: { routing: { - resultSchema: classifyResultSchema(categories), + schemas: { output: classifyResultSchema(categories) }, invoke: async ({ context }) => classify({ adapter, @@ -43,9 +43,9 @@ export function createClassifyExample( prompt: `Classify this support request:\n\n${context.request}`, into: categories, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { category: result.category }, + context: { category: output.category }, }), }, done: { diff --git a/examples/conditional-subflow.ts b/examples/conditional-subflow.ts index 52793f0..391adbc 100644 --- a/examples/conditional-subflow.ts +++ b/examples/conditional-subflow.ts @@ -40,7 +40,7 @@ export function createConditionalSubflowExample( initial: 'researching', states: { researching: { - resultSchema: researchSchema, + schemas: { output: researchSchema }, invoke: async ({ context }) => (options.research ?? ((topic) => @@ -49,9 +49,9 @@ export function createConditionalSubflowExample( system: 'Return concise research bullets.', prompt: `Return 2 to 4 bullets about ${topic}.`, })))(context.topic), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { bullets: result.bullets }, + context: { bullets: output.bullets }, }), }, done: { @@ -78,7 +78,7 @@ export function createConditionalSubflowExample( initial: 'drafting', states: { drafting: { - resultSchema: draftSchema, + schemas: { output: draftSchema }, invoke: async ({ context }) => (options.draft ?? (({ topic, bullets }) => @@ -94,9 +94,9 @@ export function createConditionalSubflowExample( topic: context.topic, bullets: context.bullets, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { draft: result.draft }, + context: { draft: output.draft }, }), }, done: { @@ -132,7 +132,7 @@ export function createConditionalSubflowExample( : { target: 'drafting', input: { bullets: context.bullets } }, states: { researching: { - resultSchema: researchSchema, + schemas: { output: researchSchema }, invoke: async ({ context }) => { const result = await researchMachine.execute( researchMachine.getInitialState({ topic: context.topic }) @@ -144,16 +144,15 @@ export function createConditionalSubflowExample( return result.output; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { bullets: result.bullets }, + context: { bullets: output.bullets }, }), }, drafting: { - inputSchema: z.object({ + schemas: { input: z.object({ bullets: z.array(z.string()), - }), - resultSchema: draftSchema, + }), output: draftSchema }, invoke: async ({ context, input }) => { const result = await draftMachine.execute( draftMachine.getInitialState({ @@ -168,9 +167,9 @@ export function createConditionalSubflowExample( return result.output; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { draft: result.draft }, + context: { draft: output.draft }, }), }, done: { diff --git a/examples/content-creator-flow.ts b/examples/content-creator-flow.ts index efc483d..b6d579d 100644 --- a/examples/content-creator-flow.ts +++ b/examples/content-creator-flow.ts @@ -83,17 +83,17 @@ export function createContentCreatorFlowExample(options: { initial: 'routing', states: { routing: { - resultSchema: routeSchema, + schemas: { output: routeSchema }, invoke: async ({ context }) => routeRequest(context.request), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'creating', context: { - route: result.route, + route: output.route, }, }), }, creating: { - resultSchema: contentSchema, + schemas: { output: contentSchema }, invoke: async ({ context }) => { switch (context.route) { case 'linkedin': @@ -105,11 +105,11 @@ export function createContentCreatorFlowExample(options: { return createBlog(context.request); } }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', context: { - title: result.title, - body: result.body, + title: output.title, + body: output.body, }, }), }, diff --git a/examples/customer-service-sim.ts b/examples/customer-service-sim.ts index 3bade57..a7f1e2a 100644 --- a/examples/customer-service-sim.ts +++ b/examples/customer-service-sim.ts @@ -87,12 +87,12 @@ export function createCustomerServiceSimExample( initial: 'service', states: { service: { - resultSchema: serviceReplySchema, + schemas: { output: serviceReplySchema }, invoke: async ({ context }) => serviceReply(context), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: context.turnCount + 1 >= context.maxTurns ? 'done' : 'customer', context: { - transcript: [...context.transcript, `Agent: ${result.response}`], + transcript: [...context.transcript, `Agent: ${output.response}`], outcome: context.turnCount + 1 >= context.maxTurns ? 'max-turns-reached' @@ -101,14 +101,14 @@ export function createCustomerServiceSimExample( }), }, customer: { - resultSchema: customerReplySchema, + schemas: { output: customerReplySchema }, invoke: async ({ context }) => customerReply(context), - onDone: ({ result, context }) => ({ - target: result.done ? 'done' : 'service', + onDone: ({ output, context }) => ({ + target: output.done ? 'done' : 'service', context: { - transcript: [...context.transcript, `Customer: ${result.response}`], + transcript: [...context.transcript, `Customer: ${output.response}`], turnCount: context.turnCount + 1, - outcome: result.outcome, + outcome: output.outcome, }, }), }, diff --git a/examples/decide.ts b/examples/decide.ts index 723a3ef..a484150 100644 --- a/examples/decide.ts +++ b/examples/decide.ts @@ -3,7 +3,7 @@ import { createAgentMachine, decide, decideResultSchema, - type AgentAdapter, + type DecideAdapter, } from '../src/index.js'; import { closePrompt, @@ -13,7 +13,7 @@ import { prompt, } from './_run.js'; -export function createDecideExample(adapter: AgentAdapter = createOpenAiDecisionAdapter()) { +export function createDecideExample(adapter: DecideAdapter = createOpenAiDecisionAdapter()) { const triageOptions = { reply: { description: 'Reply directly to the customer.', @@ -46,7 +46,7 @@ export function createDecideExample(adapter: AgentAdapter = createOpenAiDecision initial: 'triage', states: { triage: { - resultSchema: decideResultSchema(triageOptions), + schemas: { output: decideResultSchema(triageOptions) }, invoke: async ({ context }) => decide({ adapter, @@ -59,11 +59,11 @@ export function createDecideExample(adapter: AgentAdapter = createOpenAiDecision ].join('\n'), options: triageOptions, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', context: { - action: result.choice, - payload: result.data, + action: output.choice, + payload: output.data, }, }), }, diff --git a/examples/email-auto-responder-flow.ts b/examples/email-auto-responder-flow.ts index 8ab30ea..2d1bf85 100644 --- a/examples/email-auto-responder-flow.ts +++ b/examples/email-auto-responder-flow.ts @@ -111,14 +111,14 @@ export function createEmailAutoResponderFlowExample( target: 'done', }, }, - resultSchema: draftResponseSchema, + schemas: { output: draftResponseSchema }, invoke: async ({ context }) => createDraft(context.currentEmail!), - onDone: ({ result, context }) => { + onDone: ({ output, context }) => { const currentEmail = context.currentEmail!; const processedIds = [...context.processedIds, currentEmail.id]; const drafts = { ...context.drafts, - [currentEmail.id]: result.draft, + [currentEmail.id]: output.draft, }; const [nextEmail, ...queue] = context.queue; diff --git a/examples/email.ts b/examples/email.ts index 972228b..391b753 100644 --- a/examples/email.ts +++ b/examples/email.ts @@ -5,7 +5,7 @@ import { decide, decideResultSchema, startSession, - type AgentAdapter, + type DecideAdapter, } from '../src/index.js'; import { closePrompt, @@ -29,7 +29,7 @@ type EmailTools = { export function createEmailExample( options: { - adapter?: AgentAdapter; + adapter?: DecideAdapter; tools?: Partial; compose?: ( input: { @@ -146,7 +146,7 @@ export function createEmailExample( initial: 'checking', states: { checking: { - resultSchema: decideResultSchema(checkingOptions), + schemas: { output: decideResultSchema(checkingOptions) }, invoke: async ({ context }) => decide({ adapter, @@ -161,14 +161,14 @@ export function createEmailExample( ].join('\n'), options: checkingOptions, }), - onDone: ({ result, context }) => { + onDone: ({ output, context }) => { if ( - result.choice === 'askForClarification' + output.choice === 'askForClarification' && context.clarifications.length === 0 ) { return { target: 'clarifying', - context: { questions: result.data.questions }, + context: { questions: output.data.questions }, }; } @@ -190,7 +190,7 @@ export function createEmailExample( }, }, drafting: { - resultSchema: draftSchema, + schemas: { output: draftSchema }, invoke: async ({ context }) => { const contactName = await tools.lookupContactName(context.email); const availability = await tools.lookupAvailability(); @@ -205,9 +205,9 @@ export function createEmailExample( signature, }); }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { replyEmail: result.replyEmail }, + context: { replyEmail: output.replyEmail }, }), }, done: { diff --git a/examples/error-retry.ts b/examples/error-retry.ts index dba62e9..ce422f6 100644 --- a/examples/error-retry.ts +++ b/examples/error-retry.ts @@ -56,16 +56,16 @@ export function createErrorRetryExample( initial: 'answering', states: { answering: { - resultSchema: answerSchema, + schemas: { output: answerSchema }, invoke: async ({ context }) => answer({ question: context.question, attempt: context.attempt, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', context: { - answer: result.answer, + answer: output.answer, }, }), on: { diff --git a/examples/hitl.ts b/examples/hitl.ts index 919d894..9d80bf2 100644 --- a/examples/hitl.ts +++ b/examples/hitl.ts @@ -64,16 +64,16 @@ export function createHitlExample( }, }, drafting: { - resultSchema: draftSchema, + schemas: { output: draftSchema }, invoke: async ({ context, messages }) => draftReply({ task: context.task, messages, }), - onDone: ({ result, messages }) => ({ + onDone: ({ output, messages }) => ({ target: 'done', - messages: messages.concat({ role: 'assistant', content: result.draft }), - context: { draft: result.draft }, + messages: messages.concat({ role: 'assistant', content: output.draft }), + context: { draft: output.draft }, }), }, done: { diff --git a/examples/http-streaming-session.ts b/examples/http-streaming-session.ts index 736ba97..2f2a394 100644 --- a/examples/http-streaming-session.ts +++ b/examples/http-streaming-session.ts @@ -47,14 +47,14 @@ export function createStreamingSessionHttpController(options: { initial: 'writing', states: { writing: { - resultSchema: streamingOutputSchema, + schemas: { output: streamingOutputSchema }, invoke: async ({ context }, enq) => streamer.streamText(context.streamId, context.text, (delta) => { enq.emit({ type: 'textPart', delta }); }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { finalText: result.text }, + context: { finalText: output.text }, }), }, done: { diff --git a/examples/index.ts b/examples/index.ts index d0b02a4..c73aafb 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -64,6 +64,11 @@ export { createRewooExample } from './rewoo.js'; export { createReflectionExample } from './reflection.js'; export { createSelfEvaluationLoopFlowExample } from './self-evaluation-loop-flow.js'; export { createSpecAgentLoopExample } from './spec-agent-loop.js'; +export { + createGuardrailedBugfixWorkflowExample, + createGuardrailedIncidentResponseExample, + createUnguardedIncidentResponseExample, +} from './workflow-guardrails.js'; export { createSupervisorExample } from './supervisor.js'; export { createWriteABookFlowExample } from './write-a-book-flow.js'; export { createSqlAgentExample } from './sql-agent.js'; diff --git a/examples/joke.ts b/examples/joke.ts index fc4bdaf..012bcf1 100644 --- a/examples/joke.ts +++ b/examples/joke.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; -import { createAgentMachine } from '../src/index.js'; +import { createAgentMachine, type AgentAdapter } from '../src/index.js'; import { closePrompt, + createOpenAiGenerationAdapter, formatResult, - generateExampleObject, isMain, prompt, } from './_run.js'; @@ -18,38 +18,11 @@ const ratingSchema = z.object({ }); export function createJokeExample( - options: { - tellJoke?: (topic: string) => Promise>; - rateJoke?: ( - topic: string, - joke: string - ) => Promise>; - } = {} + adapter: AgentAdapter = createOpenAiGenerationAdapter() ) { - const tellJoke = - options.tellJoke ?? - ((topic: string) => - generateExampleObject({ - schema: jokeSchema, - system: 'You write short, clean jokes.', - prompt: `Write one short joke about ${topic}.`, - })); - const rateJoke = - options.rateJoke ?? - ((topic: string, joke: string) => - generateExampleObject({ - schema: ratingSchema, - system: 'You are a joke critic. Be fair and concise.', - prompt: [ - `Topic: ${topic}`, - `Joke: ${joke}`, - '', - 'Rate the joke from 1 to 10 and explain briefly.', - ].join('\n'), - })); - return createAgentMachine({ id: 'joke-example', + adapter, schemas: { input: z.object({ topic: z.string() }), output: z.object({ @@ -70,22 +43,30 @@ export function createJokeExample( initial: 'telling', states: { telling: { - resultSchema: jokeSchema, - invoke: async ({ context }) => tellJoke(context.topic), - onDone: ({ result }) => ({ + schemas: { output: jokeSchema }, + system: 'You write short, clean jokes.', + prompt: ({ context }) => `Write one short joke about ${context.topic}.`, + onDone: ({ output }) => ({ target: 'rating', - context: { joke: result.joke }, + context: { joke: output.joke }, }), }, rating: { - resultSchema: ratingSchema, - invoke: async ({ context }) => rateJoke(context.topic, context.joke ?? ''), - onDone: ({ result }) => ({ + schemas: { output: ratingSchema }, + system: 'You are a joke critic. Be fair and concise.', + prompt: ({ context }) => + [ + `Topic: ${context.topic}`, + `Joke: ${context.joke ?? ''}`, + '', + 'Rate the joke from 1 to 10 and explain briefly.', + ].join('\n'), + onDone: ({ output }) => ({ target: 'done', context: { - rating: result.rating, - explanation: result.explanation, - accepted: result.rating >= 7, + rating: output.rating, + explanation: output.explanation, + accepted: output.rating >= 7, }, }), }, diff --git a/examples/jugs.ts b/examples/jugs.ts index ae68c7e..dfce867 100644 --- a/examples/jugs.ts +++ b/examples/jugs.ts @@ -73,12 +73,12 @@ export function createJugsExample() { initial: 'choosing', states: { choosing: { - resultSchema: moveSchema, + schemas: { output: moveSchema }, invoke: async ({ context }) => chooseWaterJugMove(context.jug3, context.jug5), - onDone: ({ result, context }) => { - const nextReasoning = [...context.reasoning, result.reasoning]; + onDone: ({ output, context }) => { + const nextReasoning = [...context.reasoning, output.reasoning]; - if (result.move === 'done') { + if (output.move === 'done') { return { target: 'done' as const, context: { reasoning: nextReasoning }, @@ -87,28 +87,27 @@ export function createJugsExample() { return { target: 'applying' as const, - input: { move: result.move }, + input: { move: output.move }, context: { reasoning: nextReasoning }, }; }, }, applying: { - inputSchema: z.object({ + schemas: { input: z.object({ move: moveSchema.shape.move.exclude(['done']), - }), - resultSchema: applySchema, + }), output: applySchema }, invoke: async ({ context, input }) => applyWaterJugMove( context.jug3, context.jug5, input.move as 'fill5' | 'pour5to3' | 'empty3' ), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'choosing', context: { - jug3: result.jug3, - jug5: result.jug5, - steps: [...context.steps, result.step], + jug3: output.jug3, + jug5: output.jug5, + steps: [...context.steps, output.step], }, }), }, diff --git a/examples/lead-score-flow.ts b/examples/lead-score-flow.ts index 459f643..c0c6879 100644 --- a/examples/lead-score-flow.ts +++ b/examples/lead-score-flow.ts @@ -97,17 +97,17 @@ export function createLeadScoreFlowExample(options: { initial: 'scoring', states: { scoring: { - resultSchema: scoringSchema, + schemas: { output: scoringSchema }, invoke: async ({ context }) => scoreLeads({ leads: context.leads, reviewNote: context.reviewNote, }), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'reviewing', context: { - scoredLeads: result.scoredLeads, - topLeads: result.scoredLeads.slice(0, 3), + scoredLeads: output.scoredLeads, + topLeads: output.scoredLeads.slice(0, 3), reviewNote: null, reviewCount: context.reviewCount + 1, }, @@ -127,12 +127,12 @@ export function createLeadScoreFlowExample(options: { }, }, writing: { - resultSchema: emailBatchSchema, + schemas: { output: emailBatchSchema }, invoke: async ({ context }) => writeEmails(context.scoredLeads), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', context: { - emailDrafts: result.drafts, + emailDrafts: output.drafts, }, }), }, diff --git a/examples/map-reduce.ts b/examples/map-reduce.ts index 2f6c255..25abe38 100644 --- a/examples/map-reduce.ts +++ b/examples/map-reduce.ts @@ -47,7 +47,7 @@ export function createMapReduceExample( initial: 'planning', states: { planning: { - resultSchema: subjectsSchema, + schemas: { output: subjectsSchema }, invoke: async ({ context }) => (options.planSubjects ?? ((topic) => @@ -56,13 +56,13 @@ export function createMapReduceExample( system: 'You break a topic into a few concrete subtopics.', prompt: `List 2 to 4 specific subtopics worth covering for: ${topic}`, })))(context.topic), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'mapping', - context: { subjects: result.subjects }, + context: { subjects: output.subjects }, }), }, mapping: { - resultSchema: jokesSchema, + schemas: { output: jokesSchema }, invoke: async ({ context }) => { const jokes = await Promise.all( context.subjects.map((subject) => @@ -77,13 +77,13 @@ export function createMapReduceExample( return { jokes }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'reducing', - context: { jokes: result.jokes }, + context: { jokes: output.jokes }, }), }, reducing: { - resultSchema: bestJokeSchema, + schemas: { output: bestJokeSchema }, invoke: async ({ context }) => (options.chooseBest ?? ((jokes) => @@ -92,9 +92,9 @@ export function createMapReduceExample( system: 'You pick the strongest joke from a list.', prompt: ['Choose the best joke from this list:', ...jokes].join('\n'), })))(context.jokes), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { bestJoke: result.bestJoke }, + context: { bestJoke: output.bestJoke }, }), }, done: { diff --git a/examples/meeting-assistant-flow.ts b/examples/meeting-assistant-flow.ts index b55e26b..ae8171e 100644 --- a/examples/meeting-assistant-flow.ts +++ b/examples/meeting-assistant-flow.ts @@ -85,18 +85,18 @@ export function createMeetingAssistantFlowExample(options: { initial: 'extracting', states: { extracting: { - resultSchema: extractionSchema, + schemas: { output: extractionSchema }, invoke: async ({ context }) => extractTasks(context.notes), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'dispatching', context: { - summary: result.summary, - tasks: result.tasks, + summary: output.summary, + tasks: output.tasks, }, }), }, dispatching: { - resultSchema: fanOutSchema, + schemas: { output: fanOutSchema }, invoke: async ({ context }) => { const [trello, csv, slack] = await Promise.all([ addTasksToTrello(context.tasks), @@ -113,12 +113,12 @@ export function createMeetingAssistantFlowExample(options: { slackMessageId: slack.slackMessageId, }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', context: { - trelloCardIds: result.trelloCardIds, - csvPath: result.csvPath, - slackMessageId: result.slackMessageId, + trelloCardIds: output.trelloCardIds, + csvPath: output.csvPath, + slackMessageId: output.slackMessageId, }, }), }, diff --git a/examples/multi-agent-network.ts b/examples/multi-agent-network.ts index 65c0501..da5533d 100644 --- a/examples/multi-agent-network.ts +++ b/examples/multi-agent-network.ts @@ -3,7 +3,7 @@ import { createAgentMachine, decide, decideResultSchema, - type AgentAdapter, + type DecideAdapter, } from '../src/index.js'; import { closePrompt, @@ -42,7 +42,7 @@ const draftHandoffSchema = z.object({ export function createMultiAgentNetworkExample( options: { - adapter?: AgentAdapter; + adapter?: DecideAdapter; research?: (args: { topic: string; focus: string; @@ -120,15 +120,15 @@ export function createMultiAgentNetworkExample( initial: 'researching', states: { researching: { - resultSchema: researchNotesSchema, + schemas: { output: researchNotesSchema }, invoke: async ({ context }) => research({ topic: context.topic, focus: context.focus, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { notes: result.notes }, + context: { notes: output.notes }, }), }, done: { @@ -161,16 +161,16 @@ export function createMultiAgentNetworkExample( initial: 'writing', states: { writing: { - resultSchema: draftSchema, + schemas: { output: draftSchema }, invoke: async ({ context }) => write({ topic: context.topic, notes: context.notes, angle: context.angle, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { draft: result.draft }, + context: { draft: output.draft }, }), }, done: { @@ -202,7 +202,7 @@ export function createMultiAgentNetworkExample( initial: 'coordinating', states: { coordinating: { - resultSchema: decideResultSchema(coordinatorOptions), + schemas: { output: decideResultSchema(coordinatorOptions) }, invoke: async ({ context }) => decide({ adapter, @@ -224,21 +224,21 @@ export function createMultiAgentNetworkExample( ].join('\n'), options: coordinatorOptions, }), - onDone: ({ result }) => { - if (result.choice === 'research') { + onDone: ({ output }) => { + if (output.choice === 'research') { return { target: 'researching', input: { - focus: result.data.focus ?? 'gather the most useful supporting facts', + focus: output.data.focus ?? 'gather the most useful supporting facts', }, }; } - if (result.choice === 'write') { + if (output.choice === 'write') { return { target: 'writing', input: { - angle: result.data.angle ?? 'produce the clearest concise draft', + angle: output.data.angle ?? 'produce the clearest concise draft', }, }; } @@ -249,8 +249,7 @@ export function createMultiAgentNetworkExample( }, }, researching: { - inputSchema: researchParamsSchema, - resultSchema: researchHandoffSchema, + schemas: { input: researchParamsSchema, output: researchHandoffSchema }, invoke: async ({ context, input }) => { const result = await researchAgent.execute( researchAgent.getInitialState({ @@ -268,17 +267,16 @@ export function createMultiAgentNetworkExample( handoff: `researcher:${input.focus}`, }; }, - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'coordinating', context: { - notes: result.notes, - handoffs: [...context.handoffs, result.handoff], + notes: output.notes, + handoffs: [...context.handoffs, output.handoff], }, }), }, writing: { - inputSchema: writeParamsSchema, - resultSchema: draftHandoffSchema, + schemas: { input: writeParamsSchema, output: draftHandoffSchema }, invoke: async ({ context, input }) => { const result = await writerAgent.execute( writerAgent.getInitialState({ @@ -297,11 +295,11 @@ export function createMultiAgentNetworkExample( handoff: `writer:${input.angle}`, }; }, - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'coordinating', context: { - draft: result.draft, - handoffs: [...context.handoffs, result.handoff], + draft: output.draft, + handoffs: [...context.handoffs, output.handoff], }, }), }, diff --git a/examples/newspaper.ts b/examples/newspaper.ts index ec74eb3..7734598 100644 --- a/examples/newspaper.ts +++ b/examples/newspaper.ts @@ -111,49 +111,49 @@ export function createNewspaperExample( initial: 'searching', states: { searching: { - resultSchema: searchSchema, + schemas: { output: searchSchema }, invoke: async ({ context }) => search(context.topic), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'curating', - context: { searchResults: result.searchResults }, + context: { searchResults: output.searchResults }, }), }, curating: { - resultSchema: searchSchema, + schemas: { output: searchSchema }, invoke: async ({ context }) => curate(context.topic, context.searchResults), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'writing', - context: { searchResults: result.searchResults }, + context: { searchResults: output.searchResults }, }), }, writing: { - resultSchema: articleSchema, + schemas: { output: articleSchema }, invoke: async ({ context }) => write(context.topic, context.searchResults), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'critiquing', - context: { article: result.article }, + context: { article: output.article }, }), }, critiquing: { - resultSchema: critiqueSchema, + schemas: { output: critiqueSchema }, invoke: async ({ context }) => critique(context.article ?? '', context.revisionCount), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: - !result.critique || context.revisionCount >= context.maxRevisions + !output.critique || context.revisionCount >= context.maxRevisions ? 'done' : 'revising', - context: { critique: result.critique }, + context: { critique: output.critique }, }), }, revising: { - resultSchema: articleSchema, + schemas: { output: articleSchema }, invoke: async ({ context }) => revise(context.article ?? '', context.critique ?? ''), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'critiquing', context: { - article: result.article, + article: output.article, revisionCount: context.revisionCount + 1, }, }), diff --git a/examples/next-ai-sdk-ui.ts b/examples/next-ai-sdk-ui.ts index 8fab1d7..ccc2a31 100644 --- a/examples/next-ai-sdk-ui.ts +++ b/examples/next-ai-sdk-ui.ts @@ -98,7 +98,7 @@ export function createNextAiSdkUiRoute(options: { }, }, drafting: { - resultSchema: streamedTextSchema, + schemas: { output: streamedTextSchema }, invoke: async ({ context }, enq) => { enq.emit({ type: 'notification', @@ -122,10 +122,10 @@ export function createNextAiSdkUiRoute(options: { }, }); }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', context: { - finalText: result.text, + finalText: output.text, }, }), }, diff --git a/examples/persistence.ts b/examples/persistence.ts index b74ac48..f43c0db 100644 --- a/examples/persistence.ts +++ b/examples/persistence.ts @@ -63,15 +63,15 @@ export function createPersistenceExample( }, }, summarizing: { - resultSchema: summarySchema, + schemas: { output: summarySchema }, invoke: async ({ context }) => summarize({ request: context.request, approved: context.approved, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { summary: result.summary }, + context: { summary: output.summary }, }), }, done: { diff --git a/examples/persistent-streaming.ts b/examples/persistent-streaming.ts index 41545d6..f5f158f 100644 --- a/examples/persistent-streaming.ts +++ b/examples/persistent-streaming.ts @@ -50,14 +50,14 @@ export function createPersistentStreamingExample( initial: 'writing', states: { writing: { - resultSchema: textSchema, + schemas: { output: textSchema }, invoke: async (_args, enq) => writeText((delta) => { enq.emit({ type: 'textPart', delta }); }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { finalText: result.text }, + context: { finalText: output.text }, }), }, done: { diff --git a/examples/plan-and-execute.ts b/examples/plan-and-execute.ts index 86f7d08..49e97bc 100644 --- a/examples/plan-and-execute.ts +++ b/examples/plan-and-execute.ts @@ -98,27 +98,26 @@ export function createPlanAndExecuteExample( initial: 'planning', states: { planning: { - resultSchema: planSchema, + schemas: { output: planSchema }, invoke: async ({ context }) => planner(context.goal), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'executing', - context: { plan: result.plan }, + context: { plan: output.plan }, input: { index: 0 } }), }, executing: { - inputSchema: z.object({ + schemas: { input: z.object({ index: z.number().int().min(0), - }), - resultSchema: stepResultSchema, + }), output: stepResultSchema }, invoke: async ({ context, input }) => executeStep({ goal: context.goal, step: context.plan[input.index] ?? '', priorResults: context.stepResults, }), - onDone: ({ result, context }) => { - const nextStepResults = [...context.stepResults, result.result]; + onDone: ({ output, context }) => { + const nextStepResults = [...context.stepResults, output.result]; const nextIndex = nextStepResults.length; if (nextIndex < context.plan.length) { @@ -136,16 +135,16 @@ export function createPlanAndExecuteExample( }, }, synthesizing: { - resultSchema: finalAnswerSchema, + schemas: { output: finalAnswerSchema }, invoke: async ({ context }) => synthesize({ goal: context.goal, plan: context.plan, stepResults: context.stepResults, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { answer: result.answer }, + context: { answer: output.answer }, }), }, done: { diff --git a/examples/raffle.ts b/examples/raffle.ts index f02b3a8..b052784 100644 --- a/examples/raffle.ts +++ b/examples/raffle.ts @@ -68,15 +68,15 @@ export function createRaffleExample( }, }, drawing: { - resultSchema: winnerSchema, + schemas: { output: winnerSchema }, invoke: async ({ context }) => pickWinner(context.entries), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', context: { - winner: result.winningEntry, - firstRunnerUp: result.firstRunnerUp, - secondRunnerUp: result.secondRunnerUp, - explanation: result.explanation, + winner: output.winningEntry, + firstRunnerUp: output.firstRunnerUp, + secondRunnerUp: output.secondRunnerUp, + explanation: output.explanation, }, }), }, diff --git a/examples/rag.ts b/examples/rag.ts index abd9772..08e4376 100644 --- a/examples/rag.ts +++ b/examples/rag.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; -import { createAgentMachine } from '../src/index.js'; +import { createAgentMachine, type AgentAdapter } from '../src/index.js'; import { closePrompt, - generateExampleObject, + createOpenAiGenerationAdapter, isMain, prompt, } from './_run.js'; @@ -22,11 +22,8 @@ const answerSchema = z.object({ export function createRagExample( options: { + adapter?: AgentAdapter; retrieve?: (question: string) => Promise>; - answer?: (args: { - question: string; - documents: Array>; - }) => Promise>; } = {} ) { const retrieve = @@ -45,25 +42,9 @@ export function createRagExample( ], })); - const answer = - options.answer ?? - ((args: { - question: string; - documents: Array>; - }) => - generateExampleObject({ - schema: answerSchema, - system: 'Answer the question using only the retrieved documents.', - prompt: [ - `Question: ${args.question}`, - '', - 'Documents:', - ...args.documents.map((document) => `- [${document.id}] ${document.content}`), - ].join('\n'), - })); - return createAgentMachine({ id: 'rag-example', + adapter: options.adapter ?? createOpenAiGenerationAdapter(), schemas: { input: z.object({ question: z.string(), @@ -82,23 +63,26 @@ export function createRagExample( initial: 'retrieving', states: { retrieving: { - resultSchema: retrievedDocumentsSchema, + schemas: { output: retrievedDocumentsSchema }, invoke: async ({ context }) => retrieve(context.question), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'answering', - context: { documents: result.documents }, + context: { documents: output.documents }, }), }, answering: { - resultSchema: answerSchema, - invoke: async ({ context }) => - answer({ - question: context.question, - documents: context.documents, - }), - onDone: ({ result }) => ({ + schemas: { output: answerSchema }, + system: 'Answer the question using only the retrieved documents.', + prompt: ({ context }) => + [ + `Question: ${context.question}`, + '', + 'Documents:', + ...context.documents.map((document) => `- [${document.id}] ${document.content}`), + ].join('\n'), + onDone: ({ output }) => ({ target: 'done', - context: { answer: result.answer }, + context: { answer: output.answer }, }), }, done: { diff --git a/examples/react-agent-from-scratch.ts b/examples/react-agent-from-scratch.ts index bd82736..c0b22c0 100644 --- a/examples/react-agent-from-scratch.ts +++ b/examples/react-agent-from-scratch.ts @@ -100,7 +100,7 @@ export function createReactAgentFromScratch(options: { initial: 'agent', states: { agent: { - resultSchema: modelResultSchema, + schemas: { output: modelResultSchema }, invoke: async ({ context }, enq) => { if (context.stepCount >= maxSteps) { return { @@ -120,8 +120,8 @@ export function createReactAgentFromScratch(options: { return result; }, - onDone: ({ result, context }) => { - if (result.kind === 'final') { + onDone: ({ output, context }) => { + if (output.kind === 'final') { return { target: 'done' as const, context: { @@ -130,7 +130,7 @@ export function createReactAgentFromScratch(options: { ...context.messages, { role: 'assistant', - content: result.message, + content: output.message, } satisfies ReactAgentMessage, ], }, @@ -142,35 +142,34 @@ export function createReactAgentFromScratch(options: { context: { stepCount: context.stepCount + 1, pendingToolCall: { - toolName: result.toolName, - input: result.input, + toolName: output.toolName, + input: output.input, }, messages: [ ...context.messages, { role: 'assistant', content: - result.message - ?? `Calling tool ${result.toolName} with ${JSON.stringify(result.input)}`, + output.message + ?? `Calling tool ${output.toolName} with ${JSON.stringify(output.input)}`, } satisfies ReactAgentMessage, ], }, input: { - toolName: result.toolName, - input: result.input, + toolName: output.toolName, + input: output.input, }, }; }, }, tool: { - inputSchema: z.object({ + schemas: { input: z.object({ toolName: z.string(), input: z.record(z.string(), z.unknown()), - }), - resultSchema: z.object({ + }), output: z.object({ toolName: z.string(), output: z.unknown(), - }), + }) }, invoke: async ({ input }, enq) => { const tool = toolsByName.get(input.toolName); @@ -197,7 +196,7 @@ export function createReactAgentFromScratch(options: { output, }; }, - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'agent' as const, context: { pendingToolCall: null, @@ -205,8 +204,8 @@ export function createReactAgentFromScratch(options: { ...context.messages, { role: 'tool', - name: result.toolName, - content: serializeToolOutput(result.output), + name: output.toolName, + content: serializeToolOutput(output.output), } satisfies ReactAgentMessage, ], }, diff --git a/examples/reflection.ts b/examples/reflection.ts index 529faa9..f7f5ee7 100644 --- a/examples/reflection.ts +++ b/examples/reflection.ts @@ -94,41 +94,41 @@ export function createReflectionExample( initial: 'drafting', states: { drafting: { - resultSchema: draftSchema, + schemas: { output: draftSchema }, invoke: async ({ context }) => draft(context.task), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'reflecting', - context: { draft: result.draft }, + context: { draft: output.draft }, }), }, reflecting: { - resultSchema: feedbackSchema, + schemas: { output: feedbackSchema }, invoke: async ({ context }) => reflect({ task: context.task, draft: context.draft ?? '', revisionCount: context.revisionCount, }), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: - !result.feedback || context.revisionCount >= context.maxRevisions + !output.feedback || context.revisionCount >= context.maxRevisions ? 'done' : 'revising', - context: { feedback: result.feedback }, + context: { feedback: output.feedback }, }), }, revising: { - resultSchema: draftSchema, + schemas: { output: draftSchema }, invoke: async ({ context }) => revise({ task: context.task, draft: context.draft ?? '', feedback: context.feedback ?? '', }), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'reflecting', context: { - draft: result.draft, + draft: output.draft, revisionCount: context.revisionCount + 1, }, }), diff --git a/examples/rewoo.ts b/examples/rewoo.ts index fba6b82..45f8547 100644 --- a/examples/rewoo.ts +++ b/examples/rewoo.ts @@ -136,22 +136,21 @@ export function createRewooExample( initial: 'planning', states: { planning: { - resultSchema: rewooPlanSchema, + schemas: { output: rewooPlanSchema }, invoke: async ({ context }) => plan(context.objective), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'executing', - context: { steps: result.steps }, + context: { steps: output.steps }, input: { index: 0 }, }), }, executing: { - inputSchema: z.object({ + schemas: { input: z.object({ index: z.number().int().min(0), - }), - resultSchema: z.object({ + }), output: z.object({ stepId: z.string(), result: z.string(), - }), + }) }, invoke: async ({ context, input }) => { const step = context.steps[input.index]; @@ -172,10 +171,10 @@ export function createRewooExample( result: outcome.result, }; }, - onDone: ({ result, context }) => { + onDone: ({ output, context }) => { const nextResultsById = { ...context.resultsById, - [result.stepId]: result.result, + [output.stepId]: output.result, }; const nextIndex = Object.keys(nextResultsById).length; @@ -194,16 +193,16 @@ export function createRewooExample( }, }, solving: { - resultSchema: rewooAnswerSchema, + schemas: { output: rewooAnswerSchema }, invoke: async ({ context }) => solve({ objective: context.objective, steps: context.steps, resultsById: context.resultsById, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { answer: result.answer }, + context: { answer: output.answer }, }), }, done: { diff --git a/examples/river-crossing.ts b/examples/river-crossing.ts index c628267..497c2e5 100644 --- a/examples/river-crossing.ts +++ b/examples/river-crossing.ts @@ -111,17 +111,17 @@ export function createRiverCrossingExample() { initial: 'choosing', states: { choosing: { - resultSchema: crossingMoveSchema, + schemas: { output: crossingMoveSchema }, invoke: async ({ context }) => chooseCrossingMove( [...context.leftBank], [...context.rightBank], context.farmerPosition ), - onDone: ({ result, context }) => { - const nextReasoning = [...context.reasoning, result.reasoning]; + onDone: ({ output, context }) => { + const nextReasoning = [...context.reasoning, output.reasoning]; - if (result.move === 'done') { + if (output.move === 'done') { return { target: 'done' as const, context: { reasoning: nextReasoning }, @@ -130,16 +130,15 @@ export function createRiverCrossingExample() { return { target: 'moving' as const, - input: { move: result.move }, + input: { move: output.move }, context: { reasoning: nextReasoning }, }; }, }, moving: { - inputSchema: z.object({ + schemas: { input: z.object({ move: crossingMoveSchema.shape.move.exclude(['done']), - }), - resultSchema: crossingStateSchema, + }), output: crossingStateSchema }, invoke: async ({ context, input }) => moveItem( [...context.leftBank], @@ -147,13 +146,13 @@ export function createRiverCrossingExample() { context.farmerPosition, input.move as 'takeGoat' | 'takeWolf' | 'takeCabbage' | 'returnEmpty' ), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'choosing', context: { - leftBank: result.leftBank, - rightBank: result.rightBank, - farmerPosition: result.farmerPosition, - steps: [...context.steps, result.step], + leftBank: output.leftBank, + rightBank: output.rightBank, + farmerPosition: output.farmerPosition, + steps: [...context.steps, output.step], }, }), }, diff --git a/examples/self-evaluation-loop-flow.ts b/examples/self-evaluation-loop-flow.ts index 1827ddb..130e6eb 100644 --- a/examples/self-evaluation-loop-flow.ts +++ b/examples/self-evaluation-loop-flow.ts @@ -73,32 +73,32 @@ export function createSelfEvaluationLoopFlowExample(options: { initial: 'generating', states: { generating: { - resultSchema: postSchema, + schemas: { output: postSchema }, invoke: async ({ context }) => generatePost({ topic: context.topic, feedback: context.feedback, attempt: context.attempt, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'evaluating', context: { - post: result.post, + post: output.post, }, }), }, evaluating: { - resultSchema: evaluationSchema, + schemas: { output: evaluationSchema }, invoke: async ({ context }) => evaluatePost(context.post ?? ''), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: - result.valid || context.attempt >= context.maxAttempts + output.valid || context.attempt >= context.maxAttempts ? 'done' : 'generating', context: { - valid: result.valid, - feedback: result.feedback, - attempt: result.valid + valid: output.valid, + feedback: output.feedback, + attempt: output.valid ? context.attempt : context.attempt + 1, }, diff --git a/examples/simple.ts b/examples/simple.ts index 812fb22..ef4e86d 100644 --- a/examples/simple.ts +++ b/examples/simple.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; -import { createAgentMachine } from '../src/index.js'; +import { createAgentMachine, type AgentAdapter } from '../src/index.js'; import { closePrompt, + createOpenAiGenerationAdapter, formatResult, - generateExampleObject, isMain, prompt, } from './_run.js'; @@ -13,17 +13,11 @@ const summarySchema = z.object({ }); export function createSimpleExample( - summarize: (text: string) => Promise> = async ( - text - ) => { - return generateExampleObject({ - schema: summarySchema, - prompt: `Summarize this text in one sentence:\n\n${text}`, - }); - } + adapter: AgentAdapter = createOpenAiGenerationAdapter() ) { return createAgentMachine({ id: 'simple-example', + adapter, schemas: { input: z.object({ text: z.string() }), output: z.object({ summary: z.string().nullable() }), @@ -35,11 +29,12 @@ export function createSimpleExample( initial: 'summarizing', states: { summarizing: { - resultSchema: summarySchema, - invoke: async ({ context }) => summarize(context.text), - onDone: ({ result }) => ({ + schemas: { output: summarySchema }, + prompt: ({ context }) => + `Summarize this text in one sentence:\n\n${context.text}`, + onDone: ({ output }) => ({ target: 'done', - context: { summary: result.summary }, + context: { summary: output.summary }, }), }, done: { diff --git a/examples/spec-agent-loop.ts b/examples/spec-agent-loop.ts index 8d86de9..2968cb4 100644 --- a/examples/spec-agent-loop.ts +++ b/examples/spec-agent-loop.ts @@ -98,7 +98,7 @@ export function createSpecAgentLoopExample( initial: 'generating', states: { generating: { - resultSchema: generationSchema, + schemas: { output: generationSchema }, invoke: async ({ context, messages }) => parseTaggedResponse( await generate({ @@ -106,22 +106,22 @@ export function createSpecAgentLoopExample( messages, }) ), - onDone: ({ result, messages }) => ({ - target: result.specYaml ? 'validating' : 'repairing', + onDone: ({ output, messages }) => ({ + target: output.specYaml ? 'validating' : 'repairing', context: { - specYaml: result.specYaml, - questions: result.questions, - status: result.status, + specYaml: output.specYaml, + questions: output.questions, + status: output.status, }, - messages: appendMessages(messages, assistantMessage(result.rawText)), + messages: appendMessages(messages, assistantMessage(output.rawText)), }), }, validating: { - resultSchema: validationSchema, + schemas: { output: validationSchema }, invoke: async ({ context }) => validate(context.specYaml), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'routing', - context: { validation: result }, + context: { validation: output }, }), }, routing: { diff --git a/examples/sql-agent.ts b/examples/sql-agent.ts index d48b0c6..e5f35f3 100644 --- a/examples/sql-agent.ts +++ b/examples/sql-agent.ts @@ -5,7 +5,7 @@ import { decide, decideResultSchema, startSession, - type AgentAdapter, + type DecideAdapter, } from '../src/index.js'; import { closePrompt, @@ -49,7 +49,7 @@ const queryExecutionSchema = z.discriminatedUnion('status', [ export function createSqlAgentExample( options: { - adapter?: AgentAdapter; + adapter?: DecideAdapter; executeQuery?: (args: { question: string; schema: string; @@ -135,7 +135,7 @@ export function createSqlAgentExample( initial: 'planning', states: { planning: { - resultSchema: decideResultSchema(planningOptions), + schemas: { output: decideResultSchema(planningOptions) }, invoke: async ({ context }) => decide({ adapter, @@ -159,12 +159,12 @@ export function createSqlAgentExample( ].join('\n'), options: planningOptions, }), - onDone: ({ result }) => { - if (result.choice === 'query') { + onDone: ({ output }) => { + if (output.choice === 'query') { return { target: 'querying', input: { - query: result.data.query, + query: output.data.query, }, }; } @@ -172,16 +172,15 @@ export function createSqlAgentExample( return { target: 'done', context: { - answer: result.data.answer, + answer: output.data.answer, }, }; }, }, querying: { - inputSchema: z.object({ + schemas: { input: z.object({ query: z.string(), - }), - resultSchema: queryExecutionSchema, + }), output: queryExecutionSchema }, invoke: async ({ context, input }, enq) => { enq.emit({ type: 'toolCall', @@ -217,15 +216,15 @@ export function createSqlAgentExample( return resolvedOutput; }, - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'planning', context: { queryHistory: [ ...context.queryHistory, - result.query, + output.query, ], - latestRows: result.status === 'success' ? result.rows : null, - latestError: result.status === 'error' ? result.error : null, + latestRows: output.status === 'success' ? output.rows : null, + latestError: output.status === 'error' ? output.error : null, }, }), }, diff --git a/examples/subflow.ts b/examples/subflow.ts index 5946a3a..cb6639c 100644 --- a/examples/subflow.ts +++ b/examples/subflow.ts @@ -38,7 +38,7 @@ export function createSubflowExample( initial: 'researching', states: { researching: { - resultSchema: researchSchema, + schemas: { output: researchSchema }, invoke: async ({ context }) => (options.research ?? ((topic) => @@ -47,9 +47,9 @@ export function createSubflowExample( system: 'You research a topic and return concise bullet points.', prompt: `Return 2 to 4 concise research bullets about ${topic}.`, })))(context.topic), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { bullets: result.bullets }, + context: { bullets: output.bullets }, }), }, done: { @@ -76,7 +76,7 @@ export function createSubflowExample( initial: 'researching', states: { researching: { - resultSchema: researchSchema, + schemas: { output: researchSchema }, invoke: async ({ context }) => { const result = await childMachine.execute( childMachine.getInitialState({ topic: context.topic }) @@ -90,13 +90,13 @@ export function createSubflowExample( bullets: result.output.bullets, }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'writing', - context: { bullets: result.bullets }, + context: { bullets: output.bullets }, }), }, writing: { - resultSchema: draftSchema, + schemas: { output: draftSchema }, invoke: async ({ context }) => (options.write ?? (({ topic, bullets }) => @@ -112,9 +112,9 @@ export function createSubflowExample( topic: context.topic, bullets: context.bullets, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { draft: result.draft }, + context: { draft: output.draft }, }), }, done: { diff --git a/examples/supervisor.ts b/examples/supervisor.ts index cdbf1e2..d54887b 100644 --- a/examples/supervisor.ts +++ b/examples/supervisor.ts @@ -3,7 +3,7 @@ import { createAgentMachine, decide, decideResultSchema, - type AgentAdapter, + type DecideAdapter, } from '../src/index.js'; import { closePrompt, @@ -47,7 +47,7 @@ const supervisorOptions = { export function createSupervisorExample( options: { - adapter?: AgentAdapter; + adapter?: DecideAdapter; handle?: (args: { request: string; attempt: number; @@ -120,8 +120,7 @@ export function createSupervisorExample( }), states: { handling: { - inputSchema: handlingParamsSchema, - resultSchema: workerResultSchema, + schemas: { input: handlingParamsSchema, output: workerResultSchema }, invoke: async ({ context, input }) => handle({ request: context.request, @@ -129,18 +128,18 @@ export function createSupervisorExample( instruction: input.instruction ?? null, priorIssues: context.priorIssues, }), - onDone: ({ result, context, }) => { + onDone: ({ output, context, }) => { const nextAttemptCount = context.attemptCount + 1; - if (result.status === 'resolved') { + if (output.status === 'resolved') { return { target: 'done', context: { attemptCount: nextAttemptCount, - resolution: result.response, + resolution: output.response, history: [ ...context.history, - `worker:${nextAttemptCount}:resolved:${result.response}`, + `worker:${nextAttemptCount}:resolved:${output.response}`, ], }, }; @@ -150,18 +149,18 @@ export function createSupervisorExample( target: 'supervising', context: { attemptCount: nextAttemptCount, - latestIssue: result.issue, - priorIssues: [...context.priorIssues, result.issue], + latestIssue: output.issue, + priorIssues: [...context.priorIssues, output.issue], history: [ ...context.history, - `worker:${nextAttemptCount}:blocked:${result.issue}`, + `worker:${nextAttemptCount}:blocked:${output.issue}`, ], }, }; }, }, supervising: { - resultSchema: decideResultSchema(supervisorOptions), + schemas: { output: decideResultSchema(supervisorOptions) }, invoke: async ({ context }) => decide({ adapter, @@ -183,10 +182,10 @@ export function createSupervisorExample( ].join('\n'), options: supervisorOptions, }), - onDone: ({ result, context }) => { - if (result.choice === 'retry') { + onDone: ({ output, context }) => { + if (output.choice === 'retry') { const instruction = - result.data.instruction + output.data.instruction ?? 'Retry once with a more concrete plan and any available context.'; return { @@ -205,7 +204,7 @@ export function createSupervisorExample( } const reason = - result.data.reason + output.data.reason ?? `Escalated after ${context.attemptCount} unsuccessful attempts.`; return { diff --git a/examples/tool-calling.ts b/examples/tool-calling.ts index 36e2d12..2753b17 100644 --- a/examples/tool-calling.ts +++ b/examples/tool-calling.ts @@ -72,7 +72,7 @@ export function createToolCallingExample( initial: 'checkingWeather', states: { checkingWeather: { - resultSchema: forecastSchema, + schemas: { output: forecastSchema }, invoke: async ({ context }, enq) => { enq.emit({ type: 'toolCall', @@ -95,9 +95,9 @@ export function createToolCallingExample( return output; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { forecast: result.forecast }, + context: { forecast: output.forecast }, }), }, done: { diff --git a/examples/tutor.ts b/examples/tutor.ts index 16193e3..9ef2009 100644 --- a/examples/tutor.ts +++ b/examples/tutor.ts @@ -57,23 +57,23 @@ export function createTutorExample( initial: 'teaching', states: { teaching: { - resultSchema: feedbackSchema, + schemas: { output: feedbackSchema }, invoke: async ({ context }) => teach(context.conversation.at(-1)?.replace(/^User:\s*/, '') ?? ''), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'responding', - context: { feedback: result.instruction }, + context: { feedback: output.instruction }, }), }, responding: { - resultSchema: responseSchema, + schemas: { output: responseSchema }, invoke: async ({ context }) => respond(context.conversation.at(-1)?.replace(/^User:\s*/, '') ?? ''), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'done', context: { - response: result.response, - conversation: [...context.conversation, `Tutor: ${result.response}`], + response: output.response, + conversation: [...context.conversation, `Tutor: ${output.response}`], }, }), }, diff --git a/examples/workflow-guardrails.ts b/examples/workflow-guardrails.ts new file mode 100644 index 0000000..f67ada1 --- /dev/null +++ b/examples/workflow-guardrails.ts @@ -0,0 +1,363 @@ +import { z } from 'zod'; +import { createAgentMachine, type AgentAdapter } from '../src/index.js'; +import { closePrompt, formatResult, isMain, prompt } from './_run.js'; + +type WorkflowTool = (input?: Record) => Promise; + +const taskInputSchema = z.object({ task: z.string().optional() }).optional(); + +const planSchema = z.object({ plan: z.string() }); +const implementationSchema = z.object({ summary: z.string() }); +const testSchema = z.object({ + passed: z.boolean(), + output: z.string().optional(), +}); +const diagnosisSchema = z.object({ diagnosis: z.string() }); +const rootCauseSchema = z.object({ rootCause: z.string() }); +const proposalSchema = z.object({ proposal: z.string() }); +const fixSchema = z.object({ applied: z.boolean(), summary: z.string() }); +const verificationSchema = z.object({ + verified: z.boolean(), + summary: z.string(), +}); + +const readOnlyCodingTools = { + Read: async () => undefined, + Grep: async () => undefined, + Glob: async () => undefined, + LS: async () => undefined, + Bash: async () => undefined, +} satisfies Record; + +const editingCodingTools = { + ...readOnlyCodingTools, + Edit: async () => undefined, + Write: async () => undefined, +} satisfies Record; + +const incidentReadTools = { + Read: async () => undefined, + Bash: async () => undefined, + Grep: async () => undefined, +} satisfies Record; + +const incidentWriteTools = { + Read: async () => undefined, + Bash: async () => undefined, +} satisfies Record; + +const allIncidentTools = { + list_services: async () => undefined, + get_service: async () => undefined, + list_volumes: async () => undefined, + get_volume: async () => undefined, + get_logs: async () => undefined, + get_env: async () => undefined, + update_env: async () => undefined, + restart_service: async () => undefined, + delete_volume: async () => undefined, + delete_service: async () => undefined, + test_connection: async () => undefined, +} satisfies Record; + +function createSequenceAdapter(results: unknown[]): AgentAdapter { + let index = 0; + + return { + async generateText() { + const result = results[index] ?? results.at(-1); + index += 1; + return result; + }, + }; +} + +export function createGuardrailedBugfixWorkflowExample(options: { + adapter?: AgentAdapter; +} = {}) { + return createAgentMachine({ + id: 'guardrailed-bugfix-workflow', + schemas: { input: taskInputSchema }, + adapter: + options.adapter + ?? createSequenceAdapter([ + { plan: 'Read the failing test, inspect the implementation, then make the smallest fix.' }, + { summary: 'Applied a targeted code change.' }, + { passed: true, output: 'All tests passed.' }, + ]), + context: (input) => ({ + task: input?.task ?? 'Fix the failing tests.', + plan: null as string | null, + changeSummary: null as string | null, + testOutput: null as string | null, + }), + messages: (input) => [ + { role: 'user', content: input?.task ?? 'Fix the failing tests.' }, + ], + initial: 'planning', + states: { + planning: { + schemas: { output: planSchema }, + prompt: + 'Read relevant files and produce a brief fix plan. Do not edit anything yet.', + tools: readOnlyCodingTools, + onDone: ({ output }) => ({ + target: 'implementing', + context: { plan: output.plan }, + }), + }, + implementing: { + schemas: { output: implementationSchema }, + prompt: ({ snapshot }) => + [ + 'Implement the fix. Make targeted, minimal edits.', + `Current state: ${snapshot.value}`, + `Plan: ${snapshot.context.plan ?? 'none'}`, + ].join('\n'), + tools: editingCodingTools, + onDone: ({ output }) => ({ + target: 'testing', + context: { changeSummary: output.summary }, + }), + }, + testing: { + schemas: { output: testSchema }, + prompt: ({ snapshot }) => + [ + 'Run the tests to verify the fix.', + `Current state: ${snapshot.value}`, + `Change summary: ${snapshot.context.changeSummary ?? 'none'}`, + ].join('\n'), + tools: { + Read: readOnlyCodingTools.Read, + Bash: readOnlyCodingTools.Bash, + }, + onDone: ({ output }) => + output.passed + ? { + target: 'completed', + context: { testOutput: output.output ?? null }, + } + : { + target: 'implementing', + context: { testOutput: output.output ?? null }, + }, + }, + completed: { + type: 'final', + output: ({ context }) => ({ + plan: context.plan, + changeSummary: context.changeSummary, + testOutput: context.testOutput, + }), + }, + }, + }); +} + +export function createGuardrailedIncidentResponseExample(options: { + adapter?: AgentAdapter; +} = {}) { + return createAgentMachine({ + id: 'guardrailed-incident-response', + schemas: { + input: taskInputSchema, + events: { + APPROVED: z.object({ type: z.literal('APPROVED') }), + REJECTED: z.object({ type: z.literal('REJECTED') }), + }, + }, + adapter: + options.adapter + ?? createSequenceAdapter([ + { diagnosis: 'The web service cannot connect to its database.' }, + { rootCause: 'The staging database credential is stale.' }, + { proposal: 'Update the staging DB password and restart the web service.' }, + { applied: true, summary: 'Updated the staging DB password and restarted the service.' }, + { verified: true, summary: 'Connection test passed and service is healthy.' }, + ]), + context: (input) => ({ + task: + input?.task + ?? 'The staging environment is down. Diagnose and repair without destructive actions.', + diagnosis: null as string | null, + rootCause: null as string | null, + proposal: null as string | null, + fixSummary: null as string | null, + verification: null as string | null, + }), + messages: (input) => [ + { + role: 'user', + content: + input?.task + ?? 'The staging environment is down. Diagnose and repair without destructive actions.', + }, + ], + initial: 'diagnosing', + states: { + diagnosing: { + schemas: { output: diagnosisSchema }, + prompt: + 'Check service status and logs. Identify the likely failure. Do not modify anything.', + tools: { + ...incidentReadTools, + list_services: allIncidentTools.list_services, + get_service: allIncidentTools.get_service, + get_logs: allIncidentTools.get_logs, + get_volume: allIncidentTools.get_volume, + list_volumes: allIncidentTools.list_volumes, + }, + onDone: ({ output }) => ({ + target: 'investigating', + context: { diagnosis: output.diagnosis }, + }), + }, + investigating: { + schemas: { output: rootCauseSchema }, + prompt: + 'Investigate the root cause. Check environment variables, test connections, and read logs. Still read-only.', + tools: { + ...incidentReadTools, + get_env: allIncidentTools.get_env, + test_connection: allIncidentTools.test_connection, + get_logs: allIncidentTools.get_logs, + }, + onDone: ({ output }) => ({ + target: 'proposing', + context: { rootCause: output.rootCause }, + }), + }, + proposing: { + schemas: { output: proposalSchema }, + prompt: + 'Propose the fix. Describe exactly what should change and why. Do not execute the fix yet.', + tools: { Read: incidentReadTools.Read }, + onDone: ({ output }) => ({ + target: 'awaitingApproval', + context: { proposal: output.proposal }, + }), + }, + awaitingApproval: { + prompt: ({ snapshot }) => + [ + `Await approval while in ${snapshot.value}.`, + snapshot.context.proposal ?? '', + ].join('\n'), + tools: { Read: incidentReadTools.Read }, + on: { + APPROVED: { target: 'executingFix' }, + REJECTED: { target: 'proposing' }, + }, + }, + executingFix: { + schemas: { output: fixSchema }, + prompt: + 'Execute the approved fix. API actions allowed: update_env, restart_service. Do not delete volumes or services.', + tools: { + ...incidentWriteTools, + update_env: allIncidentTools.update_env, + restart_service: allIncidentTools.restart_service, + }, + onDone: ({ output }) => ({ + target: 'verifying', + context: { fixSummary: output.summary }, + }), + }, + verifying: { + schemas: { output: verificationSchema }, + prompt: + 'Verify the fix. Test the connection, check service status, and review logs.', + tools: { + ...incidentWriteTools, + test_connection: allIncidentTools.test_connection, + get_service: allIncidentTools.get_service, + get_logs: allIncidentTools.get_logs, + }, + onDone: ({ output }) => + output.verified + ? { + target: 'completed', + context: { verification: output.summary }, + } + : { + target: 'proposing', + context: { verification: output.summary }, + }, + }, + completed: { + type: 'final', + output: ({ context }) => ({ + diagnosis: context.diagnosis, + rootCause: context.rootCause, + proposal: context.proposal, + fixSummary: context.fixSummary, + verification: context.verification, + }), + }, + }, + }); +} + +export function createUnguardedIncidentResponseExample(options: { + adapter?: AgentAdapter; +} = {}) { + return createAgentMachine({ + id: 'unguarded-incident-response', + schemas: { input: taskInputSchema }, + adapter: + options.adapter + ?? createSequenceAdapter([ + { applied: true, summary: 'Used whatever API actions were available to repair the service.' }, + ]), + context: (input) => ({ + task: + input?.task + ?? 'The staging environment is down. Fix it with all API actions available.', + fixSummary: null as string | null, + }), + messages: (input) => [ + { + role: 'user', + content: + input?.task + ?? 'The staging environment is down. Fix it with all API actions available.', + }, + ], + initial: 'working', + states: { + working: { + schemas: { output: fixSchema }, + prompt: 'Fix the staging environment issue. All tools and API actions are available.', + tools: { + ...incidentReadTools, + ...allIncidentTools, + }, + onDone: ({ output }) => ({ + target: 'completed', + context: { fixSummary: output.summary }, + }), + }, + completed: { + type: 'final', + output: ({ context }) => ({ fixSummary: context.fixSummary }), + }, + }, + }); +} + +async function main() { + try { + const task = await prompt('Task'); + const machine = createGuardrailedBugfixWorkflowExample(); + const result = await machine.execute(machine.getInitialState({ task })); + + console.log(formatResult(result)); + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} diff --git a/examples/write-a-book-flow.ts b/examples/write-a-book-flow.ts index d22742d..f171020 100644 --- a/examples/write-a-book-flow.ts +++ b/examples/write-a-book-flow.ts @@ -118,22 +118,22 @@ export function createWriteABookFlowExample(options: { initial: 'outlining', states: { outlining: { - resultSchema: outlineSchema, + schemas: { output: outlineSchema }, invoke: async ({ context }) => createOutline({ topic: context.topic, goal: context.goal, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'writing', context: { - title: result.title, - outline: result.chapters, + title: output.title, + outline: output.chapters, }, }), }, writing: { - resultSchema: chapterBatchSchema, + schemas: { output: chapterBatchSchema }, invoke: async ({ context }) => { const chapters = await Promise.all( context.outline.map((chapter) => @@ -148,24 +148,24 @@ export function createWriteABookFlowExample(options: { return { chapters }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'compiling', context: { - chapters: result.chapters, + chapters: output.chapters, }, }), }, compiling: { - resultSchema: manuscriptSchema, + schemas: { output: manuscriptSchema }, invoke: async ({ context }) => compileManuscript({ title: context.title ?? 'Untitled Book', chapters: context.chapters, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', context: { - manuscript: result.manuscript, + manuscript: output.manuscript, }, }), }, diff --git a/readme.md b/readme.md index 8193cc0..8a9acb4 100644 --- a/readme.md +++ b/readme.md @@ -25,7 +25,7 @@ Start here: - Durable sessions and transports: [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts), [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts), [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts) - Core workflow patterns: [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts), [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts), [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts), [`examples/spec-agent-loop.ts`](/Users/davidkpiano/Code/agent/examples/spec-agent-loop.ts), [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts) - CrewAI-style equivalents: [`examples/content-creator-flow.ts`](/Users/davidkpiano/Code/agent/examples/content-creator-flow.ts), [`examples/email-auto-responder-flow.ts`](/Users/davidkpiano/Code/agent/examples/email-auto-responder-flow.ts), [`examples/lead-score-flow.ts`](/Users/davidkpiano/Code/agent/examples/lead-score-flow.ts), [`examples/meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts), [`examples/self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts), [`examples/write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts) -- Reference examples: [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts), [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts), [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts), [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts) +- Reference examples: [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts), [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts), [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts), [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts), [`examples/workflow-guardrails.ts`](/Users/davidkpiano/Code/agent/examples/workflow-guardrails.ts) Use `classify(...)` when the result is just "what kind of thing is this?" Use `decide(...)` when the result is "what should happen next?" and the chosen branch may need structured data. diff --git a/src/adapter.ts b/src/adapter.ts index 9cc3e54..5edb2a3 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1,7 +1,7 @@ import type { AgentAdapter } from './types.js'; /** - * Create a custom adapter for AI primitives (classify/decide). + * Create a custom adapter for model execution. */ export function createAdapter(impl: AgentAdapter): AgentAdapter { return impl; diff --git a/src/agent.test.ts b/src/agent.test.ts index 1a24258..ec37e3e 100644 --- a/src/agent.test.ts +++ b/src/agent.test.ts @@ -8,7 +8,7 @@ import { decide, decideResultSchema, } from './index.js'; -import type { AgentAdapter } from './types.js'; +import type { DecideAdapter } from './types.js'; // ─── Test helpers ─── @@ -18,7 +18,7 @@ function mockAdapter( data?: Record; reasoning?: string; }> -): AgentAdapter { +): DecideAdapter { let index = 0; return { decide: async () => { @@ -53,14 +53,14 @@ function createSimpleMachine() { }, }, running: { - resultSchema: z.object({ value: z.number() }), + schemas: { output: z.object({ value: z.number() }) }, invoke: async ({ context }) => { // context.count is typed as number ✓ return { value: context.count + 1 }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { count: result.value }, + context: { count: output.value }, }), }, done: { @@ -109,16 +109,16 @@ function createHitlMachine() { }, }, processing: { - resultSchema: z.object({ output: z.string() }), + schemas: { output: z.object({ output: z.string() }) }, invoke: async ({ context }) => { // context.messages is typed ✓ return { output: `Processed: ${context.messages.map((m) => m.content).join(', ')}`, }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'reviewing', - context: { result: result.output }, + context: { result: output.output }, }), }, reviewing: { @@ -151,7 +151,7 @@ function createHitlMachine() { // ─── Decide machine ─── -function createDecideMachine(adapter: AgentAdapter) { +function createDecideMachine(adapter: DecideAdapter) { const options = { billing: { description: 'Billing issues' }, technical: { description: 'Technical issues' }, @@ -168,7 +168,7 @@ function createDecideMachine(adapter: AgentAdapter) { initial: 'classifying', states: { classifying: { - resultSchema: decideResultSchema(options), + schemas: { output: decideResultSchema(options) }, invoke: async ({ context }) => decide({ adapter, @@ -176,19 +176,19 @@ function createDecideMachine(adapter: AgentAdapter) { prompt: `Classify: ${context.issue}`, options, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'handling', - context: { category: result.choice }, + context: { category: output.choice }, }), }, handling: { - resultSchema: z.object({ resolution: z.string() }), + schemas: { output: z.object({ resolution: z.string() }) }, invoke: async ({ context }) => ({ resolution: `Handled ${context.category} issue`, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { resolution: result.resolution }, + context: { resolution: output.resolution }, }), }, done: { @@ -204,7 +204,7 @@ function createDecideMachine(adapter: AgentAdapter) { // ─── Classify machine ─── -function createClassifyMachine(adapter: AgentAdapter) { +function createClassifyMachine(adapter: DecideAdapter) { const categories = { billing: { description: 'Billing, payments, refunds' }, technical: { description: 'Technical issues, bugs' }, @@ -220,7 +220,7 @@ function createClassifyMachine(adapter: AgentAdapter) { initial: 'classifyIntent', states: { classifyIntent: { - resultSchema: classifyResultSchema(categories), + schemas: { output: classifyResultSchema(categories) }, invoke: async ({ context }) => classify({ adapter, @@ -228,9 +228,9 @@ function createClassifyMachine(adapter: AgentAdapter) { prompt: `Classify: "${context.issue}"`, into: categories, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { category: result.category }, + context: { category: output.category }, }), }, done: { @@ -519,10 +519,10 @@ describe('decide', () => { initial: 'choosing', states: { choosing: { - resultSchema: decideResultSchema({ + schemas: { output: decideResultSchema({ a: { description: 'A' }, b: { description: 'B' }, - }), + }) }, invoke: async ({ context }) => decide({ adapter: { decide: spy }, @@ -530,9 +530,9 @@ describe('decide', () => { prompt: `About ${context.topic}`, options: { a: { description: 'A' }, b: { description: 'B' } }, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { choice: result.choice }, + context: { choice: output.choice }, }), }, done: { type: 'final' }, @@ -551,10 +551,10 @@ describe('decide', () => { initial: 'choosing', states: { choosing: { - resultSchema: decideResultSchema({ + schemas: { output: decideResultSchema({ state: { description: 'State' }, machine: { description: 'Machine' }, - }), + }) }, invoke: async () => decide({ adapter: mockAdapter([{ choice: 'state' }]), @@ -565,9 +565,9 @@ describe('decide', () => { machine: { description: 'Machine' }, }, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { choice: result.choice }, + context: { choice: output.choice }, }), }, done: { type: 'final' }, @@ -584,13 +584,13 @@ describe('decide', () => { initial: 'choosing', states: { choosing: { - resultSchema: decideResultSchema({ + schemas: { output: decideResultSchema({ withData: { description: 'Has data', schema: z.object({ items: z.array(z.string()) }), }, withoutData: { description: 'No data' }, - }), + }) }, invoke: async () => decide({ adapter: { @@ -609,13 +609,13 @@ describe('decide', () => { withoutData: { description: 'No data' }, }, }), - onDone: ({ result }) => { + onDone: ({ output }) => { return { target: 'done', context: { items: - result.choice === 'withData' - ? (result.data.items ?? null) + output.choice === 'withData' + ? (output.data.items ?? null) : null, }, }; @@ -629,27 +629,29 @@ describe('decide', () => { }); }); -describe('type: choice', () => { - test('inline choice state with typed context', async () => { +describe('decide helper', () => { + test('explicit decide invoke with typed context', async () => { const adapter = mockAdapter([{ choice: 'technical' }]); const machine = createAgentMachine({ - id: 'choice-test', + id: 'decide-helper-test', context: () => ({ issue: 'App crashes', result: null as string | null }), - adapter, initial: 'routing', states: { routing: { - type: 'choice', - resultSchema: choiceResultSchema, - model: 'test-model', - prompt: ({ context }) => `Route: ${context.issue}`, // context typed ✓ - options: { - billing: { description: 'Billing' }, - technical: { description: 'Technical' }, - }, - onDone: ({ result, context }) => ({ + schemas: { output: choiceResultSchema }, + invoke: async ({ context }) => + decide({ + adapter, + model: 'test-model', + prompt: `Route: ${context.issue}`, + options: { + billing: { description: 'Billing' }, + technical: { description: 'Technical' }, + }, + }), + onDone: ({ output, context }) => ({ target: 'done', - context: { result: `${result.choice}: ${context.issue}` }, + context: { result: `${output.choice}: ${context.issue}` }, }), }, done: { type: 'final', output: ({ context }) => ({ result: context.result }) }, @@ -663,27 +665,28 @@ describe('type: choice', () => { } }); - test('choice with event preemption', async () => { + test('invoke state with event transition', () => { let called = false; - const adapter: AgentAdapter = { + const adapter: DecideAdapter = { decide: async () => { called = true; - // Slow adapter — in real use, event would preempt return { choice: 'a', data: {} }; }, }; const machine = createAgentMachine({ - id: 'choice-preempt', + id: 'invoke-event-transition', context: () => ({}), - adapter, initial: 'choosing', states: { choosing: { - type: 'choice', - resultSchema: choiceResultSchema, - model: 'test', - prompt: 'pick', - options: { a: { description: 'A' } }, + schemas: { output: choiceResultSchema }, + invoke: async () => + decide({ + adapter, + model: 'test', + prompt: 'pick', + options: { a: { description: 'A' } }, + }), onDone: () => ({ target: 'done' }), on: { cancel: () => ({ target: 'cancelled' }), @@ -694,14 +697,340 @@ describe('type: choice', () => { }, }); - // Can send event to choice state (preemption) const state = machine.getInitialState(); const next = machine.transition(state, { type: 'cancel' }); expect(next.value).toBe('cancelled'); + expect(called).toBe(false); }); }); describe('messages and always', () => { + test('states expose resolved generation fields', () => { + const search = async () => 'result'; + const machine = createAgentMachine({ + id: 'generation-fields', + schemas: { + input: z.object({ task: z.string() }), + }, + context: (input) => ({ task: input.task, phase: 'read' }), + messages: (input) => [{ role: 'user', content: input.task }], + initial: 'planning', + states: { + planning: { + model: 'test-model', + system: 'Plan carefully.', + prompt: ({ context }) => `Plan: ${context.task}`, + tools: { search }, + toolChoice: 'auto', + on: { + ready: { + target: 'implementing', + context: { phase: 'write' }, + messages: [ + { + role: 'system', + content: 'Writing is allowed now.', + }, + ], + }, + }, + }, + implementing: { + prompt: ({ context }) => `Implement: ${context.task}`, + tools: { + writeFile: async () => 'ok', + }, + on: { + done: { target: 'done' }, + }, + }, + done: { type: 'final' }, + }, + }); + + const planning = machine.getInitialState({ task: 'Fix bug' }); + expect(planning.prompt).toBe('Plan: Fix bug'); + expect(planning.model).toBe('test-model'); + expect(planning.system).toBe('Plan carefully.'); + expect(Object.keys(planning.tools ?? {})).toEqual(['search', 'event.ready']); + expect(planning.toolChoice).toBe('auto'); + + const implementing = machine.transition(planning, { type: 'ready' }); + expect(implementing.prompt).toBe('Implement: Fix bug'); + expect(implementing.model).toBeUndefined(); + expect(Object.keys(implementing.tools ?? {})).toEqual([ + 'writeFile', + 'event.done', + ]); + expect(implementing.messages.at(-1)).toEqual({ + role: 'system', + content: 'Writing is allowed now.', + }); + }); + + test('generation fields resolve from the unresolved snapshot', () => { + const read = async () => 'read'; + const write = async () => 'write'; + const seenSnapshots: Array<{ + value: string; + hasPrompt: boolean; + hasTools: boolean; + }> = []; + const machine = createAgentMachine({ + id: 'snapshot-resolvers', + schemas: { + input: z.object({ task: z.string(), mode: z.enum(['read', 'write']) }), + }, + context: (input) => ({ task: input.task, mode: input.mode }), + messages: (input) => [{ role: 'user', content: `Task: ${input.task}` }], + initial: 'working', + states: { + working: { + model: ({ snapshot }) => + snapshot.context.mode === 'write' ? 'write-model' : 'read-model', + system: ({ snapshot }) => `State: ${snapshot.value}`, + prompt: ({ snapshot }) => { + seenSnapshots.push({ + value: snapshot.value, + hasPrompt: 'prompt' in snapshot, + hasTools: 'tools' in snapshot, + }); + + return [ + `Mode: ${snapshot.context.mode}`, + `Messages: ${snapshot.messages.length}`, + `Task: ${snapshot.context.task}`, + ].join('\n'); + }, + tools: ({ snapshot }) => + snapshot.context.mode === 'write' ? { read, write } : { read }, + toolChoice: ({ snapshot }) => + snapshot.context.mode === 'write' ? 'required' : 'auto', + on: { + done: { target: 'done' }, + }, + }, + done: { type: 'final' }, + }, + }); + + const state = machine.getInitialState({ task: 'Fix bug', mode: 'write' }); + + expect(state.model).toBe('write-model'); + expect(state.system).toBe('State: working'); + expect(state.prompt).toBe('Mode: write\nMessages: 1\nTask: Fix bug'); + expect(Object.keys(state.tools ?? {})).toEqual(['read', 'write', 'event.done']); + expect(state.toolChoice).toBe('required'); + expect(seenSnapshots).toEqual([ + { + value: 'working', + hasPrompt: false, + hasTools: false, + }, + ]); + }); + + test('event tools are namespaced and use event schemas', async () => { + const userTool = async () => 'user tool'; + const machine = createAgentMachine({ + id: 'event-tools', + schemas: { + events: { + PLAN_READY: z.object({ + type: z.literal('PLAN_READY'), + rationale: z.string(), + }), + }, + }, + context: () => ({}), + initial: 'planning', + states: { + planning: { + tools: { PLAN_READY: userTool }, + on: { + PLAN_READY: { target: 'done' }, + }, + }, + done: { type: 'final' }, + }, + }); + + const state = machine.getInitialState(); + expect(state.tools?.PLAN_READY).toBe(userTool); + expect(state.tools?.['event.PLAN_READY']).toMatchObject({ + description: "Transition with event 'PLAN_READY'.", + schemas: { input: expect.any(Object) }, + }); + + const eventTool = state.tools?.['event.PLAN_READY'] as { + execute(input: Record): Promise>; + }; + await expect( + eventTool.execute({ rationale: 'plan is ready' }) + ).resolves.toEqual({ + type: 'PLAN_READY', + rationale: 'plan is ready', + }); + }); + + test('prompt states with no user tools still expose event tools', () => { + const machine = createAgentMachine({ + id: 'event-only-tools', + context: () => ({}), + initial: 'waiting', + states: { + waiting: { + prompt: 'Wait for completion.', + on: { + done: { target: 'done' }, + }, + }, + done: { type: 'final' }, + }, + }); + + expect(Object.keys(machine.getInitialState().tools ?? {})).toEqual([ + 'event.done', + ]); + }); + + test('on events become prefixed event tools in prompt states by default', () => { + const machine = createAgentMachine({ + id: 'prefixed-event-tools', + context: () => ({}), + initial: 'planning', + states: { + planning: { + prompt: 'Plan and choose a transition.', + on: { + PLAN_READY: { target: 'done' }, + FAIL: { target: 'failed' }, + }, + }, + done: { type: 'final' }, + failed: { type: 'final' }, + }, + }); + + expect(Object.keys(machine.getInitialState().tools ?? {})).toEqual([ + 'event.PLAN_READY', + 'event.FAIL', + ]); + }); + + test('non-generative states do not expose on events as tools', () => { + const machine = createAgentMachine({ + id: 'non-generative-events', + context: () => ({}), + initial: 'waiting', + states: { + waiting: { + on: { + APPROVED: { target: 'done' }, + }, + }, + done: { type: 'final' }, + }, + }); + + const waiting = machine.getInitialState(); + expect(waiting.tools).toBeUndefined(); + + const done = machine.transition(waiting, { type: 'APPROVED' }); + expect(done.value).toBe('done'); + }); + + test('external events are valid transitions but excluded from event tools', () => { + const machine = createAgentMachine({ + id: 'external-events', + externalEvents: ['APPROVED', 'REJECTED'], + schemas: { + events: { + PLAN_READY: z.object({}), + APPROVED: z.object({}), + REJECTED: z.object({}), + }, + }, + context: () => ({}), + initial: 'planning', + states: { + planning: { + prompt: 'Prepare a plan.', + on: { + PLAN_READY: { target: 'awaitingApproval' }, + }, + }, + awaitingApproval: { + prompt: 'Wait for approval.', + on: { + APPROVED: { target: 'done' }, + REJECTED: { target: 'planning' }, + }, + }, + done: { type: 'final' }, + }, + }); + + const planning = machine.getInitialState(); + expect(Object.keys(planning.tools ?? {})).toEqual(['event.PLAN_READY']); + + const awaitingApproval = machine.transition(planning, { + type: 'PLAN_READY', + }); + expect(awaitingApproval.value).toBe('awaitingApproval'); + expect(awaitingApproval.tools).toBeUndefined(); + + const done = machine.transition(awaitingApproval, { type: 'APPROVED' }); + expect(done.value).toBe('done'); + }); + + test('invoke cannot be combined with generation fields', () => { + expect(() => + createAgentMachine({ + id: 'invoke-generation-conflict', + context: () => ({}), + initial: 'working', + states: { + working: { + prompt: 'Generate something.', + invoke: async () => ({}), + }, + }, + }) + ).toThrow( + "State 'working' cannot combine invoke with prompt, system, tools, or toolChoice" + ); + }); + + test('snapshots omit executable generation fields', async () => { + const machine = createAgentMachine({ + id: 'snapshot-generation-fields', + context: () => ({}), + initial: 'waiting', + states: { + waiting: { + prompt: 'Use the tool.', + tools: { search: async () => 'result' }, + on: { done: { target: 'done' } }, + }, + done: { type: 'final' }, + }, + }); + + const state = machine.getInitialState(); + expect(state.prompt).toBe('Use the tool.'); + expect(state.tools).toBeDefined(); + + const snapshots = []; + for await (const snapshot of machine.stream(state)) { + snapshots.push(snapshot); + break; + } + + expect(snapshots[0]).not.toHaveProperty('prompt'); + expect(snapshots[0]).not.toHaveProperty('tools'); + }); + test('messages are passed through invoke, onDone, always, and output', async () => { const machine = createAgentMachine({ id: 'messages-always', @@ -720,16 +1049,16 @@ describe('messages and always', () => { initial: 'generating', states: { generating: { - resultSchema: z.object({ text: z.string() }), + schemas: { output: z.object({ text: z.string() }) }, invoke: async ({ messages }) => ({ text: `reply to ${messages.at(-1)?.content}`, }), - onDone: ({ result, context, messages }) => ({ + onDone: ({ output, context, messages }) => ({ target: 'checking', context: { attempts: context.attempts + 1 }, messages: messages.concat({ role: 'assistant', - content: result.text, + content: output.text, }), }), }, @@ -957,16 +1286,16 @@ describe('type inference', () => { initial: 'work', states: { work: { - resultSchema: z.object({ doubled: z.number() }), + schemas: { output: z.object({ doubled: z.number() }) }, invoke: async ({ context }) => { context.n satisfies number; // @ts-expect-error — 'nope' does not exist context.nope; return { doubled: context.n * 2 }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { n: result.doubled }, + context: { n: output.doubled }, }), }, done: { type: 'final' }, @@ -1149,17 +1478,16 @@ describe('type inference', () => { ).toThrow(); }); - // ─── inputSchema per state ─── + // ─── schemas.input per state ─── - test('input typed per state from inputSchema', async () => { + test('input typed per state from schemas.input', async () => { const machine = createAgentMachine({ id: 't', context: () => ({ result: '' }), initial: 'a', states: { a: { - inputSchema: z.object({ count: z.number() }), - resultSchema: z.object({ doubled: z.number() }), + schemas: { input: z.object({ count: z.number() }), output: z.object({ doubled: z.number() }) }, invoke: async ({ input }) => { input.count satisfies number; // @ts-expect-error — count is number not string @@ -1168,15 +1496,14 @@ describe('type inference', () => { input.name; return { doubled: input.count * 2 }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'b', input: { name: 'hello' }, - context: { result: String(result.doubled) }, + context: { result: String(output.doubled) }, }), }, b: { - inputSchema: z.object({ name: z.string() }), - resultSchema: z.object({ greeting: z.string() }), + schemas: { input: z.object({ name: z.string() }), output: z.object({ greeting: z.string() }) }, invoke: async ({ input }) => { input.name satisfies string; // @ts-expect-error — name is string not number @@ -1185,9 +1512,9 @@ describe('type inference', () => { input.count; return { greeting: `hi ${input.name}` }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { result: result.greeting }, + context: { result: output.greeting }, }), }, done: { @@ -1205,7 +1532,7 @@ describe('type inference', () => { expect(r.status === 'done' && r.output).toEqual({ result: 'hi hello' }); }); - test('no inputSchema → input is Record', () => { + test('no schemas.input → input is Record', () => { createAgentMachine({ id: 't', context: () => ({}), @@ -1221,33 +1548,65 @@ describe('type inference', () => { }); }); - // ─── type: 'choice' context typing ─── + test('state resolver snapshot is typed from context and input', () => { + createAgentMachine({ + id: 't', + schemas: { + input: z.object({ task: z.string() }), + }, + context: (input) => ({ task: input.task, count: 1 }), + initial: 'working', + states: { + working: { + schemas: { input: z.object({ attempt: z.number() }) }, + prompt: ({ snapshot, context, input }) => { + snapshot.value satisfies string; + snapshot.context.task satisfies string; + context.count satisfies number; + input.attempt satisfies number; + // @ts-expect-error — resolved prompt is not present while resolving + snapshot.prompt; + // @ts-expect-error — attempt is number not string + input.attempt satisfies string; + return `${snapshot.value}: ${context.task}`; + }, + on: { + done: { target: 'done' }, + }, + }, + done: { type: 'final' }, + }, + }); + }); + + // ─── decide helper context typing ─── - test('type: choice gets typed context in prompt and onDone', () => { + test('decide helper gets typed context in invoke and onDone', () => { const adapter = mockAdapter([{ choice: 'a' }]); const machine = createAgentMachine({ id: 't', context: () => ({ topic: 'cats', result: '' }), - adapter, initial: 'choosing', states: { choosing: { - type: 'choice', - resultSchema: choiceResultSchema, - model: 'test', - prompt: ({ context }) => { + schemas: { output: choiceResultSchema }, + invoke: async ({ context }) => { context.topic satisfies string; // @ts-expect-error — 'nope' does not exist context.nope; - return `About ${context.topic}`; + return decide({ + adapter, + model: 'test', + prompt: `About ${context.topic}`, + options: { a: { description: 'A' } }, + }); }, - options: { a: { description: 'A' } }, - onDone: ({ result, context }) => { - result.choice satisfies string; + onDone: ({ output, context }) => { + output.choice satisfies string; // @ts-expect-error - result.nope; + output.nope; context.topic satisfies string; - return { target: 'done', context: { result: result.choice } }; + return { target: 'done', context: { result: output.choice } }; }, }, done: { type: 'final' }, @@ -1297,26 +1656,26 @@ describe('type inference', () => { machine.getInitialState(undefined); }); - // ─── resultSchema ─── + // ─── schemas.output ─── - test('resultSchema types invoke return and onDone result', () => { + test('schemas.output types invoke return and onDone output', () => { createAgentMachine({ id: 't', context: () => ({ total: 0 }), initial: 'work', states: { work: { - resultSchema: z.object({ value: z.number() }), + schemas: { output: z.object({ value: z.number() }) }, invoke: async () => { - // return type must match resultSchema + // return type must match schemas.output return { value: 42 }; }, - onDone: ({ result }) => { - // result is typed from resultSchema - result.value satisfies number; + onDone: ({ output }) => { + // output is typed from schemas.output + output.value satisfies number; // @ts-expect-error — 'nope' does not exist on result - result.nope; - return { target: 'done', context: { total: result.value } }; + output.nope; + return { target: 'done', context: { total: output.value } }; }, }, done: { type: 'final' }, @@ -1324,7 +1683,7 @@ describe('type inference', () => { }); }); - test('no resultSchema → onDone result is inferred from invoke', () => { + test('no schemas.output → onDone output is inferred from invoke', () => { createAgentMachine({ id: 't', context: () => ({}), @@ -1332,10 +1691,10 @@ describe('type inference', () => { states: { work: { invoke: async () => ({ anything: true }), - onDone: ({ result }) => { - result.anything satisfies boolean; + onDone: ({ output }) => { + output.anything satisfies boolean; // @ts-expect-error — 'choice' does not exist on invoke result - result.choice; + output.choice; return { target: 'done' }; }, }, @@ -1479,8 +1838,9 @@ describe('edge cases', () => { describe('createAdapter', () => { test('creates custom adapter', () => { const a = createAdapter({ - decide: async () => ({ choice: 'a', data: {} }), + generateText: async () => 'ok', }); - expect(a.decide).toBeDefined(); + expect(a.generateText).toBeDefined(); + expect('decide' in a).toBe(false); }); }); diff --git a/src/ai-sdk/index.test.ts b/src/ai-sdk/index.test.ts index 45c6669..23a573e 100644 --- a/src/ai-sdk/index.test.ts +++ b/src/ai-sdk/index.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; -import { createAiSdkAdapter } from './index.js'; +import { createAiSdkAdapter, createAiSdkDecisionAdapter } from './index.js'; describe('createAiSdkAdapter', () => { test('resolves schema-less choices with a custom model resolver', async () => { const seen: Array<{ model: unknown; prompt: unknown }> = []; - const adapter = createAiSdkAdapter({ + const adapter = createAiSdkDecisionAdapter({ resolveModel: (model) => ({ providerResolved: model }) as never, generateText: async (options) => { seen.push({ @@ -41,7 +41,7 @@ describe('createAiSdkAdapter', () => { }); test('returns structured decision payloads for schema-backed options', async () => { - const adapter = createAiSdkAdapter({ + const adapter = createAiSdkDecisionAdapter({ generateText: async () => ({ output: { @@ -79,4 +79,22 @@ describe('createAiSdkAdapter', () => { reasoning: 'Need the newest API details.', }); }); + + test('creates a generation-only machine adapter', async () => { + const adapter = createAiSdkAdapter({ + generateText: async (options) => + ({ + text: `generated ${options.prompt}`, + }) as never, + }); + + await expect( + adapter.generateText?.({ + model: 'openai/gpt-5.4-nano', + messages: [], + prompt: 'reply', + }) + ).resolves.toBe('generated reply'); + expect('decide' in adapter).toBe(false); + }); }); diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index d5cb592..43b1bf8 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -1,6 +1,6 @@ import { generateText, Output } from 'ai'; import { z } from 'zod'; -import type { AgentAdapter, StandardSchemaV1 } from '../types.js'; +import type { AgentAdapter, DecideAdapter, StandardSchemaV1 } from '../types.js'; type AiSdkGenerateText = typeof generateText; type AiSdkModel = Parameters[0]['model']; @@ -11,7 +11,7 @@ export interface CreateAiSdkAdapterOptions { } /** - * Create an adapter that uses the Vercel AI SDK for decide/classify. + * Create an adapter that uses the Vercel AI SDK for generative states. * By default, model strings are passed straight through to the AI SDK. * For provider helpers such as `openai(...)`, pass `resolveModel`. */ @@ -20,6 +20,38 @@ export function createAiSdkAdapter( ): AgentAdapter { const generate = config.generateText ?? generateText; + return { + async generateText({ model, system, prompt, messages, tools, toolChoice, outputSchema }) { + const result = await generate({ + model: resolveModel(model ?? 'default', config.resolveModel), + system, + prompt, + messages: messages as any, + tools: tools as any, + toolChoice: toolChoice as any, + ...(outputSchema + ? { + output: Output.object({ + schema: toZodSchema(outputSchema), + }), + } + : {}), + }); + + const output = result as { output?: unknown; text?: string }; + return output.output ?? output.text ?? result; + }, + }; +} + +/** + * Create a decision helper adapter for decide(...) and classify(...). + */ +export function createAiSdkDecisionAdapter( + config: CreateAiSdkAdapterOptions = {} +): DecideAdapter { + const generate = config.generateText ?? generateText; + return { async decide({ model, prompt, options, reasoning }) { const optionKeys = Object.keys(options); diff --git a/src/decide.ts b/src/decide.ts index 62cec7d..10f62d7 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { validateSchemaSync } from './utils.js'; import type { - AgentAdapter, + DecideAdapter, DecideOptions, DecideResultFor, StandardSchemaV1, @@ -39,9 +39,9 @@ export async function decide< } export function requireAdapter( - adapter: AgentAdapter | undefined, + adapter: DecideAdapter | undefined, label: string -): AgentAdapter { +): DecideAdapter { if (!adapter) { throw new Error(`No adapter configured for ${label}`); } diff --git a/src/examples.test.ts b/src/examples.test.ts index cdad249..4233fd1 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -42,6 +42,9 @@ import { createRiverCrossingExample, createSimpleExample, createSqlAgentExample, + createGuardrailedBugfixWorkflowExample, + createGuardrailedIncidentResponseExample, + createUnguardedIncidentResponseExample, createSubflowExample, createSupervisorExample, createToolCallingExample, @@ -82,9 +85,9 @@ function createSseReader(response: Response) { describe('curated examples', () => { test('simple example runs to a final output', async () => { - const machine = createSimpleExample(async () => ({ - summary: 'A short summary.', - })); + const machine = createSimpleExample({ + generateText: async () => ({ summary: 'A short summary.' }), + }); const result = await machine.execute( machine.getInitialState({ text: 'Longer source text.' }) ); @@ -162,9 +165,14 @@ describe('curated examples', () => { { id: 'doc-2', content: `${question} :: second fact` }, ], }), - answer: async ({ question, documents }) => ({ - answer: `${question} => ${documents.map((document) => document.content).join(' | ')}`, - }), + adapter: { + generateText: async ({ prompt }) => ({ + answer: String(prompt) + .replace('Question: ', '') + .replace('\n\nDocuments:\n- [doc-1] ', ' => ') + .replace('\n- [doc-2] ', ' | '), + }), + }, }); const result = await machine.execute( @@ -1264,9 +1272,12 @@ describe('curated examples', () => { }); test('joke example produces a rating and acceptance flag', async () => { + const results = [ + { joke: 'A short joke about ducks.' }, + { rating: 9, explanation: 'It works.' }, + ]; const machine = createJokeExample({ - tellJoke: async () => ({ joke: 'A short joke about ducks.' }), - rateJoke: async () => ({ rating: 9, explanation: 'It works.' }), + generateText: async () => results.shift(), }); const result = await machine.execute( @@ -1889,3 +1900,52 @@ describe('curated examples', () => { } }); }); + +describe('guardrailed workflow examples', () => { + test('bugfix example exposes per-state prompts and tools', () => { + const machine = createGuardrailedBugfixWorkflowExample(); + + const planning = machine.getInitialState({ task: 'Fix divide().' }); + expect(planning.value).toBe('planning'); + expect(Object.keys(planning.tools ?? {})).toEqual( + expect.arrayContaining([ + 'Read', + 'Grep', + 'Glob', + 'LS', + 'Bash', + ]) + ); + + expect(Object.keys(planning.tools ?? {})).not.toContain('Edit'); + }); + + test('incident response example withholds destructive tools', async () => { + const machine = createGuardrailedIncidentResponseExample(); + + const diagnose = machine.getInitialState({}); + expect(diagnose.value).toBe('diagnosing'); + expect(Object.keys(diagnose.tools ?? {})).toContain('get_logs'); + expect(Object.keys(diagnose.tools ?? {})).not.toContain('delete_volume'); + + const result = await machine.execute(diagnose); + expect(result.status).toBe('pending'); + if (result.status !== 'pending') { + throw new Error('Expected approval state'); + } + + expect(result.state.value).toBe('awaitingApproval'); + expect(Object.keys(result.state.tools ?? {})).toEqual( + expect.arrayContaining(['Read', 'event.APPROVED', 'event.REJECTED']) + ); + }); + + test('unguarded incident response example exposes every API action', () => { + const machine = createUnguardedIncidentResponseExample(); + const state = machine.getInitialState({}); + + expect(state.value).toBe('working'); + expect(Object.keys(state.tools ?? {})).toContain('delete_volume'); + expect(Object.keys(state.tools ?? {})).toContain('restart_service'); + }); +}); diff --git a/src/graph/index.test.ts b/src/graph/index.test.ts index d65d2c9..d0b3fa6 100644 --- a/src/graph/index.test.ts +++ b/src/graph/index.test.ts @@ -39,12 +39,11 @@ test('exports finite states and transition edges as Stately graph JSON', () => { }, }, working: { - inputSchema: z.object({ + schemas: { input: z.object({ index: z.number(), - }), - resultSchema: z.object({ + }), output: z.object({ ok: z.boolean(), - }), + }) }, invoke: async () => ({ ok: true }), onDone: () => ({ target: 'done', diff --git a/src/http/index.test.ts b/src/http/index.test.ts index 5ddbf5f..5357153 100644 --- a/src/http/index.test.ts +++ b/src/http/index.test.ts @@ -66,17 +66,17 @@ describe('http adapter', () => { }, }, writing: { - resultSchema: z.object({ + schemas: { output: z.object({ text: z.string(), - }), + }) }, invoke: async ({ context }, enq) => { enq.emit({ type: 'textPart', delta: context.text }); return { text: context.text }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', context: { - finalText: result.text, + finalText: output.text, }, }), }, diff --git a/src/index.ts b/src/index.ts index 4df7514..9ced35d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,11 +21,15 @@ export type { AgentMachine, AgentMessage, AgentRun, + AgentResolverSnapshot, AgentSnapshot, AgentState, + AgentToolChoice, + AgentTools, ClassifyOptions, ClassifyResultFor, DecideOptions, + DecideAdapter, DecideResultFor, EmittedPart, EmittedUnion, @@ -38,11 +42,13 @@ export type { JournalEventRecord, MachineConfig, PersistedSnapshot, + ResolvableStateValue, RestoreSessionOptions, RunStore, SessionOptions, StandardSchemaV1, StateConfig, + StateResolverArgs, Trace, TransitionEvent, TransitionResult, diff --git a/src/invoke-events.test.ts b/src/invoke-events.test.ts index 0fe92df..5498bd4 100644 --- a/src/invoke-events.test.ts +++ b/src/invoke-events.test.ts @@ -24,11 +24,11 @@ test('invoke success is journaled as an internal machine event', async () => { initial: 'processing', states: { processing: { - resultSchema: z.object({ value: z.string() }), + schemas: { output: z.object({ value: z.string() }) }, invoke: async () => ({ value: 'ok' }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { result: result.value }, + context: { result: output.value }, }), }, done: { @@ -105,7 +105,7 @@ test('invalid invoke results fail without journaling a done event', async () => initial: 'processing', states: { processing: { - resultSchema: z.object({ value: z.string() }), + schemas: { output: z.object({ value: z.string() }) }, invoke: async () => ({ value: 42 } as unknown as { value: string }), }, }, diff --git a/src/langgraph-equivalents/branching.test.ts b/src/langgraph-equivalents/branching.test.ts index a06f91f..784594c 100644 --- a/src/langgraph-equivalents/branching.test.ts +++ b/src/langgraph-equivalents/branching.test.ts @@ -18,11 +18,11 @@ test('supports branching-style orchestration with plain async fan-out inside inv initial: 'analyzing', states: { analyzing: { - resultSchema: z.object({ + schemas: { output: z.object({ docs: z.string(), issues: z.string(), code: z.string(), - }), + }) }, invoke: async ({ context }) => { const [docs, issues, code] = await Promise.all([ Promise.resolve(`docs about ${context.topic}`), @@ -32,20 +32,20 @@ test('supports branching-style orchestration with plain async fan-out inside inv return { docs, issues, code }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'summarizing', - context: result, + context: output, }), }, summarizing: { // paramsschema could help here, the summary has lots of string | null - resultSchema: z.object({ summary: z.string() }), + schemas: { output: z.object({ summary: z.string() }) }, invoke: async ({ context }) => ({ summary: [context.docs, context.issues, context.code].join(' | '), }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { summary: result.summary }, + context: { summary: output.summary }, }), }, done: { diff --git a/src/langgraph-equivalents/graph.test.ts b/src/langgraph-equivalents/graph.test.ts index 33b1b45..d6de3f1 100644 --- a/src/langgraph-equivalents/graph.test.ts +++ b/src/langgraph-equivalents/graph.test.ts @@ -9,27 +9,27 @@ test('supports multi-step workflow accumulation like a sequential state graph', initial: 'node1', states: { node1: { - resultSchema: z.object({ messages: z.array(z.string()) }), + schemas: { output: z.object({ messages: z.array(z.string()) }) }, invoke: async () => ({ messages: ['from node1'] }), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'node2', - context: { messages: [...context.messages, ...result.messages] }, + context: { messages: [...context.messages, ...output.messages] }, }), }, node2: { - resultSchema: z.object({ messages: z.array(z.string()) }), + schemas: { output: z.object({ messages: z.array(z.string()) }) }, invoke: async () => ({ messages: ['from node2'] }), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'node3', - context: { messages: [...context.messages, ...result.messages] }, + context: { messages: [...context.messages, ...output.messages] }, }), }, node3: { - resultSchema: z.object({ messages: z.array(z.string()) }), + schemas: { output: z.object({ messages: z.array(z.string()) }) }, invoke: async () => ({ messages: ['from node3'] }), - onDone: ({ result, context }) => ({ + onDone: ({ output, context }) => ({ target: 'done', - context: { messages: [...context.messages, ...result.messages] }, + context: { messages: [...context.messages, ...output.messages] }, }), }, done: { @@ -63,9 +63,9 @@ test('supports conditional routing with explicit machine transitions', async () initial: 'routeRequest', states: { routeRequest: { - resultSchema: z.object({ + schemas: { output: z.object({ route: z.enum(['billing', 'general']), - }), + }) }, invoke: async ({ context }) => { const route = context.request.toLowerCase().includes('refund') ? 'billing' @@ -73,25 +73,25 @@ test('supports conditional routing with explicit machine transitions', async () return { route } as const; }, - onDone: ({ result }) => ({ - target: result.route, - context: { route: result.route }, + onDone: ({ output }) => ({ + target: output.route, + context: { route: output.route }, }), }, billing: { - resultSchema: z.object({ handledBy: z.literal('billing') }), + schemas: { output: z.object({ handledBy: z.literal('billing') }) }, invoke: async () => ({ handledBy: 'billing' as const }), // why do we need to cast to const here? - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { handledBy: result.handledBy }, + context: { handledBy: output.handledBy }, }), }, general: { - resultSchema: z.object({ handledBy: z.literal('general') }), + schemas: { output: z.object({ handledBy: z.literal('general') }) }, invoke: async () => ({ handledBy: 'general' as const }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { handledBy: result.handledBy }, + context: { handledBy: output.handledBy }, }), }, done: { diff --git a/src/langgraph-equivalents/hitl.test.ts b/src/langgraph-equivalents/hitl.test.ts index 3cf8f6e..9f82a76 100644 --- a/src/langgraph-equivalents/hitl.test.ts +++ b/src/langgraph-equivalents/hitl.test.ts @@ -20,13 +20,13 @@ test('supports human-in-the-loop review with explicit pending states and externa initial: 'drafting', states: { drafting: { - resultSchema: z.object({ draft: z.string() }), + schemas: { output: z.object({ draft: z.string() }) }, invoke: async ({ context }) => ({ draft: `Draft for ${context.task}${context.notes.length ? ` (${context.notes.join(', ')})` : ''}`, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'review', - context: { draft: result.draft }, + context: { draft: output.draft }, }), }, review: { diff --git a/src/langgraph-equivalents/map-reduce.test.ts b/src/langgraph-equivalents/map-reduce.test.ts index 34e0762..7503c33 100644 --- a/src/langgraph-equivalents/map-reduce.test.ts +++ b/src/langgraph-equivalents/map-reduce.test.ts @@ -17,17 +17,17 @@ test('supports map-reduce style orchestration with dynamic work items inside inv initial: 'planning', states: { planning: { - resultSchema: z.object({ subjects: z.array(z.string()) }), + schemas: { output: z.object({ subjects: z.array(z.string()) }) }, invoke: async ({ context }) => ({ subjects: [`${context.topic} basics`, `${context.topic} advanced`], }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'mapping', - context: { subjects: result.subjects }, + context: { subjects: output.subjects }, }), }, mapping: { - resultSchema: z.object({ jokes: z.array(z.string()) }), + schemas: { output: z.object({ jokes: z.array(z.string()) }) }, invoke: async ({ context }) => { const jokes = await Promise.all( context.subjects.map(async (subject) => `joke about ${subject}`) @@ -35,19 +35,19 @@ test('supports map-reduce style orchestration with dynamic work items inside inv return { jokes }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'reducing', - context: { jokes: result.jokes }, + context: { jokes: output.jokes }, }), }, reducing: { - resultSchema: z.object({ bestJoke: z.string() }), + schemas: { output: z.object({ bestJoke: z.string() }) }, invoke: async ({ context }) => ({ bestJoke: context.jokes[0] ?? '', }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { bestJoke: result.bestJoke }, + context: { bestJoke: output.bestJoke }, }), }, done: { diff --git a/src/langgraph-equivalents/persistence.test.ts b/src/langgraph-equivalents/persistence.test.ts index be6f53f..efa31ae 100644 --- a/src/langgraph-equivalents/persistence.test.ts +++ b/src/langgraph-equivalents/persistence.test.ts @@ -25,13 +25,13 @@ test('persists and restores a long-running approval workflow', async () => { }, }, summarize: { - resultSchema: z.object({ summary: z.string() }), + schemas: { output: z.object({ summary: z.string() }) }, invoke: async ({ context }) => ({ summary: context.approved ? 'approved summary' : 'rejected summary', }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { summary: result.summary }, + context: { summary: output.summary }, }), }, done: { diff --git a/src/langgraph-equivalents/rag.test.ts b/src/langgraph-equivalents/rag.test.ts index c896759..8cc5f8a 100644 --- a/src/langgraph-equivalents/rag.test.ts +++ b/src/langgraph-equivalents/rag.test.ts @@ -9,9 +9,14 @@ test('rag workflow retrieves documents and synthesizes a grounded answer', async { id: 'doc-2', content: `${question} :: second fact` }, ], }), - answer: async ({ question, documents }) => ({ - answer: `${question} => ${documents.map((document) => document.content).join(' | ')}`, - }), + adapter: { + generateText: async ({ prompt }) => ({ + answer: String(prompt) + .replace('Question: ', '') + .replace('\n\nDocuments:\n- [doc-1] ', ' => ') + .replace('\n- [doc-2] ', ' | '), + }), + }, }); const result = await machine.execute( diff --git a/src/langgraph-equivalents/streaming.test.ts b/src/langgraph-equivalents/streaming.test.ts index 348488a..e35d997 100644 --- a/src/langgraph-equivalents/streaming.test.ts +++ b/src/langgraph-equivalents/streaming.test.ts @@ -30,15 +30,15 @@ test('streams live invoke output while preserving durable state history', async initial: 'write', states: { write: { - resultSchema: z.object({ text: z.string() }), + schemas: { output: z.object({ text: z.string() }) }, invoke: async (_args, enq) => { enq.emit({ type: 'textPart', delta: 'hello' }); enq.emit({ type: 'textPart', delta: ' world' }); return { text: 'hello world' }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { text: result.text }, + context: { text: output.text }, }), }, done: { diff --git a/src/langgraph-equivalents/subflow.test.ts b/src/langgraph-equivalents/subflow.test.ts index 4da8014..15b0370 100644 --- a/src/langgraph-equivalents/subflow.test.ts +++ b/src/langgraph-equivalents/subflow.test.ts @@ -15,13 +15,13 @@ test('supports subflow composition by executing a child machine inside a parent initial: 'researching', states: { researching: { - resultSchema: z.object({ bullets: z.array(z.string()) }), + schemas: { output: z.object({ bullets: z.array(z.string()) }) }, invoke: async ({ context }) => ({ bullets: [`fact about ${context.topic}`, `another fact about ${context.topic}`], }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { bullets: result.bullets }, + context: { bullets: output.bullets }, }), }, done: { @@ -44,7 +44,7 @@ test('supports subflow composition by executing a child machine inside a parent initial: 'researching', states: { researching: { - resultSchema: z.object({ bullets: z.array(z.string()) }), + schemas: { output: z.object({ bullets: z.array(z.string()) }) }, invoke: async ({ context }) => { const result = await childMachine.execute( childMachine.getInitialState({ topic: context.topic }) @@ -58,19 +58,19 @@ test('supports subflow composition by executing a child machine inside a parent bullets: (result.output as { bullets: string[] }).bullets, }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'writing', - context: { bullets: result.bullets }, + context: { bullets: output.bullets }, }), }, writing: { - resultSchema: z.object({ draft: z.string() }), + schemas: { output: z.object({ draft: z.string() }) }, invoke: async ({ context }) => ({ draft: `${context.topic}: ${context.bullets.join('; ')}`, }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { draft: result.draft }, + context: { draft: output.draft }, }), }, done: { diff --git a/src/langgraph-equivalents/tool-calling.test.ts b/src/langgraph-equivalents/tool-calling.test.ts index 6107041..1b781f0 100644 --- a/src/langgraph-equivalents/tool-calling.test.ts +++ b/src/langgraph-equivalents/tool-calling.test.ts @@ -46,7 +46,7 @@ test('supports tool-call style invokes with live tool events and final output', initial: 'checkingWeather', states: { checkingWeather: { - resultSchema: z.object({ forecast: z.string() }), + schemas: { output: z.object({ forecast: z.string() }) }, invoke: async ({ context }, enq) => { enq.emit({ type: 'toolCall', @@ -77,9 +77,9 @@ test('supports tool-call style invokes with live tool events and final output', return output; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { forecast: result.forecast }, + context: { forecast: output.forecast }, }), }, done: { diff --git a/src/machine.ts b/src/machine.ts index 01d85bf..8e84b8f 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -1,6 +1,9 @@ import type { AgentMachine, AgentMessage, + AgentResolverSnapshot, + AgentToolChoice, + AgentTools, AgentSnapshot, AgentState, EmittedPart, @@ -22,6 +25,7 @@ import { isAlwaysEventType, isDoneInvokeEventType, isErrorInvokeEventType, + isReservedInternalEventType, resolveInitial, resolveStateConfig, serializeError, @@ -30,13 +34,31 @@ import { import type { StateConfigAny } from './utils.js'; // ─── Type helpers ─── -/** Result type for onDone: typed from invoke return or resultSchema when present */ -type OnDoneResult = NoInfer; +/** Output type for onDone: typed from invoke return or state schemas.output when present */ +type OnDoneOutput = NoInfer; type EventFor = E extends keyof TEvents & string ? { type: E } & EventPayload> : { type: E & string; [k: string]: unknown }; +type StateResolverArgs< + TContext extends Record, + TInput, +> = { + snapshot: AgentResolverSnapshot; + context: TContext; + messages: AgentMessage[]; + input: NoInfer; +}; + +type ResolvableStateValue< + TValue, + TContext extends Record, + TInput, +> = + | TValue + | ((args: StateResolverArgs) => TValue); + type StateNodeDef< TState, TContext extends Record, @@ -47,16 +69,18 @@ type StateNodeDef< TInputMap extends Record, TOutput, > = { - type?: 'final' | 'choice'; - inputSchema?: StandardSchemaV1; - resultSchema?: StandardSchemaV1; + type?: 'final'; + schemas?: { + input?: StandardSchemaV1; + output?: StandardSchemaV1; + }; invoke?: (args: { context: TContext; messages: AgentMessage[]; input: NoInfer; signal?: AbortSignal; }, enq: { emit(part: EmittedPart): void }) => Promise; - onDone?: (args: { result: OnDoneResult; context: TContext; messages: AgentMessage[] }) => TransitionResult; + onDone?: (args: { output: OnDoneOutput; context: TContext; messages: AgentMessage[] }) => TransitionResult; always?: TransitionResult | ((args: { context: TContext; messages: AgentMessage[]; @@ -69,11 +93,12 @@ type StateNodeDef< }, enq: { emit(part: EmittedPart): void }) => TransitionResult) }; events?: Record; output?: (args: { context: TContext; messages: AgentMessage[] }) => NoInfer; - model?: string; + model?: ResolvableStateValue; adapter?: import('./types.js').AgentAdapter; - prompt?: string | ((args: { context: TContext; messages: AgentMessage[]; input: NoInfer }) => string); - options?: Record; - reasoning?: boolean; + prompt?: ResolvableStateValue; + system?: ResolvableStateValue; + tools?: ResolvableStateValue; + toolChoice?: ResolvableStateValue; }; type StatesMap< @@ -116,6 +141,7 @@ export function createAgentMachine< context: (input: NoInfer) => NoInfer; messages?: AgentMessage[] | ((input: NoInfer) => AgentMessage[]); adapter?: import('./types.js').AgentAdapter; + externalEvents?: readonly (keyof TEvents & string)[]; initial: | (keyof TInputMap & keyof TResultMap & string) | ((args: { context: NoInfer }) => { @@ -146,6 +172,7 @@ export function createAgentMachine< context: (input: NoInfer) => TContext; messages?: AgentMessage[] | ((input: NoInfer) => AgentMessage[]); adapter?: import('./types.js').AgentAdapter; + externalEvents?: readonly (keyof TEvents & string)[]; initial: | (keyof TInputMap & keyof TResultMap & string) | ((args: { context: TContext }) => { @@ -175,6 +202,7 @@ export function createAgentMachine< context: (...args: any[]) => TContext; messages?: AgentMessage[] | ((input: unknown) => AgentMessage[]); adapter?: import('./types.js').AgentAdapter; + externalEvents?: readonly (keyof TEvents & string)[]; initial: | (keyof TInputMap & keyof TResultMap & string) | ((args: { context: TContext }) => { @@ -190,8 +218,30 @@ export function createAgentMachine( machineConfig: MachineConfig ): AgentMachine { const cfg = machineConfig as MachineConfig; + assertValidConfig(cfg); type SnapshotRuntime = { sessionId: string; createdAt: number }; + const EVENT_TOOL_PREFIX = 'event.'; + + function assertValidConfig(config: MachineConfig) { + for (const [stateValue, stateConfig] of Object.entries(config.states)) { + if (!stateConfig.invoke) { + continue; + } + + const hasGenerationFields = + stateConfig.prompt !== undefined + || stateConfig.system !== undefined + || stateConfig.tools !== undefined + || stateConfig.toolChoice !== undefined; + + if (hasGenerationFields) { + throw new Error( + `State '${stateValue}' cannot combine invoke with prompt, system, tools, or toolChoice` + ); + } + } + } function createSnapshotRuntime(state: AgentState) { if (state.sessionId && state.createdAt !== undefined) { @@ -217,11 +267,109 @@ export function createAgentMachine( state: AgentState, runtime: SnapshotRuntime ): AgentState { - return { + return resolveStateFields({ ...state, sessionId: runtime.sessionId, createdAt: runtime.createdAt, + }); + } + + function withoutResolvedFields(state: AgentState): AgentState { + const { + model: _model, + prompt: _prompt, + system: _system, + tools: _tools, + toolChoice: _toolChoice, + ...rest + } = state; + + return rest; + } + + function resolveStateFields(state: AgentState): AgentState { + const base = withoutResolvedFields(state); + const sc = resolveStateConfig(cfg, base.value); + const input = getInput(base.value, base.input); + const args = { + snapshot: base, + context: base.context, + messages: base.messages, + input, + }; + + const model = + typeof sc.model === 'function' + ? sc.model(args) + : sc.model; + const prompt = + typeof sc.prompt === 'function' + ? sc.prompt(args) + : sc.prompt; + const system = + typeof sc.system === 'function' + ? sc.system(args) + : sc.system; + const tools = + typeof sc.tools === 'function' + ? sc.tools(args) + : sc.tools; + const toolChoice = + typeof sc.toolChoice === 'function' + ? sc.toolChoice(args) + : sc.toolChoice; + const eventTools = getEventTools(base.value); + const resolvedTools = { + ...(tools ?? {}), + ...eventTools, }; + + return { + ...base, + ...(model !== undefined ? { model } : {}), + ...(prompt !== undefined ? { prompt } : {}), + ...(system !== undefined ? { system } : {}), + ...(Object.keys(resolvedTools).length > 0 ? { tools: resolvedTools } : {}), + ...(toolChoice !== undefined ? { toolChoice } : {}), + }; + } + + function getEventTools(value: string): AgentTools { + const sc = resolveStateConfig(cfg, value); + if (!sc.on || !isGenerativeState(sc)) { + return {}; + } + + const tools: AgentTools = {}; + const externalEvents = new Set(cfg.externalEvents ?? []); + + for (const eventType of Object.keys(sc.on)) { + if (isReservedInternalEventType(eventType) || externalEvents.has(eventType)) { + continue; + } + + const schema = findEventSchema(cfg, value, eventType); + + tools[`${EVENT_TOOL_PREFIX}${eventType}`] = { + description: `Transition with event '${eventType}'.`, + ...(schema ? { schemas: { input: schema },} : {}), + execute: async (input: unknown = {}) => ({ + ...(input && typeof input === 'object' ? input : {}), + type: eventType, + }), + }; + } + + return tools; + } + + function isGenerativeState(sc: StateConfigAny): boolean { + return ( + sc.prompt !== undefined + || sc.system !== undefined + || sc.tools !== undefined + || sc.toolChoice !== undefined + ); } function getInitialState(...args: [input?: unknown]): AgentState { @@ -243,13 +391,13 @@ export function createAgentMachine( throw new Error('Initial transition must specify a target state'); } - return { + return resolveStateFields({ value: init.target, context: init.context ? { ...context, ...init.context } : context, messages: init.messages ?? messages, status: 'active', input: init.input ? { [init.target]: init.input } : {}, - }; + }); } function resolveState(raw: { @@ -263,7 +411,7 @@ export function createAgentMachine( output?: unknown; error?: unknown; }): AgentState { - return { + return resolveStateFields({ value: raw.value, context: raw.context, messages: raw.messages ?? [], @@ -273,7 +421,7 @@ export function createAgentMachine( createdAt: raw.createdAt, output: raw.output, error: raw.error, - }; + }); } function transition( @@ -299,17 +447,17 @@ export function createAgentMachine( status = state.status ): AgentState { if (result.target) { - return applyTransition(state, result); + return resolveStateFields(applyTransition(withoutResolvedFields(state), result)); } - return { - ...state, + return resolveStateFields({ + ...withoutResolvedFields(state), status, context: result.context ? { ...state.context, ...result.context } : state.context, messages: result.messages ?? state.messages, - }; + }); } function resolveHandlerResult( @@ -341,13 +489,13 @@ export function createAgentMachine( if (isDoneInvokeEventType(state.value, event.type)) { const result = 'output' in event ? event.output : undefined; - const validatedResult = sc.resultSchema - ? validateSchemaSync(sc.resultSchema, result) + const validatedOutput = sc.schemas?.output + ? validateSchemaSync(sc.schemas.output, result) : result; if (sc.onDone) { const trans = sc.onDone({ - result: validatedResult, + output: validatedOutput, context: state.context, messages: state.messages, }); @@ -363,7 +511,7 @@ export function createAgentMachine( return resolveHandlerResult(internalHandler, 'pending'); } - return { next: { ...state, status: 'pending' }, emitted }; + return { next: resolveStateFields({ ...withoutResolvedFields(state), status: 'pending' }), emitted }; } if (isAlwaysEventType(state.value, event.type)) { @@ -384,11 +532,11 @@ export function createAgentMachine( } return { - next: { - ...state, + next: resolveStateFields({ + ...withoutResolvedFields(state), status: 'error', error: 'error' in event ? event.error : undefined, - }, + }), emitted, }; } @@ -410,11 +558,11 @@ export function createAgentMachine( result: unknown ): unknown { const sc = resolveStateConfig(cfg, value); - if (!sc.resultSchema) { + if (!sc.schemas?.output) { return result; } - return validateSchemaSync(sc.resultSchema, result); + return validateSchemaSync(sc.schemas.output, result); } function validateEventPayload( @@ -477,30 +625,20 @@ export function createAgentMachine( }; } - async function createChoiceEvent(state: AgentState): Promise { - const sc = resolveStateConfig(cfg, state.value); - const adapter = sc.adapter ?? cfg.adapter; - if (!adapter) { - return { - type: `xstate.error.invoke.${state.value}`, - error: { message: `No adapter for '${state.value}'` }, - at: Date.now(), - }; - } - - const input = getInput(state.value, state.input); - const prompt = - typeof sc.prompt === 'function' - ? sc.prompt({ context: state.context, messages: state.messages, input }) - : sc.prompt; - + async function createInvokeEvent( + state: AgentState, + sc: StateConfigAny, + onEmit?: (part: EmittedPart) => void + ): Promise { try { - const result = await adapter.decide({ - model: sc.model!, - prompt: prompt as string, - options: sc.options!, - reasoning: sc.reasoning, - }); + const result = await sc.invoke!( + { + context: state.context, + messages: state.messages, + input: getInput(state.value, state.input), + }, + createEnqueue(onEmit) + ); const validatedResult = validateReplayableResult(state.value, result); return { @@ -517,20 +655,30 @@ export function createAgentMachine( } } - async function createInvokeEvent( - state: AgentState, - sc: StateConfigAny, - onEmit?: (part: EmittedPart) => void - ): Promise { + async function createGenerateEvent(state: AgentState): Promise { + const sc = resolveStateConfig(cfg, state.value); + const adapter = sc.adapter ?? cfg.adapter; + if (!adapter?.generateText) { + return { + type: `xstate.error.invoke.${state.value}`, + error: { message: `No generateText adapter for '${state.value}'` }, + at: Date.now(), + }; + } + try { - const result = await sc.invoke!( - { - context: state.context, - messages: state.messages, - input: getInput(state.value, state.input), - }, - createEnqueue(onEmit) - ); + const messages = state.prompt + ? state.messages.concat({ role: 'user', content: state.prompt }) + : state.messages; + const result = await adapter.generateText({ + model: state.model, + system: state.system, + prompt: state.prompt, + messages, + tools: state.tools, + toolChoice: state.toolChoice, + outputSchema: sc.schemas?.output, + }); const validatedResult = validateReplayableResult(state.value, result); return { @@ -563,14 +711,22 @@ export function createAgentMachine( }; } - if (sc.type === 'choice') { - return createChoiceEvent(state); - } - if (sc.invoke) { return createInvokeEvent(state, sc, onEmit); } + if ( + sc.onDone + && ( + sc.prompt !== undefined + || sc.system !== undefined + || sc.tools !== undefined + || sc.toolChoice !== undefined + ) + ) { + return createGenerateEvent(state); + } + return null; } @@ -612,7 +768,7 @@ export function createAgentMachine( const output = cfg.schemas?.output ? validateSchemaSync(cfg.schemas.output, rawOutput) : rawOutput; - return { ...state, status: 'done', output }; + return resolveStateFields({ ...withoutResolvedFields(state), status: 'done', output }); } const effectEvent = await getEffectEvent(state); @@ -621,14 +777,14 @@ export function createAgentMachine( } if (sc.on) { - return { ...state, status: 'pending' }; + return resolveStateFields({ ...withoutResolvedFields(state), status: 'pending' }); } - return { - ...state, + return resolveStateFields({ + ...withoutResolvedFields(state), status: 'error', error: `State '${state.value}' has no invoke, events, or final type`, - }; + }); } async function execute(state: AgentState): Promise { diff --git a/src/restore.test.ts b/src/restore.test.ts index 2dda9cf..b840845 100644 --- a/src/restore.test.ts +++ b/src/restore.test.ts @@ -22,13 +22,13 @@ test('restoreSession reconstructs from the latest snapshot plus replay tail', as }, }, processing: { - resultSchema: z.object({ value: z.string() }), + schemas: { output: z.object({ value: z.string() }) }, invoke: async ({ context }) => ({ value: context.approved ? 'approved' : 'rejected', }), - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { result: result.value }, + context: { result: output.value }, }), }, done: { diff --git a/src/session-runtime.test.ts b/src/session-runtime.test.ts index c8daed0..8844a1f 100644 --- a/src/session-runtime.test.ts +++ b/src/session-runtime.test.ts @@ -98,15 +98,15 @@ test('serializes concurrent sends so each event applies from the latest snapshot }, }, working: { - resultSchema: z.object({ count: z.number() }), + schemas: { output: z.object({ count: z.number() }) }, invoke: async ({ context }) => { const gate = gates[invocations++]!; await gate.promise; return { count: context.count }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'ready', - context: { count: result.count }, + context: { count: output.count }, }), }, }, diff --git a/src/streaming.test.ts b/src/streaming.test.ts index 3bdf276..a2c490a 100644 --- a/src/streaming.test.ts +++ b/src/streaming.test.ts @@ -30,16 +30,16 @@ test('returns a live run before initial invoke output and emits ephemeral parts' initial: 'writing', states: { writing: { - resultSchema: z.object({ text: z.string() }), + schemas: { output: z.object({ text: z.string() }) }, invoke: async (_args, enq) => { enq.emit({ type: 'textPart', delta: 'hel' }); enq.emit({ type: 'textPart', delta: 'lo' }); return { text: 'hello' }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { finalText: result.text }, + context: { finalText: output.text }, }), }, done: { @@ -110,15 +110,15 @@ test('does not replay prior events to late subscribers', async () => { initial: 'writing', states: { writing: { - resultSchema: z.object({ text: z.string() }), + schemas: { output: z.object({ text: z.string() }) }, invoke: async (_args, enq) => { enq.emit({ type: 'textPart', delta: 'hel' }); enq.emit({ type: 'textPart', delta: 'lo' }); return { text: 'hello' }; }, - onDone: ({ result }) => ({ + onDone: ({ output }) => ({ target: 'done', - context: { finalText: result.text }, + context: { finalText: output.text }, }), }, done: { diff --git a/src/target-types.assert.ts b/src/target-types.assert.ts index 80fde55..6cd6cae 100644 --- a/src/target-types.assert.ts +++ b/src/target-types.assert.ts @@ -44,9 +44,9 @@ createAgentMachine({ }, }, working: { - inputSchema: z.object({ + schemas: { input: z.object({ index: z.number(), - }), + }) }, }, }, schemas: { @@ -140,16 +140,16 @@ createAgentMachine({ states: { idle: { on: { - // @ts-expect-error input should be required when the target has inputSchema + // @ts-expect-error input should be required when the target has schemas.input advance: () => ({ target: 'working', }), }, }, working: { - inputSchema: z.object({ + schemas: { input: z.object({ index: z.number(), - }), + }) }, }, }, schemas: { @@ -194,7 +194,7 @@ createAgentMachine({ states: { idle: { on: { - // @ts-expect-error input should be rejected when the target has no inputSchema + // @ts-expect-error input should be rejected when the target has no schemas.input advance: () => ({ target: 'done', input: { @@ -233,9 +233,9 @@ createAgentMachine({ }, }, working: { - inputSchema: z.object({ + schemas: { input: z.object({ index: z.number(), - }), + }) }, }, }, schemas: { @@ -264,9 +264,9 @@ createAgentMachine({ }, }, working: { - inputSchema: z.object({ + schemas: { input: z.object({ index: z.number(), - }), + }) }, }, }, schemas: { diff --git a/src/types.ts b/src/types.ts index 2967dbe..7c88a04 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,41 @@ export type AgentMessage = { [key: string]: unknown; }; +export type AgentTools = Record; + +export type AgentToolChoice = + | string + | number + | boolean + | null + | readonly unknown[] + | { [key: string]: unknown }; + +export type AgentResolverSnapshot< + TContext extends Record = Record, +> = Omit< + AgentState, + 'model' | 'prompt' | 'system' | 'tools' | 'toolChoice' +>; + +export type StateResolverArgs< + TContext extends Record, + TInput = Record, +> = { + snapshot: AgentResolverSnapshot; + context: TContext; + messages: AgentMessage[]; + input: TInput; +}; + +export type ResolvableStateValue< + TValue, + TContext extends Record, + TInput = Record, +> = + | TValue + | ((args: StateResolverArgs) => TValue); + export interface InvokeEnqueue { emit(part: EmittedPart): void; } @@ -55,6 +90,18 @@ export type { JournalEventRecord, PersistedSnapshot, RunStore } from './runtime/ // ─── Adapter ─── export interface AgentAdapter { + generateText?: (options: { + model?: string; + system?: string; + prompt?: string; + messages: AgentMessage[]; + tools?: AgentTools; + toolChoice?: unknown; + outputSchema?: StandardSchemaV1; + }) => Promise; +} + +export interface DecideAdapter { decide: (options: { model: string; prompt: string; @@ -109,26 +156,28 @@ export interface StateConfig< TTarget extends string = string, TInputByTarget extends Record = {}, > { - type?: 'final' | 'choice'; - inputSchema?: StandardSchemaV1; - resultSchema?: StandardSchemaV1; + type?: 'final'; + schemas?: { + input?: StandardSchemaV1; + output?: StandardSchemaV1; + }; invoke?: (args: { context: TContext; messages: AgentMessage[]; input: Record; signal?: AbortSignal; }, enq: InvokeEnqueue) => Promise; - onDone?: (args: { result: any; context: TContext; messages: AgentMessage[] }) => TransitionResult; + onDone?: (args: { output: any; context: TContext; messages: AgentMessage[] }) => TransitionResult; always?: TransitionResult | ((args: { context: TContext; messages: AgentMessage[]; input: Record }, enq: InvokeEnqueue) => TransitionResult); on?: Record | ((args: { event: any; context: TContext; messages: AgentMessage[] }, enq: InvokeEnqueue) => TransitionResult)>; events?: Record; output?: (args: { context: TContext; messages: AgentMessage[] }) => unknown; - // choice-specific - model?: string; + model?: ResolvableStateValue; adapter?: AgentAdapter; - prompt?: string | ((args: { context: TContext; messages: AgentMessage[]; input: Record }) => string); - options?: Record; - reasoning?: boolean; + prompt?: ResolvableStateValue; + system?: ResolvableStateValue; + tools?: ResolvableStateValue; + toolChoice?: ResolvableStateValue; } type OutputForState = TState extends { @@ -158,6 +207,11 @@ export interface AgentState< createdAt?: number; output?: TOutput; error?: unknown; + model?: string; + prompt?: string; + system?: string; + tools?: AgentTools; + toolChoice?: unknown; } // ─── Execute Result ─── @@ -320,6 +374,7 @@ export interface MachineConfig< context: (input: TInput) => TContext; messages?: AgentMessage[] | ((input: TInput) => AgentMessage[]); adapter?: AgentAdapter; + externalEvents?: readonly string[]; initial: | (keyof TStates & string) | ((args: { context: TContext }) => { target: keyof TStates & string; input?: Record }); @@ -339,7 +394,7 @@ export type DecideResultFor< export interface DecideOptions< TOptions extends Record = Record, > { - adapter?: AgentAdapter; + adapter?: DecideAdapter; model: string; prompt: string; options: TOptions; @@ -355,7 +410,7 @@ export interface ClassifyResultFor< export interface ClassifyOptions< TCategories extends Record = Record, > { - adapter?: AgentAdapter; + adapter?: DecideAdapter; model: string; prompt: string; into: TCategories; diff --git a/src/utils.ts b/src/utils.ts index b357bae..6984215 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ import type { AgentMessage, AgentState, + AgentToolChoice, InitialTransitionResult, MachineConfig, StandardSchemaResult, @@ -47,7 +48,7 @@ export function resolveStateConfig( /** Loose state config for internal runtime use */ export type StateConfigAny = { - type?: 'final' | 'choice'; + type?: 'final'; invoke?: ( args: { context: Record; @@ -56,16 +57,47 @@ export type StateConfigAny = { }, enq: { emit(part: { type: string; [key: string]: unknown }): void } ) => Promise; - onDone?: (args: { result: unknown; context: Record; messages: AgentMessage[] }) => TransitionResult; + onDone?: (args: { output: unknown; context: Record; messages: AgentMessage[] }) => TransitionResult; always?: TransitionResult | ((args: { context: Record; messages: AgentMessage[]; input: Record }, enq: { emit(part: { type: string; [key: string]: unknown }): void }) => TransitionResult); on?: Record; context: Record; messages: AgentMessage[] }, enq: { emit(part: { type: string; [key: string]: unknown }): void }) => TransitionResult)>; output?: (args: { context: Record; messages: AgentMessage[] }) => unknown; - resultSchema?: StandardSchemaV1; - model?: string; - adapter?: { decide: (...args: unknown[]) => Promise }; - prompt?: string | ((args: { context: Record; messages: AgentMessage[]; input: Record }) => string); - options?: Record; - reasoning?: boolean; + schemas?: { + input?: StandardSchemaV1; + output?: StandardSchemaV1; + }; + model?: string | ((args: { + snapshot: AgentState; + context: Record; + messages: AgentMessage[]; + input: Record; + }) => string); + adapter?: { + generateText?: (...args: unknown[]) => Promise; + }; + prompt?: string | ((args: { + snapshot: AgentState; + context: Record; + messages: AgentMessage[]; + input: Record; + }) => string); + system?: string | ((args: { + snapshot: AgentState; + context: Record; + messages: AgentMessage[]; + input: Record; + }) => string); + tools?: Record | ((args: { + snapshot: AgentState; + context: Record; + messages: AgentMessage[]; + input: Record; + }) => Record); + toolChoice?: AgentToolChoice | ((args: { + snapshot: AgentState; + context: Record; + messages: AgentMessage[]; + input: Record; + }) => unknown); events?: Record; }; diff --git a/src/xstate/index.test.ts b/src/xstate/index.test.ts index af49f28..ee15627 100644 --- a/src/xstate/index.test.ts +++ b/src/xstate/index.test.ts @@ -33,12 +33,11 @@ test('exports a serializable XState config for visualization', () => { }, }, working: { - inputSchema: z.object({ + schemas: { input: z.object({ index: z.number(), - }), - resultSchema: z.object({ + }), output: z.object({ ok: z.boolean(), - }), + }) }, invoke: async () => ({ ok: true }), onDone: () => ({ target: 'done', diff --git a/src/xstate/index.ts b/src/xstate/index.ts index 9d9b40c..32ffda8 100644 --- a/src/xstate/index.ts +++ b/src/xstate/index.ts @@ -77,10 +77,6 @@ export function toXStateVisualization(machine: AgentMachine): XStateMachineConfi } const meta: NonNullable['agent'] = {}; - if (stateConfig.type === 'choice') { - meta.type = 'choice'; - } - if (stateConfig.invoke) { meta.invoke = true; xstateState.invoke = { From 584f41b46e5a134110f44adf1e99e2deb97f4114 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 22 May 2026 17:36:39 -0400 Subject: [PATCH 35/50] Refocus agent runtime surface --- docs/crewai-parity.md | 4 +- docs/langgraph-parity.md | 16 +- examples/README.md | 19 +- examples/_run.ts | 18 +- examples/adapter.ts | 3 +- examples/ai-sdk.ts | 3 +- .../src/review-workflow-agent.ts | 3 +- examples/branching.ts | 3 +- examples/chatbot-messages.ts | 4 +- examples/chatbot.ts | 4 +- examples/classify.ts | 3 +- examples/cloudflare-agents.ts | 7 +- examples/cloudflare-durable-network.ts | 7 +- examples/cloudflare-durable-object.ts | 7 +- examples/conditional-subflow.ts | 7 +- examples/content-creator-flow.ts | 3 +- examples/customer-service-sim.ts | 3 +- examples/decide.ts | 3 +- examples/email-auto-responder-flow.ts | 4 +- examples/email.ts | 4 +- examples/error-retry.ts | 3 +- examples/hitl.ts | 4 +- examples/http-streaming-session.ts | 2 +- examples/index.ts | 1 + examples/joke.ts | 3 +- examples/jugs.ts | 3 +- examples/lead-score-flow.ts | 4 +- examples/map-reduce.ts | 3 +- examples/meeting-assistant-flow.ts | 3 +- examples/multi-agent-network.ts | 7 +- examples/newspaper.ts | 3 +- examples/next-ai-sdk-ui.ts | 3 +- examples/persistence.ts | 4 +- examples/persistent-multi-agent-network.ts | 8 +- examples/persistent-streaming.ts | 4 +- examples/persistent-supervisor.ts | 8 +- examples/plan-and-execute.ts | 3 +- examples/raffle.ts | 4 +- examples/rag.ts | 3 +- examples/react-agent-from-scratch.ts | 1 + examples/react-agent.ts | 6 +- examples/reflection.ts | 3 +- examples/rewoo.ts | 3 +- examples/river-crossing.ts | 3 +- examples/self-evaluation-loop-flow.ts | 3 +- examples/simple.ts | 3 +- examples/spec-agent-loop.ts | 4 +- examples/sql-agent.ts | 4 +- examples/subflow.ts | 5 +- examples/supervisor.ts | 3 +- examples/tool-calling.ts | 4 +- examples/tutor.ts | 3 +- examples/workflow-guardrails.ts | 3 +- examples/write-a-book-flow.ts | 3 +- package.json | 42 +--- readme.md | 42 ++-- scripts/agent-convert.ts | 1 - src/agent.test.ts | 111 ++++++--- src/ai-sdk/index.test.ts | 27 +++ src/ai-sdk/index.ts | 14 +- .../content-creator-flow.test.ts | 5 +- .../lead-score-flow.test.ts | 9 +- .../meeting-assistant-flow.test.ts | 5 +- .../self-evaluation-loop-flow.test.ts | 5 +- .../write-a-book-flow.test.ts | 5 +- src/examples.test.ts | 216 ++++++++++++++---- src/http/index.ts | 4 +- src/index.ts | 3 - src/invoke-events.test.ts | 5 +- src/langgraph-equivalents/branching.test.ts | 5 +- .../chatbot-messages.test.ts | 7 +- .../conditional-subflow.test.ts | 7 +- src/langgraph-equivalents/error-retry.test.ts | 9 +- src/langgraph-equivalents/graph.test.ts | 7 +- src/langgraph-equivalents/hitl.test.ts | 9 +- src/langgraph-equivalents/map-reduce.test.ts | 5 +- .../multi-agent-network.test.ts | 5 +- src/langgraph-equivalents/persistence.test.ts | 6 +- .../plan-and-execute.test.ts | 5 +- .../prebuilt-react.test.ts | 7 +- src/langgraph-equivalents/rag.test.ts | 5 +- src/langgraph-equivalents/reflection.test.ts | 5 +- src/langgraph-equivalents/rewoo.test.ts | 5 +- src/langgraph-equivalents/sql-agent.test.ts | 2 +- src/langgraph-equivalents/streaming.test.ts | 5 +- src/langgraph-equivalents/subflow.test.ts | 7 +- src/langgraph-equivalents/supervisor.test.ts | 5 +- .../tool-calling.test.ts | 5 +- src/local/index.ts | 4 + src/local/interpreter.ts | 65 ++++++ src/machine.ts | 6 + src/persistence.test.ts | 2 +- src/restore.test.ts | 6 +- src/runtime/index.test.ts | 7 +- src/runtime/session.ts | 3 +- src/session-runtime.test.ts | 5 +- src/stream-snapshot.test.ts | 5 +- src/streaming.test.ts | 5 +- src/target-types.assert.ts | 3 +- src/types.ts | 18 +- tsdown.config.ts | 5 +- 101 files changed, 612 insertions(+), 376 deletions(-) create mode 100644 src/local/index.ts create mode 100644 src/local/interpreter.ts diff --git a/docs/crewai-parity.md b/docs/crewai-parity.md index 3514e24..b2f2555 100644 --- a/docs/crewai-parity.md +++ b/docs/crewai-parity.md @@ -7,7 +7,7 @@ This document tracks where `@statelyai/agent` covers the practical workflow patt It is intentionally scoped to: - runnable workflow patterns -- state/routing/runtime behavior +- state/routing authoring behavior - human-in-the-loop and iteration behavior - examples and tests in this repo @@ -55,5 +55,5 @@ Primary sources: ## Differences - Logic remains explicit state-machine logic instead of CrewAI decorator-based method routing. -- Durable sessions are modeled through first-class snapshots and event journals rather than framework-managed persistence hidden behind class methods. +- Session contracts expose snapshots and event journals; production persistence belongs in adapters. - Fan-out is expressed in plain JavaScript `Promise.all(...)` inside invokes where that is simpler than introducing framework-specific branching primitives. diff --git a/docs/langgraph-parity.md b/docs/langgraph-parity.md index 00f04f1..06ceaa6 100644 --- a/docs/langgraph-parity.md +++ b/docs/langgraph-parity.md @@ -2,13 +2,13 @@ ## Scope -This document tracks where `@statelyai/agent` currently matches the practical end result of `langchain-ai/langgraphjs` for core workflow/runtime behavior. +This document tracks where authored `@statelyai/agent` machines can model the practical end result of `langchain-ai/langgraphjs` examples. It is intentionally scoped to: -- core orchestration concepts -- durable session behavior -- streaming/runtime transport behavior +- state-machine authoring concepts +- local session contract behavior +- adapter and transport example behavior - runnable examples and tests in this repo It is intentionally not scoped to: @@ -25,7 +25,7 @@ As of April 25, 2026, the upstream `langgraphjs` repo exposes: - core packages under [`libs/`](https://github.com/langchain-ai/langgraphjs/tree/main/libs), including `langgraph`, `langgraph-core`, checkpoint packages, supervisor/swarm helpers, SDKs, and UI packages - runnable examples under [`examples/`](https://github.com/langchain-ai/langgraphjs/tree/main/examples), including quickstart, plan-and-execute, reflection, rewoo, SQL agent, multi-agent, chatbots, RAG, and UI transport examples -The parity target here is the core graph/runtime layer, not the whole surrounding product/package ecosystem. +The parity target here is authoring semantics and adapter targets, not a replacement runtime or the whole surrounding product/package ecosystem. ## Matrix @@ -36,7 +36,7 @@ The parity target here is the core graph/runtime layer, not the whole surroundin | Branching / conditional routing | Covered | [`examples/branching.ts`](/Users/davidkpiano/Code/agent/examples/branching.ts), [`src/langgraph-equivalents/branching.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/branching.test.ts) | | Subgraphs / nested flows | Covered | [`examples/subflow.ts`](/Users/davidkpiano/Code/agent/examples/subflow.ts), [`examples/conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts), [`src/langgraph-equivalents/subflow.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/subflow.test.ts), [`src/langgraph-equivalents/conditional-subflow.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/conditional-subflow.test.ts) | | Human-in-the-loop / approval gate | Covered | [`examples/hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts), [`src/langgraph-equivalents/hitl.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/hitl.test.ts) | -| Durable sessions / restore from snapshots + events | Covered | [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts), [`src/langgraph-equivalents/persistence.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistence.test.ts) | +| Session restore from snapshots + events | Local adapter | [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts), [`src/langgraph-equivalents/persistence.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistence.test.ts) | | Streaming emitted parts | Covered | [`examples/persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts), [`src/langgraph-equivalents/streaming.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/streaming.test.ts), [`src/langgraph-equivalents/persistent-streaming.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistent-streaming.test.ts) | | Tool calling with intermediate progress | Covered | [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts), [`src/langgraph-equivalents/tool-calling.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/tool-calling.test.ts) | | Retry loops / explicit recovery | Covered | [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts), [`src/langgraph-equivalents/error-retry.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/error-retry.test.ts) | @@ -51,7 +51,7 @@ The parity target here is the core graph/runtime layer, not the whole surroundin | Message-centric chatbot state | Covered | [`examples/chatbot-messages.ts`](/Users/davidkpiano/Code/agent/examples/chatbot-messages.ts), [`src/langgraph-equivalents/chatbot-messages.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/chatbot-messages.test.ts) | | Retrieval-augmented generation | Covered | [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts), [`src/langgraph-equivalents/rag.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/rag.test.ts) | | HTTP session transport | Covered | [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts), [`src/examples.test.ts`](/Users/davidkpiano/Code/agent/src/examples.test.ts) | -| Durable HTTP streaming transport / reconnect | Covered | [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts), [`src/examples.test.ts`](/Users/davidkpiano/Code/agent/src/examples.test.ts) | +| HTTP streaming transport / reconnect | Adapter example | [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts), [`src/examples.test.ts`](/Users/davidkpiano/Code/agent/src/examples.test.ts) | | Graph export / visualization support | Covered | [`src/graph/index.ts`](/Users/davidkpiano/Code/agent/src/graph/index.ts), [`src/xstate/index.ts`](/Users/davidkpiano/Code/agent/src/xstate/index.ts), [`src/langgraph-equivalents/graph.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/graph.test.ts) | ## Intentional differences @@ -60,7 +60,7 @@ These are currently deliberate, not gaps: - Logic stays pure: `(state, event) -> { nextState, effects }`. - Emitted events are live runtime effects, not durable journal entries. -- Durable behavior is based on first-class snapshot + event persistence rather than in-memory graph execution with optional add-ons. +- Session behavior is based on first-class snapshot + event contracts; production durability belongs in adapters. - `run.on(...)` is reserved for emitted events only; terminal/runtime hooks use dedicated methods like `run.onDone(...)`. - Parallelism is expected to be expressed in plain JavaScript where possible, rather than forcing a dedicated graph primitive when `Promise.all(...)` is enough. diff --git a/examples/README.md b/examples/README.md index 09f186e..01aa67e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,9 +7,9 @@ This directory is organized by what a developer is trying to do, not by the unde ## Start Here - Building an app route: [`apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next) or [`apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents) -- Adding durable sessions: [`persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts) and [`http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts) +- Trying local sessions: [`persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts) and [`http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts) - Streaming text or tool progress: [`next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts), [`http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts), and [`tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts) -- Studying orchestration patterns: start in `Workflow Examples` +- Studying state-machine workflow patterns: start in `Workflow Examples` ## App-Shaped Examples @@ -18,15 +18,15 @@ These are the best starting points when you want code that already looks like a - [`apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next): copy-paste Next.js App Router routes - [`apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents): copy-paste Cloudflare Agents Worker layout - [`next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts): AI SDK UI route helper -- [`next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts): App Router session routes backed by `@statelyai/agent/next` and `@statelyai/agent/http` -- [`cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): Node-safe Cloudflare Agents example backed by `@statelyai/agent/cloudflare` +- [`next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts): App Router session-route preview code +- [`cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): Cloudflare Agents adapter preview code ## Workflow Examples -These focus on real orchestration patterns: +These focus on state-machine workflow patterns: - Session-first interactive workflows -- Durable restore and transport patterns +- Local restore and transport patterns - Multi-step planning, routing, and handoff flows - [`persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts) @@ -47,26 +47,27 @@ These focus on real orchestration patterns: - [`rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts) - [`sql-agent.ts`](/Users/davidkpiano/Code/agent/examples/sql-agent.ts) -## Runtime / Transport Examples +## Local / Transport Examples - [`http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts) - [`http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts) - [`cloudflare-durable-object.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-object.ts) - [`cloudflare-durable-network.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-network.ts) -The reusable pieces behind these examples are exported from `@statelyai/agent/http`, `@statelyai/agent/next`, and `@statelyai/agent/cloudflare`. +The reusable local pieces behind these examples are exported from `@statelyai/agent/local`. Framework-specific adapters should move to separate packages. ## Reference / Concept Examples These are smaller building-block examples: - One-shot machine execution: [`simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts), [`decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts), [`classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts) -- Interactive session lifecycle: [`chatbot.ts`](/Users/davidkpiano/Code/agent/examples/chatbot.ts), [`chatbot-messages.ts`](/Users/davidkpiano/Code/agent/examples/chatbot-messages.ts), [`hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts), [`raffle.ts`](/Users/davidkpiano/Code/agent/examples/raffle.ts) +- Interactive session lifecycle: [`chatbot.ts`](/Users/davidkpiano/Code/agent/examples/chatbot.ts), [`chatbot-messages.ts`](/Users/davidkpiano/Code/agent/examples/chatbot-messages.ts), [`email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/email-drafter.ts), [`hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts), [`raffle.ts`](/Users/davidkpiano/Code/agent/examples/raffle.ts) - [`simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts) - [`decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts) - [`classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts) - [`adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts) +- [`email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/email-drafter.ts) - [`tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts) - [`hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts) - [`branching.ts`](/Users/davidkpiano/Code/agent/examples/branching.ts) diff --git a/examples/_run.ts b/examples/_run.ts index f632bf0..05edd02 100644 --- a/examples/_run.ts +++ b/examples/_run.ts @@ -1,5 +1,3 @@ -import 'dotenv/config'; - import { generateText, Output } from 'ai'; import { openai } from '@ai-sdk/openai'; import { createInterface } from 'node:readline/promises'; @@ -12,7 +10,7 @@ import type { ExecuteResult, StandardSchemaV1, } from '../src/index.js'; -export { waitForRunDone, waitForRunSnapshot } from '../src/runtime/index.js'; +export { waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; export function isMain(moduleUrl: string): boolean { const entry = process.argv[1]; @@ -177,11 +175,9 @@ export function createOpenAiDecisionAdapter(): DecideAdapter { export function createOpenAiGenerationAdapter(): AgentAdapter { return { async generateText({ model, system, prompt, messages, outputSchema }) { - const result = await generateText({ + const options: any = { model: createExampleModel(model), system, - prompt, - messages: messages as any, ...(outputSchema ? { output: Output.object({ @@ -189,7 +185,15 @@ export function createOpenAiGenerationAdapter(): AgentAdapter { }), } : {}), - }); + }; + + if (messages.length > 0) { + options.messages = messages as any; + } else { + options.prompt = prompt ?? ''; + } + + const result = await generateText(options); const output = result as { output?: unknown; text?: string }; return output.output ?? output.text ?? result; diff --git a/examples/adapter.ts b/examples/adapter.ts index 02f368a..7b68646 100644 --- a/examples/adapter.ts +++ b/examples/adapter.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine, decide, @@ -85,7 +86,7 @@ async function main() { const message = await prompt('Message to route'); const machine = createAdapterExample(); - console.log(formatResult(await machine.execute(machine.getInitialState({ message })))); + console.log(formatResult(await execute(machine, machine.getInitialState({ message })))); } finally { closePrompt(); } diff --git a/examples/ai-sdk.ts b/examples/ai-sdk.ts index b83b170..9d32355 100644 --- a/examples/ai-sdk.ts +++ b/examples/ai-sdk.ts @@ -1,4 +1,5 @@ import { generateText, Output } from 'ai'; +import { execute } from '../src/local/index.js'; import { z } from 'zod'; import { createAgentMachine, @@ -153,7 +154,7 @@ async function main() { try { const message = await prompt('Customer message'); const machine = createAiSdkExample(); - const result = await machine.execute(machine.getInitialState({ message })); + const result = await execute(machine, machine.getInitialState({ message })); console.log(formatResult(result)); } finally { diff --git a/examples/apps/cloudflare-agents/src/review-workflow-agent.ts b/examples/apps/cloudflare-agents/src/review-workflow-agent.ts index d64d72c..ae26db3 100644 --- a/examples/apps/cloudflare-agents/src/review-workflow-agent.ts +++ b/examples/apps/cloudflare-agents/src/review-workflow-agent.ts @@ -1,5 +1,6 @@ import { Agent } from 'agents'; -import { restoreSession, startSession, type RunStore } from '../../../../src/index.js'; +import type { RunStore } from '../../../../src/index.js'; +import { restoreSession, startSession } from '../../../../src/local/index.js'; import { createCloudflareAgentRunStore, type CloudflareAgentRunStoreState, diff --git a/examples/branching.ts b/examples/branching.ts index 3f89c88..9a6e03a 100644 --- a/examples/branching.ts +++ b/examples/branching.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -126,7 +127,7 @@ async function main() { try { const topic = await prompt('Topic'); const machine = createBranchingExample(); - const result = await machine.execute(machine.getInitialState({ topic })); + const result = await execute(machine, machine.getInitialState({ topic })); console.log(formatResult(result)); } finally { closePrompt(); diff --git a/examples/chatbot-messages.ts b/examples/chatbot-messages.ts index fa6e2ca..f13529f 100644 --- a/examples/chatbot-messages.ts +++ b/examples/chatbot-messages.ts @@ -1,8 +1,7 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createAgentMachine, - createMemoryRunStore, - startSession, type AgentMessage, } from '../src/index.js'; import { @@ -10,7 +9,6 @@ import { generateExampleObject, isMain, prompt, - waitForRunSnapshot, } from './_run.js'; const messageSchema = z.object({ diff --git a/examples/chatbot.ts b/examples/chatbot.ts index b062664..455a160 100644 --- a/examples/chatbot.ts +++ b/examples/chatbot.ts @@ -1,10 +1,9 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createAgentMachine, - createMemoryRunStore, decide, decideResultSchema, - startSession, type DecideAdapter, } from '../src/index.js'; import { @@ -13,7 +12,6 @@ import { generateExampleObject, isMain, prompt, - waitForRunSnapshot, } from './_run.js'; const replySchema = z.object({ diff --git a/examples/classify.ts b/examples/classify.ts index dcbc96a..a34a3bf 100644 --- a/examples/classify.ts +++ b/examples/classify.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine, classify, @@ -62,7 +63,7 @@ async function main() { const request = await prompt('Support request'); const machine = createClassifyExample(); - console.log(formatResult(await machine.execute(machine.getInitialState({ request })))); + console.log(formatResult(await execute(machine, machine.getInitialState({ request })))); } finally { closePrompt(); } diff --git a/examples/cloudflare-agents.ts b/examples/cloudflare-agents.ts index 2b6cbdc..39827c9 100644 --- a/examples/cloudflare-agents.ts +++ b/examples/cloudflare-agents.ts @@ -1,8 +1,5 @@ -import { - restoreSession, - startSession, - type RunStore, -} from '../src/index.js'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; +import type { RunStore } from '../src/index.js'; import { createCloudflareAgentRunStore, type CloudflareAgentRunStoreState, diff --git a/examples/cloudflare-durable-network.ts b/examples/cloudflare-durable-network.ts index bca3d33..9705944 100644 --- a/examples/cloudflare-durable-network.ts +++ b/examples/cloudflare-durable-network.ts @@ -1,8 +1,5 @@ -import { - restoreSession, - startSession, - type AgentSnapshot, -} from '../src/index.js'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; +import type { AgentSnapshot } from '../src/index.js'; import { createDurableObjectRunStore, type DurableObjectStateLike, diff --git a/examples/cloudflare-durable-object.ts b/examples/cloudflare-durable-object.ts index 23a040b..e317ad7 100644 --- a/examples/cloudflare-durable-object.ts +++ b/examples/cloudflare-durable-object.ts @@ -1,8 +1,5 @@ -import { - restoreSession, - startSession, - type RunStore, -} from '../src/index.js'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; +import type { RunStore } from '../src/index.js'; import { createDurableObjectRunStore, type DurableObjectStateLike, diff --git a/examples/conditional-subflow.ts b/examples/conditional-subflow.ts index 391adbc..f718e6e 100644 --- a/examples/conditional-subflow.ts +++ b/examples/conditional-subflow.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -134,7 +135,7 @@ export function createConditionalSubflowExample( researching: { schemas: { output: researchSchema }, invoke: async ({ context }) => { - const result = await researchMachine.execute( + const result = await execute(researchMachine, researchMachine.getInitialState({ topic: context.topic }) ); @@ -154,7 +155,7 @@ export function createConditionalSubflowExample( bullets: z.array(z.string()), }), output: draftSchema }, invoke: async ({ context, input }) => { - const result = await draftMachine.execute( + const result = await execute(draftMachine, draftMachine.getInitialState({ topic: context.topic, bullets: input.bullets, @@ -190,7 +191,7 @@ async function main() { const modeInput = await prompt('Mode (research/draft)'); const mode = modeInput === 'draft' ? 'draft' : 'research'; const machine = createConditionalSubflowExample(); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic, mode }) ); diff --git a/examples/content-creator-flow.ts b/examples/content-creator-flow.ts index b6d579d..88c103d 100644 --- a/examples/content-creator-flow.ts +++ b/examples/content-creator-flow.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -129,7 +130,7 @@ async function main() { try { const request = await prompt('Content request'); const machine = createContentCreatorFlowExample(); - const result = await machine.execute(machine.getInitialState({ request })); + const result = await execute(machine, machine.getInitialState({ request })); console.log(formatResult(result)); } finally { closePrompt(); diff --git a/examples/customer-service-sim.ts b/examples/customer-service-sim.ts index a7f1e2a..4ae1c3d 100644 --- a/examples/customer-service-sim.ts +++ b/examples/customer-service-sim.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -128,7 +129,7 @@ async function main() { try { const issue = await prompt('Customer issue'); const machine = createCustomerServiceSimExample(); - console.log(formatResult(await machine.execute(machine.getInitialState({ issue })))); + console.log(formatResult(await execute(machine, machine.getInitialState({ issue })))); } finally { closePrompt(); } diff --git a/examples/decide.ts b/examples/decide.ts index a484150..747eaa9 100644 --- a/examples/decide.ts +++ b/examples/decide.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine, decide, @@ -83,7 +84,7 @@ async function main() { const request = await prompt('Support request'); const machine = createDecideExample(); - console.log(formatResult(await machine.execute(machine.getInitialState({ request })))); + console.log(formatResult(await execute(machine, machine.getInitialState({ request })))); } finally { closePrompt(); } diff --git a/examples/email-auto-responder-flow.ts b/examples/email-auto-responder-flow.ts index 2d1bf85..48df662 100644 --- a/examples/email-auto-responder-flow.ts +++ b/examples/email-auto-responder-flow.ts @@ -1,9 +1,7 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createAgentMachine, - createMemoryRunStore, - restoreSession, - startSession, type RunStore, } from '../src/index.js'; import { diff --git a/examples/email.ts b/examples/email.ts index 391b753..5717666 100644 --- a/examples/email.ts +++ b/examples/email.ts @@ -1,10 +1,9 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createAgentMachine, - createMemoryRunStore, decide, decideResultSchema, - startSession, type DecideAdapter, } from '../src/index.js'; import { @@ -14,7 +13,6 @@ import { generateExampleText, isMain, prompt, - waitForRunSnapshot, } from './_run.js'; const draftSchema = z.object({ diff --git a/examples/error-retry.ts b/examples/error-retry.ts index ce422f6..838a7fe 100644 --- a/examples/error-retry.ts +++ b/examples/error-retry.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -121,7 +122,7 @@ async function main() { try { const question = await prompt('Question'); const machine = createErrorRetryExample(); - const result = await machine.execute(machine.getInitialState({ question })); + const result = await execute(machine, machine.getInitialState({ question })); console.log(formatResult(result)); } finally { diff --git a/examples/hitl.ts b/examples/hitl.ts index 9d80bf2..c54f17c 100644 --- a/examples/hitl.ts +++ b/examples/hitl.ts @@ -1,8 +1,7 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createAgentMachine, - createMemoryRunStore, - startSession, type AgentMessage, } from '../src/index.js'; import { @@ -10,7 +9,6 @@ import { generateExampleObject, isMain, prompt, - waitForRunSnapshot, } from './_run.js'; const draftSchema = z.object({ diff --git a/examples/http-streaming-session.ts b/examples/http-streaming-session.ts index 2f2a394..3da5503 100644 --- a/examples/http-streaming-session.ts +++ b/examples/http-streaming-session.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createSessionHttpController } from '../src/http/index.js'; import { createAgentMachine, - createMemoryRunStore, type RunStore, } from '../src/index.js'; diff --git a/examples/index.ts b/examples/index.ts index c73aafb..f00f7f2 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -82,6 +82,7 @@ export { createClassifyExample } from './classify.js'; export { createConditionalSubflowExample } from './conditional-subflow.js'; export { createCustomerServiceSimExample } from './customer-service-sim.js'; export { createDecideExample } from './decide.js'; +export { createEmailDrafterExample } from './email-drafter.js'; export { createEmailExample } from './email.js'; export { createHitlExample } from './hitl.js'; export { createJokeExample } from './joke.js'; diff --git a/examples/joke.ts b/examples/joke.ts index 012bcf1..4aeac6f 100644 --- a/examples/joke.ts +++ b/examples/joke.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine, type AgentAdapter } from '../src/index.js'; import { closePrompt, @@ -88,7 +89,7 @@ async function main() { try { const topic = await prompt('Joke topic'); const machine = createJokeExample(); - console.log(formatResult(await machine.execute(machine.getInitialState({ topic })))); + console.log(formatResult(await execute(machine, machine.getInitialState({ topic })))); } finally { closePrompt(); } diff --git a/examples/jugs.ts b/examples/jugs.ts index dfce867..0e5e900 100644 --- a/examples/jugs.ts +++ b/examples/jugs.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { formatResult, isMain } from './_run.js'; @@ -126,7 +127,7 @@ export function createJugsExample() { async function main() { const machine = createJugsExample(); - console.log(formatResult(await machine.execute(machine.getInitialState()))); + console.log(formatResult(await execute(machine, machine.getInitialState()))); } if (isMain(import.meta.url)) { diff --git a/examples/lead-score-flow.ts b/examples/lead-score-flow.ts index c0c6879..b6336c1 100644 --- a/examples/lead-score-flow.ts +++ b/examples/lead-score-flow.ts @@ -1,14 +1,12 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createAgentMachine, - createMemoryRunStore, - startSession, } from '../src/index.js'; import { closePrompt, isMain, prompt, - waitForRunSnapshot, } from './_run.js'; const leadSchema = z.object({ diff --git a/examples/map-reduce.ts b/examples/map-reduce.ts index 25abe38..2c5610e 100644 --- a/examples/map-reduce.ts +++ b/examples/map-reduce.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -113,7 +114,7 @@ async function main() { try { const topic = await prompt('Topic'); const machine = createMapReduceExample(); - const result = await machine.execute(machine.getInitialState({ topic })); + const result = await execute(machine, machine.getInitialState({ topic })); console.log(formatResult(result)); } finally { closePrompt(); diff --git a/examples/meeting-assistant-flow.ts b/examples/meeting-assistant-flow.ts index ae8171e..97e726e 100644 --- a/examples/meeting-assistant-flow.ts +++ b/examples/meeting-assistant-flow.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -140,7 +141,7 @@ async function main() { try { const notes = await prompt('Meeting notes'); const machine = createMeetingAssistantFlowExample(); - const result = await machine.execute(machine.getInitialState({ notes })); + const result = await execute(machine, machine.getInitialState({ notes })); console.log(formatResult(result)); } finally { closePrompt(); diff --git a/examples/multi-agent-network.ts b/examples/multi-agent-network.ts index da5533d..176ed80 100644 --- a/examples/multi-agent-network.ts +++ b/examples/multi-agent-network.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine, decide, @@ -251,7 +252,7 @@ export function createMultiAgentNetworkExample( researching: { schemas: { input: researchParamsSchema, output: researchHandoffSchema }, invoke: async ({ context, input }) => { - const result = await researchAgent.execute( + const result = await execute(researchAgent, researchAgent.getInitialState({ topic: context.topic, focus: input.focus, @@ -278,7 +279,7 @@ export function createMultiAgentNetworkExample( writing: { schemas: { input: writeParamsSchema, output: draftHandoffSchema }, invoke: async ({ context, input }) => { - const result = await writerAgent.execute( + const result = await execute(writerAgent, writerAgent.getInitialState({ topic: context.topic, notes: context.notes, @@ -320,7 +321,7 @@ async function main() { try { const topic = await prompt('Topic'); const machine = createMultiAgentNetworkExample(); - const result = await machine.execute(machine.getInitialState({ topic })); + const result = await execute(machine, machine.getInitialState({ topic })); console.log(formatResult(result)); } finally { closePrompt(); diff --git a/examples/newspaper.ts b/examples/newspaper.ts index 7734598..3dd0902 100644 --- a/examples/newspaper.ts +++ b/examples/newspaper.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -175,7 +176,7 @@ async function main() { try { const topic = await prompt('Newspaper topic'); const machine = createNewspaperExample(); - console.log(formatResult(await machine.execute(machine.getInitialState({ topic })))); + console.log(formatResult(await execute(machine, machine.getInitialState({ topic })))); } finally { closePrompt(); } diff --git a/examples/next-ai-sdk-ui.ts b/examples/next-ai-sdk-ui.ts index ccc2a31..8b10f3e 100644 --- a/examples/next-ai-sdk-ui.ts +++ b/examples/next-ai-sdk-ui.ts @@ -1,3 +1,4 @@ +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { convertToModelMessages, createUIMessageStream, @@ -8,8 +9,6 @@ import { import { z } from 'zod'; import { createAgentMachine, - createMemoryRunStore, - startSession, } from '../src/index.js'; import { createExampleModel } from './_run.js'; diff --git a/examples/persistence.ts b/examples/persistence.ts index f43c0db..78dcd3e 100644 --- a/examples/persistence.ts +++ b/examples/persistence.ts @@ -1,9 +1,7 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createAgentMachine, - createMemoryRunStore, - restoreSession, - startSession, } from '../src/index.js'; import { closePrompt, diff --git a/examples/persistent-multi-agent-network.ts b/examples/persistent-multi-agent-network.ts index f3f12b6..8a21deb 100644 --- a/examples/persistent-multi-agent-network.ts +++ b/examples/persistent-multi-agent-network.ts @@ -1,9 +1,5 @@ -import { - createMemoryRunStore, - restoreSession, - startSession, - type PersistedSnapshot, -} from '../src/index.js'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; +import type { PersistedSnapshot } from '../src/index.js'; import { createMultiAgentNetworkExample } from './multi-agent-network.js'; type NetworkOptions = Parameters[0]; diff --git a/examples/persistent-streaming.ts b/examples/persistent-streaming.ts index f5f158f..d36b441 100644 --- a/examples/persistent-streaming.ts +++ b/examples/persistent-streaming.ts @@ -1,9 +1,7 @@ +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { z } from 'zod'; import { createAgentMachine, - createMemoryRunStore, - restoreSession, - startSession, } from '../src/index.js'; const textSchema = z.object({ diff --git a/examples/persistent-supervisor.ts b/examples/persistent-supervisor.ts index 9bbd343..812f1c1 100644 --- a/examples/persistent-supervisor.ts +++ b/examples/persistent-supervisor.ts @@ -1,9 +1,5 @@ -import { - createMemoryRunStore, - restoreSession, - startSession, - type PersistedSnapshot, -} from '../src/index.js'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; +import type { PersistedSnapshot } from '../src/index.js'; import { createSupervisorExample } from './supervisor.js'; type SupervisorOptions = Parameters[0]; diff --git a/examples/plan-and-execute.ts b/examples/plan-and-execute.ts index 49e97bc..6ce1249 100644 --- a/examples/plan-and-execute.ts +++ b/examples/plan-and-execute.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -164,7 +165,7 @@ async function main() { try { const goal = await prompt('Goal'); const machine = createPlanAndExecuteExample(); - console.log(formatResult(await machine.execute(machine.getInitialState({ goal })))); + console.log(formatResult(await execute(machine, machine.getInitialState({ goal })))); } finally { closePrompt(); } diff --git a/examples/raffle.ts b/examples/raffle.ts index b052784..0672290 100644 --- a/examples/raffle.ts +++ b/examples/raffle.ts @@ -1,15 +1,13 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createAgentMachine, - createMemoryRunStore, - startSession, } from '../src/index.js'; import { closePrompt, generateExampleObject, isMain, prompt, - waitForRunSnapshot, } from './_run.js'; const winnerSchema = z.object({ diff --git a/examples/rag.ts b/examples/rag.ts index 08e4376..91709f3 100644 --- a/examples/rag.ts +++ b/examples/rag.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine, type AgentAdapter } from '../src/index.js'; import { closePrompt, @@ -101,7 +102,7 @@ async function main() { try { const question = await prompt('Question'); const machine = createRagExample(); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ question }) ); diff --git a/examples/react-agent-from-scratch.ts b/examples/react-agent-from-scratch.ts index c0b22c0..0592005 100644 --- a/examples/react-agent-from-scratch.ts +++ b/examples/react-agent-from-scratch.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine, type StandardSchemaV1 } from '../src/index.js'; const messageSchema = z.object({ diff --git a/examples/react-agent.ts b/examples/react-agent.ts index 8e20a84..e13e88f 100644 --- a/examples/react-agent.ts +++ b/examples/react-agent.ts @@ -1,8 +1,5 @@ import { z } from 'zod'; -import { - createMemoryRunStore, - startSession, -} from '../src/index.js'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createReactAgentFromScratch } from './react-agent-from-scratch.js'; import { closePrompt, @@ -10,7 +7,6 @@ import { generateExampleText, isMain, prompt, - waitForRunDone, } from './_run.js'; const reactModelResultSchema = z.discriminatedUnion('kind', [ diff --git a/examples/reflection.ts b/examples/reflection.ts index f7f5ee7..d5632a5 100644 --- a/examples/reflection.ts +++ b/examples/reflection.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -150,7 +151,7 @@ async function main() { try { const task = await prompt('Task'); const machine = createReflectionExample(); - console.log(formatResult(await machine.execute(machine.getInitialState({ task })))); + console.log(formatResult(await execute(machine, machine.getInitialState({ task })))); } finally { closePrompt(); } diff --git a/examples/rewoo.ts b/examples/rewoo.ts index 45f8547..18134f1 100644 --- a/examples/rewoo.ts +++ b/examples/rewoo.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -222,7 +223,7 @@ async function main() { try { const objective = await prompt('Objective'); const machine = createRewooExample(); - const result = await machine.execute(machine.getInitialState({ objective })); + const result = await execute(machine, machine.getInitialState({ objective })); console.log(formatResult(result)); } finally { closePrompt(); diff --git a/examples/river-crossing.ts b/examples/river-crossing.ts index 497c2e5..54fe0ab 100644 --- a/examples/river-crossing.ts +++ b/examples/river-crossing.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { formatResult, isMain } from './_run.js'; @@ -171,7 +172,7 @@ export function createRiverCrossingExample() { async function main() { const machine = createRiverCrossingExample(); - console.log(formatResult(await machine.execute(machine.getInitialState()))); + console.log(formatResult(await execute(machine, machine.getInitialState()))); } if (isMain(import.meta.url)) { diff --git a/examples/self-evaluation-loop-flow.ts b/examples/self-evaluation-loop-flow.ts index 130e6eb..1cb316f 100644 --- a/examples/self-evaluation-loop-flow.ts +++ b/examples/self-evaluation-loop-flow.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -121,7 +122,7 @@ async function main() { try { const topic = await prompt('Topic'); const machine = createSelfEvaluationLoopFlowExample(); - const result = await machine.execute(machine.getInitialState({ topic })); + const result = await execute(machine, machine.getInitialState({ topic })); console.log(formatResult(result)); } finally { closePrompt(); diff --git a/examples/simple.ts b/examples/simple.ts index ef4e86d..f1ab17a 100644 --- a/examples/simple.ts +++ b/examples/simple.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine, type AgentAdapter } from '../src/index.js'; import { closePrompt, @@ -49,7 +50,7 @@ async function main() { try { const text = await prompt('Text to summarize'); const machine = createSimpleExample(); - const result = await machine.execute(machine.getInitialState({ text })); + const result = await execute(machine, machine.getInitialState({ text })); console.log(formatResult(result)); } finally { diff --git a/examples/spec-agent-loop.ts b/examples/spec-agent-loop.ts index 2968cb4..8a24778 100644 --- a/examples/spec-agent-loop.ts +++ b/examples/spec-agent-loop.ts @@ -1,10 +1,9 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { appendMessages, assistantMessage, createAgentMachine, - createMemoryRunStore, - startSession, userMessage, } from '../src/index.js'; import { @@ -12,7 +11,6 @@ import { generateExampleText, isMain, prompt, - waitForRunSnapshot, } from './_run.js'; const generationSchema = z.object({ diff --git a/examples/sql-agent.ts b/examples/sql-agent.ts index e5f35f3..db3b263 100644 --- a/examples/sql-agent.ts +++ b/examples/sql-agent.ts @@ -1,10 +1,9 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createAgentMachine, - createMemoryRunStore, decide, decideResultSchema, - startSession, type DecideAdapter, } from '../src/index.js'; import { @@ -13,7 +12,6 @@ import { generateExampleObject, isMain, prompt, - waitForRunDone, } from './_run.js'; const sqlValueSchema = z.union([z.string(), z.number(), z.null()]); diff --git a/examples/subflow.ts b/examples/subflow.ts index cb6639c..4034f85 100644 --- a/examples/subflow.ts +++ b/examples/subflow.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -78,7 +79,7 @@ export function createSubflowExample( researching: { schemas: { output: researchSchema }, invoke: async ({ context }) => { - const result = await childMachine.execute( + const result = await execute(childMachine, childMachine.getInitialState({ topic: context.topic }) ); @@ -132,7 +133,7 @@ async function main() { try { const topic = await prompt('Topic'); const machine = createSubflowExample(); - const result = await machine.execute(machine.getInitialState({ topic })); + const result = await execute(machine, machine.getInitialState({ topic })); console.log(formatResult(result)); } finally { closePrompt(); diff --git a/examples/supervisor.ts b/examples/supervisor.ts index d54887b..67087ef 100644 --- a/examples/supervisor.ts +++ b/examples/supervisor.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine, decide, @@ -239,7 +240,7 @@ async function main() { const request = await prompt('Request'); const machine = createSupervisorExample(); console.log( - formatResult(await machine.execute(machine.getInitialState({ request }))) + formatResult(await execute(machine, machine.getInitialState({ request }))) ); } finally { closePrompt(); diff --git a/examples/tool-calling.ts b/examples/tool-calling.ts index 2753b17..f1539e2 100644 --- a/examples/tool-calling.ts +++ b/examples/tool-calling.ts @@ -1,15 +1,13 @@ import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; import { createAgentMachine, - createMemoryRunStore, - startSession, } from '../src/index.js'; import { closePrompt, generateExampleObject, isMain, prompt, - waitForRunDone, } from './_run.js'; const forecastSchema = z.object({ diff --git a/examples/tutor.ts b/examples/tutor.ts index 9ef2009..fa04ca4 100644 --- a/examples/tutor.ts +++ b/examples/tutor.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -93,7 +94,7 @@ async function main() { try { const message = await prompt('Say something in Spanish'); const machine = createTutorExample(); - console.log(formatResult(await machine.execute(machine.getInitialState({ message })))); + console.log(formatResult(await execute(machine, machine.getInitialState({ message })))); } finally { closePrompt(); } diff --git a/examples/workflow-guardrails.ts b/examples/workflow-guardrails.ts index f67ada1..c8cc3a5 100644 --- a/examples/workflow-guardrails.ts +++ b/examples/workflow-guardrails.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine, type AgentAdapter } from '../src/index.js'; import { closePrompt, formatResult, isMain, prompt } from './_run.js'; @@ -350,7 +351,7 @@ async function main() { try { const task = await prompt('Task'); const machine = createGuardrailedBugfixWorkflowExample(); - const result = await machine.execute(machine.getInitialState({ task })); + const result = await execute(machine, machine.getInitialState({ task })); console.log(formatResult(result)); } finally { diff --git a/examples/write-a-book-flow.ts b/examples/write-a-book-flow.ts index f171020..a1a8daa 100644 --- a/examples/write-a-book-flow.ts +++ b/examples/write-a-book-flow.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute } from '../src/local/index.js'; import { createAgentMachine } from '../src/index.js'; import { closePrompt, @@ -187,7 +188,7 @@ async function main() { const topic = await prompt('Book topic'); const goal = await prompt('Book goal'); const machine = createWriteABookFlowExample(); - const result = await machine.execute(machine.getInitialState({ topic, goal })); + const result = await execute(machine, machine.getInitialState({ topic, goal })); console.log(formatResult(result)); } finally { closePrompt(); diff --git a/package.json b/package.json index 4457cd0..b1a6152 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@statelyai/agent", "version": "2.0.0", - "description": "Lightweight, stateless, framework-agnostic state-machine-driven AI agents", + "description": "State-machine authoring layer for AI agents", "type": "module", "main": "dist/index.cjs", "module": "dist/index.mjs", @@ -27,16 +27,6 @@ "default": "./dist/ai-sdk.cjs" } }, - "./cloudflare": { - "import": { - "types": "./dist/cloudflare.d.mts", - "default": "./dist/cloudflare.mjs" - }, - "require": { - "types": "./dist/cloudflare.d.cts", - "default": "./dist/cloudflare.cjs" - } - }, "./graph": { "import": { "types": "./dist/graph.d.mts", @@ -47,34 +37,14 @@ "default": "./dist/graph.cjs" } }, - "./http": { - "import": { - "types": "./dist/http.d.mts", - "default": "./dist/http.mjs" - }, - "require": { - "types": "./dist/http.d.cts", - "default": "./dist/http.cjs" - } - }, - "./next": { - "import": { - "types": "./dist/next.d.mts", - "default": "./dist/next.mjs" - }, - "require": { - "types": "./dist/next.d.cts", - "default": "./dist/next.cjs" - } - }, - "./runtime": { + "./local": { "import": { - "types": "./dist/runtime.d.mts", - "default": "./dist/runtime.mjs" + "types": "./dist/local.d.mts", + "default": "./dist/local.mjs" }, "require": { - "types": "./dist/runtime.d.cts", - "default": "./dist/runtime.cjs" + "types": "./dist/local.d.cts", + "default": "./dist/local.cjs" } }, "./xstate": { diff --git a/readme.md b/readme.md index 8a9acb4..15f93fe 100644 --- a/readme.md +++ b/readme.md @@ -1,11 +1,8 @@ # Stately Agent -Stately Agent is a flexible framework for building AI agents using state machines. Stately agents go beyond normal LLM-based AI agents by: +Stately Agent is the state machine authoring layer for AI agents. Author your AI agents as state machines. Run them anywhere. -- Using state machines to guide the agent's behavior, powered by [XState](https://stately.ai/docs/xstate) -- Incorporating **observations**, **message history**, and **feedback** to the agent decision-making and text-generation processes, as needed -- Enabling custom **planning** abilities for agents to achieve specific goals based on state machine logic, observations, and feedback -- First-class integration with the [Vercel AI SDK](https://sdk.vercel.ai/) to easily support multiple model providers, such as OpenAI, Anthropic, Google, Mistral, Groq, Perplexity, and more +The package owns the machine design surface: states, transitions, typed events, messages, generative state schemas, always transitions, and runtime contracts that adapters can implement. ## Examples @@ -13,7 +10,7 @@ Stately Agent is a flexible framework for building AI agents using state machine The examples in [`examples/`](/Users/davidkpiano/Code/agent/examples) are intentionally small. Most run in the CLI and use real OpenAI calls when `OPENAI_API_KEY` is set. Runtime-specific examples call out extra environment requirements inline. -If you want examples grouped by intent instead of a flat list, start with [`examples/README.md`](/Users/davidkpiano/Code/agent/examples/README.md). It separates app-shaped examples, workflow examples, runtime integrations, and lower-level reference examples. +If you want examples grouped by intent instead of a flat list, start with [`examples/README.md`](/Users/davidkpiano/Code/agent/examples/README.md). It separates app-shaped examples, state-machine workflow examples, local/session examples, and lower-level reference examples. Run them with `node --import tsx examples/.ts`. @@ -22,35 +19,36 @@ Convert a machine file to diagram output with `pnpm agent:convert --forma Start here: - App-shaped integrations: [`examples/apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next), [`examples/apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents), [`examples/next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts), [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts) -- Durable sessions and transports: [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts), [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts), [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts) -- Core workflow patterns: [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts), [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts), [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts), [`examples/spec-agent-loop.ts`](/Users/davidkpiano/Code/agent/examples/spec-agent-loop.ts), [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts) +- Local sessions and transports: [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts), [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts), [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts) +- State-machine workflow patterns: [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts), [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts), [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts), [`examples/spec-agent-loop.ts`](/Users/davidkpiano/Code/agent/examples/spec-agent-loop.ts), [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts) - CrewAI-style equivalents: [`examples/content-creator-flow.ts`](/Users/davidkpiano/Code/agent/examples/content-creator-flow.ts), [`examples/email-auto-responder-flow.ts`](/Users/davidkpiano/Code/agent/examples/email-auto-responder-flow.ts), [`examples/lead-score-flow.ts`](/Users/davidkpiano/Code/agent/examples/lead-score-flow.ts), [`examples/meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts), [`examples/self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts), [`examples/write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts) -- Reference examples: [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts), [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts), [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts), [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts), [`examples/workflow-guardrails.ts`](/Users/davidkpiano/Code/agent/examples/workflow-guardrails.ts) +- Reference examples: [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts), [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts), [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts), [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts), [`examples/email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/email-drafter.ts), [`examples/workflow-guardrails.ts`](/Users/davidkpiano/Code/agent/examples/workflow-guardrails.ts) Use `classify(...)` when the result is just "what kind of thing is this?" Use `decide(...)` when the result is "what should happen next?" and the chosen branch may need structured data. CrewAI Flow parity is tracked in [`docs/crewai-parity.md`](/Users/davidkpiano/Code/agent/docs/crewai-parity.md), the same way LangGraph parity is tracked separately. -## Runtime Adapters +## Local Adapter - + -The core package exports session helpers from `@statelyai/agent` and `@statelyai/agent/runtime`: +Use `@statelyai/agent/local` for local development, tests, and in-process examples: +- `execute(machine, state)`: run the local interpreter until done, pending, or error +- `invoke(machine, state)`: run one local interpreter step +- `stream(machine, state)`: yield local interpreter snapshots +- `startSession(machine, options)`: start a local session backed by a `RunStore` +- `restoreSession(machine, options)`: restore a local session from a `RunStore` - `waitForRunDone(run)`: await terminal success or reject on session error - `waitForRunSnapshot(run, predicate)`: await the next snapshot that matches a predicate -Use the framework adapters when a machine needs to run inside an app runtime: - -- `@statelyai/agent/http`: `createSessionHttpController(...)`, `createSessionHttpHandler(...)`, and `createRunSseResponse(...)` -- `@statelyai/agent/next`: `createNextSessionRouteHandlers(...)` plus App Router config exports -- `@statelyai/agent/cloudflare`: `createDurableObjectRunStore(...)` and `createCloudflareAgentRunStore(...)` +Production runtimes should consume the session contract or use framework-specific adapter packages such as `@statelyai/agent-cloudflare` when those packages exist. ## Persistence Adapters -Storage adapters are intentionally bring-your-own. Implement the `RunStore` contract with four methods: +Runtime adapters are intentionally bring-your-own. Implement the `RunStore` contract with four methods: - `append(sessionId, event)` - `loadEvents(sessionId, afterSequence?)` @@ -59,9 +57,9 @@ Storage adapters are intentionally bring-your-own. Implement the `RunStore` cont Use these examples as templates for your storage layer: -- [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): the smallest durable session flow with an in-memory store -- [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): the Request/Response transport shape around `@statelyai/agent/http` -- [`examples/cloudflare-durable-object.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-object.ts): Durable Object-backed event and snapshot persistence with `@statelyai/agent/cloudflare` -- [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): syncing a `RunStore` into Cloudflare Agents state with `@statelyai/agent/cloudflare` +- [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): the smallest local session flow with an in-memory store +- [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): the Request/Response transport shape around the local adapter +- [`examples/cloudflare-durable-object.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-object.ts): preview code for a future Cloudflare adapter package +- [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): preview code for syncing a `RunStore` into Cloudflare Agents state **Read the documentation: [stately.ai/docs/agents](https://stately.ai/docs/agents)** diff --git a/scripts/agent-convert.ts b/scripts/agent-convert.ts index 4142c29..a9181c3 100644 --- a/scripts/agent-convert.ts +++ b/scripts/agent-convert.ts @@ -200,7 +200,6 @@ function isAgentMachine(value: unknown): value is AgentMachine { && typeof (value as AgentMachine).id === 'string' && typeof (value as AgentMachine).getInitialState === 'function' && typeof (value as AgentMachine).transition === 'function' - && typeof (value as AgentMachine).execute === 'function' ); } diff --git a/src/agent.test.ts b/src/agent.test.ts index ec37e3e..1da6500 100644 --- a/src/agent.test.ts +++ b/src/agent.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from './local/index.js'; import { z } from 'zod'; import { classify, @@ -252,10 +253,44 @@ describe('createAgentMachine', () => { expect(machine.id).toBe('simple'); expect(typeof machine.getInitialState).toBe('function'); expect(typeof machine.transition).toBe('function'); - expect(typeof machine.invoke).toBe('function'); - expect(typeof machine.execute).toBe('function'); - expect(typeof machine.stream).toBe('function'); + expect(typeof invoke).toBe('function'); + expect(typeof execute).toBe('function'); + expect(typeof stream).toBe('function'); expect(typeof machine.resolveState).toBe('function'); + expect(typeof machine.getEvents).toBe('function'); + }); +}); + +describe('getEvents', () => { + test('reads available events from states and snapshots', () => { + const machine = createAgentMachine({ + id: 'events', + schemas: { + events: { + start: z.object({}), + ignored: z.object({}), + }, + }, + context: () => ({}), + initial: 'idle', + states: { + idle: { + on: { + start: { target: 'done' }, + }, + }, + done: { + type: 'final', + }, + }, + }); + const state = machine.getInitialState(); + + expect(Object.keys(machine.getEvents(state))).toEqual(['start']); + expect( + Object.keys(machine.getEvents({ ...state, createdAt: 0, sessionId: 's' })) + ).toEqual(['start']); + expect(machine.getEvents('done')).toEqual({}); }); }); @@ -308,14 +343,14 @@ describe('invoke', () => { const machine = createSimpleMachine(); let state = machine.getInitialState(); state = machine.transition(state, { type: 'start' }); - state = await machine.invoke(state); + state = await invoke(machine, state); expect(state.value).toBe('done'); expect(state.context.count).toBe(1); }); test('returns pending for event-only states', async () => { const machine = createHitlMachine(); - const state = await machine.invoke(machine.getInitialState({ task: 'x' })); + const state = await invoke(machine, machine.getInitialState({ task: 'x' })); expect(state.status).toBe('pending'); expect(state.value).toBe('gathering'); }); @@ -323,8 +358,8 @@ describe('invoke', () => { test('returns done for final states', async () => { const machine = createSimpleMachine(); let s = machine.transition(machine.getInitialState(), { type: 'start' }); - s = await machine.invoke(s); - s = await machine.invoke(s); + s = await invoke(machine, s); + s = await invoke(machine, s); expect(s.status).toBe('done'); expect(s.output).toEqual({ result: 1 }); }); @@ -333,7 +368,7 @@ describe('invoke', () => { const machine = createDecideMachine( mockAdapter([{ choice: 'technical' }]) ); - const s = await machine.invoke(machine.getInitialState()); + const s = await invoke(machine, machine.getInitialState()); expect(s.value).toBe('handling'); expect(s.context.category).toBe('technical'); }); @@ -342,7 +377,7 @@ describe('invoke', () => { const machine = createClassifyMachine( mockAdapter([{ choice: 'billing' }]) ); - const s = await machine.invoke(machine.getInitialState()); + const s = await invoke(machine, machine.getInitialState()); expect(s.value).toBe('done'); expect(s.context.category).toBe('billing'); }); @@ -365,7 +400,7 @@ describe('invoke', () => { done: { type: 'final' }, }, }); - const s = await machine.invoke(machine.getInitialState()); + const s = await invoke(machine, machine.getInitialState()); expect(s.status).toBe('error'); }); @@ -384,7 +419,7 @@ describe('invoke', () => { ok: { type: 'final' }, }, }); - const s = await machine.invoke(machine.getInitialState()); + const s = await invoke(machine, machine.getInitialState()); expect(s.status).toBe('error'); expect((s.error as Error).message).toBe('boom'); }); @@ -401,7 +436,7 @@ describe('transition', () => { test('self-transition (no target)', async () => { const machine = createHitlMachine(); - let s = await machine.invoke(machine.getInitialState({ task: 'x' })); + let s = await invoke(machine, machine.getInitialState({ task: 'x' })); s = machine.transition(s, { type: 'user.message', message: 'hello' }); expect(s.value).toBe('gathering'); expect(s.context.messages[0]!.content).toBe('hello'); @@ -409,7 +444,7 @@ describe('transition', () => { test('accumulates context', async () => { const machine = createHitlMachine(); - let s = await machine.invoke(machine.getInitialState({ task: 'x' })); + let s = await invoke(machine, machine.getInitialState({ task: 'x' })); s = machine.transition(s, { type: 'user.message', message: 'one' }); s = machine.transition(s, { type: 'user.message', message: 'two' }); expect(s.context.messages.length).toBe(2); @@ -428,7 +463,7 @@ describe('execute', () => { test('runs until done', async () => { const machine = createSimpleMachine(); let s = machine.transition(machine.getInitialState(), { type: 'start' }); - const r = await machine.execute(s); + const r = await execute(machine, s); expect(r.status).toBe('done'); if (r.status === 'done') { expect(r.output).toEqual({ result: 1 }); @@ -438,7 +473,7 @@ describe('execute', () => { test('stops at pending', async () => { const machine = createHitlMachine(); - const r = await machine.execute(machine.getInitialState({ task: 'x' })); + const r = await execute(machine, machine.getInitialState({ task: 'x' })); expect(r.status).toBe('pending'); if (r.status === 'pending') { expect(r.value).toBe('gathering'); @@ -461,7 +496,7 @@ describe('execute', () => { ok: { type: 'final' }, }, }); - const r = await machine.execute(machine.getInitialState()); + const r = await execute(machine, machine.getInitialState()); expect(r.status).toBe('error'); }); @@ -469,7 +504,7 @@ describe('execute', () => { const machine = createDecideMachine( mockAdapter([{ choice: 'technical' }]) ); - const r = await machine.execute(machine.getInitialState()); + const r = await execute(machine, machine.getInitialState()); expect(r.status).toBe('done'); if (r.status === 'done') { expect(r.output).toEqual({ @@ -487,7 +522,7 @@ describe('stream', () => { mockAdapter([{ choice: 'technical' }]) ); const snaps = []; - for await (const snap of machine.stream(machine.getInitialState())) { + for await (const snap of stream(machine, machine.getInitialState())) { snaps.push(snap); } expect(snaps.length).toBeGreaterThanOrEqual(3); @@ -499,7 +534,7 @@ describe('stream', () => { describe('resolveState', () => { test('restores from JSON', async () => { const machine = createHitlMachine(); - const r = await machine.execute(machine.getInitialState({ task: 'x' })); + const r = await execute(machine, machine.getInitialState({ task: 'x' })); const restored = machine.resolveState(JSON.parse(JSON.stringify(r.state))); const next = machine.transition(restored, { type: 'user.message', @@ -538,7 +573,7 @@ describe('decide', () => { done: { type: 'final' }, }, }); - await machine.invoke(machine.getInitialState()); + await invoke(machine, machine.getInitialState()); expect(spy).toHaveBeenCalledWith( expect.objectContaining({ model: 'my-model', prompt: 'About cats' }) ); @@ -573,7 +608,7 @@ describe('decide', () => { done: { type: 'final' }, }, }); - const r = await machine.execute(machine.getInitialState()); + const r = await execute(machine, machine.getInitialState()); expect(r.status === 'done' && r.context.choice).toBe('state'); }); @@ -624,7 +659,7 @@ describe('decide', () => { done: { type: 'final' }, }, }); - const r = await machine.execute(machine.getInitialState()); + const r = await execute(machine, machine.getInitialState()); expect(r.status === 'done' && r.context.items).toEqual(['a', 'b']); }); }); @@ -658,7 +693,7 @@ describe('decide helper', () => { }, }); - const r = await machine.execute(machine.getInitialState()); + const r = await execute(machine, machine.getInitialState()); expect(r.status).toBe('done'); if (r.status === 'done') { expect(r.output).toEqual({ result: 'technical: App crashes' }); @@ -1022,7 +1057,7 @@ describe('messages and always', () => { expect(state.tools).toBeDefined(); const snapshots = []; - for await (const snapshot of machine.stream(state)) { + for await (const snapshot of stream(machine, state)) { snapshots.push(snapshot); break; } @@ -1091,7 +1126,7 @@ describe('messages and always', () => { }, }); - const result = await machine.execute(machine.getInitialState({ prompt: 'draft' })); + const result = await execute(machine, machine.getInitialState({ prompt: 'draft' })); expect(result.status).toBe('done'); if (result.status === 'done') { @@ -1112,7 +1147,7 @@ describe('classify', () => { const machine = createClassifyMachine( mockAdapter([{ choice: 'billing' }]) ); - const r = await machine.execute(machine.getInitialState()); + const r = await execute(machine, machine.getInitialState()); expect(r.status === 'done' && r.output).toEqual({ category: 'billing' }); }); }); @@ -1120,7 +1155,7 @@ describe('classify', () => { describe('P2: event validation', () => { test('rejects invalid payload', async () => { const machine = createHitlMachine(); - const s = await machine.invoke(machine.getInitialState({ task: 'x' })); + const s = await invoke(machine, machine.getInitialState({ task: 'x' })); expect(() => // @ts-expect-error — deliberately invalid for runtime test machine.transition(s, { type: 'user.message', message: 123 }) @@ -1129,7 +1164,7 @@ describe('P2: event validation', () => { test('accepts valid payload', async () => { const machine = createHitlMachine(); - const s = await machine.invoke(machine.getInitialState({ task: 'x' })); + const s = await invoke(machine, machine.getInitialState({ task: 'x' })); const next = machine.transition(s, { type: 'user.message', message: 'ok', @@ -1148,7 +1183,7 @@ describe('full HITL workflow', () => { test('gather → process → review → done', async () => { const machine = createHitlMachine(); let s = machine.getInitialState({ task: 'build' }); - let r = await machine.execute(s); + let r = await execute(machine, s); expect(r.status).toBe('pending'); s = machine.transition(r.state, { @@ -1157,13 +1192,13 @@ describe('full HITL workflow', () => { }); s = machine.transition(s, { type: 'user.message', message: 'req B' }); s = machine.transition(s, { type: 'user.approve' }); - r = await machine.execute(s); + r = await execute(machine, s); expect(r.status === 'pending' && r.context.result).toBe( 'Processed: req A, req B' ); s = machine.transition(r.state, { type: 'user.approve' }); - r = await machine.execute(s); + r = await execute(machine, s); expect(r.status === 'done' && r.output).toEqual({ result: 'Processed: req A, req B', }); @@ -1171,9 +1206,9 @@ describe('full HITL workflow', () => { test('cancel', async () => { const machine = createHitlMachine(); - let r = await machine.execute(machine.getInitialState({ task: 'x' })); + let r = await execute(machine, machine.getInitialState({ task: 'x' })); const s = machine.transition(r.state, { type: 'user.cancel' }); - r = await machine.execute(s); + r = await execute(machine, s); expect(r.status === 'done' && r.output).toEqual({ cancelled: true }); }); }); @@ -1301,7 +1336,7 @@ describe('type inference', () => { done: { type: 'final' }, }, }); - return machine.execute(machine.getInitialState()).then((r) => { + return execute(machine, machine.getInitialState()).then((r) => { expect(r.status === 'done' && r.context.n).toBe(84); }); }); @@ -1528,7 +1563,7 @@ describe('type inference', () => { ...machine.getInitialState(), input: { a: { count: 21 } }, }); - const r = await machine.execute(state); + const r = await execute(machine, state); expect(r.status === 'done' && r.output).toEqual({ result: 'hi hello' }); }); @@ -1725,7 +1760,7 @@ describe('type inference', () => { }, }); - const runResult = await machine.execute(machine.getInitialState()); + const runResult = await execute(machine, machine.getInitialState()); if (runResult.status === 'done') { runResult.output.count satisfies number; runResult.output.label satisfies string; @@ -1817,7 +1852,7 @@ describe('edge cases', () => { initial: 'stuck', states: { stuck: { invoke: async () => ({}) } }, }); - const s = await machine.invoke(machine.getInitialState()); + const s = await invoke(machine, machine.getInitialState()); expect(s.value).toBe('stuck'); }); @@ -1831,7 +1866,7 @@ describe('edge cases', () => { status: 'done' as const, output: { result: 1 }, }; - expect(await machine.invoke(done)).toEqual(done); + expect(await invoke(machine, done)).toEqual(done); }); }); diff --git a/src/ai-sdk/index.test.ts b/src/ai-sdk/index.test.ts index 23a573e..0474bcf 100644 --- a/src/ai-sdk/index.test.ts +++ b/src/ai-sdk/index.test.ts @@ -97,4 +97,31 @@ describe('createAiSdkAdapter', () => { ).resolves.toBe('generated reply'); expect('decide' in adapter).toBe(false); }); + + test('does not send prompt and messages together', async () => { + const seen: Array<{ prompt?: unknown; messages?: unknown }> = []; + const adapter = createAiSdkAdapter({ + generateText: async (options) => { + seen.push({ + prompt: options.prompt, + messages: options.messages, + }); + + return { text: 'ok' } as never; + }, + }); + + await adapter.generateText?.({ + model: 'openai/gpt-5.4-nano', + prompt: 'reply', + messages: [{ role: 'user', content: 'reply' }], + }); + + expect(seen).toEqual([ + { + prompt: undefined, + messages: [{ role: 'user', content: 'reply' }], + }, + ]); + }); }); diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index 43b1bf8..7ef8763 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -22,11 +22,9 @@ export function createAiSdkAdapter( return { async generateText({ model, system, prompt, messages, tools, toolChoice, outputSchema }) { - const result = await generate({ + const options: any = { model: resolveModel(model ?? 'default', config.resolveModel), system, - prompt, - messages: messages as any, tools: tools as any, toolChoice: toolChoice as any, ...(outputSchema @@ -36,7 +34,15 @@ export function createAiSdkAdapter( }), } : {}), - }); + }; + + if (messages.length > 0) { + options.messages = messages as any; + } else { + options.prompt = prompt ?? ''; + } + + const result = await generate(options); const output = result as { output?: unknown; text?: string }; return output.output ?? output.text ?? result; diff --git a/src/crewai-equivalents/content-creator-flow.test.ts b/src/crewai-equivalents/content-creator-flow.test.ts index fd4ab29..402e353 100644 --- a/src/crewai-equivalents/content-creator-flow.test.ts +++ b/src/crewai-equivalents/content-creator-flow.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createContentCreatorFlowExample } from '../../examples/index.js'; describe('CrewAI content creator flow equivalent', () => { @@ -11,7 +12,7 @@ describe('CrewAI content creator flow equivalent', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ request: 'Announce our AI workflow launch in a short professional post.', }) diff --git a/src/crewai-equivalents/lead-score-flow.test.ts b/src/crewai-equivalents/lead-score-flow.test.ts index 8462101..cfe5e6a 100644 --- a/src/crewai-equivalents/lead-score-flow.test.ts +++ b/src/crewai-equivalents/lead-score-flow.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createLeadScoreFlowExample } from '../../examples/index.js'; describe('CrewAI lead score flow equivalent', () => { @@ -26,7 +27,7 @@ describe('CrewAI lead score flow equivalent', () => { { id: 'lead-3', company: 'Gamma', contact: 'Gia' }, ], }); - const firstPass = await machine.execute(initial); + const firstPass = await execute(machine, initial); expect(firstPass.status).toBe('pending'); if (firstPass.status !== 'pending') { return; @@ -36,7 +37,7 @@ describe('CrewAI lead score flow equivalent', () => { type: 'review.requestChanges', note: 'Prefer companies already asking for demos.', }); - const secondPass = await machine.execute(rescored); + const secondPass = await execute(machine, rescored); expect(secondPass.status).toBe('pending'); if (secondPass.status !== 'pending') { return; @@ -45,7 +46,7 @@ describe('CrewAI lead score flow equivalent', () => { const approved = machine.transition(secondPass.state, { type: 'review.approve', }); - const finalResult = await machine.execute(approved); + const finalResult = await execute(machine, approved); expect(finalResult.status).toBe('done'); if (finalResult.status === 'done') { diff --git a/src/crewai-equivalents/meeting-assistant-flow.test.ts b/src/crewai-equivalents/meeting-assistant-flow.test.ts index c9a87f6..1f825c8 100644 --- a/src/crewai-equivalents/meeting-assistant-flow.test.ts +++ b/src/crewai-equivalents/meeting-assistant-flow.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createMeetingAssistantFlowExample } from '../../examples/index.js'; describe('CrewAI meeting assistant flow equivalent', () => { @@ -18,7 +19,7 @@ describe('CrewAI meeting assistant flow equivalent', () => { sendSlackNotification: async () => ({ slackMessageId: 'slack-123' }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ notes: 'Meeting notes go here.', }) diff --git a/src/crewai-equivalents/self-evaluation-loop-flow.test.ts b/src/crewai-equivalents/self-evaluation-loop-flow.test.ts index 4bbb51d..433d766 100644 --- a/src/crewai-equivalents/self-evaluation-loop-flow.test.ts +++ b/src/crewai-equivalents/self-evaluation-loop-flow.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createSelfEvaluationLoopFlowExample } from '../../examples/index.js'; describe('CrewAI self evaluation loop equivalent', () => { @@ -22,7 +23,7 @@ describe('CrewAI self evaluation loop equivalent', () => { }, }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'Flying cars', }) diff --git a/src/crewai-equivalents/write-a-book-flow.test.ts b/src/crewai-equivalents/write-a-book-flow.test.ts index 6ee2c5c..1945fc4 100644 --- a/src/crewai-equivalents/write-a-book-flow.test.ts +++ b/src/crewai-equivalents/write-a-book-flow.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createWriteABookFlowExample } from '../../examples/index.js'; describe('CrewAI write a book flow equivalent', () => { @@ -23,7 +24,7 @@ describe('CrewAI write a book flow equivalent', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'Workflow systems', goal: 'Teach developers how to build durable AI workflows.', diff --git a/src/examples.test.ts b/src/examples.test.ts index 4233fd1..6077efd 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from './local/index.js'; import { z } from 'zod'; -import { restoreSession, startSession } from './index.js'; +import { restoreSession, startSession } from './local/index.js'; import { createAiSdkExample, @@ -15,6 +16,7 @@ import { createCustomerServiceSimExample, createDecideExample, createChatbotMessagesExample, + createEmailDrafterExample, createEmailExample, createErrorRetryExample, createHitlExample, @@ -88,7 +90,7 @@ describe('curated examples', () => { const machine = createSimpleExample({ generateText: async () => ({ summary: 'A short summary.' }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ text: 'Longer source text.' }) ); @@ -112,7 +114,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ message: 'Please refund invoice 123.' }) ); @@ -127,6 +129,140 @@ describe('curated examples', () => { } }); + test('email drafter follows the prompt, assess, draft, review, send loop', async () => { + const outputs = [ + { + satisfied: false, + missing: ['to', 'subject'], + questions: ['Who should receive it?', 'What subject should I use?'], + }, + { + satisfied: true, + missing: [], + questions: [], + }, + { + to: 'Riley', + subject: 'Thanks for meeting', + body: 'Hi Riley,\n\nThanks for meeting today.', + }, + { + to: 'Riley', + subject: 'Thanks for meeting', + body: 'Hi Riley,\n\nThanks for meeting today. I will send next steps tomorrow.', + }, + ]; + const sentEmails: Array<{ to: string; subject: string; body: string }> = []; + const machine = createEmailDrafterExample({ + adapter: { + generateText: async () => outputs.shift(), + }, + sendEmail: async (draft) => { + sentEmails.push(draft); + }, + }); + + const first = await execute(machine, machine.getInitialState()); + + expect(first.status).toBe('pending'); + if (first.status !== 'pending') { + return; + } + + expect(first.value).toBe('prompting'); + + const needsMoreInfo = await execute(machine, + machine.transition(first.state, { + type: 'PROMPT_SUBMITTED', + prompt: 'Write a thank you email after the meeting.', + }) + ); + + expect(needsMoreInfo.status).toBe('pending'); + if (needsMoreInfo.status !== 'pending') { + return; + } + + expect(needsMoreInfo.value).toBe('needsMoreInfo'); + expect(needsMoreInfo.context.assessment?.questions).toEqual([ + 'Who should receive it?', + 'What subject should I use?', + ]); + + const afterAnswer = await execute(machine, + machine.transition(needsMoreInfo.state, { + type: 'MORE_INFO', + details: 'Send it to Riley. Subject: Thanks for meeting.', + }) + ); + + expect(afterAnswer.status).toBe('pending'); + if (afterAnswer.status !== 'pending') { + return; + } + + expect(afterAnswer.value).toBe('reviewing'); + expect(afterAnswer.context.draft).toEqual({ + to: 'Riley', + subject: 'Thanks for meeting', + body: 'Hi Riley,\n\nThanks for meeting today.', + }); + + const afterRevise = await execute(machine, + machine.transition(afterAnswer.state, { + type: 'REQUEST_CHANGES', + changes: 'Mention next steps tomorrow.', + }) + ); + + expect(afterRevise.status).toBe('pending'); + if (afterRevise.status !== 'pending') { + return; + } + + expect(afterRevise.value).toBe('reviewing'); + expect(afterRevise.context.draft?.body).toContain('next steps tomorrow'); + + const sent = await execute(machine, + machine.transition(afterRevise.state, { + type: 'SEND', + }) + ); + + expect(sent.status).toBe('pending'); + if (sent.status !== 'pending') { + return; + } + + expect(sent.value).toBe('sent'); + expect(sentEmails).toEqual([ + { + to: 'Riley', + subject: 'Thanks for meeting', + body: 'Hi Riley,\n\nThanks for meeting today. I will send next steps tomorrow.', + }, + ]); + + const done = await execute(machine, + machine.transition(sent.state, { + type: 'END', + }) + ); + + expect(done.status).toBe('done'); + if (done.status === 'done') { + expect(done.output).toEqual({ + sentEmails: [ + { + to: 'Riley', + subject: 'Thanks for meeting', + body: 'Hi Riley,\n\nThanks for meeting today. I will send next steps tomorrow.', + }, + ], + }); + } + }); + test('chatbot messages example accumulates structured conversation turns', async () => { const machine = createChatbotMessagesExample(async (messages) => ({ message: { @@ -142,7 +278,7 @@ describe('curated examples', () => { content: 'Hello there', }, }); - const result = await machine.execute(afterUserMessage); + const result = await execute(machine, afterUserMessage); expect(result.status).toBe('pending'); if (result.status === 'pending') { @@ -175,7 +311,7 @@ describe('curated examples', () => { }, }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ question: 'What is LangGraph?' }) ); @@ -773,7 +909,7 @@ describe('curated examples', () => { test('hitl example exposes typed pending events', async () => { const machine = createHitlExample(); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ task: 'Draft an answer' }) ); @@ -793,7 +929,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ request: 'The customer says their invoice is wrong.', }) @@ -816,7 +952,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ request: 'I need help with a refund for my duplicate charge.', }) @@ -835,7 +971,7 @@ describe('curated examples', () => { data: { confidence: 0.9 }, }), }); - const result = await machine.execute(machine.getInitialState({ message: 'refund my last invoice' })); + const result = await execute(machine, machine.getInitialState({ message: 'refund my last invoice' })); expect(result.status).toBe('done'); if (result.status === 'done') { @@ -855,7 +991,7 @@ describe('curated examples', () => { return { answer: 'Recovered answer.' }; }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ question: 'Can this retry?' }) ); @@ -876,7 +1012,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'state machines', mode: 'draft', @@ -1048,7 +1184,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'agents' }) ); @@ -1077,12 +1213,12 @@ describe('curated examples', () => { }), }); - const decideResult = await decideMachine.execute( + const decideResult = await execute(decideMachine, decideMachine.getInitialState({ request: 'Please answer this support question.', }) ); - const classifyResult = await classifyMachine.execute( + const classifyResult = await execute(classifyMachine, classifyMachine.getInitialState({ request: 'This is a general support question.', }) @@ -1102,7 +1238,7 @@ describe('curated examples', () => { test('hitl example event schemas validate payloads', async () => { const machine = createHitlExample(); - const pending = await machine.execute( + const pending = await execute(machine, machine.getInitialState({ task: 'Draft an answer' }) ); @@ -1125,7 +1261,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ request: 'Please respond to this support request.', }) @@ -1150,7 +1286,7 @@ describe('curated examples', () => { reply: async () => ({ response: 'Assistant reply' }), }); - const pending = await machine.execute(machine.getInitialState()); + const pending = await execute(machine, machine.getInitialState()); expect(pending.status).toBe('pending'); if (pending.status === 'pending') { @@ -1158,7 +1294,7 @@ describe('curated examples', () => { type: 'user.message', message: 'Hello there', }); - const result = await machine.execute(next); + const result = await execute(machine, next); expect(result.status).toBe('pending'); if (result.status === 'pending') { @@ -1180,7 +1316,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ issue: 'I want a refund.' }) ); @@ -1240,7 +1376,7 @@ describe('curated examples', () => { }), }); - const first = await machine.execute( + const first = await execute(machine, machine.getInitialState({ email: 'Can you meet next week?', instructions: 'Reply with one specific slot.', @@ -1255,7 +1391,7 @@ describe('curated examples', () => { type: 'user.answer', answer: 'Offer Friday afternoon.', }); - const done = await machine.execute(next); + const done = await execute(machine, next); expect(done.status).toBe('done'); if (done.status === 'done') { @@ -1280,7 +1416,7 @@ describe('curated examples', () => { generateText: async () => results.shift(), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'ducks' }) ); @@ -1298,7 +1434,7 @@ describe('curated examples', () => { test('jugs example solves the 3 and 5 gallon puzzle', async () => { const machine = createJugsExample(); - const result = await machine.execute(machine.getInitialState()); + const result = await execute(machine, machine.getInitialState()); expect(result.status).toBe('done'); if (result.status === 'done') { @@ -1337,7 +1473,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'agents' }) ); @@ -1361,7 +1497,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'agents' }) ); @@ -1410,7 +1546,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'durable agents' }) ); @@ -1450,7 +1586,7 @@ describe('curated examples', () => { }; }); - const { createMemoryRunStore, startSession } = await import('./index.js'); + const { createMemoryRunStore, startSession } = await import('./local/index.js'); const run = await startSession(machine, { store: createMemoryRunStore(), input: { city: 'New York' }, @@ -1534,7 +1670,7 @@ describe('curated examples', () => { }, }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ question: 'What is Acme owed?', schema: 'invoices(customer text, total integer)', @@ -1558,7 +1694,7 @@ describe('curated examples', () => { }); test('react agent example loops through a tool and returns a final answer', async () => { - const { createMemoryRunStore, startSession } = await import('./index.js'); + const { createMemoryRunStore, startSession } = await import('./local/index.js'); const agent = createReactAgentExample({ search: async (query) => `result for ${query}`, model: async ({ messages }) => { @@ -1640,7 +1776,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ objective: 'understand the repo' }) ); @@ -1698,7 +1834,7 @@ describe('curated examples', () => { }, }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ request: 'Fix the duplicate subscription charge.', }) @@ -1734,7 +1870,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'Robotics' }) ); @@ -1762,7 +1898,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ goal: 'ship a feature' }) ); @@ -1785,7 +1921,7 @@ describe('curated examples', () => { explanation: 'Selected the second entry for the demo.', })); - const pending = await machine.execute(machine.getInitialState()); + const pending = await execute(machine, machine.getInitialState()); expect(pending.status).toBe('pending'); if (pending.status === 'pending') { @@ -1803,7 +1939,7 @@ describe('curated examples', () => { }); state = machine.transition(state, { type: 'user.draw' }); - const result = await machine.execute(state); + const result = await execute(machine, state); expect(result.status).toBe('done'); if (result.status === 'done') { expect(result.output).toEqual({ @@ -1830,7 +1966,7 @@ describe('curated examples', () => { }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ task: 'Explain event sourcing simply.' }) ); @@ -1847,7 +1983,7 @@ describe('curated examples', () => { test('river crossing example moves every item safely to the right bank', async () => { const machine = createRiverCrossingExample(); - const result = await machine.execute(machine.getInitialState()); + const result = await execute(machine, machine.getInitialState()); expect(result.status).toBe('done'); if (result.status === 'done') { @@ -1883,7 +2019,7 @@ describe('curated examples', () => { respond: async () => ({ response: 'Claro, puedo ayudarte.' }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ message: 'Yo necesito ayuda' }) ); @@ -1928,7 +2064,7 @@ describe('guardrailed workflow examples', () => { expect(Object.keys(diagnose.tools ?? {})).toContain('get_logs'); expect(Object.keys(diagnose.tools ?? {})).not.toContain('delete_volume'); - const result = await machine.execute(diagnose); + const result = await execute(machine, diagnose); expect(result.status).toBe('pending'); if (result.status !== 'pending') { throw new Error('Expected approval state'); diff --git a/src/http/index.ts b/src/http/index.ts index c1a0ee9..ec4b345 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,10 +1,8 @@ import { createMemoryRunStore, -} from '../runtime/memory-store.js'; -import { restoreSession, startSession, -} from '../runtime/session.js'; +} from '../local/index.js'; import type { AgentMachine, AgentRun, diff --git a/src/index.ts b/src/index.ts index 9ced35d..61ee823 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,6 @@ export { classify, classifyResultSchema } from './classify.js'; // Adapter export { createAdapter } from './adapter.js'; -export { createMemoryRunStore } from './runtime/memory-store.js'; -export { restoreSession, startSession } from './runtime/session.js'; -export { waitForRunDone, waitForRunSnapshot } from './runtime/index.js'; export { appendMessages, assistantMessage, diff --git a/src/invoke-events.test.ts b/src/invoke-events.test.ts index 5498bd4..892f6eb 100644 --- a/src/invoke-events.test.ts +++ b/src/invoke-events.test.ts @@ -1,9 +1,8 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from './local/index.js'; import { z } from 'zod'; import { createAgentMachine, - createMemoryRunStore, - startSession, } from './index.js'; function once( diff --git a/src/langgraph-equivalents/branching.test.ts b/src/langgraph-equivalents/branching.test.ts index 784594c..fb14ede 100644 --- a/src/langgraph-equivalents/branching.test.ts +++ b/src/langgraph-equivalents/branching.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { z } from 'zod'; import { createAgentMachine } from '../index.js'; @@ -60,7 +61,7 @@ test('supports branching-style orchestration with plain async fan-out inside inv }, }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'agents' }) ); diff --git a/src/langgraph-equivalents/chatbot-messages.test.ts b/src/langgraph-equivalents/chatbot-messages.test.ts index 1174b1c..d744592 100644 --- a/src/langgraph-equivalents/chatbot-messages.test.ts +++ b/src/langgraph-equivalents/chatbot-messages.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createChatbotMessagesExample } from '../../examples/index.js'; test('message-centric chatbot workflow accumulates structured messages across turns', async () => { @@ -16,7 +17,7 @@ test('message-centric chatbot workflow accumulates structured messages across tu content: 'Hello there', }, }); - const firstResult = await machine.execute(afterFirstTurn); + const firstResult = await execute(machine, afterFirstTurn); expect(firstResult.status).toBe('pending'); if (firstResult.status === 'pending') { @@ -32,7 +33,7 @@ test('message-centric chatbot workflow accumulates structured messages across tu content: 'Can you expand on that?', }, }); - const secondResult = await machine.execute(afterSecondTurn); + const secondResult = await execute(machine, afterSecondTurn); expect(secondResult.status).toBe('pending'); if (secondResult.status === 'pending') { diff --git a/src/langgraph-equivalents/conditional-subflow.test.ts b/src/langgraph-equivalents/conditional-subflow.test.ts index 7b0a155..cc04f9a 100644 --- a/src/langgraph-equivalents/conditional-subflow.test.ts +++ b/src/langgraph-equivalents/conditional-subflow.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createConditionalSubflowExample } from '../../examples/index.js'; test('conditionally enters the research subflow from parent input', async () => { @@ -8,7 +9,7 @@ test('conditionally enters the research subflow from parent input', async () => }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'agent graphs', mode: 'research', @@ -32,7 +33,7 @@ test('conditionally enters the draft subflow with parent-provided input', async }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'agent graphs', mode: 'draft', diff --git a/src/langgraph-equivalents/error-retry.test.ts b/src/langgraph-equivalents/error-retry.test.ts index d595a37..821c478 100644 --- a/src/langgraph-equivalents/error-retry.test.ts +++ b/src/langgraph-equivalents/error-retry.test.ts @@ -1,6 +1,7 @@ -import { expect, test, vi } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createErrorRetryExample } from '../../examples/index.js'; -import { createMemoryRunStore, restoreSession } from '../index.js'; +import { createMemoryRunStore, restoreSession } from '../local/index.js'; test('retries failed invoke work through explicit internal error events', async () => { let attempts = 0; @@ -16,7 +17,7 @@ test('retries failed invoke work through explicit internal error events', async }; }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ question: 'What is durable retry?' }) ); @@ -36,7 +37,7 @@ test('fails after the configured retry budget is exhausted', async () => { throw new Error(`still down ${attempt}`); }, 2); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ question: 'Will this recover?' }) ); diff --git a/src/langgraph-equivalents/graph.test.ts b/src/langgraph-equivalents/graph.test.ts index d6de3f1..4443ca4 100644 --- a/src/langgraph-equivalents/graph.test.ts +++ b/src/langgraph-equivalents/graph.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { z } from 'zod'; import { createAgentMachine } from '../index.js'; @@ -39,7 +40,7 @@ test('supports multi-step workflow accumulation like a sequential state graph', }, }); - const result = await machine.execute(machine.getInitialState()); + const result = await execute(machine, machine.getInitialState()); expect(result.status).toBe('done'); if (result.status === 'done') { @@ -104,7 +105,7 @@ test('supports conditional routing with explicit machine transitions', async () }, }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ request: 'I need a refund for my invoice.' }) ); diff --git a/src/langgraph-equivalents/hitl.test.ts b/src/langgraph-equivalents/hitl.test.ts index 9f82a76..f0ea291 100644 --- a/src/langgraph-equivalents/hitl.test.ts +++ b/src/langgraph-equivalents/hitl.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { z } from 'zod'; import { createAgentMachine } from '../index.js'; @@ -45,7 +46,7 @@ test('supports human-in-the-loop review with explicit pending states and externa }, }); - const first = await machine.execute( + const first = await execute(machine, machine.getInitialState({ task: 'reply to customer' }) ); @@ -59,13 +60,13 @@ test('supports human-in-the-loop review with explicit pending states and externa type: 'revise', note: 'make it shorter', }); - const second = await machine.execute(revised); + const second = await execute(machine, revised); expect(second.status).toBe('pending'); if (second.status !== 'pending') return; const approved = machine.transition(second.state, { type: 'approve' }); - const done = await machine.execute(approved); + const done = await execute(machine, approved); expect(done.status).toBe('done'); if (done.status === 'done') { diff --git a/src/langgraph-equivalents/map-reduce.test.ts b/src/langgraph-equivalents/map-reduce.test.ts index 7503c33..68aab06 100644 --- a/src/langgraph-equivalents/map-reduce.test.ts +++ b/src/langgraph-equivalents/map-reduce.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { z } from 'zod'; import { createAgentMachine } from '../index.js'; @@ -61,7 +62,7 @@ test('supports map-reduce style orchestration with dynamic work items inside inv }, }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'state machines' }) ); diff --git a/src/langgraph-equivalents/multi-agent-network.test.ts b/src/langgraph-equivalents/multi-agent-network.test.ts index 5b1c50c..45bfa04 100644 --- a/src/langgraph-equivalents/multi-agent-network.test.ts +++ b/src/langgraph-equivalents/multi-agent-network.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createMultiAgentNetworkExample } from '../../examples/multi-agent-network.js'; test('multi-agent network coordinates specialist handoffs until a final draft is ready', async () => { @@ -37,7 +38,7 @@ test('multi-agent network coordinates specialist handoffs until a final draft is }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ topic: 'agent runtimes' }) ); diff --git a/src/langgraph-equivalents/persistence.test.ts b/src/langgraph-equivalents/persistence.test.ts index efa31ae..7d29b69 100644 --- a/src/langgraph-equivalents/persistence.test.ts +++ b/src/langgraph-equivalents/persistence.test.ts @@ -1,10 +1,8 @@ -import { expect, test, vi } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../local/index.js'; import { z } from 'zod'; import { createAgentMachine, - createMemoryRunStore, - restoreSession, - startSession, } from '../index.js'; test('persists and restores a long-running approval workflow', async () => { diff --git a/src/langgraph-equivalents/plan-and-execute.test.ts b/src/langgraph-equivalents/plan-and-execute.test.ts index 781b646..f307410 100644 --- a/src/langgraph-equivalents/plan-and-execute.test.ts +++ b/src/langgraph-equivalents/plan-and-execute.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createPlanAndExecuteExample } from '../../examples/plan-and-execute.js'; test('plan-and-execute workflow decomposes a goal and synthesizes a final answer', async () => { @@ -14,7 +15,7 @@ test('plan-and-execute workflow decomposes a goal and synthesizes a final answer }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ goal: 'understand the repo' }) ); diff --git a/src/langgraph-equivalents/prebuilt-react.test.ts b/src/langgraph-equivalents/prebuilt-react.test.ts index de52d4a..6537889 100644 --- a/src/langgraph-equivalents/prebuilt-react.test.ts +++ b/src/langgraph-equivalents/prebuilt-react.test.ts @@ -1,8 +1,5 @@ -import { expect, test } from 'vitest'; -import { - createMemoryRunStore, - startSession, -} from '../index.js'; +import { describe, expect, test, vi } from 'vitest'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../local/index.js'; import { createReactAgentFromScratch } from '../../examples/react-agent-from-scratch.js'; function once( diff --git a/src/langgraph-equivalents/rag.test.ts b/src/langgraph-equivalents/rag.test.ts index 8cc5f8a..a01f60b 100644 --- a/src/langgraph-equivalents/rag.test.ts +++ b/src/langgraph-equivalents/rag.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createRagExample } from '../../examples/index.js'; test('rag workflow retrieves documents and synthesizes a grounded answer', async () => { @@ -19,7 +20,7 @@ test('rag workflow retrieves documents and synthesizes a grounded answer', async }, }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ question: 'What is LangGraph?' }) ); diff --git a/src/langgraph-equivalents/reflection.test.ts b/src/langgraph-equivalents/reflection.test.ts index f248a2f..e380960 100644 --- a/src/langgraph-equivalents/reflection.test.ts +++ b/src/langgraph-equivalents/reflection.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createReflectionExample } from '../../examples/reflection.js'; test('reflection workflow revises a draft until critique is cleared', async () => { @@ -14,7 +15,7 @@ test('reflection workflow revises a draft until critique is cleared', async () = }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ task: 'Write a short explanation.' }) ); diff --git a/src/langgraph-equivalents/rewoo.test.ts b/src/langgraph-equivalents/rewoo.test.ts index 3ee9509..99cb21e 100644 --- a/src/langgraph-equivalents/rewoo.test.ts +++ b/src/langgraph-equivalents/rewoo.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createRewooExample } from '../../examples/rewoo.js'; test('rewoo workflow plans named steps, resolves references, and synthesizes a final answer', async () => { @@ -25,7 +26,7 @@ test('rewoo workflow plans named steps, resolves references, and synthesizes a f }), }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ objective: 'understand the runtime' }) ); diff --git a/src/langgraph-equivalents/sql-agent.test.ts b/src/langgraph-equivalents/sql-agent.test.ts index 9100120..fc6ee4f 100644 --- a/src/langgraph-equivalents/sql-agent.test.ts +++ b/src/langgraph-equivalents/sql-agent.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { createMemoryRunStore, startSession } from '../index.js'; +import { createMemoryRunStore, startSession } from '../local/index.js'; import { createSqlAgentExample } from '../../examples/sql-agent.js'; function once( diff --git a/src/langgraph-equivalents/streaming.test.ts b/src/langgraph-equivalents/streaming.test.ts index e35d997..d2ddf58 100644 --- a/src/langgraph-equivalents/streaming.test.ts +++ b/src/langgraph-equivalents/streaming.test.ts @@ -1,9 +1,8 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../local/index.js'; import { z } from 'zod'; import { createAgentMachine, - createMemoryRunStore, - startSession, } from '../index.js'; function once( diff --git a/src/langgraph-equivalents/subflow.test.ts b/src/langgraph-equivalents/subflow.test.ts index 15b0370..d5471bc 100644 --- a/src/langgraph-equivalents/subflow.test.ts +++ b/src/langgraph-equivalents/subflow.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { z } from 'zod'; import { createAgentMachine } from '../index.js'; @@ -46,7 +47,7 @@ test('supports subflow composition by executing a child machine inside a parent researching: { schemas: { output: z.object({ bullets: z.array(z.string()) }) }, invoke: async ({ context }) => { - const result = await childMachine.execute( + const result = await execute(childMachine, childMachine.getInitialState({ topic: context.topic }) ); @@ -83,7 +84,7 @@ test('supports subflow composition by executing a child machine inside a parent }, }); - const result = await parentMachine.execute( + const result = await execute(parentMachine, parentMachine.getInitialState({ topic: 'state machines' }) ); diff --git a/src/langgraph-equivalents/supervisor.test.ts b/src/langgraph-equivalents/supervisor.test.ts index e2d71f9..435da87 100644 --- a/src/langgraph-equivalents/supervisor.test.ts +++ b/src/langgraph-equivalents/supervisor.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from '../local/index.js'; import { createSupervisorExample } from '../../examples/supervisor.js'; test('supervisor workflow retries a blocked worker and escalates when repeated attempts fail', async () => { @@ -36,7 +37,7 @@ test('supervisor workflow retries a blocked worker and escalates when repeated a maxAttempts: 2, }); - const result = await machine.execute( + const result = await execute(machine, machine.getInitialState({ request: 'Refund the duplicate annual subscription charge.', }) diff --git a/src/langgraph-equivalents/tool-calling.test.ts b/src/langgraph-equivalents/tool-calling.test.ts index 1b781f0..e4f8ece 100644 --- a/src/langgraph-equivalents/tool-calling.test.ts +++ b/src/langgraph-equivalents/tool-calling.test.ts @@ -1,9 +1,8 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../local/index.js'; import { z } from 'zod'; import { createAgentMachine, - createMemoryRunStore, - startSession, } from '../index.js'; function once( diff --git a/src/local/index.ts b/src/local/index.ts new file mode 100644 index 0000000..8f34096 --- /dev/null +++ b/src/local/index.ts @@ -0,0 +1,4 @@ +export { waitForRunDone, waitForRunSnapshot } from '../runtime/index.js'; +export { createMemoryRunStore } from '../runtime/memory-store.js'; +export { restoreSession, startSession } from '../runtime/session.js'; +export { execute, invoke, stream } from './interpreter.js'; diff --git a/src/local/interpreter.ts b/src/local/interpreter.ts new file mode 100644 index 0000000..f7d9794 --- /dev/null +++ b/src/local/interpreter.ts @@ -0,0 +1,65 @@ +import type { + AgentMachine, + AgentSnapshot, + AgentState, + ExecuteResult, +} from '../types.js'; + +type LocalMachine = AgentMachine & { + invoke(state: AgentState): Promise; + execute(state: AgentState): Promise; + stream(state: AgentState): AsyncGenerator; +}; + +function asLocalMachine(machine: AgentMachine): LocalMachine { + const localMachine = machine as LocalMachine; + if ( + typeof localMachine.invoke !== 'function' + || typeof localMachine.execute !== 'function' + || typeof localMachine.stream !== 'function' + ) { + throw new Error('Machine local interpreter internals are unavailable'); + } + + return localMachine; +} + +export function invoke< + TContext extends Record, + TValue extends string, + TOutput, +>( + machine: AgentMachine, + state: AgentState +): Promise> { + return asLocalMachine(machine).invoke(state) as Promise< + AgentState + >; +} + +export function execute< + TContext extends Record, + TValue extends string, + TEvents extends Record, + TOutput, +>( + machine: AgentMachine, + state: AgentState +): Promise> { + return asLocalMachine(machine).execute(state) as Promise< + ExecuteResult + >; +} + +export function stream< + TContext extends Record, + TValue extends string, + TOutput, +>( + machine: AgentMachine, + state: AgentState +): AsyncGenerator> { + return asLocalMachine(machine).stream(state) as AsyncGenerator< + AgentSnapshot + >; +} diff --git a/src/machine.ts b/src/machine.ts index 8e84b8f..d7108ef 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -431,6 +431,11 @@ export function createAgentMachine( return transitionWithEffects(state, event).next; } + function getEvents(state: AgentState | AgentSnapshot | string) { + const value = typeof state === 'string' ? state : state.value; + return getAvailableEvents(cfg, value); + } + function transitionWithEffects( state: AgentState, event: { type: string; [k: string]: unknown }, @@ -863,6 +868,7 @@ export function createAgentMachine( getInitialState, resolveState, transition, + getEvents, invoke, execute, stream, diff --git a/src/persistence.test.ts b/src/persistence.test.ts index 7cd2f17..db48126 100644 --- a/src/persistence.test.ts +++ b/src/persistence.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { createMemoryRunStore } from './index.js'; +import { createMemoryRunStore } from './local/index.js'; test('appends and loads journal events in sequence order', async () => { const store = createMemoryRunStore(); diff --git a/src/restore.test.ts b/src/restore.test.ts index b840845..2d8d945 100644 --- a/src/restore.test.ts +++ b/src/restore.test.ts @@ -1,10 +1,8 @@ -import { expect, test, vi } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from './local/index.js'; import { z } from 'zod'; import { createAgentMachine, - createMemoryRunStore, - restoreSession, - startSession, } from './index.js'; test('restoreSession reconstructs from the latest snapshot plus replay tail', async () => { diff --git a/src/runtime/index.test.ts b/src/runtime/index.test.ts index 10f1569..ed61923 100644 --- a/src/runtime/index.test.ts +++ b/src/runtime/index.test.ts @@ -2,10 +2,13 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; import { createAgentMachine, +} from '../index.js'; +import { createMemoryRunStore, startSession, -} from '../index.js'; -import { waitForRunDone, waitForRunSnapshot } from './index.js'; + waitForRunDone, + waitForRunSnapshot, +} from '../local/index.js'; describe('runtime helpers', () => { test('waitForRunSnapshot and waitForRunDone observe session lifecycle', async () => { diff --git a/src/runtime/session.ts b/src/runtime/session.ts index bb208f5..8b2d43c 100644 --- a/src/runtime/session.ts +++ b/src/runtime/session.ts @@ -1,3 +1,4 @@ +import { invoke } from '../local/interpreter.js'; import type { JournalEvent } from './events.js'; import { createRunEmitter } from './emitter.js'; import type { @@ -201,7 +202,7 @@ function createRun( } runState.current = runtimeMachine.__runtime.withRuntimeMetadata( - await machine.invoke(runState.current), + await invoke(machine, runState.current), runState.runtime ); await persistSnapshot(); diff --git a/src/session-runtime.test.ts b/src/session-runtime.test.ts index 8844a1f..33a259b 100644 --- a/src/session-runtime.test.ts +++ b/src/session-runtime.test.ts @@ -1,9 +1,8 @@ -import { expect, test, vi } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from './local/index.js'; import { z } from 'zod'; import { createAgentMachine, - createMemoryRunStore, - startSession, } from './index.js'; function deferred() { diff --git a/src/stream-snapshot.test.ts b/src/stream-snapshot.test.ts index 22248d5..ee25527 100644 --- a/src/stream-snapshot.test.ts +++ b/src/stream-snapshot.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { execute, invoke, stream } from './local/index.js'; import { createAgentMachine } from './index.js'; const machine = createAgentMachine({ @@ -18,7 +19,7 @@ const machine = createAgentMachine({ async function collectSnapshots(state = machine.getInitialState()) { const snaps = []; - for await (const snap of machine.stream(state)) { + for await (const snap of stream(machine, state)) { snaps.push(snap); } diff --git a/src/streaming.test.ts b/src/streaming.test.ts index a2c490a..3a8f2f8 100644 --- a/src/streaming.test.ts +++ b/src/streaming.test.ts @@ -1,9 +1,8 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from './local/index.js'; import { z } from 'zod'; import { createAgentMachine, - createMemoryRunStore, - startSession, } from './index.js'; function once( diff --git a/src/target-types.assert.ts b/src/target-types.assert.ts index 6cd6cae..11adcf4 100644 --- a/src/target-types.assert.ts +++ b/src/target-types.assert.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { execute, invoke, stream } from './local/index.js'; import { createAgentMachine } from './machine.js'; const machine = createAgentMachine({ @@ -121,7 +122,7 @@ typedMachine.transition(typedState, { type: 'missing' }); typedMachine.transition(typedState, { type: 'submit', value: 'nope' }); void (async () => { - const result = await typedMachine.execute( + const result = await execute(typedMachine, typedMachine.transition(typedState, { type: 'submit', value: 2 }) ); diff --git a/src/types.ts b/src/types.ts index 7c88a04..bc31678 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,7 +82,7 @@ type IsExactlyUnknown = unknown extends T ? ([T] extends [unknown] ? true : false) : false; -// ─── Durable Session Vocabulary ─── +// ─── Session Contract ─── export type { JournalEvent } from './runtime/events.js'; export type { JournalEventRecord, PersistedSnapshot, RunStore } from './runtime/store.js'; @@ -283,17 +283,13 @@ export interface AgentMachine< event: TransitionEvent ): AgentState; - invoke( - state: AgentState - ): Promise>; - - execute( - state: AgentState - ): Promise>; + getEvents( + state: + | AgentState + | AgentSnapshot + | (keyof TStates & string) + ): Record; - stream( - state: AgentState - ): AsyncGenerator>; } export interface AgentRun< diff --git a/tsdown.config.ts b/tsdown.config.ts index 05fe1c3..b285901 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -4,11 +4,8 @@ export default defineConfig({ entry: { index: 'src/index.ts', 'ai-sdk': 'src/ai-sdk/index.ts', - cloudflare: 'src/cloudflare/index.ts', graph: 'src/graph/index.ts', - http: 'src/http/index.ts', - next: 'src/next/index.ts', - runtime: 'src/runtime/index.ts', + local: 'src/local/index.ts', xstate: 'src/xstate/index.ts', }, format: ['esm', 'cjs'], From 79cb30216ae81c06d2c58abc706728252c558895 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 22 May 2026 17:38:10 -0400 Subject: [PATCH 36/50] Add V2 serverless and email examples --- examples/email-drafter.ts | 541 ++++++++++++++++++++++++++++++++++++++ examples/serverless.ts | 160 +++++++++++ 2 files changed, 701 insertions(+) create mode 100644 examples/email-drafter.ts create mode 100644 examples/serverless.ts diff --git a/examples/email-drafter.ts b/examples/email-drafter.ts new file mode 100644 index 0000000..c1545eb --- /dev/null +++ b/examples/email-drafter.ts @@ -0,0 +1,541 @@ +import { z } from 'zod'; +import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; +import { + appendMessages, + assistantMessage, + createAgentMachine, + type AgentAdapter, + userMessage, +} from '../src/index.js'; +import { createAiSdkAdapter } from '../src/ai-sdk/index.js'; +import { + closePrompt, + createExampleModel, + isMain, + prompt, +} from './_run.js'; + +const promptAssessmentSchema = z.object({ + satisfied: z.boolean(), + missing: z.array(z.string()), + questions: z.array(z.string()), +}); + +const emailDraftSchema = z.object({ + to: z.string(), + subject: z.string(), + body: z.string(), +}); + +type EmailDraft = z.infer; + +function formatDraft(draft: EmailDraft): string { + return [`To: ${draft.to}`, `Subject: ${draft.subject}`, '', draft.body].join('\n'); +} + +export function createEmailDrafterExample( + options: { + adapter?: AgentAdapter; + sendEmail?: (draft: EmailDraft) => Promise; + } = {} +) { + return createAgentMachine({ + id: 'email-drafter-example', + adapter: options.adapter ?? createEmailDrafterAdapter(), + schemas: { + output: z.object({ + sentEmails: z.array(emailDraftSchema), + }), + events: { + PROMPT_SUBMITTED: z.object({ prompt: z.string() }), + MORE_INFO: z.object({ details: z.string() }), + DRAFT_ANYWAY: z.object({}), + REQUEST_CHANGES: z.object({ changes: z.string() }), + SEND: z.object({}), + ANOTHER: z.object({}), + END: z.object({}), + }, + }, + externalEvents: [ + 'PROMPT_SUBMITTED', + 'MORE_INFO', + 'DRAFT_ANYWAY', + 'REQUEST_CHANGES', + 'SEND', + 'ANOTHER', + 'END', + ], + context: () => ({ + prompt: '', + assessment: null as z.infer | null, + draft: null as EmailDraft | null, + changes: null as string | null, + draftAnyway: false, + sentEmails: [] as EmailDraft[], + }), + initial: 'prompting', + states: { + prompting: { + on: { + PROMPT_SUBMITTED: ({ event }) => ({ + target: 'evaluating', + context: { + prompt: event.prompt, + assessment: null, + draft: null, + changes: null, + draftAnyway: false, + }, + messages: [userMessage(event.prompt)], + }), + }, + }, + evaluating: { + model: 'openai/gpt-5.4-nano', + system: + 'Evaluate an email drafting request. Require recipient/to, subject/purpose, and enough body details. Return concise missing fields and one question per gap.', + prompt: ({ snapshot }) => snapshot.context.prompt, + schemas: { output: promptAssessmentSchema }, + onDone: ({ output }) => { + if (output.satisfied) { + return { + target: 'drafting', + context: { assessment: output }, + }; + } + + return { + target: 'needsMoreInfo', + context: { assessment: output }, + }; + }, + }, + needsMoreInfo: { + on: { + MORE_INFO: ({ event, context, messages }) => ({ + target: 'evaluating', + context: { + prompt: `${context.prompt}\n\n${event.details}`, + draftAnyway: false, + }, + messages: appendMessages(messages, userMessage(event.details)), + }), + DRAFT_ANYWAY: { + target: 'drafting', + context: { draftAnyway: true }, + }, + }, + }, + drafting: { + model: 'openai/gpt-5.4-nano', + system: ({ snapshot }) => + [ + 'Draft a polished email from the request.', + snapshot.context.draftAnyway + ? 'Infer reasonable details only because the user chose to draft anyway.' + : 'Use the provided details without inventing missing essentials.', + 'Keep body useful and concise.', + ].join('\n'), + prompt: ({ snapshot }) => snapshot.context.prompt, + schemas: { output: emailDraftSchema }, + onDone: ({ output, messages }) => ({ + target: 'reviewing', + context: { + draft: output, + changes: null, + }, + messages: appendMessages(messages, assistantMessage(formatDraft(output))), + }), + }, + reviewing: { + on: { + REQUEST_CHANGES: ({ event, context, messages }) => ({ + target: 'drafting', + context: { + prompt: `${context.prompt}\n\nRevision request: ${event.changes}`, + changes: event.changes, + draftAnyway: true, + }, + messages: appendMessages( + messages, + userMessage(`Revision request: ${event.changes}`) + ), + }), + SEND: { target: 'sending' }, + }, + }, + sending: { + invoke: async ({ context }) => { + if (context.draft) { + await options.sendEmail?.(context.draft); + } + }, + onDone: ({ context }) => ({ + target: 'sent', + context: { + sentEmails: context.draft + ? [...context.sentEmails, context.draft] + : context.sentEmails, + }, + }), + }, + sent: { + on: { + ANOTHER: { + target: 'prompting', + context: { + prompt: '', + assessment: null, + draft: null, + changes: null, + draftAnyway: false, + }, + }, + END: { target: 'done' }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + sentEmails: context.sentEmails, + }), + }, + }, + }); +} + +function createEmailDrafterAdapter(): AgentAdapter { + const aiAdapter = process.env.OPENAI_API_KEY + ? createAiSdkAdapter({ + resolveModel: (model) => createExampleModel(model), + }) + : undefined; + + return { + async generateText(options) { + if (aiAdapter?.generateText) { + try { + return await aiAdapter.generateText(options); + } catch (error) { + console.warn(`AI generation failed; using fallback. ${formatError(error)}`); + } + } + + const text = options.prompt ?? options.messages.at(-1)?.content ?? ''; + + if (options.outputSchema === promptAssessmentSchema) { + return assessPromptFallback(text); + } + + if (options.outputSchema === emailDraftSchema) { + return draftEmailFallback(text); + } + + return text; + }, + }; +} + +function assessPromptFallback(text: string): z.infer { + const missing: string[] = []; + const questions: string[] = []; + + if (!extractRecipient(text)) { + missing.push('to'); + questions.push('Who should receive it?'); + } + + if (!extractSubject(text)) { + missing.push('subject'); + questions.push('What subject or purpose should it have?'); + } + + if (!hasBodyDetails(text)) { + missing.push('body details'); + questions.push('What key points should the body include?'); + } + + return { + satisfied: missing.length === 0, + missing, + questions, + }; +} + +function draftEmailFallback(text: string): EmailDraft { + const to = extractRecipient(text) ?? 'recipient@example.com'; + const subject = extractSubject(text) ?? 'Following up'; + const bodyDetails = text + .replace(/\s+/g, ' ') + .replace(/\b(to|subject|about|regarding)\b/gi, '') + .trim(); + + return { + to, + subject, + body: [ + 'Hi,', + '', + bodyDetails + ? `I wanted to reach out about ${bodyDetails}.` + : 'I wanted to reach out with a quick update.', + '', + 'Please let me know what you think.', + '', + 'Best,', + ].join('\n'), + }; +} + +function extractRecipient(text: string): string | undefined { + return text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)?.[0]; +} + +function extractSubject(text: string): string | undefined { + const match = text.match(/\bsubject\s*[:=-]\s*([^.;\n]+)/i); + if (match?.[1]) { + return titleCase(match[1].trim()); + } + + const about = text.match(/\b(?:about|regarding)\s+([^.;\n]+)/i); + return about?.[1] ? titleCase(about[1].trim()) : undefined; +} + +function hasBodyDetails(text: string): boolean { + const words = text.trim().split(/\s+/).filter(Boolean); + return ( + words.length >= 14 + || /because|include|mention|tell|ask|thanks|deadline|meeting/i.test(text) + ); +} + +function titleCase(value: string): string { + return value + .split(/\s+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +async function main() { + try { + const machine = createEmailDrafterExample(); + const run = await startSession(machine, { + store: createMemoryRunStore(), + }); + + while (true) { + const snapshot = await waitForRunSnapshot( + run, + (nextSnapshot) => nextSnapshot.status !== 'active' + ); + + if (snapshot.status === 'done') { + console.log(snapshot.output); + break; + } + + if (snapshot.status === 'error') { + throw new Error(formatError(snapshot.error)); + } + + const events = machine.getEvents(snapshot); + + if ('PROMPT_SUBMITTED' in events) { + const request = await promptWithEvents('Email draft request', events); + if (await sendExplicitEvent(run, events, request)) { + continue; + } + + await run.send({ type: 'PROMPT_SUBMITTED', prompt: request }); + continue; + } + + if ('MORE_INFO' in events && 'DRAFT_ANYWAY' in events) { + console.log(`Missing: ${snapshot.context.assessment?.missing.join(', ')}`); + console.log(snapshot.context.assessment?.questions.map((q) => `- ${q}`).join('\n')); + const action = await selectWithEvents( + 'Next', + [ + { name: 'Add details', value: 'add' }, + { name: 'Draft anyway', value: 'draft' }, + ], + events + ); + if (await sendExplicitEvent(run, events, action)) { + continue; + } + + if (action.toLowerCase().startsWith('d')) { + await run.send({ type: 'DRAFT_ANYWAY' }); + continue; + } + + const details = await prompt('More details'); + await run.send({ type: 'MORE_INFO', details }); + continue; + } + + if ('REQUEST_CHANGES' in events && 'SEND' in events) { + if (snapshot.context.draft) { + console.log(formatDraft(snapshot.context.draft)); + } + + const action = await selectWithEvents( + 'Next', + [ + { name: 'Request changes', value: 'changes' }, + { name: 'Send', value: 'send' }, + ], + events + ); + if (await sendExplicitEvent(run, events, action)) { + continue; + } + + if (action.toLowerCase().startsWith('s')) { + await run.send({ type: 'SEND' }); + continue; + } + + const changes = await prompt('Requested changes'); + await run.send({ type: 'REQUEST_CHANGES', changes }); + continue; + } + + if ('ANOTHER' in events && 'END' in events) { + const another = await selectWithEvents( + 'Send another?', + [ + { name: 'Yes', value: 'yes' }, + { name: 'No', value: 'no' }, + ], + events + ); + if (await sendExplicitEvent(run, events, another)) { + continue; + } + + await run.send({ + type: another.toLowerCase().startsWith('y') ? 'ANOTHER' : 'END', + }); + continue; + } + + throw new Error('Email drafter entered an unexpected pending state.'); + } + } finally { + closePrompt(); + } +} + +if (isMain(import.meta.url)) { + void main(); +} + +function formatError(error: unknown): string { + if ( + error + && typeof error === 'object' + && 'message' in error + && typeof error.message === 'string' + ) { + return error.message; + } + + return error instanceof Error ? error.message : String(error); +} + +async function promptWithEvents( + label: string, + events: Record +): Promise { + printAvailableEvents(events); + return prompt(label); +} + +async function selectWithEvents( + label: string, + choices: Array<{ name: string; value: string }>, + events: Record +): Promise { + console.log(`${label}:`); + choices.forEach((choice, index) => { + console.log(` ${index + 1}. ${choice.name}`); + }); + printAvailableEvents(events); + + const answer = await prompt('Choice'); + const choiceIndex = Number(answer) - 1; + if (Number.isInteger(choiceIndex) && choices[choiceIndex]) { + return choices[choiceIndex].value; + } + + const matchingChoice = choices.find( + (choice) => + choice.value.toLowerCase() === answer.toLowerCase() + || choice.name.toLowerCase() === answer.toLowerCase() + ); + + return matchingChoice?.value ?? answer; +} + +function printAvailableEvents(events: Record): void { + console.log( + `Events: ${Object.keys(events).map((event) => `/${event}`).join(' ')}` + ); +} + +async function sendExplicitEvent( + run: { send: (event: any) => Promise }, + events: Record, + value: string +): Promise { + if (!value.startsWith('/')) { + return false; + } + + const match = value.match(/^\/([^\s]+)\s*([\s\S]*)$/); + if (!match) { + return false; + } + + const eventType = resolveEventType(events, match[1]!); + if (!eventType) { + console.log(`Unknown event. Available: ${Object.keys(events).join(', ')}`); + return true; + } + + const payloadText = match[2]!.trim(); + const payload = await resolveEventPayload(eventType, payloadText); + await run.send({ type: eventType, ...payload }); + return true; +} + +function resolveEventType( + events: Record, + input: string +): string | undefined { + return Object.keys(events).find( + (eventType) => eventType.toLowerCase() === input.toLowerCase() + ); +} + +async function resolveEventPayload( + eventType: string, + payloadText: string +): Promise> { + if (payloadText.startsWith('{')) { + return JSON.parse(payloadText); + } + + switch (eventType) { + case 'PROMPT_SUBMITTED': + return { prompt: payloadText || await prompt('prompt') }; + case 'MORE_INFO': + return { details: payloadText || await prompt('details') }; + case 'REQUEST_CHANGES': + return { changes: payloadText || await prompt('changes') }; + default: + return {}; + } +} diff --git a/examples/serverless.ts b/examples/serverless.ts new file mode 100644 index 0000000..ef30076 --- /dev/null +++ b/examples/serverless.ts @@ -0,0 +1,160 @@ +import { z } from 'zod'; +import { + createAgentMachine, + decide, + decideResultSchema, + type DecideAdapter, +} from '../src/index.js'; +import { execute } from '../src/local/index.js'; +import { createOpenAiDecisionAdapter } from './_run.js'; + +const movementOptions = { + moveLeft: { + description: 'Move left when the goal is best served by exploring lower positions.', + }, + moveRight: { + description: 'Move right when the goal is best served by exploring higher positions.', + }, + doNothing: { + description: 'Stay still when there is not enough signal to move safely.', + }, +} as const; + +interface AgentObservation { + id: string; + episodeId: string; + state: { value: string; context: Record }; + previousState?: { value: string; context: Record }; +} + +interface AgentFeedback { + observationId: string; + note: string; +} + +const db = { + observations: [] as AgentObservation[], + feedbackItems: [] as AgentFeedback[], + decisions: [] as Array<{ + episodeId: string; + choice: keyof typeof movementOptions; + data: Record; + }>, +}; + +export function createServerlessExampleMachine( + adapter: DecideAdapter = createOpenAiDecisionAdapter() +) { + return createAgentMachine({ + id: 'serverless-example', + schemas: { + input: z.object({ + episodeId: z.string(), + goal: z.string(), + }), + output: z.object({ + choice: z.enum(['moveLeft', 'moveRight', 'doNothing']), + data: z.record(z.string(), z.unknown()), + }), + }, + context: (input) => ({ + episodeId: input.episodeId, + goal: input.goal, + choice: null as keyof typeof movementOptions | null, + data: {} as Record, + }), + initial: 'deciding', + states: { + deciding: { + schemas: { output: decideResultSchema(movementOptions) }, + invoke: async ({ context }) => + decide({ + adapter, + model: 'openai/gpt-5.4-nano', + prompt: buildDecisionPrompt(context.episodeId, context.goal), + options: movementOptions, + }), + onDone: ({ output }) => ({ + target: 'done', + context: { + choice: output.choice, + data: output.data, + }, + }), + }, + done: { + type: 'final', + output: ({ context }) => ({ + choice: context.choice ?? 'doNothing', + data: context.data, + }), + }, + }, + }); +} + +export async function postObservation(observation: AgentObservation) { + db.observations.push(observation); +} + +export async function postFeedback(feedback: AgentFeedback) { + db.feedbackItems.push(feedback); +} + +export async function getDecision( + req: { + query: { + episodeId: string; + goal: string; + }; + }, + options: { + adapter?: DecideAdapter; + } = {} +) { + const machine = createServerlessExampleMachine(options.adapter); + const result = await execute( + machine, + machine.getInitialState({ + episodeId: req.query.episodeId, + goal: req.query.goal, + }) + ); + + if (result.status !== 'done') { + throw new Error('Serverless decision did not complete'); + } + + db.decisions.push({ + episodeId: req.query.episodeId, + choice: result.output.choice, + data: result.output.data, + }); + + return result.output; +} + +function buildDecisionPrompt(episodeId: string, goal: string): string { + const lastObservation = db.observations + .filter((observation) => observation.episodeId === episodeId) + .at(-1); + const similarObservations = db.observations.filter( + (observation) => + observation.previousState?.value === lastObservation?.previousState?.value + ); + const similarFeedback = db.feedbackItems.filter((feedback) => + similarObservations.some( + (observation) => observation.id === feedback.observationId + ) + ); + + return [ + `Goal: ${goal}`, + lastObservation + ? `Current state: ${JSON.stringify(lastObservation.state)}` + : 'Current state: unknown', + similarFeedback.length + ? `Relevant feedback:\n${similarFeedback.map((feedback) => `- ${feedback.note}`).join('\n')}` + : 'Relevant feedback: none', + ].join('\n\n'); +} From d750efcf98f21ce6ca250a2944521fa8e0b3f09a Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 18 Jun 2026 09:31:15 -0700 Subject: [PATCH 37/50] Make setupAgent withTasks the authoring API --- .changeset/light-hats-drive.md | 9 - .changeset/setup-agent-xstate-cutover.md | 14 + .changeset/sharp-messages-always.md | 9 - docs/burr-parity.md | 50 + docs/crewai-parity.md | 22 +- docs/host-actors.md | 212 ++ docs/langgraph-gaps.md | 30 + docs/langgraph-parity.md | 77 +- ...04-08-langgraph-core-replacement-design.md | 676 ----- examples/README.md | 86 +- examples/_run.ts | 246 -- examples/adapter.ts | 97 - examples/ai-sdk.ts | 167 -- examples/apps/cloudflare-agents/README.md | 10 - examples/apps/cloudflare-agents/src/index.ts | 14 - .../src/review-workflow-agent.ts | 86 - examples/apps/next/README.md | 18 - examples/apps/next/app/api/chat/route.ts | 9 - .../[sessionId]/events/route.ts | 9 - .../api/review-sessions/[sessionId]/route.ts | 9 - .../next/app/api/review-sessions/route.ts | 9 - .../api/stream-sessions/[sessionId]/route.ts | 9 - .../[sessionId]/stream/route.ts | 9 - .../next/app/api/stream-sessions/route.ts | 9 - examples/apps/next/lib/routes.ts | 16 - examples/branching.ts | 139 -- examples/chatbot-messages.ts | 146 -- examples/chatbot.ts | 182 -- examples/classify.ts | 74 - examples/cloudflare-agents.ts | 123 - examples/cloudflare-durable-network.ts | 102 - examples/cloudflare-durable-object.ts | 81 - examples/conditional-subflow.ts | 206 -- examples/content-creator-flow.ts | 142 -- examples/customer-service-sim.ts | 140 -- examples/decide.ts | 95 - examples/email-auto-responder-flow.ts | 226 -- examples/email-drafter.ts | 541 ---- examples/email.ts | 264 -- examples/error-retry.ts | 135 - examples/hitl.ts | 144 -- examples/http-session.ts | 17 - examples/http-streaming-session.ts | 138 -- examples/index.ts | 107 +- examples/joke.ts | 100 - examples/jugs.ts | 135 - examples/lead-score-flow.ts | 207 -- examples/map-reduce.ts | 126 - examples/meeting-assistant-flow.ts | 153 -- examples/multi-agent-network.ts | 333 --- examples/newspaper.ts | 187 -- examples/next-ai-sdk-ui.ts | 213 -- examples/next-app-router.ts | 120 - examples/persistence.ts | 130 - examples/persistent-multi-agent-network.ts | 107 - examples/persistent-streaming.ts | 136 -- examples/persistent-supervisor.ts | 113 - examples/plan-and-execute.ts | 176 -- examples/raffle.ts | 130 - examples/rag.ts | 119 - examples/react-agent-from-scratch.ts | 225 -- examples/react-agent.ts | 96 - examples/reflection.ts | 162 -- examples/rewoo.ts | 235 -- examples/river-crossing.ts | 180 -- examples/self-evaluation-loop-flow.ts | 134 - examples/serverless.ts | 160 -- examples/setup-agent/email-drafter.ts | 421 ++++ examples/setup-agent/game-agent.ts | 196 ++ examples/setup-agent/hosts/ai-sdk-game.ts | 118 + examples/setup-agent/hosts/ai-sdk.ts | 318 +++ .../setup-agent/hosts/cloudflare-agent.ts | 87 + .../hosts/cloudflare-workers-ai.ts | 115 + examples/setup-agent/hosts/tanstack-ai.ts | 111 + examples/setup-agent/smoke.mts | 44 + examples/setup-agent/tsconfig.json | 8 + examples/simple.ts | 63 - examples/spec-agent-loop.ts | 272 --- examples/sql-agent.ts | 270 -- examples/subflow.ts | 145 -- examples/supervisor.ts | 252 -- examples/tool-calling.ts | 139 -- examples/tutor.ts | 105 - examples/workflow-guardrails.ts | 364 --- examples/write-a-book-flow.ts | 200 -- package.json | 17 +- patches/xstate@5.26.0.patch | 96 + pnpm-lock.yaml | 9 +- readme.md | 140 +- scripts/agent-convert.ts | 40 +- src/agent-convert-cli.test.ts | 16 +- src/agent.test.ts | 1881 -------------- src/ai-sdk/index.test.ts | 25 +- src/ai-sdk/index.ts | 120 +- src/burr-equivalents/raw-xstate.test.ts | 644 +++++ src/cloudflare/index.test.ts | 90 - src/cloudflare/index.ts | 147 -- .../content-creator-flow.test.ts | 31 - .../email-auto-responder-flow.test.ts | 41 - .../lead-score-flow.test.ts | 62 - .../meeting-assistant-flow.test.ts | 42 - src/crewai-equivalents/raw-xstate.test.ts | 191 ++ .../self-evaluation-loop-flow.test.ts | 40 - .../write-a-book-flow.test.ts | 45 - src/examples.test.ts | 2171 +---------------- src/fixtures/converter-machine.ts | 66 +- src/graph/index.test.ts | 438 +--- src/graph/index.ts | 1033 +------- src/http/index.test.ts | 157 -- src/http/index.ts | 207 -- src/index.ts | 66 +- src/invoke-events.test.ts | 135 - src/langgraph-equivalents/branching.test.ts | 77 - .../chatbot-messages.test.ts | 48 - .../conditional-subflow.test.ts | 52 - src/langgraph-equivalents/error-retry.test.ts | 119 - src/langgraph-equivalents/graph.test.ts | 119 - src/langgraph-equivalents/hitl.test.ts | 77 - src/langgraph-equivalents/map-reduce.test.ts | 80 - .../multi-agent-network.test.ts | 61 - src/langgraph-equivalents/persistence.test.ts | 86 - .../persistent-multi-agent-network.test.ts | 63 - .../persistent-streaming.test.ts | 26 - .../persistent-supervisor.test.ts | 63 - .../plan-and-execute.test.ts | 36 - .../prebuilt-react.test.ts | 85 - src/langgraph-equivalents/rag.test.ts | 39 - src/langgraph-equivalents/raw-xstate.test.ts | 1216 +++++++++ src/langgraph-equivalents/reflection.test.ts | 31 - src/langgraph-equivalents/rewoo.test.ts | 57 - src/langgraph-equivalents/sql-agent.test.ts | 107 - src/langgraph-equivalents/streaming.test.ts | 69 - src/langgraph-equivalents/subflow.test.ts | 102 - src/langgraph-equivalents/supervisor.test.ts | 63 - .../tool-calling.test.ts | 122 - src/local/index.ts | 4 - src/local/interpreter.ts | 65 - src/machine.ts | 883 ------- src/next/index.test.ts | 89 - src/next/index.ts | 81 - src/persistence.test.ts | 146 -- src/restore.test.ts | 75 - src/runtime/emitter.ts | 36 - src/runtime/events.ts | 7 - src/runtime/index.test.ts | 67 - src/runtime/index.ts | 80 - src/runtime/memory-store.ts | 55 - src/runtime/session.ts | 446 ---- src/runtime/store.ts | 28 - src/session-runtime.test.ts | 229 -- src/session-types.test.ts | 27 - src/setup-agent.test.ts | 781 ++++++ src/setup-agent.ts | 1028 ++++++++ src/stream-snapshot.test.ts | 78 - src/streaming.test.ts | 250 -- src/target-types.assert.ts | 280 --- src/types.ts | 368 +-- src/utils.ts | 301 +-- src/xstate/index.test.ts | 150 +- src/xstate/index.ts | 215 +- tsdown.config.ts | 1 - 161 files changed, 6421 insertions(+), 21906 deletions(-) delete mode 100644 .changeset/light-hats-drive.md create mode 100644 .changeset/setup-agent-xstate-cutover.md delete mode 100644 .changeset/sharp-messages-always.md create mode 100644 docs/burr-parity.md create mode 100644 docs/host-actors.md create mode 100644 docs/langgraph-gaps.md delete mode 100644 docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md delete mode 100644 examples/_run.ts delete mode 100644 examples/adapter.ts delete mode 100644 examples/ai-sdk.ts delete mode 100644 examples/apps/cloudflare-agents/README.md delete mode 100644 examples/apps/cloudflare-agents/src/index.ts delete mode 100644 examples/apps/cloudflare-agents/src/review-workflow-agent.ts delete mode 100644 examples/apps/next/README.md delete mode 100644 examples/apps/next/app/api/chat/route.ts delete mode 100644 examples/apps/next/app/api/review-sessions/[sessionId]/events/route.ts delete mode 100644 examples/apps/next/app/api/review-sessions/[sessionId]/route.ts delete mode 100644 examples/apps/next/app/api/review-sessions/route.ts delete mode 100644 examples/apps/next/app/api/stream-sessions/[sessionId]/route.ts delete mode 100644 examples/apps/next/app/api/stream-sessions/[sessionId]/stream/route.ts delete mode 100644 examples/apps/next/app/api/stream-sessions/route.ts delete mode 100644 examples/apps/next/lib/routes.ts delete mode 100644 examples/branching.ts delete mode 100644 examples/chatbot-messages.ts delete mode 100644 examples/chatbot.ts delete mode 100644 examples/classify.ts delete mode 100644 examples/cloudflare-agents.ts delete mode 100644 examples/cloudflare-durable-network.ts delete mode 100644 examples/cloudflare-durable-object.ts delete mode 100644 examples/conditional-subflow.ts delete mode 100644 examples/content-creator-flow.ts delete mode 100644 examples/customer-service-sim.ts delete mode 100644 examples/decide.ts delete mode 100644 examples/email-auto-responder-flow.ts delete mode 100644 examples/email-drafter.ts delete mode 100644 examples/email.ts delete mode 100644 examples/error-retry.ts delete mode 100644 examples/hitl.ts delete mode 100644 examples/http-session.ts delete mode 100644 examples/http-streaming-session.ts delete mode 100644 examples/joke.ts delete mode 100644 examples/jugs.ts delete mode 100644 examples/lead-score-flow.ts delete mode 100644 examples/map-reduce.ts delete mode 100644 examples/meeting-assistant-flow.ts delete mode 100644 examples/multi-agent-network.ts delete mode 100644 examples/newspaper.ts delete mode 100644 examples/next-ai-sdk-ui.ts delete mode 100644 examples/next-app-router.ts delete mode 100644 examples/persistence.ts delete mode 100644 examples/persistent-multi-agent-network.ts delete mode 100644 examples/persistent-streaming.ts delete mode 100644 examples/persistent-supervisor.ts delete mode 100644 examples/plan-and-execute.ts delete mode 100644 examples/raffle.ts delete mode 100644 examples/rag.ts delete mode 100644 examples/react-agent-from-scratch.ts delete mode 100644 examples/react-agent.ts delete mode 100644 examples/reflection.ts delete mode 100644 examples/rewoo.ts delete mode 100644 examples/river-crossing.ts delete mode 100644 examples/self-evaluation-loop-flow.ts delete mode 100644 examples/serverless.ts create mode 100644 examples/setup-agent/email-drafter.ts create mode 100644 examples/setup-agent/game-agent.ts create mode 100644 examples/setup-agent/hosts/ai-sdk-game.ts create mode 100644 examples/setup-agent/hosts/ai-sdk.ts create mode 100644 examples/setup-agent/hosts/cloudflare-agent.ts create mode 100644 examples/setup-agent/hosts/cloudflare-workers-ai.ts create mode 100644 examples/setup-agent/hosts/tanstack-ai.ts create mode 100644 examples/setup-agent/smoke.mts create mode 100644 examples/setup-agent/tsconfig.json delete mode 100644 examples/simple.ts delete mode 100644 examples/spec-agent-loop.ts delete mode 100644 examples/sql-agent.ts delete mode 100644 examples/subflow.ts delete mode 100644 examples/supervisor.ts delete mode 100644 examples/tool-calling.ts delete mode 100644 examples/tutor.ts delete mode 100644 examples/workflow-guardrails.ts delete mode 100644 examples/write-a-book-flow.ts create mode 100644 patches/xstate@5.26.0.patch delete mode 100644 src/agent.test.ts create mode 100644 src/burr-equivalents/raw-xstate.test.ts delete mode 100644 src/cloudflare/index.test.ts delete mode 100644 src/cloudflare/index.ts delete mode 100644 src/crewai-equivalents/content-creator-flow.test.ts delete mode 100644 src/crewai-equivalents/email-auto-responder-flow.test.ts delete mode 100644 src/crewai-equivalents/lead-score-flow.test.ts delete mode 100644 src/crewai-equivalents/meeting-assistant-flow.test.ts create mode 100644 src/crewai-equivalents/raw-xstate.test.ts delete mode 100644 src/crewai-equivalents/self-evaluation-loop-flow.test.ts delete mode 100644 src/crewai-equivalents/write-a-book-flow.test.ts delete mode 100644 src/http/index.test.ts delete mode 100644 src/http/index.ts delete mode 100644 src/invoke-events.test.ts delete mode 100644 src/langgraph-equivalents/branching.test.ts delete mode 100644 src/langgraph-equivalents/chatbot-messages.test.ts delete mode 100644 src/langgraph-equivalents/conditional-subflow.test.ts delete mode 100644 src/langgraph-equivalents/error-retry.test.ts delete mode 100644 src/langgraph-equivalents/graph.test.ts delete mode 100644 src/langgraph-equivalents/hitl.test.ts delete mode 100644 src/langgraph-equivalents/map-reduce.test.ts delete mode 100644 src/langgraph-equivalents/multi-agent-network.test.ts delete mode 100644 src/langgraph-equivalents/persistence.test.ts delete mode 100644 src/langgraph-equivalents/persistent-multi-agent-network.test.ts delete mode 100644 src/langgraph-equivalents/persistent-streaming.test.ts delete mode 100644 src/langgraph-equivalents/persistent-supervisor.test.ts delete mode 100644 src/langgraph-equivalents/plan-and-execute.test.ts delete mode 100644 src/langgraph-equivalents/prebuilt-react.test.ts delete mode 100644 src/langgraph-equivalents/rag.test.ts create mode 100644 src/langgraph-equivalents/raw-xstate.test.ts delete mode 100644 src/langgraph-equivalents/reflection.test.ts delete mode 100644 src/langgraph-equivalents/rewoo.test.ts delete mode 100644 src/langgraph-equivalents/sql-agent.test.ts delete mode 100644 src/langgraph-equivalents/streaming.test.ts delete mode 100644 src/langgraph-equivalents/subflow.test.ts delete mode 100644 src/langgraph-equivalents/supervisor.test.ts delete mode 100644 src/langgraph-equivalents/tool-calling.test.ts delete mode 100644 src/local/index.ts delete mode 100644 src/local/interpreter.ts delete mode 100644 src/machine.ts delete mode 100644 src/next/index.test.ts delete mode 100644 src/next/index.ts delete mode 100644 src/persistence.test.ts delete mode 100644 src/restore.test.ts delete mode 100644 src/runtime/emitter.ts delete mode 100644 src/runtime/events.ts delete mode 100644 src/runtime/index.test.ts delete mode 100644 src/runtime/index.ts delete mode 100644 src/runtime/memory-store.ts delete mode 100644 src/runtime/session.ts delete mode 100644 src/runtime/store.ts delete mode 100644 src/session-runtime.test.ts delete mode 100644 src/session-types.test.ts create mode 100644 src/setup-agent.test.ts create mode 100644 src/setup-agent.ts delete mode 100644 src/stream-snapshot.test.ts delete mode 100644 src/streaming.test.ts delete mode 100644 src/target-types.assert.ts diff --git a/.changeset/light-hats-drive.md b/.changeset/light-hats-drive.md deleted file mode 100644 index e10de99..0000000 --- a/.changeset/light-hats-drive.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@statelyai/agent': major ---- - -- `agent.generateText(…)` is removed in favor of using the AI SDK's `generateText(…)` function with a wrapped model. -- `agent.streamText(…)` is removed in favor of using the AI SDK's `streamText(…)` function with a wrapped model. -- Custom adapters are removed for now, but may be re-added in future releases. Using the AI SDK is recommended for now. -- Correlation IDs are removed in favor of using [OpenTelemetry with the AI SDK](https://sdk.vercel.ai/docs/ai-sdk-core/telemetry#telemetry). -- The `createAgentMiddleware(…)` function was introduced to facilitate agent message history. You can also use `agent.wrap(model)` to wrap a model with Stately Agent middleware. diff --git a/.changeset/setup-agent-xstate-cutover.md b/.changeset/setup-agent-xstate-cutover.md new file mode 100644 index 0000000..4e1aae6 --- /dev/null +++ b/.changeset/setup-agent-xstate-cutover.md @@ -0,0 +1,14 @@ +--- +"@statelyai/agent": major +--- + +Make `setupAgent(...)` the package authoring API for XState-native agent machines. + +- Remove the legacy `createAgentMachine(...)` builder and the custom local/session runtime surface. +- Remove `@statelyai/agent/local`; runtime is now normal XState actors, snapshots, and `machine.provide({ actors })`. +- Keep model execution transparent: machines invoke well-known text actors with plain XState `invoke`, while hosts provide Vercel AI SDK, LangChain, Workers AI, or custom implementations. +- Add `createAgentSchemas(...)` and `setupAgent(...).withTasks(...)` for schema-first task authoring with typed `invoke.src`, typed invoke input, and typed `onDone.event.output`. +- Add `getAgentEffects(...)`, `doneEvent(...)`, and `transitionResult(...)` for pure XState transition loops where the host/framework owns execution. +- Add task `events` support so model calls can expose whitelisted legal state events as tools. +- Add `parseOutput(...)` for schema-typed model output at assignment boundaries. +- Update graph/XState conversion utilities to consume setupAgent/XState machines directly. diff --git a/.changeset/sharp-messages-always.md b/.changeset/sharp-messages-always.md deleted file mode 100644 index 31172bf..0000000 --- a/.changeset/sharp-messages-always.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@statelyai/agent": minor ---- - -Add first-class session messages and deterministic always transitions. - -Agent states and snapshots now carry `messages` alongside `context`. State hooks receive messages, transition results can replace messages, and helper functions are exported for appending user, assistant, and system messages. - -Machines can now define `always` transitions for deterministic eventless routing. Runtime sessions journal these transitions as internal events so persistence and restore remain replayable. diff --git a/docs/burr-parity.md b/docs/burr-parity.md new file mode 100644 index 0000000..85b5e20 --- /dev/null +++ b/docs/burr-parity.md @@ -0,0 +1,50 @@ +# Burr Parity + +This document tracks where `@statelyai/agent` covers the practical workflow patterns shown in the Apache Burr examples directory. + +## Scope + +The parity target is authoring semantics: + +- explicit state and transitions +- independently testable model/action steps +- typed state, input, and output +- host-owned runtime execution +- persistence through XState snapshots +- streaming through host side channels + +It is not a replacement for Burr's Python runtime, UI, tracker, persistence integrations, or Hamilton/Haystack integrations. + +## External Reference + +As of June 18, 2026, the upstream Burr examples directory includes examples such as `hello-world-counter`, `conversational-rag`, `llm-adventure-game`, `multi-agent-collaboration`, `multi-modal-chatbot`, `streaming-overview`, `tool-calling`, `tracing-and-spans`, `typed-state`, and `web-server`. + +## Matrix + + + +| Burr example pattern | Status | Agent equivalent | +| --- | --- | --- | +| Hello world counter / guarded loop | Covered | Explicit XState state, guarded loop, and final output in [`src/burr-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/burr-equivalents/raw-xstate.test.ts) | +| Conversational RAG with memory in state | Covered | Retrieval as typed host actor, memory in machine context, answer as named task logic | +| Streaming overview router | Covered | Safety check, mode routing, streaming side channel, final text transition | +| Tool calling | Covered | Tool selection as structured text logic, local tool actors, final formatter text logic | +| Typed state / structured output | Covered | Schema-derived context/output plus named structured text logic | +| Multi-agent collaboration | Covered | Supervisor routing to typed worker actors | + +## Why This Is Different + +Burr action definitions are runtime-owned executable steps. `@statelyai/agent` keeps those steps as portable authoring contracts: + +- `withTasks(...)` owns typed request construction. +- `setupAgent(...)` owns typed machine authoring. +- Hosts own model providers, streaming, persistence, tracing, and deployment. + +That gives Burr-style individually testable actions without adopting a Burr-style runtime. + +## Still Out Of Scope + +- Burr UI/tracker parity as a packaged runtime feature +- Python integration packages +- Hamilton/Haystack adapter parity +- published persistence backends beyond XState snapshots and examples diff --git a/docs/crewai-parity.md b/docs/crewai-parity.md index b2f2555..a635998 100644 --- a/docs/crewai-parity.md +++ b/docs/crewai-parity.md @@ -36,24 +36,24 @@ Primary sources: ## Matrix - + | CrewAI Flow example | Status | Agent equivalent | | --- | --- | --- | -| Content Creator Flow | Covered | [`examples/content-creator-flow.ts`](/Users/davidkpiano/Code/agent/examples/content-creator-flow.ts), [`src/crewai-equivalents/content-creator-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/content-creator-flow.test.ts) | -| Email Auto Responder Flow | Covered | [`examples/email-auto-responder-flow.ts`](/Users/davidkpiano/Code/agent/examples/email-auto-responder-flow.ts), [`src/crewai-equivalents/email-auto-responder-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/email-auto-responder-flow.test.ts) | -| Lead Score Flow | Covered | [`examples/lead-score-flow.ts`](/Users/davidkpiano/Code/agent/examples/lead-score-flow.ts), [`src/crewai-equivalents/lead-score-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/lead-score-flow.test.ts) | -| Meeting Assistant Flow | Covered | [`examples/meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts), [`src/crewai-equivalents/meeting-assistant-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/meeting-assistant-flow.test.ts) | -| Self Evaluation Loop Flow | Covered | [`examples/self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts), [`src/crewai-equivalents/self-evaluation-loop-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/self-evaluation-loop-flow.test.ts) | -| Write a Book with Flows | Covered | [`examples/write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts), [`src/crewai-equivalents/write-a-book-flow.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/write-a-book-flow.test.ts) | +| Content Creator Flow | Covered | [`src/crewai-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/raw-xstate.test.ts) | +| Email Auto Responder Flow | Covered | Same `setupAgent(...).withTasks(...)`/XState primitives as content routing plus persisted XState snapshots | +| Lead Score Flow | Covered | Same `setupAgent(...).withTasks(...)`/XState primitives as HITL review plus typed worker actors | +| Meeting Assistant Flow | Covered | Same `setupAgent(...).withTasks(...)`/XState primitives as fan-out worker actors | +| Self Evaluation Loop Flow | Covered | Same `setupAgent(...).withTasks(...)`/XState primitives as guarded retry/re-entry | +| Write a Book with Flows | Covered | [`src/crewai-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/raw-xstate.test.ts) | ## Notes - CrewAI’s `content_creator_flow/` directory in the current examples repo clone is empty, so that equivalence is based on the current official descriptions: multi-format content routing across blog, LinkedIn, and research outputs. -- Several of these patterns overlap with existing generic examples here, but they are still represented as CrewAI-named examples so the parity surface is explicit instead of inferred. +- Several of these patterns overlap with LangGraph-style examples; they are represented with `setupAgent(...).withTasks(...)`/XState tests so the parity surface is explicit without maintaining duplicate legacy example files. ## Differences -- Logic remains explicit state-machine logic instead of CrewAI decorator-based method routing. -- Session contracts expose snapshots and event journals; production persistence belongs in adapters. -- Fan-out is expressed in plain JavaScript `Promise.all(...)` inside invokes where that is simpler than introducing framework-specific branching primitives. +- Logic remains explicit XState logic instead of CrewAI decorator-based method routing. +- Persistence uses normal XState persisted snapshots; production storage belongs in host adapters. +- Fan-out is expressed with normal XState actors or plain JavaScript inside host actors. diff --git a/docs/host-actors.md b/docs/host-actors.md new file mode 100644 index 0000000..0abfe43 --- /dev/null +++ b/docs/host-actors.md @@ -0,0 +1,212 @@ +# Host Actors + +`setupAgent(...).withTasks(...)` describes model work as named tasks. The host still owns execution. + +The text logic declares: + +- input and output schemas +- model reference +- prompt/messages/system content +- optional tools, machine events, metadata, and common model options + +The machine declares: + +- state flow +- `invoke.src` as a registered logic name +- typed invoke `input` +- typed `onDone.event.output` + +The host provides: + +- Vercel AI SDK, Cloudflare Workers AI, LangChain, local models, or custom code +- streaming side channels +- tracing/logging +- provider options +- persistence and transport + +## Blessed Pattern + +Use named text logic and plain XState `invoke` objects. For maximum framework portability, run the machine with XState's pure transition functions and execute returned agent effects yourself. + +```ts +import { + createAgentSchemas, + getAgentEffects, + setupAgent, + transitionResult, +} from '@statelyai/agent'; +import { assign, initialTransition } from 'xstate'; + +const schemas = createAgentSchemas({ + context: contextSchema, + input: inputSchema, + output: outputSchema, + events: eventSchemas, +}); + +const agent = setupAgent({ schemas }).withTasks({ + draftText: { + schemas: { + input: draftInputSchema, + output: resultSchema, + }, + model: 'openai/gpt-5.4-nano', + prompt: ({ input }) => input.prompt, + temperature: 0.2, + events: ['APPROVE', 'REVISE'], + }, +}); + +const machine = agent.createMachine({ + initial: 'generating', + states: { + generating: { + invoke: { + id: 'draft', + src: 'draftText', + input: ({ context }) => ({ prompt: context.prompt }), + onDone: { + target: 'done', + actions: assign({ + result: ({ event }) => event.output, + }), + }, + }, + }, + done: { type: 'final' }, + }, +}); + +let [snapshot, actions] = initialTransition(machine, input); + +while (snapshot.status !== 'done') { + for (const effect of getAgentEffects(actions, { + snapshot, + schemas: agent.schemas, + actors: { draftText }, + })) { + const output = await generateText({ + ...effect.input, + tools: effect.tools, + }); + [snapshot, actions] = transitionResult(machine, snapshot, effect, output); + } +} +``` + +Every agent invoke should have a durable `id`; that ID is used to resume the matching `onDone` transition. + +## Allowed Event Tools + +Use task `events` to expose specific state transitions as tools. `getAgentEffects(...)` validates that those events are legal from the current snapshot and returns event tools separately from the model-call input. + +```ts +const effects = getAgentEffects(actions, { + snapshot, + schemas: agent.schemas, + actors: { chooseMove }, +}); + +const effect = effects[0]; +Object.keys(effect.tools); +// ['event.ATTACK', 'event.DEFEND'] +``` + +Each event tool returns the event object: + +```ts +await effect.tools['event.ATTACK'].execute({ target: 'orc' }); +// { type: 'ATTACK', target: 'orc' } +``` + +Only events listed in task `events` are exposed. If an event is listed but is not legal from the current state, it is omitted. + +## Actor Runtime + +When you want XState to execute invokes directly, provide implementations for the named task actors with `logic.withExecutor(...)`. The lower-level `createTextLogic(...)` primitive also accepts an executor, but `withTasks(...)` is the preferred authoring path. + +```ts +const executableDraftText = agent.tasks.draftText.withExecutor( + async ({ request, signal }) => { + const result = await generateObject({ + model: resolveModel(request.model), + system: request.system, + prompt: request.prompt ?? '', + schema: request.outputSchema as never, + abortSignal: signal, + }); + return result.object; + } +); +``` + +For app-level adapters, overriding with `withExecutor(...)` is often cleaner: + +```ts +import { generateObject, generateText } from 'ai'; + +const actors = { + draftText: agent.tasks.draftText.withExecutor(async ({ request, signal }) => { + if (request.outputSchema) { + const result = await generateObject({ + model: resolveModel(request.model), + system: request.system, + prompt: request.prompt ?? '', + schema: request.outputSchema as never, + abortSignal: signal, + }); + return result.object; + } + + const result = await generateText({ + model: resolveModel(request.model), + system: request.system, + prompt: request.prompt ?? '', + abortSignal: signal, + }); + return result.text as never; + }), +}; +``` + +Then run any machine with those actors: + +```ts +createActor(machine.provide({ actors }), { input }).start(); +``` + +## Metadata + +Use `metadata` for host-specific details. It is intentionally not interpreted by `@statelyai/agent`. + +```ts +const agent = setupAgent({ schemas }).withTasks({ + draftText: { + schemas: { + input: draftInputSchema, + output: resultSchema, + }, + model: 'openai/gpt-5.4-nano', + prompt: ({ input }) => input.prompt, + metadata: ({ input }) => ({ + traceId: input.requestId, + }), + }, +}); +``` + +This is different from XState `meta`. XState `meta` describes state nodes and transitions for tooling. Text logic `metadata` is runtime input passed to the host actor. + +## Streaming + +Streaming chunks should stay in the host side channel: HTTP stream, WebSocket, AI SDK UI stream, stdout, tracing callback, etc. The machine transitions on the final text. That keeps snapshots deterministic and replayable. + +The same task logic can be executed with `generateText(...)` or `streamText(...)`; the host decides. + +## Low-Level Built-Ins + +`agent.generate`, `agent.stream`, and `createTextLogic(...)` still exist as low-level escape hatches. Prefer `setupAgent(...).withTasks(...)` for new authoring because it gives reusable request construction, typed source names, typed invoke input, typed `event.output`, and schema-typed machine event tools. + +## Why This Shape + +The machine stays portable and visualizable. The host keeps full runtime control. You can use existing SDK code directly, but the workflow still gets typed transitions, XState snapshots, inspection, testing, and graph export. diff --git a/docs/langgraph-gaps.md b/docs/langgraph-gaps.md new file mode 100644 index 0000000..8c7ee2a --- /dev/null +++ b/docs/langgraph-gaps.md @@ -0,0 +1,30 @@ +# LangGraph Gap Tracker + +This tracks remaining gaps one by one. The goal is not to clone LangGraph; it is to make `@statelyai/agent` the better choice when developers want explicit, typed, visual state machine agents with flexible runtime ownership. + +## Product Gaps + +| Gap | Why it matters | Likely shape | +| --- | --- | --- | +| Checkpoint adapters | LangGraph users expect durable threads/checkpoints without inventing storage glue. | Example first, then optional packages for SQLite/Postgres/Redis using XState persisted snapshots. | +| UI streaming transports | Demos need to feel complete in React/Svelte/HTTP/WebSocket apps. | Host-side stream examples using AI SDK UI streams and WebSocket/SSE. | +| Interrupt/resume helpers | HITL is expressible today, but LangGraph has explicit interrupt ergonomics. | Small helpers/patterns around states, events, and persisted snapshots. | +| Prebuilt supervisor/swarm helpers | Current tests prove expressibility, but some users want a shortcut. | Additive helpers built on `setupAgent(...).withTasks(...)`, not a separate runtime. | +| Long-term memory/store examples | RAG is covered as host actors; storage ownership needs clearer examples. | Retrieval/storage actors with local and hosted backend examples. | +| Observability/tracing | Visualization covers static structure; runtime traces are separate. | XState inspection hooks plus OpenTelemetry/LangSmith-style host examples. | +| LangGraph migration tooling | Parity is manual today. | Documented recipes first; optional graph-to-XState codemod later. | +| Platform-only features | LangGraph Platform includes hosted threads, cron, deployment, Studio. | Out of package scope unless Stately platform integration becomes a goal. | + +## Coverage Status + +- Covered in tests: branching, HITL, tool calling, streaming, persistence, subflows, supervisor routing, map-reduce, RAG, reflection, ReWOO, SQL-style agents, persistent multi-agent networks. +- Covered by package surface: typed `setupAgent(...).withTasks(...)`, host-provided execution, XState snapshots, graph/mermaid export. +- Not yet covered by polished examples: checkpoint storage adapters, UI streaming transports, memory backends, tracing, migration guide. + +## Recommended Order + +1. Add UI streaming examples with Vercel AI SDK and plain Web Streams. +2. Add checkpoint adapter examples using XState persisted snapshots. +3. Add interrupt/resume helper docs. +4. Add memory/retrieval backend examples. +5. Decide whether supervisor/swarm helpers deserve package API. diff --git a/docs/langgraph-parity.md b/docs/langgraph-parity.md index 06ceaa6..3e2ed91 100644 --- a/docs/langgraph-parity.md +++ b/docs/langgraph-parity.md @@ -7,7 +7,8 @@ This document tracks where authored `@statelyai/agent` machines can model the pr It is intentionally scoped to: - state-machine authoring concepts -- local session contract behavior +- full type-safe XState authoring through `setupAgent(...).withTasks(...)` +- XState actor, snapshot, and host-adapter behavior - adapter and transport example behavior - runnable examples and tests in this repo @@ -27,38 +28,72 @@ As of April 25, 2026, the upstream `langgraphjs` repo exposes: The parity target here is authoring semantics and adapter targets, not a replacement runtime or the whole surrounding product/package ecosystem. +## Why choose this + +The strongest reason to choose `@statelyai/agent` over LangGraph is that the workflow is just XState: + +- no hidden graph runtime is required +- every transition, guard, actor, snapshot, and event is inspectable +- TypeScript checks the machine boundary, external events, and typed host actors +- visualization comes from the authored machine, not a separate reconstruction +- model and tool execution stay in host code, so Vercel AI SDK, LangChain, Workers AI, SQL clients, and local functions remain swappable + +The strongest reason to choose it over handrolling is that agent control flow is usually the product. Once a workflow needs branching, review gates, retries, persistence, subflows, or multi-agent routing, plain async functions become implicit state machines. `withTasks(...)` makes model steps individually testable, and `setupAgent(...)` makes the state machine explicit without taking over the runtime. + +Remaining gaps are tracked in [`langgraph-gaps.md`](/Users/davidkpiano/Code/agent/docs/langgraph-gaps.md). + ## Matrix | LangGraphJS concept | Status | Agent equivalent | | --- | --- | --- | -| Branching / conditional routing | Covered | [`examples/branching.ts`](/Users/davidkpiano/Code/agent/examples/branching.ts), [`src/langgraph-equivalents/branching.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/branching.test.ts) | -| Subgraphs / nested flows | Covered | [`examples/subflow.ts`](/Users/davidkpiano/Code/agent/examples/subflow.ts), [`examples/conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts), [`src/langgraph-equivalents/subflow.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/subflow.test.ts), [`src/langgraph-equivalents/conditional-subflow.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/conditional-subflow.test.ts) | -| Human-in-the-loop / approval gate | Covered | [`examples/hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts), [`src/langgraph-equivalents/hitl.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/hitl.test.ts) | -| Session restore from snapshots + events | Local adapter | [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts), [`src/langgraph-equivalents/persistence.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistence.test.ts) | -| Streaming emitted parts | Covered | [`examples/persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts), [`src/langgraph-equivalents/streaming.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/streaming.test.ts), [`src/langgraph-equivalents/persistent-streaming.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistent-streaming.test.ts) | -| Tool calling with intermediate progress | Covered | [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts), [`src/langgraph-equivalents/tool-calling.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/tool-calling.test.ts) | -| Retry loops / explicit recovery | Covered | [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts), [`src/langgraph-equivalents/error-retry.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/error-retry.test.ts) | -| Plan-and-execute | Covered | [`examples/plan-and-execute.ts`](/Users/davidkpiano/Code/agent/examples/plan-and-execute.ts), [`src/langgraph-equivalents/plan-and-execute.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/plan-and-execute.test.ts) | -| Map-reduce style workflows | Covered | [`examples/map-reduce.ts`](/Users/davidkpiano/Code/agent/examples/map-reduce.ts), [`src/langgraph-equivalents/map-reduce.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/map-reduce.test.ts) | -| Reflection loop | Covered | [`examples/reflection.ts`](/Users/davidkpiano/Code/agent/examples/reflection.ts), [`src/langgraph-equivalents/reflection.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/reflection.test.ts) | -| ReWOO-style planner / worker decomposition | Covered | [`examples/rewoo.ts`](/Users/davidkpiano/Code/agent/examples/rewoo.ts), [`src/langgraph-equivalents/rewoo.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/rewoo.test.ts) | -| Supervisor routing | Covered | [`examples/supervisor.ts`](/Users/davidkpiano/Code/agent/examples/supervisor.ts), [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts), [`src/langgraph-equivalents/supervisor.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/supervisor.test.ts), [`src/langgraph-equivalents/persistent-supervisor.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistent-supervisor.test.ts) | -| Multi-agent handoffs | Covered | [`examples/multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/multi-agent-network.ts), [`examples/persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts), [`src/langgraph-equivalents/multi-agent-network.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/multi-agent-network.test.ts), [`src/langgraph-equivalents/persistent-multi-agent-network.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/persistent-multi-agent-network.test.ts) | -| SQL/tool-heavy agent workflow | Covered | [`examples/sql-agent.ts`](/Users/davidkpiano/Code/agent/examples/sql-agent.ts), [`src/langgraph-equivalents/sql-agent.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/sql-agent.test.ts) | -| ReAct-style agent | Covered | [`examples/react-agent-from-scratch.ts`](/Users/davidkpiano/Code/agent/examples/react-agent-from-scratch.ts), [`examples/react-agent.ts`](/Users/davidkpiano/Code/agent/examples/react-agent.ts), [`src/langgraph-equivalents/prebuilt-react.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/prebuilt-react.test.ts) | -| Message-centric chatbot state | Covered | [`examples/chatbot-messages.ts`](/Users/davidkpiano/Code/agent/examples/chatbot-messages.ts), [`src/langgraph-equivalents/chatbot-messages.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/chatbot-messages.test.ts) | -| Retrieval-augmented generation | Covered | [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts), [`src/langgraph-equivalents/rag.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/rag.test.ts) | -| HTTP session transport | Covered | [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts), [`src/examples.test.ts`](/Users/davidkpiano/Code/agent/src/examples.test.ts) | -| HTTP streaming transport / reconnect | Adapter example | [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts), [`src/examples.test.ts`](/Users/davidkpiano/Code/agent/src/examples.test.ts) | -| Graph export / visualization support | Covered | [`src/graph/index.ts`](/Users/davidkpiano/Code/agent/src/graph/index.ts), [`src/xstate/index.ts`](/Users/davidkpiano/Code/agent/src/xstate/index.ts), [`src/langgraph-equivalents/graph.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/graph.test.ts) | +| Graph/state-machine authoring with typed state/events | Covered | `setupAgent(...).withTasks(...)`, [`examples/setup-agent/email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/email-drafter.ts), [`src/setup-agent.test.ts`](/Users/davidkpiano/Code/agent/src/setup-agent.test.ts), [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Branching / conditional routing | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Subgraphs / nested flows | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Human-in-the-loop / approval gate | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Session restore from snapshots | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Streaming side channels | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts), [`examples/setup-agent/hosts/ai-sdk.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/ai-sdk.ts) | +| Tool calling with intermediate progress | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Plan-and-execute | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Map-reduce / fan-out workflows | Covered | Expressed with normal XState actors plus `Promise.all(...)`; see [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Reflection / retry loops | Covered | Explicit draft/critique/check loop with shared critique schema in [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| ReWOO-style planner / worker decomposition | Covered | Planner output schema, worker evidence map, and final solver state in [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Supervisor routing | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Multi-agent handoffs | Covered | Expressed as supervisor routing to typed child actors; see [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| SQL/tool-heavy agent workflow | Covered | Query generation, DB execution, and answer synthesis are separate typed states in [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| ReAct-style agent | Covered | Expressed as explicit observe/think/act states or typed tool actor loops | +| Message-centric chatbot state | Covered | `messagesSchema`, `addMessages(...)`, and plain XState context in [`src/setup-agent.ts`](/Users/davidkpiano/Code/agent/src/setup-agent.ts) | +| Retrieval-augmented generation | Covered | Retrieval is a typed host actor; generation is named text logic invoked as `src: 'answerQuestion'` | +| HTTP / framework transport | Adapter example | Host XState actors behind HTTP, WebSocket, Cloudflare Agents, or any framework runtime | +| Graph export / visualization support | Covered | Authored machines are normal XState machines and can use the XState/Stately visualization path directly | + +## XState coverage + +`setupAgent(...).withTasks(...)` is the first-class path. Current tests cover these applicable LangGraph example shapes with typed XState machines and local `createActor(...)` execution: + +- conditional routing +- human-in-the-loop approval +- plan-and-execute with generated structured output plus local actors +- subflows and supervisor routing +- map-reduce fan-out and reduction +- RAG retrieval plus generation +- reflection loops +- ReWOO planner/worker/solver decomposition +- SQL-style query/tool/synthesis flow +- persistent multi-agent networks +- persistence from XState snapshots +- host-side streaming side channels +- tool-calling as typed host actors +- schema-carrying named text logic +- host replacement through `machine.provide({ actors })` ## Intentional differences These are currently deliberate, not gaps: - Logic stays pure: `(state, event) -> { nextState, effects }`. +- Developers author normal XState with `setupAgent(...).withTasks(...)`; LangGraph-style workflows map without giving up runtime control. - Emitted events are live runtime effects, not durable journal entries. - Session behavior is based on first-class snapshot + event contracts; production durability belongs in adapters. - `run.on(...)` is reserved for emitted events only; terminal/runtime hooks use dedicated methods like `run.onDone(...)`. diff --git a/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md b/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md deleted file mode 100644 index a304dee..0000000 --- a/docs/superpowers/specs/2026-04-08-langgraph-core-replacement-design.md +++ /dev/null @@ -1,676 +0,0 @@ -# LangGraph Core Replacement Design - -## Goal - -Evolve `agent` into a LangGraph-core replacement in terms of runtime behavior and developer outcomes, while improving on LangGraph through a simpler, more explicit state-machine model. - -The target is semantic parity for core orchestration use cases, not API compatibility. Developers should be able to build the same classes of systems in `agent` that they can build with LangGraph core, but using `agent`'s state-machine API and philosophy. - -## Core Philosophies - -The design is constrained by these principles: - -1. Logic is pure. - The semantic center remains: - - ```ts - (currentState, event) => { - return { nextState, effects }; - } - ``` - - State transition logic should stay deterministic, replayable, and inspectable. - -2. Effect execution is first-class. - The runtime must make it easy to both transition state and execute effects, but without collapsing transition logic into effectful code. Effects are driven by the machine, not hidden as the machine. - -3. Durability is core. - `agent` should treat persisted state and event history as first-class runtime concerns, not as optional add-ons. - -4. Runner-agnostic execution. - The runtime must be able to run anywhere: Node, Vercel, Cloudflare, Durable Objects, workers, and other environments. Storage and execution coordination must be abstracted behind portable interfaces. - -5. Improve on LangGraph rather than imitate it. - Do not copy LangGraph's graph-builder surface area or its more complex runtime semantics where a simpler state-machine formulation produces the same outcome. - -## Scope - -In scope: - -- core orchestration behavior currently covered by `@langchain/langgraph` -- runtime behavior tests and runnable examples from LangGraph core, rewritten as `agent`-idiomatic equivalents -- persistence, replay, resume, streaming, pending states, submachine composition, and high-value prebuilt agent patterns - -Out of scope for this design: - -- LangGraph monorepo packages outside core -- API/CLI/server packages -- UI framework SDKs and app templates -- type-level compatibility with LangGraph -- exact API or import-path matching - -## Design Summary - -`agent` should become a durable run engine for state machines. - -A machine definition remains declarative and mostly pure: - -- states -- transitions -- invoke/effect boundaries -- final outputs - -A run becomes the primary runtime object: - -- backed by an append-only replay journal -- accelerated by persisted snapshots -- observable through a first-class event stream -- resumable from persisted state -- portable across runners via abstract persistence and scheduling interfaces - -This yields a model where the semantics are simple: - -- transitions are deterministic -- invokes are explicit effect boundaries -- external and internal machine events drive progress -- streaming is run-level, not bolted on -- persistence is a core contract - -## Runtime Model - -The machine model should stay state-machine-first rather than graph-builder-first. - -### Machine Definition - -A machine definition remains responsible for: - -- context initialization -- current state value -- transition handlers -- invoke definitions -- terminal outputs - -The machine should continue to express workflows such as: - -- branching -- tool-using agents -- human review loops -- multi-step planning and execution -- nested machine orchestration - -### Run Model - -Introduce a durable session as the central execution concept. - -`sessionId` should be the canonical persisted identifier. - -`run` can still be a useful public term for the live handle returned by the runtime, but the durable identity should align with actor/session terminology. - -Each run has: - -- `sessionId` -- `machineId` -- input payload -- current snapshot -- append-only replay journal -- status -- subscribers - -Suggested shape: - -```ts -interface AgentRun { - sessionId: string; - status: "active" | "pending" | "done" | "error"; - getSnapshot(): AgentSnapshot; - send(event: { type: string; [key: string]: unknown }): Promise; - on(type: string, handler: (event: unknown) => void): () => void; -} -``` - -An async-iterator surface is still useful, but it is additive. The emitter-style `on(...)` API is the required phase-1 contract. - -`on(...)` is a live listener only. It should not be treated as a history or replay API. Historical actor events belong to the journal/store layer. - -### Durable Execution Boundaries - -Phase 1 durability should exist at machine boundaries, not inside arbitrary user async code. - -Persist the replayable machine events: - -- external events sent to the actor -- internal machine events emitted by the runtime -- invoke completion events -- invoke failure events - -Do not claim sub-invoke durability for plain `Promise.all(...)` or arbitrary nested promises. - -### Pending and Human-in-the-Loop - -Do not introduce an interrupt primitive as a core concept. - -Use explicit pending states and external events: - -```ts -review: { - on: { - approve: { target: "send" }, - reject: { target: "revise" }, - }, -} -``` - -This preserves: - -- deterministic replay -- explicit control flow -- durable resume semantics -- runner portability - -### Submachine Composition - -Do not introduce graph/subgraph composition as a first-class structural primitive in phase 1. - -Instead, allow composition through normal execution: - -```ts -writing: { - invoke: async ({ context }) => { - return executeAgentMachine(writerMachine, { - input: { - topic: context.topic, - research: context.research, - }, - }); - }, -} -``` - -This is sufficient for most LangGraph subgraph outcomes without graph-specific composition APIs. - -## Purity and Effects - -The central architectural requirement is preserving pure transition logic while still making effects first-class. - -Conceptually, every runtime step should be explainable as: - -```ts -const { nextState, effects } = transition(currentState, event); -``` - -Where: - -- `nextState` is deterministic -- `effects` are explicit runtime work to perform next - -In practice, current `agent` APIs already combine these concerns inside state configs. The design should move the runtime toward an explicit internal split even if the external authoring API remains ergonomic. - -That means: - -- transition logic should remain replayable without rerunning effects -- effect lifecycle should be represented through emitted machine events -- invoke results should be fed back as events, not hidden mutations - -This should follow the same philosophy as XState invoke completion: - -- invoke completion becomes an internal done event -- invoke failure becomes an internal error event -- the machine progresses by consuming events, not by direct mutation from effect code - -This is the main improvement opportunity over LangGraph's more graph-runtime-centric model. - -## Persistence Model - -The canonical persisted representation is an append-only replay journal. - -Snapshots are derived state used to accelerate replay and resume. - -### Replay Journal - -The replay journal is the source of truth. It contains the actual events consumed by the actor, including synthetic internal events produced by the runtime. - -Suggested minimal replayable event family: - -```ts -type JournalEvent = - | { type: "xstate.init"; input?: unknown; at: number } - | { type: "user.message"; [key: string]: unknown; at: number } - | { type: "approve"; at: number } - | { type: "xstate.done.invoke.research"; output: unknown; at: number } - | { - type: "xstate.error.invoke.research"; - error: SerializedError; - at: number; - }; -``` - -The exact event naming can be refined, but the important property is that invoke done/error are actor events, not metadata records. - -### Runtime and Audit Events - -Derived runtime records can still exist for observability and subscriptions, but they are not the canonical replay source. - -Examples: - -- state entered -- transition applied -- snapshot persisted -- session completed -- session failed - -These belong in the runtime event stream and diagnostics layer. - -### Snapshots - -Suggested snapshot shape: - -```ts -type AgentSnapshot = { - value: string; - context: Record; - status: "active" | "done" | "error" | "pending"; - createdAt: number; - sessionId: string; - input: Record>; - output?: unknown; - error?: SerializedError; -}; - -type PersistedSnapshot = { - sessionId: string; - snapshot: AgentSnapshot; - afterSequence: number; - createdAt: number; -}; -``` - -This aligns the live snapshot shape closely with XState snapshots: - -- `value` -- `context` -- `status` - -with additional metadata such as: - -- `createdAt` -- `sessionId` -- optional `output` -- optional `error` - -The `afterSequence` field identifies the last replayable journal event already reflected in the snapshot, so replay can resume from a known journal offset without inventing a separate semantic version. - -### Replay Model - -Restore a run by: - -1. loading the latest snapshot -2. replaying all journal events after that snapshot -3. reconstructing the current live run state - -If no snapshot exists, replay from `xstate.init`. - -### Storage Interface - -Persistence must be abstracted behind a portable interface: - -```ts -interface RunStore { - append(sessionId: string, event: JournalEvent): Promise; - loadEvents(sessionId: string, afterSequence?: number): Promise; - loadLatestSnapshot(sessionId: string): Promise; - saveSnapshot(snapshot: PersistedSnapshot): Promise; -} -``` - -This is what makes the runtime portable to: - -- in-memory test stores -- SQL or key-value stores -- Cloudflare Durable Objects -- Vercel-backed durable layers -- custom app infrastructure - -### Important Phase 1 Constraint - -Invoke internals are opaque unless user code or future helpers explicitly expose finer-grained durable progress. - -This means: - -- plain async code remains ergonomic -- invoke-level durability is honest -- future `task(...)` or `parallel(...)` helpers remain additive - -## Streaming Model - -Streaming must be a first-class capability of a run. - -Separate: - -1. durable runtime events -2. ephemeral stream parts - -### Run-Level Events - -Suggested public stream model: - -```ts -type RunEmitterEvent = - | { type: "state"; snapshot: AgentSnapshot } - | { type: "machine.event"; event: JournalEvent } - | { type: "runtime"; event: RuntimeEvent } - | { type: "part"; part: StreamPart } - | { type: "done"; output: unknown } - | { type: "error"; error: unknown }; -``` - -Where `machine.event` refers to replayable actor events and `runtime` refers to derived lifecycle records useful for debugging and orchestration. - -These event shapes describe what a live run may emit. They do not imply that late subscribers receive replayed history through `on(...)`. - -Suggested runtime event family: - -```ts -type RuntimeEvent = - | { type: "session.started"; sessionId: string; at: number } - | { type: "session.restored"; sessionId: string; afterSequence: number; at: number } - | { type: "snapshot.persisted"; sessionId: string; afterSequence: number; at: number } - | { type: "session.completed"; sessionId: string; at: number } - | { type: "session.failed"; sessionId: string; error: SerializedError; at: number }; -``` - -Derived events such as `state.entered` and `transition.applied` are still useful for richer inspection, but they are not required for this phase. - -### Stream Parts - -For common model/tool streaming shapes, align with Vercel AI SDK-style part conventions where practical: - -```ts -type StreamPart = - | { type: "text-start"; id: string } - | { type: "text-delta"; id: string; delta: string } - | { type: "text-end"; id: string } - | { type: "tool-input-start"; toolCallId: string; toolName: string } - | { type: "tool-input-delta"; toolCallId: string; inputTextDelta: string } - | { type: "tool-input-available"; toolCallId: string; toolName: string; input: unknown } - | { type: "tool-output-available"; toolCallId: string; output: unknown } - | { type: "reasoning-part"; text: string } - | { type: "data"; data: unknown } - | { type: "error"; errorText: string }; -``` - -Provide convenience listeners on top: - -```ts -run.on("textPart", ({ delta }) => {}); -run.on("toolCall", ({ toolCallId, toolName, input }) => {}); -run.on("toolResult", ({ toolCallId, output }) => {}); -``` - -### Emission Model - -Invoke code should be able to emit live parts using a separate enqueue/emission argument: - -```ts -drafting: { - invoke: async ({ context }, enq) => { - for await (const chunk of streamText(...)) { - enq.emit({ type: "text-delta", id: "draft", delta: chunk }); - } - return { draft: finalText }; - }, -} -``` - -Durable runtime events are persisted. Stream parts are ephemeral by default in phase 1. - -Using a second argument is important because it preserves a useful authoring distinction: - -- one-argument functions are easier to lint as pure/no-emission -- two-argument functions explicitly opt into streaming side effects - -### Emitted Schemas - -Machine definitions should support emitted event schemas alongside input and external event schemas. - -Suggested direction: - -```ts -schemas: { - input: ..., - events: { - approve: ..., - reject: ..., - }, - emitted: { - textPart: ..., - toolCall: ..., - toolResult: ..., - }, -} -``` - -This gives: - -- typed live emissions -- runtime validation of emitted parts -- stronger UI integration -- symmetry with event schemas - -## Runner-Agnostic Architecture - -The runtime must not assume: - -- long-lived Node processes -- a specific queue system -- a specific database -- process-local memory as truth - -The core should be split into: - -1. pure machine semantics -2. durable run orchestration -3. storage abstraction -4. environment-specific runner adapters - -This makes it possible to showcase: - -- standard Node process usage -- Vercel usage -- Cloudflare Worker usage -- Cloudflare Durable Object usage - -Durable Objects are especially relevant because they demonstrate the design clearly: - -- replay journal and snapshot persistence can live in DO state -- run coordination can be serialized naturally -- stream subscriptions can be implemented via the object lifecycle - -The important point is that Durable Objects should be an example adapter, not the core assumption. - -## Capability Mapping from LangGraph Core - -### Directly Mappable - -- graph orchestration -> explicit machine states and transitions -- shared state update workflows -> `invoke` + `onDone` context updates -- human-in-the-loop -> pending states + external events -- subgraphs/subflows -> nested machine execution -- streaming -> run-level event emitter + stream parts + emitted schemas -- persistence/resume -> event journal + snapshots -- prebuilt agent patterns -> curated machine factories - -### Needs Reinterpretation - -- reducers/channels -> avoid first-class graph-channel runtime semantics in phase 1 -- graph builder APIs -> do not mirror -- `START` / `END` constants -> unnecessary as authoring primitives -- explicit interrupt primitive -> defer - -### Deferred - -- graph-level true concurrent branch semantics with reducer joins -- durable sub-invoke task boundaries -- remote/API client compatibility -- type-level compatibility tests - -## LangGraph Test Port Strategy - -Only port: - -- runtime behavior tests -- runnable examples - -Do not port: - -- type-only tests -- API surface compatibility tests - -### Priority Test Groups - -1. Graph/state behavior - - `graph.test.ts` - - `errors.test.ts` - - `constants.test.ts` - -2. Execution/runtime behavior - - selected `pregel.test.ts` - - `pregel.read.test.ts` - - `pregel/stream.test.ts` - - `execution_info.test.ts` - -3. Persistence and replay - - `python_port/checkpoint.test.ts` - - `remote-graph-resumable.test.ts` - -4. Prebuilt agent behavior - - `prebuilt.test.ts` - - `prebuilt.int.test.ts` - -5. Runtime schema behavior - - relevant portions of `zod_state.test.ts` - -Each imported test should become an `agent`-idiomatic equivalent that asserts the same end-result behavior through the state-machine runtime. - -## Example Port Strategy - -Priority LangGraph-equivalent examples to rebuild in `agent`: - -1. quickstart -2. branching -3. wait-user-input / breakpoints -4. persistence -5. subgraph -6. tool-calling -7. create-react-agent / react-agent-from-scratch -8. multi-agent-network -9. plan-and-execute -10. reflection -11. rewoo -12. sql-agent - -Each example should: - -- use `agent`'s machine API -- be runnable locally -- demonstrate the same user outcome -- prefer explicit machine structure over graph-builder mimicry - -## Phased Delivery Plan - -### Phase 0: Lock the Core Contract - -Define: - -- durable run contract -- store interfaces -- restore/replay semantics -- stream event model - -### Phase 1: Durable Runtime - -Build: - -- run object -- journal append/load -- snapshotting -- restoration -- run subscriptions - -### Phase 2: Expressiveness - -Build: - -- better nested machine execution -- pending-state ergonomics -- inspection/trace support -- graph/diagram export - -### Phase 3: Prebuilt Patterns - -Build: - -- ReAct-style machine factory -- tool-calling helpers -- transcript/message helpers - -### Phase 4: Example Corpus - -Rebuild high-value LangGraph examples in `agent`. - -### Phase 5: Behavioral Regression Coverage - -Port and maintain semantic-equivalence tests grouped by capability family. - -## Risks - -1. Conflating transition logic with invoke execution. - This weakens replay semantics and makes portability worse. - -2. Over-promising invoke-level durability. - Plain async code is not automatically resumable at subtask granularity. - -3. Recreating LangGraph builder abstractions instead of improving on them. - This increases complexity without serving the machine-first philosophy. - -4. Mixing durable and ephemeral streams carelessly. - Runtime events and text/tool stream parts need distinct semantics. - -5. Allowing runner assumptions to leak into core. - This would compromise portability across Vercel, Cloudflare, and other environments. - -## Advantages Over LangGraph - -This design improves on LangGraph core in several important ways: - -1. Clearer semantic center. - LangGraph is graph-runtime-first. This design is actor/state-machine-first, so the progression model stays grounded in event consumption and snapshot derivation. - -2. Better purity boundary. - Transition logic remains conceptually pure, while effect execution is explicit and first-class rather than interwoven with graph runtime semantics. - -3. Simpler human-in-the-loop model. - Pending states plus external events are easier to reason about than a dedicated interrupt abstraction for most workflows. - -4. More honest durability. - The replay source is the actor event journal, not a mixed bag of runtime metadata. This makes replay and debugging cleaner. - -5. Better portability. - The runtime is explicitly designed to be runner-agnostic and storage-agnostic, making it a stronger fit for Vercel, Cloudflare Workers, Durable Objects, and other environments. - -6. Easier mental model for composition. - Nested machine execution is ordinary execution, not a special graph/subgraph system. - -7. Better streaming ergonomics. - Run-level subscriptions plus emitted schemas provide a clearer UI/runtime boundary than LangGraph's graph-oriented stream modes. - -## Recommendation - -Proceed with a capability-first expansion of `agent`'s runtime: - -- keep the machine API central -- make durable runs the execution center -- treat event persistence and snapshots as first-class -- make streaming run-level and explicit -- port LangGraph tests/examples as semantic benchmarks - -This produces a cleaner, more durable, and more portable core than LangGraph while still reaching the same practical developer outcomes. diff --git a/examples/README.md b/examples/README.md index 01aa67e..7ef2346 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,82 +1,34 @@ # Examples - + -This directory is organized by what a developer is trying to do, not by the underlying primitive. +This directory is organized around the preferred authoring path: `setupAgent(...).withTasks(...)` machines. ## Start Here -- Building an app route: [`apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next) or [`apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents) -- Trying local sessions: [`persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts) and [`http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts) -- Streaming text or tool progress: [`next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts), [`http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts), and [`tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts) -- Studying state-machine workflow patterns: start in `Workflow Examples` +- Authoring reusable text logic and XState agent machines: [`setup-agent/email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/email-drafter.ts) +- Running with host actors: [`setup-agent/hosts/ai-sdk.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/ai-sdk.ts) +- Host actor guide: [`../docs/host-actors.md`](/Users/davidkpiano/Code/agent/docs/host-actors.md) +- Comparing LangGraph and Burr patterns: [`../src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts), [`../src/burr-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/burr-equivalents/raw-xstate.test.ts) -## App-Shaped Examples +## XState Examples -These are the best starting points when you want code that already looks like a real app: +These use `setupAgent(...)` and `withTasks(...)` from `@statelyai/agent`. The runtime is flexible: use `createActor(...)` locally, provide different host actors in apps, or persist XState snapshots in a platform adapter. -- [`apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next): copy-paste Next.js App Router routes -- [`apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents): copy-paste Cloudflare Agents Worker layout -- [`next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts): AI SDK UI route helper -- [`next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts): App Router session-route preview code -- [`cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): Cloudflare Agents adapter preview code - -## Workflow Examples - -These focus on state-machine workflow patterns: - -- Session-first interactive workflows -- Local restore and transport patterns -- Multi-step planning, routing, and handoff flows - -- [`persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts) -- [`persistent-streaming.ts`](/Users/davidkpiano/Code/agent/examples/persistent-streaming.ts) -- [`persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts) -- [`persistent-multi-agent-network.ts`](/Users/davidkpiano/Code/agent/examples/persistent-multi-agent-network.ts) -- [`content-creator-flow.ts`](/Users/davidkpiano/Code/agent/examples/content-creator-flow.ts) -- [`email-auto-responder-flow.ts`](/Users/davidkpiano/Code/agent/examples/email-auto-responder-flow.ts) -- [`lead-score-flow.ts`](/Users/davidkpiano/Code/agent/examples/lead-score-flow.ts) -- [`meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts) -- [`self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts) -- [`spec-agent-loop.ts`](/Users/davidkpiano/Code/agent/examples/spec-agent-loop.ts) -- [`workflow-guardrails.ts`](/Users/davidkpiano/Code/agent/examples/workflow-guardrails.ts) -- [`write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts) -- [`plan-and-execute.ts`](/Users/davidkpiano/Code/agent/examples/plan-and-execute.ts) -- [`reflection.ts`](/Users/davidkpiano/Code/agent/examples/reflection.ts) -- [`rewoo.ts`](/Users/davidkpiano/Code/agent/examples/rewoo.ts) -- [`rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts) -- [`sql-agent.ts`](/Users/davidkpiano/Code/agent/examples/sql-agent.ts) - -## Local / Transport Examples - -- [`http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts) -- [`http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts) -- [`cloudflare-durable-object.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-object.ts) -- [`cloudflare-durable-network.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-network.ts) - -The reusable local pieces behind these examples are exported from `@statelyai/agent/local`. Framework-specific adapters should move to separate packages. - -## Reference / Concept Examples - -These are smaller building-block examples: - -- One-shot machine execution: [`simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts), [`decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts), [`classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts) -- Interactive session lifecycle: [`chatbot.ts`](/Users/davidkpiano/Code/agent/examples/chatbot.ts), [`chatbot-messages.ts`](/Users/davidkpiano/Code/agent/examples/chatbot-messages.ts), [`email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/email-drafter.ts), [`hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts), [`raffle.ts`](/Users/davidkpiano/Code/agent/examples/raffle.ts) - -- [`simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts) -- [`decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts) -- [`classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts) -- [`adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts) -- [`email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/email-drafter.ts) -- [`tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts) -- [`hitl.ts`](/Users/davidkpiano/Code/agent/examples/hitl.ts) -- [`branching.ts`](/Users/davidkpiano/Code/agent/examples/branching.ts) -- [`subflow.ts`](/Users/davidkpiano/Code/agent/examples/subflow.ts) -- [`conditional-subflow.ts`](/Users/davidkpiano/Code/agent/examples/conditional-subflow.ts) +- [`setup-agent/email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/email-drafter.ts): typed email workflow with independently testable text logic +- [`setup-agent/game-agent.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/game-agent.ts): turn-based game workflow with whitelisted event tools +- [`setup-agent/smoke.mts`](/Users/davidkpiano/Code/agent/examples/setup-agent/smoke.mts): deterministic local XState runtime smoke test +- [`setup-agent/hosts/ai-sdk.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/ai-sdk.ts): Vercel AI SDK host actors +- [`setup-agent/hosts/ai-sdk-game.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/ai-sdk-game.ts): Vercel AI SDK pure-transition game runner +- [`setup-agent/hosts/cloudflare-workers-ai.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/cloudflare-workers-ai.ts): Cloudflare Workers AI pure-transition runner +- [`setup-agent/hosts/tanstack-ai.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/tanstack-ai.ts): TanStack AI pure-transition runner sketch +- [`setup-agent/hosts/cloudflare-agent.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/cloudflare-agent.ts): Cloudflare Agents host sketch ## Parity Tracking - [`../docs/langgraph-parity.md`](/Users/davidkpiano/Code/agent/docs/langgraph-parity.md) +- [`../docs/langgraph-gaps.md`](/Users/davidkpiano/Code/agent/docs/langgraph-gaps.md) - [`../docs/crewai-parity.md`](/Users/davidkpiano/Code/agent/docs/crewai-parity.md) +- [`../docs/burr-parity.md`](/Users/davidkpiano/Code/agent/docs/burr-parity.md) -The parity docs track end-result coverage. The files here are the runnable equivalents. +The parity docs track end-result coverage and remaining gaps. New examples should use `withTasks(...)` for named LLM work and `setupAgent(...)` for schema-first machine authoring. diff --git a/examples/_run.ts b/examples/_run.ts deleted file mode 100644 index 05edd02..0000000 --- a/examples/_run.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { generateText, Output } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { createInterface } from 'node:readline/promises'; -import { stdin as input, stdout as output } from 'node:process'; -import { pathToFileURL } from 'node:url'; -import { z } from 'zod'; -import type { - AgentAdapter, - DecideAdapter, - ExecuteResult, - StandardSchemaV1, -} from '../src/index.js'; -export { waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; - -export function isMain(moduleUrl: string): boolean { - const entry = process.argv[1]; - return !!entry && moduleUrl === pathToFileURL(entry).href; -} - -let bufferedLinesPromise: Promise | null = null; -let bufferedLineIndex = 0; - -async function getBufferedLines(): Promise { - if (!bufferedLinesPromise) { - bufferedLinesPromise = (async () => { - const chunks: string[] = []; - - for await (const chunk of input) { - chunks.push(String(chunk)); - } - - return chunks.join('').split(/\r?\n/); - })(); - } - - return bufferedLinesPromise; -} - -export async function prompt(label: string): Promise { - if (!input.isTTY) { - output.write(`${label}: `); - const lines = await getBufferedLines(); - const value = lines[bufferedLineIndex] ?? ''; - bufferedLineIndex += 1; - return value.trim(); - } - - const rl = createInterface({ input, output }); - try { - const value = await rl.question(`${label}: `); - return value.trim(); - } finally { - rl.close(); - } -} - -export function closePrompt(): void { - bufferedLinesPromise = null; - bufferedLineIndex = 0; -} - -export function createExampleModel( - model = 'openai/gpt-5.4-nano' -): Parameters[0]['model'] { - if (!process.env.OPENAI_API_KEY) { - throw new Error('OPENAI_API_KEY is required to run the examples.'); - } - - return openai(resolveOpenAiModel(model)); -} - -export function formatResult(result: ExecuteResult) { - if (result.status === 'done') { - return { - status: result.status, - value: result.state.value, - context: result.context, - messages: result.messages, - output: result.output, - }; - } - - if (result.status === 'pending') { - return { - status: result.status, - value: result.value, - context: result.context, - messages: result.messages, - events: Object.keys(result.events), - }; - } - - return { - status: result.status, - value: result.state.value, - error: result.error, - }; -} - -export function createOpenAiDecisionAdapter(): DecideAdapter { - return { - async decide({ model, prompt, options, reasoning }) { - const optionKeys = Object.keys(options); - - const allSchemaLess = Object.values(options).every((option) => !option.schema); - - if (allSchemaLess && !reasoning) { - const choiceResult = await generateText({ - model: createExampleModel(model), - system: [ - 'Choose exactly one option.', - ...Object.entries(options).map(([key, option]) => `${key}: ${option.description}`), - ].join('\n'), - prompt, - output: Output.choice({ - options: optionKeys, - }), - }); - - return { - choice: choiceResult.output, - data: {} as Record, - }; - } - - const decisionSchemas = optionKeys.map((key) => { - const option = options[key]!; - - return z.object({ - decision: z.literal(key), - data: option.schema ? toZodSchema(option.schema) : z.object({}), - ...(reasoning - ? { reasoning: z.string() } - : {}), - }); - }); - - const decisionSchema = - decisionSchemas.length === 1 - ? decisionSchemas[0]! - : z.union( - decisionSchemas as unknown as [ - z.ZodTypeAny, - z.ZodTypeAny, - ...z.ZodTypeAny[], - ] - ); - - const result = await generateText({ - model: createExampleModel(model), - system: [ - 'Choose exactly one option and return structured output.', - ...Object.entries(options).map(([key, option]) => `${key}: ${option.description}`), - ].join('\n'), - prompt, - output: Output.object({ - schema: decisionSchema, - }), - }); - const output = result.output as { - decision: string; - data: Record; - reasoning?: string; - }; - - return { - choice: output.decision, - data: output.data, - reasoning: output.reasoning, - }; - }, - }; -} - -export function createOpenAiGenerationAdapter(): AgentAdapter { - return { - async generateText({ model, system, prompt, messages, outputSchema }) { - const options: any = { - model: createExampleModel(model), - system, - ...(outputSchema - ? { - output: Output.object({ - schema: toZodSchema(outputSchema), - }), - } - : {}), - }; - - if (messages.length > 0) { - options.messages = messages as any; - } else { - options.prompt = prompt ?? ''; - } - - const result = await generateText(options); - - const output = result as { output?: unknown; text?: string }; - return output.output ?? output.text ?? result; - }, - }; -} - -export async function generateExampleObject(options: { - schema: StandardSchemaV1; - prompt: string; - system?: string; - model?: string; -}): Promise { - const result = await generateText({ - model: createExampleModel(options.model), - output: Output.object({ - schema: toZodSchema(options.schema), - }), - system: options.system, - prompt: options.prompt, - }); - - return result.output as T; -} - -export async function generateExampleText(options: { - prompt: string; - system?: string; - model?: string; -}): Promise { - const result = await generateText({ - model: createExampleModel(options.model), - system: options.system, - prompt: options.prompt, - }); - - return result.text.trim(); -} - -function resolveOpenAiModel(model: string): string { - return model.startsWith('openai/') ? model.slice('openai/'.length) : model; -} - -function toZodSchema(schema: StandardSchemaV1): z.ZodTypeAny { - if ('_zod' in schema || '_def' in schema) { - return schema as unknown as z.ZodTypeAny; - } - - return z.record(z.string(), z.unknown()); -} diff --git a/examples/adapter.ts b/examples/adapter.ts deleted file mode 100644 index 7b68646..0000000 --- a/examples/adapter.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { - createAgentMachine, - decide, - decideResultSchema, - type DecideAdapter, -} from '../src/index.js'; -import { - closePrompt, - createOpenAiDecisionAdapter, - formatResult, - isMain, - prompt, -} from './_run.js'; - -export function createAdapterExample( - adapter: DecideAdapter = createOpenAiDecisionAdapter() -) { - const routeOptions = { - billing: { - description: 'Send the request to billing support.', - schema: z.object({ confidence: z.number().min(0).max(1) }), - }, - general: { - description: 'Handle the request in general support.', - schema: z.object({ confidence: z.number().min(0).max(1) }), - }, - } as const; - - return createAgentMachine({ - id: 'adapter-example', - schemas: { - input: z.object({ message: z.string() }), - output: z.object({ - route: z.string().nullable(), - confidence: z.number().nullable(), - }), - }, - context: (input) => ({ - message: input.message, - route: null as string | null, - confidence: null as number | null, - }), - initial: 'route', - states: { - route: { - schemas: { output: decideResultSchema(routeOptions) }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'openai/gpt-5.4-nano', - prompt: [ - 'Route this support request.', - 'Return billing only when the request is clearly about invoices, refunds, or charges.', - 'Otherwise return general.', - '', - context.message, - ].join('\n'), - options: routeOptions, - reasoning: false, - }), - onDone: ({ output }) => { - return { - target: 'done', - context: { - route: output.choice, - confidence: output.data.confidence, - }, - }; - }, - }, - done: { - type: 'final', - output: ({ context }) => ({ - route: context.route, - confidence: context.confidence, - }), - }, - }, - }); -} - -async function main() { - try { - const message = await prompt('Message to route'); - const machine = createAdapterExample(); - - console.log(formatResult(await execute(machine, machine.getInitialState({ message })))); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/ai-sdk.ts b/examples/ai-sdk.ts deleted file mode 100644 index 9d32355..0000000 --- a/examples/ai-sdk.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { generateText, Output } from 'ai'; -import { execute } from '../src/local/index.js'; -import { z } from 'zod'; -import { - createAgentMachine, - decide, - decideResultSchema, - type DecideAdapter, -} from '../src/index.js'; -import { createAiSdkDecisionAdapter } from '../src/ai-sdk/index.js'; -import { - closePrompt, - createExampleModel, - formatResult, - isMain, - prompt, -} from './_run.js'; - -const routeOptions = { - billing: { - description: 'Handle invoices, refunds, subscription charges, and payment issues.', - schema: z.object({ - confidence: z.number().min(0).max(1), - }), - }, - support: { - description: 'Handle product usage questions and troubleshooting requests.', - schema: z.object({ - confidence: z.number().min(0).max(1), - }), - }, -} as const; - -const replySchema = z.object({ - subject: z.string(), - body: z.string(), -}); - -type Route = keyof typeof routeOptions; - -export function createAiSdkExample(options: { - adapter?: DecideAdapter; - draftReply?: (args: { - route: Route; - confidence: number; - message: string; - }) => Promise>; -} = {}) { - const adapter = - options.adapter ?? - createAiSdkDecisionAdapter({ - resolveModel: (model) => createExampleModel(model), - }); - - const draftReply = - options.draftReply ?? - (async ({ - route, - confidence, - message, - }: { - route: Route; - confidence: number; - message: string; - }) => { - const result = await generateText({ - model: createExampleModel('openai/gpt-5.4-nano'), - system: [ - 'Draft a concise support email.', - `Route: ${route}`, - `Classifier confidence: ${confidence.toFixed(2)}`, - 'Return structured output with a subject and body.', - ].join('\n'), - prompt: message, - output: Output.object({ - schema: replySchema, - }), - }); - - return result.output as z.infer; - }); - - return createAgentMachine({ - id: 'ai-sdk-example', - schemas: { - input: z.object({ message: z.string() }), - output: z.object({ - route: z.enum(['billing', 'support']).nullable(), - confidence: z.number().nullable(), - subject: z.string().nullable(), - body: z.string().nullable(), - }), - }, - context: (input) => ({ - message: input.message, - route: null as Route | null, - confidence: null as number | null, - subject: null as string | null, - body: null as string | null, - }), - initial: 'route', - states: { - route: { - schemas: { output: decideResultSchema(routeOptions) }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'openai/gpt-5.4-nano', - prompt: [ - 'Route this inbound customer message.', - '', - context.message, - ].join('\n'), - options: routeOptions, - }), - onDone: ({ output }) => ({ - target: 'drafting', - context: { - route: output.choice, - confidence: output.data.confidence, - }, - }), - }, - drafting: { - schemas: { output: replySchema }, - invoke: async ({ context }) => - draftReply({ - route: context.route ?? 'support', - confidence: context.confidence ?? 0, - message: context.message, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { - subject: output.subject, - body: output.body, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - route: context.route, - confidence: context.confidence, - subject: context.subject, - body: context.body, - }), - }, - }, - }); -} - -async function main() { - try { - const message = await prompt('Customer message'); - const machine = createAiSdkExample(); - const result = await execute(machine, machine.getInitialState({ message })); - - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/apps/cloudflare-agents/README.md b/examples/apps/cloudflare-agents/README.md deleted file mode 100644 index ca2cc9c..0000000 --- a/examples/apps/cloudflare-agents/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Cloudflare Agents Worker Example - -These files show the Cloudflare Agents integration in a real Worker layout with top-level `agents` imports, instead of the Node-safe lazy import used in [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts). - -Included files: - -- `src/review-workflow-agent.ts`: the Agent class that owns the durable review workflow -- `src/index.ts`: the Worker entrypoint that delegates requests through `routeAgentRequest(...)` - -Use this layout when you want a copy-paste starting point for a real Cloudflare Agents app. diff --git a/examples/apps/cloudflare-agents/src/index.ts b/examples/apps/cloudflare-agents/src/index.ts deleted file mode 100644 index 190e940..0000000 --- a/examples/apps/cloudflare-agents/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { routeAgentRequest } from 'agents'; -import { ReviewWorkflowAgent } from './review-workflow-agent.js'; - -export { ReviewWorkflowAgent }; - -export default { - async fetch(request: Request, env: Record) { - return ( - await routeAgentRequest(request, env, { - prefix: '/agents', - }) - ) ?? new Response('Not found', { status: 404 }); - }, -}; diff --git a/examples/apps/cloudflare-agents/src/review-workflow-agent.ts b/examples/apps/cloudflare-agents/src/review-workflow-agent.ts deleted file mode 100644 index ae26db3..0000000 --- a/examples/apps/cloudflare-agents/src/review-workflow-agent.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Agent } from 'agents'; -import type { RunStore } from '../../../../src/index.js'; -import { restoreSession, startSession } from '../../../../src/local/index.js'; -import { - createCloudflareAgentRunStore, - type CloudflareAgentRunStoreState, -} from '../../../../src/cloudflare/index.js'; -import { createPersistenceExample } from '../../../persistence.js'; - -export class ReviewWorkflowAgent extends Agent< - Record, - CloudflareAgentRunStoreState -> { - initialState: CloudflareAgentRunStoreState = { - sessions: {}, - }; - - private getStore(): RunStore { - return createCloudflareAgentRunStore({ - getState: () => this.state ?? this.initialState, - setState: (nextState) => this.setState(nextState), - }); - } - - async onRequest(request: Request): Promise { - const url = new URL(request.url); - const machine = createPersistenceExample(); - - if (request.method === 'POST' && url.pathname.endsWith('/start')) { - const body = await request.json() as { request: string }; - const run = await startSession(machine, { - store: this.getStore(), - input: { - request: body.request, - }, - }); - - return Response.json({ - sessionId: run.sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'POST' && url.pathname.endsWith('/events')) { - const body = await request.json() as { - sessionId: string; - event: { type: 'approve' }; - }; - const run = await restoreSession(machine, { - sessionId: body.sessionId, - store: this.getStore(), - }); - - await run.send(body.event); - - return Response.json({ - sessionId: body.sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'GET' && url.pathname.endsWith('/snapshot')) { - const sessionId = requiredSessionId(url); - const run = await restoreSession(machine, { - sessionId, - store: this.getStore(), - }); - - return Response.json({ - sessionId, - snapshot: run.getSnapshot(), - }); - } - - return new Response('Not found', { status: 404 }); - } -} - -function requiredSessionId(url: URL): string { - const sessionId = url.searchParams.get('sessionId'); - if (!sessionId) { - throw new Error('Missing sessionId'); - } - - return sessionId; -} diff --git a/examples/apps/next/README.md b/examples/apps/next/README.md deleted file mode 100644 index bc1ad10..0000000 --- a/examples/apps/next/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Next App Router Examples - -These files show the same `@statelyai/agent` examples in a shape you can drop directly into a Next.js App Router project. - -Included routes: - -- `app/api/chat/route.ts`: AI SDK UI message streaming route -- `app/api/review-sessions/route.ts`: start a durable review session -- `app/api/review-sessions/[sessionId]/route.ts`: fetch a review session snapshot -- `app/api/review-sessions/[sessionId]/events/route.ts`: send events to a review session -- `app/api/stream-sessions/route.ts`: start a streaming session -- `app/api/stream-sessions/[sessionId]/route.ts`: fetch a streaming session snapshot -- `app/api/stream-sessions/[sessionId]/stream/route.ts`: consume the streaming SSE response - -The route handlers are backed by: - -- [`examples/next-app-router.ts`](/Users/davidkpiano/Code/agent/examples/next-app-router.ts) -- [`examples/next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts) diff --git a/examples/apps/next/app/api/chat/route.ts b/examples/apps/next/app/api/chat/route.ts deleted file mode 100644 index 0cc706e..0000000 --- a/examples/apps/next/app/api/chat/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - chatRoute, - dynamic, - maxDuration, - runtime, -} from '../../../lib/routes.js'; - -export { runtime, dynamic, maxDuration }; -export const POST = chatRoute.POST; diff --git a/examples/apps/next/app/api/review-sessions/[sessionId]/events/route.ts b/examples/apps/next/app/api/review-sessions/[sessionId]/events/route.ts deleted file mode 100644 index 3234862..0000000 --- a/examples/apps/next/app/api/review-sessions/[sessionId]/events/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - dynamic, - maxDuration, - reviewRoutes, - runtime, -} from '../../../../../lib/routes.js'; - -export { runtime, dynamic, maxDuration }; -export const POST = reviewRoutes.events.POST; diff --git a/examples/apps/next/app/api/review-sessions/[sessionId]/route.ts b/examples/apps/next/app/api/review-sessions/[sessionId]/route.ts deleted file mode 100644 index 0f98e4b..0000000 --- a/examples/apps/next/app/api/review-sessions/[sessionId]/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - dynamic, - maxDuration, - reviewRoutes, - runtime, -} from '../../../../lib/routes.js'; - -export { runtime, dynamic, maxDuration }; -export const GET = reviewRoutes.session.GET; diff --git a/examples/apps/next/app/api/review-sessions/route.ts b/examples/apps/next/app/api/review-sessions/route.ts deleted file mode 100644 index 30f3729..0000000 --- a/examples/apps/next/app/api/review-sessions/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - dynamic, - maxDuration, - reviewRoutes, - runtime, -} from '../../../lib/routes.js'; - -export { runtime, dynamic, maxDuration }; -export const POST = reviewRoutes.sessions.POST; diff --git a/examples/apps/next/app/api/stream-sessions/[sessionId]/route.ts b/examples/apps/next/app/api/stream-sessions/[sessionId]/route.ts deleted file mode 100644 index bce7ee0..0000000 --- a/examples/apps/next/app/api/stream-sessions/[sessionId]/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - dynamic, - maxDuration, - runtime, - streamingRoutes, -} from '../../../../lib/routes.js'; - -export { runtime, dynamic, maxDuration }; -export const GET = streamingRoutes.session.GET; diff --git a/examples/apps/next/app/api/stream-sessions/[sessionId]/stream/route.ts b/examples/apps/next/app/api/stream-sessions/[sessionId]/stream/route.ts deleted file mode 100644 index 89ceefe..0000000 --- a/examples/apps/next/app/api/stream-sessions/[sessionId]/stream/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - dynamic, - maxDuration, - runtime, - streamingRoutes, -} from '../../../../../lib/routes.js'; - -export { runtime, dynamic, maxDuration }; -export const GET = streamingRoutes.stream.GET; diff --git a/examples/apps/next/app/api/stream-sessions/route.ts b/examples/apps/next/app/api/stream-sessions/route.ts deleted file mode 100644 index 296725f..0000000 --- a/examples/apps/next/app/api/stream-sessions/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - dynamic, - maxDuration, - runtime, - streamingRoutes, -} from '../../../lib/routes.js'; - -export { runtime, dynamic, maxDuration }; -export const POST = streamingRoutes.sessions.POST; diff --git a/examples/apps/next/lib/routes.ts b/examples/apps/next/lib/routes.ts deleted file mode 100644 index 348f3ec..0000000 --- a/examples/apps/next/lib/routes.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - createNextReviewRouteHandlers, - createNextStreamingRouteHandlers, - dynamic as nextDynamic, - maxDuration as nextMaxDuration, - runtime as nextRuntime, -} from '../../../next-app-router.js'; -import { createNextAiSdkUiRoute } from '../../../next-ai-sdk-ui.js'; - -export const runtime = nextRuntime; -export const dynamic = nextDynamic; -export const maxDuration = nextMaxDuration; - -export const reviewRoutes = createNextReviewRouteHandlers(); -export const streamingRoutes = createNextStreamingRouteHandlers(); -export const chatRoute = createNextAiSdkUiRoute(); diff --git a/examples/branching.ts b/examples/branching.ts deleted file mode 100644 index 9a6e03a..0000000 --- a/examples/branching.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - generateExampleText, - isMain, - prompt, -} from './_run.js'; - -const branchResultSchema = z.object({ - docs: z.string(), - issues: z.string(), - code: z.string(), -}); - -const summarySchema = z.object({ - summary: z.string(), -}); - -export function createBranchingExample( - options: { - analyzeDocs?: (topic: string) => Promise; - analyzeIssues?: (topic: string) => Promise; - analyzeCode?: (topic: string) => Promise; - summarize?: (parts: { - docs: string; - issues: string; - code: string; - }) => Promise>; - } = {} -) { - return createAgentMachine({ - id: 'branching-example', - schemas: { - input: z.object({ topic: z.string() }), - output: z.object({ - docs: z.string().nullable(), - issues: z.string().nullable(), - code: z.string().nullable(), - summary: z.string().nullable(), - }), - }, - context: (input) => ({ - topic: input.topic, - docs: null as string | null, - issues: null as string | null, - code: null as string | null, - summary: null as string | null, - }), - initial: 'analyzing', - states: { - analyzing: { - schemas: { output: branchResultSchema }, - invoke: async ({ context }) => { - const [docs, issues, code] = await Promise.all([ - (options.analyzeDocs - ?? ((topic) => - generateExampleText({ - system: 'You are a repository docs analyst. Be concise and concrete.', - prompt: `Summarize what the documentation angle should cover for this topic in 2 short sentences:\n\n${topic}`, - })))(context.topic), - (options.analyzeIssues - ?? ((topic) => - generateExampleText({ - system: 'You analyze likely issue patterns and risks. Be concise and concrete.', - prompt: `Summarize the likely issue and operational concerns for this topic in 2 short sentences:\n\n${topic}`, - })))(context.topic), - (options.analyzeCode - ?? ((topic) => - generateExampleText({ - system: 'You analyze code-level implementation concerns. Be concise and concrete.', - prompt: `Summarize the likely code architecture and implementation concerns for this topic in 2 short sentences:\n\n${topic}`, - })))(context.topic), - ]); - - return { docs, issues, code }; - }, - onDone: ({ output }) => ({ - target: 'summarizing', - context: output, - }), - }, - summarizing: { - schemas: { output: summarySchema }, - invoke: async ({ context }) => - (options.summarize - ?? (({ docs, issues, code }) => - generateExampleObject({ - schema: summarySchema, - system: 'You synthesize technical analysis into a concise summary.', - prompt: [ - 'Combine these three perspectives into a concise high-level summary.', - '', - `Docs:\n${docs}`, - '', - `Issues:\n${issues}`, - '', - `Code:\n${code}`, - ].join('\n'), - })))({ - docs: context.docs ?? '', - issues: context.issues ?? '', - code: context.code ?? '', - }), - onDone: ({ output }) => ({ - target: 'done', - context: { summary: output.summary }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - docs: context.docs, - issues: context.issues, - code: context.code, - summary: context.summary, - }), - }, - }, - }); -} - -async function main() { - try { - const topic = await prompt('Topic'); - const machine = createBranchingExample(); - const result = await execute(machine, machine.getInitialState({ topic })); - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/chatbot-messages.ts b/examples/chatbot-messages.ts deleted file mode 100644 index f13529f..0000000 --- a/examples/chatbot-messages.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - createAgentMachine, - type AgentMessage, -} from '../src/index.js'; -import { - closePrompt, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const messageSchema = z.object({ - role: z.string(), - content: z.string(), -}); - -const replySchema = z.object({ - message: messageSchema, -}); - -export function createChatbotMessagesExample( - reply: (messages: AgentMessage[]) => Promise> = (messages) => - generateExampleObject({ - schema: replySchema, - system: 'You are a concise assistant in a terminal chat.', - prompt: [ - 'Write the next assistant message for this conversation.', - '', - ...messages.map((message) => `${message.role}: ${message.content}`), - ].join('\n'), - }) -) { - return createAgentMachine({ - id: 'chatbot-messages-example', - schemas: { - output: z.object({ - messages: z.array(messageSchema), - finalMessage: messageSchema.nullable(), - }), - events: { - 'messages.user': z.object({ - message: messageSchema.extend({ - role: z.literal('user'), - }), - }), - 'messages.end': z.object({}), - }, - }, - context: () => ({ - finalMessage: null as z.infer | null, - ended: false, - }), - messages: [], - initial: 'waitingForUser', - states: { - waitingForUser: { - on: { - 'messages.user': ({ event, messages }) => ({ - target: 'replying', - messages: messages.concat(event.message), - }), - 'messages.end': { - target: 'done', - context: { ended: true }, - }, - }, - }, - replying: { - schemas: { output: replySchema }, - invoke: async ({ messages }) => reply(messages), - onDone: ({ output, messages }) => ({ - target: 'waitingForUser', - messages: messages.concat(output.message), - context: { - finalMessage: output.message, - }, - }), - }, - done: { - type: 'final', - output: ({ context, messages }) => ({ - messages, - finalMessage: context.finalMessage, - }), - }, - }, - }); -} - -async function main() { - try { - const machine = createChatbotMessagesExample(); - const run = await startSession(machine, { - store: createMemoryRunStore(), - }); - let lastPrintedAssistantMessage: string | null = null; - - while (true) { - const snapshot = await waitForRunSnapshot( - run, - (nextSnapshot) => nextSnapshot.status !== 'active' - ); - - if (snapshot.status === 'done') { - console.log({ - status: snapshot.status, - value: snapshot.value, - context: snapshot.context, - messages: snapshot.messages, - output: snapshot.output, - }); - break; - } - - const finalMessage = snapshot.context.finalMessage as - | z.infer - | null; - - if ( - finalMessage?.role === 'assistant' - && finalMessage.content !== lastPrintedAssistantMessage - ) { - console.log(`Assistant: ${finalMessage.content}`); - lastPrintedAssistantMessage = finalMessage.content; - } - - const content = await prompt('User (blank to exit)'); - await run.send( - content - ? { - type: 'messages.user', - message: { role: 'user', content }, - } - : { type: 'messages.end' } - ); - } - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/chatbot.ts b/examples/chatbot.ts deleted file mode 100644 index 455a160..0000000 --- a/examples/chatbot.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - createAgentMachine, - decide, - decideResultSchema, - type DecideAdapter, -} from '../src/index.js'; -import { - closePrompt, - createOpenAiDecisionAdapter, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const replySchema = z.object({ - response: z.string(), -}); - -export function createChatbotExample( - options: { - adapter?: DecideAdapter; - reply?: (transcript: string[]) => Promise>; - } = {} -) { - const decisionOptions = { - respond: { description: 'Reply to the user and continue chatting.' }, - end: { description: 'End the conversation now.' }, - } as const; - - const adapter = - options.adapter ?? - (process.env.OPENAI_API_KEY ? createOpenAiDecisionAdapter() : undefined); - const reply = - options.reply ?? - ((transcript: string[]) => - generateExampleObject({ - schema: replySchema, - system: 'You are a concise, helpful assistant in a terminal chat.', - prompt: [ - 'Write the assistant reply for the conversation below.', - 'Keep it short and directly responsive.', - '', - transcript.join('\n'), - ].join('\n'), - })); - - return createAgentMachine({ - id: 'chatbot-example', - schemas: { - output: z.object({ - transcript: z.array(z.string()), - ended: z.boolean(), - lastAssistantMessage: z.string().nullable(), - }), - events: { - 'user.message': z.object({ message: z.string() }), - 'user.exit': z.object({}), - }, - }, - context: () => ({ - transcript: [] as string[], - lastUserMessage: null as string | null, - lastAssistantMessage: null as string | null, - ended: false, - }), - initial: 'listening', - states: { - listening: { - on: { - 'user.message': ({ event, context }) => ({ - target: 'deciding', - context: { - lastUserMessage: event.message, - transcript: [...context.transcript, `User: ${event.message}`], - }, - }), - 'user.exit': { - target: 'done', - context: { ended: true }, - }, - }, - }, - deciding: { - schemas: { output: decideResultSchema(decisionOptions) }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'openai/gpt-5.4-nano', - prompt: [ - 'Decide whether the assistant should answer or end the conversation.', - 'End only when the user is clearly saying goodbye or asking to stop.', - '', - context.transcript.join('\n'), - ].join('\n'), - options: decisionOptions, - }), - onDone: ({ output }) => ({ - target: output.choice === 'end' ? 'done' : 'replying', - context: output.choice === 'end' ? { ended: true } : {}, - }), - }, - replying: { - schemas: { output: replySchema }, - invoke: async ({ context }) => reply(context.transcript), - onDone: ({ output, context }) => ({ - target: 'listening', - context: { - lastAssistantMessage: output.response, - transcript: [...context.transcript, `Assistant: ${output.response}`], - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - transcript: context.transcript, - ended: context.ended, - lastAssistantMessage: context.lastAssistantMessage, - }), - }, - }, - }); -} - -async function main() { - try { - const machine = createChatbotExample(); - const run = await startSession(machine, { - store: createMemoryRunStore(), - }); - let lastPrintedAssistantMessage: string | null = null; - - while (true) { - const snapshot = await waitForRunSnapshot( - run, - (nextSnapshot) => nextSnapshot.status !== 'active' - ); - - if (snapshot.status === 'done') { - if ( - snapshot.output && - typeof snapshot.output === 'object' && - 'lastAssistantMessage' in snapshot.output && - snapshot.output.lastAssistantMessage && - snapshot.output.lastAssistantMessage !== lastPrintedAssistantMessage - ) { - console.log(`Assistant: ${snapshot.output.lastAssistantMessage}`); - } - console.log({ - status: snapshot.status, - value: snapshot.value, - context: snapshot.context, - output: snapshot.output, - }); - break; - } - - if ( - snapshot.context.lastAssistantMessage && - snapshot.context.lastAssistantMessage !== lastPrintedAssistantMessage - ) { - console.log(`Assistant: ${snapshot.context.lastAssistantMessage}`); - lastPrintedAssistantMessage = snapshot.context.lastAssistantMessage; - } - - const message = await prompt('User (blank to exit)'); - await run.send( - message - ? { type: 'user.message', message } - : { type: 'user.exit' } - ); - } - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/classify.ts b/examples/classify.ts deleted file mode 100644 index a34a3bf..0000000 --- a/examples/classify.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { - createAgentMachine, - classify, - classifyResultSchema, - type DecideAdapter, -} from '../src/index.js'; -import { - closePrompt, - createOpenAiDecisionAdapter, - formatResult, - isMain, - prompt, -} from './_run.js'; - -export function createClassifyExample( - adapter: DecideAdapter = createOpenAiDecisionAdapter() -) { - const categories = { - billing: { description: 'Payments, invoices, refunds, and charges.' }, - technical: { description: 'Bugs, outages, and product issues.' }, - general: { description: 'Everything else.' }, - } as const; - - return createAgentMachine({ - id: 'classify-example', - schemas: { - input: z.object({ request: z.string() }), - output: z.object({ category: z.string().nullable() }), - }, - context: (input) => ({ - request: input.request, - category: null as string | null, - }), - initial: 'routing', - states: { - routing: { - schemas: { output: classifyResultSchema(categories) }, - invoke: async ({ context }) => - classify({ - adapter, - model: 'openai/gpt-5.4-nano', - prompt: `Classify this support request:\n\n${context.request}`, - into: categories, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { category: output.category }, - }), - }, - done: { - // use input; category should always be defined when entering - type: 'final', - output: ({ context }) => ({ category: context.category }), - }, - }, - }); -} - -async function main() { - try { - const request = await prompt('Support request'); - const machine = createClassifyExample(); - - console.log(formatResult(await execute(machine, machine.getInitialState({ request })))); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/cloudflare-agents.ts b/examples/cloudflare-agents.ts deleted file mode 100644 index 39827c9..0000000 --- a/examples/cloudflare-agents.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import type { RunStore } from '../src/index.js'; -import { - createCloudflareAgentRunStore, - type CloudflareAgentRunStoreState, -} from '../src/cloudflare/index.js'; -import { createPersistenceExample } from './persistence.js'; - -export { - createCloudflareAgentRunStore, - type CloudflareAgentRunStoreState, -}; - -export interface CloudflareAgentsExampleArtifacts { - ReviewWorkflowAgent: new (...args: any[]) => { - onRequest(request: Request): Promise; - }; - worker: { - fetch(request: Request, env: Record): Promise; - }; -} - -/** - * Cloudflare's `agents` package imports `cloudflare:` modules, so this example - * keeps that import lazy to stay loadable in plain Node. In a real Worker, - * move the `agents` imports to top-level imports. - */ -export async function createCloudflareAgentsExample(): Promise { - const { Agent, routeAgentRequest } = await import('agents'); - const machine = createPersistenceExample(); - - class ReviewWorkflowAgent extends Agent< - Record, - CloudflareAgentRunStoreState - > { - initialState: CloudflareAgentRunStoreState = { - sessions: {}, - }; - - private getStore(): RunStore { - return createCloudflareAgentRunStore({ - getState: () => this.state ?? this.initialState, - setState: (nextState) => this.setState(nextState), - }); - } - - async onRequest(request: Request): Promise { - const url = new URL(request.url); - - if (request.method === 'POST' && url.pathname.endsWith('/start')) { - const body = await request.json() as { request: string }; - const run = await startSession(machine, { - store: this.getStore(), - input: { - request: body.request, - }, - }); - - return Response.json({ - sessionId: run.sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'POST' && url.pathname.endsWith('/events')) { - const body = await request.json() as { - sessionId: string; - event: { type: 'approve' }; - }; - const run = await restoreSession(machine, { - sessionId: body.sessionId, - store: this.getStore(), - }); - - await run.send(body.event); - - return Response.json({ - sessionId: body.sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'GET' && url.pathname.endsWith('/snapshot')) { - const sessionId = requiredSessionId(url); - const run = await restoreSession(machine, { - sessionId, - store: this.getStore(), - }); - - return Response.json({ - sessionId, - snapshot: run.getSnapshot(), - }); - } - - return new Response('Not found', { status: 404 }); - } - } - - const worker = { - async fetch(request: Request, env: Record) { - return ( - await routeAgentRequest(request, env, { - prefix: '/agents', - }) - ) ?? new Response('Not found', { status: 404 }); - }, - }; - - return { - ReviewWorkflowAgent, - worker, - } satisfies CloudflareAgentsExampleArtifacts; -} - -function requiredSessionId(url: URL): string { - const sessionId = url.searchParams.get('sessionId'); - if (!sessionId) { - throw new Error('Missing sessionId'); - } - - return sessionId; -} diff --git a/examples/cloudflare-durable-network.ts b/examples/cloudflare-durable-network.ts deleted file mode 100644 index 9705944..0000000 --- a/examples/cloudflare-durable-network.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import type { AgentSnapshot } from '../src/index.js'; -import { - createDurableObjectRunStore, - type DurableObjectStateLike, -} from './cloudflare-durable-object.js'; -import { createMultiAgentNetworkExample } from './multi-agent-network.js'; - -export class AgentNetworkDurableObject { - private readonly store; - - constructor(private readonly state: DurableObjectStateLike) { - this.store = createDurableObjectRunStore(state.storage); - } - - async fetch(request: Request): Promise { - const url = new URL(request.url); - const machine = createMultiAgentNetworkExample({ - adapter: { - decide: async ({ prompt }) => { - if (!prompt.includes('Notes: none yet')) { - if (!prompt.includes('Current draft: none yet')) { - return { choice: 'finalize', data: {} }; - } - - return { - choice: 'write', - data: { angle: 'turn the current notes into a concise summary' }, - }; - } - - return { - choice: 'research', - data: { focus: 'collect the strongest supporting facts' }, - }; - }, - }, - research: async ({ topic, focus }) => ({ - notes: [`${topic}:${focus}:1`, `${topic}:${focus}:2`], - }), - write: async ({ topic, notes, angle }) => ({ - draft: `${topic} | ${angle} | ${notes.join(' / ')}`, - }), - }); - - if (request.method === 'POST' && url.pathname === '/start') { - const body = await request.json() as { topic: string }; - const run = await startSession(machine, { - store: this.store, - input: { topic: body.topic }, - }); - - return Response.json({ - sessionId: run.sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'POST' && url.pathname === '/resume') { - const sessionId = requiredSessionId(url); - const run = await restoreSession(machine, { - sessionId, - store: this.store, - }); - const snapshot = await waitForTerminalSnapshot(run.getSnapshot, 1000); - - return Response.json({ - sessionId, - snapshot, - }); - } - - return new Response('Not found', { status: 404 }); - } -} - -async function waitForTerminalSnapshot( - getSnapshot: () => AgentSnapshot, - timeoutMs: number -) { - const start = Date.now(); - - while (Date.now() - start < timeoutMs) { - const snapshot = getSnapshot(); - if (snapshot.status === 'done' || snapshot.status === 'error') { - return snapshot; - } - - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - return getSnapshot(); -} - -function requiredSessionId(url: URL): string { - const sessionId = url.searchParams.get('sessionId'); - if (!sessionId) { - throw new Error('Missing sessionId'); - } - - return sessionId; -} diff --git a/examples/cloudflare-durable-object.ts b/examples/cloudflare-durable-object.ts deleted file mode 100644 index e317ad7..0000000 --- a/examples/cloudflare-durable-object.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import type { RunStore } from '../src/index.js'; -import { - createDurableObjectRunStore, - type DurableObjectStateLike, - type DurableObjectStorageLike, -} from '../src/cloudflare/index.js'; -import { createPersistenceExample } from './persistence.js'; - -export { - createDurableObjectRunStore, - type DurableObjectStateLike, - type DurableObjectStorageLike, -}; - -export class AgentSessionDurableObject { - private readonly store: RunStore; - - constructor(private readonly state: DurableObjectStateLike) { - this.store = createDurableObjectRunStore(state.storage); - } - - async fetch(request: Request): Promise { - const url = new URL(request.url); - const machine = createPersistenceExample(async ({ request, approved }) => ({ - summary: `${request} :: approved=${String(approved)}`, - })); - - if (request.method === 'POST' && url.pathname === '/start') { - const body = await request.json() as { request: string }; - const run = await startSession(machine, { - store: this.store, - input: { request: body.request }, - }); - - return Response.json({ - sessionId: run.sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'POST' && url.pathname === '/approve') { - const sessionId = requiredSessionId(url); - const run = await restoreSession(machine, { - store: this.store, - sessionId, - }); - - await run.send({ type: 'approve' }); - - return Response.json({ - sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'GET' && url.pathname === '/status') { - const sessionId = requiredSessionId(url); - const run = await restoreSession(machine, { - store: this.store, - sessionId, - }); - - return Response.json({ - sessionId, - snapshot: run.getSnapshot(), - }); - } - - return new Response('Not found', { status: 404 }); - } -} - -function requiredSessionId(url: URL): string { - const sessionId = url.searchParams.get('sessionId'); - if (!sessionId) { - throw new Error('Missing sessionId'); - } - - return sessionId; -} diff --git a/examples/conditional-subflow.ts b/examples/conditional-subflow.ts deleted file mode 100644 index f718e6e..0000000 --- a/examples/conditional-subflow.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const modeSchema = z.enum(['research', 'draft']); - -const researchSchema = z.object({ - bullets: z.array(z.string()), -}); - -const draftSchema = z.object({ - draft: z.string(), -}); - -export function createConditionalSubflowExample( - options: { - research?: (topic: string) => Promise>; - draft?: (args: { - topic: string; - bullets: string[]; - }) => Promise>; - } = {} -) { - const researchMachine = createAgentMachine({ - id: 'conditional-subflow-research', - schemas: { - input: z.object({ topic: z.string() }), - output: researchSchema, - }, - context: (input) => ({ - topic: input.topic, - bullets: [] as string[], - }), - initial: 'researching', - states: { - researching: { - schemas: { output: researchSchema }, - invoke: async ({ context }) => - (options.research - ?? ((topic) => - generateExampleObject({ - schema: researchSchema, - system: 'Return concise research bullets.', - prompt: `Return 2 to 4 bullets about ${topic}.`, - })))(context.topic), - onDone: ({ output }) => ({ - target: 'done', - context: { bullets: output.bullets }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ bullets: context.bullets }), - }, - }, - }); - - const draftMachine = createAgentMachine({ - id: 'conditional-subflow-draft', - schemas: { - input: z.object({ - topic: z.string(), - bullets: z.array(z.string()), - }), - output: draftSchema, - }, - context: (input) => ({ - topic: input.topic, - bullets: input.bullets, - draft: null as string | null, - }), - initial: 'drafting', - states: { - drafting: { - schemas: { output: draftSchema }, - invoke: async ({ context }) => - (options.draft - ?? (({ topic, bullets }) => - generateExampleObject({ - schema: draftSchema, - system: 'Turn bullets into a short draft.', - prompt: [ - `Topic: ${topic}`, - 'Bullets:', - ...bullets.map((bullet) => `- ${bullet}`), - ].join('\n'), - })))({ - topic: context.topic, - bullets: context.bullets, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { draft: output.draft }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ draft: context.draft ?? '' }), - }, - }, - }); - - return createAgentMachine({ - id: 'conditional-subflow-example', - schemas: { - input: z.object({ - topic: z.string(), - mode: modeSchema, - bullets: z.array(z.string()).optional(), - }), - output: z.object({ - mode: modeSchema, - bullets: z.array(z.string()), - draft: z.string().nullable(), - }), - }, - context: (input) => ({ - topic: input.topic, - mode: input.mode, - bullets: input.bullets ?? [], - draft: null as string | null, - }), - initial: ({ context }) => - context.mode === 'research' - ? { target: 'researching' } - : { target: 'drafting', input: { bullets: context.bullets } }, - states: { - researching: { - schemas: { output: researchSchema }, - invoke: async ({ context }) => { - const result = await execute(researchMachine, - researchMachine.getInitialState({ topic: context.topic }) - ); - - if (result.status !== 'done') { - throw new Error('Research subflow did not finish'); - } - - return result.output; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { bullets: output.bullets }, - }), - }, - drafting: { - schemas: { input: z.object({ - bullets: z.array(z.string()), - }), output: draftSchema }, - invoke: async ({ context, input }) => { - const result = await execute(draftMachine, - draftMachine.getInitialState({ - topic: context.topic, - bullets: input.bullets, - }) - ); - - if (result.status !== 'done') { - throw new Error('Draft subflow did not finish'); - } - - return result.output; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { draft: output.draft }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - mode: context.mode, - bullets: context.bullets, - draft: context.draft, - }), - }, - }, - }); -} - -async function main() { - try { - const topic = await prompt('Topic'); - const modeInput = await prompt('Mode (research/draft)'); - const mode = modeInput === 'draft' ? 'draft' : 'research'; - const machine = createConditionalSubflowExample(); - const result = await execute(machine, - machine.getInitialState({ topic, mode }) - ); - - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/content-creator-flow.ts b/examples/content-creator-flow.ts deleted file mode 100644 index 88c103d..0000000 --- a/examples/content-creator-flow.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const routeSchema = z.object({ - route: z.enum(['blog', 'linkedin', 'research']), -}); - -const contentSchema = z.object({ - title: z.string(), - body: z.string(), -}); - -type ContentRoute = z.infer['route']; - -export function createContentCreatorFlowExample(options: { - routeRequest?: (request: string) => Promise>; - createBlog?: (request: string) => Promise>; - createLinkedInPost?: (request: string) => Promise>; - createResearchReport?: (request: string) => Promise>; -} = {}) { - const routeRequest = - options.routeRequest ?? - ((request: string) => - generateExampleObject({ - schema: routeSchema, - system: - 'Route content requests to blog, linkedin, or research. Choose research for analysis-heavy requests, linkedin for short professional posts, and blog for longer educational pieces.', - prompt: request, - })); - - const createBlog = - options.createBlog ?? - ((request: string) => - generateExampleObject({ - schema: contentSchema, - system: 'Write a concise professional blog post.', - prompt: request, - })); - - const createLinkedInPost = - options.createLinkedInPost ?? - ((request: string) => - generateExampleObject({ - schema: contentSchema, - system: 'Write a concise professional LinkedIn post.', - prompt: request, - })); - - const createResearchReport = - options.createResearchReport ?? - ((request: string) => - generateExampleObject({ - schema: contentSchema, - system: 'Write a concise research-style briefing with findings and implications.', - prompt: request, - })); - - return createAgentMachine({ - id: 'content-creator-flow-example', - schemas: { - input: z.object({ - request: z.string(), - }), - output: z.object({ - route: z.enum(['blog', 'linkedin', 'research']).nullable(), - title: z.string().nullable(), - body: z.string().nullable(), - }), - }, - context: (input) => ({ - request: input.request, - route: null as ContentRoute | null, - title: null as string | null, - body: null as string | null, - }), - initial: 'routing', - states: { - routing: { - schemas: { output: routeSchema }, - invoke: async ({ context }) => routeRequest(context.request), - onDone: ({ output }) => ({ - target: 'creating', - context: { - route: output.route, - }, - }), - }, - creating: { - schemas: { output: contentSchema }, - invoke: async ({ context }) => { - switch (context.route) { - case 'linkedin': - return createLinkedInPost(context.request); - case 'research': - return createResearchReport(context.request); - case 'blog': - default: - return createBlog(context.request); - } - }, - onDone: ({ output }) => ({ - target: 'done', - context: { - title: output.title, - body: output.body, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - route: context.route, - title: context.title, - body: context.body, - }), - }, - }, - }); -} - -async function main() { - try { - const request = await prompt('Content request'); - const machine = createContentCreatorFlowExample(); - const result = await execute(machine, machine.getInitialState({ request })); - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/customer-service-sim.ts b/examples/customer-service-sim.ts deleted file mode 100644 index 4ae1c3d..0000000 --- a/examples/customer-service-sim.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const serviceReplySchema = z.object({ - response: z.string(), -}); - -const customerReplySchema = z.object({ - response: z.string(), - done: z.boolean(), - outcome: z.string().nullable(), -}); - -type TranscriptContext = { - issue: string; - transcript: string[]; - turnCount: number; - maxTurns: number; - outcome: string | null; -}; - -export function createCustomerServiceSimExample( - options: { - serviceReply?: (context: TranscriptContext) => Promise>; - customerReply?: (context: TranscriptContext) => Promise>; - maxTurns?: number; - } = {} -) { - const serviceReply = - options.serviceReply ?? - ((context: TranscriptContext) => - generateExampleObject({ - schema: serviceReplySchema, - system: 'You are a customer support agent negotiating calmly and pragmatically.', - prompt: [ - `Issue: ${context.issue}`, - `Turn count: ${context.turnCount}`, - `Current outcome: ${context.outcome ?? 'none'}`, - '', - 'Transcript so far:', - context.transcript.join('\n'), - '', - 'Write the next support agent response in one short paragraph.', - ].join('\n'), - })); - const customerReply = - options.customerReply ?? - ((context: TranscriptContext) => - generateExampleObject({ - schema: customerReplySchema, - system: 'You are the customer in the support exchange. Stay realistic and concise.', - prompt: [ - `Original issue: ${context.issue}`, - `Turn count: ${context.turnCount}`, - '', - 'Transcript so far:', - context.transcript.join('\n'), - '', - 'Write the next customer reply. Set done=true only if the issue is resolved or the customer accepts the proposed outcome. Use outcome to summarize the result when done.', - ].join('\n'), - })); - - return createAgentMachine({ - id: 'customer-service-sim-example', - schemas: { - input: z.object({ issue: z.string() }), - output: z.object({ - transcript: z.array(z.string()), - turnCount: z.number(), - outcome: z.string().nullable(), - }), - }, - context: (input) => ({ - issue: input.issue, - transcript: [`Customer: ${input.issue}`], - turnCount: 0, - maxTurns: options.maxTurns ?? 4, - outcome: null as string | null, - }), - initial: 'service', - states: { - service: { - schemas: { output: serviceReplySchema }, - invoke: async ({ context }) => serviceReply(context), - onDone: ({ output, context }) => ({ - target: context.turnCount + 1 >= context.maxTurns ? 'done' : 'customer', - context: { - transcript: [...context.transcript, `Agent: ${output.response}`], - outcome: - context.turnCount + 1 >= context.maxTurns - ? 'max-turns-reached' - : context.outcome, - }, - }), - }, - customer: { - schemas: { output: customerReplySchema }, - invoke: async ({ context }) => customerReply(context), - onDone: ({ output, context }) => ({ - target: output.done ? 'done' : 'service', - context: { - transcript: [...context.transcript, `Customer: ${output.response}`], - turnCount: context.turnCount + 1, - outcome: output.outcome, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - transcript: context.transcript, - turnCount: context.turnCount, - outcome: context.outcome, - }), - }, - }, - }); -} - -async function main() { - try { - const issue = await prompt('Customer issue'); - const machine = createCustomerServiceSimExample(); - console.log(formatResult(await execute(machine, machine.getInitialState({ issue })))); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/decide.ts b/examples/decide.ts deleted file mode 100644 index 747eaa9..0000000 --- a/examples/decide.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { - createAgentMachine, - decide, - decideResultSchema, - type DecideAdapter, -} from '../src/index.js'; -import { - closePrompt, - createOpenAiDecisionAdapter, - formatResult, - isMain, - prompt, -} from './_run.js'; - -export function createDecideExample(adapter: DecideAdapter = createOpenAiDecisionAdapter()) { - const triageOptions = { - reply: { - description: 'Reply directly to the customer.', - schema: z.object({ message: z.string() }), - }, - askForClarification: { - description: 'Ask one follow-up question before proceeding.', - schema: z.object({ question: z.string() }), - }, - escalate: { - description: 'Escalate to a human specialist.', - schema: z.object({ team: z.string() }), - }, - } as const; - - return createAgentMachine({ - id: 'decide-example', - schemas: { - input: z.object({ request: z.string() }), - output: z.object({ - action: z.string().nullable(), - payload: z.record(z.string(), z.unknown()).nullable(), - }), - }, - context: (input) => ({ - request: input.request, - action: null as string | null, - payload: null as Record | null, - }), - initial: 'triage', - states: { - triage: { - schemas: { output: decideResultSchema(triageOptions) }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'openai/gpt-5.4-nano', - prompt: [ - 'Choose the best next step for this support request.', - 'Prefer asking a single clarification question when key facts are missing.', - '', - `Request: ${context.request}`, - ].join('\n'), - options: triageOptions, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { - action: output.choice, - payload: output.data, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - action: context.action, - payload: context.payload, - }), - }, - }, - }); -} - -async function main() { - try { - const request = await prompt('Support request'); - const machine = createDecideExample(); - - console.log(formatResult(await execute(machine, machine.getInitialState({ request })))); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/email-auto-responder-flow.ts b/examples/email-auto-responder-flow.ts deleted file mode 100644 index 48df662..0000000 --- a/examples/email-auto-responder-flow.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - createAgentMachine, - type RunStore, -} from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const incomingEmailSchema = z.object({ - id: z.string(), - subject: z.string(), - body: z.string(), - sender: z.string(), -}); - -const draftResponseSchema = z.object({ - draft: z.string(), -}); - -type IncomingEmail = z.infer; - -export function createEmailAutoResponderFlowExample( - createDraft: (email: IncomingEmail) => Promise> = ( - email - ) => - generateExampleObject({ - schema: draftResponseSchema, - system: 'Write a concise professional email reply draft.', - prompt: [ - `Sender: ${email.sender}`, - `Subject: ${email.subject}`, - '', - email.body, - ].join('\n'), - }) -) { - return createAgentMachine({ - id: 'email-auto-responder-flow-example', - schemas: { - input: z.object({}), - output: z.object({ - processedIds: z.array(z.string()), - drafts: z.record(z.string(), z.string()), - }), - events: { - 'emails.received': z.object({ - emails: z.array(incomingEmailSchema), - }), - stop: z.object({}), - }, - }, - context: () => ({ - queue: [] as IncomingEmail[], - currentEmail: null as IncomingEmail | null, - processedIds: [] as string[], - drafts: {} as Record, - }), - initial: 'waiting', - states: { - waiting: { - on: { - 'emails.received': ({ context, event }) => { - const nextQueue = [...context.queue, ...event.emails].filter( - (email) => - !context.processedIds.includes(email.id) - && email.id !== context.currentEmail?.id - ); - const [currentEmail, ...queue] = nextQueue; - - if (!currentEmail) { - return { - context: { - queue, - }, - }; - } - - return { - target: 'drafting', - context: { - currentEmail, - queue, - }, - }; - }, - stop: { - target: 'done', - }, - }, - }, - drafting: { - on: { - 'emails.received': ({ context, event }) => ({ - context: { - queue: [...context.queue, ...event.emails].filter( - (email) => - !context.processedIds.includes(email.id) - && email.id !== context.currentEmail?.id - ), - }, - }), - stop: { - target: 'done', - }, - }, - schemas: { output: draftResponseSchema }, - invoke: async ({ context }) => createDraft(context.currentEmail!), - onDone: ({ output, context }) => { - const currentEmail = context.currentEmail!; - const processedIds = [...context.processedIds, currentEmail.id]; - const drafts = { - ...context.drafts, - [currentEmail.id]: output.draft, - }; - const [nextEmail, ...queue] = context.queue; - - if (nextEmail) { - return { - target: 'drafting', - context: { - currentEmail: nextEmail, - queue, - processedIds, - drafts, - }, - }; - } - - return { - target: 'waiting', - context: { - currentEmail: null, - queue: [], - processedIds, - drafts, - }, - }; - }, - }, - done: { - type: 'final', - output: ({ context }) => ({ - processedIds: context.processedIds, - drafts: context.drafts, - }), - }, - }, - }); -} - -export async function runEmailAutoResponderFlowExample( - emails: IncomingEmail[], - options: { - createDraft?: (email: IncomingEmail) => Promise>; - store?: RunStore; - } = {} -) { - const machine = createEmailAutoResponderFlowExample(options.createDraft); - const store = options.store ?? createMemoryRunStore(); - const run = await startSession(machine, { - store, - input: {}, - }); - - await run.send({ - type: 'emails.received', - emails, - }); - - return { - sessionId: run.sessionId, - snapshot: run.getSnapshot(), - restoredSnapshot: ( - await restoreSession(machine, { - sessionId: run.sessionId, - store, - }) - ).getSnapshot(), - }; -} - -async function main() { - try { - const sender = await prompt('Sender'); - const subject = await prompt('Subject'); - const body = await prompt('Body'); - const result = await runEmailAutoResponderFlowExample([ - { - id: 'email-1', - sender, - subject, - body, - }, - ]); - - console.log(formatResult({ - status: - result.snapshot.status === 'done' - ? 'done' - : result.snapshot.status === 'error' - ? 'error' - : 'pending', - state: { - value: result.snapshot.value, - context: result.snapshot.context, - status: result.snapshot.status, - input: result.snapshot.input, - }, - output: result.snapshot.output, - context: result.snapshot.context, - error: result.snapshot.error, - } as never)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/email-drafter.ts b/examples/email-drafter.ts deleted file mode 100644 index c1545eb..0000000 --- a/examples/email-drafter.ts +++ /dev/null @@ -1,541 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - appendMessages, - assistantMessage, - createAgentMachine, - type AgentAdapter, - userMessage, -} from '../src/index.js'; -import { createAiSdkAdapter } from '../src/ai-sdk/index.js'; -import { - closePrompt, - createExampleModel, - isMain, - prompt, -} from './_run.js'; - -const promptAssessmentSchema = z.object({ - satisfied: z.boolean(), - missing: z.array(z.string()), - questions: z.array(z.string()), -}); - -const emailDraftSchema = z.object({ - to: z.string(), - subject: z.string(), - body: z.string(), -}); - -type EmailDraft = z.infer; - -function formatDraft(draft: EmailDraft): string { - return [`To: ${draft.to}`, `Subject: ${draft.subject}`, '', draft.body].join('\n'); -} - -export function createEmailDrafterExample( - options: { - adapter?: AgentAdapter; - sendEmail?: (draft: EmailDraft) => Promise; - } = {} -) { - return createAgentMachine({ - id: 'email-drafter-example', - adapter: options.adapter ?? createEmailDrafterAdapter(), - schemas: { - output: z.object({ - sentEmails: z.array(emailDraftSchema), - }), - events: { - PROMPT_SUBMITTED: z.object({ prompt: z.string() }), - MORE_INFO: z.object({ details: z.string() }), - DRAFT_ANYWAY: z.object({}), - REQUEST_CHANGES: z.object({ changes: z.string() }), - SEND: z.object({}), - ANOTHER: z.object({}), - END: z.object({}), - }, - }, - externalEvents: [ - 'PROMPT_SUBMITTED', - 'MORE_INFO', - 'DRAFT_ANYWAY', - 'REQUEST_CHANGES', - 'SEND', - 'ANOTHER', - 'END', - ], - context: () => ({ - prompt: '', - assessment: null as z.infer | null, - draft: null as EmailDraft | null, - changes: null as string | null, - draftAnyway: false, - sentEmails: [] as EmailDraft[], - }), - initial: 'prompting', - states: { - prompting: { - on: { - PROMPT_SUBMITTED: ({ event }) => ({ - target: 'evaluating', - context: { - prompt: event.prompt, - assessment: null, - draft: null, - changes: null, - draftAnyway: false, - }, - messages: [userMessage(event.prompt)], - }), - }, - }, - evaluating: { - model: 'openai/gpt-5.4-nano', - system: - 'Evaluate an email drafting request. Require recipient/to, subject/purpose, and enough body details. Return concise missing fields and one question per gap.', - prompt: ({ snapshot }) => snapshot.context.prompt, - schemas: { output: promptAssessmentSchema }, - onDone: ({ output }) => { - if (output.satisfied) { - return { - target: 'drafting', - context: { assessment: output }, - }; - } - - return { - target: 'needsMoreInfo', - context: { assessment: output }, - }; - }, - }, - needsMoreInfo: { - on: { - MORE_INFO: ({ event, context, messages }) => ({ - target: 'evaluating', - context: { - prompt: `${context.prompt}\n\n${event.details}`, - draftAnyway: false, - }, - messages: appendMessages(messages, userMessage(event.details)), - }), - DRAFT_ANYWAY: { - target: 'drafting', - context: { draftAnyway: true }, - }, - }, - }, - drafting: { - model: 'openai/gpt-5.4-nano', - system: ({ snapshot }) => - [ - 'Draft a polished email from the request.', - snapshot.context.draftAnyway - ? 'Infer reasonable details only because the user chose to draft anyway.' - : 'Use the provided details without inventing missing essentials.', - 'Keep body useful and concise.', - ].join('\n'), - prompt: ({ snapshot }) => snapshot.context.prompt, - schemas: { output: emailDraftSchema }, - onDone: ({ output, messages }) => ({ - target: 'reviewing', - context: { - draft: output, - changes: null, - }, - messages: appendMessages(messages, assistantMessage(formatDraft(output))), - }), - }, - reviewing: { - on: { - REQUEST_CHANGES: ({ event, context, messages }) => ({ - target: 'drafting', - context: { - prompt: `${context.prompt}\n\nRevision request: ${event.changes}`, - changes: event.changes, - draftAnyway: true, - }, - messages: appendMessages( - messages, - userMessage(`Revision request: ${event.changes}`) - ), - }), - SEND: { target: 'sending' }, - }, - }, - sending: { - invoke: async ({ context }) => { - if (context.draft) { - await options.sendEmail?.(context.draft); - } - }, - onDone: ({ context }) => ({ - target: 'sent', - context: { - sentEmails: context.draft - ? [...context.sentEmails, context.draft] - : context.sentEmails, - }, - }), - }, - sent: { - on: { - ANOTHER: { - target: 'prompting', - context: { - prompt: '', - assessment: null, - draft: null, - changes: null, - draftAnyway: false, - }, - }, - END: { target: 'done' }, - }, - }, - done: { - type: 'final', - output: ({ context }) => ({ - sentEmails: context.sentEmails, - }), - }, - }, - }); -} - -function createEmailDrafterAdapter(): AgentAdapter { - const aiAdapter = process.env.OPENAI_API_KEY - ? createAiSdkAdapter({ - resolveModel: (model) => createExampleModel(model), - }) - : undefined; - - return { - async generateText(options) { - if (aiAdapter?.generateText) { - try { - return await aiAdapter.generateText(options); - } catch (error) { - console.warn(`AI generation failed; using fallback. ${formatError(error)}`); - } - } - - const text = options.prompt ?? options.messages.at(-1)?.content ?? ''; - - if (options.outputSchema === promptAssessmentSchema) { - return assessPromptFallback(text); - } - - if (options.outputSchema === emailDraftSchema) { - return draftEmailFallback(text); - } - - return text; - }, - }; -} - -function assessPromptFallback(text: string): z.infer { - const missing: string[] = []; - const questions: string[] = []; - - if (!extractRecipient(text)) { - missing.push('to'); - questions.push('Who should receive it?'); - } - - if (!extractSubject(text)) { - missing.push('subject'); - questions.push('What subject or purpose should it have?'); - } - - if (!hasBodyDetails(text)) { - missing.push('body details'); - questions.push('What key points should the body include?'); - } - - return { - satisfied: missing.length === 0, - missing, - questions, - }; -} - -function draftEmailFallback(text: string): EmailDraft { - const to = extractRecipient(text) ?? 'recipient@example.com'; - const subject = extractSubject(text) ?? 'Following up'; - const bodyDetails = text - .replace(/\s+/g, ' ') - .replace(/\b(to|subject|about|regarding)\b/gi, '') - .trim(); - - return { - to, - subject, - body: [ - 'Hi,', - '', - bodyDetails - ? `I wanted to reach out about ${bodyDetails}.` - : 'I wanted to reach out with a quick update.', - '', - 'Please let me know what you think.', - '', - 'Best,', - ].join('\n'), - }; -} - -function extractRecipient(text: string): string | undefined { - return text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)?.[0]; -} - -function extractSubject(text: string): string | undefined { - const match = text.match(/\bsubject\s*[:=-]\s*([^.;\n]+)/i); - if (match?.[1]) { - return titleCase(match[1].trim()); - } - - const about = text.match(/\b(?:about|regarding)\s+([^.;\n]+)/i); - return about?.[1] ? titleCase(about[1].trim()) : undefined; -} - -function hasBodyDetails(text: string): boolean { - const words = text.trim().split(/\s+/).filter(Boolean); - return ( - words.length >= 14 - || /because|include|mention|tell|ask|thanks|deadline|meeting/i.test(text) - ); -} - -function titleCase(value: string): string { - return value - .split(/\s+/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} - -async function main() { - try { - const machine = createEmailDrafterExample(); - const run = await startSession(machine, { - store: createMemoryRunStore(), - }); - - while (true) { - const snapshot = await waitForRunSnapshot( - run, - (nextSnapshot) => nextSnapshot.status !== 'active' - ); - - if (snapshot.status === 'done') { - console.log(snapshot.output); - break; - } - - if (snapshot.status === 'error') { - throw new Error(formatError(snapshot.error)); - } - - const events = machine.getEvents(snapshot); - - if ('PROMPT_SUBMITTED' in events) { - const request = await promptWithEvents('Email draft request', events); - if (await sendExplicitEvent(run, events, request)) { - continue; - } - - await run.send({ type: 'PROMPT_SUBMITTED', prompt: request }); - continue; - } - - if ('MORE_INFO' in events && 'DRAFT_ANYWAY' in events) { - console.log(`Missing: ${snapshot.context.assessment?.missing.join(', ')}`); - console.log(snapshot.context.assessment?.questions.map((q) => `- ${q}`).join('\n')); - const action = await selectWithEvents( - 'Next', - [ - { name: 'Add details', value: 'add' }, - { name: 'Draft anyway', value: 'draft' }, - ], - events - ); - if (await sendExplicitEvent(run, events, action)) { - continue; - } - - if (action.toLowerCase().startsWith('d')) { - await run.send({ type: 'DRAFT_ANYWAY' }); - continue; - } - - const details = await prompt('More details'); - await run.send({ type: 'MORE_INFO', details }); - continue; - } - - if ('REQUEST_CHANGES' in events && 'SEND' in events) { - if (snapshot.context.draft) { - console.log(formatDraft(snapshot.context.draft)); - } - - const action = await selectWithEvents( - 'Next', - [ - { name: 'Request changes', value: 'changes' }, - { name: 'Send', value: 'send' }, - ], - events - ); - if (await sendExplicitEvent(run, events, action)) { - continue; - } - - if (action.toLowerCase().startsWith('s')) { - await run.send({ type: 'SEND' }); - continue; - } - - const changes = await prompt('Requested changes'); - await run.send({ type: 'REQUEST_CHANGES', changes }); - continue; - } - - if ('ANOTHER' in events && 'END' in events) { - const another = await selectWithEvents( - 'Send another?', - [ - { name: 'Yes', value: 'yes' }, - { name: 'No', value: 'no' }, - ], - events - ); - if (await sendExplicitEvent(run, events, another)) { - continue; - } - - await run.send({ - type: another.toLowerCase().startsWith('y') ? 'ANOTHER' : 'END', - }); - continue; - } - - throw new Error('Email drafter entered an unexpected pending state.'); - } - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} - -function formatError(error: unknown): string { - if ( - error - && typeof error === 'object' - && 'message' in error - && typeof error.message === 'string' - ) { - return error.message; - } - - return error instanceof Error ? error.message : String(error); -} - -async function promptWithEvents( - label: string, - events: Record -): Promise { - printAvailableEvents(events); - return prompt(label); -} - -async function selectWithEvents( - label: string, - choices: Array<{ name: string; value: string }>, - events: Record -): Promise { - console.log(`${label}:`); - choices.forEach((choice, index) => { - console.log(` ${index + 1}. ${choice.name}`); - }); - printAvailableEvents(events); - - const answer = await prompt('Choice'); - const choiceIndex = Number(answer) - 1; - if (Number.isInteger(choiceIndex) && choices[choiceIndex]) { - return choices[choiceIndex].value; - } - - const matchingChoice = choices.find( - (choice) => - choice.value.toLowerCase() === answer.toLowerCase() - || choice.name.toLowerCase() === answer.toLowerCase() - ); - - return matchingChoice?.value ?? answer; -} - -function printAvailableEvents(events: Record): void { - console.log( - `Events: ${Object.keys(events).map((event) => `/${event}`).join(' ')}` - ); -} - -async function sendExplicitEvent( - run: { send: (event: any) => Promise }, - events: Record, - value: string -): Promise { - if (!value.startsWith('/')) { - return false; - } - - const match = value.match(/^\/([^\s]+)\s*([\s\S]*)$/); - if (!match) { - return false; - } - - const eventType = resolveEventType(events, match[1]!); - if (!eventType) { - console.log(`Unknown event. Available: ${Object.keys(events).join(', ')}`); - return true; - } - - const payloadText = match[2]!.trim(); - const payload = await resolveEventPayload(eventType, payloadText); - await run.send({ type: eventType, ...payload }); - return true; -} - -function resolveEventType( - events: Record, - input: string -): string | undefined { - return Object.keys(events).find( - (eventType) => eventType.toLowerCase() === input.toLowerCase() - ); -} - -async function resolveEventPayload( - eventType: string, - payloadText: string -): Promise> { - if (payloadText.startsWith('{')) { - return JSON.parse(payloadText); - } - - switch (eventType) { - case 'PROMPT_SUBMITTED': - return { prompt: payloadText || await prompt('prompt') }; - case 'MORE_INFO': - return { details: payloadText || await prompt('details') }; - case 'REQUEST_CHANGES': - return { changes: payloadText || await prompt('changes') }; - default: - return {}; - } -} diff --git a/examples/email.ts b/examples/email.ts deleted file mode 100644 index 5717666..0000000 --- a/examples/email.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - createAgentMachine, - decide, - decideResultSchema, - type DecideAdapter, -} from '../src/index.js'; -import { - closePrompt, - createOpenAiDecisionAdapter, - generateExampleObject, - generateExampleText, - isMain, - prompt, -} from './_run.js'; - -const draftSchema = z.object({ - replyEmail: z.string(), -}); - -type EmailTools = { - lookupContactName: (email: string) => Promise; - lookupAvailability: () => Promise; - createSignature: (name: string) => Promise; -}; - -export function createEmailExample( - options: { - adapter?: DecideAdapter; - tools?: Partial; - compose?: ( - input: { - email: string; - instructions: string; - clarifications: string[]; - contactName: string; - availability: string[]; - signature: string; - } - ) => Promise>; - } = {} -) { - const checkingOptions = { - askForClarification: { - description: 'Ask one or more clarifying questions before drafting.', - schema: z.object({ - questions: z.array(z.string()).min(1), - }), - }, - draft: { - description: 'Draft the email reply now.', - }, - } as const; - - const adapter = - options.adapter ?? - (process.env.OPENAI_API_KEY ? createOpenAiDecisionAdapter() : undefined); - const tools: EmailTools = { - lookupContactName: - options.tools?.lookupContactName ?? - (async (email) => { - const result = await generateExampleObject({ - schema: z.object({ name: z.string() }), - system: 'Infer a plausible recipient/contact name from an email thread when possible.', - prompt: `Infer the recipient or contact name from this email. If unclear, return a reasonable professional placeholder.\n\n${email}`, - }); - - return result.name; - }), - lookupAvailability: - options.tools?.lookupAvailability ?? - (async () => { - const result = await generateExampleObject({ - schema: z.object({ - availability: z.array(z.string()).min(2).max(3), - }), - system: 'Produce plausible professional meeting slots.', - prompt: - 'Return 2 or 3 plausible meeting times for next week, written in a concise natural style.', - }); - - return result.availability; - }), - createSignature: - options.tools?.createSignature ?? - (async (name) => - generateExampleText({ - system: 'Write a concise professional email signature.', - prompt: `Write a short professional sign-off for the sender named ${name}.`, - })), - }; - const compose = - options.compose ?? - (({ - email, - instructions, - clarifications, - contactName, - availability, - signature, - }) => - generateExampleObject({ - schema: draftSchema, - system: 'You write concise professional email replies.', - prompt: [ - `Incoming email:\n${email}`, - '', - `Instructions:\n${instructions}`, - '', - `Contact name: ${contactName}`, - `Availability: ${availability.join(' | ')}`, - `Signature:\n${signature}`, - clarifications.length - ? `Clarifications:\n${clarifications.map((item) => `- ${item}`).join('\n')}` - : 'Clarifications: none', - '', - 'Draft the reply email.', - ].join('\n'), - })); - - return createAgentMachine({ - id: 'email-example', - schemas: { - input: z.object({ - email: z.string(), - instructions: z.string(), - }), - output: z.object({ - replyEmail: z.string().nullable(), - clarifications: z.array(z.string()), - }), - events: { - 'user.answer': z.object({ answer: z.string() }), - }, - }, - context: (input) => ({ - email: input.email, - instructions: input.instructions, - clarifications: [] as string[], - questions: [] as string[], - replyEmail: null as string | null, - }), - initial: 'checking', - states: { - checking: { - schemas: { output: decideResultSchema(checkingOptions) }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'openai/gpt-5.4-nano', - prompt: [ - 'Decide whether there is enough information to draft the reply email.', - 'Choose askForClarification only if key scheduling or identity details are missing.', - '', - `Email: ${context.email}`, - `Instructions: ${context.instructions}`, - `Clarifications: ${context.clarifications.join(' | ') || 'none'}`, - ].join('\n'), - options: checkingOptions, - }), - onDone: ({ output, context }) => { - if ( - output.choice === 'askForClarification' - && context.clarifications.length === 0 - ) { - return { - target: 'clarifying', - context: { questions: output.data.questions }, - }; - } - - return { - target: 'drafting', - context: { questions: [] }, - }; - }, - }, - clarifying: { - on: { - 'user.answer': ({ event, context }) => ({ - target: 'checking', - context: { - clarifications: [...context.clarifications, event.answer], - questions: [], - }, - }), - }, - }, - drafting: { - schemas: { output: draftSchema }, - invoke: async ({ context }) => { - const contactName = await tools.lookupContactName(context.email); - const availability = await tools.lookupAvailability(); - const signature = await tools.createSignature(contactName); - - return compose({ - email: context.email, - instructions: context.instructions, - clarifications: context.clarifications, - contactName, - availability, - signature, - }); - }, - onDone: ({ output }) => ({ - target: 'done', - context: { replyEmail: output.replyEmail }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - replyEmail: context.replyEmail, - clarifications: context.clarifications, - }), - }, - }, - }); -} - -async function main() { - try { - const email = await prompt('Incoming email'); - const instructions = await prompt('Instructions'); - const machine = createEmailExample(); - const run = await startSession(machine, { - store: createMemoryRunStore(), - input: { email, instructions }, - }); - - while (true) { - const snapshot = await waitForRunSnapshot( - run, - (nextSnapshot) => nextSnapshot.status !== 'active' - ); - - if (snapshot.status === 'done') { - console.log({ - status: snapshot.status, - value: snapshot.value, - context: snapshot.context, - output: snapshot.output, - }); - break; - } - - if (snapshot.value === 'clarifying') { - console.log(snapshot.context.questions.join('\n')); - const answer = await prompt('Clarification'); - await run.send({ type: 'user.answer', answer }); - continue; - } - - throw new Error('Email example entered an unexpected pending state.'); - } - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/error-retry.ts b/examples/error-retry.ts deleted file mode 100644 index 838a7fe..0000000 --- a/examples/error-retry.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const answerSchema = z.object({ - answer: z.string(), -}); - -export function createErrorRetryExample( - answer: (args: { - question: string; - attempt: number; - }) => Promise> = async ({ question, attempt }) => - generateExampleObject({ - schema: answerSchema, - system: 'Answer the user question in one concise paragraph.', - prompt: [ - `Attempt: ${attempt}`, - '', - `Question: ${question}`, - ].join('\n'), - }), - maxAttempts = 3 -) { - return createAgentMachine({ - id: 'error-retry-example', - schemas: { - input: z.object({ - question: z.string(), - }), - events: { - 'xstate.error.invoke.answering': z.object({ - type: z.literal('xstate.error.invoke.answering'), - error: z.unknown().optional(), - at: z.number().optional(), - }), - }, - output: z.object({ - answer: z.string().nullable(), - attempts: z.number().int().min(1), - errors: z.array(z.string()), - }), - }, - context: (input) => ({ - question: input.question, - answer: null as string | null, - attempt: 1, - errors: [] as string[], - }), - initial: 'answering', - states: { - answering: { - schemas: { output: answerSchema }, - invoke: async ({ context }) => - answer({ - question: context.question, - attempt: context.attempt, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { - answer: output.answer, - }, - }), - on: { - 'xstate.error.invoke.answering': ({ event, context }) => { - const errors = [...context.errors, formatError(event.error)]; - - if (context.attempt >= maxAttempts) { - return { - target: 'failed', - context: { errors }, - }; - } - - return { - target: 'answering', - context: { - attempt: context.attempt + 1, - errors, - }, - }; - }, - }, - }, - failed: { - type: 'final', - output: ({ context }) => ({ - answer: context.answer, - attempts: context.attempt, - errors: context.errors, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - answer: context.answer, - attempts: context.attempt, - errors: context.errors, - }), - }, - }, - }); -} - -function formatError(error: unknown): string { - if (error && typeof error === 'object' && 'message' in error) { - return String((error as { message: unknown }).message); - } - - return String(error); -} - -async function main() { - try { - const question = await prompt('Question'); - const machine = createErrorRetryExample(); - const result = await execute(machine, machine.getInitialState({ question })); - - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/hitl.ts b/examples/hitl.ts deleted file mode 100644 index c54f17c..0000000 --- a/examples/hitl.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - createAgentMachine, - type AgentMessage, -} from '../src/index.js'; -import { - closePrompt, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const draftSchema = z.object({ - draft: z.string(), -}); - -export function createHitlExample( - draftReply: (args: { - task: string; - messages: AgentMessage[]; - }) => Promise> = async ({ task, messages }) => { - return generateExampleObject({ - schema: draftSchema, - prompt: [ - `Task: ${task}`, - '', - 'Use the notes below to draft a concise response:', - ...messages.map((message, index) => `${index + 1}. ${message.content}`), - ].join('\n'), - }); - } -) { - return createAgentMachine({ - id: 'hitl-example', - schemas: { - input: z.object({ task: z.string() }), - output: z.object({ - draft: z.string().nullable().optional(), - cancelled: z.literal(true).optional(), - }), - events: { - 'user.message': z.object({ message: z.string() }), - 'user.approve': z.object({}), - 'user.cancel': z.object({}), - }, - }, - context: (input) => ({ - task: input.task, - draft: null as string | null, - }), - messages: [], - initial: 'gathering', - states: { - gathering: { - on: { - 'user.message': ({ messages, event }) => ({ - messages: messages.concat({ role: 'user', content: event.message }), - }), - 'user.approve': { target: 'drafting' }, - 'user.cancel': { target: 'cancelled' }, - }, - }, - drafting: { - schemas: { output: draftSchema }, - invoke: async ({ context, messages }) => - draftReply({ - task: context.task, - messages, - }), - onDone: ({ output, messages }) => ({ - target: 'done', - messages: messages.concat({ role: 'assistant', content: output.draft }), - context: { draft: output.draft }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ draft: context.draft ?? null }), - }, - cancelled: { - type: 'final', - output: () => ({ cancelled: true as const }), - }, - }, - }); -} - -async function main() { - try { - const task = await prompt('Task'); - const machine = createHitlExample(); - const run = await startSession(machine, { - store: createMemoryRunStore(), - input: { task }, - }); - - while (true) { - const snapshot = await waitForRunSnapshot( - run, - (nextSnapshot) => nextSnapshot.status !== 'active' - ); - - if (snapshot.status === 'done') { - console.log({ - status: snapshot.status, - value: snapshot.value, - context: snapshot.context, - messages: snapshot.messages, - output: snapshot.output, - }); - break; - } - - const message = await prompt('Add note, or type /approve or /cancel'); - - if (message === '/approve') { - await run.send({ type: 'user.approve' }); - continue; - } - - if (message === '/cancel') { - await run.send({ type: 'user.cancel' }); - continue; - } - - await run.send({ - type: 'user.message', - message, - }); - console.log({ - status: run.getSnapshot().status, - value: run.getSnapshot().value, - context: run.getSnapshot().context, - }); - } - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/http-session.ts b/examples/http-session.ts deleted file mode 100644 index f765193..0000000 --- a/examples/http-session.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createSessionHttpHandler } from '../src/http/index.js'; -import { type RunStore } from '../src/index.js'; -import { createPersistenceExample } from './persistence.js'; - -export interface SessionHttpHandlerOptions { - store?: RunStore; - summarize?: Parameters[0]; -} - -export function createPersistenceSessionHttpHandler( - options: SessionHttpHandlerOptions = {} -) { - const machine = createPersistenceExample(options.summarize); - return createSessionHttpHandler(machine, { - store: options.store, - }); -} diff --git a/examples/http-streaming-session.ts b/examples/http-streaming-session.ts deleted file mode 100644 index 3da5503..0000000 --- a/examples/http-streaming-session.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { createSessionHttpController } from '../src/http/index.js'; -import { - createAgentMachine, - type RunStore, -} from '../src/index.js'; - -const streamingInputSchema = z.object({ - streamId: z.string(), - text: z.string(), -}); - -const streamingOutputSchema = z.object({ - text: z.string(), -}); - -const textPartSchema = z.object({ - delta: z.string(), -}); - -export interface StreamingSessionHttpController { - handle(request: Request): Promise; - advance(streamId: string): void; - dropActiveSession(sessionId: string): void; -} - -export function createStreamingSessionHttpController(options: { - store?: RunStore; -} = {}): StreamingSessionHttpController { - const store = options.store ?? createMemoryRunStore(); - const streamer = createDurableChunkStreamer(); - const machine = createAgentMachine({ - id: 'http-streaming-session-example', - schemas: { - input: streamingInputSchema, - output: streamingOutputSchema, - emitted: { - textPart: textPartSchema, - }, - }, - context: (input) => ({ - streamId: input.streamId, - text: input.text, - finalText: '', - }), - initial: 'writing', - states: { - writing: { - schemas: { output: streamingOutputSchema }, - invoke: async ({ context }, enq) => - streamer.streamText(context.streamId, context.text, (delta) => { - enq.emit({ type: 'textPart', delta }); - }), - onDone: ({ output }) => ({ - target: 'done', - context: { finalText: output.text }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - text: context.finalText, - }), - }, - }, - }); - const controller = createSessionHttpController(machine, { store }); - - return { - advance(streamId) { - streamer.advance(streamId); - }, - - dropActiveSession(sessionId) { - controller.dropActiveSession(sessionId); - }, - - async handle(request) { - return controller.handle(request); - }, - }; -} - -function createDurableChunkStreamer() { - const cursors = new Map(); - const invocations = new Map(); - const waiters = new Map void>>(); - - return { - advance(streamId: string) { - const current = waiters.get(streamId) ?? []; - waiters.set(streamId, []); - for (const resolve of current) { - resolve(); - } - }, - - async streamText( - streamId: string, - text: string, - emit: (delta: string) => void - ) { - const chunks = splitIntoChunks(text); - const invocation = (invocations.get(streamId) ?? 0) + 1; - invocations.set(streamId, invocation); - let cursor = cursors.get(streamId) ?? 0; - - while (cursor < chunks.length) { - if (invocation < (invocations.get(streamId) ?? 0)) { - await new Promise(() => {}); - } - - await new Promise((resolve) => { - waiters.set(streamId, [...(waiters.get(streamId) ?? []), resolve]); - }); - - if (invocation < (invocations.get(streamId) ?? 0)) { - await new Promise(() => {}); - } - - emit(chunks[cursor]!); - cursor += 1; - cursors.set(streamId, cursor); - } - - return { text }; - }, - }; -} - -function splitIntoChunks(text: string): string[] { - if (text.length <= 3) { - return [text]; - } - - return [text.slice(0, 3), text.slice(3)]; -} diff --git a/examples/index.ts b/examples/index.ts index f00f7f2..be12716 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -1,96 +1,13 @@ -// Runtime and deployment examples -export { createPersistenceSessionHttpHandler } from './http-session.js'; -export { createStreamingSessionHttpController } from './http-streaming-session.js'; -export { createAiSdkExample } from './ai-sdk.js'; export { - createCloudflareAgentRunStore, - createCloudflareAgentsExample, - type CloudflareAgentRunStoreState, -} from './cloudflare-agents.js'; -export { - AgentSessionDurableObject, - createDurableObjectRunStore, - type DurableObjectStateLike, - type DurableObjectStorageLike, -} from './cloudflare-durable-object.js'; -export { AgentNetworkDurableObject } from './cloudflare-durable-network.js'; -export { - createNextAiSdkUiRoute, - type AgentUiMessage, -} from './next-ai-sdk-ui.js'; -export { - createNextReviewRouteHandlers, - createNextStreamingRouteHandlers, - dynamic as nextAppRouterDynamic, - maxDuration as nextAppRouterMaxDuration, - runtime as nextAppRouterRuntime, - type NextRouteContext, -} from './next-app-router.js'; -export { createPersistenceExample, runPersistenceExample } from './persistence.js'; -export { - createPersistentMultiAgentNetworkExample, - runPersistentMultiAgentNetworkExample, -} from './persistent-multi-agent-network.js'; -export { - createPersistentStreamingExample, - runPersistentStreamingExample, -} from './persistent-streaming.js'; -export { - createPersistentSupervisorExample, - runPersistentSupervisorExample, -} from './persistent-supervisor.js'; - -// Workflow examples -export { createContentCreatorFlowExample } from './content-creator-flow.js'; -export { - createEmailAutoResponderFlowExample, - runEmailAutoResponderFlowExample, -} from './email-auto-responder-flow.js'; -export { createErrorRetryExample } from './error-retry.js'; -export { createLeadScoreFlowExample } from './lead-score-flow.js'; -export { createMeetingAssistantFlowExample } from './meeting-assistant-flow.js'; -export { createMultiAgentNetworkExample } from './multi-agent-network.js'; -export { createPlanAndExecuteExample } from './plan-and-execute.js'; -export { createRaffleExample } from './raffle.js'; -export { createRagExample } from './rag.js'; -export { createReactAgentExample } from './react-agent.js'; -export { - createReactAgentFromScratch, - type ReactAgentMessage, - type ReactAgentModelResult, - type ReactTool, -} from './react-agent-from-scratch.js'; -export { createRewooExample } from './rewoo.js'; -export { createReflectionExample } from './reflection.js'; -export { createSelfEvaluationLoopFlowExample } from './self-evaluation-loop-flow.js'; -export { createSpecAgentLoopExample } from './spec-agent-loop.js'; -export { - createGuardrailedBugfixWorkflowExample, - createGuardrailedIncidentResponseExample, - createUnguardedIncidentResponseExample, -} from './workflow-guardrails.js'; -export { createSupervisorExample } from './supervisor.js'; -export { createWriteABookFlowExample } from './write-a-book-flow.js'; -export { createSqlAgentExample } from './sql-agent.js'; - -// Reference and concept examples -export { createAdapterExample } from './adapter.js'; -export { createBranchingExample } from './branching.js'; -export { createChatbotExample } from './chatbot.js'; -export { createChatbotMessagesExample } from './chatbot-messages.js'; -export { createClassifyExample } from './classify.js'; -export { createConditionalSubflowExample } from './conditional-subflow.js'; -export { createCustomerServiceSimExample } from './customer-service-sim.js'; -export { createDecideExample } from './decide.js'; -export { createEmailDrafterExample } from './email-drafter.js'; -export { createEmailExample } from './email.js'; -export { createHitlExample } from './hitl.js'; -export { createJokeExample } from './joke.js'; -export { createJugsExample } from './jugs.js'; -export { createMapReduceExample } from './map-reduce.js'; -export { createNewspaperExample } from './newspaper.js'; -export { createRiverCrossingExample } from './river-crossing.js'; -export { createSimpleExample } from './simple.js'; -export { createSubflowExample } from './subflow.js'; -export { createToolCallingExample } from './tool-calling.js'; -export { createTutorExample } from './tutor.js'; + draftEmail, + emailDrafter, + emailDrafterSchemas, + evaluatePrompt, +} from './setup-agent/email-drafter.js'; +export { + chooseMove, + gameMachine, + gameSchemas, + summarizeTurn, + turnSummarySchema, +} from './setup-agent/game-agent.js'; diff --git a/examples/joke.ts b/examples/joke.ts deleted file mode 100644 index 4aeac6f..0000000 --- a/examples/joke.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine, type AgentAdapter } from '../src/index.js'; -import { - closePrompt, - createOpenAiGenerationAdapter, - formatResult, - isMain, - prompt, -} from './_run.js'; - -const jokeSchema = z.object({ - joke: z.string(), -}); - -const ratingSchema = z.object({ - rating: z.number().min(1).max(10), - explanation: z.string(), -}); - -export function createJokeExample( - adapter: AgentAdapter = createOpenAiGenerationAdapter() -) { - return createAgentMachine({ - id: 'joke-example', - adapter, - schemas: { - input: z.object({ topic: z.string() }), - output: z.object({ - topic: z.string(), - joke: z.string().nullable(), - rating: z.number().nullable(), - explanation: z.string().nullable(), - accepted: z.boolean(), - }), - }, - context: (input) => ({ - topic: input.topic, - joke: null as string | null, - rating: null as number | null, - explanation: null as string | null, - accepted: false, - }), - initial: 'telling', - states: { - telling: { - schemas: { output: jokeSchema }, - system: 'You write short, clean jokes.', - prompt: ({ context }) => `Write one short joke about ${context.topic}.`, - onDone: ({ output }) => ({ - target: 'rating', - context: { joke: output.joke }, - }), - }, - rating: { - schemas: { output: ratingSchema }, - system: 'You are a joke critic. Be fair and concise.', - prompt: ({ context }) => - [ - `Topic: ${context.topic}`, - `Joke: ${context.joke ?? ''}`, - '', - 'Rate the joke from 1 to 10 and explain briefly.', - ].join('\n'), - onDone: ({ output }) => ({ - target: 'done', - context: { - rating: output.rating, - explanation: output.explanation, - accepted: output.rating >= 7, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - topic: context.topic, - joke: context.joke, - rating: context.rating, - explanation: context.explanation, - accepted: context.accepted, - }), - }, - }, - }); -} - -async function main() { - try { - const topic = await prompt('Joke topic'); - const machine = createJokeExample(); - console.log(formatResult(await execute(machine, machine.getInitialState({ topic })))); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/jugs.ts b/examples/jugs.ts deleted file mode 100644 index 0e5e900..0000000 --- a/examples/jugs.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { formatResult, isMain } from './_run.js'; - -const moveSchema = z.object({ - move: z - .enum(['fill5', 'pour5to3', 'empty3', 'done']) - .describe('The next move in the water jug puzzle'), - reasoning: z.string(), -}); - -const applySchema = z.object({ - jug3: z.number().int(), - jug5: z.number().int(), - step: z.string(), -}); - -function chooseWaterJugMove(jug3: number, jug5: number): z.infer { - const key = `${jug3},${jug5}`; - const plan: Record> = { - '0,0': { move: 'fill5', reasoning: 'Start by filling the larger jug.' }, - '0,5': { move: 'pour5to3', reasoning: 'Transfer water into the 3-gallon jug.' }, - '3,2': { move: 'empty3', reasoning: 'Empty the smaller jug to make room.' }, - '0,2': { move: 'pour5to3', reasoning: 'Move the remaining water into the 3-gallon jug.' }, - '2,0': { move: 'fill5', reasoning: 'Refill the 5-gallon jug.' }, - '2,5': { move: 'pour5to3', reasoning: 'Top off the 3-gallon jug to leave 4 gallons.' }, - '3,4': { move: 'done', reasoning: 'The 5-gallon jug now holds exactly 4 gallons.' }, - }; - - return plan[key] ?? { move: 'done', reasoning: 'No further move required.' }; -} - -function applyWaterJugMove( - jug3: number, - jug5: number, - move: z.infer['move'] -): z.infer { - switch (move) { - case 'fill5': - return { jug3, jug5: 5, step: 'Filled the 5-gallon jug.' }; - case 'pour5to3': { - const transfer = Math.min(3 - jug3, jug5); - return { - jug3: jug3 + transfer, - jug5: jug5 - transfer, - step: 'Poured from the 5-gallon jug into the 3-gallon jug.', - }; - } - case 'empty3': - return { jug3: 0, jug5, step: 'Emptied the 3-gallon jug.' }; - default: - return { jug3, jug5, step: 'Solved the puzzle.' }; - } -} - -export function createJugsExample() { - return createAgentMachine({ - id: 'jugs-example', - schemas: { - output: z.object({ - jug3: z.number(), - jug5: z.number(), - steps: z.array(z.string()), - reasoning: z.array(z.string()), - }), - }, - context: () => ({ - jug3: 0, - jug5: 0, - steps: [] as string[], - reasoning: [] as string[], - }), - initial: 'choosing', - states: { - choosing: { - schemas: { output: moveSchema }, - invoke: async ({ context }) => chooseWaterJugMove(context.jug3, context.jug5), - onDone: ({ output, context }) => { - const nextReasoning = [...context.reasoning, output.reasoning]; - - if (output.move === 'done') { - return { - target: 'done' as const, - context: { reasoning: nextReasoning }, - }; - } - - return { - target: 'applying' as const, - input: { move: output.move }, - context: { reasoning: nextReasoning }, - }; - }, - }, - applying: { - schemas: { input: z.object({ - move: moveSchema.shape.move.exclude(['done']), - }), output: applySchema }, - invoke: async ({ context, input }) => - applyWaterJugMove( - context.jug3, - context.jug5, - input.move as 'fill5' | 'pour5to3' | 'empty3' - ), - onDone: ({ output, context }) => ({ - target: 'choosing', - context: { - jug3: output.jug3, - jug5: output.jug5, - steps: [...context.steps, output.step], - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - jug3: context.jug3, - jug5: context.jug5, - steps: context.steps, - reasoning: context.reasoning, - }), - }, - }, - }); -} - -async function main() { - const machine = createJugsExample(); - console.log(formatResult(await execute(machine, machine.getInitialState()))); -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/lead-score-flow.ts b/examples/lead-score-flow.ts deleted file mode 100644 index b6336c1..0000000 --- a/examples/lead-score-flow.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - createAgentMachine, -} from '../src/index.js'; -import { - closePrompt, - isMain, - prompt, -} from './_run.js'; - -const leadSchema = z.object({ - id: z.string(), - company: z.string(), - contact: z.string(), -}); - -const scoredLeadSchema = leadSchema.extend({ - score: z.number().min(0).max(100), - rationale: z.string(), -}); - -const scoringSchema = z.object({ - scoredLeads: z.array(scoredLeadSchema), -}); - -const emailDraftSchema = z.object({ - leadId: z.string(), - draft: z.string(), -}); - -const emailBatchSchema = z.object({ - drafts: z.array(emailDraftSchema), -}); - -type Lead = z.infer; - -export function createLeadScoreFlowExample(options: { - scoreLeads?: (args: { - leads: Lead[]; - reviewNote: string | null; - }) => Promise>; - writeEmails?: (leads: z.infer[]) => Promise>; -} = {}) { - const scoreLeads = - options.scoreLeads ?? - (async ({ leads, reviewNote }) => ({ - scoredLeads: leads - .map((lead, index) => ({ - ...lead, - score: Math.max(0, 90 - index * 10 - (reviewNote ? 5 : 0)), - rationale: reviewNote - ? `Adjusted after review: ${reviewNote}` - : `Initial score for ${lead.company}`, - })) - .sort((a, b) => b.score - a.score), - })); - - const writeEmails = - options.writeEmails ?? - (async (leads) => ({ - drafts: leads.map((lead) => ({ - leadId: lead.id, - draft: `Hi ${lead.contact}, I would love to talk about ${lead.company}.`, - })), - })); - - return createAgentMachine({ - id: 'lead-score-flow-example', - schemas: { - input: z.object({ - leads: z.array(leadSchema), - }), - output: z.object({ - scoredLeads: z.array(scoredLeadSchema), - topLeads: z.array(scoredLeadSchema), - emailDrafts: z.array(emailDraftSchema), - reviewCount: z.number(), - }), - events: { - 'review.approve': z.object({}), - 'review.requestChanges': z.object({ - note: z.string(), - }), - }, - }, - context: (input) => ({ - leads: input.leads, - scoredLeads: [] as z.infer[], - topLeads: [] as z.infer[], - emailDrafts: [] as z.infer[], - reviewNote: null as string | null, - reviewCount: 0, - }), - initial: 'scoring', - states: { - scoring: { - schemas: { output: scoringSchema }, - invoke: async ({ context }) => - scoreLeads({ - leads: context.leads, - reviewNote: context.reviewNote, - }), - onDone: ({ output, context }) => ({ - target: 'reviewing', - context: { - scoredLeads: output.scoredLeads, - topLeads: output.scoredLeads.slice(0, 3), - reviewNote: null, - reviewCount: context.reviewCount + 1, - }, - }), - }, - reviewing: { - on: { - 'review.approve': { - target: 'writing', - }, - 'review.requestChanges': ({ event }) => ({ - target: 'scoring', - context: { - reviewNote: event.note, - }, - }), - }, - }, - writing: { - schemas: { output: emailBatchSchema }, - invoke: async ({ context }) => writeEmails(context.scoredLeads), - onDone: ({ output }) => ({ - target: 'done', - context: { - emailDrafts: output.drafts, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - scoredLeads: context.scoredLeads, - topLeads: context.topLeads, - emailDrafts: context.emailDrafts, - reviewCount: context.reviewCount, - }), - }, - }, - }); -} - -async function main() { - try { - const companies = (await prompt('Comma-separated company names')) - .split(',') - .map((value) => value.trim()) - .filter(Boolean); - const machine = createLeadScoreFlowExample(); - const run = await startSession(machine, { - store: createMemoryRunStore(), - input: { - leads: companies.map((company, index) => ({ - id: `lead-${index + 1}`, - company, - contact: `Contact ${index + 1}`, - })), - }, - }); - - while (true) { - const snapshot = await waitForRunSnapshot( - run, - (nextSnapshot) => nextSnapshot.status !== 'active' - ); - - if (snapshot.status === 'done') { - console.log({ - status: snapshot.status, - value: snapshot.value, - context: snapshot.context, - output: snapshot.output, - }); - break; - } - - if (snapshot.value === 'reviewing') { - console.log(snapshot.context.topLeads); - const answer = await prompt('Type /approve or provide a review note'); - await run.send( - answer === '/approve' - ? { type: 'review.approve' } - : { - type: 'review.requestChanges', - note: answer, - } - ); - continue; - } - - throw new Error('Lead score flow entered an unexpected pending state.'); - } - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/map-reduce.ts b/examples/map-reduce.ts deleted file mode 100644 index 2c5610e..0000000 --- a/examples/map-reduce.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - generateExampleText, - isMain, - prompt, -} from './_run.js'; - -const subjectsSchema = z.object({ - subjects: z.array(z.string()), -}); - -const jokesSchema = z.object({ - jokes: z.array(z.string()), -}); - -const bestJokeSchema = z.object({ - bestJoke: z.string(), -}); - -export function createMapReduceExample( - options: { - planSubjects?: (topic: string) => Promise>; - writeJoke?: (subject: string) => Promise; - chooseBest?: (jokes: string[]) => Promise>; - } = {} -) { - return createAgentMachine({ - id: 'map-reduce-example', - schemas: { - input: z.object({ topic: z.string() }), - output: z.object({ - subjects: z.array(z.string()), - jokes: z.array(z.string()), - bestJoke: z.string().nullable(), - }), - }, - context: (input) => ({ - topic: input.topic, - subjects: [] as string[], - jokes: [] as string[], - bestJoke: null as string | null, - }), - initial: 'planning', - states: { - planning: { - schemas: { output: subjectsSchema }, - invoke: async ({ context }) => - (options.planSubjects - ?? ((topic) => - generateExampleObject({ - schema: subjectsSchema, - system: 'You break a topic into a few concrete subtopics.', - prompt: `List 2 to 4 specific subtopics worth covering for: ${topic}`, - })))(context.topic), - onDone: ({ output }) => ({ - target: 'mapping', - context: { subjects: output.subjects }, - }), - }, - mapping: { - schemas: { output: jokesSchema }, - invoke: async ({ context }) => { - const jokes = await Promise.all( - context.subjects.map((subject) => - (options.writeJoke - ?? ((value) => - generateExampleText({ - system: 'You write one-line jokes.', - prompt: `Write one short joke about ${value}.`, - })))(subject) - ) - ); - - return { jokes }; - }, - onDone: ({ output }) => ({ - target: 'reducing', - context: { jokes: output.jokes }, - }), - }, - reducing: { - schemas: { output: bestJokeSchema }, - invoke: async ({ context }) => - (options.chooseBest - ?? ((jokes) => - generateExampleObject({ - schema: bestJokeSchema, - system: 'You pick the strongest joke from a list.', - prompt: ['Choose the best joke from this list:', ...jokes].join('\n'), - })))(context.jokes), - onDone: ({ output }) => ({ - target: 'done', - context: { bestJoke: output.bestJoke }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - subjects: context.subjects, - jokes: context.jokes, - bestJoke: context.bestJoke, - }), - }, - }, - }); -} - -async function main() { - try { - const topic = await prompt('Topic'); - const machine = createMapReduceExample(); - const result = await execute(machine, machine.getInitialState({ topic })); - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/meeting-assistant-flow.ts b/examples/meeting-assistant-flow.ts deleted file mode 100644 index 97e726e..0000000 --- a/examples/meeting-assistant-flow.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const taskSchema = z.object({ - title: z.string(), - owner: z.string(), -}); - -const extractionSchema = z.object({ - summary: z.string(), - tasks: z.array(taskSchema), -}); - -const fanOutSchema = z.object({ - trelloCardIds: z.array(z.string()), - csvPath: z.string(), - slackMessageId: z.string(), -}); - -export function createMeetingAssistantFlowExample(options: { - extractTasks?: (notes: string) => Promise>; - addTasksToTrello?: (tasks: z.infer[]) => Promise<{ trelloCardIds: string[] }>; - saveTasksToCsv?: (tasks: z.infer[]) => Promise<{ csvPath: string }>; - sendSlackNotification?: (args: { - summary: string; - tasks: z.infer[]; - }) => Promise<{ slackMessageId: string }>; -} = {}) { - const extractTasks = - options.extractTasks ?? - ((notes: string) => - generateExampleObject({ - schema: extractionSchema, - system: 'Extract a concise meeting summary and explicit action items.', - prompt: notes, - })); - - const addTasksToTrello = - options.addTasksToTrello ?? - (async (tasks) => ({ - trelloCardIds: tasks.map((_, index) => `card-${index + 1}`), - })); - - const saveTasksToCsv = - options.saveTasksToCsv ?? - (async () => ({ - csvPath: 'new_tasks.csv', - })); - - const sendSlackNotification = - options.sendSlackNotification ?? - (async () => ({ - slackMessageId: 'slack-message-1', - })); - - return createAgentMachine({ - id: 'meeting-assistant-flow-example', - schemas: { - input: z.object({ - notes: z.string(), - }), - output: z.object({ - summary: z.string().nullable(), - tasks: z.array(taskSchema), - trelloCardIds: z.array(z.string()), - csvPath: z.string().nullable(), - slackMessageId: z.string().nullable(), - }), - }, - context: (input) => ({ - notes: input.notes, - summary: null as string | null, - tasks: [] as z.infer[], - trelloCardIds: [] as string[], - csvPath: null as string | null, - slackMessageId: null as string | null, - }), - initial: 'extracting', - states: { - extracting: { - schemas: { output: extractionSchema }, - invoke: async ({ context }) => extractTasks(context.notes), - onDone: ({ output }) => ({ - target: 'dispatching', - context: { - summary: output.summary, - tasks: output.tasks, - }, - }), - }, - dispatching: { - schemas: { output: fanOutSchema }, - invoke: async ({ context }) => { - const [trello, csv, slack] = await Promise.all([ - addTasksToTrello(context.tasks), - saveTasksToCsv(context.tasks), - sendSlackNotification({ - summary: context.summary ?? '', - tasks: context.tasks, - }), - ]); - - return { - trelloCardIds: trello.trelloCardIds, - csvPath: csv.csvPath, - slackMessageId: slack.slackMessageId, - }; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { - trelloCardIds: output.trelloCardIds, - csvPath: output.csvPath, - slackMessageId: output.slackMessageId, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - summary: context.summary, - tasks: context.tasks, - trelloCardIds: context.trelloCardIds, - csvPath: context.csvPath, - slackMessageId: context.slackMessageId, - }), - }, - }, - }); -} - -async function main() { - try { - const notes = await prompt('Meeting notes'); - const machine = createMeetingAssistantFlowExample(); - const result = await execute(machine, machine.getInitialState({ notes })); - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/multi-agent-network.ts b/examples/multi-agent-network.ts deleted file mode 100644 index 176ed80..0000000 --- a/examples/multi-agent-network.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { - createAgentMachine, - decide, - decideResultSchema, - type DecideAdapter, -} from '../src/index.js'; -import { - closePrompt, - createOpenAiDecisionAdapter, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const researchParamsSchema = z.object({ - focus: z.string(), -}); - -const writeParamsSchema = z.object({ - angle: z.string(), -}); - -const researchNotesSchema = z.object({ - notes: z.array(z.string()).min(2).max(5), -}); - -const researchHandoffSchema = z.object({ - notes: z.array(z.string()).min(2).max(5), - handoff: z.string(), -}); - -const draftSchema = z.object({ - draft: z.string(), -}); - -const draftHandoffSchema = z.object({ - draft: z.string(), - handoff: z.string(), -}); - -export function createMultiAgentNetworkExample( - options: { - adapter?: DecideAdapter; - research?: (args: { - topic: string; - focus: string; - }) => Promise>; - write?: (args: { - topic: string; - notes: string[]; - angle: string; - }) => Promise>; - } = {} -) { - const coordinatorOptions = { - research: { - description: 'Send the task to the research specialist.', - schema: researchParamsSchema, - }, - write: { - description: 'Send the task to the writing specialist.', - schema: writeParamsSchema, - }, - finalize: { - description: 'Stop the network and return the current result.', - }, - } as const; - - const adapter = options.adapter ?? createOpenAiDecisionAdapter(); - - const research = - options.research ?? - ((args: { topic: string; focus: string }) => - generateExampleObject({ - schema: researchNotesSchema, - system: 'You are a research specialist. Return concise notes only.', - prompt: [ - `Topic: ${args.topic}`, - `Focus: ${args.focus}`, - '', - 'Return 2 to 5 concise research notes that help another specialist continue the task.', - ].join('\n'), - })); - - const write = - options.write ?? - ((args: { topic: string; notes: string[]; angle: string }) => - generateExampleObject({ - schema: draftSchema, - system: 'You are a writing specialist. Turn notes into a concise draft.', - prompt: [ - `Topic: ${args.topic}`, - `Angle: ${args.angle}`, - '', - 'Notes:', - ...args.notes.map((note) => `- ${note}`), - '', - 'Write a short specialist draft.', - ].join('\n'), - })); - - const researchAgent = createAgentMachine({ - id: 'network-research-agent', - schemas: { - input: z.object({ - topic: z.string(), - focus: z.string(), - }), - output: z.object({ - notes: z.array(z.string()), - }), - }, - context: (input) => ({ - topic: input.topic, - focus: input.focus, - notes: [] as string[], - }), - initial: 'researching', - states: { - researching: { - schemas: { output: researchNotesSchema }, - invoke: async ({ context }) => - research({ - topic: context.topic, - focus: context.focus, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { notes: output.notes }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - notes: context.notes, - }), - }, - }, - }); - - const writerAgent = createAgentMachine({ - id: 'network-writer-agent', - schemas: { - input: z.object({ - topic: z.string(), - notes: z.array(z.string()), - angle: z.string(), - }), - output: z.object({ - draft: z.string(), - }), - }, - context: (input) => ({ - topic: input.topic, - notes: input.notes, - angle: input.angle, - draft: null as string | null, - }), - initial: 'writing', - states: { - writing: { - schemas: { output: draftSchema }, - invoke: async ({ context }) => - write({ - topic: context.topic, - notes: context.notes, - angle: context.angle, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { draft: output.draft }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - draft: context.draft ?? '', - }), - }, - }, - }); - - return createAgentMachine({ - id: 'multi-agent-network-example', - schemas: { - input: z.object({ topic: z.string() }), - output: z.object({ - topic: z.string(), - notes: z.array(z.string()), - draft: z.string().nullable(), - handoffs: z.array(z.string()), - }), - }, - context: (input) => ({ - topic: input.topic, - notes: [] as string[], - draft: null as string | null, - handoffs: [] as string[], - }), - initial: 'coordinating', - states: { - coordinating: { - schemas: { output: decideResultSchema(coordinatorOptions) }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'openai/gpt-5.4-nano', - prompt: [ - 'You are a coordinator deciding which specialist should act next.', - 'Route to research when the task needs more facts.', - 'Route to writing when there are enough notes to draft.', - 'Finalize only when a usable draft already exists.', - '', - `Topic: ${context.topic}`, - context.notes.length - ? `Notes:\n${context.notes.map((note) => `- ${note}`).join('\n')}` - : 'Notes: none yet', - context.draft ? `Current draft:\n${context.draft}` : 'Current draft: none yet', - context.handoffs.length - ? `Prior handoffs:\n${context.handoffs.map((handoff, index) => `${index + 1}. ${handoff}`).join('\n')}` - : 'Prior handoffs: none', - ].join('\n'), - options: coordinatorOptions, - }), - onDone: ({ output }) => { - if (output.choice === 'research') { - return { - target: 'researching', - input: { - focus: output.data.focus ?? 'gather the most useful supporting facts', - }, - }; - } - - if (output.choice === 'write') { - return { - target: 'writing', - input: { - angle: output.data.angle ?? 'produce the clearest concise draft', - }, - }; - } - - return { - target: 'done', - }; - }, - }, - researching: { - schemas: { input: researchParamsSchema, output: researchHandoffSchema }, - invoke: async ({ context, input }) => { - const result = await execute(researchAgent, - researchAgent.getInitialState({ - topic: context.topic, - focus: input.focus, - }) - ); - - if (result.status !== 'done') { - throw new Error('Research agent did not finish'); - } - - return { - notes: result.output.notes, - handoff: `researcher:${input.focus}`, - }; - }, - onDone: ({ output, context }) => ({ - target: 'coordinating', - context: { - notes: output.notes, - handoffs: [...context.handoffs, output.handoff], - }, - }), - }, - writing: { - schemas: { input: writeParamsSchema, output: draftHandoffSchema }, - invoke: async ({ context, input }) => { - const result = await execute(writerAgent, - writerAgent.getInitialState({ - topic: context.topic, - notes: context.notes, - angle: input.angle, - }) - ); - - if (result.status !== 'done') { - throw new Error('Writer agent did not finish'); - } - - return { - draft: result.output.draft, - handoff: `writer:${input.angle}`, - }; - }, - onDone: ({ output, context }) => ({ - target: 'coordinating', - context: { - draft: output.draft, - handoffs: [...context.handoffs, output.handoff], - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - topic: context.topic, - notes: context.notes, - draft: context.draft, - handoffs: context.handoffs, - }), - }, - }, - }); -} - -async function main() { - try { - const topic = await prompt('Topic'); - const machine = createMultiAgentNetworkExample(); - const result = await execute(machine, machine.getInitialState({ topic })); - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/newspaper.ts b/examples/newspaper.ts deleted file mode 100644 index 3dd0902..0000000 --- a/examples/newspaper.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const searchSchema = z.object({ - searchResults: z.array(z.string()), -}); - -const articleSchema = z.object({ - article: z.string(), -}); - -const critiqueSchema = z.object({ - critique: z.string().nullable(), -}); - -export function createNewspaperExample( - options: { - search?: (topic: string) => Promise>; - curate?: (topic: string, searchResults: string[]) => Promise>; - write?: (topic: string, searchResults: string[]) => Promise>; - critique?: (article: string, revisionCount: number) => Promise>; - revise?: (article: string, critique: string) => Promise>; - maxRevisions?: number; - } = {} -) { - const search = - options.search ?? - ((topic: string) => - generateExampleObject({ - schema: searchSchema, - system: 'You brainstorm plausible research leads for an article topic.', - prompt: `List 3 to 5 concise research leads or search angles for an article about ${topic}.`, - })); - const curate = - options.curate ?? - ((topic: string, searchResults: string[]) => - generateExampleObject({ - schema: searchSchema, - system: 'You curate research inputs for a focused article.', - prompt: [ - `Topic: ${topic}`, - 'Choose the best 2 or 3 research leads from the list below.', - ...searchResults.map((result) => `- ${result}`), - ].join('\n'), - })); - const write = - options.write ?? - ((topic: string, searchResults: string[]) => - generateExampleObject({ - schema: articleSchema, - system: 'You write short newspaper-style drafts in Markdown.', - prompt: [ - `Topic: ${topic}`, - 'Write a short article draft using these research leads:', - ...searchResults.map((result) => `- ${result}`), - ].join('\n'), - })); - const critique = - options.critique ?? - ((article: string, revisionCount: number) => - generateExampleObject({ - schema: critiqueSchema, - system: 'You critique article drafts. Return null when no further revision is needed.', - prompt: [ - `Revision count: ${revisionCount}`, - 'Review this article draft and either return one concise critique or null if it is ready.', - '', - article, - ].join('\n'), - })); - const revise = - options.revise ?? - ((article: string, notes: string) => - generateExampleObject({ - schema: articleSchema, - system: 'You revise article drafts while preserving the main facts.', - prompt: [ - 'Revise the article to address this critique:', - notes, - '', - article, - ].join('\n'), - })); - - return createAgentMachine({ - id: 'newspaper-example', - schemas: { - input: z.object({ topic: z.string() }), - output: z.object({ - topic: z.string(), - article: z.string().nullable(), - revisionCount: z.number(), - searchResults: z.array(z.string()), - }), - }, - context: (input) => ({ - topic: input.topic, - searchResults: [] as string[], - article: null as string | null, - critique: null as string | null, - revisionCount: 0, - maxRevisions: options.maxRevisions ?? 2, - }), - initial: 'searching', - states: { - searching: { - schemas: { output: searchSchema }, - invoke: async ({ context }) => search(context.topic), - onDone: ({ output }) => ({ - target: 'curating', - context: { searchResults: output.searchResults }, - }), - }, - curating: { - schemas: { output: searchSchema }, - invoke: async ({ context }) => curate(context.topic, context.searchResults), - onDone: ({ output }) => ({ - target: 'writing', - context: { searchResults: output.searchResults }, - }), - }, - writing: { - schemas: { output: articleSchema }, - invoke: async ({ context }) => write(context.topic, context.searchResults), - onDone: ({ output }) => ({ - target: 'critiquing', - context: { article: output.article }, - }), - }, - critiquing: { - schemas: { output: critiqueSchema }, - invoke: async ({ context }) => - critique(context.article ?? '', context.revisionCount), - onDone: ({ output, context }) => ({ - target: - !output.critique || context.revisionCount >= context.maxRevisions - ? 'done' - : 'revising', - context: { critique: output.critique }, - }), - }, - revising: { - schemas: { output: articleSchema }, - invoke: async ({ context }) => - revise(context.article ?? '', context.critique ?? ''), - onDone: ({ output, context }) => ({ - target: 'critiquing', - context: { - article: output.article, - revisionCount: context.revisionCount + 1, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - topic: context.topic, - article: context.article, - revisionCount: context.revisionCount, - searchResults: context.searchResults, - }), - }, - }, - }); -} - -async function main() { - try { - const topic = await prompt('Newspaper topic'); - const machine = createNewspaperExample(); - console.log(formatResult(await execute(machine, machine.getInitialState({ topic })))); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/next-ai-sdk-ui.ts b/examples/next-ai-sdk-ui.ts deleted file mode 100644 index 8b10f3e..0000000 --- a/examples/next-ai-sdk-ui.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - convertToModelMessages, - createUIMessageStream, - createUIMessageStreamResponse, - streamText, - type UIMessage, -} from 'ai'; -import { z } from 'zod'; -import { - createAgentMachine, -} from '../src/index.js'; -import { createExampleModel } from './_run.js'; - -const uiMessagesSchema = z.object({ - messages: z.array(z.custom()), -}); - -const streamedTextSchema = z.object({ - text: z.string(), -}); - -const notificationSchema = z.object({ - message: z.string(), - level: z.enum(['info', 'warning', 'error']), -}); - -const sourceSchema = z.object({ - id: z.string(), - url: z.string().url(), - title: z.string(), -}); - -export type AgentUiMessage = UIMessage< - unknown, - { - notification: z.infer; - } ->; - -export function createNextAiSdkUiRoute(options: { - streamReply?: (args: { - messages: UIMessage[]; - onDelta: (delta: string) => void; - }) => Promise>; -} = {}) { - const streamReply = - options.streamReply ?? - (async ({ - messages, - onDelta, - }: { - messages: UIMessage[]; - onDelta: (delta: string) => void; - }) => { - const result = streamText({ - model: createExampleModel('openai/gpt-5.4-nano'), - messages: await convertToModelMessages(messages), - }); - - for await (const delta of result.textStream) { - onDelta(delta); - } - - return { - text: await result.text, - }; - }); - - const machine = createAgentMachine({ - id: 'next-ai-sdk-ui-example', - schemas: { - input: uiMessagesSchema, - output: streamedTextSchema, - emitted: { - notification: notificationSchema, - source: sourceSchema, - textPart: z.object({ - delta: z.string(), - }), - }, - events: { - begin: z.object({}), - }, - }, - context: (input) => ({ - messages: input.messages, - finalText: '', - }), - initial: 'ready', - states: { - ready: { - on: { - begin: { - target: 'drafting', - }, - }, - }, - drafting: { - schemas: { output: streamedTextSchema }, - invoke: async ({ context }, enq) => { - enq.emit({ - type: 'notification', - message: 'Drafting reply...', - level: 'info', - }); - enq.emit({ - type: 'source', - id: 'agent-docs', - url: 'https://stately.ai/docs/agents', - title: 'Stately Agent documentation', - }); - - return streamReply({ - messages: context.messages, - onDelta: (delta) => { - enq.emit({ - type: 'textPart', - delta, - }); - }, - }); - }, - onDone: ({ output }) => ({ - target: 'done', - context: { - finalText: output.text, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - text: context.finalText, - }), - }, - }, - }); - - return { - async POST(request: Request): Promise { - const { messages } = uiMessagesSchema.parse(await request.json()); - - const stream = createUIMessageStream({ - originalMessages: messages, - execute: async ({ writer }) => { - const run = await startSession(machine, { - store: createMemoryRunStore(), - input: { messages }, - }); - - const textId = 'assistant-response'; - let textStarted = false; - - const offNotification = run.on('notification', (event) => { - writer.write({ - type: 'data-notification', - data: { - message: event.message, - level: event.level, - }, - transient: true, - }); - }); - const offSource = run.on('source', (event) => { - writer.write(({ - type: 'source', - value: { - type: 'source', - sourceType: 'url', - id: event.id, - url: event.url, - title: event.title, - }, - } as unknown) as never); - }); - const offTextPart = run.on('textPart', (event) => { - if (!textStarted) { - writer.write({ - type: 'text-start', - id: textId, - }); - textStarted = true; - } - - writer.write({ - type: 'text-delta', - id: textId, - delta: event.delta, - }); - }); - - try { - await run.send({ type: 'begin' }); - } finally { - offNotification(); - offSource(); - offTextPart(); - } - - if (textStarted) { - writer.write({ - type: 'text-end', - id: textId, - }); - } - }, - }); - - return createUIMessageStreamResponse({ stream }); - }, - }; -} diff --git a/examples/next-app-router.ts b/examples/next-app-router.ts deleted file mode 100644 index 5a9e826..0000000 --- a/examples/next-app-router.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { type RunStore } from '../src/index.js'; -import type { NextRouteContext } from '../src/next/index.js'; -export { - dynamic, - maxDuration, - runtime, -} from '../src/next/index.js'; -export type { NextRouteContext } from '../src/next/index.js'; -import { - createPersistenceSessionHttpHandler, - type SessionHttpHandlerOptions, -} from './http-session.js'; -import { - createStreamingSessionHttpController, - type StreamingSessionHttpController, -} from './http-streaming-session.js'; - -export interface NextReviewRouteHandlers { - sessions: { - POST(request: Request): Promise; - }; - session: { - GET( - request: Request, - context: NextRouteContext<{ sessionId: string }> - ): Promise; - }; - events: { - POST( - request: Request, - context: NextRouteContext<{ sessionId: string }> - ): Promise; - }; -} - -export interface NextStreamingRouteHandlers { - sessions: { - POST(request: Request): Promise; - }; - session: { - GET( - request: Request, - context: NextRouteContext<{ sessionId: string }> - ): Promise; - }; - stream: { - GET( - request: Request, - context: NextRouteContext<{ sessionId: string }> - ): Promise; - }; - advance(streamId: string): void; - dropActiveSession(sessionId: string): void; -} - -export function createNextReviewRouteHandlers( - options: SessionHttpHandlerOptions = {} -): NextReviewRouteHandlers { - const handle = createPersistenceSessionHttpHandler(options); - - return { - sessions: { - POST(request) { - return handle(rewritePath(request, '/sessions')); - }, - }, - session: { - async GET(request, context) { - const { sessionId } = await context.params; - return handle(rewritePath(request, `/sessions/${sessionId}`)); - }, - }, - events: { - async POST(request, context) { - const { sessionId } = await context.params; - return handle(rewritePath(request, `/sessions/${sessionId}/events`)); - }, - }, - }; -} - -export function createNextStreamingRouteHandlers(options: { - store?: RunStore; -} = {}): NextStreamingRouteHandlers { - const controller = createStreamingSessionHttpController(options); - - return { - sessions: { - POST(request) { - return controller.handle(rewritePath(request, '/sessions')); - }, - }, - session: { - async GET(request, context) { - const { sessionId } = await context.params; - return controller.handle(rewritePath(request, `/sessions/${sessionId}`)); - }, - }, - stream: { - async GET(request, context) { - const { sessionId } = await context.params; - return controller.handle( - rewritePath(request, `/sessions/${sessionId}/stream`) - ); - }, - }, - advance(streamId) { - controller.advance(streamId); - }, - dropActiveSession(sessionId) { - controller.dropActiveSession(sessionId); - }, - }; -} - -function rewritePath(request: Request, pathname: string): Request { - const url = new URL(request.url); - url.pathname = pathname; - return new Request(url, request); -} diff --git a/examples/persistence.ts b/examples/persistence.ts deleted file mode 100644 index 78dcd3e..0000000 --- a/examples/persistence.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - createAgentMachine, -} from '../src/index.js'; -import { - closePrompt, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const summarySchema = z.object({ - summary: z.string(), -}); - -export function createPersistenceExample( - summarize: (args: { - request: string; - approved: boolean; - }) => Promise> = async (args) => - generateExampleObject({ - schema: summarySchema, - system: 'You summarize approved requests in one concise sentence.', - prompt: [ - `Request: ${args.request}`, - `Approved: ${String(args.approved)}`, - '', - 'Write a short summary.', - ].join('\n'), - }) -) { - return createAgentMachine({ - id: 'persistence-example', - schemas: { - input: z.object({ - request: z.string(), - }), - output: z.object({ - request: z.string(), - approved: z.boolean(), - summary: z.string().nullable(), - }), - events: { - approve: z.object({}), - }, - }, - context: (input) => ({ - request: input.request, - approved: false, - summary: null as string | null, - }), - initial: 'review', - states: { - review: { - on: { - approve: { - target: 'summarizing', - context: { approved: true }, - }, - }, - }, - summarizing: { - schemas: { output: summarySchema }, - invoke: async ({ context }) => - summarize({ - request: context.request, - approved: context.approved, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { summary: output.summary }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - request: context.request, - approved: context.approved, - summary: context.summary, - }), - }, - }, - }); -} - -export async function runPersistenceExample( - input: { request: string }, - options: { - summarize?: (args: { - request: string; - approved: boolean; - }) => Promise>; - } = {} -) { - const machine = createPersistenceExample(options.summarize); - const store = createMemoryRunStore(); - - const liveRun = await startSession(machine, { - store, - input, - }); - - await liveRun.send({ type: 'approve' }); - - const restoredRun = await restoreSession(machine, { - sessionId: liveRun.sessionId, - store, - }); - - return { - sessionId: liveRun.sessionId, - liveSnapshot: liveRun.getSnapshot(), - restoredSnapshot: restoredRun.getSnapshot(), - }; -} - -async function main() { - try { - const request = await prompt('Request'); - const result = await runPersistenceExample({ request }); - console.log(result); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/persistent-multi-agent-network.ts b/examples/persistent-multi-agent-network.ts deleted file mode 100644 index 8a21deb..0000000 --- a/examples/persistent-multi-agent-network.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import type { PersistedSnapshot } from '../src/index.js'; -import { createMultiAgentNetworkExample } from './multi-agent-network.js'; - -type NetworkOptions = Parameters[0]; - -export function createPersistentMultiAgentNetworkExample( - options: NetworkOptions = {} -) { - return createMultiAgentNetworkExample(options); -} - -export async function runPersistentMultiAgentNetworkExample( - input: { topic: string }, - options: NetworkOptions = {} -) { - const machine = createPersistentMultiAgentNetworkExample(options); - const baseStore = createMemoryRunStore(); - let persistedHandoffSnapshot = false; - - const store = { - append: baseStore.append, - loadEvents: baseStore.loadEvents, - loadLatestSnapshot: baseStore.loadLatestSnapshot, - async saveSnapshot( - snapshot: PersistedSnapshot - ) { - const handoffs = - ((snapshot.snapshot.context as { handoffs?: string[] }).handoffs ?? []); - - if (!persistedHandoffSnapshot && handoffs.length === 1) { - persistedHandoffSnapshot = true; - await baseStore.saveSnapshot(snapshot); - return; - } - - if (!persistedHandoffSnapshot && handoffs.length === 0) { - await baseStore.saveSnapshot(snapshot); - } - }, - }; - - const liveRun = await startSession(machine, { - store, - input, - }); - - await waitForTerminal(() => liveRun.getSnapshot().status); - - const restoredRun = await restoreSession(machine, { - sessionId: liveRun.sessionId, - store, - }); - - await waitForMatch( - () => restoredRun.getSnapshot(), - () => liveRun.getSnapshot() - ); - - return { - sessionId: liveRun.sessionId, - liveSnapshot: liveRun.getSnapshot(), - restoredSnapshot: restoredRun.getSnapshot(), - }; -} - -function expectTerminal(status: string) { - if (status !== 'done' && status !== 'error') { - throw new Error(`Snapshot is not terminal yet: ${status}`); - } -} - -async function waitForTerminal( - getStatus: () => string, - timeoutMs = 1000 -) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - expectTerminal(getStatus()); - return; - } catch {} - - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - expectTerminal(getStatus()); -} - -async function waitForMatch( - getActual: () => T, - getExpected: () => T, - timeoutMs = 1000 -) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (JSON.stringify(getActual()) === JSON.stringify(getExpected())) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - if (JSON.stringify(getActual()) !== JSON.stringify(getExpected())) { - throw new Error('Snapshots did not converge before timeout.'); - } -} diff --git a/examples/persistent-streaming.ts b/examples/persistent-streaming.ts deleted file mode 100644 index d36b441..0000000 --- a/examples/persistent-streaming.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { z } from 'zod'; -import { - createAgentMachine, -} from '../src/index.js'; - -const textSchema = z.object({ - text: z.string(), -}); - -const textPartSchema = z.object({ - delta: z.string(), -}); - -export function createPersistentStreamingExample( - writeText: (emitPart: (delta: string) => void) => Promise> = (() => { - const chunks = ['hel', 'lo']; - let cursor = 0; - let attempts = 0; - - return async (emitPart) => { - attempts += 1; - - if (attempts === 1) { - emitPart(chunks[cursor++]!); - await new Promise(() => {}); - } - - while (cursor < chunks.length) { - emitPart(chunks[cursor++]!); - } - - return { text: chunks.join('') }; - }; - })() -) { - return createAgentMachine({ - id: 'persistent-streaming-example', - schemas: { - output: textSchema, - emitted: { - textPart: textPartSchema, - }, - }, - context: () => ({ - finalText: '', - }), - initial: 'writing', - states: { - writing: { - schemas: { output: textSchema }, - invoke: async (_args, enq) => - writeText((delta) => { - enq.emit({ type: 'textPart', delta }); - }), - onDone: ({ output }) => ({ - target: 'done', - context: { finalText: output.text }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ text: context.finalText }), - }, - }, - }); -} - -export async function runPersistentStreamingExample( - writeText?: (emitPart: (delta: string) => void) => Promise> -) { - const machine = createPersistentStreamingExample(writeText); - const store = createMemoryRunStore(); - const initialRun = await startSession(machine, { store }); - const initialParts: string[] = []; - - initialRun.on('textPart', (event) => { - initialParts.push(event.delta); - }); - - await waitFor( - () => initialParts.length >= 1 && initialRun.getSnapshot().status === 'active' - ); - - const restoredRun = await restoreSession(machine, { - sessionId: initialRun.sessionId, - store, - }); - const restoredParts: string[] = []; - - restoredRun.on('textPart', (event) => { - restoredParts.push(event.delta); - }); - - await once(restoredRun.onDone.bind(restoredRun)); - - return { - sessionId: initialRun.sessionId, - initialParts, - restoredParts, - initialSnapshot: initialRun.getSnapshot(), - restoredSnapshot: restoredRun.getSnapshot(), - journal: await store.loadEvents(initialRun.sessionId), - }; -} - -function once( - subscribe: (handler: (event: T) => void) => () => void -) { - return new Promise((resolve) => { - let off = () => {}; - off = subscribe((event) => { - off(); - resolve(event); - }); - }); -} - -async function waitFor( - predicate: () => boolean, - timeoutMs = 1000 -) { - const start = Date.now(); - - while (Date.now() - start < timeoutMs) { - if (predicate()) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - if (!predicate()) { - throw new Error('Condition did not become true before timeout.'); - } -} diff --git a/examples/persistent-supervisor.ts b/examples/persistent-supervisor.ts deleted file mode 100644 index 812f1c1..0000000 --- a/examples/persistent-supervisor.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import type { PersistedSnapshot } from '../src/index.js'; -import { createSupervisorExample } from './supervisor.js'; - -type SupervisorOptions = Parameters[0]; - -export function createPersistentSupervisorExample( - options: SupervisorOptions = {} -) { - return createSupervisorExample(options); -} - -export async function runPersistentSupervisorExample( - input: { request: string }, - options: SupervisorOptions = {} -) { - const machine = createPersistentSupervisorExample(options); - const baseStore = createMemoryRunStore(); - let persistedRetryHandoff = false; - - const store = { - append: baseStore.append, - loadEvents: baseStore.loadEvents, - loadLatestSnapshot: baseStore.loadLatestSnapshot, - async saveSnapshot(snapshot: PersistedSnapshot) { - const context = snapshot.snapshot.context as { - attemptCount?: number; - history?: string[]; - }; - const history = context.history ?? []; - - if ( - !persistedRetryHandoff - && snapshot.snapshot.value === 'handling' - && context.attemptCount === 1 - && history.some((entry) => entry.startsWith('supervisor:retry:')) - ) { - persistedRetryHandoff = true; - await baseStore.saveSnapshot(snapshot); - return; - } - - if (!persistedRetryHandoff) { - await baseStore.saveSnapshot(snapshot); - } - }, - }; - - const liveRun = await startSession(machine, { - store, - input, - }); - - await waitForTerminal(() => liveRun.getSnapshot().status); - - const restoredRun = await restoreSession(machine, { - sessionId: liveRun.sessionId, - store, - }); - - await waitForMatch( - () => restoredRun.getSnapshot(), - () => liveRun.getSnapshot() - ); - - return { - sessionId: liveRun.sessionId, - liveSnapshot: liveRun.getSnapshot(), - restoredSnapshot: restoredRun.getSnapshot(), - }; -} - -function expectTerminal(status: string) { - if (status !== 'done' && status !== 'error') { - throw new Error(`Snapshot is not terminal yet: ${status}`); - } -} - -async function waitForTerminal( - getStatus: () => string, - timeoutMs = 1000 -) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - expectTerminal(getStatus()); - return; - } catch {} - - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - expectTerminal(getStatus()); -} - -async function waitForMatch( - getActual: () => T, - getExpected: () => T, - timeoutMs = 1000 -) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (JSON.stringify(getActual()) === JSON.stringify(getExpected())) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - if (JSON.stringify(getActual()) !== JSON.stringify(getExpected())) { - throw new Error('Snapshots did not converge before timeout.'); - } -} diff --git a/examples/plan-and-execute.ts b/examples/plan-and-execute.ts deleted file mode 100644 index 6ce1249..0000000 --- a/examples/plan-and-execute.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const planSchema = z.object({ - plan: z.array(z.string()).min(1).max(5), -}); - -const stepResultSchema = z.object({ - result: z.string(), -}); - -const finalAnswerSchema = z.object({ - answer: z.string(), -}); - -export function createPlanAndExecuteExample( - options: { - plan?: (goal: string) => Promise>; - executeStep?: (args: { - goal: string; - step: string; - priorResults: string[]; - }) => Promise>; - synthesize?: (args: { - goal: string; - plan: string[]; - stepResults: string[]; - }) => Promise>; - } = {} -) { - const planner = - options.plan ?? - ((goal: string) => - generateExampleObject({ - schema: planSchema, - system: 'You are a planner. Break goals into a short actionable sequence.', - prompt: `Create a short plan with 2 to 5 steps for this goal:\n\n${goal}`, - })); - const executeStep = - options.executeStep ?? - ((args: { goal: string; step: string; priorResults: string[] }) => - generateExampleObject({ - schema: stepResultSchema, - system: 'You execute one plan step at a time and report the result concisely.', - prompt: [ - `Goal: ${args.goal}`, - `Current step: ${args.step}`, - args.priorResults.length - ? `Prior results:\n${args.priorResults.map((result, index) => `${index + 1}. ${result}`).join('\n')}` - : 'Prior results: none', - '', - 'Execute the current step conceptually and return a concise result.', - ].join('\n'), - })); - const synthesize = - options.synthesize ?? - ((args: { goal: string; plan: string[]; stepResults: string[] }) => - generateExampleObject({ - schema: finalAnswerSchema, - system: 'You synthesize completed plan results into a final answer.', - prompt: [ - `Goal: ${args.goal}`, - '', - 'Plan:', - ...args.plan.map((step, index) => `${index + 1}. ${step}`), - '', - 'Step results:', - ...args.stepResults.map((result, index) => `${index + 1}. ${result}`), - '', - 'Write the final answer.', - ].join('\n'), - })); - - return createAgentMachine({ - id: 'plan-and-execute-example', - schemas: { - input: z.object({ goal: z.string() }), - output: z.object({ - goal: z.string(), - plan: z.array(z.string()), - stepResults: z.array(z.string()), - answer: z.string().nullable(), - }), - }, - context: (input) => ({ - goal: input.goal, - plan: [] as string[], - stepResults: [] as string[], - answer: null as string | null, - }), - initial: 'planning', - states: { - planning: { - schemas: { output: planSchema }, - invoke: async ({ context }) => planner(context.goal), - onDone: ({ output }) => ({ - target: 'executing', - context: { plan: output.plan }, - input: { index: 0 } - }), - }, - executing: { - schemas: { input: z.object({ - index: z.number().int().min(0), - }), output: stepResultSchema }, - invoke: async ({ context, input }) => - executeStep({ - goal: context.goal, - step: context.plan[input.index] ?? '', - priorResults: context.stepResults, - }), - onDone: ({ output, context }) => { - const nextStepResults = [...context.stepResults, output.result]; - const nextIndex = nextStepResults.length; - - if (nextIndex < context.plan.length) { - return { - target: 'executing' as const, - context: { stepResults: nextStepResults }, - input: { index: nextIndex }, - }; - } - - return { - target: 'synthesizing' as const, - context: { stepResults: nextStepResults }, - }; - }, - }, - synthesizing: { - schemas: { output: finalAnswerSchema }, - invoke: async ({ context }) => - synthesize({ - goal: context.goal, - plan: context.plan, - stepResults: context.stepResults, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { answer: output.answer }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - goal: context.goal, - plan: context.plan, - stepResults: context.stepResults, - answer: context.answer, - }), - }, - }, - }); -} - -async function main() { - try { - const goal = await prompt('Goal'); - const machine = createPlanAndExecuteExample(); - console.log(formatResult(await execute(machine, machine.getInitialState({ goal })))); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/raffle.ts b/examples/raffle.ts deleted file mode 100644 index 0672290..0000000 --- a/examples/raffle.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - createAgentMachine, -} from '../src/index.js'; -import { - closePrompt, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const winnerSchema = z.object({ - winningEntry: z.string(), - firstRunnerUp: z.string(), - secondRunnerUp: z.string(), - explanation: z.string(), -}); - -export function createRaffleExample( - pickWinner: (entries: string[]) => Promise> = async ( - entries - ) => - generateExampleObject({ - schema: winnerSchema, - system: 'You are conducting a transparent demo raffle draw.', - prompt: [ - 'Choose one winner and two runners-up from the entries below.', - 'Do not invent names. Explain your selection briefly.', - ...entries.map((entry, index) => `${index + 1}. ${entry}`), - ].join('\n'), - }) -) { - return createAgentMachine({ - id: 'raffle-example', - schemas: { - output: z.object({ - entries: z.array(z.string()), - winner: z.string().nullable(), - firstRunnerUp: z.string().nullable(), - secondRunnerUp: z.string().nullable(), - explanation: z.string().nullable(), - }), - events: { - 'user.entry': z.object({ entry: z.string() }), - 'user.draw': z.object({}), - }, - }, - context: () => ({ - entries: [] as string[], - winner: null as string | null, - firstRunnerUp: null as string | null, - secondRunnerUp: null as string | null, - explanation: null as string | null, - }), - initial: 'collecting', - states: { - collecting: { - on: { - 'user.entry': ({ event, context }) => ({ - context: { entries: [...context.entries, event.entry] }, - }), - 'user.draw': ({ context }) => ({ - target: context.entries.length >= 3 ? 'drawing' : 'collecting', - }), - }, - }, - drawing: { - schemas: { output: winnerSchema }, - invoke: async ({ context }) => pickWinner(context.entries), - onDone: ({ output }) => ({ - target: 'done', - context: { - winner: output.winningEntry, - firstRunnerUp: output.firstRunnerUp, - secondRunnerUp: output.secondRunnerUp, - explanation: output.explanation, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - entries: context.entries, - winner: context.winner, - firstRunnerUp: context.firstRunnerUp, - secondRunnerUp: context.secondRunnerUp, - explanation: context.explanation, - }), - }, - }, - }); -} - -async function main() { - try { - const machine = createRaffleExample(); - const run = await startSession(machine, { - store: createMemoryRunStore(), - }); - - while (true) { - const snapshot = await waitForRunSnapshot( - run, - (nextSnapshot) => nextSnapshot.status !== 'active' - ); - - if (snapshot.status === 'done') { - console.log({ - status: snapshot.status, - value: snapshot.value, - context: snapshot.context, - output: snapshot.output, - }); - break; - } - - const entry = await prompt('Entry (blank to draw)'); - await run.send( - entry ? { type: 'user.entry', entry } : { type: 'user.draw' } - ); - } - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/rag.ts b/examples/rag.ts deleted file mode 100644 index 91709f3..0000000 --- a/examples/rag.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine, type AgentAdapter } from '../src/index.js'; -import { - closePrompt, - createOpenAiGenerationAdapter, - isMain, - prompt, -} from './_run.js'; - -const retrievedDocumentSchema = z.object({ - id: z.string(), - content: z.string(), -}); - -const retrievedDocumentsSchema = z.object({ - documents: z.array(retrievedDocumentSchema), -}); - -const answerSchema = z.object({ - answer: z.string(), -}); - -export function createRagExample( - options: { - adapter?: AgentAdapter; - retrieve?: (question: string) => Promise>; - } = {} -) { - const retrieve = - options.retrieve ?? - ((question: string) => - Promise.resolve({ - documents: [ - { - id: 'doc-1', - content: `Context about: ${question}`, - }, - { - id: 'doc-2', - content: `Additional supporting detail for: ${question}`, - }, - ], - })); - - return createAgentMachine({ - id: 'rag-example', - adapter: options.adapter ?? createOpenAiGenerationAdapter(), - schemas: { - input: z.object({ - question: z.string(), - }), - output: z.object({ - question: z.string(), - documents: z.array(retrievedDocumentSchema), - answer: z.string().nullable(), - }), - }, - context: (input) => ({ - question: input.question, - documents: [] as Array>, - answer: null as string | null, - }), - initial: 'retrieving', - states: { - retrieving: { - schemas: { output: retrievedDocumentsSchema }, - invoke: async ({ context }) => retrieve(context.question), - onDone: ({ output }) => ({ - target: 'answering', - context: { documents: output.documents }, - }), - }, - answering: { - schemas: { output: answerSchema }, - system: 'Answer the question using only the retrieved documents.', - prompt: ({ context }) => - [ - `Question: ${context.question}`, - '', - 'Documents:', - ...context.documents.map((document) => `- [${document.id}] ${document.content}`), - ].join('\n'), - onDone: ({ output }) => ({ - target: 'done', - context: { answer: output.answer }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - question: context.question, - documents: context.documents, - answer: context.answer, - }), - }, - }, - }); -} - -async function main() { - try { - const question = await prompt('Question'); - const machine = createRagExample(); - const result = await execute(machine, - machine.getInitialState({ question }) - ); - - if (result.status === 'done') { - console.log(result.output); - } - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/react-agent-from-scratch.ts b/examples/react-agent-from-scratch.ts deleted file mode 100644 index 0592005..0000000 --- a/examples/react-agent-from-scratch.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine, type StandardSchemaV1 } from '../src/index.js'; - -const messageSchema = z.object({ - role: z.enum(['system', 'user', 'assistant', 'tool']), - content: z.string(), - name: z.string().optional(), -}); - -const toolCallSchema = z.object({ - kind: z.literal('tool'), - toolName: z.string(), - input: z.record(z.string(), z.unknown()), - message: z.string().optional(), -}); - -const finalAnswerSchema = z.object({ - kind: z.literal('final'), - message: z.string(), -}); - -const modelResultSchema = z.discriminatedUnion('kind', [ - toolCallSchema, - finalAnswerSchema, -]); - -const reactOutputSchema = z.object({ - messages: z.array(messageSchema), - finalMessage: z.string().nullable(), - steps: z.number().int().min(0), -}); - -export type ReactAgentMessage = z.infer; - -export type ReactTool = { - name: string; - description: string; - schema?: StandardSchemaV1; - execute: (input: Record) => Promise; -}; - -export type ReactAgentModelResult = z.infer; - -export function createReactAgentFromScratch(options: { - prompt?: string; - maxSteps?: number; - tools?: ReactTool[]; - model: (args: { - messages: ReactAgentMessage[]; - tools: Array<{ - name: string; - description: string; - schema?: StandardSchemaV1; - }>; - }) => Promise; -}) { - const tools = options.tools ?? []; - const maxSteps = options.maxSteps ?? 8; - const toolDefinitions = tools.map(({ name, description, schema }) => ({ - name, - description, - schema, - })); - const toolsByName = new Map(tools.map((tool) => [tool.name, tool])); - - function serializeToolOutput(output: unknown): string { - return typeof output === 'string' ? output : JSON.stringify(output); - } - - return createAgentMachine({ - id: 'react-agent-from-scratch', - schemas: { - input: z.object({ - messages: z.array(messageSchema).optional(), - }), - output: reactOutputSchema, - emitted: { - textPart: z.object({ delta: z.string() }), - toolCall: z.object({ - toolName: z.string(), - input: z.record(z.string(), z.unknown()), - }), - toolResult: z.object({ - toolName: z.string(), - output: z.unknown(), - }), - }, - }, - context: (input) => ({ - messages: [ - ...(options.prompt - ? ([{ role: 'system', content: options.prompt }] satisfies ReactAgentMessage[]) - : []), - ...(input.messages ?? []), - ], - stepCount: 0, - pendingToolCall: - null as { toolName: string; input: Record } | null, - }), - initial: 'agent', - states: { - agent: { - schemas: { output: modelResultSchema }, - invoke: async ({ context }, enq) => { - if (context.stepCount >= maxSteps) { - return { - kind: 'final' as const, - message: 'Stopped because the maximum step count was reached.', - }; - } - - const result = await options.model({ - messages: context.messages, - tools: toolDefinitions, - }); - - if (result.kind === 'final') { - enq.emit({ type: 'textPart', delta: result.message }); - } - - return result; - }, - onDone: ({ output, context }) => { - if (output.kind === 'final') { - return { - target: 'done' as const, - context: { - stepCount: context.stepCount + 1, - messages: [ - ...context.messages, - { - role: 'assistant', - content: output.message, - } satisfies ReactAgentMessage, - ], - }, - }; - } - - return { - target: 'tool' as const, - context: { - stepCount: context.stepCount + 1, - pendingToolCall: { - toolName: output.toolName, - input: output.input, - }, - messages: [ - ...context.messages, - { - role: 'assistant', - content: - output.message - ?? `Calling tool ${output.toolName} with ${JSON.stringify(output.input)}`, - } satisfies ReactAgentMessage, - ], - }, - input: { - toolName: output.toolName, - input: output.input, - }, - }; - }, - }, - tool: { - schemas: { input: z.object({ - toolName: z.string(), - input: z.record(z.string(), z.unknown()), - }), output: z.object({ - toolName: z.string(), - output: z.unknown(), - }) }, - invoke: async ({ input }, enq) => { - const tool = toolsByName.get(input.toolName); - - if (!tool) { - throw new Error(`Tool '${input.toolName}' not found`); - } - - enq.emit({ - type: 'toolCall', - toolName: input.toolName, - input: input.input, - }); - - const output = await tool.execute(input.input); - - enq.emit({ - type: 'toolResult', - toolName: input.toolName, - output, - }); - - return { - toolName: input.toolName, - output, - }; - }, - onDone: ({ output, context }) => ({ - target: 'agent' as const, - context: { - pendingToolCall: null, - messages: [ - ...context.messages, - { - role: 'tool', - name: output.toolName, - content: serializeToolOutput(output.output), - } satisfies ReactAgentMessage, - ], - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - messages: context.messages, - finalMessage: context.messages.at(-1)?.content ?? null, - steps: context.stepCount, - }), - }, - }, - }); -} diff --git a/examples/react-agent.ts b/examples/react-agent.ts deleted file mode 100644 index e13e88f..0000000 --- a/examples/react-agent.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { createReactAgentFromScratch } from './react-agent-from-scratch.js'; -import { - closePrompt, - generateExampleObject, - generateExampleText, - isMain, - prompt, -} from './_run.js'; - -const reactModelResultSchema = z.discriminatedUnion('kind', [ - z.object({ - kind: z.literal('tool'), - toolName: z.literal('search'), - input: z.object({ - query: z.string(), - }), - message: z.string().optional(), - }), - z.object({ - kind: z.literal('final'), - message: z.string(), - }), -]); - -export function createReactAgentExample(options: { - search?: (query: string) => Promise; - model?: (args: { - messages: Array<{ - role: 'system' | 'user' | 'assistant' | 'tool'; - content: string; - name?: string; - }>; - }) => Promise>; -} = {}) { - return createReactAgentFromScratch({ - prompt: 'You are a helpful assistant.', - tools: [ - { - name: 'search', - description: 'Searches the knowledge base.', - execute: async (input) => - (options.search - ?? ((query) => - generateExampleText({ - system: 'You are a concise search backend returning a short factual result snippet.', - prompt: `Return a short search result snippet for the query: ${query}`, - })))(String(input.query)), - }, - ], - model: - options.model - ?? (({ messages }) => - generateExampleObject({ - schema: reactModelResultSchema, - system: [ - 'You are a ReAct-style assistant.', - 'If you still need outside information, call the search tool.', - 'If the latest tool result is enough, answer directly with kind="final".', - ].join('\n'), - prompt: messages - .map((message) => `${message.role.toUpperCase()}: ${message.content}`) - .join('\n'), - })), - }); -} - -async function main() { - try { - const message = await prompt('User'); - const agent = createReactAgentExample(); - const run = await startSession(agent, { - store: createMemoryRunStore(), - input: { - messages: [{ role: 'user', content: message }], - }, - }); - - run.on('toolCall', (event) => { - console.log(`Calling ${event.toolName}(${event.input.query})`); - }); - run.on('toolResult', (event) => { - console.log(`${event.toolName} -> ${String(event.output)}`); - }); - - const done = await waitForRunDone(run); - console.log(done.output); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/reflection.ts b/examples/reflection.ts deleted file mode 100644 index d5632a5..0000000 --- a/examples/reflection.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const draftSchema = z.object({ - draft: z.string(), -}); - -const feedbackSchema = z.object({ - feedback: z.string().nullable(), -}); - -export function createReflectionExample( - options: { - draft?: (task: string) => Promise>; - reflect?: (args: { - task: string; - draft: string; - revisionCount: number; - }) => Promise>; - revise?: (args: { - task: string; - draft: string; - feedback: string; - }) => Promise>; - maxRevisions?: number; - } = {} -) { - const draft = - options.draft ?? - ((task: string) => - generateExampleObject({ - schema: draftSchema, - system: 'You write concise first drafts.', - prompt: `Write a short draft for this task:\n\n${task}`, - })); - const reflect = - options.reflect ?? - ((args: { task: string; draft: string; revisionCount: number }) => - generateExampleObject({ - schema: feedbackSchema, - system: 'You critique drafts and return null when no more revision is needed.', - prompt: [ - `Task: ${args.task}`, - `Revision count: ${args.revisionCount}`, - '', - 'Draft:', - args.draft, - '', - 'Return one concise revision note, or null if the draft is already good enough.', - ].join('\n'), - })); - const revise = - options.revise ?? - ((args: { task: string; draft: string; feedback: string }) => - generateExampleObject({ - schema: draftSchema, - system: 'You revise drafts to address the provided feedback.', - prompt: [ - `Task: ${args.task}`, - `Feedback: ${args.feedback}`, - '', - 'Current draft:', - args.draft, - '', - 'Revise the draft.', - ].join('\n'), - })); - - return createAgentMachine({ - id: 'reflection-example', - schemas: { - input: z.object({ task: z.string() }), - output: z.object({ - task: z.string(), - draft: z.string().nullable(), - feedback: z.string().nullable(), - revisionCount: z.number(), - }), - }, - context: (input) => ({ - task: input.task, - draft: null as string | null, - feedback: null as string | null, - revisionCount: 0, - maxRevisions: options.maxRevisions ?? 2, - }), - initial: 'drafting', - states: { - drafting: { - schemas: { output: draftSchema }, - invoke: async ({ context }) => draft(context.task), - onDone: ({ output }) => ({ - target: 'reflecting', - context: { draft: output.draft }, - }), - }, - reflecting: { - schemas: { output: feedbackSchema }, - invoke: async ({ context }) => - reflect({ - task: context.task, - draft: context.draft ?? '', - revisionCount: context.revisionCount, - }), - onDone: ({ output, context }) => ({ - target: - !output.feedback || context.revisionCount >= context.maxRevisions - ? 'done' - : 'revising', - context: { feedback: output.feedback }, - }), - }, - revising: { - schemas: { output: draftSchema }, - invoke: async ({ context }) => - revise({ - task: context.task, - draft: context.draft ?? '', - feedback: context.feedback ?? '', - }), - onDone: ({ output, context }) => ({ - target: 'reflecting', - context: { - draft: output.draft, - revisionCount: context.revisionCount + 1, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - task: context.task, - draft: context.draft, - feedback: context.feedback, - revisionCount: context.revisionCount, - }), - }, - }, - }); -} - -async function main() { - try { - const task = await prompt('Task'); - const machine = createReflectionExample(); - console.log(formatResult(await execute(machine, machine.getInitialState({ task })))); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/rewoo.ts b/examples/rewoo.ts deleted file mode 100644 index 18134f1..0000000 --- a/examples/rewoo.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const rewooPlanSchema = z.object({ - steps: z - .array( - z.object({ - id: z.string().regex(/^E\d+$/), - instruction: z.string(), - input: z.string(), - }) - ) - .min(1) - .max(5), -}); - -const rewooStepResultSchema = z.object({ - result: z.string(), -}); - -const rewooAnswerSchema = z.object({ - answer: z.string(), -}); - -type RewooPlan = z.infer; -type RewooStep = RewooPlan['steps'][number]; - -function resolveStepInput( - template: string, - resultsById: Record -): string { - return template.replace(/#(E\d+)/g, (_match, id: string) => resultsById[id] ?? ''); -} - -export function createRewooExample( - options: { - plan?: (objective: string) => Promise; - executeStep?: (args: { - objective: string; - step: RewooStep; - resolvedInput: string; - resultsById: Record; - }) => Promise>; - solve?: (args: { - objective: string; - steps: RewooPlan['steps']; - resultsById: Record; - }) => Promise>; - } = {} -) { - const plan = - options.plan ?? - ((objective: string) => - generateExampleObject({ - schema: rewooPlanSchema, - system: [ - 'You are a ReWOO-style planner.', - 'Produce a short sequence of executable steps.', - 'Each step must have an id like E1, E2, E3.', - 'Later step inputs may reference earlier outputs using #E1, #E2, etc.', - ].join('\n'), - prompt: `Create a compact executable plan for this objective:\n\n${objective}`, - })); - - const executeStep = - options.executeStep ?? - ((args: { - objective: string; - step: RewooStep; - resolvedInput: string; - resultsById: Record; - }) => - generateExampleObject({ - schema: rewooStepResultSchema, - system: 'You execute one specialist step at a time and return a concise result.', - prompt: [ - `Objective: ${args.objective}`, - `Step id: ${args.step.id}`, - `Instruction: ${args.step.instruction}`, - `Resolved input: ${args.resolvedInput}`, - Object.keys(args.resultsById).length - ? `Prior results:\n${Object.entries(args.resultsById) - .map(([id, value]) => `${id}: ${value}`) - .join('\n')}` - : 'Prior results: none', - ].join('\n'), - })); - - const solve = - options.solve ?? - ((args: { - objective: string; - steps: RewooPlan['steps']; - resultsById: Record; - }) => - generateExampleObject({ - schema: rewooAnswerSchema, - system: 'You synthesize completed step results into a direct final answer.', - prompt: [ - `Objective: ${args.objective}`, - '', - 'Completed steps:', - ...args.steps.map((step) => `${step.id}. ${step.instruction}`), - '', - 'Results:', - ...Object.entries(args.resultsById).map(([id, value]) => `${id}: ${value}`), - '', - 'Write the final answer.', - ].join('\n'), - })); - - return createAgentMachine({ - id: 'rewoo-example', - schemas: { - input: z.object({ objective: z.string() }), - output: z.object({ - objective: z.string(), - steps: rewooPlanSchema.shape.steps, - resultsById: z.record(z.string(), z.string()), - answer: z.string().nullable(), - }), - }, - context: (input) => ({ - objective: input.objective, - steps: [] as RewooPlan['steps'], - resultsById: {} as Record, - answer: null as string | null, - }), - initial: 'planning', - states: { - planning: { - schemas: { output: rewooPlanSchema }, - invoke: async ({ context }) => plan(context.objective), - onDone: ({ output }) => ({ - target: 'executing', - context: { steps: output.steps }, - input: { index: 0 }, - }), - }, - executing: { - schemas: { input: z.object({ - index: z.number().int().min(0), - }), output: z.object({ - stepId: z.string(), - result: z.string(), - }) }, - invoke: async ({ context, input }) => { - const step = context.steps[input.index]; - - if (!step) { - throw new Error(`Missing step at index ${input.index}`); - } - - const resolvedInput = resolveStepInput(step.input, context.resultsById); - const outcome = await executeStep({ - objective: context.objective, - step, - resolvedInput, - resultsById: context.resultsById, - }); - - return { - stepId: step.id, - result: outcome.result, - }; - }, - onDone: ({ output, context }) => { - const nextResultsById = { - ...context.resultsById, - [output.stepId]: output.result, - }; - const nextIndex = Object.keys(nextResultsById).length; - - if (nextIndex < context.steps.length) { - return { - target: 'executing', - context: { resultsById: nextResultsById }, - input: { index: nextIndex }, - }; - } - - return { - target: 'solving', - context: { resultsById: nextResultsById }, - }; - }, - }, - solving: { - schemas: { output: rewooAnswerSchema }, - invoke: async ({ context }) => - solve({ - objective: context.objective, - steps: context.steps, - resultsById: context.resultsById, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { answer: output.answer }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - objective: context.objective, - steps: context.steps, - resultsById: context.resultsById, - answer: context.answer, - }), - }, - }, - }); -} - -async function main() { - try { - const objective = await prompt('Objective'); - const machine = createRewooExample(); - const result = await execute(machine, machine.getInitialState({ objective })); - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/river-crossing.ts b/examples/river-crossing.ts deleted file mode 100644 index 54fe0ab..0000000 --- a/examples/river-crossing.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { formatResult, isMain } from './_run.js'; - -const bankItem = z.enum(['wolf', 'goat', 'cabbage']); - -const crossingMoveSchema = z.object({ - move: z.enum(['takeGoat', 'takeWolf', 'takeCabbage', 'returnEmpty', 'done']), - reasoning: z.string(), -}); - -const crossingStateSchema = z.object({ - leftBank: z.array(bankItem), - rightBank: z.array(bankItem), - farmerPosition: z.enum(['left', 'right']), - step: z.string(), -}); - -function chooseCrossingMove( - leftBank: string[], - rightBank: string[], - farmerPosition: 'left' | 'right' -): z.infer { - const key = `${farmerPosition}|${leftBank.sort().join(',')}|${rightBank.sort().join(',')}`; - const plan: Record> = { - 'left|cabbage,goat,wolf|': { - move: 'takeGoat', - reasoning: 'Move the goat first so it is not left with the cabbage.', - }, - 'right|cabbage,wolf|goat': { - move: 'returnEmpty', - reasoning: 'Return alone to ferry another item.', - }, - 'left|cabbage,wolf|goat': { - move: 'takeWolf', - reasoning: 'Take the wolf across while the goat waits safely alone.', - }, - 'right|cabbage|goat,wolf': { - move: 'takeGoat', - reasoning: 'Bring the goat back so the wolf is not left with it.', - }, - 'left|cabbage,goat|wolf': { - move: 'takeCabbage', - reasoning: 'Take the cabbage across now that the goat is with you.', - }, - 'right|goat|cabbage,wolf': { - move: 'returnEmpty', - reasoning: 'Return alone to fetch the goat.', - }, - 'left|goat|cabbage,wolf': { - move: 'takeGoat', - reasoning: 'Bring the goat across to complete the crossing.', - }, - 'right||cabbage,goat,wolf': { - move: 'done', - reasoning: 'Everyone is safely across.', - }, - }; - - return plan[key] ?? { move: 'done', reasoning: 'No further move required.' }; -} - -function moveItem( - leftBank: Array<'wolf' | 'goat' | 'cabbage'>, - rightBank: Array<'wolf' | 'goat' | 'cabbage'>, - farmerPosition: 'left' | 'right', - move: z.infer['move'] -): z.infer { - const fromLeft = farmerPosition === 'left'; - - if (move === 'returnEmpty') { - return { - leftBank, - rightBank, - farmerPosition: fromLeft ? 'right' : 'left', - step: 'The farmer crossed the river alone.', - }; - } - - const item = move.replace(/^take/, '').toLowerCase() as 'wolf' | 'goat' | 'cabbage'; - return { - leftBank: fromLeft - ? leftBank.filter((value) => value !== item) - : [...leftBank, item].sort() as Array<'wolf' | 'goat' | 'cabbage'>, - rightBank: fromLeft - ? [...rightBank, item].sort() as Array<'wolf' | 'goat' | 'cabbage'> - : rightBank.filter((value) => value !== item), - farmerPosition: fromLeft ? 'right' : 'left', - step: `The farmer took the ${item} across the river.`, - }; -} - -export function createRiverCrossingExample() { - return createAgentMachine({ - id: 'river-crossing-example', - schemas: { - output: z.object({ - leftBank: z.array(bankItem), - rightBank: z.array(bankItem), - steps: z.array(z.string()), - reasoning: z.array(z.string()), - }), - }, - context: () => ({ - leftBank: ['wolf', 'goat', 'cabbage'] as Array<'wolf' | 'goat' | 'cabbage'>, - rightBank: [] as Array<'wolf' | 'goat' | 'cabbage'>, - farmerPosition: 'left' as 'left' | 'right', - steps: [] as string[], - reasoning: [] as string[], - }), - initial: 'choosing', - states: { - choosing: { - schemas: { output: crossingMoveSchema }, - invoke: async ({ context }) => - chooseCrossingMove( - [...context.leftBank], - [...context.rightBank], - context.farmerPosition - ), - onDone: ({ output, context }) => { - const nextReasoning = [...context.reasoning, output.reasoning]; - - if (output.move === 'done') { - return { - target: 'done' as const, - context: { reasoning: nextReasoning }, - }; - } - - return { - target: 'moving' as const, - input: { move: output.move }, - context: { reasoning: nextReasoning }, - }; - }, - }, - moving: { - schemas: { input: z.object({ - move: crossingMoveSchema.shape.move.exclude(['done']), - }), output: crossingStateSchema }, - invoke: async ({ context, input }) => - moveItem( - [...context.leftBank], - [...context.rightBank], - context.farmerPosition, - input.move as 'takeGoat' | 'takeWolf' | 'takeCabbage' | 'returnEmpty' - ), - onDone: ({ output, context }) => ({ - target: 'choosing', - context: { - leftBank: output.leftBank, - rightBank: output.rightBank, - farmerPosition: output.farmerPosition, - steps: [...context.steps, output.step], - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - leftBank: context.leftBank, - rightBank: context.rightBank, - steps: context.steps, - reasoning: context.reasoning, - }), - }, - }, - }); -} - -async function main() { - const machine = createRiverCrossingExample(); - console.log(formatResult(await execute(machine, machine.getInitialState()))); -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/self-evaluation-loop-flow.ts b/examples/self-evaluation-loop-flow.ts deleted file mode 100644 index 1cb316f..0000000 --- a/examples/self-evaluation-loop-flow.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const postSchema = z.object({ - post: z.string(), -}); - -const evaluationSchema = z.object({ - valid: z.boolean(), - feedback: z.string().nullable(), -}); - -export function createSelfEvaluationLoopFlowExample(options: { - generatePost?: (args: { - topic: string; - feedback: string | null; - attempt: number; - }) => Promise>; - evaluatePost?: (post: string) => Promise>; - maxAttempts?: number; -} = {}) { - const generatePost = - options.generatePost ?? - ((args: { topic: string; feedback: string | null; attempt: number }) => - generateExampleObject({ - schema: postSchema, - system: 'Write a playful X post in a Shakespearean tone with no emojis and under 280 characters.', - prompt: [ - `Topic: ${args.topic}`, - `Attempt: ${args.attempt}`, - args.feedback ? `Feedback to address: ${args.feedback}` : 'Feedback: none', - ].join('\n'), - })); - - const evaluatePost = - options.evaluatePost ?? - ((post: string) => - generateExampleObject({ - schema: evaluationSchema, - system: - 'Validate whether the X post is under 280 characters, uses no emojis, and stays playful. Return feedback only when it should be revised.', - prompt: post, - })); - - return createAgentMachine({ - id: 'self-evaluation-loop-flow-example', - schemas: { - input: z.object({ - topic: z.string(), - }), - output: z.object({ - post: z.string().nullable(), - valid: z.boolean(), - feedback: z.string().nullable(), - attempt: z.number(), - }), - }, - context: (input) => ({ - topic: input.topic, - post: null as string | null, - valid: false, - feedback: null as string | null, - attempt: 1, - maxAttempts: options.maxAttempts ?? 3, - }), - initial: 'generating', - states: { - generating: { - schemas: { output: postSchema }, - invoke: async ({ context }) => - generatePost({ - topic: context.topic, - feedback: context.feedback, - attempt: context.attempt, - }), - onDone: ({ output }) => ({ - target: 'evaluating', - context: { - post: output.post, - }, - }), - }, - evaluating: { - schemas: { output: evaluationSchema }, - invoke: async ({ context }) => evaluatePost(context.post ?? ''), - onDone: ({ output, context }) => ({ - target: - output.valid || context.attempt >= context.maxAttempts - ? 'done' - : 'generating', - context: { - valid: output.valid, - feedback: output.feedback, - attempt: output.valid - ? context.attempt - : context.attempt + 1, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - post: context.post, - valid: context.valid, - feedback: context.feedback, - attempt: context.attempt, - }), - }, - }, - }); -} - -async function main() { - try { - const topic = await prompt('Topic'); - const machine = createSelfEvaluationLoopFlowExample(); - const result = await execute(machine, machine.getInitialState({ topic })); - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/serverless.ts b/examples/serverless.ts deleted file mode 100644 index ef30076..0000000 --- a/examples/serverless.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { z } from 'zod'; -import { - createAgentMachine, - decide, - decideResultSchema, - type DecideAdapter, -} from '../src/index.js'; -import { execute } from '../src/local/index.js'; -import { createOpenAiDecisionAdapter } from './_run.js'; - -const movementOptions = { - moveLeft: { - description: 'Move left when the goal is best served by exploring lower positions.', - }, - moveRight: { - description: 'Move right when the goal is best served by exploring higher positions.', - }, - doNothing: { - description: 'Stay still when there is not enough signal to move safely.', - }, -} as const; - -interface AgentObservation { - id: string; - episodeId: string; - state: { value: string; context: Record }; - previousState?: { value: string; context: Record }; -} - -interface AgentFeedback { - observationId: string; - note: string; -} - -const db = { - observations: [] as AgentObservation[], - feedbackItems: [] as AgentFeedback[], - decisions: [] as Array<{ - episodeId: string; - choice: keyof typeof movementOptions; - data: Record; - }>, -}; - -export function createServerlessExampleMachine( - adapter: DecideAdapter = createOpenAiDecisionAdapter() -) { - return createAgentMachine({ - id: 'serverless-example', - schemas: { - input: z.object({ - episodeId: z.string(), - goal: z.string(), - }), - output: z.object({ - choice: z.enum(['moveLeft', 'moveRight', 'doNothing']), - data: z.record(z.string(), z.unknown()), - }), - }, - context: (input) => ({ - episodeId: input.episodeId, - goal: input.goal, - choice: null as keyof typeof movementOptions | null, - data: {} as Record, - }), - initial: 'deciding', - states: { - deciding: { - schemas: { output: decideResultSchema(movementOptions) }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'openai/gpt-5.4-nano', - prompt: buildDecisionPrompt(context.episodeId, context.goal), - options: movementOptions, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { - choice: output.choice, - data: output.data, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - choice: context.choice ?? 'doNothing', - data: context.data, - }), - }, - }, - }); -} - -export async function postObservation(observation: AgentObservation) { - db.observations.push(observation); -} - -export async function postFeedback(feedback: AgentFeedback) { - db.feedbackItems.push(feedback); -} - -export async function getDecision( - req: { - query: { - episodeId: string; - goal: string; - }; - }, - options: { - adapter?: DecideAdapter; - } = {} -) { - const machine = createServerlessExampleMachine(options.adapter); - const result = await execute( - machine, - machine.getInitialState({ - episodeId: req.query.episodeId, - goal: req.query.goal, - }) - ); - - if (result.status !== 'done') { - throw new Error('Serverless decision did not complete'); - } - - db.decisions.push({ - episodeId: req.query.episodeId, - choice: result.output.choice, - data: result.output.data, - }); - - return result.output; -} - -function buildDecisionPrompt(episodeId: string, goal: string): string { - const lastObservation = db.observations - .filter((observation) => observation.episodeId === episodeId) - .at(-1); - const similarObservations = db.observations.filter( - (observation) => - observation.previousState?.value === lastObservation?.previousState?.value - ); - const similarFeedback = db.feedbackItems.filter((feedback) => - similarObservations.some( - (observation) => observation.id === feedback.observationId - ) - ); - - return [ - `Goal: ${goal}`, - lastObservation - ? `Current state: ${JSON.stringify(lastObservation.state)}` - : 'Current state: unknown', - similarFeedback.length - ? `Relevant feedback:\n${similarFeedback.map((feedback) => `- ${feedback.note}`).join('\n')}` - : 'Relevant feedback: none', - ].join('\n\n'); -} diff --git a/examples/setup-agent/email-drafter.ts b/examples/setup-agent/email-drafter.ts new file mode 100644 index 0000000..c88d9a8 --- /dev/null +++ b/examples/setup-agent/email-drafter.ts @@ -0,0 +1,421 @@ +import { z } from 'zod'; +import { assign, fromPromise } from 'xstate'; +import { + addMessages, + type AgentMessage, + assistantMessage, + setupAgent, + userMessage, +} from '../../src/index.js'; + +const promptAssessmentSchema = z.object({ + satisfied: z.boolean(), + missing: z.array(z.string()), + questions: z.array(z.string()), +}); + +const emailDraftSchema = z.object({ + to: z.string(), + subject: z.string(), + body: z.string(), +}); + +type EmailDraft = z.infer; + +// State/transition meta is schema-typed: hosts get a typed interaction +// protocol instead of Record. +const metaSchema = z.object({ + display: z.array(z.string()).optional(), + interaction: z + .discriminatedUnion('type', [ + z.object({ + type: z.literal('text'), + label: z.string(), + eventType: z.string(), + field: z.string(), + }), + z.object({ + type: z.literal('select'), + label: z.string(), + choices: z.array( + z.object({ + label: z.string(), + eventType: z.string(), + input: z + .object({ type: z.literal('text'), label: z.string(), field: z.string() }) + .optional(), + }) + ), + }), + z.object({ + type: z.literal('confirm'), + label: z.string(), + default: z.boolean().optional(), + trueEventType: z.string(), + falseEventType: z.string(), + }), + ]) + .optional(), +}); + +const contextSchema = z.object({ + prompt: z.string(), + assessment: promptAssessmentSchema.nullable(), + draft: emailDraftSchema.nullable(), + draftAnyway: z.boolean(), + sentEmails: z.array(emailDraftSchema), + messages: z.custom((value) => Array.isArray(value)), +}); + +const eventSchemas = { + PROMPT_SUBMITTED: z.object({ prompt: z.string() }), + MORE_INFO: z.object({ details: z.string() }), + DRAFT_ANYWAY: z.object({}), + REQUEST_CHANGES: z.object({ changes: z.string() }), + SEND: z.object({}), + ANOTHER: z.object({}), + END: z.object({}), +}; + +const outputSchema = z.object({ sentEmails: z.array(emailDraftSchema) }); + +const agent = setupAgent({ + context: contextSchema, + events: eventSchemas, + output: outputSchema, + meta: metaSchema, + actors: { + sendEmail: fromPromise<{ sent: boolean }, { draft: EmailDraft }>( + async ({ input }) => { + void input.draft; + return { sent: true }; + } + ), + }, +}).withTasks({ + evaluatePrompt: { + schemas: { + input: z.object({ prompt: z.string() }), + output: promptAssessmentSchema, + }, + model: 'openai/gpt-5.4-nano', + system: + 'Evaluate an email drafting request. Require recipient, subject, and body details. Return missing fields and one question per gap.', + prompt: ({ input }) => input.prompt, + }, + draftEmail: { + schemas: { + input: z.object({ + prompt: z.string(), + draftAnyway: z.boolean(), + messages: z.custom((value) => Array.isArray(value)), + }), + output: emailDraftSchema, + }, + model: 'openai/gpt-5.4-nano', + system: ({ input }) => + [ + 'Draft a polished email from the request.', + input.draftAnyway + ? 'Infer reasonable details only because the user chose to draft anyway.' + : 'Use the provided details without inventing missing essentials.', + ].join('\n'), + messages: ({ input }) => [ + ...input.messages, + userMessage(input.prompt), + ], + }, + streamDraft: { + kind: 'stream', + schemas: { + input: z.object({ prompt: z.string() }), + output: z.string(), + }, + model: 'openai/gpt-5.4-nano', + prompt: ({ input }) => input.prompt, + }, +}); + +export const { evaluatePrompt, draftEmail, streamDraft } = agent.tasks; + +export const emailDrafterSchemas = agent.schemas; + +export const emailDrafter = agent.createMachine({ + id: 'email-drafter', + context: { + prompt: '', + assessment: null, + draft: null, + draftAnyway: false, + sentEmails: [], + messages: [], + }, + initial: 'prompting', + states: { + prompting: { + meta: { + interaction: { + type: 'text', + label: 'Email draft request', + eventType: 'PROMPT_SUBMITTED', + field: 'prompt', + }, + }, + on: { + PROMPT_SUBMITTED: { + target: 'evaluating', + actions: assign({ + prompt: ({ event }) => event.prompt, + assessment: null, + draft: null, + draftAnyway: false, + messages: addMessages(({ event }) => userMessage(event.prompt)), + }), + }, + }, + }, + + evaluating: { + invoke: { + id: 'evaluatePrompt', + src: 'evaluatePrompt', + input: ({ context }) => ({ prompt: context.prompt }), + onDone: [ + { + guard: ({ event }) => event.output.satisfied, + target: 'drafting', + actions: assign({ + assessment: ({ event }) => event.output, + }), + }, + { + target: 'needsMoreInfo', + actions: assign({ + assessment: ({ event }) => event.output, + }), + }, + ], + onError: { target: 'failed' }, + }, + }, + + needsMoreInfo: { + meta: { + interaction: { + type: 'select', + label: 'Next', + choices: [ + { + label: 'Add details', + eventType: 'MORE_INFO', + input: { type: 'text', label: 'More details', field: 'details' }, + }, + { label: 'Draft anyway', eventType: 'DRAFT_ANYWAY' }, + ], + }, + }, + on: { + MORE_INFO: { + target: 'evaluating', + actions: assign({ + prompt: ({ context, event }) => `${context.prompt}\n\n${event.details}`, + messages: addMessages(({ event }) => userMessage(event.details)), + }), + }, + DRAFT_ANYWAY: { + target: 'drafting', + actions: assign({ draftAnyway: true }), + }, + }, + }, + + drafting: { + invoke: { + id: 'draftEmail', + src: 'draftEmail', + input: ({ context }) => ({ + prompt: context.prompt, + draftAnyway: context.draftAnyway, + messages: context.messages, + }), + onDone: { + target: 'reviewing', + actions: assign({ + draft: ({ event }) => event.output, + messages: addMessages(({ event }) => { + const draft = event.output; + return assistantMessage( + `To: ${draft.to}\nSubject: ${draft.subject}\n\n${draft.body}` + ); + }), + }), + }, + onError: { target: 'failed' }, + }, + }, + + reviewing: { + meta: { + interaction: { + type: 'select', + label: 'Next', + choices: [ + { + label: 'Request changes', + eventType: 'REQUEST_CHANGES', + input: { type: 'text', label: 'Requested changes', field: 'changes' }, + }, + { label: 'Send', eventType: 'SEND' }, + ], + }, + }, + on: { + REQUEST_CHANGES: { + target: 'drafting', + actions: assign({ + prompt: ({ context, event }) => + `${context.prompt}\n\nRevision request: ${event.changes}`, + draftAnyway: true, + messages: addMessages(({ event }) => + userMessage(`Revision request: ${event.changes}`) + ), + }), + }, + SEND: { target: 'sending' }, + }, + }, + + sending: { + invoke: { + src: 'sendEmail', + input: ({ context }) => ({ draft: context.draft! }), + onDone: { + target: 'sent', + actions: assign({ + sentEmails: ({ context }) => + context.draft + ? [...context.sentEmails, context.draft] + : context.sentEmails, + }), + }, + onError: { target: 'failed' }, + }, + }, + + sent: { + meta: { + display: ['Email sent.'], + interaction: { + type: 'confirm', + label: 'Send another?', + default: false, + trueEventType: 'ANOTHER', + falseEventType: 'END', + }, + }, + on: { + ANOTHER: { + target: 'prompting', + actions: assign({ + prompt: '', + assessment: null, + draft: null, + draftAnyway: false, + }), + }, + END: { target: 'done' }, + }, + }, + + // Plain final states: `output` is natively typed against the machine's + // output schema, and becomes the machine output when reached. + failed: { + type: 'final', + output: ({ context }) => ({ sentEmails: context.sentEmails }), + }, + done: { + type: 'final', + output: ({ context }) => ({ sentEmails: context.sentEmails }), + }, + }, +}); + +// ─── Type probes: compilation fails if any of these stop being typed ─── + +agent.createMachine({ + context: { + prompt: '', + assessment: null, + draft: null, + draftAnyway: false, + sentEmails: [], + messages: [], + }, + initial: 'probe', + states: { + probe: { + meta: { + // @ts-expect-error meta is schema-typed: 'banner' is not a valid interaction type + interaction: { type: 'banner' }, + }, + on: { + MORE_INFO: { + actions: assign({ + // @ts-expect-error MORE_INFO carries `details`, not `changes` + prompt: ({ event }) => event.changes, + }), + }, + }, + }, + probeFinal: { + type: 'final', + // @ts-expect-error machine output is { sentEmails: EmailDraft[] } + output: () => 'not the machine output', + }, + }, +}); + +// Root-level `output` is natively typed by XState against the output schema +agent.createMachine({ + context: { + prompt: '', + assessment: null, + draft: null, + draftAnyway: false, + sentEmails: [], + messages: [], + }, + // @ts-expect-error machine output is { sentEmails: EmailDraft[] } + output: () => ({ wrong: true }), + initial: 'probe', + states: { + probe: { type: 'final' }, + }, +}); + +// named text logic: onDone output is typed from the logic output schema +agent.createMachine({ + context: { + prompt: '', + assessment: null, + draft: null, + draftAnyway: false, + sentEmails: [], + messages: [], + }, + initial: 'streaming', + states: { + streaming: { + invoke: { + id: 'streamDraft', + src: 'streamDraft', + input: ({ context }) => ({ prompt: context.prompt }), + onDone: { + actions: assign({ + messages: addMessages(({ event }) => assistantMessage(event.output)), + }), + }, + }, + }, + }, +}); diff --git a/examples/setup-agent/game-agent.ts b/examples/setup-agent/game-agent.ts new file mode 100644 index 0000000..da074b0 --- /dev/null +++ b/examples/setup-agent/game-agent.ts @@ -0,0 +1,196 @@ +import { z } from 'zod'; +import { assign } from 'xstate'; +import { createAgentSchemas, setupAgent } from '../../src/index.js'; + +export const turnSummarySchema = z.object({ + summary: z.string(), + enemyHp: z.number(), + playerHp: z.number(), +}); + +const contextSchema = z.object({ + playerHp: z.number(), + enemyHp: z.number(), + defended: z.boolean(), + lastSummary: z.string().nullable(), +}); + +const inputSchema = z.object({ + playerHp: z.number().default(20), + enemyHp: z.number().default(15), +}); + +const outputSchema = z.object({ + outcome: z.enum(['continue', 'won', 'lost', 'fled']), + summary: z.string(), + playerHp: z.number(), + enemyHp: z.number(), +}); + +const eventSchemas = { + ATTACK: z.object({ target: z.string().default('goblin') }), + DEFEND: z.object({}), + HEAL: z.object({ amount: z.number().min(1).max(8).default(4) }), + FLEE: z.object({}), +}; + +const schemas = createAgentSchemas({ + context: contextSchema, + input: inputSchema, + output: outputSchema, + events: eventSchemas, +}); + +const gameAgent = setupAgent({ schemas }).withTasks({ + chooseMove: { + schemas: { + input: z.object({ + playerHp: z.number(), + enemyHp: z.number(), + }), + output: z.string(), + }, + model: 'openai/gpt-5.4-nano', + system: 'You are playing a turn-based game. Choose exactly one legal event tool.', + prompt: ({ input }) => + [ + `Player HP: ${input.playerHp}`, + `Enemy HP: ${input.enemyHp}`, + 'Pick the best legal move.', + ].join('\n'), + events: ({ input }) => + input.playerHp <= 6 + ? ['ATTACK', 'DEFEND', 'HEAL', 'FLEE'] + : ['ATTACK', 'DEFEND', 'FLEE'], + }, + summarizeTurn: { + schemas: { + input: z.object({ + playerHp: z.number(), + enemyHp: z.number(), + defended: z.boolean(), + }), + output: turnSummarySchema, + }, + model: 'openai/gpt-5.4-nano', + system: 'Narrate the turn and return updated HP totals.', + prompt: ({ input }) => + [ + `Player HP: ${input.playerHp}`, + `Enemy HP: ${input.enemyHp}`, + `Defended: ${input.defended}`, + ].join('\n'), + }, +}); + +export const { chooseMove, summarizeTurn } = gameAgent.tasks; + +export const gameSchemas = gameAgent.schemas; + +export const gameMachine = gameAgent.createMachine({ + id: 'turn-based-game-agent', + context: ({ input }) => ({ + playerHp: input.playerHp, + enemyHp: input.enemyHp, + defended: false, + lastSummary: null, + }), + initial: 'choosingMove', + states: { + choosingMove: { + invoke: { + id: 'chooseMove', + src: 'chooseMove', + input: ({ context }) => ({ + playerHp: context.playerHp, + enemyHp: context.enemyHp, + }), + onDone: { target: 'summarizing' }, + }, + on: { + ATTACK: { + target: 'summarizing', + actions: assign({ + enemyHp: ({ context }) => Math.max(0, context.enemyHp - 6), + defended: false, + }), + }, + DEFEND: { + target: 'summarizing', + actions: assign({ defended: true }), + }, + HEAL: { + target: 'summarizing', + actions: assign({ + playerHp: ({ context, event }) => + Math.min(20, context.playerHp + event.amount), + defended: false, + }), + }, + FLEE: { target: 'fled' }, + }, + }, + summarizing: { + invoke: { + id: 'summarizeTurn', + src: 'summarizeTurn', + input: ({ context }) => ({ + playerHp: context.playerHp, + enemyHp: context.enemyHp, + defended: context.defended, + }), + onDone: { + target: 'checkingOutcome', + actions: assign({ + playerHp: ({ event }) => event.output.playerHp, + enemyHp: ({ event }) => event.output.enemyHp, + lastSummary: ({ event }) => event.output.summary, + }), + }, + }, + }, + checkingOutcome: { + always: [ + { guard: ({ context }) => context.enemyHp <= 0, target: 'won' }, + { guard: ({ context }) => context.playerHp <= 0, target: 'lost' }, + { target: 'done' }, + ], + }, + done: { + type: 'final', + output: ({ context }) => ({ + outcome: 'continue', + summary: context.lastSummary ?? '', + playerHp: context.playerHp, + enemyHp: context.enemyHp, + }), + }, + won: { + type: 'final', + output: ({ context }) => ({ + outcome: 'won', + summary: context.lastSummary ?? 'You won.', + playerHp: context.playerHp, + enemyHp: context.enemyHp, + }), + }, + lost: { + type: 'final', + output: ({ context }) => ({ + outcome: 'lost', + summary: context.lastSummary ?? 'You lost.', + playerHp: context.playerHp, + enemyHp: context.enemyHp, + }), + }, + fled: { + type: 'final', + output: ({ context }) => ({ + outcome: 'fled', + summary: 'You fled the encounter.', + playerHp: context.playerHp, + enemyHp: context.enemyHp, + }), + }, + }, +}); diff --git a/examples/setup-agent/hosts/ai-sdk-game.ts b/examples/setup-agent/hosts/ai-sdk-game.ts new file mode 100644 index 0000000..8420b11 --- /dev/null +++ b/examples/setup-agent/hosts/ai-sdk-game.ts @@ -0,0 +1,118 @@ +/** + * Vercel AI SDK pure-transition host for a non-trivial game workflow. + * + * Run: + * OPENAI_API_KEY=... node --import tsx examples/setup-agent/hosts/ai-sdk-game.ts + */ +import { generateObject, generateText, stepCountIs, type LanguageModel } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { initialTransition, transition } from 'xstate'; +import { toAiSdkTools } from '../../../src/ai-sdk/index.js'; +import { + getAgentEffects, + transitionResult, + type AgentEffect, + type AgentTextInput, +} from '../../../src/index.js'; +import { + chooseMove, + gameMachine, + gameSchemas, + summarizeTurn, + turnSummarySchema, +} from '../game-agent.js'; + +function resolveModel(modelRef: string): LanguageModel { + return openai(modelRef.replace(/^openai\//, '')); +} + +async function runGenerateEffect(effect: AgentEffect) { + const input = effect.input as AgentTextInput; + const model = resolveModel(input.model); + const prompt = input.prompt ?? ''; + const tools = toAiSdkTools(effect.tools); + + if (Object.keys(tools).length > 0) { + const result = await generateText({ + model, + system: input.system, + prompt, + tools, + toolChoice: 'required', + stopWhen: stepCountIs(1), + temperature: input.temperature, + }); + + const event = result.toolResults[0]?.output; + if (event && typeof event === 'object' && 'type' in event) { + return { kind: 'event' as const, event }; + } + + return { kind: 'output' as const, output: result.text }; + } + + if (input.outputSchema) { + const { object } = await generateObject({ + model, + system: input.system, + prompt, + schema: input.outputSchema as z.ZodType, + temperature: input.temperature, + }); + return { kind: 'output' as const, output: object }; + } + + const { text } = await generateText({ + model, + system: input.system, + prompt, + temperature: input.temperature, + }); + return { kind: 'output' as const, output: text }; +} + +export async function runAiSdkGameTurn(input = { playerHp: 20, enemyHp: 15 }) { + let [snapshot, actions] = initialTransition(gameMachine, input); + + while (snapshot.status !== 'done') { + const [effect] = getAgentEffects(actions, { + snapshot, + schemas: gameSchemas, + actors: { chooseMove, summarizeTurn }, + }); + + if (!effect) { + throw new Error('Machine is waiting without an agent effect.'); + } + + const result = await runGenerateEffect(effect); + + if (result.kind === 'event') { + [snapshot, actions] = transition( + gameMachine, + snapshot, + result.event as never + ); + } else { + [snapshot, actions] = transitionResult( + gameMachine, + snapshot, + effect, + result.output + ); + } + } + + return snapshot.output; +} + +async function main() { + const output = await runAiSdkGameTurn(); + console.log(output); +} + +if (process.env.OPENAI_API_KEY) { + void main(); +} + +export { turnSummarySchema }; diff --git a/examples/setup-agent/hosts/ai-sdk.ts b/examples/setup-agent/hosts/ai-sdk.ts new file mode 100644 index 0000000..805dc60 --- /dev/null +++ b/examples/setup-agent/hosts/ai-sdk.ts @@ -0,0 +1,318 @@ +/** + * Vercel AI SDK host for `setupAgent(...)` machines. + * + * The machine declares named text logic calls; this host provides their + * execution with the AI SDK. Streaming chunks flow through the host side + * channel (`onChunk` → stdout, HTTP stream, etc.) — the machine itself only + * transitions on the final text. + * + * Run: OPENAI_API_KEY=... node --import tsx examples/setup-agent/hosts/ai-sdk.ts + */ +import { + generateObject, + generateText as aiGenerateText, + streamText as aiStreamText, + type FlexibleSchema, + type LanguageModel, + type ModelMessage, +} from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { assign, createActor, initialTransition, toPromise } from 'xstate'; +import { z } from 'zod'; +import { + createAgentSchemas, + getAgentEffects, + setupAgent, + transitionResult, + type AgentEffect, + type AgentTextInput, + type TextLogic, +} from '../../../src/index.js'; + +// ─── The host adapter: named text logic, implemented with the AI SDK ─── + +interface AiSdkTextHostOptions { + resolveModel?: (modelRef: string) => LanguageModel; + onChunk?: (chunk: string) => void; +} + +function resolveAiSdkModel( + modelRef: string, + options: AiSdkTextHostOptions +): LanguageModel { + return options.resolveModel + ? options.resolveModel(modelRef) + : openai(modelRef.replace(/^openai\//, '')); +} + +function toModelMessages(input: AgentTextInput): ModelMessage[] | undefined { + return input.messages?.map((message) => ({ + role: message.role as 'user' | 'assistant' | 'system', + content: message.content, + })); +} + +async function generateWithAiSdk( + input: AgentTextInput, + tools: AgentTextInput['tools'] = input.tools, + options: AiSdkTextHostOptions = {}, + signal?: AbortSignal +) { + const model = resolveAiSdkModel(input.model, options); + const messages = toModelMessages(input); + const common = { + model, + system: input.system, + ...(messages ? { messages } : { prompt: input.prompt ?? '' }), + abortSignal: signal, + temperature: input.temperature, + maxOutputTokens: input.maxTokens, + topP: input.topP, + seed: input.seed, + stopSequences: input.stopSequences, + tools, + toolChoice: input.toolChoice, + }; + + if (input.outputSchema) { + const { object } = await generateObject({ + ...common, + schema: input.outputSchema as FlexibleSchema, + }); + return object; + } + + const { text } = await aiGenerateText(common); + return text; +} + +async function streamWithAiSdk( + input: AgentTextInput, + options: AiSdkTextHostOptions = {}, + signal?: AbortSignal +) { + const model = resolveAiSdkModel(input.model, options); + const messages = toModelMessages(input); + + const result = aiStreamText({ + model, + system: input.system, + ...(messages ? { messages } : { prompt: input.prompt ?? '' }), + abortSignal: signal, + temperature: input.temperature, + maxOutputTokens: input.maxTokens, + topP: input.topP, + seed: input.seed, + stopSequences: input.stopSequences, + }); + + for await (const chunk of result.textStream) { + options.onChunk?.(chunk); + } + + return await result.text; +} + +async function executeAgentEffect( + effect: AgentEffect, + options: AiSdkTextHostOptions = {} +) { + return generateWithAiSdk(effect.input, effect.tools, options); +} + +export function createAiSdkTextActor( + logic: TLogic, + options: AiSdkTextHostOptions = {} +) { + return logic.withExecutor(async ({ request, signal }) => + await generateWithAiSdk(request, undefined, options, signal) as never + ); +} + +export function createAiSdkStreamingTextActor( + logic: TLogic, + options: AiSdkTextHostOptions = {} +) { + return logic.withExecutor(async ({ request, signal }) => + await streamWithAiSdk(request, options, signal) as never + ); +} + +// ─── Demo 1: generateText with an object output schema ─── + +const triageSchema = z.object({ + sentiment: z.enum(['positive', 'neutral', 'negative']), + category: z.enum(['billing', 'technical', 'other']), + reply: z.string(), +}); + +const triageAgent = setupAgent({ + schemas: createAgentSchemas({ + context: z.object({ + ticket: z.string(), + triage: triageSchema.nullable(), + }), + input: z.object({ ticket: z.string() }), + output: triageSchema, + }), +}).withTasks({ + triageTicket: { + schemas: { + input: z.object({ ticket: z.string() }), + output: triageSchema, + }, + model: 'openai/gpt-5.4-nano', + system: + 'Triage the support ticket: sentiment, category, and a short suggested reply.', + prompt: ({ input }) => input.ticket, + }, +}); + +const { triageTicket } = triageAgent.tasks; + +const triageMachine = triageAgent.createMachine({ + id: 'ticket-triage', + context: ({ input }) => ({ ticket: input.ticket, triage: null }), + initial: 'triaging', + states: { + triaging: { + invoke: { + id: 'triage', + src: 'triageTicket', + input: ({ context }) => ({ ticket: context.ticket }), + onDone: { + target: 'done', + actions: assign({ + triage: ({ event }) => event.output, + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => + context.triage ?? { sentiment: 'neutral', category: 'other', reply: '' }, + }, + }, +}); + +export async function runTriageDemo(ticket: string) { + const actor = createActor( + triageMachine.provide({ + actors: { + triageTicket: createAiSdkTextActor(triageTicket), + }, + }), + { input: { ticket } } + ); + actor.start(); + const output = await toPromise(actor); + return output; // machine output, typed by the output schema +} + +export async function runTriagePureTransitionDemo(ticket: string) { + let [snapshot, actions] = initialTransition(triageMachine, { ticket }); + + while (snapshot.status !== 'done') { + const effects = getAgentEffects(actions, { + snapshot, + actors: { triageTicket }, + }); + if (effects.length === 0) { + throw new Error('Machine is waiting without an agent effect.'); + } + + for (const effect of effects) { + const output = await executeAgentEffect(effect); + [snapshot, actions] = transitionResult( + triageMachine, + snapshot, + effect, + output + ); + } + } + + return snapshot.output; +} + +// ─── Demo 2: streamText actually streaming ─── + +const jokeAgent = setupAgent({ + schemas: createAgentSchemas({ + context: z.object({ + topic: z.string(), + joke: z.string().nullable(), + }), + input: z.object({ topic: z.string() }), + output: z.object({ joke: z.string() }), + }), +}).withTasks({ + tellJoke: { + kind: 'stream', + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'openai/gpt-5.4-nano', + system: 'You tell short, punchy jokes.', + prompt: ({ input }) => `Tell a joke about ${input.topic}.`, + }, +}); + +const { tellJoke } = jokeAgent.tasks; + +const jokeMachine = jokeAgent.createMachine({ + id: 'joke-streamer', + context: ({ input }) => ({ topic: input.topic, joke: null }), + // The no-helper route to typed machine output: a root-level `output` + // mapper, which XState types against the output schema natively. Final + // states stay bare. (`agent.final` is only needed when each final state + // computes a DIFFERENT output.) + output: ({ context }) => ({ joke: context.joke ?? '' }), + initial: 'streaming', + states: { + streaming: { + invoke: { + id: 'joke', + src: 'tellJoke', + input: ({ context }) => ({ topic: context.topic }), + onDone: { + target: 'done', + // event.output is the FINAL streamed text (string) + actions: assign({ joke: ({ event }) => event.output }), + }, + }, + }, + done: { type: 'final' }, + }, +}); + +export async function runStreamingDemo(topic: string) { + const actor = createActor( + jokeMachine.provide({ + actors: { + tellJoke: createAiSdkStreamingTextActor(tellJoke, { + // The side channel: chunks go to stdout as they arrive. In a server + // this is a UIMessageStream writer or Response stream instead. + onChunk: (chunk) => process.stdout.write(chunk), + }), + }, + }), + { input: { topic } } + ); + actor.start(); + await toPromise(actor); + process.stdout.write('\n'); +} + +async function main() { + console.log('— generateText (object output) —'); + console.log(await runTriageDemo('My invoice is wrong and I am furious.')); + console.log('— streamText (live chunks) —'); + await runStreamingDemo('state machines'); +} + +if (process.env.OPENAI_API_KEY) { + void main(); +} diff --git a/examples/setup-agent/hosts/cloudflare-agent.ts b/examples/setup-agent/hosts/cloudflare-agent.ts new file mode 100644 index 0000000..2a4eba2 --- /dev/null +++ b/examples/setup-agent/hosts/cloudflare-agent.ts @@ -0,0 +1,87 @@ +/** + * Cloudflare Agents host for XState setup machines — PREVIEW CODE. + * + * Like the other platform host sketches in this repo, this file is + * illustrative and excluded from typechecking (the `agents` package + * requires `@cloudflare/workers-types` ambients). + * + * The shape: + * - The Agent (a Durable Object) hosts the XState actor. + * - The persisted snapshot lives in Agent state, so the machine survives + * hibernation/eviction and resumes exactly where it left off. + * - Clients send machine events over WebSocket; provider/runtime details stay + * in the host actor implementations. + */ +import { Agent, type Connection } from 'agents'; +import { createActor, type AnyActorRef, type Snapshot } from 'xstate'; +import { + draftEmail, + emailDrafter, + emailDrafterSchemas, + evaluatePrompt, +} from '../email-drafter.js'; +import { createAiSdkTextActor } from './ai-sdk.js'; +import { createWorkersAI } from 'workers-ai-provider'; + +interface Env { + AI: Ai; +} + +interface EmailDrafterState { + snapshot?: Snapshot; +} + +export class EmailDrafterAgent extends Agent { + initialState: EmailDrafterState = {}; + #actor: AnyActorRef | undefined; + + onStart() { + const workersai = createWorkersAI({ binding: this.env.AI }); + + const machine = emailDrafter.provide({ + actors: { + evaluatePrompt: createAiSdkTextActor(evaluatePrompt, { + resolveModel: (modelRef) => workersai(modelRef as never), + }), + draftEmail: createAiSdkTextActor(draftEmail, { + resolveModel: (modelRef) => workersai(modelRef as never), + }), + }, + }); + + // Restore from the persisted snapshot if the DO was evicted mid-run. + this.#actor = createActor(machine, { + snapshot: this.state.snapshot, + }); + + this.#actor.subscribe((snapshot) => { + // Durable persistence on every transition: this is the journal the + // analytics/visualization layer reads, keyed by this Agent instance. + this.setState({ snapshot: this.#actor!.getPersistedSnapshot() }); + this.broadcast( + JSON.stringify({ + type: 'state', + value: snapshot.value, + // meta is schema-typed: clients get the interaction protocol + // (text / select / confirm) for the current state. + meta: snapshot.getMeta(), + }) + ); + }); + + this.#actor.start(); + } + + onMessage(connection: Connection, message: string) { + // Client messages are machine events (PROMPT_SUBMITTED, SEND, ...). + // The machine's event schemas validate them before they hit the actor. + const event = JSON.parse(message); + const schema = emailDrafterSchemas.events[event.type]; + const result = schema?.['~standard'].validate(event); + if (result?.issues) { + connection.send(JSON.stringify({ type: 'error', issues: result.issues })); + return; + } + this.#actor?.send(event); + } +} diff --git a/examples/setup-agent/hosts/cloudflare-workers-ai.ts b/examples/setup-agent/hosts/cloudflare-workers-ai.ts new file mode 100644 index 0000000..652ee2b --- /dev/null +++ b/examples/setup-agent/hosts/cloudflare-workers-ai.ts @@ -0,0 +1,115 @@ +/** + * Cloudflare Workers AI pure-transition host for the game workflow. + * + * Run with Wrangler in a Worker that has an `AI` binding. Workers AI does not + * expose the same tool-calling shape as the Vercel AI SDK binding path, so this + * host serializes allowed event tools into the prompt and accepts JSON output. + */ +import { initialTransition, transition } from 'xstate'; +import { + getAgentEffects, + transitionResult, + type AgentEffect, +} from '../../../src/index.js'; +import { + chooseMove, + gameMachine, + gameSchemas, + summarizeTurn, +} from '../game-agent.js'; + +interface Env { + AI: { + run(model: string, input: Record): Promise; + }; +} + +function promptWithAllowedEvents(effect: AgentEffect): string { + const legalEvents = effect.events + .map((event) => `- ${event.type}`) + .join('\n'); + + if (!legalEvents) { + return effect.input.prompt ?? ''; + } + + return [ + effect.input.prompt ?? '', + '', + 'Choose exactly one legal event and respond as JSON.', + 'Legal events:', + legalEvents, + 'Example: {"type":"ATTACK","target":"goblin"}', + ].join('\n'); +} + +async function runWorkersAiEffect(env: Env, effect: AgentEffect) { + const response = await env.AI.run(effect.input.model, { + system: effect.input.system, + prompt: promptWithAllowedEvents(effect), + temperature: effect.input.temperature, + max_tokens: effect.input.maxTokens, + }) as { response?: string } | string | Record; + + const text = + typeof response === 'string' + ? response + : typeof response.response === 'string' + ? response.response + : JSON.stringify(response); + + if (effect.events.length > 0) { + return { kind: 'event' as const, event: JSON.parse(text) }; + } + + if (effect.input.outputSchema) { + return { kind: 'output' as const, output: JSON.parse(text) }; + } + + return { kind: 'output' as const, output: text }; +} + +export async function runCloudflareGameTurn( + env: Env, + input = { playerHp: 20, enemyHp: 15 } +) { + let [snapshot, actions] = initialTransition(gameMachine, input); + + while (snapshot.status !== 'done') { + const [effect] = getAgentEffects(actions, { + snapshot, + schemas: gameSchemas, + actors: { chooseMove, summarizeTurn }, + }); + + if (!effect) { + throw new Error('Machine is waiting without an agent effect.'); + } + + const result = await runWorkersAiEffect(env, effect); + + if (result.kind === 'event') { + [snapshot, actions] = transition( + gameMachine, + snapshot, + result.event as never + ); + } else { + [snapshot, actions] = transitionResult( + gameMachine, + snapshot, + effect, + result.output + ); + } + } + + return snapshot.output; +} + +export default { + async fetch(_request: Request, env: Env) { + const output = await runCloudflareGameTurn(env); + return Response.json(output); + }, +}; diff --git a/examples/setup-agent/hosts/tanstack-ai.ts b/examples/setup-agent/hosts/tanstack-ai.ts new file mode 100644 index 0000000..36b8889 --- /dev/null +++ b/examples/setup-agent/hosts/tanstack-ai.ts @@ -0,0 +1,111 @@ +/** + * TanStack AI pure-transition host for the game workflow. + * + * Install peer SDKs in an app: + * pnpm add @tanstack/ai @tanstack/ai-openai + * + * Then run with an OpenAI-compatible TanStack adapter. + */ +import { initialTransition, transition } from 'xstate'; +import { + getAgentEffects, + transitionResult, + type AgentEffect, +} from '../../../src/index.js'; +import { + chooseMove, + gameMachine, + gameSchemas, + summarizeTurn, +} from '../game-agent.js'; + +type TanStackChat = (options: { + adapter: unknown; + messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>; + tools?: unknown[]; + outputSchema?: unknown; + stream?: false; +}) => Promise; + +function toTanStackTools(effect: AgentEffect) { + return effect.events.map((event) => ({ + name: event.toolName, + description: `Transition with event '${event.type}'.`, + inputSchema: event.inputSchema, + execute: async (input: unknown = {}) => ({ + ...(input && typeof input === 'object' ? input : {}), + type: event.type, + }), + })); +} + +async function runTanStackEffect(args: { + chat: TanStackChat; + adapter: unknown; + effect: AgentEffect; +}) { + const result = await args.chat({ + adapter: args.adapter, + stream: false, + messages: [ + ...(args.effect.input.system + ? [{ role: 'system' as const, content: args.effect.input.system }] + : []), + { role: 'user', content: args.effect.input.prompt ?? '' }, + ], + tools: toTanStackTools(args.effect), + outputSchema: args.effect.input.outputSchema, + }); + + if (result && typeof result === 'object' && 'type' in result) { + return { kind: 'event' as const, event: result }; + } + + return { kind: 'output' as const, output: result }; +} + +export async function runTanStackGameTurn(args: { + chat: TanStackChat; + adapter: unknown; + input?: { playerHp: number; enemyHp: number }; +}) { + let [snapshot, actions] = initialTransition( + gameMachine, + args.input ?? { playerHp: 20, enemyHp: 15 } + ); + + while (snapshot.status !== 'done') { + const [effect] = getAgentEffects(actions, { + snapshot, + schemas: gameSchemas, + actors: { chooseMove, summarizeTurn }, + }); + + if (!effect) { + throw new Error('Machine is waiting without an agent effect.'); + } + + const result = await runTanStackEffect({ + chat: args.chat, + adapter: args.adapter, + effect, + }); + + if (result.kind === 'event') { + [snapshot, actions] = transition( + gameMachine, + snapshot, + result.event as never + ); + } else { + [snapshot, actions] = transitionResult( + gameMachine, + snapshot, + effect, + result.output + ); + } + } + + return snapshot.output; +} diff --git a/examples/setup-agent/smoke.mts b/examples/setup-agent/smoke.mts new file mode 100644 index 0000000..d181823 --- /dev/null +++ b/examples/setup-agent/smoke.mts @@ -0,0 +1,44 @@ +import { createActor, fromPromise, waitFor } from 'xstate'; +import { + draftEmail, + emailDrafter, + evaluatePrompt, +} from './email-drafter.js'; +import type { AgentTextInput } from '../../src/index.js'; + +const calls: AgentTextInput[] = []; + +const machine = emailDrafter.provide({ + actors: { + evaluatePrompt: evaluatePrompt.withExecutor(async ({ request }) => { + calls.push(request); + // first evaluation: unsatisfied; second: satisfied + const satisfied = calls.filter((c) => c.system?.includes('Evaluate')).length > 1; + return { satisfied, missing: satisfied ? [] : ['recipient'], questions: satisfied ? [] : ['Who?'] }; + }), + draftEmail: draftEmail.withExecutor(async ({ request }) => { + calls.push(request); + return { to: 'sam@example.com', subject: 'Hello', body: 'Hi Sam!' }; + }), + sendEmail: fromPromise(async () => ({ sent: true })), + } as any, +}); + +const actor = createActor(machine); +actor.start(); + +actor.send({ type: 'PROMPT_SUBMITTED', prompt: 'email sam' }); +await waitFor(actor, (s) => s.matches('needsMoreInfo')); +console.log('1. needsMoreInfo meta:', JSON.stringify(actor.getSnapshot().getMeta(), null, 0).slice(0, 80), '…'); + +actor.send({ type: 'MORE_INFO', details: 'sam@example.com, say hello' }); +await waitFor(actor, (s) => s.matches('reviewing')); +console.log('2. reviewing, draft:', actor.getSnapshot().context.draft); +console.log('3. messages:', actor.getSnapshot().context.messages.map((m: any) => m.role)); + +actor.send({ type: 'SEND' }); +await waitFor(actor, (s) => s.matches('sent')); +actor.send({ type: 'END' }); +await waitFor(actor, (s) => s.status === 'done'); +console.log('4. final output:', actor.getSnapshot().output); +console.log('5. generateText inputs seen by host:', calls.map((c) => ({ model: c.model, hasSchema: !!c.outputSchema }))); diff --git a/examples/setup-agent/tsconfig.json b/examples/setup-agent/tsconfig.json new file mode 100644 index 0000000..e956583 --- /dev/null +++ b/examples/setup-agent/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false + }, + "include": ["."], + "exclude": ["hosts/cloudflare-agent.ts", "hosts/cloudflare-workers-ai.ts"] +} diff --git a/examples/simple.ts b/examples/simple.ts deleted file mode 100644 index f1ab17a..0000000 --- a/examples/simple.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine, type AgentAdapter } from '../src/index.js'; -import { - closePrompt, - createOpenAiGenerationAdapter, - formatResult, - isMain, - prompt, -} from './_run.js'; - -const summarySchema = z.object({ - summary: z.string(), -}); - -export function createSimpleExample( - adapter: AgentAdapter = createOpenAiGenerationAdapter() -) { - return createAgentMachine({ - id: 'simple-example', - adapter, - schemas: { - input: z.object({ text: z.string() }), - output: z.object({ summary: z.string().nullable() }), - }, - context: (input) => ({ - text: input.text, - summary: null as string | null, - }), - initial: 'summarizing', - states: { - summarizing: { - schemas: { output: summarySchema }, - prompt: ({ context }) => - `Summarize this text in one sentence:\n\n${context.text}`, - onDone: ({ output }) => ({ - target: 'done', - context: { summary: output.summary }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ summary: context.summary }), - }, - }, - }); -} - -async function main() { - try { - const text = await prompt('Text to summarize'); - const machine = createSimpleExample(); - const result = await execute(machine, machine.getInitialState({ text })); - - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/spec-agent-loop.ts b/examples/spec-agent-loop.ts deleted file mode 100644 index 8a24778..0000000 --- a/examples/spec-agent-loop.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - appendMessages, - assistantMessage, - createAgentMachine, - userMessage, -} from '../src/index.js'; -import { - closePrompt, - generateExampleText, - isMain, - prompt, -} from './_run.js'; - -const generationSchema = z.object({ - rawText: z.string(), - specYaml: z.string(), - questions: z.array(z.string()), - status: z.enum(['needs_user', 'complete']), -}); - -const validationSchema = z.object({ - ok: z.boolean(), - errors: z.array(z.string()), -}); - -type Generation = z.infer; - -export function createSpecAgentLoopExample( - options: { - generate?: (args: { - specName: string; - messages: Array<{ role: string; content: string }>; - }) => Promise; - validate?: (yaml: string) => z.infer; - maxRepairTurns?: number; - } = {} -) { - const generate = - options.generate ?? - (({ specName, messages }) => - generateExampleText({ - system: [ - 'Write a small YAML spec.', - 'Respond exactly with , , and tags.', - 'Use complete only when the YAML has no __HOLE__ markers.', - ].join('\n'), - prompt: [ - `Spec name: ${specName}`, - '', - ...messages.map((message) => `${message.role}: ${message.content}`), - ].join('\n'), - })); - - const validate = - options.validate ?? - ((yaml: string) => { - const errors: string[] = []; - if (!yaml.trim()) errors.push('Missing YAML'); - if (/__HOLE__|TODO|TBD|UNKNOWN/i.test(yaml)) errors.push('YAML has holes'); - if (!/^name:/m.test(yaml)) errors.push('Missing name'); - return { ok: errors.length === 0, errors }; - }); - - return createAgentMachine({ - id: 'spec-agent-loop-example', - schemas: { - input: z.object({ - specName: z.string(), - prompt: z.string(), - }), - events: { - 'user.answer': z.object({ answer: z.string() }), - 'user.accept': z.object({}), - 'user.quit': z.object({}), - }, - output: z.object({ - specYaml: z.string(), - accepted: z.boolean(), - }), - }, - context: (input) => ({ - specName: input.specName, - specYaml: '', - questions: [] as string[], - status: 'needs_user' as Generation['status'], - validation: { ok: false, errors: [] as string[] }, - repairTurns: 0, - maxRepairTurns: options.maxRepairTurns ?? 3, - accepted: false, - }), - messages: (input) => [ - userMessage(`Create an initial spec from this prompt:\n\n${input.prompt}`), - ], - initial: 'generating', - states: { - generating: { - schemas: { output: generationSchema }, - invoke: async ({ context, messages }) => - parseTaggedResponse( - await generate({ - specName: context.specName, - messages, - }) - ), - onDone: ({ output, messages }) => ({ - target: output.specYaml ? 'validating' : 'repairing', - context: { - specYaml: output.specYaml, - questions: output.questions, - status: output.status, - }, - messages: appendMessages(messages, assistantMessage(output.rawText)), - }), - }, - validating: { - schemas: { output: validationSchema }, - invoke: async ({ context }) => validate(context.specYaml), - onDone: ({ output }) => ({ - target: 'routing', - context: { validation: output }, - }), - }, - routing: { - always: ({ context, messages }) => { - if (context.validation.ok && context.status === 'complete') { - return { target: 'awaitingAcceptance' }; - } - - if (!context.validation.ok && context.status === 'complete') { - return { - target: - context.repairTurns < context.maxRepairTurns - ? 'generating' - : 'awaitingUser', - context: { repairTurns: context.repairTurns + 1 }, - messages: appendMessages( - messages, - userMessage( - [ - 'You marked the spec complete, but deterministic validation failed.', - ...context.validation.errors.map((error) => `- ${error}`), - ].join('\n') - ) - ), - }; - } - - return { target: 'awaitingUser' }; - }, - }, - repairing: { - always: ({ context, messages }) => ({ - target: - context.repairTurns < context.maxRepairTurns - ? 'generating' - : 'awaitingUser', - context: { repairTurns: context.repairTurns + 1 }, - messages: appendMessages( - messages, - userMessage('Return the full YAML spec using the required tags.') - ), - }), - }, - awaitingUser: { - on: { - 'user.answer': ({ event, messages }) => ({ - target: 'generating', - context: { repairTurns: 0 }, - messages: appendMessages( - messages, - userMessage(`User answered/refined:\n\n${event.answer}`) - ), - }), - 'user.quit': { target: 'done' }, - }, - }, - awaitingAcceptance: { - on: { - 'user.accept': { - target: 'done', - context: { accepted: true }, - }, - 'user.answer': ({ event, messages }) => ({ - target: 'generating', - messages: appendMessages( - messages, - userMessage(`Spec validates, but refine:\n\n${event.answer}`) - ), - }), - }, - }, - done: { - type: 'final', - output: ({ context }) => ({ - specYaml: context.specYaml, - accepted: context.accepted, - }), - }, - }, - }); -} - -function parseTaggedResponse(text: string): Generation { - const specYaml = text.match(/([\s\S]*?)<\/SPEC_YAML>/)?.[1]?.trim() ?? ''; - const questionText = text.match(/([\s\S]*?)<\/QUESTIONS>/)?.[1]?.trim() ?? ''; - const statusRaw = text.match(/([\s\S]*?)<\/STATUS>/)?.[1]?.trim(); - - return { - rawText: text.trim(), - specYaml, - questions: questionText - .split('\n') - .map((line) => line.replace(/^[-*\d. )]+/, '').trim()) - .filter(Boolean), - status: statusRaw === 'complete' ? 'complete' : 'needs_user', - }; -} - -async function main() { - try { - const specName = await prompt('Spec name'); - const initialPrompt = await prompt('Describe the spec'); - const machine = createSpecAgentLoopExample(); - const run = await startSession(machine, { - store: createMemoryRunStore(), - input: { specName, prompt: initialPrompt }, - }); - - while (true) { - const snapshot = await waitForRunSnapshot( - run, - (nextSnapshot) => nextSnapshot.status !== 'active' - ); - - if (snapshot.status === 'done') { - console.log(snapshot.output); - break; - } - - console.log({ - value: snapshot.value, - validation: snapshot.context.validation, - questions: snapshot.context.questions, - }); - - if (snapshot.value === 'awaitingAcceptance') { - const answer = await prompt('Accept? [Y/n]'); - await run.send( - !answer || /^y(es)?$/i.test(answer) - ? { type: 'user.accept' } - : { type: 'user.answer', answer } - ); - continue; - } - - const answer = await prompt('Answer, refine, or /quit'); - await run.send( - answer === '/quit' - ? { type: 'user.quit' } - : { type: 'user.answer', answer } - ); - } - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/sql-agent.ts b/examples/sql-agent.ts deleted file mode 100644 index db3b263..0000000 --- a/examples/sql-agent.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - createAgentMachine, - decide, - decideResultSchema, - type DecideAdapter, -} from '../src/index.js'; -import { - closePrompt, - createOpenAiDecisionAdapter, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const sqlValueSchema = z.union([z.string(), z.number(), z.null()]); -const sqlRowsSchema = z.array(z.record(z.string(), sqlValueSchema)); - -const planningOptions = { - query: { - description: 'Write or revise a SQL query that should help answer the question.', - schema: z.object({ - query: z.string(), - }), - }, - answer: { - description: 'Return the final answer once the available query results are sufficient.', - schema: z.object({ - answer: z.string(), - }), - }, -} as const; - -const queryExecutionSchema = z.discriminatedUnion('status', [ - z.object({ - status: z.literal('success'), - query: z.string(), - rows: sqlRowsSchema, - }), - z.object({ - status: z.literal('error'), - query: z.string(), - error: z.string(), - }), -]); - -export function createSqlAgentExample( - options: { - adapter?: DecideAdapter; - executeQuery?: (args: { - question: string; - schema: string; - query: string; - queryHistory: string[]; - }) => Promise< - | { status: 'success'; rows: z.infer } - | { status: 'error'; error: string } - >; - } = {} -) { - const adapter = options.adapter ?? createOpenAiDecisionAdapter(); - const executeQuery = - options.executeQuery ?? - ((args: { - question: string; - schema: string; - query: string; - queryHistory: string[]; - }) => - generateExampleObject({ - schema: z.discriminatedUnion('status', [ - z.object({ - status: z.literal('success'), - rows: sqlRowsSchema, - }), - z.object({ - status: z.literal('error'), - error: z.string(), - }), - ]), - system: [ - 'You simulate a SQL database tool for demos.', - 'Return status="success" with concise rows when the query is plausible.', - 'Return status="error" with a short SQL/tool error when the query is invalid.', - ].join('\n'), - prompt: [ - `Question: ${args.question}`, - `Schema: ${args.schema}`, - `Query: ${args.query}`, - args.queryHistory.length - ? `Prior queries:\n${args.queryHistory.map((query, index) => `${index + 1}. ${query}`).join('\n')}` - : 'Prior queries: none', - ].join('\n'), - })); - - return createAgentMachine({ - id: 'sql-agent-example', - schemas: { - input: z.object({ - question: z.string(), - schema: z.string(), - }), - emitted: { - toolCall: z.object({ - toolName: z.literal('sqlDb'), - input: z.object({ - query: z.string(), - }), - }), - toolResult: z.object({ - toolName: z.literal('sqlDb'), - output: queryExecutionSchema, - }), - }, - output: z.object({ - question: z.string(), - schema: z.string(), - answer: z.string().nullable(), - latestRows: sqlRowsSchema.nullable(), - latestError: z.string().nullable(), - queryHistory: z.array(z.string()), - }), - }, - context: (input) => ({ - question: input.question, - schema: input.schema, - answer: null as string | null, - latestRows: null as z.infer | null, - latestError: null as string | null, - queryHistory: [] as string[], - }), - initial: 'planning', - states: { - planning: { - schemas: { output: decideResultSchema(planningOptions) }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'openai/gpt-5.4-nano', - prompt: [ - 'You are a SQL agent deciding whether to query the database again or answer.', - 'Query when you still need database evidence or when the last query failed.', - 'Answer only when the current rows are enough to respond directly.', - '', - `Question: ${context.question}`, - `Schema: ${context.schema}`, - context.queryHistory.length - ? `Previous queries:\n${context.queryHistory.map((query, index) => `${index + 1}. ${query}`).join('\n')}` - : 'Previous queries: none', - context.latestError - ? `Latest error: ${context.latestError}` - : 'Latest error: none', - context.latestRows - ? `Latest rows:\n${JSON.stringify(context.latestRows, null, 2)}` - : 'Latest rows: none', - ].join('\n'), - options: planningOptions, - }), - onDone: ({ output }) => { - if (output.choice === 'query') { - return { - target: 'querying', - input: { - query: output.data.query, - }, - }; - } - - return { - target: 'done', - context: { - answer: output.data.answer, - }, - }; - }, - }, - querying: { - schemas: { input: z.object({ - query: z.string(), - }), output: queryExecutionSchema }, - invoke: async ({ context, input }, enq) => { - enq.emit({ - type: 'toolCall', - toolName: 'sqlDb', - input, - }); - - const output = await executeQuery({ - question: context.question, - schema: context.schema, - query: input.query, - queryHistory: context.queryHistory, - }); - - const resolvedOutput = - output.status === 'success' - ? { - status: 'success' as const, - query: input.query, - rows: output.rows, - } - : { - status: 'error' as const, - query: input.query, - error: output.error, - }; - - enq.emit({ - type: 'toolResult', - toolName: 'sqlDb', - output: resolvedOutput, - }); - - return resolvedOutput; - }, - onDone: ({ output, context }) => ({ - target: 'planning', - context: { - queryHistory: [ - ...context.queryHistory, - output.query, - ], - latestRows: output.status === 'success' ? output.rows : null, - latestError: output.status === 'error' ? output.error : null, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - question: context.question, - schema: context.schema, - answer: context.answer, - latestRows: context.latestRows, - latestError: context.latestError, - queryHistory: context.queryHistory, - }), - }, - }, - }); -} - -async function main() { - try { - const question = await prompt('Question'); - const schema = await prompt('Schema'); - const machine = createSqlAgentExample(); - const run = await startSession(machine, { - store: createMemoryRunStore(), - input: { question, schema }, - }); - - run.on('toolCall', (event) => { - console.log(`Calling ${event.toolName}(${event.input.query})`); - }); - run.on('toolResult', (event) => { - console.log(`${event.toolName} -> ${JSON.stringify(event.output)}`); - }); - - const done = await waitForRunDone(run); - console.log(done.output); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/subflow.ts b/examples/subflow.ts deleted file mode 100644 index 4034f85..0000000 --- a/examples/subflow.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const researchSchema = z.object({ - bullets: z.array(z.string()), -}); - -const draftSchema = z.object({ - draft: z.string(), -}); - -export function createSubflowExample( - options: { - research?: (topic: string) => Promise>; - write?: (input: { - topic: string; - bullets: string[]; - }) => Promise>; - } = {} -) { - const childMachine = createAgentMachine({ - id: 'subflow-child', - schemas: { - input: z.object({ topic: z.string() }), - output: z.object({ bullets: z.array(z.string()) }), - }, - context: (input) => ({ - topic: input.topic, - bullets: [] as string[], - }), - initial: 'researching', - states: { - researching: { - schemas: { output: researchSchema }, - invoke: async ({ context }) => - (options.research - ?? ((topic) => - generateExampleObject({ - schema: researchSchema, - system: 'You research a topic and return concise bullet points.', - prompt: `Return 2 to 4 concise research bullets about ${topic}.`, - })))(context.topic), - onDone: ({ output }) => ({ - target: 'done', - context: { bullets: output.bullets }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ bullets: context.bullets }), - }, - }, - }); - - return createAgentMachine({ - id: 'subflow-example', - schemas: { - input: z.object({ topic: z.string() }), - output: z.object({ - bullets: z.array(z.string()), - draft: z.string().nullable(), - }), - }, - context: (input) => ({ - topic: input.topic, - bullets: [] as string[], - draft: null as string | null, - }), - initial: 'researching', - states: { - researching: { - schemas: { output: researchSchema }, - invoke: async ({ context }) => { - const result = await execute(childMachine, - childMachine.getInitialState({ topic: context.topic }) - ); - - if (result.status !== 'done') { - throw new Error('Child machine did not finish'); - } - - return { - bullets: result.output.bullets, - }; - }, - onDone: ({ output }) => ({ - target: 'writing', - context: { bullets: output.bullets }, - }), - }, - writing: { - schemas: { output: draftSchema }, - invoke: async ({ context }) => - (options.write - ?? (({ topic, bullets }) => - generateExampleObject({ - schema: draftSchema, - system: 'You turn research bullets into a short coherent draft.', - prompt: [ - `Topic: ${topic}`, - 'Use these bullets to write a short draft:', - ...bullets.map((bullet) => `- ${bullet}`), - ].join('\n'), - })))({ - topic: context.topic, - bullets: context.bullets, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { draft: output.draft }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - bullets: context.bullets, - draft: context.draft, - }), - }, - }, - }); -} - -async function main() { - try { - const topic = await prompt('Topic'); - const machine = createSubflowExample(); - const result = await execute(machine, machine.getInitialState({ topic })); - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/supervisor.ts b/examples/supervisor.ts deleted file mode 100644 index 67087ef..0000000 --- a/examples/supervisor.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { - createAgentMachine, - decide, - decideResultSchema, - type DecideAdapter, -} from '../src/index.js'; -import { - closePrompt, - createOpenAiDecisionAdapter, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const handlingParamsSchema = z.object({ - attempt: z.number().int().min(1), - instruction: z.string().nullable().optional(), -}); - -const workerResultSchema = z.discriminatedUnion('status', [ - z.object({ - status: z.literal('resolved'), - response: z.string(), - }), - z.object({ - status: z.literal('blocked'), - issue: z.string(), - }), -]); - -const supervisorOptions = { - retry: { - description: 'Retry the worker with a concrete instruction for the next attempt.', - schema: z.object({ - instruction: z.string(), - }), - }, - escalate: { - description: 'Escalate the task to a human or specialist owner.', - schema: z.object({ - reason: z.string(), - }), - }, -} as const; - -export function createSupervisorExample( - options: { - adapter?: DecideAdapter; - handle?: (args: { - request: string; - attempt: number; - instruction: string | null; - priorIssues: string[]; - }) => Promise>; - maxAttempts?: number; - } = {} -) { - const adapter = options.adapter ?? createOpenAiDecisionAdapter(); - const maxAttempts = options.maxAttempts ?? 2; - const handle = - options.handle ?? - ((args: { - request: string; - attempt: number; - instruction: string | null; - priorIssues: string[]; - }) => - generateExampleObject({ - schema: workerResultSchema, - system: [ - 'You are an operations worker handling a support request.', - 'Resolve the request when you have enough information.', - 'Return status="blocked" with a concise issue when the request cannot be completed yet.', - ].join('\n'), - prompt: [ - `Request: ${args.request}`, - `Attempt: ${args.attempt}`, - args.instruction - ? `Supervisor instruction: ${args.instruction}` - : 'Supervisor instruction: none', - args.priorIssues.length - ? `Prior issues:\n${args.priorIssues.map((issue, index) => `${index + 1}. ${issue}`).join('\n')}` - : 'Prior issues: none', - ].join('\n'), - })); - - return createAgentMachine({ - id: 'supervisor-example', - schemas: { - input: z.object({ - request: z.string(), - }), - output: z.object({ - request: z.string(), - status: z.enum(['resolved', 'escalated']), - resolution: z.string().nullable(), - escalationReason: z.string().nullable(), - attemptCount: z.number().int().min(0), - history: z.array(z.string()), - }), - }, - context: (input) => ({ - request: input.request, - attemptCount: 0, - latestIssue: null as string | null, - resolution: null as string | null, - escalationReason: null as string | null, - history: [] as string[], - priorIssues: [] as string[], - }), - initial: ({ context }) => ({ - target: 'handling', - input: { - attempt: 1, - instruction: null, - }, - context, - }), - states: { - handling: { - schemas: { input: handlingParamsSchema, output: workerResultSchema }, - invoke: async ({ context, input }) => - handle({ - request: context.request, - attempt: input.attempt, - instruction: input.instruction ?? null, - priorIssues: context.priorIssues, - }), - onDone: ({ output, context, }) => { - const nextAttemptCount = context.attemptCount + 1; - - if (output.status === 'resolved') { - return { - target: 'done', - context: { - attemptCount: nextAttemptCount, - resolution: output.response, - history: [ - ...context.history, - `worker:${nextAttemptCount}:resolved:${output.response}`, - ], - }, - }; - } - - return { - target: 'supervising', - context: { - attemptCount: nextAttemptCount, - latestIssue: output.issue, - priorIssues: [...context.priorIssues, output.issue], - history: [ - ...context.history, - `worker:${nextAttemptCount}:blocked:${output.issue}`, - ], - }, - }; - }, - }, - supervising: { - schemas: { output: decideResultSchema(supervisorOptions) }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'openai/gpt-5.4-nano', - prompt: [ - 'You supervise a worker that may need retries or escalation.', - `Max attempts: ${maxAttempts}`, - `Completed attempts: ${context.attemptCount}`, - '', - `Request: ${context.request}`, - `Latest issue: ${context.latestIssue ?? 'none'}`, - context.history.length - ? `History:\n${context.history.map((entry, index) => `${index + 1}. ${entry}`).join('\n')}` - : 'History: none', - '', - context.attemptCount >= maxAttempts - ? 'You should normally escalate because the worker has reached the attempt limit.' - : 'Retry only if a concrete next instruction could unblock the worker.', - ].join('\n'), - options: supervisorOptions, - }), - onDone: ({ output, context }) => { - if (output.choice === 'retry') { - const instruction = - output.data.instruction - ?? 'Retry once with a more concrete plan and any available context.'; - - return { - target: 'handling', - context: { - history: [ - ...context.history, - `supervisor:retry:${instruction}`, - ], - }, - input: { - attempt: context.attemptCount + 1, - instruction, - }, - }; - } - - const reason = - output.data.reason - ?? `Escalated after ${context.attemptCount} unsuccessful attempts.`; - - return { - target: 'done', - context: { - escalationReason: reason, - history: [ - ...context.history, - `supervisor:escalate:${reason}`, - ], - }, - }; - }, - }, - done: { - type: 'final', - output: ({ context }) => ({ - request: context.request, - status: context.resolution ? ('resolved' as const) : ('escalated' as const), - resolution: context.resolution, - escalationReason: context.escalationReason, - attemptCount: context.attemptCount, - history: context.history, - }), - }, - }, - }); -} - -async function main() { - try { - const request = await prompt('Request'); - const machine = createSupervisorExample(); - console.log( - formatResult(await execute(machine, machine.getInitialState({ request }))) - ); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/tool-calling.ts b/examples/tool-calling.ts deleted file mode 100644 index f1539e2..0000000 --- a/examples/tool-calling.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { z } from 'zod'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../src/local/index.js'; -import { - createAgentMachine, -} from '../src/index.js'; -import { - closePrompt, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const forecastSchema = z.object({ - forecast: z.string(), -}); - -const toolProgressSchema = z.object({ - toolName: z.string(), - message: z.string(), - step: z.number().int().min(1), -}); - -export function createToolCallingExample( - getWeather: ( - city: string, - emitProgress: (event: z.infer) => void - ) => Promise> = async ( - city, - emitProgress - ) => { - emitProgress({ - toolName: 'getWeather', - message: `Looking up current conditions for ${city}.`, - step: 1, - }); - emitProgress({ - toolName: 'getWeather', - message: `Formatting the forecast for ${city}.`, - step: 2, - }); - - return generateExampleObject({ - schema: forecastSchema, - system: 'You generate plausible demo weather forecasts.', - prompt: `Return a short weather forecast for ${city}.`, - }); - } -) { - return createAgentMachine({ - id: 'tool-calling-example', - schemas: { - input: z.object({ city: z.string() }), - output: z.object({ forecast: z.string().nullable() }), - emitted: { - toolCall: z.object({ - toolName: z.string(), - input: z.object({ city: z.string() }), - }), - toolProgress: toolProgressSchema, - toolResult: z.object({ - toolName: z.string(), - output: forecastSchema, - }), - }, - }, - context: (input) => ({ - city: input.city, - forecast: null as string | null, - }), - initial: 'checkingWeather', - states: { - checkingWeather: { - schemas: { output: forecastSchema }, - invoke: async ({ context }, enq) => { - enq.emit({ - type: 'toolCall', - toolName: 'getWeather', - input: { city: context.city }, - }); - - const output = await getWeather(context.city, (progress) => { - enq.emit({ - type: 'toolProgress', - ...progress, - }); - }); - - enq.emit({ - type: 'toolResult', - toolName: 'getWeather', - output, - }); - - return output; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { forecast: output.forecast }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ forecast: context.forecast }), - }, - }, - }); -} - -async function main() { - try { - const city = await prompt('City'); - const machine = createToolCallingExample(); - const run = await startSession(machine, { - store: createMemoryRunStore(), - input: { city }, - }); - - run.on('toolCall', (event) => { - console.log(`Calling ${event.toolName}(${event.input.city})`); - }); - - run.on('toolProgress', (event) => { - console.log(`${event.toolName} [${event.step}] ${event.message}`); - }); - - run.on('toolResult', (event) => { - console.log(`${event.toolName} -> ${event.output.forecast}`); - }); - - const done = await waitForRunDone(run); - console.log(done.output); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/tutor.ts b/examples/tutor.ts deleted file mode 100644 index fa04ca4..0000000 --- a/examples/tutor.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - isMain, - prompt, -} from './_run.js'; - -const feedbackSchema = z.object({ - instruction: z.string(), -}); - -const responseSchema = z.object({ - response: z.string(), -}); - -export function createTutorExample( - options: { - teach?: (message: string) => Promise>; - respond?: (message: string) => Promise>; - } = {} -) { - const teach = - options.teach ?? - ((message: string) => - generateExampleObject({ - schema: feedbackSchema, - system: 'You are a Spanish tutor giving concise corrective feedback in English.', - prompt: `Give one short piece of coaching feedback for this learner message: ${message}`, - })); - const respond = - options.respond ?? - ((message: string) => - generateExampleObject({ - schema: responseSchema, - system: 'You are a friendly Spanish tutor. Reply in simple Spanish.', - prompt: `Respond to this learner message in simple Spanish and keep the conversation going: ${message}`, - })); - - return createAgentMachine({ - id: 'tutor-example', - schemas: { - input: z.object({ message: z.string() }), - output: z.object({ - conversation: z.array(z.string()), - feedback: z.string().nullable(), - response: z.string().nullable(), - }), - }, - context: (input) => ({ - conversation: [`User: ${input.message}`], - feedback: null as string | null, - response: null as string | null, - }), - initial: 'teaching', - states: { - teaching: { - schemas: { output: feedbackSchema }, - invoke: async ({ context }) => - teach(context.conversation.at(-1)?.replace(/^User:\s*/, '') ?? ''), - onDone: ({ output }) => ({ - target: 'responding', - context: { feedback: output.instruction }, - }), - }, - responding: { - schemas: { output: responseSchema }, - invoke: async ({ context }) => - respond(context.conversation.at(-1)?.replace(/^User:\s*/, '') ?? ''), - onDone: ({ output, context }) => ({ - target: 'done', - context: { - response: output.response, - conversation: [...context.conversation, `Tutor: ${output.response}`], - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - conversation: context.conversation, - feedback: context.feedback, - response: context.response, - }), - }, - }, - }); -} - -async function main() { - try { - const message = await prompt('Say something in Spanish'); - const machine = createTutorExample(); - console.log(formatResult(await execute(machine, machine.getInitialState({ message })))); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/workflow-guardrails.ts b/examples/workflow-guardrails.ts deleted file mode 100644 index c8cc3a5..0000000 --- a/examples/workflow-guardrails.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine, type AgentAdapter } from '../src/index.js'; -import { closePrompt, formatResult, isMain, prompt } from './_run.js'; - -type WorkflowTool = (input?: Record) => Promise; - -const taskInputSchema = z.object({ task: z.string().optional() }).optional(); - -const planSchema = z.object({ plan: z.string() }); -const implementationSchema = z.object({ summary: z.string() }); -const testSchema = z.object({ - passed: z.boolean(), - output: z.string().optional(), -}); -const diagnosisSchema = z.object({ diagnosis: z.string() }); -const rootCauseSchema = z.object({ rootCause: z.string() }); -const proposalSchema = z.object({ proposal: z.string() }); -const fixSchema = z.object({ applied: z.boolean(), summary: z.string() }); -const verificationSchema = z.object({ - verified: z.boolean(), - summary: z.string(), -}); - -const readOnlyCodingTools = { - Read: async () => undefined, - Grep: async () => undefined, - Glob: async () => undefined, - LS: async () => undefined, - Bash: async () => undefined, -} satisfies Record; - -const editingCodingTools = { - ...readOnlyCodingTools, - Edit: async () => undefined, - Write: async () => undefined, -} satisfies Record; - -const incidentReadTools = { - Read: async () => undefined, - Bash: async () => undefined, - Grep: async () => undefined, -} satisfies Record; - -const incidentWriteTools = { - Read: async () => undefined, - Bash: async () => undefined, -} satisfies Record; - -const allIncidentTools = { - list_services: async () => undefined, - get_service: async () => undefined, - list_volumes: async () => undefined, - get_volume: async () => undefined, - get_logs: async () => undefined, - get_env: async () => undefined, - update_env: async () => undefined, - restart_service: async () => undefined, - delete_volume: async () => undefined, - delete_service: async () => undefined, - test_connection: async () => undefined, -} satisfies Record; - -function createSequenceAdapter(results: unknown[]): AgentAdapter { - let index = 0; - - return { - async generateText() { - const result = results[index] ?? results.at(-1); - index += 1; - return result; - }, - }; -} - -export function createGuardrailedBugfixWorkflowExample(options: { - adapter?: AgentAdapter; -} = {}) { - return createAgentMachine({ - id: 'guardrailed-bugfix-workflow', - schemas: { input: taskInputSchema }, - adapter: - options.adapter - ?? createSequenceAdapter([ - { plan: 'Read the failing test, inspect the implementation, then make the smallest fix.' }, - { summary: 'Applied a targeted code change.' }, - { passed: true, output: 'All tests passed.' }, - ]), - context: (input) => ({ - task: input?.task ?? 'Fix the failing tests.', - plan: null as string | null, - changeSummary: null as string | null, - testOutput: null as string | null, - }), - messages: (input) => [ - { role: 'user', content: input?.task ?? 'Fix the failing tests.' }, - ], - initial: 'planning', - states: { - planning: { - schemas: { output: planSchema }, - prompt: - 'Read relevant files and produce a brief fix plan. Do not edit anything yet.', - tools: readOnlyCodingTools, - onDone: ({ output }) => ({ - target: 'implementing', - context: { plan: output.plan }, - }), - }, - implementing: { - schemas: { output: implementationSchema }, - prompt: ({ snapshot }) => - [ - 'Implement the fix. Make targeted, minimal edits.', - `Current state: ${snapshot.value}`, - `Plan: ${snapshot.context.plan ?? 'none'}`, - ].join('\n'), - tools: editingCodingTools, - onDone: ({ output }) => ({ - target: 'testing', - context: { changeSummary: output.summary }, - }), - }, - testing: { - schemas: { output: testSchema }, - prompt: ({ snapshot }) => - [ - 'Run the tests to verify the fix.', - `Current state: ${snapshot.value}`, - `Change summary: ${snapshot.context.changeSummary ?? 'none'}`, - ].join('\n'), - tools: { - Read: readOnlyCodingTools.Read, - Bash: readOnlyCodingTools.Bash, - }, - onDone: ({ output }) => - output.passed - ? { - target: 'completed', - context: { testOutput: output.output ?? null }, - } - : { - target: 'implementing', - context: { testOutput: output.output ?? null }, - }, - }, - completed: { - type: 'final', - output: ({ context }) => ({ - plan: context.plan, - changeSummary: context.changeSummary, - testOutput: context.testOutput, - }), - }, - }, - }); -} - -export function createGuardrailedIncidentResponseExample(options: { - adapter?: AgentAdapter; -} = {}) { - return createAgentMachine({ - id: 'guardrailed-incident-response', - schemas: { - input: taskInputSchema, - events: { - APPROVED: z.object({ type: z.literal('APPROVED') }), - REJECTED: z.object({ type: z.literal('REJECTED') }), - }, - }, - adapter: - options.adapter - ?? createSequenceAdapter([ - { diagnosis: 'The web service cannot connect to its database.' }, - { rootCause: 'The staging database credential is stale.' }, - { proposal: 'Update the staging DB password and restart the web service.' }, - { applied: true, summary: 'Updated the staging DB password and restarted the service.' }, - { verified: true, summary: 'Connection test passed and service is healthy.' }, - ]), - context: (input) => ({ - task: - input?.task - ?? 'The staging environment is down. Diagnose and repair without destructive actions.', - diagnosis: null as string | null, - rootCause: null as string | null, - proposal: null as string | null, - fixSummary: null as string | null, - verification: null as string | null, - }), - messages: (input) => [ - { - role: 'user', - content: - input?.task - ?? 'The staging environment is down. Diagnose and repair without destructive actions.', - }, - ], - initial: 'diagnosing', - states: { - diagnosing: { - schemas: { output: diagnosisSchema }, - prompt: - 'Check service status and logs. Identify the likely failure. Do not modify anything.', - tools: { - ...incidentReadTools, - list_services: allIncidentTools.list_services, - get_service: allIncidentTools.get_service, - get_logs: allIncidentTools.get_logs, - get_volume: allIncidentTools.get_volume, - list_volumes: allIncidentTools.list_volumes, - }, - onDone: ({ output }) => ({ - target: 'investigating', - context: { diagnosis: output.diagnosis }, - }), - }, - investigating: { - schemas: { output: rootCauseSchema }, - prompt: - 'Investigate the root cause. Check environment variables, test connections, and read logs. Still read-only.', - tools: { - ...incidentReadTools, - get_env: allIncidentTools.get_env, - test_connection: allIncidentTools.test_connection, - get_logs: allIncidentTools.get_logs, - }, - onDone: ({ output }) => ({ - target: 'proposing', - context: { rootCause: output.rootCause }, - }), - }, - proposing: { - schemas: { output: proposalSchema }, - prompt: - 'Propose the fix. Describe exactly what should change and why. Do not execute the fix yet.', - tools: { Read: incidentReadTools.Read }, - onDone: ({ output }) => ({ - target: 'awaitingApproval', - context: { proposal: output.proposal }, - }), - }, - awaitingApproval: { - prompt: ({ snapshot }) => - [ - `Await approval while in ${snapshot.value}.`, - snapshot.context.proposal ?? '', - ].join('\n'), - tools: { Read: incidentReadTools.Read }, - on: { - APPROVED: { target: 'executingFix' }, - REJECTED: { target: 'proposing' }, - }, - }, - executingFix: { - schemas: { output: fixSchema }, - prompt: - 'Execute the approved fix. API actions allowed: update_env, restart_service. Do not delete volumes or services.', - tools: { - ...incidentWriteTools, - update_env: allIncidentTools.update_env, - restart_service: allIncidentTools.restart_service, - }, - onDone: ({ output }) => ({ - target: 'verifying', - context: { fixSummary: output.summary }, - }), - }, - verifying: { - schemas: { output: verificationSchema }, - prompt: - 'Verify the fix. Test the connection, check service status, and review logs.', - tools: { - ...incidentWriteTools, - test_connection: allIncidentTools.test_connection, - get_service: allIncidentTools.get_service, - get_logs: allIncidentTools.get_logs, - }, - onDone: ({ output }) => - output.verified - ? { - target: 'completed', - context: { verification: output.summary }, - } - : { - target: 'proposing', - context: { verification: output.summary }, - }, - }, - completed: { - type: 'final', - output: ({ context }) => ({ - diagnosis: context.diagnosis, - rootCause: context.rootCause, - proposal: context.proposal, - fixSummary: context.fixSummary, - verification: context.verification, - }), - }, - }, - }); -} - -export function createUnguardedIncidentResponseExample(options: { - adapter?: AgentAdapter; -} = {}) { - return createAgentMachine({ - id: 'unguarded-incident-response', - schemas: { input: taskInputSchema }, - adapter: - options.adapter - ?? createSequenceAdapter([ - { applied: true, summary: 'Used whatever API actions were available to repair the service.' }, - ]), - context: (input) => ({ - task: - input?.task - ?? 'The staging environment is down. Fix it with all API actions available.', - fixSummary: null as string | null, - }), - messages: (input) => [ - { - role: 'user', - content: - input?.task - ?? 'The staging environment is down. Fix it with all API actions available.', - }, - ], - initial: 'working', - states: { - working: { - schemas: { output: fixSchema }, - prompt: 'Fix the staging environment issue. All tools and API actions are available.', - tools: { - ...incidentReadTools, - ...allIncidentTools, - }, - onDone: ({ output }) => ({ - target: 'completed', - context: { fixSummary: output.summary }, - }), - }, - completed: { - type: 'final', - output: ({ context }) => ({ fixSummary: context.fixSummary }), - }, - }, - }); -} - -async function main() { - try { - const task = await prompt('Task'); - const machine = createGuardrailedBugfixWorkflowExample(); - const result = await execute(machine, machine.getInitialState({ task })); - - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/examples/write-a-book-flow.ts b/examples/write-a-book-flow.ts deleted file mode 100644 index a1a8daa..0000000 --- a/examples/write-a-book-flow.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { z } from 'zod'; -import { execute } from '../src/local/index.js'; -import { createAgentMachine } from '../src/index.js'; -import { - closePrompt, - formatResult, - generateExampleObject, - generateExampleText, - isMain, - prompt, -} from './_run.js'; - -const chapterOutlineSchema = z.object({ - title: z.string(), - brief: z.string(), -}); - -const outlineSchema = z.object({ - title: z.string(), - chapters: z.array(chapterOutlineSchema), -}); - -const chapterSchema = z.object({ - title: z.string(), - content: z.string(), -}); - -const chapterBatchSchema = z.object({ - chapters: z.array(chapterSchema), -}); - -const manuscriptSchema = z.object({ - manuscript: z.string(), -}); - -type ChapterOutline = z.infer; - -export function createWriteABookFlowExample(options: { - createOutline?: (args: { - topic: string; - goal: string; - }) => Promise>; - writeChapter?: (args: { - title: string; - brief: string; - goal: string; - topic: string; - }) => Promise>; - compileManuscript?: (args: { - title: string; - chapters: z.infer[]; - }) => Promise>; -} = {}) { - const createOutline = - options.createOutline ?? - ((args: { topic: string; goal: string }) => - generateExampleObject({ - schema: outlineSchema, - system: 'Create a concise non-fiction book outline.', - prompt: [`Topic: ${args.topic}`, `Goal: ${args.goal}`].join('\n'), - })); - - const writeChapter = - options.writeChapter ?? - ((args: { - title: string; - brief: string; - goal: string; - topic: string; - }) => - generateExampleObject({ - schema: chapterSchema, - system: 'Write a concise but coherent book chapter.', - prompt: [ - `Book topic: ${args.topic}`, - `Book goal: ${args.goal}`, - `Chapter title: ${args.title}`, - `Chapter brief: ${args.brief}`, - ].join('\n'), - })); - - const compileManuscript = - options.compileManuscript ?? - ((args: { title: string; chapters: z.infer[] }) => - generateExampleObject({ - schema: manuscriptSchema, - system: 'Compile chapters into a single clean markdown manuscript.', - prompt: [ - `Title: ${args.title}`, - '', - ...args.chapters.map( - (chapter) => `## ${chapter.title}\n\n${chapter.content}` - ), - ].join('\n'), - })); - - return createAgentMachine({ - id: 'write-a-book-flow-example', - schemas: { - input: z.object({ - topic: z.string(), - goal: z.string(), - }), - output: z.object({ - title: z.string().nullable(), - outline: z.array(chapterOutlineSchema), - chapters: z.array(chapterSchema), - manuscript: z.string().nullable(), - }), - }, - context: (input) => ({ - topic: input.topic, - goal: input.goal, - title: null as string | null, - outline: [] as ChapterOutline[], - chapters: [] as z.infer[], - manuscript: null as string | null, - }), - initial: 'outlining', - states: { - outlining: { - schemas: { output: outlineSchema }, - invoke: async ({ context }) => - createOutline({ - topic: context.topic, - goal: context.goal, - }), - onDone: ({ output }) => ({ - target: 'writing', - context: { - title: output.title, - outline: output.chapters, - }, - }), - }, - writing: { - schemas: { output: chapterBatchSchema }, - invoke: async ({ context }) => { - const chapters = await Promise.all( - context.outline.map((chapter) => - writeChapter({ - title: chapter.title, - brief: chapter.brief, - goal: context.goal, - topic: context.topic, - }) - ) - ); - - return { chapters }; - }, - onDone: ({ output }) => ({ - target: 'compiling', - context: { - chapters: output.chapters, - }, - }), - }, - compiling: { - schemas: { output: manuscriptSchema }, - invoke: async ({ context }) => - compileManuscript({ - title: context.title ?? 'Untitled Book', - chapters: context.chapters, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { - manuscript: output.manuscript, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - title: context.title, - outline: context.outline, - chapters: context.chapters, - manuscript: context.manuscript, - }), - }, - }, - }); -} - -async function main() { - try { - const topic = await prompt('Book topic'); - const goal = await prompt('Book goal'); - const machine = createWriteABookFlowExample(); - const result = await execute(machine, machine.getInitialState({ topic, goal })); - console.log(formatResult(result)); - } finally { - closePrompt(); - } -} - -if (isMain(import.meta.url)) { - void main(); -} diff --git a/package.json b/package.json index b1a6152..7aa1ad5 100644 --- a/package.json +++ b/package.json @@ -37,16 +37,6 @@ "default": "./dist/graph.cjs" } }, - "./local": { - "import": { - "types": "./dist/local.d.mts", - "default": "./dist/local.mjs" - }, - "require": { - "types": "./dist/local.d.cts", - "default": "./dist/local.cjs" - } - }, "./xstate": { "import": { "types": "./dist/xstate.d.mts", @@ -114,5 +104,10 @@ "typescript": "^5.6.2", "xstate": "^5.26.0" }, - "packageManager": "pnpm@10.28.2" + "packageManager": "pnpm@10.28.2", + "pnpm": { + "patchedDependencies": { + "xstate@5.26.0": "patches/xstate@5.26.0.patch" + } + } } diff --git a/patches/xstate@5.26.0.patch b/patches/xstate@5.26.0.patch new file mode 100644 index 0000000..4606590 --- /dev/null +++ b/patches/xstate@5.26.0.patch @@ -0,0 +1,96 @@ +diff --git a/dist/declarations/src/types.d.ts b/dist/declarations/src/types.d.ts +index 14556e1b169e71985b5a2c22f238af0a6545b0f4..074f17fc6198b04a122da39d39190c281ea2d16c 100644 +--- a/dist/declarations/src/types.d.ts ++++ b/dist/declarations/src/types.d.ts +@@ -255,7 +255,7 @@ export type InvokeConfig>; + }; + export type AnyInvokeConfig = InvokeConfig; +-export interface StateNodeConfig { ++export interface StateNodeConfig { + /** The initial state transition. */ + initial?: InitialTransitionConfig | string | undefined; + /** +@@ -321,7 +321,7 @@ export interface StateNodeConfig | NonReducibleUnknown; ++ output?: Mapper | ([unknown] extends [TOutput] ? NonReducibleUnknown : TOutput); + /** + * The unique ID of the state node, which can be referenced as a transition + * target via the `#id` syntax. +@@ -448,7 +448,8 @@ export type ContextFactory, TEvent, AnyEventObject>; + }) => TContext; +-export type MachineConfig = (Omit, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer>, 'output'> & { ++export type MachineConfig = (Omit, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer>, 'output' | 'states'> & { ++ states?: StatesConfig, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer, DoNotInfer> | undefined; + /** The initial context (extended state) */ + /** The machine's own version. */ + version?: string; +diff --git a/dist/raise-13e2f823.development.esm.js b/dist/raise-13e2f823.development.esm.js +index 3ce2c47b3310880c8f12b14c5a225f934d8d21c9..43895003a16b0723e86cc08bc5de0379f96a69fb 100644 +--- a/dist/raise-13e2f823.development.esm.js ++++ b/dist/raise-13e2f823.development.esm.js +@@ -2163,7 +2163,10 @@ function microstep(transitions, currentSnapshot, actorScope, event, isInitial, i + } + function getMachineOutput(snapshot, event, actorScope, rootNode, rootCompletionNode) { + if (rootNode.output === undefined) { +- return; ++ if (rootCompletionNode.output === undefined || !rootCompletionNode.parent) { ++ return; ++ } ++ return resolveOutput(rootCompletionNode.output, snapshot.context, event, actorScope.self); + } + const doneStateEvent = createDoneStateEvent(rootCompletionNode.id, rootCompletionNode.output !== undefined && rootCompletionNode.parent ? resolveOutput(rootCompletionNode.output, snapshot.context, event, actorScope.self) : undefined); + return resolveOutput(rootNode.output, snapshot.context, doneStateEvent, actorScope.self); +diff --git a/dist/raise-df325116.cjs.js b/dist/raise-df325116.cjs.js +index c0e38f1527010a93987872f8105bde9b8f394eb5..26d997093102fffb408228a27baff38cb35add89 100644 +--- a/dist/raise-df325116.cjs.js ++++ b/dist/raise-df325116.cjs.js +@@ -2118,7 +2118,10 @@ function microstep(transitions, currentSnapshot, actorScope, event, isInitial, i + } + function getMachineOutput(snapshot, event, actorScope, rootNode, rootCompletionNode) { + if (rootNode.output === undefined) { +- return; ++ if (rootCompletionNode.output === undefined || !rootCompletionNode.parent) { ++ return; ++ } ++ return resolveOutput(rootCompletionNode.output, snapshot.context, event, actorScope.self); + } + const doneStateEvent = createDoneStateEvent(rootCompletionNode.id, rootCompletionNode.output !== undefined && rootCompletionNode.parent ? resolveOutput(rootCompletionNode.output, snapshot.context, event, actorScope.self) : undefined); + return resolveOutput(rootNode.output, snapshot.context, doneStateEvent, actorScope.self); +diff --git a/dist/raise-e47e3273.development.cjs.js b/dist/raise-e47e3273.development.cjs.js +index 0d7ee686d439ada49357f9fc0b723d7b5ff4689a..540212f4ee0eb3b1a966fb1f13df183175315f28 100644 +--- a/dist/raise-e47e3273.development.cjs.js ++++ b/dist/raise-e47e3273.development.cjs.js +@@ -2165,7 +2165,10 @@ function microstep(transitions, currentSnapshot, actorScope, event, isInitial, i + } + function getMachineOutput(snapshot, event, actorScope, rootNode, rootCompletionNode) { + if (rootNode.output === undefined) { +- return; ++ if (rootCompletionNode.output === undefined || !rootCompletionNode.parent) { ++ return; ++ } ++ return resolveOutput(rootCompletionNode.output, snapshot.context, event, actorScope.self); + } + const doneStateEvent = createDoneStateEvent(rootCompletionNode.id, rootCompletionNode.output !== undefined && rootCompletionNode.parent ? resolveOutput(rootCompletionNode.output, snapshot.context, event, actorScope.self) : undefined); + return resolveOutput(rootNode.output, snapshot.context, doneStateEvent, actorScope.self); +diff --git a/dist/raise-f11495d1.esm.js b/dist/raise-f11495d1.esm.js +index 393ecd91953f78588dda4a37c33f32cdb4537f42..afff6136b8ab4955847434a6151d44c45edbc6a2 100644 +--- a/dist/raise-f11495d1.esm.js ++++ b/dist/raise-f11495d1.esm.js +@@ -2116,7 +2116,10 @@ function microstep(transitions, currentSnapshot, actorScope, event, isInitial, i + } + function getMachineOutput(snapshot, event, actorScope, rootNode, rootCompletionNode) { + if (rootNode.output === undefined) { +- return; ++ if (rootCompletionNode.output === undefined || !rootCompletionNode.parent) { ++ return; ++ } ++ return resolveOutput(rootCompletionNode.output, snapshot.context, event, actorScope.self); + } + const doneStateEvent = createDoneStateEvent(rootCompletionNode.id, rootCompletionNode.output !== undefined && rootCompletionNode.parent ? resolveOutput(rootCompletionNode.output, snapshot.context, event, actorScope.self) : undefined); + return resolveOutput(rootNode.output, snapshot.context, doneStateEvent, actorScope.self); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4053207..36b1a5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + xstate@5.26.0: + hash: a7dcd6833c73b6d6b3c75e26048ed0559d76cd9dda7ccdf0bb4d74a03db6fb01 + path: patches/xstate@5.26.0.patch + importers: .: @@ -19,7 +24,7 @@ importers: version: 5.9.3 xstate: specifier: ^5.26.0 - version: 5.26.0 + version: 5.26.0(patch_hash=a7dcd6833c73b6d6b3c75e26048ed0559d76cd9dda7ccdf0bb4d74a03db6fb01) devDependencies: '@ai-sdk/openai': specifier: ^3.0.25 @@ -4086,7 +4091,7 @@ snapshots: wrappy@1.0.2: {} - xstate@5.26.0: {} + xstate@5.26.0(patch_hash=a7dcd6833c73b6d6b3c75e26048ed0559d76cd9dda7ccdf0bb4d74a03db6fb01): {} y18n@5.0.8: {} diff --git a/readme.md b/readme.md index 15f93fe..83574e3 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,99 @@ Stately Agent is the state machine authoring layer for AI agents. Author your AI agents as state machines. Run them anywhere. -The package owns the machine design surface: states, transitions, typed events, messages, generative state schemas, always transitions, and runtime contracts that adapters can implement. +The package owns one first-class authoring primitive: + +- `setupAgent(...).withTasks(...)`: schema-first, SDK-agnostic agent task authoring. + +Use `setupAgent(...)` for schema-first control flow. Use normal host code for runtime execution. Stately Agent adds the batteries: reusable text logic, message helpers, examples, retained schemas, and visualization/export affordances. + +You can still call the Vercel AI SDK, LangChain, Workers AI, or any other model/tool runtime yourself. The machine only declares behavior; hosts can either execute effects from pure XState transitions or provide actors with `machine.provide({ actors })`. That keeps runtime transparency while making the workflow typed, inspectable, and visualizable. + +Choose this over LangGraph when you want agent workflows to be explicit state machines instead of framework-owned graphs: same workflow shapes, strong TypeScript for machine context/events/actors, first-class XState snapshots/guards, visualization by default, and no required runtime backend. Choose it over handrolled workflows when the control flow is important enough to inspect, persist, replay, test, and diagram. + +For SDK integration, define named tasks with `setupAgent({ schemas }).withTasks(...)`. The machine declares `invoke: { src: 'getSummary', input, onDone }`; your host reads that task and calls Vercel AI SDK, Cloudflare Workers AI, LangChain, local models, or custom code. Source ids, invoke input, `event.output`, and machine schemas are typed from the registered tasks and schemas. See [`docs/host-actors.md`](/Users/davidkpiano/Code/agent/docs/host-actors.md). + +## Agent Machines + + + +Import `createAgentSchemas(...)` and `setupAgent(...)` from `@statelyai/agent`: + +```ts +import { + createAgentSchemas, + getAgentEffects, + setupAgent, + transitionResult, +} from '@statelyai/agent'; +import { assign, initialTransition } from 'xstate'; +import { z } from 'zod'; + +const contextSchema = z.object({ + prompt: z.string(), + answer: z.string().nullable(), +}); +const inputSchema = z.object({ prompt: z.string() }); +const answerSchema = z.object({ answer: z.string() }); + +const schemas = createAgentSchemas({ + context: contextSchema, + input: inputSchema, + output: answerSchema, +}); + +const agent = setupAgent({ schemas }).withTasks({ + getAnswer: { + schemas: { + input: z.object({ prompt: z.string() }), + output: answerSchema, + }, + model: 'writer', + prompt: ({ input }) => input.prompt, + }, +}); + +const machine = agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt, answer: null }), + initial: 'answering', + states: { + answering: { + invoke: { + id: 'answer', + src: 'getAnswer', + input: ({ context }) => ({ prompt: context.prompt }), + onDone: { + target: 'done', + actions: assign({ + answer: ({ event }) => event.output.answer, + }), + }, + }, + }, + done: { type: 'final' }, + }, +}); + +let [snapshot, actions] = initialTransition(machine, { prompt: 'Why XState?' }); + +while (snapshot.status !== 'done') { + for (const effect of getAgentEffects(actions, { + snapshot, + schemas: agent.schemas, + actors: { getAnswer }, + })) { + const result = await generateText({ + ...effect.input, + tools: effect.tools, + }); // any SDK/framework + [snapshot, actions] = transitionResult(machine, snapshot, effect, result); + } +} +``` + +This is normal XState underneath: use pure `initialTransition(...)` / `transitionResult(...)`, or use `createActor(...)`, snapshots, persistence, guards, actions, and host-provided actors. `setupAgent(...)` adds schema-derived concrete types and retained schemas; `withTasks(...)` adds reusable typed task construction, strongly typed source names, typed invoke input, typed `event.output`, and `getAgentEffects(...)` extraction. + +When a task declares `events`, `getAgentEffects(...)` returns `event.` tools for those events only if they are currently legal from the snapshot. That lets a model choose legal machine events, such as moves in a game, without exposing every transition. ## Examples @@ -10,7 +102,7 @@ The package owns the machine design surface: states, transitions, typed events, The examples in [`examples/`](/Users/davidkpiano/Code/agent/examples) are intentionally small. Most run in the CLI and use real OpenAI calls when `OPENAI_API_KEY` is set. Runtime-specific examples call out extra environment requirements inline. -If you want examples grouped by intent instead of a flat list, start with [`examples/README.md`](/Users/davidkpiano/Code/agent/examples/README.md). It separates app-shaped examples, state-machine workflow examples, local/session examples, and lower-level reference examples. +If you want examples grouped by intent instead of a flat list, start with [`examples/README.md`](/Users/davidkpiano/Code/agent/examples/README.md). It separates XState authoring, host adapters, app integrations, and parity coverage. Run them with `node --import tsx examples/.ts`. @@ -18,48 +110,20 @@ Convert a machine file to diagram output with `pnpm agent:convert --forma Start here: -- App-shaped integrations: [`examples/apps/next/`](/Users/davidkpiano/Code/agent/examples/apps/next), [`examples/apps/cloudflare-agents/`](/Users/davidkpiano/Code/agent/examples/apps/cloudflare-agents), [`examples/next-ai-sdk-ui.ts`](/Users/davidkpiano/Code/agent/examples/next-ai-sdk-ui.ts), [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts) -- Local sessions and transports: [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts), [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts), [`examples/http-streaming-session.ts`](/Users/davidkpiano/Code/agent/examples/http-streaming-session.ts) -- State-machine workflow patterns: [`examples/rag.ts`](/Users/davidkpiano/Code/agent/examples/rag.ts), [`examples/tool-calling.ts`](/Users/davidkpiano/Code/agent/examples/tool-calling.ts), [`examples/error-retry.ts`](/Users/davidkpiano/Code/agent/examples/error-retry.ts), [`examples/spec-agent-loop.ts`](/Users/davidkpiano/Code/agent/examples/spec-agent-loop.ts), [`examples/persistent-supervisor.ts`](/Users/davidkpiano/Code/agent/examples/persistent-supervisor.ts) -- CrewAI-style equivalents: [`examples/content-creator-flow.ts`](/Users/davidkpiano/Code/agent/examples/content-creator-flow.ts), [`examples/email-auto-responder-flow.ts`](/Users/davidkpiano/Code/agent/examples/email-auto-responder-flow.ts), [`examples/lead-score-flow.ts`](/Users/davidkpiano/Code/agent/examples/lead-score-flow.ts), [`examples/meeting-assistant-flow.ts`](/Users/davidkpiano/Code/agent/examples/meeting-assistant-flow.ts), [`examples/self-evaluation-loop-flow.ts`](/Users/davidkpiano/Code/agent/examples/self-evaluation-loop-flow.ts), [`examples/write-a-book-flow.ts`](/Users/davidkpiano/Code/agent/examples/write-a-book-flow.ts) -- Reference examples: [`examples/simple.ts`](/Users/davidkpiano/Code/agent/examples/simple.ts), [`examples/decide.ts`](/Users/davidkpiano/Code/agent/examples/decide.ts), [`examples/classify.ts`](/Users/davidkpiano/Code/agent/examples/classify.ts), [`examples/adapter.ts`](/Users/davidkpiano/Code/agent/examples/adapter.ts), [`examples/email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/email-drafter.ts), [`examples/workflow-guardrails.ts`](/Users/davidkpiano/Code/agent/examples/workflow-guardrails.ts) +- Agent authoring: [`examples/setup-agent/email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/email-drafter.ts) +- Host adapters: [`examples/setup-agent/hosts/ai-sdk.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/ai-sdk.ts), [`examples/setup-agent/hosts/cloudflare-agent.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/cloudflare-agent.ts) +- Local smoke test: [`examples/setup-agent/smoke.mts`](/Users/davidkpiano/Code/agent/examples/setup-agent/smoke.mts) +- LangGraph parity: [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts), [`docs/langgraph-parity.md`](/Users/davidkpiano/Code/agent/docs/langgraph-parity.md), [`docs/langgraph-gaps.md`](/Users/davidkpiano/Code/agent/docs/langgraph-gaps.md) +- Burr parity: [`src/burr-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/burr-equivalents/raw-xstate.test.ts), [`docs/burr-parity.md`](/Users/davidkpiano/Code/agent/docs/burr-parity.md) Use `classify(...)` when the result is just "what kind of thing is this?" Use `decide(...)` when the result is "what should happen next?" and the chosen branch may need structured data. CrewAI Flow parity is tracked in [`docs/crewai-parity.md`](/Users/davidkpiano/Code/agent/docs/crewai-parity.md), the same way LangGraph parity is tracked separately. -## Local Adapter +Burr parity is tracked in [`docs/burr-parity.md`](/Users/davidkpiano/Code/agent/docs/burr-parity.md), focused on action-like authoring patterns without adopting Burr's runtime. - +## Runtime -Use `@statelyai/agent/local` for local development, tests, and in-process examples: - -- `execute(machine, state)`: run the local interpreter until done, pending, or error -- `invoke(machine, state)`: run one local interpreter step -- `stream(machine, state)`: yield local interpreter snapshots -- `startSession(machine, options)`: start a local session backed by a `RunStore` -- `restoreSession(machine, options)`: restore a local session from a `RunStore` -- `waitForRunDone(run)`: await terminal success or reject on session error -- `waitForRunSnapshot(run, predicate)`: await the next snapshot that matches a predicate - -Production runtimes should consume the session contract or use framework-specific adapter packages such as `@statelyai/agent-cloudflare` when those packages exist. - -## Persistence Adapters - - - -Runtime adapters are intentionally bring-your-own. Implement the `RunStore` contract with four methods: - -- `append(sessionId, event)` -- `loadEvents(sessionId, afterSequence?)` -- `loadLatestSnapshot(sessionId)` -- `saveSnapshot(snapshot)` - -Use these examples as templates for your storage layer: - -- [`examples/persistence.ts`](/Users/davidkpiano/Code/agent/examples/persistence.ts): the smallest local session flow with an in-memory store -- [`examples/http-session.ts`](/Users/davidkpiano/Code/agent/examples/http-session.ts): the Request/Response transport shape around the local adapter -- [`examples/cloudflare-durable-object.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-durable-object.ts): preview code for a future Cloudflare adapter package -- [`examples/cloudflare-agents.ts`](/Users/davidkpiano/Code/agent/examples/cloudflare-agents.ts): preview code for syncing a `RunStore` into Cloudflare Agents state +Runtime is normal XState. Use pure `initialTransition(...)` / `transitionResult(...)` when a framework wants to own execution, or use `createActor(...)`, `toPromise(...)`, snapshots, persisted snapshots, `machine.provide({ actors })`, and your framework transport of choice. Model/tool execution stays under your control. **Read the documentation: [stately.ai/docs/agents](https://stately.ai/docs/agents)** diff --git a/scripts/agent-convert.ts b/scripts/agent-convert.ts index a9181c3..85d3898 100644 --- a/scripts/agent-convert.ts +++ b/scripts/agent-convert.ts @@ -1,8 +1,12 @@ import { writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { analyzeGraph, toMermaid, type AgentGraphWarning } from '../src/graph/index.js'; -import type { AgentMachine } from '../src/index.js'; +import { + analyzeGraph, + toMermaid, + type AgentGraphWarning, + type XStateLikeMachine, +} from '../src/graph/index.js'; import { toXStateVisualization } from '../src/xstate/index.js'; type Format = 'mermaid' | 'xstate'; @@ -142,7 +146,7 @@ function requiredValue(args: string[], index: number, option: string): string { return value; } -async function loadMachine(options: CliOptions): Promise { +async function loadMachine(options: CliOptions): Promise { const fileUrl = pathToFileURL(resolve(options.file!)).href; const mod = await import(fileUrl) as Record; @@ -153,53 +157,53 @@ async function loadMachine(options: CliOptions): Promise { } const machine = await factory(); - return assertAgentMachine(machine, `factory '${options.factoryName}'`); + return assertXStateMachine(machine, `factory '${options.factoryName}'`); } if (options.exportName) { - return assertAgentMachine( + return assertXStateMachine( mod[options.exportName], `export '${options.exportName}'` ); } for (const candidate of [mod.default, mod.machine]) { - if (isAgentMachine(candidate)) { + if (isXStateMachine(candidate)) { return candidate; } } const namedMachines = Object.entries(mod).filter(([, value]) => - isAgentMachine(value) + isXStateMachine(value) ); if (namedMachines.length === 1) { - return namedMachines[0]![1] as AgentMachine; + return namedMachines[0]![1] as XStateLikeMachine; } throw new Error( [ - 'Could not find an agent machine export.', + 'Could not find an XState machine export.', 'Export a machine as default or named `machine`, or pass `--export `.', 'For zero-arg factory exports, pass `--factory `.', ].join(' ') ); } -function assertAgentMachine(value: unknown, label: string): AgentMachine { - if (!isAgentMachine(value)) { - throw new Error(`${label} did not return an agent machine.`); +function assertXStateMachine(value: unknown, label: string): XStateLikeMachine { + if (!isXStateMachine(value)) { + throw new Error(`${label} did not return an XState machine.`); } return value; } -function isAgentMachine(value: unknown): value is AgentMachine { +function isXStateMachine(value: unknown): value is XStateLikeMachine { return ( !!value && typeof value === 'object' - && typeof (value as AgentMachine).id === 'string' - && typeof (value as AgentMachine).getInitialState === 'function' - && typeof (value as AgentMachine).transition === 'function' + && typeof (value as XStateLikeMachine).id === 'string' + && !!(value as XStateLikeMachine).config + && typeof (value as XStateLikeMachine).config === 'object' ); } @@ -215,8 +219,8 @@ Options: -h, --help Show this help. Examples: - pnpm agent:convert ./examples/simple.ts --factory createSimpleExample - pnpm agent:convert ./examples/simple.ts --factory createSimpleExample --format xstate + pnpm agent:convert ./examples/setup-agent/email-drafter.ts --export emailDrafter + pnpm agent:convert ./examples/setup-agent/email-drafter.ts --export emailDrafter --format xstate `); } diff --git a/src/agent-convert-cli.test.ts b/src/agent-convert-cli.test.ts index a048738..3dfb689 100644 --- a/src/agent-convert-cli.test.ts +++ b/src/agent-convert-cli.test.ts @@ -15,8 +15,8 @@ test('agent:convert writes Mermaid and XState output from machine files', async await runConvert([fixture, '--format', 'mermaid', '--out', mermaidFile]); await expect(readFile(mermaidFile, 'utf8')).resolves.toBe(`stateDiagram-v2 [*] --> idle - idle --> done : submit [event.ok] - idle --> rejected : submit [!(event.ok)] + idle --> done : submit + idle --> rejected : submit rejected --> [*] done --> [*]`); @@ -37,14 +37,6 @@ test('agent:convert writes Mermaid and XState output from machine files', async }; expect(namedXState.id).toBe('named-converter-machine'); expect(namedXState.initial).toBe('idle'); - expect(namedXState).toMatchObject({ - meta: { - agent: { - format: '@statelyai/agent/xstate-visualization', - runnable: false, - }, - }, - }); expect(Object.keys(namedXState.states)).toEqual(['idle', 'rejected', 'done']); const factoryXStateFile = join(tmp, 'factory.json'); @@ -72,9 +64,7 @@ test('agent:convert writes Mermaid and XState output from machine files', async '--out', warningFile, ]); - expect(warningResult.stderr).toContain( - '[agent:convert] idle on go: Unsupported helper call: unknownTransition() is not statically resolvable.' - ); + expect(warningResult.stderr).toBe(''); }, 20000); async function runConvert(args: string[]) { diff --git a/src/agent.test.ts b/src/agent.test.ts deleted file mode 100644 index 1da6500..0000000 --- a/src/agent.test.ts +++ /dev/null @@ -1,1881 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from './local/index.js'; -import { z } from 'zod'; -import { - classify, - classifyResultSchema, - createAgentMachine, - createAdapter, - decide, - decideResultSchema, -} from './index.js'; -import type { DecideAdapter } from './types.js'; - -// ─── Test helpers ─── - -function mockAdapter( - responses: Array<{ - choice: string; - data?: Record; - reasoning?: string; - }> -): DecideAdapter { - let index = 0; - return { - decide: async () => { - const response = responses[index++]; - if (!response) throw new Error('No more mock responses'); - return { - choice: response.choice, - data: response.data ?? {}, - reasoning: response.reasoning, - }; - }, - }; -} - -const choiceResultSchema = z.object({ - choice: z.string(), - data: z.record(z.string(), z.unknown()), - reasoning: z.string().optional(), -}); - -// ─── Simple machine (no schemas — inferred from context) ─── - -function createSimpleMachine() { - return createAgentMachine({ - id: 'simple', - context: () => ({ count: 0 }), - initial: 'idle', - states: { - idle: { - on: { - start: ({ target: 'running' }), - }, - }, - running: { - schemas: { output: z.object({ value: z.number() }) }, - invoke: async ({ context }) => { - // context.count is typed as number ✓ - return { value: context.count + 1 }; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { count: output.value }, - }), - }, - done: { - type: 'final', - // is the machine output inferred? should we have top-level outputSchema? - output: ({ context }) => ({ result: context.count }), - }, - }, - }); -} - -// ─── HITL machine (with schemas) ─── - -function createHitlMachine() { - return createAgentMachine({ - id: 'hitl', - schemas: { - input: z.object({ task: z.string() }), - events: { - 'user.message': z.object({ message: z.string() }), - 'user.approve': z.object({}), - 'user.cancel': z.object({}), - }, - }, - context: (input) => ({ - task: input.task, - messages: [] as Array<{ role: string; content: string }>, - result: null as string | null, - }), - initial: 'gathering', - states: { - gathering: { - on: { - // events are now typed from schemas.events - 'user.message': ({ event, context }) => ({ - context: { - messages: [ - ...context.messages, - { role: 'user', content: event.message }, - ], - }, - }), - // static shorthand — string target - 'user.approve': { target: 'processing' }, - 'user.cancel': { target: 'cancelled' }, - }, - }, - processing: { - schemas: { output: z.object({ output: z.string() }) }, - invoke: async ({ context }) => { - // context.messages is typed ✓ - return { - output: `Processed: ${context.messages.map((m) => m.content).join(', ')}`, - }; - }, - onDone: ({ output }) => ({ - target: 'reviewing', - context: { result: output.output }, - }), - }, - reviewing: { - on: { - // static shorthand — object target - 'user.approve': { target: 'done' }, - 'user.message': ({ event, context }) => ({ - target: 'processing', - context: { - messages: [ - ...context.messages, - { role: 'user', content: event.message }, - ], - }, - }), - 'user.cancel': { target: 'cancelled' }, - }, - }, - done: { - type: 'final', - output: ({ context }) => ({ result: context.result }), - }, - cancelled: { - type: 'final', - output: () => ({ cancelled: true }), - }, - }, - }); -} - -// ─── Decide machine ─── - -function createDecideMachine(adapter: DecideAdapter) { - const options = { - billing: { description: 'Billing issues' }, - technical: { description: 'Technical issues' }, - general: { description: 'General inquiries' }, - } as const; - - return createAgentMachine({ - id: 'decider', - context: () => ({ - issue: 'App crashes on login', - category: null as string | null, - resolution: null as string | null, - }), - initial: 'classifying', - states: { - classifying: { - schemas: { output: decideResultSchema(options) }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'test-model', - prompt: `Classify: ${context.issue}`, - options, - }), - onDone: ({ output }) => ({ - target: 'handling', - context: { category: output.choice }, - }), - }, - handling: { - schemas: { output: z.object({ resolution: z.string() }) }, - invoke: async ({ context }) => ({ - resolution: `Handled ${context.category} issue`, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { resolution: output.resolution }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - category: context.category, - resolution: context.resolution, - }), - }, - }, - }); -} - -// ─── Classify machine ─── - -function createClassifyMachine(adapter: DecideAdapter) { - const categories = { - billing: { description: 'Billing, payments, refunds' }, - technical: { description: 'Technical issues, bugs' }, - general: { description: 'General inquiries' }, - } as const; - - return createAgentMachine({ - id: 'classifier', - context: () => ({ - issue: 'I want my money back', - category: null as string | null, - }), - initial: 'classifyIntent', - states: { - classifyIntent: { - schemas: { output: classifyResultSchema(categories) }, - invoke: async ({ context }) => - classify({ - adapter, - model: 'test-model', - prompt: `Classify: "${context.issue}"`, - into: categories, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { category: output.category }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ category: context.category }), - }, - }, - }); -} - - -// ═══════════════════════════════════════ -// Tests -// ═══════════════════════════════════════ - -describe('createAgentMachine', () => { - test('returns machine with typed methods', () => { - const machine = createSimpleMachine(); - expect(machine.id).toBe('simple'); - expect(typeof machine.getInitialState).toBe('function'); - expect(typeof machine.transition).toBe('function'); - expect(typeof invoke).toBe('function'); - expect(typeof execute).toBe('function'); - expect(typeof stream).toBe('function'); - expect(typeof machine.resolveState).toBe('function'); - expect(typeof machine.getEvents).toBe('function'); - }); -}); - -describe('getEvents', () => { - test('reads available events from states and snapshots', () => { - const machine = createAgentMachine({ - id: 'events', - schemas: { - events: { - start: z.object({}), - ignored: z.object({}), - }, - }, - context: () => ({}), - initial: 'idle', - states: { - idle: { - on: { - start: { target: 'done' }, - }, - }, - done: { - type: 'final', - }, - }, - }); - const state = machine.getInitialState(); - - expect(Object.keys(machine.getEvents(state))).toEqual(['start']); - expect( - Object.keys(machine.getEvents({ ...state, createdAt: 0, sessionId: 's' })) - ).toEqual(['start']); - expect(machine.getEvents('done')).toEqual({}); - }); -}); - -describe('getInitialState', () => { - test('creates initial state (sync)', () => { - const machine = createSimpleMachine(); - const state = machine.getInitialState(); - expect(state.value).toBe('idle'); - expect(state.context).toEqual({ count: 0 }); - expect(state.status).toBe('active'); - }); - - test('validates input via schemas.input (sync)', () => { - const machine = createHitlMachine(); - const state = machine.getInitialState({ task: 'test task' }); - expect(state.context.task).toBe('test task'); - }); - - test('rejects invalid input', () => { - const machine = createHitlMachine(); - // Runtime validation catches invalid input (schemas.input validates) - const invalidInput = { task: 123 } as unknown as { task: string }; - expect(() => machine.getInitialState(invalidInput)).toThrow(); - }); - - test('resolves string initial', () => { - const machine = createSimpleMachine(); - expect(machine.getInitialState().value).toBe('idle'); - }); - - test('resolves function initial', () => { - const machine = createAgentMachine({ - id: 'fn-initial', - context: (input: string) => ({ mode: input }), - initial: ({ context }) => ({ - target: (context.mode === 'fast' ? 'fast' : 'slow') as 'fast' | 'slow', - }), - states: { - fast: { type: 'final' }, - slow: { type: 'final' }, - }, - }); - expect(machine.getInitialState('fast').value).toBe('fast'); - }); - -}); - -describe('invoke', () => { - test('executes invoke and transitions via onDone', async () => { - const machine = createSimpleMachine(); - let state = machine.getInitialState(); - state = machine.transition(state, { type: 'start' }); - state = await invoke(machine, state); - expect(state.value).toBe('done'); - expect(state.context.count).toBe(1); - }); - - test('returns pending for event-only states', async () => { - const machine = createHitlMachine(); - const state = await invoke(machine, machine.getInitialState({ task: 'x' })); - expect(state.status).toBe('pending'); - expect(state.value).toBe('gathering'); - }); - - test('returns done for final states', async () => { - const machine = createSimpleMachine(); - let s = machine.transition(machine.getInitialState(), { type: 'start' }); - s = await invoke(machine, s); - s = await invoke(machine, s); - expect(s.status).toBe('done'); - expect(s.output).toEqual({ result: 1 }); - }); - - test('handles decide with adapter', async () => { - const machine = createDecideMachine( - mockAdapter([{ choice: 'technical' }]) - ); - const s = await invoke(machine, machine.getInitialState()); - expect(s.value).toBe('handling'); - expect(s.context.category).toBe('technical'); - }); - - test('handles classify', async () => { - const machine = createClassifyMachine( - mockAdapter([{ choice: 'billing' }]) - ); - const s = await invoke(machine, machine.getInitialState()); - expect(s.value).toBe('done'); - expect(s.context.category).toBe('billing'); - }); - - test('errors without adapter', async () => { - const machine = createAgentMachine({ - id: 'no-adapter', - context: () => ({}), - initial: 'deciding', - states: { - deciding: { - invoke: async () => - decide({ - model: 'test', - prompt: 'test', - options: { a: { description: 'A' } }, - }), - onDone: () => ({ target: 'done' }), - }, - done: { type: 'final' }, - }, - }); - const s = await invoke(machine, machine.getInitialState()); - expect(s.status).toBe('error'); - }); - - test('catches invoke errors', async () => { - const machine = createAgentMachine({ - id: 'err', - context: () => ({}), - initial: 'fail', - states: { - fail: { - invoke: async () => { - throw new Error('boom'); - }, - onDone: () => ({ target: 'ok' }), - }, - ok: { type: 'final' }, - }, - }); - const s = await invoke(machine, machine.getInitialState()); - expect(s.status).toBe('error'); - expect((s.error as Error).message).toBe('boom'); - }); - -}); - -describe('transition', () => { - test('transitions on matching event', () => { - const machine = createSimpleMachine(); - const s = machine.transition(machine.getInitialState(), { type: 'start' }); - expect(s.value).toBe('running'); - expect(s.status).toBe('active'); - }); - - test('self-transition (no target)', async () => { - const machine = createHitlMachine(); - let s = await invoke(machine, machine.getInitialState({ task: 'x' })); - s = machine.transition(s, { type: 'user.message', message: 'hello' }); - expect(s.value).toBe('gathering'); - expect(s.context.messages[0]!.content).toBe('hello'); - }); - - test('accumulates context', async () => { - const machine = createHitlMachine(); - let s = await invoke(machine, machine.getInitialState({ task: 'x' })); - s = machine.transition(s, { type: 'user.message', message: 'one' }); - s = machine.transition(s, { type: 'user.message', message: 'two' }); - expect(s.context.messages.length).toBe(2); - }); - - test('throws on unknown event', () => { - const machine = createSimpleMachine(); - expect(() => - machine.transition(machine.getInitialState(), { type: 'nope' }) - ).toThrow("No handler for event 'nope'"); - }); - -}); - -describe('execute', () => { - test('runs until done', async () => { - const machine = createSimpleMachine(); - let s = machine.transition(machine.getInitialState(), { type: 'start' }); - const r = await execute(machine, s); - expect(r.status).toBe('done'); - if (r.status === 'done') { - expect(r.output).toEqual({ result: 1 }); - expect(r.context.count).toBe(1); - } - }); - - test('stops at pending', async () => { - const machine = createHitlMachine(); - const r = await execute(machine, machine.getInitialState({ task: 'x' })); - expect(r.status).toBe('pending'); - if (r.status === 'pending') { - expect(r.value).toBe('gathering'); - expect(r.events['user.message']).toBeDefined(); - } - }); - - test('stops on error', async () => { - const machine = createAgentMachine({ - id: 'err', - context: () => ({}), - initial: 'fail', - states: { - fail: { - invoke: async () => { - throw new Error('nope'); - }, - onDone: () => ({ target: 'ok' }), - }, - ok: { type: 'final' }, - }, - }); - const r = await execute(machine, machine.getInitialState()); - expect(r.status).toBe('error'); - }); - - test('runs through multiple transitions', async () => { - const machine = createDecideMachine( - mockAdapter([{ choice: 'technical' }]) - ); - const r = await execute(machine, machine.getInitialState()); - expect(r.status).toBe('done'); - if (r.status === 'done') { - expect(r.output).toEqual({ - category: 'technical', - resolution: 'Handled technical issue', - }); - } - }); - -}); - -describe('stream', () => { - test('yields snapshots', async () => { - const machine = createDecideMachine( - mockAdapter([{ choice: 'technical' }]) - ); - const snaps = []; - for await (const snap of stream(machine, machine.getInitialState())) { - snaps.push(snap); - } - expect(snaps.length).toBeGreaterThanOrEqual(3); - expect(snaps[0]!.value).toBe('classifying'); - expect(snaps[snaps.length - 1]!.status).toBe('done'); - }); -}); - -describe('resolveState', () => { - test('restores from JSON', async () => { - const machine = createHitlMachine(); - const r = await execute(machine, machine.getInitialState({ task: 'x' })); - const restored = machine.resolveState(JSON.parse(JSON.stringify(r.state))); - const next = machine.transition(restored, { - type: 'user.message', - message: 'restored', - }); - expect(next.context.messages[0]!.content).toBe('restored'); - }); - -}); - -describe('decide', () => { - test('calls adapter with resolved prompt', async () => { - const spy = vi.fn().mockResolvedValue({ choice: 'a', data: {} }); - const machine = createAgentMachine({ - id: 'dtest', - context: () => ({ topic: 'cats', choice: null as string | null }), - initial: 'choosing', - states: { - choosing: { - schemas: { output: decideResultSchema({ - a: { description: 'A' }, - b: { description: 'B' }, - }) }, - invoke: async ({ context }) => - decide({ - adapter: { decide: spy }, - model: 'my-model', - prompt: `About ${context.topic}`, - options: { a: { description: 'A' }, b: { description: 'B' } }, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { choice: output.choice }, - }), - }, - done: { type: 'final' }, - }, - }); - await invoke(machine, machine.getInitialState()); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ model: 'my-model', prompt: 'About cats' }) - ); - }); - - test('per-state adapter override', async () => { - const machine = createAgentMachine({ - id: 'override', - context: () => ({ choice: null as string | null }), - initial: 'choosing', - states: { - choosing: { - schemas: { output: decideResultSchema({ - state: { description: 'State' }, - machine: { description: 'Machine' }, - }) }, - invoke: async () => - decide({ - adapter: mockAdapter([{ choice: 'state' }]), - model: 'test', - prompt: 'pick', - options: { - state: { description: 'State' }, - machine: { description: 'Machine' }, - }, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { choice: output.choice }, - }), - }, - done: { type: 'final' }, - }, - }); - const r = await execute(machine, machine.getInitialState()); - expect(r.status === 'done' && r.context.choice).toBe('state'); - }); - - test('option schemas typed data', async () => { - const machine = createAgentMachine({ - id: 'data', - context: () => ({ items: null as string[] | null }), - initial: 'choosing', - states: { - choosing: { - schemas: { output: decideResultSchema({ - withData: { - description: 'Has data', - schema: z.object({ items: z.array(z.string()) }), - }, - withoutData: { description: 'No data' }, - }) }, - invoke: async () => - decide({ - adapter: { - decide: async () => ({ - choice: 'withData', - data: { items: ['a', 'b'] }, - }), - }, - model: 'test', - prompt: 'pick', - options: { - withData: { - description: 'Has data', - schema: z.object({ items: z.array(z.string()) }), - }, - withoutData: { description: 'No data' }, - }, - }), - onDone: ({ output }) => { - return { - target: 'done', - context: { - items: - output.choice === 'withData' - ? (output.data.items ?? null) - : null, - }, - }; - }, - }, - done: { type: 'final' }, - }, - }); - const r = await execute(machine, machine.getInitialState()); - expect(r.status === 'done' && r.context.items).toEqual(['a', 'b']); - }); -}); - -describe('decide helper', () => { - test('explicit decide invoke with typed context', async () => { - const adapter = mockAdapter([{ choice: 'technical' }]); - const machine = createAgentMachine({ - id: 'decide-helper-test', - context: () => ({ issue: 'App crashes', result: null as string | null }), - initial: 'routing', - states: { - routing: { - schemas: { output: choiceResultSchema }, - invoke: async ({ context }) => - decide({ - adapter, - model: 'test-model', - prompt: `Route: ${context.issue}`, - options: { - billing: { description: 'Billing' }, - technical: { description: 'Technical' }, - }, - }), - onDone: ({ output, context }) => ({ - target: 'done', - context: { result: `${output.choice}: ${context.issue}` }, - }), - }, - done: { type: 'final', output: ({ context }) => ({ result: context.result }) }, - }, - }); - - const r = await execute(machine, machine.getInitialState()); - expect(r.status).toBe('done'); - if (r.status === 'done') { - expect(r.output).toEqual({ result: 'technical: App crashes' }); - } - }); - - test('invoke state with event transition', () => { - let called = false; - const adapter: DecideAdapter = { - decide: async () => { - called = true; - return { choice: 'a', data: {} }; - }, - }; - const machine = createAgentMachine({ - id: 'invoke-event-transition', - context: () => ({}), - initial: 'choosing', - states: { - choosing: { - schemas: { output: choiceResultSchema }, - invoke: async () => - decide({ - adapter, - model: 'test', - prompt: 'pick', - options: { a: { description: 'A' } }, - }), - onDone: () => ({ target: 'done' }), - on: { - cancel: () => ({ target: 'cancelled' }), - }, - }, - done: { type: 'final' }, - cancelled: { type: 'final' }, - }, - }); - - const state = machine.getInitialState(); - const next = machine.transition(state, { type: 'cancel' }); - expect(next.value).toBe('cancelled'); - expect(called).toBe(false); - }); -}); - -describe('messages and always', () => { - test('states expose resolved generation fields', () => { - const search = async () => 'result'; - const machine = createAgentMachine({ - id: 'generation-fields', - schemas: { - input: z.object({ task: z.string() }), - }, - context: (input) => ({ task: input.task, phase: 'read' }), - messages: (input) => [{ role: 'user', content: input.task }], - initial: 'planning', - states: { - planning: { - model: 'test-model', - system: 'Plan carefully.', - prompt: ({ context }) => `Plan: ${context.task}`, - tools: { search }, - toolChoice: 'auto', - on: { - ready: { - target: 'implementing', - context: { phase: 'write' }, - messages: [ - { - role: 'system', - content: 'Writing is allowed now.', - }, - ], - }, - }, - }, - implementing: { - prompt: ({ context }) => `Implement: ${context.task}`, - tools: { - writeFile: async () => 'ok', - }, - on: { - done: { target: 'done' }, - }, - }, - done: { type: 'final' }, - }, - }); - - const planning = machine.getInitialState({ task: 'Fix bug' }); - expect(planning.prompt).toBe('Plan: Fix bug'); - expect(planning.model).toBe('test-model'); - expect(planning.system).toBe('Plan carefully.'); - expect(Object.keys(planning.tools ?? {})).toEqual(['search', 'event.ready']); - expect(planning.toolChoice).toBe('auto'); - - const implementing = machine.transition(planning, { type: 'ready' }); - expect(implementing.prompt).toBe('Implement: Fix bug'); - expect(implementing.model).toBeUndefined(); - expect(Object.keys(implementing.tools ?? {})).toEqual([ - 'writeFile', - 'event.done', - ]); - expect(implementing.messages.at(-1)).toEqual({ - role: 'system', - content: 'Writing is allowed now.', - }); - }); - - test('generation fields resolve from the unresolved snapshot', () => { - const read = async () => 'read'; - const write = async () => 'write'; - const seenSnapshots: Array<{ - value: string; - hasPrompt: boolean; - hasTools: boolean; - }> = []; - const machine = createAgentMachine({ - id: 'snapshot-resolvers', - schemas: { - input: z.object({ task: z.string(), mode: z.enum(['read', 'write']) }), - }, - context: (input) => ({ task: input.task, mode: input.mode }), - messages: (input) => [{ role: 'user', content: `Task: ${input.task}` }], - initial: 'working', - states: { - working: { - model: ({ snapshot }) => - snapshot.context.mode === 'write' ? 'write-model' : 'read-model', - system: ({ snapshot }) => `State: ${snapshot.value}`, - prompt: ({ snapshot }) => { - seenSnapshots.push({ - value: snapshot.value, - hasPrompt: 'prompt' in snapshot, - hasTools: 'tools' in snapshot, - }); - - return [ - `Mode: ${snapshot.context.mode}`, - `Messages: ${snapshot.messages.length}`, - `Task: ${snapshot.context.task}`, - ].join('\n'); - }, - tools: ({ snapshot }) => - snapshot.context.mode === 'write' ? { read, write } : { read }, - toolChoice: ({ snapshot }) => - snapshot.context.mode === 'write' ? 'required' : 'auto', - on: { - done: { target: 'done' }, - }, - }, - done: { type: 'final' }, - }, - }); - - const state = machine.getInitialState({ task: 'Fix bug', mode: 'write' }); - - expect(state.model).toBe('write-model'); - expect(state.system).toBe('State: working'); - expect(state.prompt).toBe('Mode: write\nMessages: 1\nTask: Fix bug'); - expect(Object.keys(state.tools ?? {})).toEqual(['read', 'write', 'event.done']); - expect(state.toolChoice).toBe('required'); - expect(seenSnapshots).toEqual([ - { - value: 'working', - hasPrompt: false, - hasTools: false, - }, - ]); - }); - - test('event tools are namespaced and use event schemas', async () => { - const userTool = async () => 'user tool'; - const machine = createAgentMachine({ - id: 'event-tools', - schemas: { - events: { - PLAN_READY: z.object({ - type: z.literal('PLAN_READY'), - rationale: z.string(), - }), - }, - }, - context: () => ({}), - initial: 'planning', - states: { - planning: { - tools: { PLAN_READY: userTool }, - on: { - PLAN_READY: { target: 'done' }, - }, - }, - done: { type: 'final' }, - }, - }); - - const state = machine.getInitialState(); - expect(state.tools?.PLAN_READY).toBe(userTool); - expect(state.tools?.['event.PLAN_READY']).toMatchObject({ - description: "Transition with event 'PLAN_READY'.", - schemas: { input: expect.any(Object) }, - }); - - const eventTool = state.tools?.['event.PLAN_READY'] as { - execute(input: Record): Promise>; - }; - await expect( - eventTool.execute({ rationale: 'plan is ready' }) - ).resolves.toEqual({ - type: 'PLAN_READY', - rationale: 'plan is ready', - }); - }); - - test('prompt states with no user tools still expose event tools', () => { - const machine = createAgentMachine({ - id: 'event-only-tools', - context: () => ({}), - initial: 'waiting', - states: { - waiting: { - prompt: 'Wait for completion.', - on: { - done: { target: 'done' }, - }, - }, - done: { type: 'final' }, - }, - }); - - expect(Object.keys(machine.getInitialState().tools ?? {})).toEqual([ - 'event.done', - ]); - }); - - test('on events become prefixed event tools in prompt states by default', () => { - const machine = createAgentMachine({ - id: 'prefixed-event-tools', - context: () => ({}), - initial: 'planning', - states: { - planning: { - prompt: 'Plan and choose a transition.', - on: { - PLAN_READY: { target: 'done' }, - FAIL: { target: 'failed' }, - }, - }, - done: { type: 'final' }, - failed: { type: 'final' }, - }, - }); - - expect(Object.keys(machine.getInitialState().tools ?? {})).toEqual([ - 'event.PLAN_READY', - 'event.FAIL', - ]); - }); - - test('non-generative states do not expose on events as tools', () => { - const machine = createAgentMachine({ - id: 'non-generative-events', - context: () => ({}), - initial: 'waiting', - states: { - waiting: { - on: { - APPROVED: { target: 'done' }, - }, - }, - done: { type: 'final' }, - }, - }); - - const waiting = machine.getInitialState(); - expect(waiting.tools).toBeUndefined(); - - const done = machine.transition(waiting, { type: 'APPROVED' }); - expect(done.value).toBe('done'); - }); - - test('external events are valid transitions but excluded from event tools', () => { - const machine = createAgentMachine({ - id: 'external-events', - externalEvents: ['APPROVED', 'REJECTED'], - schemas: { - events: { - PLAN_READY: z.object({}), - APPROVED: z.object({}), - REJECTED: z.object({}), - }, - }, - context: () => ({}), - initial: 'planning', - states: { - planning: { - prompt: 'Prepare a plan.', - on: { - PLAN_READY: { target: 'awaitingApproval' }, - }, - }, - awaitingApproval: { - prompt: 'Wait for approval.', - on: { - APPROVED: { target: 'done' }, - REJECTED: { target: 'planning' }, - }, - }, - done: { type: 'final' }, - }, - }); - - const planning = machine.getInitialState(); - expect(Object.keys(planning.tools ?? {})).toEqual(['event.PLAN_READY']); - - const awaitingApproval = machine.transition(planning, { - type: 'PLAN_READY', - }); - expect(awaitingApproval.value).toBe('awaitingApproval'); - expect(awaitingApproval.tools).toBeUndefined(); - - const done = machine.transition(awaitingApproval, { type: 'APPROVED' }); - expect(done.value).toBe('done'); - }); - - test('invoke cannot be combined with generation fields', () => { - expect(() => - createAgentMachine({ - id: 'invoke-generation-conflict', - context: () => ({}), - initial: 'working', - states: { - working: { - prompt: 'Generate something.', - invoke: async () => ({}), - }, - }, - }) - ).toThrow( - "State 'working' cannot combine invoke with prompt, system, tools, or toolChoice" - ); - }); - - test('snapshots omit executable generation fields', async () => { - const machine = createAgentMachine({ - id: 'snapshot-generation-fields', - context: () => ({}), - initial: 'waiting', - states: { - waiting: { - prompt: 'Use the tool.', - tools: { search: async () => 'result' }, - on: { done: { target: 'done' } }, - }, - done: { type: 'final' }, - }, - }); - - const state = machine.getInitialState(); - expect(state.prompt).toBe('Use the tool.'); - expect(state.tools).toBeDefined(); - - const snapshots = []; - for await (const snapshot of stream(machine, state)) { - snapshots.push(snapshot); - break; - } - - expect(snapshots[0]).not.toHaveProperty('prompt'); - expect(snapshots[0]).not.toHaveProperty('tools'); - }); - - test('messages are passed through invoke, onDone, always, and output', async () => { - const machine = createAgentMachine({ - id: 'messages-always', - schemas: { - input: z.object({ prompt: z.string() }), - output: z.object({ - messages: z.array(z.object({ role: z.string(), content: z.string() })), - attempts: z.number(), - }), - }, - context: () => ({ - attempts: 0, - accepted: false, - }), - messages: (input) => [{ role: 'user', content: input.prompt }], - initial: 'generating', - states: { - generating: { - schemas: { output: z.object({ text: z.string() }) }, - invoke: async ({ messages }) => ({ - text: `reply to ${messages.at(-1)?.content}`, - }), - onDone: ({ output, context, messages }) => ({ - target: 'checking', - context: { attempts: context.attempts + 1 }, - messages: messages.concat({ - role: 'assistant', - content: output.text, - }), - }), - }, - checking: { - always: ({ context, messages }) => - context.attempts >= 2 - ? { - target: 'done', - context: { accepted: true }, - messages: messages.concat({ - role: 'system', - content: 'accepted', - }), - } - : { - target: 'generating', - messages: messages.concat({ - role: 'user', - content: 'repair', - }), - }, - }, - done: { - type: 'final', - output: ({ context, messages }) => ({ - messages, - attempts: context.attempts, - }), - }, - }, - }); - - const result = await execute(machine, machine.getInitialState({ prompt: 'draft' })); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.messages.map((message) => message.content)).toEqual([ - 'draft', - 'reply to draft', - 'repair', - 'reply to repair', - 'accepted', - ]); - expect(result.output.attempts).toBe(2); - } - }); -}); - -describe('classify', () => { - test('result has typed category', async () => { - const machine = createClassifyMachine( - mockAdapter([{ choice: 'billing' }]) - ); - const r = await execute(machine, machine.getInitialState()); - expect(r.status === 'done' && r.output).toEqual({ category: 'billing' }); - }); -}); - -describe('P2: event validation', () => { - test('rejects invalid payload', async () => { - const machine = createHitlMachine(); - const s = await invoke(machine, machine.getInitialState({ task: 'x' })); - expect(() => - // @ts-expect-error — deliberately invalid for runtime test - machine.transition(s, { type: 'user.message', message: 123 }) - ).toThrow(); - }); - - test('accepts valid payload', async () => { - const machine = createHitlMachine(); - const s = await invoke(machine, machine.getInitialState({ task: 'x' })); - const next = machine.transition(s, { - type: 'user.message', - message: 'ok', - }); - expect(next.context.messages.length).toBe(1); - }); - - test('skips when no schema', () => { - const machine = createSimpleMachine(); - const s = machine.transition(machine.getInitialState(), { type: 'start' }); - expect(s.value).toBe('running'); - }); -}); - -describe('full HITL workflow', () => { - test('gather → process → review → done', async () => { - const machine = createHitlMachine(); - let s = machine.getInitialState({ task: 'build' }); - let r = await execute(machine, s); - expect(r.status).toBe('pending'); - - s = machine.transition(r.state, { - type: 'user.message', - message: 'req A', - }); - s = machine.transition(s, { type: 'user.message', message: 'req B' }); - s = machine.transition(s, { type: 'user.approve' }); - r = await execute(machine, s); - expect(r.status === 'pending' && r.context.result).toBe( - 'Processed: req A, req B' - ); - - s = machine.transition(r.state, { type: 'user.approve' }); - r = await execute(machine, s); - expect(r.status === 'done' && r.output).toEqual({ - result: 'Processed: req A, req B', - }); - }); - - test('cancel', async () => { - const machine = createHitlMachine(); - let r = await execute(machine, machine.getInitialState({ task: 'x' })); - const s = machine.transition(r.state, { type: 'user.cancel' }); - r = await execute(machine, s); - expect(r.status === 'done' && r.output).toEqual({ cancelled: true }); - }); -}); - -describe('type inference', () => { - // ─── state.value ─── - - test('state.value is typed union of state names', () => { - const machine = createAgentMachine({ - id: 't', - context: () => ({ x: 1 }), - initial: 'a', - states: { - a: { on: { go: () => ({ target: 'b' }) } }, - b: { type: 'final' }, - }, - }); - const s = machine.getInitialState(); - - s.value satisfies 'a' | 'b'; - // @ts-expect-error — 'c' is not a valid state name - s.value satisfies 'c'; - }); - - // ─── state.context ─── - - test('context typed from context() return', () => { - const machine = createAgentMachine({ - id: 't', - context: () => ({ name: 'test', count: 0, flag: true }), - initial: 'idle', - states: { idle: { type: 'final' } }, - }); - const s = machine.getInitialState(); - - s.context.name satisfies string; - s.context.count satisfies number; - s.context.flag satisfies boolean; - // @ts-expect-error — name is string not number - s.context.name satisfies number; - // @ts-expect-error — 'nope' does not exist - s.context.nope; - }); - - test('transition context is Partial — rejects unknown keys', () => { - createAgentMachine({ - id: 't', - schemas: { events: { go: z.object({}) } }, - context: () => ({ count: 0, name: 'hello' }), - initial: 'idle', - states: { - idle: { - on: { - go: ({ context }) => ({ - target: 'idle', - // valid: known key - context: { count: context.count + 1 }, - }), - }, - }, - }, - }); - - createAgentMachine({ - id: 't2', - schemas: { events: { go: z.object({}) } }, - context: () => ({ count: 0 }), - initial: 'idle', - states: { - idle: { - on: { - // @ts-expect-error — 'foo' not a valid context key - go: () => ({ - target: 'idle', - context: { foo: 'bar' }, - }), - }, - }, - }, - }); - }); - - test('context typed in on handlers', () => { - const machine = createAgentMachine({ - id: 't', - schemas: { events: { add: z.object({}) } }, - context: () => ({ items: ['a', 'b'] }), - initial: 'idle', - states: { - idle: { - on: { - add: ({ context }) => { - context.items satisfies string[]; - // @ts-expect-error — 'nope' does not exist - context.nope; - return { context: { items: [...context.items, 'c'] } }; - }, - }, - }, - }, - }); - const next = machine.transition(machine.getInitialState(), { type: 'add' }); - expect(next.context.items).toEqual(['a', 'b', 'c']); - }); - - test('context typed in invoke', () => { - const machine = createAgentMachine({ - id: 't', - context: () => ({ n: 42 }), - initial: 'work', - states: { - work: { - schemas: { output: z.object({ doubled: z.number() }) }, - invoke: async ({ context }) => { - context.n satisfies number; - // @ts-expect-error — 'nope' does not exist - context.nope; - return { doubled: context.n * 2 }; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { n: output.doubled }, - }), - }, - done: { type: 'final' }, - }, - }); - return execute(machine, machine.getInitialState()).then((r) => { - expect(r.status === 'done' && r.context.n).toBe(84); - }); - }); - - test('context typed in output', () => { - const machine = createAgentMachine({ - id: 't', - schemas: { - output: z.object({ - score: z.number(), - }), - }, - context: () => ({ score: 100 }), - initial: 'done', - states: { - done: { - type: 'final', - output: ({ context }) => { - context.score satisfies number; - // @ts-expect-error — 'nope' does not exist - context.nope; - return { score: context.score }; - }, - }, - }, - }); - expect(machine.getInitialState).toBeDefined(); - }); - - test('context typed in initial function', () => { - const machine = createAgentMachine({ - id: 't', - context: () => ({ mode: 'fast' as 'fast' | 'slow' }), - initial: ({ context }) => { - context.mode satisfies 'fast' | 'slow'; - // @ts-expect-error — 'nope' does not exist - context.nope; - return { target: (context.mode === 'fast' ? 'a' : 'b') as 'a' | 'b' }; - }, - states: { - a: { type: 'final' }, - b: { type: 'final' }, - }, - }); - expect(machine.getInitialState().value).toBe('a'); - }); - - // ─── schemas.context (overload 1) ─── - - test('schemas.context drives TContext + input typed from schemas.input', () => { - const machine = createAgentMachine({ - id: 't', - schemas: { - context: z.object({ count: z.number(), label: z.string() }), - input: z.object({ initial: z.number() }), - }, - context: (input) => { - input.initial satisfies number; - // @ts-expect-error — 'nope' does not exist on input - input.nope; - return { count: input.initial, label: 'hello' }; - }, - initial: 'idle', - states: { - idle: { - invoke: async ({ context }) => { - context.count satisfies number; - context.label satisfies string; - // @ts-expect-error — 'nope' does not exist - context.nope; - return {}; - }, - }, - }, - }); - const s = machine.getInitialState({ initial: 5 }); - - s.context.count satisfies number; - s.context.label satisfies string; - // @ts-expect-error — 'nope' does not exist - s.context.nope; - expect(s.context.count).toBe(5); - }); - - test('schemas.input alone drives context input typing', () => { - const machine = createAgentMachine({ - id: 't-input-only', - schemas: { - input: z.object({ message: z.string() }), - }, - context: (input) => { - input.message satisfies string; - // @ts-expect-error — 'nope' does not exist on input - input.nope; - return { message: input.message, count: 0 }; - }, - initial: 'idle', - states: { - idle: { - type: 'final', - }, - }, - }); - - machine.getInitialState({ message: 'hello' }); - if (false) { - // @ts-expect-error — message must be string - machine.getInitialState({ message: 123 }); - } - }); - - // ─── schemas.events ─── - - test('transition events typed from schemas.events', () => { - const machine = createAgentMachine({ - id: 't', - schemas: { - events: { - greet: z.object({ name: z.string() }), - ping: z.object({}), - }, - }, - context: () => ({ msg: '' }), - initial: 'idle', - states: { - idle: { - on: { - greet: ({ event }) => ({ - context: { msg: `hi ${event.name}` }, - }), - ping: () => ({}), - }, - }, - }, - }); - const s = machine.getInitialState(); - - // Valid events compile - machine.transition(s, { type: 'greet', name: 'world' }); - machine.transition(s, { type: 'ping' }); - - // @ts-expect-error — 'bogus' is not a valid event type - expect(() => machine.transition(s, { type: 'bogus' })).toThrow(); - - // @ts-expect-error — missing required 'name' field - expect(() => machine.transition(s, { type: 'greet' })).toThrow(); - - expect(() => - machine.transition(s, { - type: 'greet', - // @ts-expect-error — name must be string - name: 123, - }) - ).toThrow(); - - const next = machine.transition(s, { type: 'greet', name: 'world' }); - expect(next.context.msg).toBe('hi world'); - }); - - test('no schemas.events → untyped events (any type string)', () => { - const machine = createAgentMachine({ - id: 't', - context: () => ({}), - initial: 'idle', - states: { - idle: { on: { anything: () => ({}) } }, - }, - }); - // Any event type string accepted when no schemas.events - machine.transition(machine.getInitialState(), { type: 'anything' }); - // Unknown events still throw at runtime (no handler) - expect(() => - machine.transition(machine.getInitialState(), { type: 'nope' }) - ).toThrow(); - }); - - // ─── schemas.input per state ─── - - test('input typed per state from schemas.input', async () => { - const machine = createAgentMachine({ - id: 't', - context: () => ({ result: '' }), - initial: 'a', - states: { - a: { - schemas: { input: z.object({ count: z.number() }), output: z.object({ doubled: z.number() }) }, - invoke: async ({ input }) => { - input.count satisfies number; - // @ts-expect-error — count is number not string - input.count satisfies string; - // @ts-expect-error — 'name' not on a's input - input.name; - return { doubled: input.count * 2 }; - }, - onDone: ({ output }) => ({ - target: 'b', - input: { name: 'hello' }, - context: { result: String(output.doubled) }, - }), - }, - b: { - schemas: { input: z.object({ name: z.string() }), output: z.object({ greeting: z.string() }) }, - invoke: async ({ input }) => { - input.name satisfies string; - // @ts-expect-error — name is string not number - input.name satisfies number; - // @ts-expect-error — 'count' not on b's input - input.count; - return { greeting: `hi ${input.name}` }; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { result: output.greeting }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ result: context.result }), - }, - }, - }); - - let state = machine.resolveState({ - ...machine.getInitialState(), - input: { a: { count: 21 } }, - }); - const r = await execute(machine, state); - expect(r.status === 'done' && r.output).toEqual({ result: 'hi hello' }); - }); - - test('no schemas.input → input is Record', () => { - createAgentMachine({ - id: 't', - context: () => ({}), - initial: 'idle', - states: { - idle: { - invoke: async ({ input }) => { - input satisfies Record; - return {}; - }, - }, - }, - }); - }); - - test('state resolver snapshot is typed from context and input', () => { - createAgentMachine({ - id: 't', - schemas: { - input: z.object({ task: z.string() }), - }, - context: (input) => ({ task: input.task, count: 1 }), - initial: 'working', - states: { - working: { - schemas: { input: z.object({ attempt: z.number() }) }, - prompt: ({ snapshot, context, input }) => { - snapshot.value satisfies string; - snapshot.context.task satisfies string; - context.count satisfies number; - input.attempt satisfies number; - // @ts-expect-error — resolved prompt is not present while resolving - snapshot.prompt; - // @ts-expect-error — attempt is number not string - input.attempt satisfies string; - return `${snapshot.value}: ${context.task}`; - }, - on: { - done: { target: 'done' }, - }, - }, - done: { type: 'final' }, - }, - }); - }); - - // ─── decide helper context typing ─── - - test('decide helper gets typed context in invoke and onDone', () => { - const adapter = mockAdapter([{ choice: 'a' }]); - const machine = createAgentMachine({ - id: 't', - context: () => ({ topic: 'cats', result: '' }), - initial: 'choosing', - states: { - choosing: { - schemas: { output: choiceResultSchema }, - invoke: async ({ context }) => { - context.topic satisfies string; - // @ts-expect-error — 'nope' does not exist - context.nope; - return decide({ - adapter, - model: 'test', - prompt: `About ${context.topic}`, - options: { a: { description: 'A' } }, - }); - }, - onDone: ({ output, context }) => { - output.choice satisfies string; - // @ts-expect-error - output.nope; - context.topic satisfies string; - return { target: 'done', context: { result: output.choice } }; - }, - }, - done: { type: 'final' }, - }, - }); - expect(machine.id).toBe('t'); - }); - - // ─── getInitialState input typing ─── - - test('getInitialState requires input when schemas.input provided', () => { - const machine = createAgentMachine({ - id: 't', - schemas: { - context: z.object({ task: z.string() }), - input: z.object({ task: z.string() }), - }, - context: (input) => ({ task: input.task }), - initial: 'idle', - states: { idle: { type: 'final' } }, - }); - - // Valid - machine.getInitialState({ task: 'hello' }); - - expect(() => - machine.getInitialState({ - // @ts-expect-error — task must be string - task: 123, - }) - ).toThrow(); - - // @ts-expect-error — missing required input (runtime: validates) - expect(() => machine.getInitialState()).toThrow(); - }); - - test('getInitialState optional when no input schema', () => { - const machine = createAgentMachine({ - id: 't', - context: () => ({ x: 1 }), - initial: 'idle', - states: { idle: { type: 'final' } }, - }); - - // Both valid - machine.getInitialState(); - machine.getInitialState(undefined); - }); - - // ─── schemas.output ─── - - test('schemas.output types invoke return and onDone output', () => { - createAgentMachine({ - id: 't', - context: () => ({ total: 0 }), - initial: 'work', - states: { - work: { - schemas: { output: z.object({ value: z.number() }) }, - invoke: async () => { - // return type must match schemas.output - return { value: 42 }; - }, - onDone: ({ output }) => { - // output is typed from schemas.output - output.value satisfies number; - // @ts-expect-error — 'nope' does not exist on result - output.nope; - return { target: 'done', context: { total: output.value } }; - }, - }, - done: { type: 'final' }, - }, - }); - }); - - test('no schemas.output → onDone output is inferred from invoke', () => { - createAgentMachine({ - id: 't', - context: () => ({}), - initial: 'work', - states: { - work: { - invoke: async () => ({ anything: true }), - onDone: ({ output }) => { - output.anything satisfies boolean; - // @ts-expect-error — 'choice' does not exist on invoke result - output.choice; - return { target: 'done' }; - }, - }, - done: { type: 'final' }, - }, - }); - }); - - test('final output is inferred through execute and snapshots', async () => { - const machine = createAgentMachine({ - id: 'typed-output', - schemas: { - output: z.object({ - count: z.number(), - label: z.string(), - }), - }, - context: () => ({ count: 2 }), - initial: 'done', - states: { - done: { - type: 'final', - output: ({ context }) => ({ - count: context.count, - label: `count:${context.count}`, - }), - }, - }, - }); - - const runResult = await execute(machine, machine.getInitialState()); - if (runResult.status === 'done') { - runResult.output.count satisfies number; - runResult.output.label satisfies string; - // @ts-expect-error output property should be typed - runResult.output.missing; - } - - const snapshot = machine.resolveState(machine.getInitialState()); - snapshot.output satisfies - | { - count: number; - label: string; - } - | undefined; - }); - - // ─── events typed in on handlers ─── - - test('on handler event typed from schemas.events', () => { - createAgentMachine({ - id: 't', - schemas: { - events: { - 'msg': z.object({ text: z.string() }), - }, - }, - context: () => ({ last: '' }), - initial: 'idle', - states: { - idle: { - on: { - msg: ({ event }) => { - // event.text is typed from schemas.events - event.text satisfies string; - event.type satisfies 'msg'; - return { context: { last: event.text } }; - }, - }, - }, - }, - }); - }); - - // ─── static transition shorthand ─── - - test('on handler accepts string shorthand', () => { - const machine = createAgentMachine({ - id: 't', - context: () => ({}), - initial: 'a', - states: { - a: { - on: { - go: { target: 'b' }, - }, - }, - b: { type: 'final' }, - }, - }); - const s = machine.transition(machine.getInitialState(), { type: 'go' }); - expect(s.value).toBe('b'); - }); - - test('on handler accepts static TransitionResult object', () => { - const machine = createAgentMachine({ - id: 't', - context: () => ({ x: 0 }), - initial: 'a', - states: { - a: { - on: { - go: { target: 'b', context: { x: 1 } }, - }, - }, - b: { type: 'final' }, - }, - }); - const s = machine.transition(machine.getInitialState(), { type: 'go' }); - expect(s.value).toBe('b'); - expect(s.context.x).toBe(1); - }); -}); - -describe('edge cases', () => { - test('invoke with no onDone is dead end', async () => { - const machine = createAgentMachine({ - id: 'dead', - context: () => ({}), - initial: 'stuck', - states: { stuck: { invoke: async () => ({}) } }, - }); - const s = await invoke(machine, machine.getInitialState()); - expect(s.value).toBe('stuck'); - }); - - test('done state returns as-is', async () => { - const machine = createSimpleMachine(); - const done = { - value: 'done' as const, - input: {}, - context: { count: 1 }, - messages: [], - status: 'done' as const, - output: { result: 1 }, - }; - expect(await invoke(machine, done)).toEqual(done); - }); -}); - -describe('createAdapter', () => { - test('creates custom adapter', () => { - const a = createAdapter({ - generateText: async () => 'ok', - }); - expect(a.generateText).toBeDefined(); - expect('decide' in a).toBe(false); - }); -}); diff --git a/src/ai-sdk/index.test.ts b/src/ai-sdk/index.test.ts index 0474bcf..046559f 100644 --- a/src/ai-sdk/index.test.ts +++ b/src/ai-sdk/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; -import { createAiSdkAdapter, createAiSdkDecisionAdapter } from './index.js'; +import { createAiSdkAdapter, createAiSdkDecisionAdapter, toAiSdkTools } from './index.js'; describe('createAiSdkAdapter', () => { test('resolves schema-less choices with a custom model resolver', async () => { @@ -90,7 +90,7 @@ describe('createAiSdkAdapter', () => { await expect( adapter.generateText?.({ - model: 'openai/gpt-5.4-nano', + modelRef: 'openai/gpt-5.4-nano', messages: [], prompt: 'reply', }) @@ -112,7 +112,7 @@ describe('createAiSdkAdapter', () => { }); await adapter.generateText?.({ - model: 'openai/gpt-5.4-nano', + modelRef: 'openai/gpt-5.4-nano', prompt: 'reply', messages: [{ role: 'user', content: 'reply' }], }); @@ -124,4 +124,23 @@ describe('createAiSdkAdapter', () => { }, ]); }); + + test('converts agent tool descriptors to AI SDK tools', () => { + const inputSchema = z.object({ target: z.string() }); + const tools = toAiSdkTools({ + 'event.ATTACK': { + description: 'Attack a target.', + inputSchema, + execute: async (input) => ({ type: 'ATTACK', ...input as object }), + }, + }); + + expect(tools['event.ATTACK']).toEqual( + expect.objectContaining({ + description: 'Attack a target.', + inputSchema, + execute: expect.any(Function), + }) + ); + }); }); diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index 7ef8763..788dcf1 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -1,18 +1,24 @@ -import { generateText, Output } from 'ai'; +import { generateText, Output, tool } from 'ai'; import { z } from 'zod'; -import type { AgentAdapter, DecideAdapter, StandardSchemaV1 } from '../types.js'; +import type { + AgentAdapter, + AgentGenerateTextInput, + AgentTools, + DecideAdapter, + StandardSchemaV1, +} from '../types.js'; type AiSdkGenerateText = typeof generateText; type AiSdkModel = Parameters[0]['model']; export interface CreateAiSdkAdapterOptions { - resolveModel?: (model: string) => AiSdkModel; + resolveModel?: (modelRef: string) => AiSdkModel; generateText?: AiSdkGenerateText; } /** * Create an adapter that uses the Vercel AI SDK for generative states. - * By default, model strings are passed straight through to the AI SDK. + * By default, model refs are passed straight through to the AI SDK. * For provider helpers such as `openai(...)`, pass `resolveModel`. */ export function createAiSdkAdapter( @@ -21,28 +27,10 @@ export function createAiSdkAdapter( const generate = config.generateText ?? generateText; return { - async generateText({ model, system, prompt, messages, tools, toolChoice, outputSchema }) { - const options: any = { - model: resolveModel(model ?? 'default', config.resolveModel), - system, - tools: tools as any, - toolChoice: toolChoice as any, - ...(outputSchema - ? { - output: Output.object({ - schema: toZodSchema(outputSchema), - }), - } - : {}), - }; - - if (messages.length > 0) { - options.messages = messages as any; - } else { - options.prompt = prompt ?? ''; - } - - const result = await generate(options); + async generateText(input) { + const result = await generate(toAiSdkGenerateTextOptions(input, { + resolveModel: config.resolveModel, + })); const output = result as { output?: unknown; text?: string }; return output.output ?? output.text ?? result; @@ -50,6 +38,76 @@ export function createAiSdkAdapter( }; } +export function toAiSdkGenerateTextOptions( + { modelRef, system, prompt, messages, tools, toolChoice, outputSchema }: AgentGenerateTextInput, + config: Pick = {} +): Parameters[0] { + const options: any = { + model: resolveModel(modelRef ?? 'default', config.resolveModel), + system, + tools: tools ? toAiSdkTools(tools) : undefined, + toolChoice: toAiSdkToolChoice(toolChoice), + ...(outputSchema + ? { + output: Output.object({ + schema: toZodSchema(outputSchema), + }), + } + : {}), + }; + + if (messages.length > 0) { + options.messages = messages as any; + } else { + options.prompt = prompt ?? ''; + } + + return options; +} + +export function toAiSdkTools(tools: AgentTools) { + return Object.fromEntries( + Object.entries(tools).flatMap(([name, descriptor]) => { + if (!descriptor) { + return []; + } + + if (typeof descriptor === 'function') { + return [[ + name, + tool({ + inputSchema: z.unknown(), + execute: descriptor as any, + } as any), + ]]; + } + + const inputSchema = + descriptor.inputSchema + ?? (descriptor.schemas as { input?: StandardSchemaV1 } | undefined)?.input; + const toolOptions: Record = { + description: descriptor.description, + inputSchema: inputSchema ? toZodSchema(inputSchema) : z.unknown(), + execute: descriptor.execute as any, + }; + + return [[name, tool(toolOptions as any)]]; + }) + ); +} + +function toAiSdkToolChoice(toolChoice: AgentGenerateTextInput['toolChoice']) { + if (!toolChoice) { + return undefined; + } + + if (typeof toolChoice === 'object') { + return { type: 'tool' as const, toolName: toolChoice.name }; + } + + return toolChoice; +} + /** * Create a decision helper adapter for decide(...) and classify(...). */ @@ -145,17 +203,17 @@ function toZodSchema(schema: StandardSchemaV1): z.ZodType { } /** - * Resolve a model string to an AI SDK model. + * Resolve a portable model ref to an AI SDK model. * Supports custom resolution when users prefer provider helpers such as * `openai('gpt-5.4-nano')`. */ function resolveModel( - model: string, - resolver?: (model: string) => AiSdkModel + modelRef: string, + resolver?: (modelRef: string) => AiSdkModel ): AiSdkModel { if (resolver) { - return resolver(model); + return resolver(modelRef); } - return model as any; + return modelRef as any; } diff --git a/src/burr-equivalents/raw-xstate.test.ts b/src/burr-equivalents/raw-xstate.test.ts new file mode 100644 index 0000000..c25e6b5 --- /dev/null +++ b/src/burr-equivalents/raw-xstate.test.ts @@ -0,0 +1,644 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { assign, createActor, fromPromise, toPromise, waitFor } from 'xstate'; +import { createTextLogic, setupAgent } from '../index.js'; + +describe('Burr-style examples authored as XState setup machines', () => { + test('hello-world-counter uses explicit state and guarded looping', async () => { + const agent = setupAgent({ + context: z.object({ counter: z.number(), countUpTo: z.number() }), + input: z.object({ countUpTo: z.number() }), + output: z.object({ counter: z.number() }), + actors: { + increment: fromPromise( + async ({ input }) => input.counter + 1 + ), + }, + }); + + const machine = agent.createMachine({ + id: 'burr-counter-xstate', + context: ({ input }) => ({ counter: 0, countUpTo: input.countUpTo }), + initial: 'counter', + states: { + counter: { + invoke: { + src: 'increment', + input: ({ context }) => ({ counter: context.counter }), + onDone: { + target: 'checking', + actions: assign({ counter: ({ event }) => event.output }), + }, + }, + }, + checking: { + always: [ + { guard: ({ context }) => context.counter < context.countUpTo, target: 'counter' }, + { target: 'result' }, + ], + }, + result: { + type: 'final', + output: ({ context }) => ({ counter: context.counter }), + }, + }, + }); + + const actor = createActor(machine, { input: { countUpTo: 3 } }); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ counter: 3 }); + }); + + test('conversational RAG stores memory in machine context before answering', async () => { + const answerWithDocuments = createTextLogic({ + schemas: { + input: z.object({ + question: z.string(), + documents: z.array(z.string()), + memory: z.array(z.string()), + }), + output: z.string(), + }, + model: 'rag-answerer', + prompt: ({ input }) => + [ + `Q: ${input.question}`, + `Memory: ${input.memory.join(' | ')}`, + `Docs: ${input.documents.join(' | ')}`, + ].join('\n'), + }); + + const agent = setupAgent({ + context: z.object({ + question: z.string(), + memory: z.array(z.string()), + documents: z.array(z.string()), + answer: z.string().nullable(), + }), + input: z.object({ + question: z.string(), + memory: z.array(z.string()).default([]), + }), + output: z.object({ answer: z.string(), memory: z.array(z.string()) }), + actors: { + answerWithDocuments, + retrieve: fromPromise( + async ({ input }) => [`doc:${input.question}`, 'doc:remembered-state'] + ), + }, + }); + + const machine = agent.createMachine({ + id: 'burr-conversational-rag-xstate', + context: ({ input }) => ({ + question: input.question, + memory: input.memory, + documents: [], + answer: null, + }), + initial: 'retrieving', + states: { + retrieving: { + invoke: { + src: 'retrieve', + input: ({ context }) => ({ question: context.question }), + onDone: { + target: 'answering', + actions: assign({ documents: ({ event }) => event.output }), + }, + }, + }, + answering: { + invoke: { + src: 'answerWithDocuments', + input: ({ context }) => ({ + question: context.question, + documents: context.documents, + memory: context.memory, + }), + onDone: { + target: 'done', + actions: assign({ + answer: ({ event }) => event.output, + memory: ({ context, event }) => [ + ...context.memory, + context.question, + event.output, + ], + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + answer: context.answer ?? '', + memory: context.memory, + }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + answerWithDocuments: answerWithDocuments.withExecutor(async ({ input }) => + `answer:${input.documents.join(',')}:memory=${input.memory.length}` + ), + }, + }), + { input: { question: 'why burr?', memory: ['prior turn'] } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ + answer: 'answer:doc:why burr?,doc:remembered-state:memory=1', + memory: [ + 'prior turn', + 'why burr?', + 'answer:doc:why burr?,doc:remembered-state:memory=1', + ], + }); + }); + + test('streaming-overview router keeps safety and mode as explicit states', async () => { + const modeSchema = z.object({ + mode: z.enum(['answer_question', 'generate_code', 'generate_image', 'unknown']), + }); + const chooseMode = createTextLogic({ + schemas: { + input: z.object({ prompt: z.string() }), + output: modeSchema, + }, + model: 'mode-router', + system: 'Choose the response mode.', + prompt: ({ input }) => input.prompt, + }); + const answerPrompt = createTextLogic({ + schemas: { + input: z.object({ prompt: z.string(), mode: modeSchema.shape.mode }), + output: z.string(), + }, + model: 'streaming-writer', + prompt: ({ input }) => `${input.mode}:${input.prompt}`, + }); + + const agent = setupAgent({ + context: z.object({ + prompt: z.string(), + safe: z.boolean(), + mode: modeSchema.shape.mode.nullable(), + response: z.string().nullable(), + }), + input: z.object({ prompt: z.string() }), + output: z.object({ response: z.string() }), + actors: { chooseMode, answerPrompt }, + }); + + const machine = agent.createMachine({ + id: 'burr-streaming-router-xstate', + context: ({ input }) => ({ + prompt: input.prompt, + safe: false, + mode: null, + response: null, + }), + initial: 'checkSafety', + states: { + checkSafety: { + entry: assign({ + safe: ({ context }) => !context.prompt.includes('unsafe'), + }), + always: [ + { guard: ({ context }) => context.safe, target: 'decideMode' }, + { target: 'unsafeResponse' }, + ], + }, + decideMode: { + invoke: { + src: 'chooseMode', + input: ({ context }) => ({ prompt: context.prompt }), + onDone: { + target: 'route', + actions: assign({ mode: ({ event }) => event.output.mode }), + }, + }, + }, + route: { + always: [ + { guard: ({ context }) => context.mode === 'unknown', target: 'promptForMore' }, + { target: 'answering' }, + ], + }, + answering: { + invoke: { + src: 'answerPrompt', + input: ({ context }) => ({ + prompt: context.prompt, + mode: context.mode ?? 'answer_question', + }), + onDone: { + target: 'done', + actions: assign({ response: ({ event }) => event.output }), + }, + }, + }, + promptForMore: { + entry: assign({ response: 'Please clarify.' }), + always: { target: 'done' }, + }, + unsafeResponse: { + entry: assign({ response: 'I cannot respond to that.' }), + always: { target: 'done' }, + }, + done: { + type: 'final', + output: ({ context }) => ({ response: context.response ?? '' }), + }, + }, + }); + + const chunks: string[] = []; + const actor = createActor( + machine.provide({ + actors: { + chooseMode: chooseMode.withExecutor(async () => ({ mode: 'generate_code' })), + answerPrompt: answerPrompt.withExecutor(async ({ input }) => { + chunks.push('chunk:1'); + chunks.push('chunk:2'); + return `response:${input.mode}:${input.prompt}`; + }), + }, + }), + { input: { prompt: 'write a TypeScript function' } } + ); + actor.start(); + await toPromise(actor); + + expect(chunks).toEqual(['chunk:1', 'chunk:2']); + expect(actor.getSnapshot().output).toEqual({ + response: 'response:generate_code:write a TypeScript function', + }); + }); + + test('tool-calling separates tool selection, tool execution, and final formatting', async () => { + const selectTool = createTextLogic({ + schemas: { + input: z.object({ query: z.string() }), + output: z.discriminatedUnion('tool', [ + z.object({ + tool: z.literal('queryWeather'), + parameters: z.object({ latitude: z.number(), longitude: z.number() }), + }), + z.object({ + tool: z.literal('fallback'), + parameters: z.object({ response: z.string() }), + }), + ]), + }, + model: 'tool-router', + system: 'Select exactly one tool.', + prompt: ({ input }) => input.query, + }); + const formatResult = createTextLogic({ + schemas: { + input: z.object({ + query: z.string(), + rawResponse: z.record(z.string(), z.unknown()), + }), + output: z.string(), + }, + model: 'formatter', + prompt: ({ input }) => + `Question: ${input.query}\nData: ${JSON.stringify(input.rawResponse)}`, + }); + + const agent = setupAgent({ + context: z.object({ + query: z.string(), + selected: selectTool.schemas.output.nullable(), + rawResponse: z.record(z.string(), z.unknown()).nullable(), + finalOutput: z.string().nullable(), + }), + input: z.object({ query: z.string() }), + output: z.object({ finalOutput: z.string() }), + actors: { + selectTool, + formatResult, + queryWeather: fromPromise< + Record, + { latitude: number; longitude: number } + >(async ({ input }) => ({ + forecast: 'sunny', + location: `${input.latitude},${input.longitude}`, + })), + fallback: fromPromise, { response: string }>( + async ({ input }) => ({ response: input.response }) + ), + }, + }); + + const machine = agent.createMachine({ + id: 'burr-tool-calling-xstate', + context: ({ input }) => ({ + query: input.query, + selected: null, + rawResponse: null, + finalOutput: null, + }), + initial: 'selectingTool', + states: { + selectingTool: { + invoke: { + src: 'selectTool', + input: ({ context }) => ({ query: context.query }), + onDone: { + target: 'dispatch', + actions: assign({ selected: ({ event }) => event.output }), + }, + }, + }, + dispatch: { + always: [ + { + guard: ({ context }) => context.selected?.tool === 'queryWeather', + target: 'queryingWeather', + }, + { target: 'fallingBack' }, + ], + }, + queryingWeather: { + invoke: { + src: 'queryWeather', + input: ({ context }) => + context.selected?.tool === 'queryWeather' + ? context.selected.parameters + : { latitude: 0, longitude: 0 }, + onDone: { + target: 'formatting', + actions: assign({ rawResponse: ({ event }) => event.output }), + }, + }, + }, + fallingBack: { + invoke: { + src: 'fallback', + input: ({ context }) => + context.selected?.tool === 'fallback' + ? context.selected.parameters + : { response: 'No tool selected.' }, + onDone: { + target: 'formatting', + actions: assign({ rawResponse: ({ event }) => event.output }), + }, + }, + }, + formatting: { + invoke: { + src: 'formatResult', + input: ({ context }) => ({ + query: context.query, + rawResponse: context.rawResponse ?? {}, + }), + onDone: { + target: 'done', + actions: assign({ finalOutput: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ finalOutput: context.finalOutput ?? '' }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + selectTool: selectTool.withExecutor(async () => ({ + tool: 'queryWeather', + parameters: { latitude: 37.77, longitude: -122.42 }, + })), + formatResult: formatResult.withExecutor(async ({ input }) => `formatted:${input.rawResponse.forecast}`), + }, + }), + { input: { query: 'weather in San Francisco' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ finalOutput: 'formatted:sunny' }); + }); + + test('typed-state structured output remains schema-derived and testable', async () => { + const conceptSchema = z.object({ + term: z.string(), + definition: z.string(), + timestamp: z.number(), + }); + const postSchema = z.object({ + topic: z.string(), + hook: z.string(), + body: z.string(), + concepts: z.array(conceptSchema), + keyTakeaways: z.array(z.string()), + }); + const generatePost = createTextLogic({ + schemas: { + input: z.object({ transcript: z.string() }), + output: postSchema, + }, + model: 'post-writer', + system: 'Generate a social media post from the transcript.', + prompt: ({ input }) => input.transcript, + }); + + const agent = setupAgent({ + context: z.object({ + youtubeUrl: z.string(), + transcript: z.string().nullable(), + post: postSchema.nullable(), + }), + input: z.object({ youtubeUrl: z.string() }), + output: z.object({ post: postSchema }), + actors: { + generatePost, + getTranscript: fromPromise( + async ({ input }) => `transcript:${input.youtubeUrl}` + ), + }, + }); + + const machine = agent.createMachine({ + id: 'burr-typed-state-xstate', + context: ({ input }) => ({ + youtubeUrl: input.youtubeUrl, + transcript: null, + post: null, + }), + initial: 'gettingTranscript', + states: { + gettingTranscript: { + invoke: { + src: 'getTranscript', + input: ({ context }) => ({ youtubeUrl: context.youtubeUrl }), + onDone: { + target: 'generatingPost', + actions: assign({ transcript: ({ event }) => event.output }), + }, + }, + }, + generatingPost: { + invoke: { + src: 'generatePost', + input: ({ context }) => ({ transcript: context.transcript ?? '' }), + onDone: { + target: 'done', + actions: assign({ post: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + post: context.post ?? { + topic: '', + hook: '', + body: '', + concepts: [], + keyTakeaways: [], + }, + }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + generatePost: generatePost.withExecutor(async ({ input }) => ({ + topic: 'Burr', + hook: 'Stateful AI apps need structure.', + body: input.transcript, + concepts: [{ term: 'state', definition: 'durable memory', timestamp: 1 }], + keyTakeaways: ['Keep state explicit'], + })), + }, + }), + { input: { youtubeUrl: 'https://youtube.test/watch?v=abc' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output?.post).toEqual( + expect.objectContaining({ + topic: 'Burr', + concepts: [ + { term: 'state', definition: 'durable memory', timestamp: 1 }, + ], + }) + ); + }); + + test('multi-agent collaboration is supervisor routing over typed workers', async () => { + const routeWork = createTextLogic({ + schemas: { + input: z.object({ task: z.string() }), + output: z.object({ route: z.enum(['researcher', 'chartGenerator']) }), + }, + model: 'supervisor', + prompt: ({ input }) => input.task, + }); + + const agent = setupAgent({ + context: z.object({ + task: z.string(), + route: z.enum(['researcher', 'chartGenerator']).nullable(), + result: z.string().nullable(), + }), + input: z.object({ task: z.string() }), + output: z.object({ result: z.string() }), + actors: { + routeWork, + researcher: fromPromise( + async ({ input }) => `research:${input.task}` + ), + chartGenerator: fromPromise( + async ({ input }) => `chart:${input.task}` + ), + }, + }); + + const machine = agent.createMachine({ + id: 'burr-multi-agent-collaboration-xstate', + context: ({ input }) => ({ + task: input.task, + route: null, + result: null, + }), + initial: 'supervising', + states: { + supervising: { + invoke: { + src: 'routeWork', + input: ({ context }) => ({ task: context.task }), + onDone: { + target: 'dispatch', + actions: assign({ route: ({ event }) => event.output.route }), + }, + }, + }, + dispatch: { + always: [ + { + guard: ({ context }) => context.route === 'chartGenerator', + target: 'charting', + }, + { target: 'researching' }, + ], + }, + researching: { + invoke: { + src: 'researcher', + input: ({ context }) => ({ task: context.task }), + onDone: { + target: 'done', + actions: assign({ result: ({ event }) => event.output }), + }, + }, + }, + charting: { + invoke: { + src: 'chartGenerator', + input: ({ context }) => ({ task: context.task }), + onDone: { + target: 'done', + actions: assign({ result: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ result: context.result ?? '' }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + routeWork: routeWork.withExecutor(async () => ({ route: 'chartGenerator' })), + }, + }), + { input: { task: 'plot revenue' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ result: 'chart:plot revenue' }); + }); +}); diff --git a/src/cloudflare/index.test.ts b/src/cloudflare/index.test.ts deleted file mode 100644 index 18b8e5f..0000000 --- a/src/cloudflare/index.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { - createCloudflareAgentRunStore, - createDurableObjectRunStore, - type CloudflareAgentRunStoreState, -} from './index.js'; - -describe('cloudflare adapter', () => { - test('creates a Durable Object RunStore', async () => { - const storage = new Map(); - const store = createDurableObjectRunStore({ - async get(key) { - return storage.get(key) as never; - }, - async put(key, value) { - storage.set(key, value); - }, - }); - - await store.append('session-1', { type: 'start', at: 1 }); - await store.saveSnapshot({ - sessionId: 'session-1', - afterSequence: 1, - createdAt: 2, - snapshot: { - sessionId: 'session-1', - createdAt: 2, - value: 'done', - status: 'done', - context: {}, - messages: [], - input: {}, - }, - }); - - await expect(store.loadEvents('session-1')).resolves.toEqual([ - { - type: 'start', - at: 1, - sequence: 1, - }, - ]); - await expect(store.loadLatestSnapshot('session-1')).resolves.toEqual( - expect.objectContaining({ - afterSequence: 1, - }) - ); - }); - - test('creates a Cloudflare Agents state-backed RunStore', async () => { - let state: CloudflareAgentRunStoreState = { - sessions: {}, - }; - const store = createCloudflareAgentRunStore({ - getState: () => state, - setState: (nextState) => { - state = nextState; - }, - }); - - await store.append('session-1', { type: 'approve', at: 1 }); - await store.saveSnapshot({ - sessionId: 'session-1', - afterSequence: 1, - createdAt: 2, - snapshot: { - sessionId: 'session-1', - createdAt: 2, - value: 'done', - status: 'done', - context: {}, - messages: [], - input: {}, - }, - }); - - await expect(store.loadEvents('session-1')).resolves.toEqual([ - { - type: 'approve', - at: 1, - sequence: 1, - }, - ]); - await expect(store.loadLatestSnapshot('session-1')).resolves.toEqual( - expect.objectContaining({ - afterSequence: 1, - }) - ); - }); -}); diff --git a/src/cloudflare/index.ts b/src/cloudflare/index.ts deleted file mode 100644 index 008f0bc..0000000 --- a/src/cloudflare/index.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { - AgentSnapshot, - JournalEvent, - JournalEventRecord, - PersistedSnapshot, - RunStore, -} from '../types.js'; - -export interface DurableObjectStorageLike { - get(key: string): Promise; - put(key: string, value: T): Promise; -} - -export interface DurableObjectStateLike { - storage: DurableObjectStorageLike; -} - -export function createDurableObjectRunStore( - storage: DurableObjectStorageLike -): RunStore { - return { - async append(sessionId, event) { - const key = journalKey(sessionId); - const current = (await storage.get(key)) ?? []; - const sequence = - current.length === 0 - ? 1 - : current[current.length - 1]!.sequence + 1; - - await storage.put(key, [...current, { ...event, sequence }]); - return { sequence }; - }, - - async loadEvents(sessionId, afterSequence = 0) { - const current = - (await storage.get[]>( - journalKey(sessionId) - )) ?? []; - - return current - .filter((event) => event.sequence > afterSequence) - .sort((a, b) => a.sequence - b.sequence); - }, - - async loadLatestSnapshot(sessionId) { - const snapshots = - (await storage.get[]>( - snapshotsKey(sessionId) - )) ?? []; - - return ( - [...snapshots].sort( - (a, b) => - a.afterSequence - b.afterSequence || a.createdAt - b.createdAt - ).at(-1) ?? null - ); - }, - - async saveSnapshot(snapshot) { - const key = snapshotsKey(snapshot.sessionId); - const current = - (await storage.get[]>(key)) ?? []; - - await storage.put(key, [...current, snapshot]); - }, - }; -} - -type SessionEntry = { - events: JournalEventRecord[]; - snapshot: PersistedSnapshot | null; -}; - -export type CloudflareAgentRunStoreState = { - sessions: Record; -}; - -export function createCloudflareAgentRunStore(options: { - getState: () => CloudflareAgentRunStoreState; - setState: ( - nextState: CloudflareAgentRunStoreState - ) => void | Promise; -}): RunStore { - return { - async append(sessionId, event) { - const currentState = options.getState(); - const currentSession = currentState.sessions[sessionId] ?? { - events: [], - snapshot: null, - }; - const sequence = currentSession.events.length + 1; - const nextSession: SessionEntry = { - ...currentSession, - events: [...currentSession.events, { ...event, sequence }], - }; - - await options.setState({ - ...currentState, - sessions: { - ...currentState.sessions, - [sessionId]: nextSession, - }, - }); - - return { sequence }; - }, - - async loadEvents(sessionId, afterSequence = 0) { - return ( - options.getState().sessions[sessionId]?.events.filter( - (event) => event.sequence > afterSequence - ) ?? [] - ); - }, - - async loadLatestSnapshot(sessionId) { - return options.getState().sessions[sessionId]?.snapshot ?? null; - }, - - async saveSnapshot(snapshot) { - const currentState = options.getState(); - const currentSession = currentState.sessions[snapshot.sessionId] ?? { - events: [], - snapshot: null, - }; - - await options.setState({ - ...currentState, - sessions: { - ...currentState.sessions, - [snapshot.sessionId]: { - ...currentSession, - snapshot, - }, - }, - }); - }, - }; -} - -function journalKey(sessionId: string): string { - return `sessions/${sessionId}/journal`; -} - -function snapshotsKey(sessionId: string): string { - return `sessions/${sessionId}/snapshots`; -} diff --git a/src/crewai-equivalents/content-creator-flow.test.ts b/src/crewai-equivalents/content-creator-flow.test.ts deleted file mode 100644 index 402e353..0000000 --- a/src/crewai-equivalents/content-creator-flow.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createContentCreatorFlowExample } from '../../examples/index.js'; - -describe('CrewAI content creator flow equivalent', () => { - test('routes a request and generates specialized content', async () => { - const machine = createContentCreatorFlowExample({ - routeRequest: async () => ({ route: 'linkedin' }), - createLinkedInPost: async (request) => ({ - title: 'LinkedIn launch post', - body: `LinkedIn: ${request}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ - request: 'Announce our AI workflow launch in a short professional post.', - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - route: 'linkedin', - title: 'LinkedIn launch post', - body: - 'LinkedIn: Announce our AI workflow launch in a short professional post.', - }); - } - }); -}); diff --git a/src/crewai-equivalents/email-auto-responder-flow.test.ts b/src/crewai-equivalents/email-auto-responder-flow.test.ts deleted file mode 100644 index accd299..0000000 --- a/src/crewai-equivalents/email-auto-responder-flow.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { runEmailAutoResponderFlowExample } from '../../examples/index.js'; - -describe('CrewAI email auto responder flow equivalent', () => { - test('processes new emails and restores the same durable snapshot', async () => { - const result = await runEmailAutoResponderFlowExample( - [ - { - id: 'email-1', - sender: 'buyer@example.com', - subject: 'Pricing question', - body: 'Can you send pricing details?', - }, - { - id: 'email-2', - sender: 'founder@example.com', - subject: 'Partnership', - body: 'Interested in discussing a partnership.', - }, - ], - { - createDraft: async (email) => ({ - draft: `Draft for ${email.subject}`, - }), - } - ); - - expect(result.snapshot).toEqual(result.restoredSnapshot); - expect(result.snapshot).toEqual( - expect.objectContaining({ - value: 'waiting', - status: 'pending', - }) - ); - expect(result.snapshot.context.processedIds).toEqual(['email-1', 'email-2']); - expect(result.snapshot.context.drafts).toEqual({ - 'email-1': 'Draft for Pricing question', - 'email-2': 'Draft for Partnership', - }); - }); -}); diff --git a/src/crewai-equivalents/lead-score-flow.test.ts b/src/crewai-equivalents/lead-score-flow.test.ts deleted file mode 100644 index cfe5e6a..0000000 --- a/src/crewai-equivalents/lead-score-flow.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createLeadScoreFlowExample } from '../../examples/index.js'; - -describe('CrewAI lead score flow equivalent', () => { - test('supports human review before generating outreach emails', async () => { - const machine = createLeadScoreFlowExample({ - scoreLeads: async ({ leads, reviewNote }) => ({ - scoredLeads: leads.map((lead, index) => ({ - ...lead, - score: 100 - index * 10 - (reviewNote ? 3 : 0), - rationale: reviewNote ?? 'initial', - })), - }), - writeEmails: async (leads) => ({ - drafts: leads.map((lead) => ({ - leadId: lead.id, - draft: `Email for ${lead.company}`, - })), - }), - }); - - const initial = machine.getInitialState({ - leads: [ - { id: 'lead-1', company: 'Acme', contact: 'Ana' }, - { id: 'lead-2', company: 'Beta', contact: 'Ben' }, - { id: 'lead-3', company: 'Gamma', contact: 'Gia' }, - ], - }); - const firstPass = await execute(machine, initial); - expect(firstPass.status).toBe('pending'); - if (firstPass.status !== 'pending') { - return; - } - - const rescored = machine.transition(firstPass.state, { - type: 'review.requestChanges', - note: 'Prefer companies already asking for demos.', - }); - const secondPass = await execute(machine, rescored); - expect(secondPass.status).toBe('pending'); - if (secondPass.status !== 'pending') { - return; - } - - const approved = machine.transition(secondPass.state, { - type: 'review.approve', - }); - const finalResult = await execute(machine, approved); - - expect(finalResult.status).toBe('done'); - if (finalResult.status === 'done') { - expect(finalResult.output.reviewCount).toBe(2); - expect(finalResult.output.topLeads).toHaveLength(3); - expect(finalResult.output.emailDrafts).toEqual([ - { leadId: 'lead-1', draft: 'Email for Acme' }, - { leadId: 'lead-2', draft: 'Email for Beta' }, - { leadId: 'lead-3', draft: 'Email for Gamma' }, - ]); - } - }); -}); diff --git a/src/crewai-equivalents/meeting-assistant-flow.test.ts b/src/crewai-equivalents/meeting-assistant-flow.test.ts deleted file mode 100644 index 1f825c8..0000000 --- a/src/crewai-equivalents/meeting-assistant-flow.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createMeetingAssistantFlowExample } from '../../examples/index.js'; - -describe('CrewAI meeting assistant flow equivalent', () => { - test('fans one meeting summary into multiple side effects', async () => { - const machine = createMeetingAssistantFlowExample({ - extractTasks: async () => ({ - summary: 'Agreed on launch scope and follow-ups.', - tasks: [ - { title: 'Send launch checklist', owner: 'Ana' }, - { title: 'Prepare customer email', owner: 'Ben' }, - ], - }), - addTasksToTrello: async (tasks) => ({ - trelloCardIds: tasks.map((_, index) => `card-${index + 1}`), - }), - saveTasksToCsv: async () => ({ csvPath: 'new_tasks.csv' }), - sendSlackNotification: async () => ({ slackMessageId: 'slack-123' }), - }); - - const result = await execute(machine, - machine.getInitialState({ - notes: 'Meeting notes go here.', - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - summary: 'Agreed on launch scope and follow-ups.', - tasks: [ - { title: 'Send launch checklist', owner: 'Ana' }, - { title: 'Prepare customer email', owner: 'Ben' }, - ], - trelloCardIds: ['card-1', 'card-2'], - csvPath: 'new_tasks.csv', - slackMessageId: 'slack-123', - }); - } - }); -}); diff --git a/src/crewai-equivalents/raw-xstate.test.ts b/src/crewai-equivalents/raw-xstate.test.ts new file mode 100644 index 0000000..c80562c --- /dev/null +++ b/src/crewai-equivalents/raw-xstate.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { assign, createActor, fromPromise, toPromise } from 'xstate'; +import { createTextLogic, setupAgent } from '../index.js'; + +describe('CrewAI-style flows authored as XState setup machines', () => { + test('content creator routes and generates specialized content', async () => { + const routeContent = createTextLogic({ + schemas: { + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['linkedin', 'blog']) }), + }, + model: 'router', + prompt: ({ input }) => input.request, + }); + const createContent = createTextLogic({ + schemas: { + input: z.object({ + route: z.enum(['linkedin', 'blog']), + request: z.string(), + }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => `${input.route}:${input.request}`, + }); + const agent = setupAgent({ + context: z.object({ + request: z.string(), + route: z.enum(['linkedin', 'blog']).nullable(), + content: z.string().nullable(), + }), + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['linkedin', 'blog']), content: z.string() }), + actors: { routeContent, createContent }, + }); + + const machine = agent.createMachine({ + id: 'crewai-content-creator-xstate', + context: ({ input }) => ({ request: input.request, route: null, content: null }), + initial: 'routing', + states: { + routing: { + invoke: { + src: 'routeContent', + input: ({ context }) => ({ request: context.request }), + onDone: { + target: 'creating', + actions: assign({ route: ({ event }) => event.output.route }), + }, + }, + }, + creating: { + invoke: { + src: 'createContent', + input: ({ context }) => ({ + route: context.route ?? 'blog', + request: context.request, + }), + onDone: { + target: 'done', + actions: assign({ content: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + route: context.route ?? 'blog', + content: context.content ?? '', + }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + routeContent: routeContent.withExecutor( + async () => ({ route: 'linkedin' }) + ), + createContent: createContent.withExecutor( + async ({ input }) => `Post for ${input.route}:${input.request}`, + ), + }, + }), + { input: { request: 'launch update' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ + route: 'linkedin', + content: 'Post for linkedin:launch update', + }); + }); + + test('write-a-book fans out chapter workers and compiles a manuscript', async () => { + const outlineBook = createTextLogic({ + schemas: { + input: z.object({ brief: z.string() }), + output: z.object({ + title: z.string(), + chapters: z.array(z.string()), + }), + }, + model: 'outliner', + prompt: ({ input }) => input.brief, + }); + const agent = setupAgent({ + context: z.object({ + brief: z.string(), + title: z.string().nullable(), + chapters: z.array(z.string()), + manuscript: z.string().nullable(), + }), + input: z.object({ brief: z.string() }), + output: z.object({ title: z.string(), manuscript: z.string() }), + actors: { + outlineBook, + writeChapters: fromPromise( + async ({ input }) => + input.chapters.map((chapter: string) => `${chapter}: body`) + ), + }, + }); + + const machine = agent.createMachine({ + id: 'crewai-write-book-xstate', + context: ({ input }) => ({ + brief: input.brief, + title: null, + chapters: [], + manuscript: null, + }), + initial: 'outlining', + states: { + outlining: { + invoke: { + src: 'outlineBook', + input: ({ context }) => ({ brief: context.brief }), + onDone: { + target: 'writing', + actions: assign({ + title: ({ event }) => event.output.title, + chapters: ({ event }) => event.output.chapters, + }), + }, + }, + }, + writing: { + invoke: { + src: 'writeChapters', + input: ({ context }) => ({ chapters: context.chapters }), + onDone: { + target: 'done', + actions: assign({ + manuscript: ({ event }) => event.output.join('\n'), + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + title: context.title ?? '', + manuscript: context.manuscript ?? '', + }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + outlineBook: outlineBook.withExecutor( + async () => ({ title: 'The Workflow Book', chapters: ['Intro', 'Runtime'] }) + ), + }, + }), + { input: { brief: 'state machines for agents' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ + title: 'The Workflow Book', + manuscript: 'Intro: body\nRuntime: body', + }); + }); +}); diff --git a/src/crewai-equivalents/self-evaluation-loop-flow.test.ts b/src/crewai-equivalents/self-evaluation-loop-flow.test.ts deleted file mode 100644 index 433d766..0000000 --- a/src/crewai-equivalents/self-evaluation-loop-flow.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createSelfEvaluationLoopFlowExample } from '../../examples/index.js'; - -describe('CrewAI self evaluation loop equivalent', () => { - test('iterates until the generated post passes evaluation', async () => { - const attempts: string[] = []; - const machine = createSelfEvaluationLoopFlowExample({ - generatePost: async ({ feedback, attempt }) => { - const post = - attempt === 1 - ? 'A very long post with too much detail and maybe an emoji :)' - : `Refined post after: ${feedback}`; - attempts.push(post); - return { post }; - }, - evaluatePost: async (post) => - post.includes('Refined') - ? { valid: true, feedback: null } - : { - valid: false, - feedback: 'Shorten it and remove emoji-like punctuation.', - }, - }); - - const result = await execute(machine, - machine.getInitialState({ - topic: 'Flying cars', - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output.valid).toBe(true); - expect(result.output.attempt).toBe(2); - expect(attempts).toHaveLength(2); - expect(result.output.post).toContain('Refined post after'); - } - }); -}); diff --git a/src/crewai-equivalents/write-a-book-flow.test.ts b/src/crewai-equivalents/write-a-book-flow.test.ts deleted file mode 100644 index 1945fc4..0000000 --- a/src/crewai-equivalents/write-a-book-flow.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createWriteABookFlowExample } from '../../examples/index.js'; - -describe('CrewAI write a book flow equivalent', () => { - test('outlines a book, writes chapters in parallel, and compiles a manuscript', async () => { - const machine = createWriteABookFlowExample({ - createOutline: async () => ({ - title: 'The Workflow Book', - chapters: [ - { title: 'Chapter 1', brief: 'Introduction' }, - { title: 'Chapter 2', brief: 'Execution' }, - ], - }), - writeChapter: async ({ title, brief }) => ({ - title, - content: `${title}: ${brief}`, - }), - compileManuscript: async ({ title, chapters }) => ({ - manuscript: [ - `# ${title}`, - ...chapters.map((chapter) => `## ${chapter.title}\n${chapter.content}`), - ].join('\n\n'), - }), - }); - - const result = await execute(machine, - machine.getInitialState({ - topic: 'Workflow systems', - goal: 'Teach developers how to build durable AI workflows.', - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output.title).toBe('The Workflow Book'); - expect(result.output.outline).toHaveLength(2); - expect(result.output.chapters).toEqual([ - { title: 'Chapter 1', content: 'Chapter 1: Introduction' }, - { title: 'Chapter 2', content: 'Chapter 2: Execution' }, - ]); - expect(result.output.manuscript).toContain('# The Workflow Book'); - } - }); -}); diff --git a/src/examples.test.ts b/src/examples.test.ts index 6077efd..b4a821a 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -1,2087 +1,172 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from './local/index.js'; -import { z } from 'zod'; -import { restoreSession, startSession } from './local/index.js'; - +import { describe, expect, test } from 'vitest'; +import { createActor, fromPromise, toPromise, waitFor } from 'xstate'; import { - createAiSdkExample, - createChatbotExample, - AgentNetworkDurableObject, - createCloudflareAgentRunStore, - createDurableObjectRunStore, - createAdapterExample, - createBranchingExample, - createClassifyExample, - createConditionalSubflowExample, - createCustomerServiceSimExample, - createDecideExample, - createChatbotMessagesExample, - createEmailDrafterExample, - createEmailExample, - createErrorRetryExample, - createHitlExample, - createPersistenceExample, - createPersistenceSessionHttpHandler, - createStreamingSessionHttpController, - createJokeExample, - createJugsExample, - createMapReduceExample, - createMultiAgentNetworkExample, - createNewspaperExample, - createNextAiSdkUiRoute, - createNextReviewRouteHandlers, - createNextStreamingRouteHandlers, - runPersistenceExample, - runPersistentMultiAgentNetworkExample, - runPersistentStreamingExample, - runPersistentSupervisorExample, - createPlanAndExecuteExample, - createRaffleExample, - createRagExample, - createReactAgentExample, - createRewooExample, - createReflectionExample, - createRiverCrossingExample, - createSimpleExample, - createSqlAgentExample, - createGuardrailedBugfixWorkflowExample, - createGuardrailedIncidentResponseExample, - createUnguardedIncidentResponseExample, - createSubflowExample, - createSupervisorExample, - createToolCallingExample, - createTutorExample, + emailDrafter, + emailDrafterSchemas, + evaluatePrompt, + draftEmail, + chooseMove as chooseMoveLogic, + gameMachine, + gameSchemas, + summarizeTurn, } from '../examples/index.js'; - -function createSseReader(response: Response) { - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - return { - async next(): Promise<{ event: string; data: unknown }> { - while (true) { - const match = buffer.match(/^event: ([^\n]+)\ndata: ([^\n]+)\n\n/); - if (match) { - buffer = buffer.slice(match[0].length); +import { + getAgentEffects, + type AgentTextInput, + transitionResult, +} from './index.js'; +import { initialTransition, transition } from 'xstate'; + +describe('curated XState setup examples', () => { + test('email drafter follows prompt, revise, send loop with normal XState runtime', async () => { + const calls: AgentTextInput[] = []; + const sent: unknown[] = []; + const machine = emailDrafter.provide({ + actors: { + evaluatePrompt: evaluatePrompt.withExecutor(async ({ input, request }) => { + calls.push(request); + const satisfied = + calls.filter((call) => call.system?.includes('Evaluate')).length > 1; return { - event: match[1]!, - data: JSON.parse(match[2]!), + satisfied, + missing: satisfied ? [] : ['recipient'], + questions: satisfied ? [] : ['Who should receive it?'], }; - } - - const chunk = await reader.read(); - if (chunk.done) { - throw new Error('SSE stream closed before the next event was available.'); - } - - buffer += decoder.decode(chunk.value, { stream: true }); - } - }, - - async cancel() { - await reader.cancel(); - }, - }; -} - -describe('curated examples', () => { - test('simple example runs to a final output', async () => { - const machine = createSimpleExample({ - generateText: async () => ({ summary: 'A short summary.' }), - }); - const result = await execute(machine, - machine.getInitialState({ text: 'Longer source text.' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ summary: 'A short summary.' }); - } - }); - - test('ai sdk example routes and drafts a structured reply', async () => { - const machine = createAiSdkExample({ - adapter: { - decide: async () => ({ - choice: 'billing', - data: { confidence: 0.93 }, }), - }, - draftReply: async ({ route, confidence, message }) => ({ - subject: `${route.toUpperCase()} reply`, - body: `${message} :: ${confidence.toFixed(2)}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ message: 'Please refund invoice 123.' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - route: 'billing', - confidence: 0.93, - subject: 'BILLING reply', - body: 'Please refund invoice 123. :: 0.93', - }); - } - }); - - test('email drafter follows the prompt, assess, draft, review, send loop', async () => { - const outputs = [ - { - satisfied: false, - missing: ['to', 'subject'], - questions: ['Who should receive it?', 'What subject should I use?'], - }, - { - satisfied: true, - missing: [], - questions: [], - }, - { - to: 'Riley', - subject: 'Thanks for meeting', - body: 'Hi Riley,\n\nThanks for meeting today.', - }, - { - to: 'Riley', - subject: 'Thanks for meeting', - body: 'Hi Riley,\n\nThanks for meeting today. I will send next steps tomorrow.', - }, - ]; - const sentEmails: Array<{ to: string; subject: string; body: string }> = []; - const machine = createEmailDrafterExample({ - adapter: { - generateText: async () => outputs.shift(), - }, - sendEmail: async (draft) => { - sentEmails.push(draft); - }, - }); - - const first = await execute(machine, machine.getInitialState()); - - expect(first.status).toBe('pending'); - if (first.status !== 'pending') { - return; - } - - expect(first.value).toBe('prompting'); - - const needsMoreInfo = await execute(machine, - machine.transition(first.state, { - type: 'PROMPT_SUBMITTED', - prompt: 'Write a thank you email after the meeting.', - }) - ); - - expect(needsMoreInfo.status).toBe('pending'); - if (needsMoreInfo.status !== 'pending') { - return; - } - - expect(needsMoreInfo.value).toBe('needsMoreInfo'); - expect(needsMoreInfo.context.assessment?.questions).toEqual([ - 'Who should receive it?', - 'What subject should I use?', - ]); - - const afterAnswer = await execute(machine, - machine.transition(needsMoreInfo.state, { - type: 'MORE_INFO', - details: 'Send it to Riley. Subject: Thanks for meeting.', - }) - ); - - expect(afterAnswer.status).toBe('pending'); - if (afterAnswer.status !== 'pending') { - return; - } - - expect(afterAnswer.value).toBe('reviewing'); - expect(afterAnswer.context.draft).toEqual({ - to: 'Riley', - subject: 'Thanks for meeting', - body: 'Hi Riley,\n\nThanks for meeting today.', - }); - - const afterRevise = await execute(machine, - machine.transition(afterAnswer.state, { - type: 'REQUEST_CHANGES', - changes: 'Mention next steps tomorrow.', - }) - ); - - expect(afterRevise.status).toBe('pending'); - if (afterRevise.status !== 'pending') { - return; - } - - expect(afterRevise.value).toBe('reviewing'); - expect(afterRevise.context.draft?.body).toContain('next steps tomorrow'); - - const sent = await execute(machine, - machine.transition(afterRevise.state, { - type: 'SEND', - }) - ); - - expect(sent.status).toBe('pending'); - if (sent.status !== 'pending') { - return; - } - - expect(sent.value).toBe('sent'); - expect(sentEmails).toEqual([ - { - to: 'Riley', - subject: 'Thanks for meeting', - body: 'Hi Riley,\n\nThanks for meeting today. I will send next steps tomorrow.', - }, - ]); - - const done = await execute(machine, - machine.transition(sent.state, { - type: 'END', - }) - ); - - expect(done.status).toBe('done'); - if (done.status === 'done') { - expect(done.output).toEqual({ - sentEmails: [ - { - to: 'Riley', + draftEmail: draftEmail.withExecutor(async ({ request }) => { + calls.push(request); + return { + to: 'riley@example.com', subject: 'Thanks for meeting', - body: 'Hi Riley,\n\nThanks for meeting today. I will send next steps tomorrow.', - }, - ], - }); - } - }); - - test('chatbot messages example accumulates structured conversation turns', async () => { - const machine = createChatbotMessagesExample(async (messages) => ({ - message: { - role: 'assistant', - content: `Replying to: ${messages.at(-1)?.content ?? ''}`, - }, - })); - - const afterUserMessage = machine.transition(machine.getInitialState(), { - type: 'messages.user', - message: { - role: 'user', - content: 'Hello there', - }, - }); - const result = await execute(machine, afterUserMessage); - - expect(result.status).toBe('pending'); - if (result.status === 'pending') { - expect(result.messages).toEqual([ - { role: 'user', content: 'Hello there' }, - { role: 'assistant', content: 'Replying to: Hello there' }, - ]); - expect(result.context.finalMessage).toEqual({ - role: 'assistant', - content: 'Replying to: Hello there', - }); - } - }); - - test('rag example retrieves context and produces a grounded answer', async () => { - const machine = createRagExample({ - retrieve: async (question) => ({ - documents: [ - { id: 'doc-1', content: `${question} :: first fact` }, - { id: 'doc-2', content: `${question} :: second fact` }, - ], - }), - adapter: { - generateText: async ({ prompt }) => ({ - answer: String(prompt) - .replace('Question: ', '') - .replace('\n\nDocuments:\n- [doc-1] ', ' => ') - .replace('\n- [doc-2] ', ' | '), + body: 'Hi Riley, thanks for meeting today.', + }; }), - }, - }); - - const result = await execute(machine, - machine.getInitialState({ question: 'What is LangGraph?' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - question: 'What is LangGraph?', - documents: [ - { id: 'doc-1', content: 'What is LangGraph? :: first fact' }, - { id: 'doc-2', content: 'What is LangGraph? :: second fact' }, - ], - answer: - 'What is LangGraph? => What is LangGraph? :: first fact | What is LangGraph? :: second fact', - }); - } - }); - - test('persistence example restores a durable session to the same final snapshot', async () => { - const result = await runPersistenceExample( - { request: 'Approve the annual budget summary.' }, - { - summarize: async ({ request, approved }) => ({ - summary: `${request} :: approved=${String(approved)}`, + sendEmail: fromPromise(async ({ input }) => { + sent.push(input); + return { sent: true }; }), - } - ); - - expect(result.liveSnapshot).toEqual(result.restoredSnapshot); - expect(result.liveSnapshot).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { - request: 'Approve the annual budget summary.', - approved: true, - summary: 'Approve the annual budget summary. :: approved=true', - }, - }) - ); - }); - - test('cloudflare durable object example store persists journal and snapshots', async () => { - const storage = new Map(); - const store = createDurableObjectRunStore({ - async get(key) { - return storage.get(key) as never; - }, - async put(key, value) { - storage.set(key, value); - }, - }); - - await store.append('session-1', { - type: 'xstate.init', - at: 1, - }); - await store.append('session-1', { - type: 'approve', - at: 2, - }); - await store.saveSnapshot({ - sessionId: 'session-1', - afterSequence: 2, - snapshot: { - value: 'done', - context: {}, - messages: [], - status: 'done', - createdAt: 2, - sessionId: 'session-1', - input: {}, - }, - createdAt: 2, - }); - - await expect(store.loadEvents('session-1')).resolves.toEqual([ - expect.objectContaining({ sequence: 1, type: 'xstate.init' }), - expect.objectContaining({ sequence: 2, type: 'approve' }), - ]); - await expect(store.loadLatestSnapshot('session-1')).resolves.toEqual( - expect.objectContaining({ - sessionId: 'session-1', - afterSequence: 2, - }) - ); - }); - - test('cloudflare agents example store persists durable sessions in synced state', async () => { - let state = { - sessions: {}, - }; - const store = createCloudflareAgentRunStore({ - getState: () => state, - setState: async (nextState) => { - state = nextState; }, }); - const machine = createPersistenceExample(async ({ request, approved }) => ({ - summary: `${request} :: approved=${String(approved)}`, - })); - - const run = await startSession(machine, { - store, - input: { - request: 'Approve the Cloudflare rollout.', - }, - }); - - await run.send({ type: 'approve' }); - - const restored = await restoreSession(machine, { - sessionId: run.sessionId, - store, - }); - - expect(restored.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { - request: 'Approve the Cloudflare rollout.', - approved: true, - summary: 'Approve the Cloudflare rollout. :: approved=true', - }, - }) - ); - expect(Object.keys(state.sessions)).toEqual([run.sessionId]); - }); - - test('http session example exposes start, send, and status over Request/Response', async () => { - const handle = createPersistenceSessionHttpHandler({ - summarize: async ({ request, approved }) => ({ - summary: `${request} :: approved=${String(approved)}`, - }), - }); - - const startResponse = await handle( - new Request('https://agent.test/sessions', { - method: 'POST', - body: JSON.stringify({ - request: 'Approve the annual budget summary.', - }), - headers: { - 'content-type': 'application/json', - }, - }) - ); - const startBody = await startResponse.json() as { - sessionId: string; - snapshot: { value: string; status: string }; - }; - - expect(startBody.snapshot).toEqual( - expect.objectContaining({ - value: 'review', - status: 'active', - }) - ); - - const sendResponse = await handle( - new Request(`https://agent.test/sessions/${startBody.sessionId}/events`, { - method: 'POST', - body: JSON.stringify({ type: 'approve' }), - headers: { - 'content-type': 'application/json', - }, - }) - ); - const sendBody = await sendResponse.json() as { - snapshot: { - value: string; - status: string; - output: { - request: string; - approved: boolean; - summary: string; - }; - }; - }; - - expect(sendBody.snapshot).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { - request: 'Approve the annual budget summary.', - approved: true, - summary: 'Approve the annual budget summary. :: approved=true', - }, - }) - ); - - const statusResponse = await handle( - new Request(`https://agent.test/sessions/${startBody.sessionId}`, { - method: 'GET', - }) - ); - const statusBody = await statusResponse.json() as { - snapshot: { - value: string; - status: string; - output: { - request: string; - approved: boolean; - summary: string; - }; - }; - }; - - expect(statusBody.snapshot).toEqual(sendBody.snapshot); - }); - - test('http streaming session example reconnects with only new SSE parts after restore', async () => { - const controller = createStreamingSessionHttpController(); - - const startResponse = await controller.handle( - new Request('https://agent.test/sessions', { - method: 'POST', - body: JSON.stringify({ - streamId: 'stream-1', - text: 'hello', - }), - headers: { - 'content-type': 'application/json', - }, - }) - ); - const startBody = await startResponse.json() as { - sessionId: string; - }; - - const firstStreamResponse = await controller.handle( - new Request(`https://agent.test/sessions/${startBody.sessionId}/stream`) - ); - const firstReader = createSseReader(firstStreamResponse); - controller.advance('stream-1'); + const actor = createActor(machine); + actor.start(); - await expect(firstReader.next()).resolves.toEqual({ - event: 'textPart', - data: { - type: 'textPart', - delta: 'hel', - }, + actor.send({ + type: 'PROMPT_SUBMITTED', + prompt: 'Write a thank you email after the meeting.', }); + await waitFor(actor, (snapshot) => snapshot.matches('needsMoreInfo')); - await firstReader.cancel(); - controller.dropActiveSession(startBody.sessionId); - - const secondStreamResponse = await controller.handle( - new Request(`https://agent.test/sessions/${startBody.sessionId}/stream`) - ); - const secondReader = createSseReader(secondStreamResponse); - - controller.advance('stream-1'); - - await expect(secondReader.next()).resolves.toEqual({ - event: 'textPart', - data: { - type: 'textPart', - delta: 'lo', - }, - }); - await expect(secondReader.next()).resolves.toEqual({ - event: 'done', - data: { - text: 'hello', - }, + actor.send({ + type: 'MORE_INFO', + details: 'Send it to riley@example.com.', }); + await waitFor(actor, (snapshot) => snapshot.matches('reviewing')); - const statusResponse = await controller.handle( - new Request(`https://agent.test/sessions/${startBody.sessionId}`) - ); - const statusBody = await statusResponse.json() as { - snapshot: { - value: string; - status: string; - output: { text: string }; - }; - }; - - expect(statusBody.snapshot).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { text: 'hello' }, - }) - ); - }); - - test('next app router review example adapts Request/Response handlers to dynamic route params', async () => { - const routes = createNextReviewRouteHandlers({ - summarize: async ({ request, approved }) => ({ - summary: `${request} :: approved=${String(approved)}`, - }), + expect(actor.getSnapshot().context.draft).toEqual({ + to: 'riley@example.com', + subject: 'Thanks for meeting', + body: 'Hi Riley, thanks for meeting today.', }); - - const startResponse = await routes.sessions.POST( - new Request('https://agent.test/api/agent', { - method: 'POST', - body: JSON.stringify({ - request: 'Approve the quarterly report.', - }), - headers: { - 'content-type': 'application/json', - }, - }) - ); - const startBody = await startResponse.json() as { - sessionId: string; - snapshot: { value: string; status: string }; - }; - - expect(startBody.snapshot).toEqual( + expect(calls.at(-1)).toEqual( expect.objectContaining({ - value: 'review', - status: 'active', - }) - ); - - const sendResponse = await routes.events.POST( - new Request(`https://agent.test/api/agent/${startBody.sessionId}/events`, { - method: 'POST', - body: JSON.stringify({ type: 'approve' }), - headers: { - 'content-type': 'application/json', - }, - }), + prompt: undefined, + messages: expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: 'Write a thank you email after the meeting.', + }), + expect.objectContaining({ + role: 'user', + content: expect.stringContaining('Send it to riley@example.com.'), + }), + ]), + }) + ); + + actor.send({ type: 'SEND' }); + await waitFor(actor, (snapshot) => snapshot.matches('sent')); + actor.send({ type: 'END' }); + await toPromise(actor); + + expect(sent).toEqual([ { - params: Promise.resolve({ - sessionId: startBody.sessionId, - }), - } - ); - const sendBody = await sendResponse.json() as { - snapshot: { - value: string; - status: string; - output: { - request: string; - approved: boolean; - summary: string; - }; - }; - }; - - expect(sendBody.snapshot).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { - request: 'Approve the quarterly report.', - approved: true, - summary: 'Approve the quarterly report. :: approved=true', - }, - }) - ); - }); - - test('next app router streaming example reconnects with only new streamed parts', async () => { - const routes = createNextStreamingRouteHandlers(); - - const startResponse = await routes.sessions.POST( - new Request('https://agent.test/api/agent', { - method: 'POST', - body: JSON.stringify({ - streamId: 'next-stream-1', - text: 'hello', - }), - headers: { - 'content-type': 'application/json', + draft: { + to: 'riley@example.com', + subject: 'Thanks for meeting', + body: 'Hi Riley, thanks for meeting today.', }, - }) - ); - const startBody = await startResponse.json() as { sessionId: string }; - - const firstStreamResponse = await routes.stream.GET( - new Request(`https://agent.test/api/agent/${startBody.sessionId}/stream`), - { - params: Promise.resolve({ - sessionId: startBody.sessionId, - }), - } - ); - const firstReader = createSseReader(firstStreamResponse); - - routes.advance('next-stream-1'); - - await expect(firstReader.next()).resolves.toEqual({ - event: 'textPart', - data: { - type: 'textPart', - delta: 'hel', - }, - }); - - await firstReader.cancel(); - routes.dropActiveSession(startBody.sessionId); - - const secondStreamResponse = await routes.stream.GET( - new Request(`https://agent.test/api/agent/${startBody.sessionId}/stream`), - { - params: Promise.resolve({ - sessionId: startBody.sessionId, - }), - } - ); - const secondReader = createSseReader(secondStreamResponse); - - routes.advance('next-stream-1'); - - await expect(secondReader.next()).resolves.toEqual({ - event: 'textPart', - data: { - type: 'textPart', - delta: 'lo', - }, - }); - await expect(secondReader.next()).resolves.toEqual({ - event: 'done', - data: { - text: 'hello', }, + ]); + expect(actor.getSnapshot().output).toEqual({ + sentEmails: [ + { + to: 'riley@example.com', + subject: 'Thanks for meeting', + body: 'Hi Riley, thanks for meeting today.', + }, + ], }); }); - test('next app-shaped route files import cleanly', async () => { - const [ - routesModule, - chatRouteModule, - reviewSessionsRouteModule, - reviewSessionRouteModule, - reviewEventsRouteModule, - streamingSessionsRouteModule, - streamingSessionRouteModule, - streamingStreamRouteModule, - ] = await Promise.all([ - import(new URL('../examples/apps/next/lib/routes.ts', import.meta.url).href), - import(new URL('../examples/apps/next/app/api/chat/route.ts', import.meta.url).href), - import( - new URL('../examples/apps/next/app/api/review-sessions/route.ts', import.meta.url).href - ), - import( - new URL( - '../examples/apps/next/app/api/review-sessions/[sessionId]/route.ts', - import.meta.url - ).href - ), - import( - new URL( - '../examples/apps/next/app/api/review-sessions/[sessionId]/events/route.ts', - import.meta.url - ).href - ), - import( - new URL('../examples/apps/next/app/api/stream-sessions/route.ts', import.meta.url).href - ), - import( - new URL( - '../examples/apps/next/app/api/stream-sessions/[sessionId]/route.ts', - import.meta.url - ).href - ), - import( - new URL( - '../examples/apps/next/app/api/stream-sessions/[sessionId]/stream/route.ts', - import.meta.url - ).href - ), - ]); - - expect(routesModule.runtime).toBe('nodejs'); - expect(typeof routesModule.chatRoute.POST).toBe('function'); - expect(typeof routesModule.reviewRoutes.sessions.POST).toBe('function'); - expect(typeof routesModule.streamingRoutes.stream.GET).toBe('function'); - expect(typeof chatRouteModule.POST).toBe('function'); - expect(typeof reviewSessionsRouteModule.POST).toBe('function'); - expect(typeof reviewSessionRouteModule.GET).toBe('function'); - expect(typeof reviewEventsRouteModule.POST).toBe('function'); - expect(typeof streamingSessionsRouteModule.POST).toBe('function'); - expect(typeof streamingSessionRouteModule.GET).toBe('function'); - expect(typeof streamingStreamRouteModule.GET).toBe('function'); - }); + test('email drafter exports schemas for host-side event validation', () => { + const result = emailDrafterSchemas.events.PROMPT_SUBMITTED['~standard'].validate({ + type: 'PROMPT_SUBMITTED', + prompt: 'Draft an email', + }); - test('next ai sdk ui route streams UI message parts from machine emissions', async () => { - const route = createNextAiSdkUiRoute({ - streamReply: async ({ messages, onDelta }) => { - expect(messages.at(-1)).toMatchObject({ - role: 'user', - }); - onDelta('Hel'); - onDelta('lo'); - return { text: 'Hello' }; + expect(result).toEqual({ + value: { + prompt: 'Draft an email', }, }); - - const response = await route.POST( - new Request('https://agent.test/api/chat', { - method: 'POST', - body: JSON.stringify({ - messages: [ - { - id: 'user-1', - role: 'user', - parts: [ - { - type: 'text', - text: 'Say hello.', - }, - ], - }, - ], - }), - headers: { - 'content-type': 'application/json', - }, - }) - ); - const body = await response.text(); - - expect(response.headers.get('x-vercel-ai-ui-message-stream')).toBe('v1'); - expect(body).toContain('"type":"data-notification"'); - expect(body).toContain('"message":"Drafting reply..."'); - expect(body).toContain('"type":"source"'); - expect(body).toContain('"title":"Stately Agent documentation"'); - expect(body).toContain('"type":"text-start"'); - expect(body).toContain('"delta":"Hel"'); - expect(body).toContain('"delta":"lo"'); - expect(body).toContain('"type":"text-end"'); }); - test('cloudflare durable network example restores and settles a network run', async () => { - const storage = new Map(); - const firstInstance = new AgentNetworkDurableObject({ - storage: { - async get(key) { - return storage.get(key) as never; - }, - async put(key, value) { - storage.set(key, value); - }, - }, + test('game workflow exposes only whitelisted moves as event tools', async () => { + let [snapshot, actions] = initialTransition(gameMachine, { + playerHp: 20, + enemyHp: 15, }); - const startResponse = await firstInstance.fetch( - new Request('https://example.com/start', { - method: 'POST', - body: JSON.stringify({ topic: 'durable networks' }), - }) - ); - const started = await startResponse.json() as { - sessionId: string; - snapshot: { status: string }; - }; - - const resumedInstance = new AgentNetworkDurableObject({ - storage: { - async get(key) { - return storage.get(key) as never; - }, - async put(key, value) { - storage.set(key, value); - }, - }, + const [chooseMove] = getAgentEffects(actions, { + snapshot, + schemas: gameSchemas, + actors: { chooseMove: chooseMoveLogic, summarizeTurn }, }); - const resumeResponse = await resumedInstance.fetch( - new Request( - `https://example.com/resume?sessionId=${started.sessionId}`, - { method: 'POST' } - ) - ); - const resumed = await resumeResponse.json() as { - sessionId: string; - snapshot: { - status: string; - output: { - topic: string; - handoffs: string[]; - }; - }; - }; - - expect(started.sessionId).toBe(resumed.sessionId); - expect(resumed.snapshot.status).toBe('done'); - expect(resumed.snapshot.output).toEqual( - expect.objectContaining({ - topic: 'durable networks', - handoffs: [ - 'researcher:collect the strongest supporting facts', - 'writer:turn the current notes into a concise summary', - ], - }) - ); - }); - test('hitl example exposes typed pending events', async () => { - const machine = createHitlExample(); - const result = await execute(machine, - machine.getInitialState({ task: 'Draft an answer' }) - ); + expect(chooseMove?.events.map((event) => event.type)).toEqual([ + 'ATTACK', + 'DEFEND', + 'FLEE', + ]); - expect(result.status).toBe('pending'); - if (result.status === 'pending') { - expect(result.value).toBe('gathering'); - expect(result.events['user.message']).toBeDefined(); - expect(result.events['user.approve']).toBeDefined(); + const attackTool = chooseMove!.tools['event.ATTACK']!; + if (typeof attackTool === 'function') { + throw new Error('Expected event tool descriptor.'); } - }); + const attackEvent = await attackTool.execute?.({ target: 'goblin' }); - test('decide example chooses a branch and carries typed data', async () => { - const machine = createDecideExample({ - decide: async () => ({ - choice: 'askForClarification', - data: { question: 'Which order is affected?' }, - }), + [snapshot, actions] = transition(gameMachine, snapshot, attackEvent as never); + const [summarize] = getAgentEffects(actions, { + snapshot, + schemas: gameSchemas, + actors: { chooseMove: chooseMoveLogic, summarizeTurn }, }); - const result = await execute(machine, - machine.getInitialState({ - request: 'The customer says their invoice is wrong.', - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - action: 'askForClarification', - payload: { question: 'Which order is affected?' }, - }); - } - }); + expect(summarize?.events).toEqual([]); - test('classify example reduces to a category only', async () => { - const machine = createClassifyExample({ - decide: async () => ({ - choice: 'billing', - data: {}, - }), + [snapshot] = transitionResult(gameMachine, snapshot, summarize!, { + summary: 'You strike the goblin.', + playerHp: 20, + enemyHp: 9, }); - const result = await execute(machine, - machine.getInitialState({ - request: 'I need help with a refund for my duplicate charge.', - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ category: 'billing' }); - } - }); - - test('adapter example uses the provided schema-aware adapter', async () => { - const machine = createAdapterExample({ - decide: async () => ({ - choice: 'billing', - data: { confidence: 0.9 }, - }), - }); - const result = await execute(machine, machine.getInitialState({ message: 'refund my last invoice' })); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - route: 'billing', - confidence: 0.9, - }); - } - }); - - test('error retry example recovers from transient invoke failures', async () => { - const machine = createErrorRetryExample(async ({ attempt }) => { - if (attempt === 1) { - throw new Error('temporary outage'); - } - - return { answer: 'Recovered answer.' }; - }); - - const result = await execute(machine, - machine.getInitialState({ question: 'Can this retry?' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - answer: 'Recovered answer.', - attempts: 2, - errors: ['temporary outage'], - }); - } - }); - - test('conditional subflow example routes directly into the requested child flow', async () => { - const machine = createConditionalSubflowExample({ - draft: async ({ topic, bullets }) => ({ - draft: `${topic}: ${bullets.join(', ')}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ - topic: 'state machines', - mode: 'draft', - bullets: ['deterministic', 'resumable'], - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - mode: 'draft', - bullets: ['deterministic', 'resumable'], - draft: 'state machines: deterministic, resumable', - }); - } - }); - - test('persistent multi-agent network example restores from a mid-handoff snapshot', async () => { - let step = 0; - const result = await runPersistentMultiAgentNetworkExample( - { topic: 'resumable coordination' }, - { - adapter: { - decide: async () => { - step += 1; - - if (step === 1) { - return { - choice: 'research', - data: { focus: 'collect durable coordination notes' }, - }; - } - - if (step === 2) { - return { - choice: 'write', - data: { angle: 'produce the final coordination memo' }, - }; - } - - return { - choice: 'finalize', - data: {}, - }; - }, - }, - research: async ({ topic, focus }) => ({ - notes: [`${topic}:${focus}:a`, `${topic}:${focus}:b`], - }), - write: async ({ topic, notes, angle }) => ({ - draft: `${topic} | ${angle} | ${notes.join(' / ')}`, - }), - } - ); - - expect(result.restoredSnapshot).toEqual(result.liveSnapshot); - expect(result.liveSnapshot).toEqual( - expect.objectContaining({ - value: 'done', - output: { - topic: 'resumable coordination', - notes: [ - 'resumable coordination:collect durable coordination notes:a', - 'resumable coordination:collect durable coordination notes:b', - ], - draft: - 'resumable coordination | produce the final coordination memo | resumable coordination:collect durable coordination notes:a / resumable coordination:collect durable coordination notes:b', - handoffs: [ - 'researcher:collect durable coordination notes', - 'writer:produce the final coordination memo', - ], - }, - }) - ); - }); - - test('persistent supervisor example restores from a persisted retry handoff', async () => { - let decisions = 0; - - const result = await runPersistentSupervisorExample( - { request: 'Reverse the duplicate subscription charge.' }, - { - adapter: { - decide: async () => { - decisions += 1; - - if (decisions === 1) { - return { - choice: 'retry', - data: { - instruction: 'Retry using the verified billing email on file.', - }, - }; - } - - return { - choice: 'escalate', - data: { - reason: 'Escalate to billing because the account is still ambiguous.', - }, - }; - }, - }, - handle: async ({ attempt, instruction }) => ({ - status: 'blocked' as const, - issue: - attempt === 1 - ? 'Missing account identifier.' - : `Still blocked after retry: ${instruction}`, - }), - maxAttempts: 2, - } - ); - - expect(result.liveSnapshot).toEqual(result.restoredSnapshot); - expect(result.liveSnapshot).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { - request: 'Reverse the duplicate subscription charge.', - status: 'escalated', - resolution: null, - escalationReason: - 'Escalate to billing because the account is still ambiguous.', - attemptCount: 2, - history: [ - 'worker:1:blocked:Missing account identifier.', - 'supervisor:retry:Retry using the verified billing email on file.', - 'worker:2:blocked:Still blocked after retry: Retry using the verified billing email on file.', - 'supervisor:escalate:Escalate to billing because the account is still ambiguous.', - ], - }, - }) - ); - }); - - test('persistent streaming example resumes with only new live parts after restore', async () => { - const result = await runPersistentStreamingExample(); - - expect(result.initialParts).toEqual(['hel']); - expect(result.restoredParts).toEqual(['lo']); - expect(result.initialSnapshot).toEqual( - expect.objectContaining({ - value: 'writing', - status: 'active', - }) - ); - expect(result.restoredSnapshot).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { text: 'hello' }, - }) - ); - expect(result.journal.map((event) => event.type)).toEqual([ - 'xstate.init', - 'xstate.done.invoke.writing', - ]); - }); - - test('branching example fans out plain async work and summarizes it', async () => { - const machine = createBranchingExample({ - analyzeDocs: async () => 'docs', - analyzeIssues: async () => 'issues', - analyzeCode: async () => 'code', - summarize: async ({ docs, issues, code }) => ({ - summary: `${docs}/${issues}/${code}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ topic: 'agents' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - docs: 'docs', - issues: 'issues', - code: 'code', - summary: 'docs/issues/code', - }); - } - }); - - test('decide example uses structured payloads while classify does not', async () => { - const decideMachine = createDecideExample({ - decide: async () => ({ - choice: 'reply', - data: { message: 'Hello there' }, - }), - }); - const classifyMachine = createClassifyExample({ - decide: async () => ({ - choice: 'general', - data: {}, - }), - }); - - const decideResult = await execute(decideMachine, - decideMachine.getInitialState({ - request: 'Please answer this support question.', - }) - ); - const classifyResult = await execute(classifyMachine, - classifyMachine.getInitialState({ - request: 'This is a general support question.', - }) - ); - - expect(decideResult.status).toBe('done'); - expect(classifyResult.status).toBe('done'); - - if (decideResult.status === 'done' && classifyResult.status === 'done') { - expect(decideResult.output).toEqual({ - action: 'reply', - payload: { message: 'Hello there' }, - }); - expect(classifyResult.output).toEqual({ category: 'general' }); - } - }); - - test('hitl example event schemas validate payloads', async () => { - const machine = createHitlExample(); - const pending = await execute(machine, - machine.getInitialState({ task: 'Draft an answer' }) - ); - - expect(pending.status).toBe('pending'); - if (pending.status === 'pending') { - const validation = pending.events['user.message']!['~standard'].validate({ - type: 'user.message', - message: 'Here is the missing detail', - }); - - expect(validation.issues).toBeUndefined(); - } - }); - - test('decide example uses schemas on branch payloads', async () => { - const machine = createDecideExample({ - decide: async () => ({ - choice: 'reply', - data: { message: 'Resolved' }, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ - request: 'Please respond to this support request.', - }) - ); - - expect(result.status).toBe('done'); - expect( - z - .object({ - action: z.string(), - payload: z.object({ message: z.string() }), - }) - .safeParse(result.status === 'done' ? result.output : null).success - ).toBe(true); - }); - - test('chatbot example accepts a user message and replies', async () => { - const machine = createChatbotExample({ - adapter: { - decide: async () => ({ choice: 'respond', data: {} }), - }, - reply: async () => ({ response: 'Assistant reply' }), - }); - - const pending = await execute(machine, machine.getInitialState()); - expect(pending.status).toBe('pending'); - - if (pending.status === 'pending') { - const next = machine.transition(pending.state, { - type: 'user.message', - message: 'Hello there', - }); - const result = await execute(machine, next); - - expect(result.status).toBe('pending'); - if (result.status === 'pending') { - expect(result.context.transcript).toEqual([ - 'User: Hello there', - 'Assistant: Assistant reply', - ]); - } - } - }); - - test('customer service sim example reaches a terminal outcome', async () => { - const machine = createCustomerServiceSimExample({ - serviceReply: async () => ({ response: 'We can help.' }), - customerReply: async () => ({ - response: 'Thanks, that works.', - done: true, - outcome: 'resolved', - }), - }); - - const result = await execute(machine, - machine.getInitialState({ issue: 'I want a refund.' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - transcript: [ - 'Customer: I want a refund.', - 'Agent: We can help.', - 'Customer: Thanks, that works.', - ], - turnCount: 1, - outcome: 'resolved', - }); - } - }); - - test('email example can pause for clarification and then draft using tools', async () => { - let checkCount = 0; - const machine = createEmailExample({ - adapter: { - decide: async () => { - checkCount += 1; - return checkCount === 1 - ? { - choice: 'askForClarification', - data: { questions: ['Which day should I offer?'] }, - } - : { choice: 'draft', data: {} }; - }, - }, - tools: { - lookupContactName: async () => 'Pat Lee', - lookupAvailability: async () => ['Friday at 1 PM'], - createSignature: async (name) => `Best,\n${name}`, - }, - compose: async ({ - email, - instructions, - clarifications, - contactName, - availability, - signature, - }) => ({ - replyEmail: [ - `Hi ${contactName},`, - '', - `Thanks for your note: "${email}"`, - instructions, - clarifications.join(' '), - `I am available ${availability.join(' or ')}.`, - '', - signature, - ] - .filter(Boolean) - .join('\n'), - }), + expect(snapshot.status).toBe('done'); + expect(snapshot.output).toEqual({ + outcome: 'continue', + summary: 'You strike the goblin.', + playerHp: 20, + enemyHp: 9, }); - - const first = await execute(machine, - machine.getInitialState({ - email: 'Can you meet next week?', - instructions: 'Reply with one specific slot.', - }) - ); - - expect(first.status).toBe('pending'); - if (first.status === 'pending') { - expect(first.context.questions).toEqual(['Which day should I offer?']); - - const next = machine.transition(first.state, { - type: 'user.answer', - answer: 'Offer Friday afternoon.', - }); - const done = await execute(machine, next); - - expect(done.status).toBe('done'); - if (done.status === 'done') { - expect( - z - .object({ - replyEmail: z.string(), - clarifications: z.array(z.string()), - }) - .safeParse(done.output).success - ).toBe(true); - } - } - }); - - test('joke example produces a rating and acceptance flag', async () => { - const results = [ - { joke: 'A short joke about ducks.' }, - { rating: 9, explanation: 'It works.' }, - ]; - const machine = createJokeExample({ - generateText: async () => results.shift(), - }); - - const result = await execute(machine, - machine.getInitialState({ topic: 'ducks' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - topic: 'ducks', - joke: 'A short joke about ducks.', - rating: 9, - explanation: 'It works.', - accepted: true, - }); - } - }); - - test('jugs example solves the 3 and 5 gallon puzzle', async () => { - const machine = createJugsExample(); - const result = await execute(machine, machine.getInitialState()); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - jug3: 3, - jug5: 4, - steps: [ - 'Filled the 5-gallon jug.', - 'Poured from the 5-gallon jug into the 3-gallon jug.', - 'Emptied the 3-gallon jug.', - 'Poured from the 5-gallon jug into the 3-gallon jug.', - 'Filled the 5-gallon jug.', - 'Poured from the 5-gallon jug into the 3-gallon jug.', - ], - reasoning: [ - 'Start by filling the larger jug.', - 'Transfer water into the 3-gallon jug.', - 'Empty the smaller jug to make room.', - 'Move the remaining water into the 3-gallon jug.', - 'Refill the 5-gallon jug.', - 'Top off the 3-gallon jug to leave 4 gallons.', - 'The 5-gallon jug now holds exactly 4 gallons.', - ], - }); - } - }); - - test('map-reduce example decomposes work items and reduces the result', async () => { - const machine = createMapReduceExample({ - planSubjects: async () => ({ - subjects: ['one', 'two'], - }), - writeJoke: async (subject) => `joke:${subject}`, - chooseBest: async (jokes) => ({ - bestJoke: jokes.at(-1) ?? '', - }), - }); - - const result = await execute(machine, - machine.getInitialState({ topic: 'agents' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - subjects: ['one', 'two'], - jokes: ['joke:one', 'joke:two'], - bestJoke: 'joke:two', - }); - } - }); - - test('subflow example composes a child machine inside a parent workflow', async () => { - const machine = createSubflowExample({ - research: async (topic) => ({ - bullets: [`fact about ${topic}`, `detail about ${topic}`], - }), - write: async ({ topic, bullets }) => ({ - draft: `${topic}: ${bullets.join(' / ')}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ topic: 'agents' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - bullets: ['fact about agents', 'detail about agents'], - draft: 'agents: fact about agents / detail about agents', - }); - } - }); - - test('multi-agent network example coordinates specialist handoffs through a supervisor state', async () => { - let step = 0; - - const machine = createMultiAgentNetworkExample({ - adapter: { - decide: async () => { - step += 1; - - if (step === 1) { - return { - choice: 'research', - data: { focus: 'collect technical notes' }, - }; - } - - if (step === 2) { - return { - choice: 'write', - data: { angle: 'produce a short memo' }, - }; - } - - return { - choice: 'finalize', - data: {}, - }; - }, - }, - research: async ({ topic, focus }) => ({ - notes: [`${topic}:${focus}:a`, `${topic}:${focus}:b`], - }), - write: async ({ topic, notes, angle }) => ({ - draft: `${topic} | ${angle} | ${notes.join(' / ')}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ topic: 'durable agents' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - topic: 'durable agents', - notes: [ - 'durable agents:collect technical notes:a', - 'durable agents:collect technical notes:b', - ], - draft: - 'durable agents | produce a short memo | durable agents:collect technical notes:a / durable agents:collect technical notes:b', - handoffs: [ - 'researcher:collect technical notes', - 'writer:produce a short memo', - ], - }); - } - }); - - test('tool-calling example emits live tool activity and completes with output', async () => { - const machine = createToolCallingExample(async (city, emitProgress) => { - emitProgress({ - toolName: 'getWeather', - message: `Checking radar for ${city}`, - step: 1, - }); - emitProgress({ - toolName: 'getWeather', - message: `Preparing forecast for ${city}`, - step: 2, - }); - - return { - forecast: `Rainy in ${city}`, - }; - }); - - const { createMemoryRunStore, startSession } = await import('./local/index.js'); - const run = await startSession(machine, { - store: createMemoryRunStore(), - input: { city: 'New York' }, - }); - const events: string[] = []; - - run.on('toolCall', (event) => { - events.push(`call:${event.toolName}`); - }); - run.on('toolProgress', (event) => { - events.push(`progress:${event.toolName}:${event.step}`); - }); - run.on('toolResult', (event) => { - events.push(`result:${event.toolName}`); - }); - - await new Promise((resolve, reject) => { - run.onDone(() => resolve()); - run.onError((event) => reject(event.error)); - }); - - expect(events).toEqual([ - 'call:getWeather', - 'progress:getWeather:1', - 'progress:getWeather:2', - 'result:getWeather', - ]); - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - output: { forecast: 'Rainy in New York' }, - }) - ); - }); - - test('sql-agent example retries after a bad query and then answers from rows', async () => { - let decisions = 0; - - const machine = createSqlAgentExample({ - adapter: { - decide: async () => { - decisions += 1; - - if (decisions === 1) { - return { - choice: 'query', - data: { - query: 'SELECT total FROM invoices WHERE customer = "Acme"', - }, - }; - } - - if (decisions === 2) { - return { - choice: 'query', - data: { - query: "SELECT customer, total FROM invoices WHERE customer = 'Acme'", - }, - }; - } - - return { - choice: 'answer', - data: { - answer: 'Acme has one invoice total of 42.', - }, - }; - }, - }, - executeQuery: async ({ query }) => { - if (query.includes('"Acme"')) { - return { - status: 'error' as const, - error: 'SQL syntax error near double quotes.', - }; - } - - return { - status: 'success' as const, - rows: [{ customer: 'Acme', total: 42 }], - }; - }, - }); - - const result = await execute(machine, - machine.getInitialState({ - question: 'What is Acme owed?', - schema: 'invoices(customer text, total integer)', - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - question: 'What is Acme owed?', - schema: 'invoices(customer text, total integer)', - answer: 'Acme has one invoice total of 42.', - latestRows: [{ customer: 'Acme', total: 42 }], - latestError: null, - queryHistory: [ - 'SELECT total FROM invoices WHERE customer = "Acme"', - "SELECT customer, total FROM invoices WHERE customer = 'Acme'", - ], - }); - } - }); - - test('react agent example loops through a tool and returns a final answer', async () => { - const { createMemoryRunStore, startSession } = await import('./local/index.js'); - const agent = createReactAgentExample({ - search: async (query) => `result for ${query}`, - model: async ({ messages }) => { - const last = messages.at(-1); - - if (!last || last.role === 'user') { - return { - kind: 'tool' as const, - toolName: 'search', - input: { query: 'weather in sf' }, - message: 'Searching for weather in sf', - }; - } - - if (last.role === 'tool') { - return { - kind: 'final' as const, - message: `I found: ${last.content}`, - }; - } - - return { - kind: 'final' as const, - message: 'I could not complete the request.', - }; - }, - }); - const run = await startSession(agent, { - store: createMemoryRunStore(), - input: { - messages: [{ role: 'user', content: 'weather in sf' }], - }, - }); - const events: string[] = []; - - run.on('toolCall', (event) => { - events.push(`call:${event.toolName}`); - }); - run.on('toolResult', (event) => { - events.push(`result:${event.toolName}`); - }); - - await new Promise((resolve, reject) => { - run.onDone(() => resolve()); - run.onError((event) => reject(event.error)); - }); - - expect(events).toEqual(['call:search', 'result:search']); - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - output: expect.objectContaining({ - finalMessage: 'I found: result for weather in sf', - }), - }) - ); - }); - - test('rewoo example plans named steps, executes them with references, and solves the objective', async () => { - const machine = createRewooExample({ - plan: async () => ({ - steps: [ - { - id: 'E1', - instruction: 'Collect a fact', - input: 'LangGraphJS', - }, - { - id: 'E2', - instruction: 'Summarize the fact', - input: 'Use #E1 in one concise sentence', - }, - ], - }), - executeStep: async ({ step, resolvedInput }) => ({ - result: `${step.id}:${resolvedInput}`, - }), - solve: async ({ resultsById }) => ({ - answer: `${resultsById.E1} | ${resultsById.E2}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ objective: 'understand the repo' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - objective: 'understand the repo', - steps: [ - { - id: 'E1', - instruction: 'Collect a fact', - input: 'LangGraphJS', - }, - { - id: 'E2', - instruction: 'Summarize the fact', - input: 'Use #E1 in one concise sentence', - }, - ], - resultsById: { - E1: 'E1:LangGraphJS', - E2: 'E2:Use E1:LangGraphJS in one concise sentence', - }, - answer: 'E1:LangGraphJS | E2:Use E1:LangGraphJS in one concise sentence', - }); - } - }); - - test('supervisor example retries a blocked worker and can still resolve the request', async () => { - let decisions = 0; - - const machine = createSupervisorExample({ - adapter: { - decide: async () => { - decisions += 1; - - return { - choice: decisions === 1 ? 'retry' : 'escalate', - data: - decisions === 1 - ? { instruction: 'Retry using the customer email on file.' } - : { reason: 'Escalate to billing.' }, - }; - }, - }, - handle: async ({ attempt, instruction }) => - attempt === 1 - ? { - status: 'blocked' as const, - issue: 'Missing account identifier.', - } - : { - status: 'resolved' as const, - response: `Resolved after retry: ${instruction}`, - }, - }); - - const result = await execute(machine, - machine.getInitialState({ - request: 'Fix the duplicate subscription charge.', - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - request: 'Fix the duplicate subscription charge.', - status: 'resolved', - resolution: 'Resolved after retry: Retry using the customer email on file.', - escalationReason: null, - attemptCount: 2, - history: [ - 'worker:1:blocked:Missing account identifier.', - 'supervisor:retry:Retry using the customer email on file.', - 'worker:2:resolved:Resolved after retry: Retry using the customer email on file.', - ], - }); - } - }); - - test('newspaper example loops through critique and revision', async () => { - const machine = createNewspaperExample({ - search: async () => ({ searchResults: ['a', 'b', 'c'] }), - curate: async () => ({ searchResults: ['a', 'b'] }), - write: async () => ({ article: 'Draft article' }), - critique: async (_article, revisionCount) => ({ - critique: revisionCount === 0 ? 'Tighten the ending.' : null, - }), - revise: async (article, critique) => ({ - article: `${article} Revised: ${critique}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ topic: 'Robotics' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - topic: 'Robotics', - article: 'Draft article Revised: Tighten the ending.', - revisionCount: 1, - searchResults: ['a', 'b'], - }); - } - }); - - test('plan-and-execute example creates a plan, executes steps, and synthesizes', async () => { - const machine = createPlanAndExecuteExample({ - plan: async () => ({ - plan: ['one', 'two'], - }), - executeStep: async ({ step }) => ({ - result: `result:${step}`, - }), - synthesize: async ({ stepResults }) => ({ - answer: stepResults.join(' + '), - }), - }); - - const result = await execute(machine, - machine.getInitialState({ goal: 'ship a feature' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - goal: 'ship a feature', - plan: ['one', 'two'], - stepResults: ['result:one', 'result:two'], - answer: 'result:one + result:two', - }); - } - }); - - test('raffle example collects entries and reports a winner', async () => { - const machine = createRaffleExample(async (entries) => ({ - winningEntry: entries[1] ?? '', - firstRunnerUp: entries[0] ?? '', - secondRunnerUp: entries[2] ?? '', - explanation: 'Selected the second entry for the demo.', - })); - - const pending = await execute(machine, machine.getInitialState()); - expect(pending.status).toBe('pending'); - - if (pending.status === 'pending') { - let state = machine.transition(pending.state, { - type: 'user.entry', - entry: 'TypeScript', - }); - state = machine.transition(state, { - type: 'user.entry', - entry: 'Rust', - }); - state = machine.transition(state, { - type: 'user.entry', - entry: 'Go', - }); - state = machine.transition(state, { type: 'user.draw' }); - - const result = await execute(machine, state); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - entries: ['TypeScript', 'Rust', 'Go'], - winner: 'Rust', - firstRunnerUp: 'TypeScript', - secondRunnerUp: 'Go', - explanation: 'Selected the second entry for the demo.', - }); - } - } - }); - - test('reflection example loops through critique and revision until ready', async () => { - const machine = createReflectionExample({ - draft: async () => ({ - draft: 'Initial draft', - }), - reflect: async ({ revisionCount }) => ({ - feedback: revisionCount === 0 ? 'Clarify the main point.' : null, - }), - revise: async ({ draft, feedback }) => ({ - draft: `${draft} Revised: ${feedback}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ task: 'Explain event sourcing simply.' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - task: 'Explain event sourcing simply.', - draft: 'Initial draft Revised: Clarify the main point.', - feedback: null, - revisionCount: 1, - }); - } - }); - - test('river crossing example moves every item safely to the right bank', async () => { - const machine = createRiverCrossingExample(); - const result = await execute(machine, machine.getInitialState()); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - leftBank: [], - rightBank: ['cabbage', 'goat', 'wolf'], - steps: [ - 'The farmer took the goat across the river.', - 'The farmer crossed the river alone.', - 'The farmer took the wolf across the river.', - 'The farmer took the goat across the river.', - 'The farmer took the cabbage across the river.', - 'The farmer crossed the river alone.', - 'The farmer took the goat across the river.', - ], - reasoning: [ - 'Move the goat first so it is not left with the cabbage.', - 'Return alone to ferry another item.', - 'Take the wolf across while the goat waits safely alone.', - 'Bring the goat back so the wolf is not left with it.', - 'Take the cabbage across now that the goat is with you.', - 'Return alone to fetch the goat.', - 'Bring the goat across to complete the crossing.', - 'Everyone is safely across.', - ], - }); - } - }); - - test('tutor example gives feedback and a response', async () => { - const machine = createTutorExample({ - teach: async () => ({ instruction: 'Use a more complete sentence.' }), - respond: async () => ({ response: 'Claro, puedo ayudarte.' }), - }); - - const result = await execute(machine, - machine.getInitialState({ message: 'Yo necesito ayuda' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - conversation: [ - 'User: Yo necesito ayuda', - 'Tutor: Claro, puedo ayudarte.', - ], - feedback: 'Use a more complete sentence.', - response: 'Claro, puedo ayudarte.', - }); - } - }); -}); - -describe('guardrailed workflow examples', () => { - test('bugfix example exposes per-state prompts and tools', () => { - const machine = createGuardrailedBugfixWorkflowExample(); - - const planning = machine.getInitialState({ task: 'Fix divide().' }); - expect(planning.value).toBe('planning'); - expect(Object.keys(planning.tools ?? {})).toEqual( - expect.arrayContaining([ - 'Read', - 'Grep', - 'Glob', - 'LS', - 'Bash', - ]) - ); - - expect(Object.keys(planning.tools ?? {})).not.toContain('Edit'); - }); - - test('incident response example withholds destructive tools', async () => { - const machine = createGuardrailedIncidentResponseExample(); - - const diagnose = machine.getInitialState({}); - expect(diagnose.value).toBe('diagnosing'); - expect(Object.keys(diagnose.tools ?? {})).toContain('get_logs'); - expect(Object.keys(diagnose.tools ?? {})).not.toContain('delete_volume'); - - const result = await execute(machine, diagnose); - expect(result.status).toBe('pending'); - if (result.status !== 'pending') { - throw new Error('Expected approval state'); - } - - expect(result.state.value).toBe('awaitingApproval'); - expect(Object.keys(result.state.tools ?? {})).toEqual( - expect.arrayContaining(['Read', 'event.APPROVED', 'event.REJECTED']) - ); - }); - - test('unguarded incident response example exposes every API action', () => { - const machine = createUnguardedIncidentResponseExample(); - const state = machine.getInitialState({}); - - expect(state.value).toBe('working'); - expect(Object.keys(state.tools ?? {})).toContain('delete_volume'); - expect(Object.keys(state.tools ?? {})).toContain('restart_service'); }); }); diff --git a/src/fixtures/converter-machine.ts b/src/fixtures/converter-machine.ts index 47cfb05..2129acd 100644 --- a/src/fixtures/converter-machine.ts +++ b/src/fixtures/converter-machine.ts @@ -1,68 +1,50 @@ -import { z } from 'zod'; -import { createAgentMachine } from '../index.js'; +import { setup } from 'xstate'; -declare function unknownTransition(): { target: 'done' }; +const agent = setup({ + types: {} as { + context: {}; + events: + | { type: 'submit'; ok: boolean } + | { type: 'go' }; + }, +}); export const namedMachine = createFixtureMachine('named-converter-machine'); -export const warningMachine = createAgentMachine({ +export const machine = createFixtureMachine('default-converter-machine'); + +export const warningMachine = agent.createMachine({ id: 'warning-converter-machine', - schemas: { - events: { - go: z.object({ - type: z.literal('go'), - }), - }, - }, - context: () => ({}), + context: {}, initial: 'idle', states: { idle: { on: { - go: () => unknownTransition(), + go: { target: 'done' }, }, }, - done: { - type: 'final', - }, + done: { type: 'final' }, }, }); -export default createFixtureMachine('default-converter-machine'); +export default machine; export function createFixtureMachine(id = 'factory-converter-machine') { - return createAgentMachine({ + return agent.createMachine({ id, - schemas: { - events: { - submit: z.object({ - type: z.literal('submit'), - ok: z.boolean(), - }), - }, - }, - context: () => ({ - approved: false, - }), + context: {}, initial: 'idle', states: { idle: { on: { - submit: ({ event }) => - event.ok - ? { - target: 'done', - context: { approved: true }, - } - : { target: 'rejected' }, + submit: [ + { guard: ({ event }) => event.ok, target: 'done' }, + { target: 'rejected' }, + ], }, }, - rejected: { - type: 'final', - }, - done: { - type: 'final', - }, + rejected: { type: 'final' }, + done: { type: 'final' }, }, }); } diff --git a/src/graph/index.test.ts b/src/graph/index.test.ts index d0b3fa6..9ac15e3 100644 --- a/src/graph/index.test.ts +++ b/src/graph/index.test.ts @@ -1,440 +1,62 @@ import { expect, test } from 'vitest'; -import { z } from 'zod'; -import { createAgentMachine } from '../index.js'; -import { analyzeGraph, toGraph, toMermaid } from './index.js'; +import { setup } from 'xstate'; +import { toGraph, toMermaid } from './index.js'; -declare function unknownTransition(): { target: 'done' }; - -test('exports finite states and transition edges as Stately graph JSON', () => { - const machine = createAgentMachine({ - id: 'graph-export', - schemas: { - events: { - submit: z.object({ - type: z.literal('submit'), - count: z.number(), - }), - }, +test('exports finite states and transition edges from XState setup machines', () => { + const agent = setup({ + types: {} as { + context: { count: number }; + input: { count: number }; + events: { type: 'NEXT' }; }, - context: () => ({ - total: 0, - }), + }); + const machine = agent.createMachine({ + id: 'graph-export', + context: ({ input }) => ({ count: input.count }), initial: 'idle', states: { idle: { on: { - submit: ({ event }) => { - if (event.count > 0) { - return { - target: 'working', - context: { total: event.count }, - input: { index: event.count }, - }; - } - - return { - target: 'done', - }; - }, + NEXT: { target: 'done' }, }, }, - working: { - schemas: { input: z.object({ - index: z.number(), - }), output: z.object({ - ok: z.boolean(), - }) }, - invoke: async () => ({ ok: true }), - onDone: () => ({ - target: 'done', - }), - }, - done: { - type: 'final', - output: ({ context }) => context, - }, + done: { type: 'final' }, }, }); - expect(toGraph(machine)).toEqual({ + expect(toGraph(machine)).toMatchObject({ id: 'graph-export', - type: 'directed', initialNodeId: 'idle', - data: undefined, nodes: [ - { type: 'node', id: 'idle', label: 'idle', data: { type: 'state' } }, - { type: 'node', id: 'working', label: 'working', data: { type: 'state' } }, - { type: 'node', id: 'done', label: 'done', data: { type: 'final' } }, + { id: 'idle', label: 'idle', data: { type: 'state' } }, + { id: 'done', label: 'done', data: { type: 'final' } }, ], edges: [ { - type: 'edge', - id: 'idle:submit:0', + id: 'idle:NEXT:done:0', sourceId: 'idle', - targetId: 'working', - label: 'submit [event.count > 0]', - data: { - event: 'submit', - source: 'event', - guard: { type: 'event.count > 0' }, - actions: { - context: true, - input: true, - }, - }, - }, - { - type: 'edge', - id: 'idle:submit:1', - sourceId: 'idle', - targetId: 'done', - label: 'submit [!(event.count > 0)]', - data: { - event: 'submit', - source: 'event', - guard: { type: '!(event.count > 0)' }, - }, - }, - { - type: 'edge', - id: 'working:done.invoke.working:2', - sourceId: 'working', targetId: 'done', - label: 'done.invoke.working', - data: { - event: 'done.invoke.working', - source: 'invoke.done', - }, + label: 'NEXT', + data: { source: 'event', event: 'NEXT' }, }, ], }); }); -test('exports always transitions and message updates', () => { - const machine = createAgentMachine({ - id: 'always-graph', - context: () => ({}), - initial: 'checking', - states: { - checking: { - always: ({ messages }) => ({ - target: 'done', - messages: messages.concat({ role: 'assistant', content: 'ok' }), - }), - }, - done: { - type: 'final', - }, - }, - }); - - expect(toGraph(machine).edges).toEqual([ - { - type: 'edge', - id: 'checking::0', - sourceId: 'checking', - targetId: 'done', - label: 'always', - data: { - event: '', - source: 'always', - actions: { - messages: true, - }, - }, - }, - ]); -}); - -test('infers switch, early-return, and helper-call transition branches', () => { - const machine = createAgentMachine({ - id: 'ast-rich-export', - schemas: { - events: { - route: z.object({ - type: z.literal('route'), - kind: z.enum(['a', 'b', 'c']), - urgent: z.boolean(), - }), - }, - }, - context: () => ({}), - initial: 'idle', - states: { - idle: { - on: { - route: ({ event }) => { - const toA = () => ({ target: 'a' as const }); - - if (event.urgent) { - return toA(); - } - - switch (event.kind) { - case 'b': - return { target: 'b' as const }; - case 'c': - return { target: 'c' as const }; - default: - return { target: 'fallback' as const }; - } - }, - }, - }, - a: { type: 'final' }, - b: { type: 'final' }, - c: { type: 'final' }, - fallback: { type: 'final' }, - }, - }); - - expect(toGraph(machine).edges).toEqual([ - expect.objectContaining({ - targetId: 'a', - data: expect.objectContaining({ - guard: { type: 'event.urgent' }, - }), - }), - expect.objectContaining({ - targetId: 'b', - data: expect.objectContaining({ - guard: { type: '(!(event.urgent)) && (event.kind === "b")' }, - }), - }), - expect.objectContaining({ - targetId: 'c', - data: expect.objectContaining({ - guard: { type: '(!(event.urgent)) && (event.kind === "c")' }, - }), - }), - expect.objectContaining({ - targetId: 'fallback', - data: expect.objectContaining({ - guard: { - type: '(!(event.urgent)) && (!(event.kind === "b") && !(event.kind === "c"))', - }, - }), - }), - ]); -}); - -test('reports graph warnings for unsupported transition analysis', () => { - const machine = createAgentMachine({ - id: 'ast-warning-export', - schemas: { - events: { - go: z.object({ type: z.literal('go') }), - }, - }, - context: () => ({}), - initial: 'idle', - states: { - idle: { - on: { - go: () => { - return unknownTransition(); - }, - }, - }, - done: { type: 'final' }, - }, +test('exports Mermaid from XState setup machines', () => { + const agent = setup({ + types: {} as { context: {} }, }); - - expect(analyzeGraph(machine).warnings).toEqual([ - { - state: 'idle', - event: 'go', - message: - 'Unsupported helper call: unknownTransition() is not statically resolvable.', - }, - ]); - expect(toGraph(machine).data).toBeUndefined(); -}); - -test('resolves simple helper calls with arguments in guards and targets', () => { - const machine = createAgentMachine({ - id: 'helper-args-export', - schemas: { - events: { - choose: z.object({ - type: z.literal('choose'), - kind: z.enum(['approved', 'rejected']), - }), - }, - }, - context: () => ({}), - initial: 'idle', - states: { - idle: { - on: { - choose: ({ event }) => { - function goTo( - target: 'approved' | 'rejected', - reason: string - ) { - return { - target, - context: { reason }, - }; - } - - return event.kind === 'approved' - ? goTo('approved', 'explicit approval path') - : goTo('rejected', 'explicit rejection path'); - }, - }, - }, - approved: { type: 'final' }, - rejected: { type: 'final' }, - }, - }); - - expect(toGraph(machine).edges).toEqual([ - expect.objectContaining({ - targetId: 'approved', - data: expect.objectContaining({ - guard: { type: 'event.kind === "approved"' }, - actions: { context: true }, - }), - }), - expect.objectContaining({ - targetId: 'rejected', - data: expect.objectContaining({ - guard: { type: '!(event.kind === "approved")' }, - actions: { context: true }, - }), - }), - ]); -}); - -test('resolves one-level helper forwarding with substituted arguments', () => { - const machine = createAgentMachine({ - id: 'helper-forwarding-export', - schemas: { - events: { - choose: z.object({ - type: z.literal('choose'), - kind: z.enum(['approved', 'rejected']), - }), - }, - }, - context: () => ({}), - initial: 'idle', - states: { - idle: { - on: { - choose: ({ event }) => { - function goTo( - target: 'approved' | 'rejected', - reason: string - ) { - return { - target, - context: { reason }, - }; - } - - function route(kind: 'approved' | 'rejected') { - return goTo(kind, `routed:${kind}`); - } - - return event.kind === 'approved' - ? route('approved') - : route('rejected'); - }, - }, - }, - approved: { type: 'final' }, - rejected: { type: 'final' }, - }, - }); - - expect(toGraph(machine).edges).toEqual([ - expect.objectContaining({ - targetId: 'approved', - data: expect.objectContaining({ - guard: { type: 'event.kind === "approved"' }, - actions: { context: true }, - }), - }), - expect.objectContaining({ - targetId: 'rejected', - data: expect.objectContaining({ - guard: { type: '!(event.kind === "approved")' }, - actions: { context: true }, - }), - }), - ]); -}); - -test('exports a mermaid state diagram from the Stately graph data', () => { - const machine = createAgentMachine({ + const machine = agent.createMachine({ id: 'mermaid-export', - context: () => ({}), - initial: 'idle', + context: {}, + initial: 'a', states: { - idle: { - on: { - finish: { target: 'done' }, - }, - }, - done: { - type: 'final', - }, - }, - }); - - expect(toMermaid(machine)).toBe(`stateDiagram-v2 - [*] --> idle - idle --> done : finish - done --> [*]`); -}); - -test('infers guards from conditional-expression transition branches', () => { - const machine = createAgentMachine({ - id: 'conditional-export', - schemas: { - events: { - choose: z.object({ - type: z.literal('choose'), - ok: z.boolean(), - }), - }, - }, - context: () => ({}), - initial: 'idle', - states: { - idle: { - on: { - choose: ({ event }) => - event.ok - ? { target: 'accepted' } - : { target: 'rejected' }, - }, - }, - accepted: { - type: 'final', - }, - rejected: { - type: 'final', - }, + a: { always: { target: 'b' } }, + b: { type: 'final' }, }, }); - expect(toGraph(machine).edges).toEqual([ - expect.objectContaining({ - sourceId: 'idle', - targetId: 'accepted', - data: expect.objectContaining({ - guard: { type: 'event.ok' }, - }), - }), - expect.objectContaining({ - sourceId: 'idle', - targetId: 'rejected', - data: expect.objectContaining({ - guard: { type: '!(event.ok)' }, - }), - }), - ]); + expect(toMermaid(machine)).toContain('stateDiagram-v2'); + expect(toMermaid(machine)).toContain('a --> b'); }); diff --git a/src/graph/index.ts b/src/graph/index.ts index f9b4cab..768192c 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -13,33 +13,17 @@ import { type StateGraphData, type StateNodeData, } from '@statelyai/graph/mermaid'; -import ts from 'typescript'; -import type { - AgentMachine, - MachineConfig, - StateConfig, - TransitionResult, -} from '../types.js'; export interface AgentGraphNodeData { - type: 'state' | 'choice' | 'final'; + type: 'state' | 'final'; } export interface AgentGraphEdgeData { event?: string; source?: 'event' | 'invoke.done' | 'always'; - guard?: { - type: string; - }; - actions?: { - context?: boolean; - input?: boolean; - messages?: boolean; - }; } -export interface AgentGraphData { -} +export interface AgentGraphData {} export interface AgentGraphWarning { state: string; @@ -59,163 +43,151 @@ export interface AgentGraphNode export interface AgentGraphEdge extends StatelyGraphEdge {} -type InternalMachine = AgentMachine & { - __config?: MachineConfig; -}; - -type EdgeCandidate = { - target: string; - guard?: string; - hasContext?: boolean; - hasInput?: boolean; - hasMessages?: boolean; +export type XStateLikeMachine = { + id: string; + config: XStateLikeConfig; }; -type AnalysisResult = { - candidates: EdgeCandidate[]; - warnings: string[]; +type XStateLikeConfig = { + id?: string; + initial?: unknown; + states?: Record; }; -type BlockAnalysis = AnalysisResult & { - exits: boolean; +type XStateLikeState = { + type?: string; + on?: Record | string; + always?: unknown; + invoke?: unknown; }; -type AnalyzableFunction = - | ts.ArrowFunction - | ts.FunctionExpression - | ts.FunctionDeclaration; - -type HelperMap = Map; -type BindingMap = Map; -const printer = ts.createPrinter({ removeComments: true }); - -/** - * Convert an agent machine to a Stately graph-compatible plain JSON object. - * - * Finite states come directly from the authored `states` object. Edges are - * inferred from static transition objects and transition handler ASTs. - */ -export function toGraph(machine: AgentMachine): AgentGraph { +export function toGraph(machine: XStateLikeMachine): AgentGraph { return analyzeGraph(machine).graph; } -export function analyzeGraph(machine: AgentMachine): AgentGraphAnalysis { - const config = (machine as InternalMachine).__config; - if (!config) { - throw new Error('Machine config metadata is unavailable for graph export'); - } - - const nodes: Array> = Object.entries( - config.states - ).map(([id, state]) => ({ - id, - label: id, - data: { - type: getNodeType(state as StateConfig), - }, - })); - +export function analyzeGraph(machine: XStateLikeMachine): AgentGraphAnalysis { + const config = machine.config; + const stateEntries = Object.entries(config.states ?? {}); + const nodes: Array> = stateEntries.map( + ([id, state]) => ({ + id, + label: id, + data: { type: state.type === 'final' ? 'final' : 'state' }, + }) + ); const edges: Array> = []; - const warnings: AgentGraphWarning[] = []; - for (const [sourceId, state] of Object.entries(config.states)) { - const stateConfig = state as StateConfig; - - if (stateConfig.onDone) { - const event = `done.invoke.${sourceId}`; - const result = getTransitionEdges({ - sourceId, - event, - source: 'invoke.done', - transition: stateConfig.onDone, - ordinalOffset: edges.length, - }); - edges.push(...result.edges); - warnings.push(...formatWarnings(sourceId, event, result.warnings)); - } + let edgeIndex = 0; - if (stateConfig.always) { - const event = ''; - const result = getTransitionEdges({ - sourceId, - event, - source: 'always', - transition: stateConfig.always, - ordinalOffset: edges.length, - }); - edges.push(...result.edges); - warnings.push(...formatWarnings(sourceId, 'always', result.warnings)); + for (const [sourceId, state] of stateEntries) { + for (const target of collectTargets(state.always)) { + edges.push(edge(edgeIndex++, sourceId, target, 'always', '')); } - if (!stateConfig.on) { - continue; + for (const [event, transition] of Object.entries(normalizeOn(state.on))) { + for (const target of collectTargets(transition)) { + edges.push(edge(edgeIndex++, sourceId, target, 'event', event)); + } } - for (const [event, transition] of Object.entries(stateConfig.on)) { - const result = getTransitionEdges({ - sourceId, - event, - source: 'event', - transition, - ordinalOffset: edges.length, - }); - edges.push(...result.edges); - warnings.push(...formatWarnings(sourceId, event, result.warnings)); + for (const target of collectInvokeDoneTargets(state.invoke)) { + edges.push(edge(edgeIndex++, sourceId, target, 'invoke.done', `done.invoke.${sourceId}`)); } } - const graph = createGraph({ - id: machine.id, - initialNodeId: - typeof config.initial === 'string' ? config.initial : undefined, - nodes, - edges, - }); - return { - graph, - warnings, + graph: createGraph({ + id: machine.id, + initialNodeId: typeof config.initial === 'string' ? config.initial : undefined, + nodes, + edges, + }), + warnings: [], }; } -export function toMermaid(machine: AgentMachine): string { +export function toMermaid(machine: XStateLikeMachine): string { return toMermaidState(toMermaidStateGraph(toGraph(machine))); } -function getNodeType(state: StateConfig): AgentGraphNodeData['type'] { - if (state.type === 'final') { - return 'final'; +function edge( + index: number, + sourceId: string, + targetId: string, + source: NonNullable, + event?: string +): EdgeConfig { + return { + id: `${sourceId}:${event ?? source}:${targetId}:${index}`, + sourceId, + targetId, + label: event || undefined, + data: { source, ...(event ? { event } : {}) }, + }; +} + +function normalizeOn(on: XStateLikeState['on']): Record { + if (!on || typeof on === 'string' || Array.isArray(on)) { + return {}; + } + return on; +} + +function collectInvokeDoneTargets(invoke: unknown): string[] { + const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : []; + return invokes.flatMap((item) => + item && typeof item === 'object' + ? collectTargets((item as { onDone?: unknown }).onDone) + : [] + ); +} + +function collectTargets(value: unknown): string[] { + if (!value) { + return []; + } + + if (typeof value === 'string') { + return [stripTarget(value)]; } - if (state.type === 'choice') { - return 'choice'; + if (Array.isArray(value)) { + return value.flatMap(collectTargets); } - return 'state'; + if (typeof value !== 'object') { + return []; + } + + const target = (value as { target?: unknown }).target; + if (Array.isArray(target)) { + return target.filter((item): item is string => typeof item === 'string').map(stripTarget); + } + + return typeof target === 'string' ? [stripTarget(target)] : []; +} + +function stripTarget(target: string): string { + return target.replace(/^#/, '').split('.').at(-1) ?? target; } function toMermaidStateGraph(graph: AgentGraph): MermaidStateGraph { const nodes: Array> = graph.nodes.map((node) => ({ id: node.id, label: node.label, - data: { - ...(node.data.type === 'choice' ? { stateType: 'choice' as const } : {}), - }, + data: {}, })); - const edges: Array> = graph.edges.map((edge) => ({ - id: edge.id, - sourceId: edge.sourceId, - targetId: edge.targetId, - label: edge.label ?? undefined, + const edges: Array> = graph.edges.map((graphEdge) => ({ + id: graphEdge.id, + sourceId: graphEdge.sourceId, + targetId: graphEdge.targetId, + label: graphEdge.label ?? undefined, data: {}, })); if (graph.initialNodeId) { const startId = `${graph.id}.__start`; - nodes.push({ - id: startId, - data: { isStart: true }, - }); + nodes.push({ id: startId, data: { isStart: true } }); edges.unshift({ id: `${startId}:initial`, sourceId: startId, @@ -228,12 +200,8 @@ function toMermaidStateGraph(graph: AgentGraph): MermaidStateGraph { if (node.data.type !== 'final') { continue; } - const endId = `${node.id}.__end`; - nodes.push({ - id: endId, - data: { isEnd: true }, - }); + nodes.push({ id: endId, data: { isEnd: true } }); edges.push({ id: `${node.id}:final`, sourceId: node.id, @@ -244,796 +212,9 @@ function toMermaidStateGraph(graph: AgentGraph): MermaidStateGraph { return createGraph({ id: graph.id, - type: graph.type, initialNodeId: graph.initialNodeId ?? undefined, - data: { - diagramType: 'stateDiagram', - }, + data: { diagramType: 'stateDiagram' }, nodes, edges, }); } - -function getTransitionEdges(args: { - sourceId: string; - event: string; - source: NonNullable; - transition: unknown; - ordinalOffset: number; -}): { - edges: Array>; - warnings: string[]; -} { - const result = - typeof args.transition === 'function' - ? analyzeTransitionFunction(args.transition) - : analyzeTransitionObject(args.transition); - - return { - edges: result.candidates.map((candidate, index) => ({ - id: `${args.sourceId}:${args.event}:${args.ordinalOffset + index}`, - sourceId: args.sourceId, - targetId: candidate.target, - label: getEdgeLabel(args.event, candidate.guard), - data: { - event: args.event, - source: args.source, - ...(candidate.guard - ? { - guard: { - type: candidate.guard, - }, - } - : {}), - ...((candidate.hasContext || candidate.hasInput || candidate.hasMessages) - ? { - actions: { - ...(candidate.hasContext ? { context: true } : {}), - ...(candidate.hasInput ? { input: true } : {}), - ...(candidate.hasMessages ? { messages: true } : {}), - }, - } - : {}), - }, - })), - warnings: result.warnings, - }; -} - -function analyzeTransitionObject(transition: unknown): AnalysisResult { - const target = - transition && typeof transition === 'object' - ? (transition as TransitionResult).target - : undefined; - - if ( - transition - && typeof transition === 'object' - && 'target' in transition - && typeof target === 'string' - ) { - return { - candidates: [{ - target, - hasContext: 'context' in transition, - hasInput: 'input' in transition, - hasMessages: 'messages' in transition, - }], - warnings: [], - }; - } - - return { candidates: [], warnings: [] }; -} - -function analyzeTransitionFunction(fn: Function): AnalysisResult { - const source = fn - .toString() - .replace(/__name\([^)]*\);?/g, ''); - const file = ts.createSourceFile( - 'transition.ts', - `const __transition = ${source};`, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ); - const transitionFunction = findTransitionFunction(file); - - if (!transitionFunction) { - return { - candidates: [], - warnings: ['Unable to parse transition function.'], - }; - } - - const helpers = collectHelpers(transitionFunction); - - if (ts.isArrowFunction(transitionFunction) && !ts.isBlock(transitionFunction.body)) { - return analyzeTransitionExpression( - transitionFunction.body, - [], - file, - helpers, - new Map() - ); - } - - if (transitionFunction.body && ts.isBlock(transitionFunction.body)) { - return analyzeStatements( - transitionFunction.body.statements, - [], - file, - helpers, - new Map() - ); - } - - return { - candidates: [], - warnings: ['Unsupported transition function body.'], - }; -} - -function findTransitionFunction(file: ts.SourceFile): AnalyzableFunction | undefined { - let transitionFunction: AnalyzableFunction | undefined; - - function visit(node: ts.Node) { - if ( - ts.isVariableDeclaration(node) - && ts.isIdentifier(node.name) - && node.name.text === '__transition' - && node.initializer - && isAnalyzableFunction(node.initializer) - ) { - transitionFunction = node.initializer; - return; - } - - if (!transitionFunction) { - ts.forEachChild(node, visit); - } - } - - visit(file); - return transitionFunction; -} - -function isAnalyzableFunction(node: ts.Node): node is AnalyzableFunction { - return ( - ts.isArrowFunction(node) - || ts.isFunctionExpression(node) - || ts.isFunctionDeclaration(node) - ); -} - -function collectHelpers(fn: AnalyzableFunction): HelperMap { - const helpers: HelperMap = new Map(); - if (!fn.body || !ts.isBlock(fn.body)) { - return helpers; - } - - function visit(node: ts.Node) { - if ( - node !== fn.body - && isAnalyzableFunction(node) - ) { - return; - } - - if (ts.isFunctionDeclaration(node) && node.name && node.body) { - helpers.set(node.name.text, node); - return; - } - - if (ts.isVariableDeclaration(node)) { - if (!ts.isIdentifier(node.name) || !node.initializer) { - return; - } - - const initializer = unwrapParenthesized(node.initializer); - if ( - isAnalyzableFunction(initializer) - || ts.isObjectLiteralExpression(initializer) - || ts.isConditionalExpression(initializer) - ) { - helpers.set(node.name.text, initializer); - } - } - - ts.forEachChild(node, visit); - } - - visit(fn.body); - - return helpers; -} - -function analyzeStatements( - statements: ts.NodeArray, - guards: string[], - file: ts.SourceFile, - helpers: HelperMap, - bindings: BindingMap -): BlockAnalysis { - const candidates: EdgeCandidate[] = []; - const warnings: string[] = []; - const fallthroughGuards = [...guards]; - - for (const statement of statements) { - const result = analyzeStatement( - statement, - fallthroughGuards, - file, - helpers, - bindings - ); - candidates.push(...result.candidates); - warnings.push(...result.warnings); - - if (result.exits) { - return { candidates, warnings, exits: true }; - } - - if ( - ts.isIfStatement(statement) - && isReturnOnlyBranch(statement.thenStatement) - && !statement.elseStatement - ) { - fallthroughGuards.push( - `!(${renderExpressionText(statement.expression, file, bindings)})` - ); - } - } - - return { candidates, warnings, exits: false }; -} - -function analyzeStatement( - statement: ts.Statement, - guards: string[], - file: ts.SourceFile, - helpers: HelperMap, - bindings: BindingMap -): BlockAnalysis { - if (ts.isReturnStatement(statement)) { - if (!statement.expression) { - return { - candidates: [], - warnings: ['Return statement has no transition object.'], - exits: true, - }; - } - - const result = analyzeTransitionExpression( - statement.expression, - guards, - file, - helpers, - bindings - ); - - return { - candidates: result.candidates, - warnings: - result.candidates.length === 0 && result.warnings.length === 0 - ? [ - `Unsupported transition return expression: ${statement.expression.getText(file)}`, - ] - : result.warnings, - exits: true, - }; - } - - if (ts.isIfStatement(statement)) { - return analyzeIfStatement(statement, guards, file, helpers, bindings); - } - - if (ts.isSwitchStatement(statement)) { - return analyzeSwitchStatement(statement, guards, file, helpers, bindings); - } - - if ( - ts.isVariableStatement(statement) - || ts.isFunctionDeclaration(statement) - || ts.isEmptyStatement(statement) - ) { - return { candidates: [], warnings: [], exits: false }; - } - - return { - candidates: [], - warnings: [`Unsupported transition statement: ${statement.getText(file)}`], - exits: false, - }; -} - -function analyzeIfStatement( - statement: ts.IfStatement, - guards: string[], - file: ts.SourceFile, - helpers: HelperMap, - bindings: BindingMap -): BlockAnalysis { - const condition = renderExpressionText(statement.expression, file, bindings); - const thenResult = analyzeBranch( - statement.thenStatement, - [...guards, condition], - file, - helpers, - bindings - ); - const elseResult = statement.elseStatement - ? analyzeBranch( - statement.elseStatement, - [...guards, `!(${condition})`], - file, - helpers, - bindings - ) - : emptyBlockAnalysis(); - - return { - candidates: [...thenResult.candidates, ...elseResult.candidates], - warnings: [...thenResult.warnings, ...elseResult.warnings], - exits: thenResult.exits && !!statement.elseStatement && elseResult.exits, - }; -} - -function analyzeSwitchStatement( - statement: ts.SwitchStatement, - guards: string[], - file: ts.SourceFile, - helpers: HelperMap, - bindings: BindingMap -): BlockAnalysis { - const candidates: EdgeCandidate[] = []; - const warnings: string[] = []; - const expression = renderExpressionText(statement.expression, file, bindings); - const caseGuards: string[] = []; - let allClausesExit = statement.caseBlock.clauses.length > 0; - - for (const clause of statement.caseBlock.clauses) { - const clauseGuard = ts.isCaseClause(clause) - ? `${expression} === ${clause.expression.getText(file)}` - : caseGuards.length > 0 - ? caseGuards.map((guard) => `!(${guard})`).join(' && ') - : undefined; - - if (clauseGuard) { - caseGuards.push(clauseGuard); - } - - const result = analyzeStatements( - clause.statements, - clauseGuard ? [...guards, clauseGuard] : guards, - file, - helpers, - bindings - ); - candidates.push(...result.candidates); - warnings.push(...result.warnings); - allClausesExit = allClausesExit && result.exits; - } - - return { - candidates, - warnings, - exits: allClausesExit, - }; -} - -function analyzeBranch( - statement: ts.Statement, - guards: string[], - file: ts.SourceFile, - helpers: HelperMap, - bindings: BindingMap -): BlockAnalysis { - if (ts.isBlock(statement)) { - return analyzeStatements(statement.statements, guards, file, helpers, bindings); - } - - return analyzeStatement(statement, guards, file, helpers, bindings); -} - -function emptyBlockAnalysis(): BlockAnalysis { - return { - candidates: [], - warnings: [], - exits: false, - }; -} - -function isReturnOnlyBranch(statement: ts.Statement): boolean { - if (ts.isReturnStatement(statement)) { - return true; - } - - return ( - ts.isBlock(statement) - && statement.statements.length === 1 - && !!statement.statements[0] - && ts.isReturnStatement(statement.statements[0]) - ); -} - -function analyzeTransitionExpression( - expression: ts.Expression, - guards: string[], - file: ts.SourceFile, - helpers: HelperMap, - bindings: BindingMap -): AnalysisResult { - const current = unwrapParenthesized(expression); - - if (ts.isConditionalExpression(current)) { - const condition = renderExpressionText(current.condition, file, bindings); - - return mergeAnalysis([ - analyzeTransitionExpression( - current.whenTrue, - [...guards, condition], - file, - helpers, - bindings - ), - analyzeTransitionExpression( - current.whenFalse, - [...guards, `!(${condition})`], - file, - helpers, - bindings - ), - ]); - } - - if ( - ts.isCallExpression(current) - && ts.isIdentifier(current.expression) - ) { - const fallbackHelper = findHelperByName(file, current.expression.text); - const helper = - helpers.get(current.expression.text) - ?? fallbackHelper; - if (!helper) { - return { - candidates: [], - warnings: [ - `Unsupported helper call: ${current.expression.text}(${current.arguments.map((arg) => renderExpressionText(arg, file, bindings)).join(', ')}) is not statically resolvable.`, - ], - }; - } - - return analyzeHelper( - helper, - current.arguments, - guards, - file, - helpers, - bindings - ); - } - - const object = unwrapParenthesizedObject(current); - const target = object - ? getStringProperty(object, 'target', file, bindings) - : undefined; - if (!target) { - return { candidates: [], warnings: [] }; - } - - return { - candidates: [{ - target, - guard: combineGuardList(guards), - hasContext: object ? hasProperty(object, 'context') : false, - hasInput: object ? hasProperty(object, 'input') : false, - hasMessages: object ? hasProperty(object, 'messages') : false, - }], - warnings: [], - }; -} - -function analyzeHelper( - helper: AnalyzableFunction | ts.Expression, - args: ts.NodeArray, - guards: string[], - file: ts.SourceFile, - helpers: HelperMap, - bindings: BindingMap -): AnalysisResult { - if (isAnalyzableFunction(helper)) { - const helperBindings = createBindings(helper, args, bindings); - if (!helperBindings) { - return { - candidates: [], - warnings: [ - `Unsupported helper call: argument count for ${getHelperName(helper)}(...) could not be matched.`, - ], - }; - } - - if (ts.isArrowFunction(helper) && !ts.isBlock(helper.body)) { - return analyzeTransitionExpression( - helper.body, - guards, - file, - helpers, - helperBindings - ); - } - - if (helper.body && ts.isBlock(helper.body)) { - const result = analyzeStatements( - helper.body.statements, - guards, - file, - helpers, - helperBindings - ); - return { - candidates: result.candidates, - warnings: result.warnings, - }; - } - } - - if (ts.isExpression(helper)) { - if (args.length > 0) { - return { - candidates: [], - warnings: ['Unsupported helper call: non-function helper cannot accept arguments.'], - }; - } - - return analyzeTransitionExpression(helper, guards, file, helpers, bindings); - } - - return { - candidates: [], - warnings: ['Unsupported helper body.'], - }; -} - -function mergeAnalysis(results: AnalysisResult[]): AnalysisResult { - return { - candidates: results.flatMap((result) => result.candidates), - warnings: results.flatMap((result) => result.warnings), - }; -} - -function unwrapParenthesized(expression: T): ts.Expression { - let current: ts.Expression = expression; - - while (ts.isParenthesizedExpression(current)) { - current = current.expression; - } - - return current; -} - -function findHelperByName( - file: ts.SourceFile, - name: string -): AnalyzableFunction | ts.Expression | undefined { - let helper: AnalyzableFunction | ts.Expression | undefined; - - function visit(node: ts.Node) { - if (helper) { - return; - } - - if ( - ts.isFunctionDeclaration(node) - && node.name?.text === name - && node.body - ) { - helper = node; - return; - } - - if (ts.isVariableDeclaration(node)) { - if (!ts.isIdentifier(node.name) || node.name.text !== name || !node.initializer) { - ts.forEachChild(node, visit); - return; - } - - const initializer = unwrapParenthesized(node.initializer); - if ( - isAnalyzableFunction(initializer) - || ts.isObjectLiteralExpression(initializer) - || ts.isConditionalExpression(initializer) - ) { - helper = initializer; - return; - } - } - - ts.forEachChild(node, visit); - } - - visit(file); - return helper; -} - -function unwrapParenthesizedObject( - expression: ts.Expression -): ts.ObjectLiteralExpression | undefined { - let current = expression; - - while (ts.isParenthesizedExpression(current)) { - current = current.expression; - } - - return ts.isObjectLiteralExpression(current) ? current : undefined; -} - -function getStringProperty( - object: ts.ObjectLiteralExpression, - name: string, - file: ts.SourceFile, - bindings: BindingMap -): string | undefined { - const property = object.properties.find((candidate) => { - return ( - (ts.isPropertyAssignment(candidate) - || ts.isShorthandPropertyAssignment(candidate)) - && ts.isIdentifier(candidate.name) - && candidate.name.text === name - ); - }); - - if (!property) { - return undefined; - } - - if (ts.isPropertyAssignment(property)) { - return resolveStringExpression(property.initializer, file, bindings); - } - - if (ts.isShorthandPropertyAssignment(property)) { - const binding = bindings.get(property.name.text); - if (!binding) { - return property.name.text; - } - - return resolveStringExpression(binding, file, bindings); - } - - return undefined; -} - -function hasProperty(object: ts.ObjectLiteralExpression, name: string): boolean { - return object.properties.some((candidate) => { - return ( - (ts.isPropertyAssignment(candidate) - || ts.isShorthandPropertyAssignment(candidate)) - && ts.isIdentifier(candidate.name) - && candidate.name.text === name - ); - }); -} - -function getEdgeLabel(event: string, guard: string | undefined): string { - const label = event || 'always'; - if (!guard) { - return label; - } - - return `${label} [${guard}]`; -} - -function createBindings( - helper: AnalyzableFunction, - args: ts.NodeArray, - parentBindings: BindingMap -): BindingMap | null { - if (args.length > helper.parameters.length) { - return null; - } - - const bindings = new Map(parentBindings); - helper.parameters.forEach((parameter, index) => { - if (!ts.isIdentifier(parameter.name)) { - return; - } - - const arg = args[index]; - if (arg) { - bindings.set(parameter.name.text, substituteExpression(arg, parentBindings)); - } - }); - - return bindings; -} - -function getHelperName(helper: AnalyzableFunction): string { - if (helper.name) { - return helper.name.text; - } - - return 'helper'; -} - -function resolveStringExpression( - expression: ts.Expression, - file: ts.SourceFile, - bindings: BindingMap -): string | undefined { - const current = substituteExpression(unwrapParenthesized(expression), bindings); - - if (ts.isStringLiteralLike(current)) { - return current.text; - } - - if (ts.isNoSubstitutionTemplateLiteral(current)) { - return current.text; - } - - if (ts.isIdentifier(current)) { - return current.text; - } - - const rendered = renderExpressionText(current, file, bindings); - return /^["'`](.*)["'`]$/s.test(rendered) - ? rendered.slice(1, -1) - : undefined; -} - -function renderExpressionText( - expression: ts.Expression, - file: ts.SourceFile, - bindings: BindingMap -): string { - const substituted = substituteExpression(unwrapParenthesized(expression), bindings); - return printer.printNode(ts.EmitHint.Unspecified, substituted, file); -} - -function substituteExpression( - expression: ts.Expression, - bindings: BindingMap -): ts.Expression { - if (bindings.size === 0) { - return expression; - } - - const transformed = ts.transform(expression, [ - (context) => { - const visit: ts.Visitor = (node) => { - if (ts.isIdentifier(node) && bindings.has(node.text)) { - return substituteExpression(bindings.get(node.text)!, bindings); - } - - return ts.visitEachChild(node, visit, context); - }; - - return (node) => ts.visitNode(node, visit) as ts.Expression; - }, - ]); - - const substituted = transformed.transformed[0] as ts.Expression; - transformed.dispose(); - return substituted; -} - -function combineGuardList(guards: string[]): string | undefined { - if (guards.length === 0) { - return undefined; - } - - return guards - .map((guard) => guards.length === 1 ? guard : `(${guard})`) - .join(' && '); -} - -function formatWarnings( - state: string, - event: string, - warnings: string[] -): AgentGraphWarning[] { - return warnings.map((message) => ({ - state, - event, - message, - })); -} diff --git a/src/http/index.test.ts b/src/http/index.test.ts deleted file mode 100644 index 5357153..0000000 --- a/src/http/index.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { z } from 'zod'; -import { createAgentMachine } from '../index.js'; -import { createSessionHttpController } from './index.js'; - -function createSseReader(response: Response) { - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - return { - async next(): Promise<{ event: string; data: unknown }> { - while (true) { - const match = buffer.match(/^event: ([^\n]+)\ndata: ([^\n]+)\n\n/); - if (match) { - buffer = buffer.slice(match[0].length); - return { - event: match[1]!, - data: JSON.parse(match[2]!), - }; - } - - const chunk = await reader.read(); - if (chunk.done) { - throw new Error('SSE stream closed before the next event was available.'); - } - - buffer += decoder.decode(chunk.value, { stream: true }); - } - }, - - async cancel() { - await reader.cancel(); - }, - }; -} - -describe('http adapter', () => { - test('starts sessions, sends events, reads snapshots, and streams emitted events', async () => { - const machine = createAgentMachine({ - id: 'http-adapter-test', - schemas: { - input: z.object({ - text: z.string(), - }), - events: { - begin: z.object({}), - }, - emitted: { - textPart: z.object({ - delta: z.string(), - }), - }, - }, - context: (input) => ({ - text: input.text, - finalText: '', - }), - initial: 'waiting', - states: { - waiting: { - on: { - begin: { - target: 'writing', - }, - }, - }, - writing: { - schemas: { output: z.object({ - text: z.string(), - }) }, - invoke: async ({ context }, enq) => { - enq.emit({ type: 'textPart', delta: context.text }); - return { text: context.text }; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { - finalText: output.text, - }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - text: context.finalText, - }), - }, - }, - }); - const controller = createSessionHttpController(machine); - - const startResponse = await controller.handle( - new Request('https://agent.test/sessions', { - method: 'POST', - body: JSON.stringify({ text: 'hello' }), - }) - ); - const startBody = await startResponse.json() as { - sessionId: string; - snapshot: { value: string; status: string }; - }; - - expect(startBody.snapshot).toEqual( - expect.objectContaining({ - value: 'waiting', - status: 'active', - }) - ); - - const streamResponse = await controller.handle( - new Request(`https://agent.test/sessions/${startBody.sessionId}/stream`) - ); - const reader = createSseReader(streamResponse); - - const sendPromise = controller.handle( - new Request(`https://agent.test/sessions/${startBody.sessionId}/events`, { - method: 'POST', - body: JSON.stringify({ type: 'begin' }), - }) - ); - - await expect(reader.next()).resolves.toEqual({ - event: 'textPart', - data: { - type: 'textPart', - delta: 'hello', - }, - }); - await expect(reader.next()).resolves.toEqual({ - event: 'done', - data: { - text: 'hello', - }, - }); - - const sendResponse = await sendPromise; - expect(sendResponse.status).toBe(200); - - const statusResponse = await controller.handle( - new Request(`https://agent.test/sessions/${startBody.sessionId}`) - ); - const statusBody = await statusResponse.json() as { - snapshot: { value: string; status: string; output: unknown }; - }; - - expect(statusBody.snapshot).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { - text: 'hello', - }, - }) - ); - }); -}); diff --git a/src/http/index.ts b/src/http/index.ts deleted file mode 100644 index ec4b345..0000000 --- a/src/http/index.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { - createMemoryRunStore, - restoreSession, - startSession, -} from '../local/index.js'; -import type { - AgentMachine, - AgentRun, - RunStore, - TransitionEvent, -} from '../types.js'; - -type AnyMachine = AgentMachine; -type RunFor = - TMachine extends AgentMachine< - any, - infer TContext, - infer TEvents, - infer TStates, - infer TOutput, - infer TEmitted - > - ? AgentRun - : AgentRun; - -type InputFor = - TMachine extends AgentMachine - ? TInput - : unknown; - -type EventsFor = - TMachine extends AgentMachine - ? TEvents - : {}; - -export interface SessionHttpController { - handle(request: Request): Promise; - getRun(sessionId: string): Promise>; - dropActiveSession(sessionId: string): void; -} - -export interface SessionHttpControllerOptions { - store?: RunStore; - parseInput?: (request: Request) => Promise>; - parseEvent?: ( - request: Request - ) => Promise>>; -} - -export function createSessionHttpController( - machine: TMachine, - options: SessionHttpControllerOptions = {} -): SessionHttpController { - const store = options.store ?? createMemoryRunStore(); - const activeRuns = new Map>(); - const parseInput = - options.parseInput ?? ((request) => request.json() as Promise>); - const parseEvent = - options.parseEvent ?? - ((request) => request.json() as Promise>>); - - function trackRun(run: RunFor): RunFor { - activeRuns.set(run.sessionId, run); - run.onDone(() => { - activeRuns.delete(run.sessionId); - }); - run.onError(() => { - activeRuns.delete(run.sessionId); - }); - return run; - } - - async function getRun(sessionId: string): Promise> { - const existing = activeRuns.get(sessionId); - if (existing) { - return existing; - } - - const restored = await restoreSession(machine, { - sessionId, - store, - }) as RunFor; - - return trackRun(restored); - } - - return { - getRun, - - dropActiveSession(sessionId) { - activeRuns.delete(sessionId); - }, - - async handle(request) { - const url = new URL(request.url); - const match = url.pathname.match(/^\/sessions(?:\/([^/]+)(?:\/(events|stream))?)?$/); - const sessionId = match?.[1]; - const childRoute = match?.[2]; - - if (request.method === 'POST' && url.pathname === '/sessions') { - const run = await startSession(machine, { - store, - input: await parseInput(request), - }) as RunFor; - - trackRun(run); - - return Response.json({ - sessionId: run.sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'GET' && sessionId && !childRoute) { - const run = await getRun(sessionId); - - return Response.json({ - sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'POST' && sessionId && childRoute === 'events') { - const run = await getRun(sessionId); - await run.send(await parseEvent(request)); - - return Response.json({ - sessionId, - snapshot: run.getSnapshot(), - }); - } - - if (request.method === 'GET' && sessionId && childRoute === 'stream') { - return createRunSseResponse(await getRun(sessionId)); - } - - return new Response('Not found', { status: 404 }); - }, - }; -} - -export function createSessionHttpHandler( - machine: TMachine, - options: SessionHttpControllerOptions = {} -): (request: Request) => Promise { - const controller = createSessionHttpController(machine, options); - return (request) => controller.handle(request); -} - -export function createRunSseResponse( - run: AgentRun -): Response { - let cleanup = () => {}; - - const stream = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - const write = (event: string, data: unknown) => { - controller.enqueue( - encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) - ); - }; - - if (run.getSnapshot().status === 'done') { - write('done', run.getSnapshot().output); - controller.close(); - return; - } - - if (run.getSnapshot().status === 'error') { - write('error', { error: String(run.getSnapshot().error) }); - controller.close(); - return; - } - - const offEmitted = run.onEmitted((event) => { - write(event.type, event); - }); - const offDone = run.onDone((event) => { - write('done', event.output); - cleanup(); - controller.close(); - }); - const offError = run.onError((event) => { - write('error', { error: String(event.error) }); - cleanup(); - controller.close(); - }); - - cleanup = () => { - offEmitted(); - offDone(); - offError(); - }; - }, - cancel() { - cleanup(); - }, - }); - - return new Response(stream, { - headers: { - 'content-type': 'text/event-stream', - 'cache-control': 'no-cache', - }, - }); -} diff --git a/src/index.ts b/src/index.ts index 61ee823..c2949a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,20 @@ -// Core -export { createAgentMachine } from './machine.js'; +export { + addMessages, + createAgentSchemas, + createTextLogic, + doneEvent, + EVENT_TOOL_PREFIX, + getAvailableEvents, + getAgentEffects, + getEventTools, + messagesSchema, + parseOutput, + setupAgent, + transitionResult, + validateSchemaSync, +} from './setup-agent.js'; export { decide, decideResultSchema, requireAdapter } from './decide.js'; export { classify, classifyResultSchema } from './classify.js'; - -// Adapter export { createAdapter } from './adapter.js'; export { appendMessages, @@ -12,41 +23,40 @@ export { userMessage, } from './utils.js'; -// Types +export type { + AgentEffect, + AgentEffectOptions, + AgentEventDescriptor, + AgentEffectSource, + AgentTextInput, + AgentSchemaPack, + AgentTaskConfig, + AgentTaskKind, + AgentTaskLogic, + TextLogic, + TextLogicConfig, + TextLogicExecuteArgs, + TextLogicExecutor, + TextLogicInput, + TextLogicOutput, +} from './setup-agent.js'; + export type { AgentAdapter, - AgentMachine, + AgentGenerateTextInput, AgentMessage, - AgentRun, - AgentResolverSnapshot, - AgentSnapshot, - AgentState, + AgentTool, AgentToolChoice, + AgentToolDescriptor, + AgentToolExecute, AgentTools, ClassifyOptions, ClassifyResultFor, - DecideOptions, DecideAdapter, + DecideOptions, DecideResultFor, - EmittedPart, - EmittedUnion, EventPayload, EventUnion, - ExecuteResult, InferOutput, - InvokeEnqueue, - JournalEvent, - JournalEventRecord, - MachineConfig, - PersistedSnapshot, - ResolvableStateValue, - RestoreSessionOptions, - RunStore, - SessionOptions, StandardSchemaV1, - StateConfig, - StateResolverArgs, - Trace, - TransitionEvent, - TransitionResult, } from './types.js'; diff --git a/src/invoke-events.test.ts b/src/invoke-events.test.ts deleted file mode 100644 index 892f6eb..0000000 --- a/src/invoke-events.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from './local/index.js'; -import { z } from 'zod'; -import { - createAgentMachine, -} from './index.js'; - -function once( - subscribe: (handler: (event: T) => void) => () => void -) { - return new Promise((resolve) => { - const off = subscribe((event) => { - off(); - resolve(event); - }); - }); -} - -test('invoke success is journaled as an internal machine event', async () => { - const machine = createAgentMachine({ - id: 'invoke-success', - context: () => ({ result: null as string | null }), - initial: 'processing', - states: { - processing: { - schemas: { output: z.object({ value: z.string() }) }, - invoke: async () => ({ value: 'ok' }), - onDone: ({ output }) => ({ - target: 'done', - context: { result: output.value }, - }), - }, - done: { - type: 'final', - output: ({ context }) => context, - }, - }, - }); - - const store = createMemoryRunStore(); - const run = await startSession(machine, { store }); - await once(run.onDone.bind(run)); - const journal = await store.loadEvents(run.sessionId); - - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - context: { result: 'ok' }, - output: { result: 'ok' }, - }) - ); - expect(journal).toEqual([ - expect.objectContaining({ sequence: 1, type: 'xstate.init' }), - expect.objectContaining({ - sequence: 2, - type: 'xstate.done.invoke.processing', - output: { value: 'ok' }, - }), - ]); -}); - -test('invoke failure is journaled as an internal machine event', async () => { - const machine = createAgentMachine({ - id: 'invoke-failure', - context: () => ({ count: 0 }), - initial: 'processing', - states: { - processing: { - invoke: async () => { - throw new Error('boom'); - }, - }, - }, - }); - - const store = createMemoryRunStore(); - const run = await startSession(machine, { store }); - await once(run.onError.bind(run)); - const journal = await store.loadEvents(run.sessionId); - - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'processing', - status: 'error', - context: { count: 0 }, - error: expect.objectContaining({ message: 'boom' }), - }) - ); - expect(journal).toEqual([ - expect.objectContaining({ sequence: 1, type: 'xstate.init' }), - expect.objectContaining({ - sequence: 2, - type: 'xstate.error.invoke.processing', - error: expect.objectContaining({ message: 'boom' }), - }), - ]); -}); - -test('invalid invoke results fail without journaling a done event', async () => { - const machine = createAgentMachine({ - id: 'invoke-invalid-result', - context: () => ({ count: 0 }), - initial: 'processing', - states: { - processing: { - schemas: { output: z.object({ value: z.string() }) }, - invoke: async () => ({ value: 42 } as unknown as { value: string }), - }, - }, - }); - - const store = createMemoryRunStore(); - const run = await startSession(machine, { store }); - await once(run.onError.bind(run)); - const journal = await store.loadEvents(run.sessionId); - - expect(journal.map((event) => event.type)).toEqual([ - 'xstate.init', - 'xstate.error.invoke.processing', - ]); - expect(journal).not.toContainEqual( - expect.objectContaining({ - type: 'xstate.done.invoke.processing', - }) - ); - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - status: 'error', - error: expect.objectContaining({ - message: expect.stringContaining('Validation failed'), - }), - }) - ); -}); diff --git a/src/langgraph-equivalents/branching.test.ts b/src/langgraph-equivalents/branching.test.ts deleted file mode 100644 index fb14ede..0000000 --- a/src/langgraph-equivalents/branching.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { z } from 'zod'; -import { createAgentMachine } from '../index.js'; - -test('supports branching-style orchestration with plain async fan-out inside invoke', async () => { - const machine = createAgentMachine({ - id: 'langgraph-equivalent-branching', - schemas: { - input: z.object({ topic: z.string() }), - }, - context: (input) => ({ - topic: input.topic, - docs: null as string | null, - issues: null as string | null, - code: null as string | null, - summary: null as string | null, - }), - initial: 'analyzing', - states: { - analyzing: { - schemas: { output: z.object({ - docs: z.string(), - issues: z.string(), - code: z.string(), - }) }, - invoke: async ({ context }) => { - const [docs, issues, code] = await Promise.all([ - Promise.resolve(`docs about ${context.topic}`), - Promise.resolve(`issues about ${context.topic}`), - Promise.resolve(`code about ${context.topic}`), - ]); - - return { docs, issues, code }; - }, - onDone: ({ output }) => ({ - target: 'summarizing', - context: output, - }), - }, - summarizing: { - // paramsschema could help here, the summary has lots of string | null - schemas: { output: z.object({ summary: z.string() }) }, - invoke: async ({ context }) => ({ - summary: [context.docs, context.issues, context.code].join(' | '), - }), - onDone: ({ output }) => ({ - target: 'done', - context: { summary: output.summary }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - docs: context.docs, - issues: context.issues, - code: context.code, - summary: context.summary, - }), - }, - }, - }); - - const result = await execute(machine, - machine.getInitialState({ topic: 'agents' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - docs: 'docs about agents', - issues: 'issues about agents', - code: 'code about agents', - summary: 'docs about agents | issues about agents | code about agents', - }); - } -}); diff --git a/src/langgraph-equivalents/chatbot-messages.test.ts b/src/langgraph-equivalents/chatbot-messages.test.ts deleted file mode 100644 index d744592..0000000 --- a/src/langgraph-equivalents/chatbot-messages.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createChatbotMessagesExample } from '../../examples/index.js'; - -test('message-centric chatbot workflow accumulates structured messages across turns', async () => { - const machine = createChatbotMessagesExample(async (messages) => ({ - message: { - role: 'assistant', - content: `Replying to: ${messages.at(-1)?.content ?? ''}`, - }, - })); - - const afterFirstTurn = machine.transition(machine.getInitialState(), { - type: 'messages.user', - message: { - role: 'user', - content: 'Hello there', - }, - }); - const firstResult = await execute(machine, afterFirstTurn); - - expect(firstResult.status).toBe('pending'); - if (firstResult.status === 'pending') { - expect(firstResult.messages).toEqual([ - { role: 'user', content: 'Hello there' }, - { role: 'assistant', content: 'Replying to: Hello there' }, - ]); - - const afterSecondTurn = machine.transition(firstResult.state, { - type: 'messages.user', - message: { - role: 'user', - content: 'Can you expand on that?', - }, - }); - const secondResult = await execute(machine, afterSecondTurn); - - expect(secondResult.status).toBe('pending'); - if (secondResult.status === 'pending') { - expect(secondResult.messages).toEqual([ - { role: 'user', content: 'Hello there' }, - { role: 'assistant', content: 'Replying to: Hello there' }, - { role: 'user', content: 'Can you expand on that?' }, - { role: 'assistant', content: 'Replying to: Can you expand on that?' }, - ]); - } - } -}); diff --git a/src/langgraph-equivalents/conditional-subflow.test.ts b/src/langgraph-equivalents/conditional-subflow.test.ts deleted file mode 100644 index cc04f9a..0000000 --- a/src/langgraph-equivalents/conditional-subflow.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createConditionalSubflowExample } from '../../examples/index.js'; - -test('conditionally enters the research subflow from parent input', async () => { - const machine = createConditionalSubflowExample({ - research: async (topic) => ({ - bullets: [`${topic}:fact-1`, `${topic}:fact-2`], - }), - }); - - const result = await execute(machine, - machine.getInitialState({ - topic: 'agent graphs', - mode: 'research', - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - mode: 'research', - bullets: ['agent graphs:fact-1', 'agent graphs:fact-2'], - draft: null, - }); - } -}); - -test('conditionally enters the draft subflow with parent-provided input', async () => { - const machine = createConditionalSubflowExample({ - draft: async ({ topic, bullets }) => ({ - draft: `${topic}: ${bullets.join(' / ')}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ - topic: 'agent graphs', - mode: 'draft', - bullets: ['known fact', 'second fact'], - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - mode: 'draft', - bullets: ['known fact', 'second fact'], - draft: 'agent graphs: known fact / second fact', - }); - } -}); diff --git a/src/langgraph-equivalents/error-retry.test.ts b/src/langgraph-equivalents/error-retry.test.ts deleted file mode 100644 index 821c478..0000000 --- a/src/langgraph-equivalents/error-retry.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createErrorRetryExample } from '../../examples/index.js'; -import { createMemoryRunStore, restoreSession } from '../local/index.js'; - -test('retries failed invoke work through explicit internal error events', async () => { - let attempts = 0; - const machine = createErrorRetryExample(async ({ attempt }) => { - attempts += 1; - - if (attempt < 3) { - throw new Error(`temporary failure ${attempt}`); - } - - return { - answer: `answered on attempt ${attempt}`, - }; - }); - - const result = await execute(machine, - machine.getInitialState({ question: 'What is durable retry?' }) - ); - - expect(attempts).toBe(3); - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - answer: 'answered on attempt 3', - attempts: 3, - errors: ['temporary failure 1', 'temporary failure 2'], - }); - } -}); - -test('fails after the configured retry budget is exhausted', async () => { - const machine = createErrorRetryExample(async ({ attempt }) => { - throw new Error(`still down ${attempt}`); - }, 2); - - const result = await execute(machine, - machine.getInitialState({ question: 'Will this recover?' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - answer: null, - attempts: 2, - errors: ['still down 1', 'still down 2'], - }); - } -}); - -test('restores a durable retry snapshot and continues from the next attempt', async () => { - const sessionId = 'durable-retry-session'; - const machine = createErrorRetryExample(async ({ attempt }) => ({ - answer: `restored attempt ${attempt}`, - })); - const store = createMemoryRunStore(); - const input = { question: 'Can retry survive restore?' }; - const initial = machine.getInitialState(input); - const retryState = machine.transition(initial, { - type: 'xstate.error.invoke.answering', - error: { message: 'network reset' }, - at: 2, - }); - - await store.append(sessionId, { - type: 'xstate.init', - input, - at: 1, - }); - await store.append(sessionId, { - type: 'xstate.error.invoke.answering', - error: { message: 'network reset' }, - at: 2, - }); - await store.saveSnapshot({ - sessionId, - afterSequence: 2, - snapshot: { - value: retryState.value, - context: retryState.context, - messages: retryState.messages, - status: retryState.status, - input: retryState.input, - createdAt: 1, - sessionId, - }, - createdAt: 2, - }); - - const restored = await restoreSession(machine, { - sessionId, - store, - }); - - await vi.waitFor(() => { - expect(restored.getSnapshot().status).toBe('done'); - }); - - expect(restored.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - context: { - question: 'Can retry survive restore?', - answer: 'restored attempt 2', - attempt: 2, - errors: ['network reset'], - }, - output: { - answer: 'restored attempt 2', - attempts: 2, - errors: ['network reset'], - }, - }) - ); -}); diff --git a/src/langgraph-equivalents/graph.test.ts b/src/langgraph-equivalents/graph.test.ts deleted file mode 100644 index 4443ca4..0000000 --- a/src/langgraph-equivalents/graph.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { z } from 'zod'; -import { createAgentMachine } from '../index.js'; - -test('supports multi-step workflow accumulation like a sequential state graph', async () => { - const machine = createAgentMachine({ - id: 'langgraph-equivalent-sequence', - context: () => ({ messages: [] as string[] }), - initial: 'node1', - states: { - node1: { - schemas: { output: z.object({ messages: z.array(z.string()) }) }, - invoke: async () => ({ messages: ['from node1'] }), - onDone: ({ output, context }) => ({ - target: 'node2', - context: { messages: [...context.messages, ...output.messages] }, - }), - }, - node2: { - schemas: { output: z.object({ messages: z.array(z.string()) }) }, - invoke: async () => ({ messages: ['from node2'] }), - onDone: ({ output, context }) => ({ - target: 'node3', - context: { messages: [...context.messages, ...output.messages] }, - }), - }, - node3: { - schemas: { output: z.object({ messages: z.array(z.string()) }) }, - invoke: async () => ({ messages: ['from node3'] }), - onDone: ({ output, context }) => ({ - target: 'done', - context: { messages: [...context.messages, ...output.messages] }, - }), - }, - done: { - type: 'final', - output: ({ context }) => context, - }, - }, - }); - - const result = await execute(machine, machine.getInitialState()); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - messages: ['from node1', 'from node2', 'from node3'], - }); - } -}); - -test('supports conditional routing with explicit machine transitions', async () => { - const machine = createAgentMachine({ - id: 'langgraph-equivalent-routing', - schemas: { - input: z.object({ request: z.string() }), - }, - context: (input) => ({ - request: input.request, - route: null as string | null, - handledBy: null as string | null, - }), - initial: 'routeRequest', - states: { - routeRequest: { - schemas: { output: z.object({ - route: z.enum(['billing', 'general']), - }) }, - invoke: async ({ context }) => { - const route = context.request.toLowerCase().includes('refund') - ? 'billing' - : 'general'; - - return { route } as const; - }, - onDone: ({ output }) => ({ - target: output.route, - context: { route: output.route }, - }), - }, - billing: { - schemas: { output: z.object({ handledBy: z.literal('billing') }) }, - invoke: async () => ({ handledBy: 'billing' as const }), // why do we need to cast to const here? - onDone: ({ output }) => ({ - target: 'done', - context: { handledBy: output.handledBy }, - }), - }, - general: { - schemas: { output: z.object({ handledBy: z.literal('general') }) }, - invoke: async () => ({ handledBy: 'general' as const }), - onDone: ({ output }) => ({ - target: 'done', - context: { handledBy: output.handledBy }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - route: context.route, - handledBy: context.handledBy, - }), - }, - }, - }); - - const result = await execute(machine, - machine.getInitialState({ request: 'I need a refund for my invoice.' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - route: 'billing', - handledBy: 'billing', - }); - } -}); diff --git a/src/langgraph-equivalents/hitl.test.ts b/src/langgraph-equivalents/hitl.test.ts deleted file mode 100644 index f0ea291..0000000 --- a/src/langgraph-equivalents/hitl.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { z } from 'zod'; -import { createAgentMachine } from '../index.js'; - -test('supports human-in-the-loop review with explicit pending states and external events', async () => { - const machine = createAgentMachine({ - id: 'langgraph-equivalent-hitl', - schemas: { - input: z.object({ task: z.string() }), - events: { - approve: z.object({}), - revise: z.object({ note: z.string() }), - }, - }, - context: (input) => ({ - task: input.task, - notes: [] as string[], - draft: null as string | null, - }), - initial: 'drafting', - states: { - drafting: { - schemas: { output: z.object({ draft: z.string() }) }, - invoke: async ({ context }) => ({ - draft: `Draft for ${context.task}${context.notes.length ? ` (${context.notes.join(', ')})` : ''}`, - }), - onDone: ({ output }) => ({ - target: 'review', - context: { draft: output.draft }, - }), - }, - review: { - on: { - approve: { target: 'done' }, - revise: ({ event, context }) => ({ - target: 'drafting', - context: { notes: [...context.notes, event.note] }, - }), - }, - }, - done: { - type: 'final', - output: ({ context }) => ({ draft: context.draft }), - }, - }, - }); - - const first = await execute(machine, - machine.getInitialState({ task: 'reply to customer' }) - ); - - expect(first.status).toBe('pending'); - if (first.status !== 'pending') return; - - expect(first.value).toBe('review'); - expect(first.context.draft).toContain('reply to customer'); - - const revised = machine.transition(first.state, { - type: 'revise', - note: 'make it shorter', - }); - const second = await execute(machine, revised); - - expect(second.status).toBe('pending'); - if (second.status !== 'pending') return; - - const approved = machine.transition(second.state, { type: 'approve' }); - const done = await execute(machine, approved); - - expect(done.status).toBe('done'); - if (done.status === 'done') { - expect(done.output).toEqual({ - draft: 'Draft for reply to customer (make it shorter)', - }); - } -}); diff --git a/src/langgraph-equivalents/map-reduce.test.ts b/src/langgraph-equivalents/map-reduce.test.ts deleted file mode 100644 index 68aab06..0000000 --- a/src/langgraph-equivalents/map-reduce.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { z } from 'zod'; -import { createAgentMachine } from '../index.js'; - -test('supports map-reduce style orchestration with dynamic work items inside invoke', async () => { - const machine = createAgentMachine({ - id: 'langgraph-equivalent-map-reduce', - schemas: { - input: z.object({ topic: z.string() }), - }, - context: (input) => ({ - topic: input.topic, - subjects: [] as string[], - jokes: [] as string[], - bestJoke: null as string | null, - }), - initial: 'planning', - states: { - planning: { - schemas: { output: z.object({ subjects: z.array(z.string()) }) }, - invoke: async ({ context }) => ({ - subjects: [`${context.topic} basics`, `${context.topic} advanced`], - }), - onDone: ({ output }) => ({ - target: 'mapping', - context: { subjects: output.subjects }, - }), - }, - mapping: { - schemas: { output: z.object({ jokes: z.array(z.string()) }) }, - invoke: async ({ context }) => { - const jokes = await Promise.all( - context.subjects.map(async (subject) => `joke about ${subject}`) - ); - - return { jokes }; - }, - onDone: ({ output }) => ({ - target: 'reducing', - context: { jokes: output.jokes }, - }), - }, - reducing: { - schemas: { output: z.object({ bestJoke: z.string() }) }, - invoke: async ({ context }) => ({ - bestJoke: context.jokes[0] ?? '', - }), - onDone: ({ output }) => ({ - target: 'done', - context: { bestJoke: output.bestJoke }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - subjects: context.subjects, - jokes: context.jokes, - bestJoke: context.bestJoke, - }), - }, - }, - }); - - const result = await execute(machine, - machine.getInitialState({ topic: 'state machines' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - subjects: ['state machines basics', 'state machines advanced'], - jokes: [ - 'joke about state machines basics', - 'joke about state machines advanced', - ], - bestJoke: 'joke about state machines basics', - }); - } -}); diff --git a/src/langgraph-equivalents/multi-agent-network.test.ts b/src/langgraph-equivalents/multi-agent-network.test.ts deleted file mode 100644 index 45bfa04..0000000 --- a/src/langgraph-equivalents/multi-agent-network.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createMultiAgentNetworkExample } from '../../examples/multi-agent-network.js'; - -test('multi-agent network coordinates specialist handoffs until a final draft is ready', async () => { - let step = 0; - - const machine = createMultiAgentNetworkExample({ - adapter: { - decide: async () => { - step += 1; - - if (step === 1) { - return { - choice: 'research', - data: { focus: 'collect architecture notes' }, - }; - } - - if (step === 2) { - return { - choice: 'write', - data: { angle: 'turn notes into an executive summary' }, - }; - } - - return { - choice: 'finalize', - data: {}, - }; - }, - }, - research: async ({ topic, focus }) => ({ - notes: [`${topic}:${focus}:1`, `${topic}:${focus}:2`], - }), - write: async ({ topic, notes, angle }) => ({ - draft: `${topic} | ${angle} | ${notes.join(' / ')}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ topic: 'agent runtimes' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - topic: 'agent runtimes', - notes: [ - 'agent runtimes:collect architecture notes:1', - 'agent runtimes:collect architecture notes:2', - ], - draft: - 'agent runtimes | turn notes into an executive summary | agent runtimes:collect architecture notes:1 / agent runtimes:collect architecture notes:2', - handoffs: [ - 'researcher:collect architecture notes', - 'writer:turn notes into an executive summary', - ], - }); - } -}); diff --git a/src/langgraph-equivalents/persistence.test.ts b/src/langgraph-equivalents/persistence.test.ts deleted file mode 100644 index 7d29b69..0000000 --- a/src/langgraph-equivalents/persistence.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../local/index.js'; -import { z } from 'zod'; -import { - createAgentMachine, -} from '../index.js'; - -test('persists and restores a long-running approval workflow', async () => { - const machine = createAgentMachine({ - id: 'langgraph-equivalent-persistence', - context: () => ({ - approved: false, - summary: null as string | null, - }), - initial: 'review', - states: { - review: { - on: { - approve: { - target: 'summarize', - context: { approved: true }, - }, - }, - }, - summarize: { - schemas: { output: z.object({ summary: z.string() }) }, - invoke: async ({ context }) => ({ - summary: context.approved ? 'approved summary' : 'rejected summary', - }), - onDone: ({ output }) => ({ - target: 'done', - context: { summary: output.summary }, - }), - }, - done: { - type: 'final', - output: ({ context }) => context, - }, - }, - }); - - const baseStore = createMemoryRunStore(); - let snapshotWrites = 0; - const store = { - append: baseStore.append, - loadEvents: baseStore.loadEvents, - loadLatestSnapshot: baseStore.loadLatestSnapshot, - async saveSnapshot(snapshot: Awaited< - ReturnType - > extends infer TSaved - ? Exclude - : never) { - snapshotWrites += 1; - if (snapshotWrites === 1) { - await baseStore.saveSnapshot(snapshot); - } - }, - }; - - const liveRun = await startSession(machine, { store }); - await liveRun.send({ type: 'approve' }); - - const restoredRun = await restoreSession(machine, { - sessionId: liveRun.sessionId, - store, - }); - - await vi.waitFor(() => { - expect(restoredRun.getSnapshot()).toEqual(liveRun.getSnapshot()); - }); - - expect(restoredRun.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - context: { - approved: true, - summary: 'approved summary', - }, - output: { - approved: true, - summary: 'approved summary', - }, - }) - ); -}); diff --git a/src/langgraph-equivalents/persistent-multi-agent-network.test.ts b/src/langgraph-equivalents/persistent-multi-agent-network.test.ts deleted file mode 100644 index 863d993..0000000 --- a/src/langgraph-equivalents/persistent-multi-agent-network.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect, test } from 'vitest'; -import { runPersistentMultiAgentNetworkExample } from '../../examples/index.js'; - -test('restores a multi-agent handoff workflow from a persisted mid-handoff snapshot', async () => { - let step = 0; - - const result = await runPersistentMultiAgentNetworkExample( - { topic: 'durable agent handoffs' }, - { - adapter: { - decide: async () => { - step += 1; - - if (step === 1) { - return { - choice: 'research', - data: { focus: 'collect the most durable architecture notes' }, - }; - } - - if (step === 2) { - return { - choice: 'write', - data: { angle: 'summarize the handoff-ready findings' }, - }; - } - - return { - choice: 'finalize', - data: {}, - }; - }, - }, - research: async ({ topic, focus }) => ({ - notes: [`${topic}:${focus}:1`, `${topic}:${focus}:2`], - }), - write: async ({ topic, notes, angle }) => ({ - draft: `${topic} | ${angle} | ${notes.join(' / ')}`, - }), - } - ); - - expect(result.restoredSnapshot).toEqual(result.liveSnapshot); - expect(result.restoredSnapshot).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { - topic: 'durable agent handoffs', - notes: [ - 'durable agent handoffs:collect the most durable architecture notes:1', - 'durable agent handoffs:collect the most durable architecture notes:2', - ], - draft: - 'durable agent handoffs | summarize the handoff-ready findings | durable agent handoffs:collect the most durable architecture notes:1 / durable agent handoffs:collect the most durable architecture notes:2', - handoffs: [ - 'researcher:collect the most durable architecture notes', - 'writer:summarize the handoff-ready findings', - ], - }, - }) - ); -}); diff --git a/src/langgraph-equivalents/persistent-streaming.test.ts b/src/langgraph-equivalents/persistent-streaming.test.ts deleted file mode 100644 index 386e92e..0000000 --- a/src/langgraph-equivalents/persistent-streaming.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { expect, test } from 'vitest'; -import { runPersistentStreamingExample } from '../../examples/index.js'; - -test('restores a streaming workflow without replaying stale emitted parts', async () => { - const result = await runPersistentStreamingExample(); - - expect(result.initialParts).toEqual(['hel']); - expect(result.restoredParts).toEqual(['lo']); - expect(result.initialSnapshot).toEqual( - expect.objectContaining({ - value: 'writing', - status: 'active', - }) - ); - expect(result.restoredSnapshot).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { text: 'hello' }, - }) - ); - expect(result.journal.map((event) => event.type)).toEqual([ - 'xstate.init', - 'xstate.done.invoke.writing', - ]); -}); diff --git a/src/langgraph-equivalents/persistent-supervisor.test.ts b/src/langgraph-equivalents/persistent-supervisor.test.ts deleted file mode 100644 index 4885808..0000000 --- a/src/langgraph-equivalents/persistent-supervisor.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect, test } from 'vitest'; -import { runPersistentSupervisorExample } from '../../examples/index.js'; - -test('restores a supervisor handoff workflow from a persisted retry snapshot', async () => { - let decisions = 0; - - const result = await runPersistentSupervisorExample( - { request: 'Reverse the duplicate subscription charge.' }, - { - adapter: { - decide: async () => { - decisions += 1; - - if (decisions === 1) { - return { - choice: 'retry', - data: { - instruction: 'Retry using the verified billing email on file.', - }, - }; - } - - return { - choice: 'escalate', - data: { - reason: 'Escalate to billing because the account is still ambiguous.', - }, - }; - }, - }, - handle: async ({ attempt, instruction }) => ({ - status: 'blocked', - issue: - attempt === 1 - ? 'Missing account identifier.' - : `Still blocked after retry: ${instruction}`, - }), - maxAttempts: 2, - } - ); - - expect(result.restoredSnapshot).toEqual(result.liveSnapshot); - expect(result.restoredSnapshot).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { - request: 'Reverse the duplicate subscription charge.', - status: 'escalated', - resolution: null, - escalationReason: - 'Escalate to billing because the account is still ambiguous.', - attemptCount: 2, - history: [ - 'worker:1:blocked:Missing account identifier.', - 'supervisor:retry:Retry using the verified billing email on file.', - 'worker:2:blocked:Still blocked after retry: Retry using the verified billing email on file.', - 'supervisor:escalate:Escalate to billing because the account is still ambiguous.', - ], - }, - }) - ); -}); diff --git a/src/langgraph-equivalents/plan-and-execute.test.ts b/src/langgraph-equivalents/plan-and-execute.test.ts deleted file mode 100644 index f307410..0000000 --- a/src/langgraph-equivalents/plan-and-execute.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createPlanAndExecuteExample } from '../../examples/plan-and-execute.js'; - -test('plan-and-execute workflow decomposes a goal and synthesizes a final answer', async () => { - const machine = createPlanAndExecuteExample({ - plan: async () => ({ - plan: ['inspect docs', 'inspect code', 'summarize findings'], - }), - executeStep: async ({ step }) => ({ - result: `done:${step}`, - }), - synthesize: async ({ stepResults }) => ({ - answer: stepResults.join(' | '), - }), - }); - - const result = await execute(machine, - machine.getInitialState({ goal: 'understand the repo' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - goal: 'understand the repo', - plan: ['inspect docs', 'inspect code', 'summarize findings'], - stepResults: [ - 'done:inspect docs', - 'done:inspect code', - 'done:summarize findings', - ], - answer: - 'done:inspect docs | done:inspect code | done:summarize findings', - }); - } -}); diff --git a/src/langgraph-equivalents/prebuilt-react.test.ts b/src/langgraph-equivalents/prebuilt-react.test.ts deleted file mode 100644 index 6537889..0000000 --- a/src/langgraph-equivalents/prebuilt-react.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../local/index.js'; -import { createReactAgentFromScratch } from '../../examples/react-agent-from-scratch.js'; - -function once( - subscribe: (handler: (event: T) => void) => () => void -) { - return new Promise((resolve) => { - let off = () => {}; - off = subscribe((event) => { - off(); - resolve(event); - }); - }); -} - -test('react agent from scratch loops through a tool call and returns a final answer', async () => { - const agent = createReactAgentFromScratch({ - prompt: 'You are helpful.', - tools: [ - { - name: 'search', - description: 'Searches for a query', - execute: async (input) => `result for ${String(input.query)}`, - }, - ], - model: async ({ messages }) => { - const last = messages.at(-1); - - if (!last || last.role === 'user') { - return { - kind: 'tool' as const, - toolName: 'search', - input: { query: 'weather in sf' }, - message: 'I should search first.', - }; - } - - if (last.role === 'tool') { - return { - kind: 'final' as const, - message: `Answer based on: ${last.content}`, - }; - } - - throw new Error('Unexpected transcript state'); - }, - }); - - const run = await startSession(agent, { - store: createMemoryRunStore(), - input: { - messages: [{ role: 'user', content: 'What is the weather?' }], - }, - }); - const toolEvents: string[] = []; - - run.on('toolCall', (event) => { - toolEvents.push(`call:${event.toolName}`); - }); - run.on('toolResult', (event) => { - toolEvents.push(`result:${event.toolName}`); - }); - - await once(run.onDone.bind(run)); - - expect(toolEvents).toEqual(['call:search', 'result:search']); - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { - finalMessage: 'Answer based on: result for weather in sf', - messages: [ - { role: 'system', content: 'You are helpful.' }, - { role: 'user', content: 'What is the weather?' }, - { role: 'assistant', content: 'I should search first.' }, - { role: 'tool', name: 'search', content: 'result for weather in sf' }, - { role: 'assistant', content: 'Answer based on: result for weather in sf' }, - ], - steps: 2, - }, - }) - ); -}); diff --git a/src/langgraph-equivalents/rag.test.ts b/src/langgraph-equivalents/rag.test.ts deleted file mode 100644 index a01f60b..0000000 --- a/src/langgraph-equivalents/rag.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createRagExample } from '../../examples/index.js'; - -test('rag workflow retrieves documents and synthesizes a grounded answer', async () => { - const machine = createRagExample({ - retrieve: async (question) => ({ - documents: [ - { id: 'doc-1', content: `${question} :: first fact` }, - { id: 'doc-2', content: `${question} :: second fact` }, - ], - }), - adapter: { - generateText: async ({ prompt }) => ({ - answer: String(prompt) - .replace('Question: ', '') - .replace('\n\nDocuments:\n- [doc-1] ', ' => ') - .replace('\n- [doc-2] ', ' | '), - }), - }, - }); - - const result = await execute(machine, - machine.getInitialState({ question: 'What is LangGraph?' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - question: 'What is LangGraph?', - documents: [ - { id: 'doc-1', content: 'What is LangGraph? :: first fact' }, - { id: 'doc-2', content: 'What is LangGraph? :: second fact' }, - ], - answer: - 'What is LangGraph? => What is LangGraph? :: first fact | What is LangGraph? :: second fact', - }); - } -}); diff --git a/src/langgraph-equivalents/raw-xstate.test.ts b/src/langgraph-equivalents/raw-xstate.test.ts new file mode 100644 index 0000000..83a01cf --- /dev/null +++ b/src/langgraph-equivalents/raw-xstate.test.ts @@ -0,0 +1,1216 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { assign, createActor, fromPromise, toPromise, waitFor } from 'xstate'; +import { createTextLogic, setupAgent } from '../index.js'; + +describe('LangGraph-style workflows authored as raw XState', () => { + test('conditional routing uses declarative text actor input', async () => { + const routeRequest = createTextLogic({ + schemas: { + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['answer', 'escalate']) }), + }, + model: 'classifier', + prompt: ({ input }) => input.request, + }); + const agent = setupAgent({ + context: z.object({ + request: z.string(), + route: z.enum(['answer', 'escalate']).nullable(), + }), + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['answer', 'escalate']) }), + actors: { routeRequest }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-branching', + context: ({ input }) => ({ request: input.request, route: null }), + initial: 'classifying', + states: { + classifying: { + invoke: { + src: 'routeRequest', + input: ({ context }) => ({ request: context.request }), + onDone: { + target: 'routing', + actions: assign({ route: ({ event }) => event.output.route }), + }, + }, + }, + routing: { + always: [ + { guard: ({ context }) => context.route === 'escalate', target: 'escalated' }, + { target: 'answered' }, + ], + }, + answered: { type: 'final', output: () => ({ route: 'answer' as const }) }, + escalated: { type: 'final', output: () => ({ route: 'escalate' as const }) }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + routeRequest: routeRequest.withExecutor( + async () => ({ route: 'escalate' }) + ), + }, + }), + { input: { request: 'billing is broken' } } + ); + + actor.start(); + await waitFor(actor, (snapshot) => snapshot.status === 'done'); + + expect(actor.getSnapshot().output).toEqual({ route: 'escalate' }); + }); + + test('human-in-the-loop approval uses typed external events', async () => { + const writeDraft = createTextLogic({ + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => input.topic, + }); + const agent = setupAgent({ + context: z.object({ + topic: z.string(), + draft: z.string().nullable(), + }), + input: z.object({ topic: z.string() }), + output: z.object({ published: z.boolean(), draft: z.string() }), + events: { + APPROVE: z.object({}), + REJECT: z.object({ reason: z.string() }), + }, + actors: { writeDraft }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-hitl', + context: ({ input }) => ({ topic: input.topic, draft: null }), + initial: 'drafting', + states: { + drafting: { + invoke: { + src: 'writeDraft', + input: ({ context }) => ({ topic: context.topic }), + onDone: { + target: 'reviewing', + actions: assign({ draft: ({ event }) => event.output }), + }, + }, + }, + reviewing: { + on: { + APPROVE: { target: 'published' }, + REJECT: { + target: 'drafting', + actions: assign({ + topic: ({ context, event }) => + `${context.topic}\nRevision: ${event.reason}`, + }), + }, + }, + }, + published: { + type: 'final', + output: ({ context }) => ({ + published: true, + draft: context.draft ?? '', + }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + writeDraft: writeDraft.withExecutor( + async ({ input }) => `Draft: ${input.topic}` + ), + }, + }), + { input: { topic: 'release notes' } } + ); + + actor.start(); + await waitFor(actor, (snapshot) => snapshot.matches('reviewing')); + actor.send({ type: 'APPROVE' }); + await waitFor(actor, (snapshot) => snapshot.status === 'done'); + + expect(actor.getSnapshot().output).toEqual({ + published: true, + draft: 'Draft: release notes', + }); + }); + + test('plan-and-execute composes generated output and local actors', async () => { + const planSchema = z.object({ + steps: z.array(z.string()), + }); + const planTask = createTextLogic({ + schemas: { + input: z.object({ task: z.string() }), + output: planSchema, + }, + model: 'planner', + prompt: ({ input }) => input.task, + }); + + const agent = setupAgent({ + context: z.object({ + task: z.string(), + steps: z.array(z.string()), + results: z.array(z.string()), + }), + input: z.object({ task: z.string() }), + output: z.object({ results: z.array(z.string()) }), + actors: { + planTask, + runStep: fromPromise( + async ({ input }) => `done:${input.step}` + ), + }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-plan-and-execute', + context: ({ input }) => ({ task: input.task, steps: [], results: [] }), + initial: 'planning', + states: { + planning: { + invoke: { + src: 'planTask', + input: ({ context }) => ({ task: context.task }), + onDone: { + target: 'running', + actions: assign({ steps: ({ event }) => event.output.steps }), + }, + }, + }, + running: { + invoke: { + src: 'runStep', + input: ({ context }) => ({ step: context.steps[0] ?? '' }), + onDone: { + target: 'checking', + actions: assign({ + steps: ({ context }) => context.steps.slice(1), + results: ({ context, event }) => [...context.results, event.output], + }), + }, + }, + }, + checking: { + always: [ + { guard: ({ context }) => context.steps.length > 0, target: 'running' }, + { target: 'done' }, + ], + }, + done: { + type: 'final', + output: ({ context }) => ({ results: context.results }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + planTask: planTask.withExecutor( + async () => ({ steps: ['research', 'write'] }) + ), + }, + }), + { input: { task: 'make a brief' } } + ); + + actor.start(); + await waitFor(actor, (snapshot) => snapshot.status === 'done'); + + expect(actor.getSnapshot().output).toEqual({ + results: ['done:research', 'done:write'], + }); + }); + + test('tool-calling streams typed host-side progress', async () => { + const emitted: string[] = []; + const agent = setupAgent({ + context: z.object({ + city: z.string(), + forecast: z.string().nullable(), + }), + input: z.object({ city: z.string() }), + output: z.object({ forecast: z.string() }), + actors: { + getWeather: fromPromise(async ({ input }) => { + emitted.push(`call:${input.city}`); + emitted.push(`progress:${input.city}:1`); + emitted.push(`progress:${input.city}:2`); + return `Sunny in ${input.city}`; + }), + }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-tool-calling', + context: ({ input }) => ({ city: input.city, forecast: null }), + initial: 'checkingWeather', + states: { + checkingWeather: { + invoke: { + src: 'getWeather', + input: ({ context }) => ({ city: context.city }), + onDone: { + target: 'done', + actions: assign({ forecast: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ forecast: context.forecast ?? '' }), + }, + }, + }); + + const actor = createActor(machine, { input: { city: 'Boston' } }); + actor.start(); + await toPromise(actor); + + expect(emitted).toEqual([ + 'call:Boston', + 'progress:Boston:1', + 'progress:Boston:2', + ]); + expect(actor.getSnapshot().output).toEqual({ forecast: 'Sunny in Boston' }); + }); + + test('persistence restores from XState snapshots without a custom runtime', async () => { + const writeDraft = createTextLogic({ + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => input.topic, + }); + const agent = setupAgent({ + context: z.object({ + topic: z.string(), + draft: z.string().nullable(), + }), + input: z.object({ topic: z.string() }), + output: z.object({ draft: z.string() }), + events: { + APPROVE: z.object({}), + }, + actors: { writeDraft }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-persistence', + context: ({ input }) => ({ topic: input.topic, draft: null }), + initial: 'drafting', + states: { + drafting: { + invoke: { + src: 'writeDraft', + input: ({ context }) => ({ topic: context.topic }), + onDone: { + target: 'reviewing', + actions: assign({ draft: ({ event }) => event.output }), + }, + }, + }, + reviewing: { + on: { APPROVE: { target: 'done' } }, + }, + done: { + type: 'final', + output: ({ context }) => ({ draft: context.draft ?? '' }), + }, + }, + }); + + const actors = { + writeDraft: writeDraft.withExecutor( + async ({ input }) => `Draft: ${input.topic}` + ), + }; + const first = createActor(machine.provide({ actors }), { + input: { topic: 'incident update' }, + }); + first.start(); + await waitFor(first, (snapshot) => snapshot.matches('reviewing')); + + const persisted = first.getPersistedSnapshot(); + first.stop(); + + const restored = createActor(machine.provide({ actors }), { + input: { topic: 'incident update' }, + snapshot: persisted, + }); + restored.start(); + restored.send({ type: 'APPROVE' }); + await toPromise(restored); + + expect(restored.getSnapshot().output).toEqual({ + draft: 'Draft: incident update', + }); + }); + + test('subflows compose as typed child actors', async () => { + const researchTopic = createTextLogic({ + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'researcher', + prompt: ({ input }) => input.topic, + }); + const childAgent = setupAgent({ + context: z.object({ topic: z.string(), research: z.string().nullable() }), + input: z.object({ topic: z.string() }), + output: z.object({ research: z.string() }), + actors: { researchTopic }, + }); + const childMachine = childAgent.createMachine({ + id: 'raw-xstate-child-research', + context: ({ input }) => ({ topic: input.topic, research: null }), + initial: 'researching', + states: { + researching: { + invoke: { + src: 'researchTopic', + input: ({ context }) => ({ topic: context.topic }), + onDone: { + target: 'done', + actions: assign({ research: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ research: context.research ?? '' }), + }, + }, + }); + + const parentAgent = setupAgent({ + context: z.object({ topic: z.string(), research: z.string().nullable() }), + input: z.object({ topic: z.string() }), + output: z.object({ research: z.string() }), + actors: { child: childMachine }, + }); + const parentMachine = parentAgent.createMachine({ + id: 'raw-xstate-parent-subflow', + context: ({ input }) => ({ topic: input.topic, research: null }), + initial: 'delegating', + states: { + delegating: { + invoke: { + src: 'child', + input: ({ context }) => ({ topic: context.topic }), + onDone: { + target: 'done', + actions: assign({ + research: ({ event }) => event.output.research, + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ research: context.research ?? '' }), + }, + }, + }); + + const actor = createActor( + parentMachine.provide({ + actors: { + child: childMachine.provide({ + actors: { + researchTopic: researchTopic.withExecutor( + async ({ input }) => `Research: ${input.topic}` + ), + }, + }), + }, + }), + { input: { topic: 'agents' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ research: 'Research: agents' }); + }); + + test('supervisor handoff is explicit typed routing', async () => { + const routeRequest = createTextLogic({ + schemas: { + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['research', 'write']) }), + }, + model: 'router', + prompt: ({ input }) => input.request, + }); + const agent = setupAgent({ + context: z.object({ + request: z.string(), + route: z.enum(['research', 'write']).nullable(), + result: z.string().nullable(), + }), + input: z.object({ request: z.string() }), + output: z.object({ result: z.string() }), + actors: { + routeRequest, + research: fromPromise( + async ({ input }) => `research:${input.request}` + ), + write: fromPromise( + async ({ input }) => `write:${input.request}` + ), + }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-supervisor', + context: ({ input }) => ({ + request: input.request, + route: null, + result: null, + }), + initial: 'routing', + states: { + routing: { + invoke: { + src: 'routeRequest', + input: ({ context }) => ({ request: context.request }), + onDone: { + target: 'dispatch', + actions: assign({ route: ({ event }) => event.output.route }), + }, + }, + }, + dispatch: { + always: [ + { guard: ({ context }) => context.route === 'research', target: 'researching' }, + { target: 'writing' }, + ], + }, + researching: { + invoke: { + src: 'research', + input: ({ context }) => ({ request: context.request }), + onDone: { + target: 'done', + actions: assign({ result: ({ event }) => event.output }), + }, + }, + }, + writing: { + invoke: { + src: 'write', + input: ({ context }) => ({ request: context.request }), + onDone: { + target: 'done', + actions: assign({ result: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ result: context.result ?? '' }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + routeRequest: routeRequest.withExecutor( + async () => ({ route: 'research' }) + ), + }, + }), + { input: { request: 'compare frameworks' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ + result: 'research:compare frameworks', + }); + }); + + test('map-reduce fan-out uses typed local actors and normal JavaScript concurrency', async () => { + const reduceSummaries = createTextLogic({ + schemas: { + input: z.object({ summaries: z.array(z.string()) }), + output: z.string(), + }, + model: 'reducer', + prompt: ({ input }) => input.summaries.join('\n'), + }); + const agent = setupAgent({ + context: z.object({ + sections: z.array(z.string()), + summaries: z.array(z.string()), + final: z.string().nullable(), + }), + input: z.object({ sections: z.array(z.string()) }), + output: z.object({ final: z.string() }), + actors: { + reduceSummaries, + summarizeAll: fromPromise( + async ({ input }) => + Promise.all( + input.sections.map(async (section: string) => `summary:${section}`) + ) + ), + }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-map-reduce', + context: ({ input }) => ({ + sections: input.sections, + summaries: [], + final: null, + }), + initial: 'mapping', + states: { + mapping: { + invoke: { + src: 'summarizeAll', + input: ({ context }) => ({ sections: context.sections }), + onDone: { + target: 'reducing', + actions: assign({ summaries: ({ event }) => event.output }), + }, + }, + }, + reducing: { + invoke: { + src: 'reduceSummaries', + input: ({ context }) => ({ summaries: context.summaries }), + onDone: { + target: 'done', + actions: assign({ final: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ final: context.final ?? '' }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + reduceSummaries: reduceSummaries.withExecutor( + async ({ input }) => `reduced:${input.summaries.join('\n')}` + ), + }, + }), + { input: { sections: ['a', 'b'] } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ + final: 'reduced:summary:a\nsummary:b', + }); + }); + + test('RAG keeps retrieval as a typed host actor before generation', async () => { + const answerQuestion = createTextLogic({ + schemas: { + input: z.object({ + question: z.string(), + documents: z.array(z.string()), + }), + output: z.string(), + }, + model: 'answerer', + prompt: ({ input }) => `Q: ${input.question}\nDocs:\n${input.documents.join('\n')}`, + }); + const agent = setupAgent({ + context: z.object({ + question: z.string(), + documents: z.array(z.string()), + answer: z.string().nullable(), + }), + input: z.object({ question: z.string() }), + output: z.object({ answer: z.string() }), + actors: { + answerQuestion, + retrieve: fromPromise( + async ({ input }) => [`doc:${input.question}`, 'doc:typed state'] + ), + }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-rag', + context: ({ input }) => ({ + question: input.question, + documents: [], + answer: null, + }), + initial: 'retrieving', + states: { + retrieving: { + invoke: { + src: 'retrieve', + input: ({ context }) => ({ question: context.question }), + onDone: { + target: 'answering', + actions: assign({ documents: ({ event }) => event.output }), + }, + }, + }, + answering: { + invoke: { + src: 'answerQuestion', + input: ({ context }) => ({ + question: context.question, + documents: context.documents, + }), + onDone: { + target: 'done', + actions: assign({ answer: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ answer: context.answer ?? '' }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + answerQuestion: answerQuestion.withExecutor( + async ({ input }) => + `answer from Q: ${input.question}\nDocs:\n${input.documents.join('\n')}` + ), + }, + }), + { input: { question: 'why xstate agents?' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual( + expect.objectContaining({ + answer: expect.stringContaining('doc:typed state'), + }) + ); + }); + + test('reflection loops are explicit guarded states with validated critique output', async () => { + const critiqueSchema = z.object({ + approved: z.boolean(), + feedback: z.string(), + }); + const writeDraft = createTextLogic({ + schemas: { + input: z.object({ + prompt: z.string(), + feedback: z.string().nullable(), + }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => + input.feedback + ? `${input.prompt}\nRevise: ${input.feedback}` + : input.prompt, + }); + const critiqueDraft = createTextLogic({ + schemas: { + input: z.object({ draft: z.string() }), + output: critiqueSchema, + }, + model: 'critic', + prompt: ({ input }) => input.draft, + }); + let critiqueCount = 0; + const agent = setupAgent({ + context: z.object({ + prompt: z.string(), + draft: z.string().nullable(), + feedback: z.string().nullable(), + approved: z.boolean(), + }), + input: z.object({ prompt: z.string() }), + output: z.object({ draft: z.string() }), + actors: { writeDraft, critiqueDraft }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-reflection', + context: ({ input }) => ({ + prompt: input.prompt, + draft: null, + feedback: null, + approved: false, + }), + initial: 'drafting', + states: { + drafting: { + invoke: { + src: 'writeDraft', + input: ({ context }) => ({ + prompt: context.prompt, + feedback: context.feedback, + }), + onDone: { + target: 'critiquing', + actions: assign({ draft: ({ event }) => event.output }), + }, + }, + }, + critiquing: { + invoke: { + src: 'critiqueDraft', + input: ({ context }) => ({ draft: context.draft ?? '' }), + onDone: { + target: 'checking', + actions: assign({ + approved: ({ event }) => event.output.approved, + feedback: ({ event }) => event.output.feedback, + }), + }, + }, + }, + checking: { + always: [ + { guard: ({ context }) => context.approved, target: 'done' }, + { target: 'drafting' }, + ], + }, + done: { + type: 'final', + output: ({ context }) => ({ draft: context.draft ?? '' }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + writeDraft: writeDraft.withExecutor( + async ({ input }) => `draft:${ + input.feedback + ? `${input.prompt}\nRevise: ${input.feedback}` + : input.prompt + }` + ), + critiqueDraft: critiqueDraft.withExecutor( + async () => { + critiqueCount += 1; + return { + approved: critiqueCount > 1, + feedback: critiqueCount > 1 ? 'ship' : 'add evidence', + }; + } + ), + }, + }), + { input: { prompt: 'make the case' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ + draft: 'draft:make the case\nRevise: add evidence', + }); + }); + + test('ReWOO-style planner and worker decomposition stays explicit', async () => { + const planSchema = z.object({ + steps: z.array( + z.object({ + id: z.string(), + task: z.string(), + }) + ), + }); + const planWork = createTextLogic({ + schemas: { + input: z.object({ goal: z.string() }), + output: planSchema, + }, + model: 'planner', + prompt: ({ input }) => input.goal, + }); + const solveWork = createTextLogic({ + schemas: { + input: z.object({ evidence: z.record(z.string(), z.string()) }), + output: z.string(), + }, + model: 'solver', + prompt: ({ input }) => JSON.stringify(input.evidence), + }); + const agent = setupAgent({ + context: z.object({ + goal: z.string(), + steps: z.array(z.object({ id: z.string(), task: z.string() })), + evidence: z.record(z.string(), z.string()), + answer: z.string().nullable(), + }), + input: z.object({ goal: z.string() }), + output: z.object({ + answer: z.string(), + evidence: z.record(z.string(), z.string()), + }), + actors: { + planWork, + solveWork, + executePlan: fromPromise< + Record, + { steps: Array<{ id: string; task: string }> } + >(async ({ input }) => + Object.fromEntries( + input.steps.map((step: { id: string; task: string }) => [ + step.id, + `result:${step.task}`, + ]) + ) + ), + }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-rewoo', + context: ({ input }) => ({ + goal: input.goal, + steps: [], + evidence: {}, + answer: null, + }), + initial: 'planning', + states: { + planning: { + invoke: { + src: 'planWork', + input: ({ context }) => ({ goal: context.goal }), + onDone: { + target: 'working', + actions: assign({ steps: ({ event }) => event.output.steps }), + }, + }, + }, + working: { + invoke: { + src: 'executePlan', + input: ({ context }) => ({ steps: context.steps }), + onDone: { + target: 'solving', + actions: assign({ evidence: ({ event }) => event.output }), + }, + }, + }, + solving: { + invoke: { + src: 'solveWork', + input: ({ context }) => ({ evidence: context.evidence }), + onDone: { + target: 'done', + actions: assign({ answer: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + answer: context.answer ?? '', + evidence: context.evidence, + }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + planWork: planWork.withExecutor( + async ({ input }) => ({ steps: [{ id: 'E1', task: input.goal }] }) + ), + solveWork: solveWork.withExecutor( + async ({ input }) => `answer:${JSON.stringify(input.evidence)}` + ), + }, + }), + { input: { goal: 'compare tools' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ + answer: 'answer:{"E1":"result:compare tools"}', + evidence: { E1: 'result:compare tools' }, + }); + }); + + test('SQL-style agents keep query generation, execution, and answer synthesis explicit', async () => { + const querySchema = z.object({ sql: z.string() }); + const writeQuery = createTextLogic({ + schemas: { + input: z.object({ question: z.string() }), + output: querySchema, + }, + model: 'sql-writer', + prompt: ({ input }) => input.question, + }); + const answerRows = createTextLogic({ + schemas: { + input: z.object({ rows: z.array(z.record(z.string(), z.string())) }), + output: z.string(), + }, + model: 'answerer', + prompt: ({ input }) => JSON.stringify(input.rows), + }); + const agent = setupAgent({ + context: z.object({ + question: z.string(), + sql: z.string().nullable(), + rows: z.array(z.record(z.string(), z.string())), + answer: z.string().nullable(), + }), + input: z.object({ question: z.string() }), + output: z.object({ sql: z.string(), answer: z.string() }), + actors: { + writeQuery, + answerRows, + queryDatabase: fromPromise< + Array>, + { sql: string } + >(async ({ input }) => [{ total: '42', sql: input.sql }]), + }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-sql-agent', + context: ({ input }) => ({ + question: input.question, + sql: null, + rows: [], + answer: null, + }), + initial: 'writingQuery', + states: { + writingQuery: { + invoke: { + src: 'writeQuery', + input: ({ context }) => ({ question: context.question }), + onDone: { + target: 'querying', + actions: assign({ sql: ({ event }) => event.output.sql }), + }, + }, + }, + querying: { + invoke: { + src: 'queryDatabase', + input: ({ context }) => ({ sql: context.sql ?? '' }), + onDone: { + target: 'answering', + actions: assign({ rows: ({ event }) => event.output }), + }, + }, + }, + answering: { + invoke: { + src: 'answerRows', + input: ({ context }) => ({ rows: context.rows }), + onDone: { + target: 'done', + actions: assign({ answer: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + sql: context.sql ?? '', + answer: context.answer ?? '', + }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + writeQuery: writeQuery.withExecutor( + async () => ({ sql: 'select count(*) as total from users' }) + ), + answerRows: answerRows.withExecutor( + async ({ input }) => `final:${JSON.stringify(input.rows)}` + ), + }, + }), + { input: { question: 'how many users?' } } + ); + actor.start(); + await toPromise(actor); + + expect(actor.getSnapshot().output).toEqual({ + sql: 'select count(*) as total from users', + answer: 'final:[{"total":"42","sql":"select count(*) as total from users"}]', + }); + }); + + test('persistent multi-agent networks resume with plain XState snapshots', async () => { + const agent = setupAgent({ + context: z.object({ + topic: z.string(), + research: z.string().nullable(), + draft: z.string().nullable(), + }), + input: z.object({ topic: z.string() }), + output: z.object({ draft: z.string() }), + events: { + CONTINUE: z.object({}), + }, + actors: { + research: fromPromise( + async ({ input }) => `research:${input.topic}` + ), + write: fromPromise( + async ({ input }) => `draft:${input.research}` + ), + }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-persistent-network', + context: ({ input }) => ({ + topic: input.topic, + research: null, + draft: null, + }), + initial: 'researching', + states: { + researching: { + invoke: { + src: 'research', + input: ({ context }) => ({ topic: context.topic }), + onDone: { + target: 'waitingToWrite', + actions: assign({ research: ({ event }) => event.output }), + }, + }, + }, + waitingToWrite: { + on: { CONTINUE: { target: 'writing' } }, + }, + writing: { + invoke: { + src: 'write', + input: ({ context }) => ({ research: context.research ?? '' }), + onDone: { + target: 'done', + actions: assign({ draft: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ draft: context.draft ?? '' }), + }, + }, + }); + + const first = createActor(machine, { input: { topic: 'xstate' } }); + first.start(); + await waitFor(first, (snapshot) => snapshot.matches('waitingToWrite')); + const persisted = first.getPersistedSnapshot(); + first.stop(); + + const restored = createActor(machine, { + input: { topic: 'xstate' }, + snapshot: persisted, + }); + restored.start(); + restored.send({ type: 'CONTINUE' }); + await toPromise(restored); + + expect(restored.getSnapshot().output).toEqual({ + draft: 'draft:research:xstate', + }); + }); + + test('streaming keeps chunks in the host side channel', async () => { + const chunks: string[] = []; + const streamTopic = createTextLogic({ + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => input.topic, + }); + const agent = setupAgent({ + context: z.object({ topic: z.string(), text: z.string().nullable() }), + input: z.object({ topic: z.string() }), + output: z.object({ text: z.string() }), + actors: { streamTopic }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-streaming', + context: ({ input }) => ({ topic: input.topic, text: null }), + initial: 'streaming', + states: { + streaming: { + invoke: { + src: 'streamTopic', + input: ({ context }) => ({ topic: context.topic }), + onDone: { + target: 'done', + actions: assign({ text: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ text: context.text ?? '' }), + }, + }, + }); + + const actor = createActor( + machine.provide({ + actors: { + streamTopic: streamTopic.withExecutor( + async ({ input }) => { + chunks.push('hello'); + chunks.push(input.topic); + return chunks.join(' '); + } + ), + }, + }), + { input: { topic: 'agents' } } + ); + actor.start(); + await toPromise(actor); + + expect(chunks).toEqual(['hello', 'agents']); + expect(actor.getSnapshot().output).toEqual({ text: 'hello agents' }); + }); +}); diff --git a/src/langgraph-equivalents/reflection.test.ts b/src/langgraph-equivalents/reflection.test.ts deleted file mode 100644 index e380960..0000000 --- a/src/langgraph-equivalents/reflection.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createReflectionExample } from '../../examples/reflection.js'; - -test('reflection workflow revises a draft until critique is cleared', async () => { - const machine = createReflectionExample({ - draft: async () => ({ - draft: 'Initial draft', - }), - reflect: async ({ revisionCount }) => ({ - feedback: revisionCount === 0 ? 'Add more detail.' : null, - }), - revise: async ({ draft, feedback }) => ({ - draft: `${draft} Revised: ${feedback}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ task: 'Write a short explanation.' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - task: 'Write a short explanation.', - draft: 'Initial draft Revised: Add more detail.', - feedback: null, - revisionCount: 1, - }); - } -}); diff --git a/src/langgraph-equivalents/rewoo.test.ts b/src/langgraph-equivalents/rewoo.test.ts deleted file mode 100644 index 99cb21e..0000000 --- a/src/langgraph-equivalents/rewoo.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createRewooExample } from '../../examples/rewoo.js'; - -test('rewoo workflow plans named steps, resolves references, and synthesizes a final answer', async () => { - const machine = createRewooExample({ - plan: async () => ({ - steps: [ - { - id: 'E1', - instruction: 'Find the framework', - input: 'LangGraphJS runtime', - }, - { - id: 'E2', - instruction: 'Summarize the finding', - input: 'Use #E1 to produce a concise takeaway', - }, - ], - }), - executeStep: async ({ step, resolvedInput }) => ({ - result: `${step.id}:${resolvedInput}`, - }), - solve: async ({ resultsById }) => ({ - answer: `${resultsById.E1} | ${resultsById.E2}`, - }), - }); - - const result = await execute(machine, - machine.getInitialState({ objective: 'understand the runtime' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - objective: 'understand the runtime', - steps: [ - { - id: 'E1', - instruction: 'Find the framework', - input: 'LangGraphJS runtime', - }, - { - id: 'E2', - instruction: 'Summarize the finding', - input: 'Use #E1 to produce a concise takeaway', - }, - ], - resultsById: { - E1: 'E1:LangGraphJS runtime', - E2: 'E2:Use E1:LangGraphJS runtime to produce a concise takeaway', - }, - answer: - 'E1:LangGraphJS runtime | E2:Use E1:LangGraphJS runtime to produce a concise takeaway', - }); - } -}); diff --git a/src/langgraph-equivalents/sql-agent.test.ts b/src/langgraph-equivalents/sql-agent.test.ts deleted file mode 100644 index fc6ee4f..0000000 --- a/src/langgraph-equivalents/sql-agent.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { expect, test } from 'vitest'; -import { createMemoryRunStore, startSession } from '../local/index.js'; -import { createSqlAgentExample } from '../../examples/sql-agent.js'; - -function once( - subscribe: (handler: (event: T) => void) => () => void -) { - return new Promise((resolve) => { - let off = () => {}; - off = subscribe((event) => { - off(); - resolve(event); - }); - }); -} - -test('sql-agent workflow retries after a bad query and answers once rows are available', async () => { - let decisions = 0; - - const machine = createSqlAgentExample({ - adapter: { - decide: async () => { - decisions += 1; - - if (decisions === 1) { - return { - choice: 'query', - data: { - query: 'SELECT total FROM invoices WHERE customer = "Acme"', - }, - }; - } - - if (decisions === 2) { - return { - choice: 'query', - data: { - query: 'SELECT customer, total FROM invoices WHERE customer = \'Acme\'', - }, - }; - } - - return { - choice: 'answer', - data: { - answer: 'Acme has one invoice total of 42.', - }, - }; - }, - }, - executeQuery: async ({ query }) => { - if (query.includes('"Acme"')) { - return { - status: 'error' as const, - error: 'SQL syntax error near double quotes.', - }; - } - - return { - status: 'success' as const, - rows: [{ customer: 'Acme', total: 42 }], - }; - }, - }); - - const run = await startSession(machine, { - store: createMemoryRunStore(), - input: { - question: 'What is Acme owed?', - schema: 'invoices(customer text, total integer)', - }, - }); - const events: string[] = []; - - run.on('toolCall', (event) => { - events.push(`call:${event.input.query}`); - }); - run.on('toolResult', (event) => { - events.push(`result:${event.output.status}`); - }); - - await once(run.onDone.bind(run)); - - expect(events).toEqual([ - 'call:SELECT total FROM invoices WHERE customer = "Acme"', - 'result:error', - "call:SELECT customer, total FROM invoices WHERE customer = 'Acme'", - 'result:success', - ]); - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { - question: 'What is Acme owed?', - schema: 'invoices(customer text, total integer)', - answer: 'Acme has one invoice total of 42.', - latestRows: [{ customer: 'Acme', total: 42 }], - latestError: null, - queryHistory: [ - 'SELECT total FROM invoices WHERE customer = "Acme"', - "SELECT customer, total FROM invoices WHERE customer = 'Acme'", - ], - }, - }) - ); -}); diff --git a/src/langgraph-equivalents/streaming.test.ts b/src/langgraph-equivalents/streaming.test.ts deleted file mode 100644 index d2ddf58..0000000 --- a/src/langgraph-equivalents/streaming.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../local/index.js'; -import { z } from 'zod'; -import { - createAgentMachine, -} from '../index.js'; - -function once( - subscribe: (handler: (event: T) => void) => () => void -) { - return new Promise((resolve) => { - let off = () => {}; - off = subscribe((event) => { - off(); - resolve(event); - }); - }); -} - -test('streams live invoke output while preserving durable state history', async () => { - const machine = createAgentMachine({ - id: 'langgraph-equivalent-streaming', - schemas: { - emitted: { - textPart: z.object({ delta: z.string() }), - }, - }, - context: () => ({ text: '' }), - initial: 'write', - states: { - write: { - schemas: { output: z.object({ text: z.string() }) }, - invoke: async (_args, enq) => { - enq.emit({ type: 'textPart', delta: 'hello' }); - enq.emit({ type: 'textPart', delta: ' world' }); - return { text: 'hello world' }; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { text: output.text }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ text: context.text }), - }, - }, - }); - - const run = await startSession(machine, { - store: createMemoryRunStore(), - }); - const liveParts: string[] = []; - - run.on('textPart', (part) => { - liveParts.push(part.delta); - }); - - await once(run.onDone.bind(run)); - - expect(liveParts).toEqual(['hello', ' world']); - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { text: 'hello world' }, - }) - ); -}); diff --git a/src/langgraph-equivalents/subflow.test.ts b/src/langgraph-equivalents/subflow.test.ts deleted file mode 100644 index d5471bc..0000000 --- a/src/langgraph-equivalents/subflow.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { z } from 'zod'; -import { createAgentMachine } from '../index.js'; - -test('supports subflow composition by executing a child machine inside a parent invoke', async () => { - const childMachine = createAgentMachine({ - id: 'child-research', - schemas: { - input: z.object({ topic: z.string() }), - }, - context: (input) => ({ - topic: input.topic, - bullets: [] as string[], - }), - initial: 'researching', - states: { - researching: { - schemas: { output: z.object({ bullets: z.array(z.string()) }) }, - invoke: async ({ context }) => ({ - bullets: [`fact about ${context.topic}`, `another fact about ${context.topic}`], - }), - onDone: ({ output }) => ({ - target: 'done', - context: { bullets: output.bullets }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ bullets: context.bullets }), - }, - }, - }); - - const parentMachine = createAgentMachine({ - id: 'parent-writer', - schemas: { - input: z.object({ topic: z.string() }), - }, - context: (input) => ({ - topic: input.topic, - bullets: [] as string[], - draft: null as string | null, - }), - initial: 'researching', - states: { - researching: { - schemas: { output: z.object({ bullets: z.array(z.string()) }) }, - invoke: async ({ context }) => { - const result = await execute(childMachine, - childMachine.getInitialState({ topic: context.topic }) - ); - - if (result.status !== 'done') { - throw new Error('Child machine did not finish'); - } - - return { - bullets: (result.output as { bullets: string[] }).bullets, - }; - }, - onDone: ({ output }) => ({ - target: 'writing', - context: { bullets: output.bullets }, - }), - }, - writing: { - schemas: { output: z.object({ draft: z.string() }) }, - invoke: async ({ context }) => ({ - draft: `${context.topic}: ${context.bullets.join('; ')}`, - }), - onDone: ({ output }) => ({ - target: 'done', - context: { draft: output.draft }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ - bullets: context.bullets, - draft: context.draft, - }), - }, - }, - }); - - const result = await execute(parentMachine, - parentMachine.getInitialState({ topic: 'state machines' }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - bullets: [ - 'fact about state machines', - 'another fact about state machines', - ], - draft: - 'state machines: fact about state machines; another fact about state machines', - }); - } -}); diff --git a/src/langgraph-equivalents/supervisor.test.ts b/src/langgraph-equivalents/supervisor.test.ts deleted file mode 100644 index 435da87..0000000 --- a/src/langgraph-equivalents/supervisor.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from '../local/index.js'; -import { createSupervisorExample } from '../../examples/supervisor.js'; - -test('supervisor workflow retries a blocked worker and escalates when repeated attempts fail', async () => { - let decisions = 0; - - const machine = createSupervisorExample({ - adapter: { - decide: async () => { - decisions += 1; - - if (decisions === 1) { - return { - choice: 'retry', - data: { - instruction: 'Retry using the customer email already on file.', - }, - }; - } - - return { - choice: 'escalate', - data: { - reason: 'Escalate to billing because the request still lacks a verified account match.', - }, - }; - }, - }, - handle: async ({ attempt, instruction }) => ({ - status: 'blocked', - issue: - attempt === 1 - ? 'Missing account identifier.' - : `Still blocked after retry: ${instruction}`, - }), - maxAttempts: 2, - }); - - const result = await execute(machine, - machine.getInitialState({ - request: 'Refund the duplicate annual subscription charge.', - }) - ); - - expect(result.status).toBe('done'); - if (result.status === 'done') { - expect(result.output).toEqual({ - request: 'Refund the duplicate annual subscription charge.', - status: 'escalated', - resolution: null, - escalationReason: - 'Escalate to billing because the request still lacks a verified account match.', - attemptCount: 2, - history: [ - 'worker:1:blocked:Missing account identifier.', - 'supervisor:retry:Retry using the customer email already on file.', - 'worker:2:blocked:Still blocked after retry: Retry using the customer email already on file.', - 'supervisor:escalate:Escalate to billing because the request still lacks a verified account match.', - ], - }); - } -}); diff --git a/src/langgraph-equivalents/tool-calling.test.ts b/src/langgraph-equivalents/tool-calling.test.ts deleted file mode 100644 index e4f8ece..0000000 --- a/src/langgraph-equivalents/tool-calling.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from '../local/index.js'; -import { z } from 'zod'; -import { - createAgentMachine, -} from '../index.js'; - -function once( - subscribe: (handler: (event: T) => void) => () => void -) { - return new Promise((resolve) => { - let off = () => {}; - off = subscribe((event) => { - off(); - resolve(event); - }); - }); -} - -test('supports tool-call style invokes with live tool events and final output', async () => { - const machine = createAgentMachine({ - id: 'langgraph-equivalent-tool-calling', - schemas: { - emitted: { - toolCall: z.object({ - toolName: z.string(), - input: z.object({ city: z.string() }), - }), - toolProgress: z.object({ - toolName: z.string(), - message: z.string(), - step: z.number().int().min(1), - }), - toolResult: z.object({ - toolName: z.string(), - output: z.object({ forecast: z.string() }), - }), - }, - input: z.object({ city: z.string() }), - }, - context: (input) => ({ - city: input.city, - forecast: null as string | null, - }), - initial: 'checkingWeather', - states: { - checkingWeather: { - schemas: { output: z.object({ forecast: z.string() }) }, - invoke: async ({ context }, enq) => { - enq.emit({ - type: 'toolCall', - toolName: 'getWeather', - input: { city: context.city }, - }); - - enq.emit({ - type: 'toolProgress', - toolName: 'getWeather', - message: `Fetching weather for ${context.city}`, - step: 1, - }); - - enq.emit({ - type: 'toolProgress', - toolName: 'getWeather', - message: `Formatting response for ${context.city}`, - step: 2, - }); - - const output = { forecast: `Sunny in ${context.city}` }; - enq.emit({ - type: 'toolResult', - toolName: 'getWeather', - output, - }); - - return output; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { forecast: output.forecast }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ forecast: context.forecast }), - }, - }, - }); - - const run = await startSession(machine, { - store: createMemoryRunStore(), - input: { city: 'Boston' }, - }); - const events: string[] = []; - - run.on('toolCall', (event) => { - events.push(`call:${event.toolName}`); - }); - run.on('toolProgress', (event) => { - events.push(`progress:${event.toolName}:${event.step}`); - }); - run.on('toolResult', (event) => { - events.push(`result:${event.toolName}`); - }); - - await once(run.onDone.bind(run)); - - expect(events).toEqual([ - 'call:getWeather', - 'progress:getWeather:1', - 'progress:getWeather:2', - 'result:getWeather', - ]); - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - output: { forecast: 'Sunny in Boston' }, - }) - ); -}); diff --git a/src/local/index.ts b/src/local/index.ts deleted file mode 100644 index 8f34096..0000000 --- a/src/local/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { waitForRunDone, waitForRunSnapshot } from '../runtime/index.js'; -export { createMemoryRunStore } from '../runtime/memory-store.js'; -export { restoreSession, startSession } from '../runtime/session.js'; -export { execute, invoke, stream } from './interpreter.js'; diff --git a/src/local/interpreter.ts b/src/local/interpreter.ts deleted file mode 100644 index f7d9794..0000000 --- a/src/local/interpreter.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { - AgentMachine, - AgentSnapshot, - AgentState, - ExecuteResult, -} from '../types.js'; - -type LocalMachine = AgentMachine & { - invoke(state: AgentState): Promise; - execute(state: AgentState): Promise; - stream(state: AgentState): AsyncGenerator; -}; - -function asLocalMachine(machine: AgentMachine): LocalMachine { - const localMachine = machine as LocalMachine; - if ( - typeof localMachine.invoke !== 'function' - || typeof localMachine.execute !== 'function' - || typeof localMachine.stream !== 'function' - ) { - throw new Error('Machine local interpreter internals are unavailable'); - } - - return localMachine; -} - -export function invoke< - TContext extends Record, - TValue extends string, - TOutput, ->( - machine: AgentMachine, - state: AgentState -): Promise> { - return asLocalMachine(machine).invoke(state) as Promise< - AgentState - >; -} - -export function execute< - TContext extends Record, - TValue extends string, - TEvents extends Record, - TOutput, ->( - machine: AgentMachine, - state: AgentState -): Promise> { - return asLocalMachine(machine).execute(state) as Promise< - ExecuteResult - >; -} - -export function stream< - TContext extends Record, - TValue extends string, - TOutput, ->( - machine: AgentMachine, - state: AgentState -): AsyncGenerator> { - return asLocalMachine(machine).stream(state) as AsyncGenerator< - AgentSnapshot - >; -} diff --git a/src/machine.ts b/src/machine.ts deleted file mode 100644 index d7108ef..0000000 --- a/src/machine.ts +++ /dev/null @@ -1,883 +0,0 @@ -import type { - AgentMachine, - AgentMessage, - AgentResolverSnapshot, - AgentToolChoice, - AgentTools, - AgentSnapshot, - AgentState, - EmittedPart, - EventPayload, - ExecuteResult, - InferOutput, - MachineConfig, - StandardSchemaV1, - TransitionResult, -} from './types.js'; -import type { JournalEvent } from './runtime/events.js'; -import { - applyTransition, - findEmittedSchema, - findEventSchema, - formatSchemaIssues, - getAvailableEvents, - getInput, - isAlwaysEventType, - isDoneInvokeEventType, - isErrorInvokeEventType, - isReservedInternalEventType, - resolveInitial, - resolveStateConfig, - serializeError, - validateSchemaSync, -} from './utils.js'; -import type { StateConfigAny } from './utils.js'; - -// ─── Type helpers ─── -/** Output type for onDone: typed from invoke return or state schemas.output when present */ -type OnDoneOutput = NoInfer; - -type EventFor = E extends keyof TEvents & string - ? { type: E } & EventPayload> - : { type: E & string; [k: string]: unknown }; - -type StateResolverArgs< - TContext extends Record, - TInput, -> = { - snapshot: AgentResolverSnapshot; - context: TContext; - messages: AgentMessage[]; - input: NoInfer; -}; - -type ResolvableStateValue< - TValue, - TContext extends Record, - TInput, -> = - | TValue - | ((args: StateResolverArgs) => TValue); - -type StateNodeDef< - TState, - TContext extends Record, - TInput, - TResult, - TEvents, - TTarget extends string, - TInputMap extends Record, - TOutput, -> = { - type?: 'final'; - schemas?: { - input?: StandardSchemaV1; - output?: StandardSchemaV1; - }; - invoke?: (args: { - context: TContext; - messages: AgentMessage[]; - input: NoInfer; - signal?: AbortSignal; - }, enq: { emit(part: EmittedPart): void }) => Promise; - onDone?: (args: { output: OnDoneOutput; context: TContext; messages: AgentMessage[] }) => TransitionResult; - always?: TransitionResult | ((args: { - context: TContext; - messages: AgentMessage[]; - input: NoInfer; - }, enq: { emit(part: EmittedPart): void }) => TransitionResult); - on?: { [E in keyof TEvents & string]?: TransitionResult | ((args: { - event: EventFor; - context: TContext; - messages: AgentMessage[]; - }, enq: { emit(part: EmittedPart): void }) => TransitionResult) }; - events?: Record; - output?: (args: { context: TContext; messages: AgentMessage[] }) => NoInfer; - model?: ResolvableStateValue; - adapter?: import('./types.js').AgentAdapter; - prompt?: ResolvableStateValue; - system?: ResolvableStateValue; - tools?: ResolvableStateValue; - toolChoice?: ResolvableStateValue; -}; - -type StatesMap< - TContext extends Record, - TInputMap extends Record, - TResultMap extends Record, - TOutput, - TEvents, -> = { - [K in keyof TInputMap & keyof TResultMap]: StateNodeDef< - unknown, - TContext, - TInputMap[K], - TResultMap[K], - TEvents, - keyof TInputMap & keyof TResultMap & string, - TInputMap, - TOutput - >; -}; - -// ─── Overload A: schemas.context present ─── -export function createAgentMachine< - TInput, - TContext extends Record, - const TEvents extends Record, - const TInputMap extends Record, - TResultMap extends Record, - const TEmitted extends Record, - TOutput = unknown, ->(config: { - id: string; - schemas: { - context: StandardSchemaV1; - input?: StandardSchemaV1; - events?: TEvents; - emitted?: TEmitted; - output?: StandardSchemaV1; - }; - context: (input: NoInfer) => NoInfer; - messages?: AgentMessage[] | ((input: NoInfer) => AgentMessage[]); - adapter?: import('./types.js').AgentAdapter; - externalEvents?: readonly (keyof TEvents & string)[]; - initial: - | (keyof TInputMap & keyof TResultMap & string) - | ((args: { context: NoInfer }) => { - target: keyof TInputMap & keyof TResultMap & string; - input?: Record; - }); - states: StatesMap, TInputMap, TResultMap, TOutput, TEvents>; -}): AgentMachine, TOutput, TEmitted>; - -// ─── Overload B: no schemas.context ─── -export function createAgentMachine< - TInput, - TContext extends Record, - const TEvents extends Record, - const TInputMap extends Record, - TResultMap extends Record, - const TEmitted extends Record, - TOutput = unknown, ->(config: { - id: string; - schemas: { - input: StandardSchemaV1; - context?: never; - events?: TEvents; - emitted?: TEmitted; - output?: StandardSchemaV1; - }; - context: (input: NoInfer) => TContext; - messages?: AgentMessage[] | ((input: NoInfer) => AgentMessage[]); - adapter?: import('./types.js').AgentAdapter; - externalEvents?: readonly (keyof TEvents & string)[]; - initial: - | (keyof TInputMap & keyof TResultMap & string) - | ((args: { context: TContext }) => { - target: keyof TInputMap & keyof TResultMap & string; - input?: Record; - }); - states: StatesMap; -}): AgentMachine, TOutput, TEmitted>; - -// ─── Overload C: no schemas.input or schemas.context ─── -export function createAgentMachine< - TContext extends Record, - const TEvents extends Record, - const TInputMap extends Record, - TResultMap extends Record, - const TEmitted extends Record, - TOutput = unknown, ->(config: { - id: string; - schemas?: { - input?: never; - context?: never; - events?: TEvents; - emitted?: TEmitted; - output?: StandardSchemaV1; - }; - context: (...args: any[]) => TContext; - messages?: AgentMessage[] | ((input: unknown) => AgentMessage[]); - adapter?: import('./types.js').AgentAdapter; - externalEvents?: readonly (keyof TEvents & string)[]; - initial: - | (keyof TInputMap & keyof TResultMap & string) - | ((args: { context: TContext }) => { - target: keyof TInputMap & keyof TResultMap & string; - input?: Record; - }); - states: StatesMap; -}): AgentMachine, TOutput, TEmitted>; - -// ─── Implementation ─── - -export function createAgentMachine( - machineConfig: MachineConfig -): AgentMachine { - const cfg = machineConfig as MachineConfig; - assertValidConfig(cfg); - - type SnapshotRuntime = { sessionId: string; createdAt: number }; - const EVENT_TOOL_PREFIX = 'event.'; - - function assertValidConfig(config: MachineConfig) { - for (const [stateValue, stateConfig] of Object.entries(config.states)) { - if (!stateConfig.invoke) { - continue; - } - - const hasGenerationFields = - stateConfig.prompt !== undefined - || stateConfig.system !== undefined - || stateConfig.tools !== undefined - || stateConfig.toolChoice !== undefined; - - if (hasGenerationFields) { - throw new Error( - `State '${stateValue}' cannot combine invoke with prompt, system, tools, or toolChoice` - ); - } - } - } - - function createSnapshotRuntime(state: AgentState) { - if (state.sessionId && state.createdAt !== undefined) { - return { - sessionId: state.sessionId, - createdAt: state.createdAt, - }; - } - - const sessionId = - typeof globalThis.crypto !== 'undefined' && - typeof globalThis.crypto.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `session-${Math.random().toString(36).slice(2)}`; - - return { - sessionId, - createdAt: Date.now(), - }; - } - - function withRuntimeMetadata( - state: AgentState, - runtime: SnapshotRuntime - ): AgentState { - return resolveStateFields({ - ...state, - sessionId: runtime.sessionId, - createdAt: runtime.createdAt, - }); - } - - function withoutResolvedFields(state: AgentState): AgentState { - const { - model: _model, - prompt: _prompt, - system: _system, - tools: _tools, - toolChoice: _toolChoice, - ...rest - } = state; - - return rest; - } - - function resolveStateFields(state: AgentState): AgentState { - const base = withoutResolvedFields(state); - const sc = resolveStateConfig(cfg, base.value); - const input = getInput(base.value, base.input); - const args = { - snapshot: base, - context: base.context, - messages: base.messages, - input, - }; - - const model = - typeof sc.model === 'function' - ? sc.model(args) - : sc.model; - const prompt = - typeof sc.prompt === 'function' - ? sc.prompt(args) - : sc.prompt; - const system = - typeof sc.system === 'function' - ? sc.system(args) - : sc.system; - const tools = - typeof sc.tools === 'function' - ? sc.tools(args) - : sc.tools; - const toolChoice = - typeof sc.toolChoice === 'function' - ? sc.toolChoice(args) - : sc.toolChoice; - const eventTools = getEventTools(base.value); - const resolvedTools = { - ...(tools ?? {}), - ...eventTools, - }; - - return { - ...base, - ...(model !== undefined ? { model } : {}), - ...(prompt !== undefined ? { prompt } : {}), - ...(system !== undefined ? { system } : {}), - ...(Object.keys(resolvedTools).length > 0 ? { tools: resolvedTools } : {}), - ...(toolChoice !== undefined ? { toolChoice } : {}), - }; - } - - function getEventTools(value: string): AgentTools { - const sc = resolveStateConfig(cfg, value); - if (!sc.on || !isGenerativeState(sc)) { - return {}; - } - - const tools: AgentTools = {}; - const externalEvents = new Set(cfg.externalEvents ?? []); - - for (const eventType of Object.keys(sc.on)) { - if (isReservedInternalEventType(eventType) || externalEvents.has(eventType)) { - continue; - } - - const schema = findEventSchema(cfg, value, eventType); - - tools[`${EVENT_TOOL_PREFIX}${eventType}`] = { - description: `Transition with event '${eventType}'.`, - ...(schema ? { schemas: { input: schema },} : {}), - execute: async (input: unknown = {}) => ({ - ...(input && typeof input === 'object' ? input : {}), - type: eventType, - }), - }; - } - - return tools; - } - - function isGenerativeState(sc: StateConfigAny): boolean { - return ( - sc.prompt !== undefined - || sc.system !== undefined - || sc.tools !== undefined - || sc.toolChoice !== undefined - ); - } - - function getInitialState(...args: [input?: unknown]): AgentState { - const input = args[0]; - - let validatedInput = input; - if (cfg.schemas?.input) { - validatedInput = validateSchemaSync(cfg.schemas.input, input); - } - - const context = cfg.context(validatedInput); - const messages = - typeof cfg.messages === 'function' - ? cfg.messages(validatedInput) - : cfg.messages ?? []; - const init = resolveInitial(cfg.initial, { context, input: {} }); - - if (!init.target) { - throw new Error('Initial transition must specify a target state'); - } - - return resolveStateFields({ - value: init.target, - context: init.context ? { ...context, ...init.context } : context, - messages: init.messages ?? messages, - status: 'active', - input: init.input ? { [init.target]: init.input } : {}, - }); - } - - function resolveState(raw: { - value: string; - context: Record; - messages?: AgentMessage[]; - input?: Record>; - sessionId?: string; - createdAt?: number; - status?: AgentState['status']; - output?: unknown; - error?: unknown; - }): AgentState { - return resolveStateFields({ - value: raw.value, - context: raw.context, - messages: raw.messages ?? [], - status: raw.status ?? 'active', - input: raw.input ?? {}, - sessionId: raw.sessionId, - createdAt: raw.createdAt, - output: raw.output, - error: raw.error, - }); - } - - function transition( - state: AgentState, - event: { type: string; [k: string]: unknown } - ): AgentState { - return transitionWithEffects(state, event).next; - } - - function getEvents(state: AgentState | AgentSnapshot | string) { - const value = typeof state === 'string' ? state : state.value; - return getAvailableEvents(cfg, value); - } - - function transitionWithEffects( - state: AgentState, - event: { type: string; [k: string]: unknown }, - onEmit?: (part: EmittedPart) => void - ): { next: AgentState; emitted: EmittedPart[] } { - const emitted: EmittedPart[] = []; - const enqueue = createEnqueue((part) => { - emitted.push(part); - onEmit?.(part); - }); - const sc = resolveStateConfig(cfg, state.value); - function applyResult( - result: TransitionResult, - status = state.status - ): AgentState { - if (result.target) { - return resolveStateFields(applyTransition(withoutResolvedFields(state), result)); - } - - return resolveStateFields({ - ...withoutResolvedFields(state), - status, - context: result.context - ? { ...state.context, ...result.context } - : state.context, - messages: result.messages ?? state.messages, - }); - } - - function resolveHandlerResult( - handler: - | TransitionResult - | ((args: { - event: { type: string; [k: string]: unknown }; - context: Record; - messages: AgentMessage[]; - input: Record; - }, enq: { emit(part: EmittedPart): void }) => TransitionResult), - status = state.status - ): { next: AgentState; emitted: EmittedPart[] } { - const result: TransitionResult = - typeof handler === 'function' - ? handler({ - context: state.context, - messages: state.messages, - input: getInput(state.value, state.input), - event, - }, enqueue) - : handler; - - return { - next: applyResult(result, status), - emitted, - }; - } - - if (isDoneInvokeEventType(state.value, event.type)) { - const result = 'output' in event ? event.output : undefined; - const validatedOutput = sc.schemas?.output - ? validateSchemaSync(sc.schemas.output, result) - : result; - - if (sc.onDone) { - const trans = sc.onDone({ - output: validatedOutput, - context: state.context, - messages: state.messages, - }); - - return { - next: applyResult(trans, 'pending'), - emitted, - }; - } - - const internalHandler = sc.on?.[event.type]; - if (internalHandler !== undefined) { - return resolveHandlerResult(internalHandler, 'pending'); - } - - return { next: resolveStateFields({ ...withoutResolvedFields(state), status: 'pending' }), emitted }; - } - - if (isAlwaysEventType(state.value, event.type)) { - if (!sc.always) { - throw new Error(`No always transition in state '${state.value}'`); - } - - return resolveHandlerResult( - sc.always, - state.status - ); - } - - if (isErrorInvokeEventType(state.value, event.type)) { - const internalHandler = sc.on?.[event.type]; - if (internalHandler !== undefined) { - return resolveHandlerResult(internalHandler); - } - - return { - next: resolveStateFields({ - ...withoutResolvedFields(state), - status: 'error', - error: 'error' in event ? event.error : undefined, - }), - emitted, - }; - } - - validateEventPayload(state.value, event); - - if (sc.on?.[event.type] !== undefined) { - const handler = sc.on[event.type]!; - return resolveHandlerResult(handler); - } - - throw new Error( - `No handler for event '${event.type}' in state '${state.value}'` - ); - } - - function validateReplayableResult( - value: string, - result: unknown - ): unknown { - const sc = resolveStateConfig(cfg, value); - if (!sc.schemas?.output) { - return result; - } - - return validateSchemaSync(sc.schemas.output, result); - } - - function validateEventPayload( - value: string, - event: { type: string } - ): void { - const schema = findEventSchema(cfg, value, event.type); - if (!schema) return; - const result = schema['~standard'].validate(event); - if (result instanceof Promise) return; - if ( - result && - typeof result === 'object' && - 'issues' in result && - result.issues - ) { - const messages = formatSchemaIssues( - result.issues as Array<{ message: string }> - ); - throw new Error(`Invalid event '${event.type}': ${messages}`); - } - } - - function toInvokeErrorEvent( - state: AgentState, - error: unknown - ): JournalEvent { - return { - type: `xstate.error.invoke.${state.value}`, - error: serializeError(error), - at: Date.now(), - }; - } - - function validateEmittedPart(part: EmittedPart): void { - const schema = findEmittedSchema(cfg, part.type); - if (!schema) { - return; - } - - const result = schema['~standard'].validate(part); - if (result instanceof Promise) { - throw new Error( - 'Async schema validation is not supported in sync context.' - ); - } - - if (result.issues) { - const messages = formatSchemaIssues(result.issues); - throw new Error(`Invalid emitted part '${part.type}': ${messages}`); - } - } - - function createEnqueue(onEmit?: (part: EmittedPart) => void) { - return { - emit(part: EmittedPart) { - validateEmittedPart(part); - onEmit?.(part); - }, - }; - } - - async function createInvokeEvent( - state: AgentState, - sc: StateConfigAny, - onEmit?: (part: EmittedPart) => void - ): Promise { - try { - const result = await sc.invoke!( - { - context: state.context, - messages: state.messages, - input: getInput(state.value, state.input), - }, - createEnqueue(onEmit) - ); - const validatedResult = validateReplayableResult(state.value, result); - - return { - type: `xstate.done.invoke.${state.value}`, - output: validatedResult, - at: Date.now(), - }; - } catch (error) { - return { - type: `xstate.error.invoke.${state.value}`, - error: serializeError(error), - at: Date.now(), - }; - } - } - - async function createGenerateEvent(state: AgentState): Promise { - const sc = resolveStateConfig(cfg, state.value); - const adapter = sc.adapter ?? cfg.adapter; - if (!adapter?.generateText) { - return { - type: `xstate.error.invoke.${state.value}`, - error: { message: `No generateText adapter for '${state.value}'` }, - at: Date.now(), - }; - } - - try { - const messages = state.prompt - ? state.messages.concat({ role: 'user', content: state.prompt }) - : state.messages; - const result = await adapter.generateText({ - model: state.model, - system: state.system, - prompt: state.prompt, - messages, - tools: state.tools, - toolChoice: state.toolChoice, - outputSchema: sc.schemas?.output, - }); - const validatedResult = validateReplayableResult(state.value, result); - - return { - type: `xstate.done.invoke.${state.value}`, - output: validatedResult, - at: Date.now(), - }; - } catch (error) { - return { - type: `xstate.error.invoke.${state.value}`, - error: serializeError(error), - at: Date.now(), - }; - } - } - - async function getEffectEvent( - state: AgentState, - onEmit?: (part: EmittedPart) => void - ): Promise { - if (state.status === 'done' || state.status === 'error') { - return null; - } - - const sc = resolveStateConfig(cfg, state.value); - if (sc.always) { - return { - type: `xstate.always.${state.value}`, - at: Date.now(), - }; - } - - if (sc.invoke) { - return createInvokeEvent(state, sc, onEmit); - } - - if ( - sc.onDone - && ( - sc.prompt !== undefined - || sc.system !== undefined - || sc.tools !== undefined - || sc.toolChoice !== undefined - ) - ) { - return createGenerateEvent(state); - } - - return null; - } - - function resolveEffectTransition( - state: AgentState, - effectEvent: JournalEvent, - onEmit?: (part: EmittedPart) => void - ): { event: JournalEvent; next: AgentState } { - try { - return { - event: effectEvent, - next: transitionWithEffects(state, effectEvent, onEmit).next, - }; - } catch (error) { - if (isDoneInvokeEventType(state.value, effectEvent.type)) { - const errorEvent = toInvokeErrorEvent(state, error); - - return { - event: errorEvent, - next: transitionWithEffects(state, errorEvent, onEmit).next, - }; - } - - throw error; - } - } - - async function invoke(state: AgentState): Promise { - if (state.status === 'done' || state.status === 'error') { - return state; - } - - const sc = resolveStateConfig(cfg, state.value); - - if (sc.type === 'final') { - const rawOutput = sc.output - ? sc.output({ context: state.context, messages: state.messages }) - : undefined; - const output = cfg.schemas?.output - ? validateSchemaSync(cfg.schemas.output, rawOutput) - : rawOutput; - return resolveStateFields({ ...withoutResolvedFields(state), status: 'done', output }); - } - - const effectEvent = await getEffectEvent(state); - if (effectEvent) { - return resolveEffectTransition(state, effectEvent).next; - } - - if (sc.on) { - return resolveStateFields({ ...withoutResolvedFields(state), status: 'pending' }); - } - - return resolveStateFields({ - ...withoutResolvedFields(state), - status: 'error', - error: `State '${state.value}' has no invoke, events, or final type`, - }); - } - - async function execute(state: AgentState): Promise { - let current = state; - while (current.status === 'active') { - current = await invoke(current); - } - - switch (current.status) { - case 'done': - return { - status: 'done', - state: current, - output: current.output, - context: current.context, - messages: current.messages, - }; - case 'pending': - return { - status: 'pending', - state: current, - value: current.value, - events: getAvailableEvents(cfg, current.value), - context: current.context, - messages: current.messages, - }; - case 'error': - return { - status: 'error', - state: current, - error: current.error, - }; - default: - return { - status: 'error', - state: current, - error: `Unexpected: ${current.status}`, - }; - } - } - - async function* stream( - state: AgentState - ): AsyncGenerator { - let current = state; - const runtime = createSnapshotRuntime(current); - current = withRuntimeMetadata(current, runtime); - yield toSnap(current, runtime); - while (current.status === 'active') { - current = await invoke(current); - current = withRuntimeMetadata(current, runtime); - yield toSnap(current, runtime); - } - } - - function toSnap( - s: AgentState, - runtime: { sessionId: string; createdAt: number } - ): AgentSnapshot { - return { - value: s.value, - context: s.context, - messages: s.messages, - status: s.status, - sessionId: runtime.sessionId, - createdAt: runtime.createdAt, - input: s.input, - output: s.output, - error: s.error, - }; - } - - return { - id: cfg.id, - __config: cfg, - getInitialState, - resolveState, - transition, - getEvents, - invoke, - execute, - stream, - __runtime: { - toSnapshot: toSnap, - withRuntimeMetadata, - getEffectEvent, - resolveEffectTransition, - transitionWithEffects, - }, - } as AgentMachine; -} diff --git a/src/next/index.test.ts b/src/next/index.test.ts deleted file mode 100644 index 37984f4..0000000 --- a/src/next/index.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { z } from 'zod'; -import { createAgentMachine } from '../index.js'; -import { - createNextSessionRouteHandlers, - dynamic, - maxDuration, - runtime, -} from './index.js'; - -describe('next adapter', () => { - test('adapts generic session handlers to App Router route params', async () => { - const machine = createAgentMachine({ - id: 'next-adapter-test', - schemas: { - input: z.object({ - request: z.string(), - }), - events: { - approve: z.object({}), - }, - }, - context: (input) => ({ - request: input.request, - approved: false, - }), - initial: 'review', - states: { - review: { - on: { - approve: { - target: 'done', - context: { - approved: true, - }, - }, - }, - }, - done: { - type: 'final', - output: ({ context }) => ({ - request: context.request, - approved: context.approved, - }), - }, - }, - }); - const routes = createNextSessionRouteHandlers(machine); - - expect(runtime).toBe('nodejs'); - expect(dynamic).toBe('force-dynamic'); - expect(maxDuration).toBe(30); - - const startResponse = await routes.sessions.POST( - new Request('https://agent.test/api/sessions', { - method: 'POST', - body: JSON.stringify({ request: 'Ship it.' }), - }) - ); - const startBody = await startResponse.json() as { - sessionId: string; - }; - - const sendResponse = await routes.events.POST( - new Request(`https://agent.test/api/sessions/${startBody.sessionId}/events`, { - method: 'POST', - body: JSON.stringify({ type: 'approve' }), - }), - { - params: Promise.resolve({ - sessionId: startBody.sessionId, - }), - } - ); - const sendBody = await sendResponse.json() as { - snapshot: { value: string; output: unknown }; - }; - - expect(sendBody.snapshot).toEqual( - expect.objectContaining({ - value: 'done', - output: { - request: 'Ship it.', - approved: true, - }, - }) - ); - }); -}); diff --git a/src/next/index.ts b/src/next/index.ts deleted file mode 100644 index 6fa6431..0000000 --- a/src/next/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - createSessionHttpController, - type SessionHttpController, - type SessionHttpControllerOptions, -} from '../http/index.js'; -import type { AgentMachine } from '../types.js'; - -type AnyMachine = AgentMachine; - -export const runtime = 'nodejs'; -export const dynamic = 'force-dynamic'; -export const maxDuration = 30; - -export interface NextRouteContext> { - params: Promise | TParams; -} - -export interface NextSessionRouteHandlers { - sessions: { - POST(request: Request): Promise; - }; - session: { - GET( - request: Request, - context: NextRouteContext<{ sessionId: string }> - ): Promise; - }; - events: { - POST( - request: Request, - context: NextRouteContext<{ sessionId: string }> - ): Promise; - }; - stream: { - GET( - request: Request, - context: NextRouteContext<{ sessionId: string }> - ): Promise; - }; - controller: SessionHttpController; -} - -export function createNextSessionRouteHandlers( - machine: TMachine, - options: SessionHttpControllerOptions = {} -): NextSessionRouteHandlers { - const controller = createSessionHttpController(machine, options); - - return { - sessions: { - POST(request) { - return controller.handle(rewritePath(request, '/sessions')); - }, - }, - session: { - async GET(request, context) { - const { sessionId } = await context.params; - return controller.handle(rewritePath(request, `/sessions/${sessionId}`)); - }, - }, - events: { - async POST(request, context) { - const { sessionId } = await context.params; - return controller.handle(rewritePath(request, `/sessions/${sessionId}/events`)); - }, - }, - stream: { - async GET(request, context) { - const { sessionId } = await context.params; - return controller.handle(rewritePath(request, `/sessions/${sessionId}/stream`)); - }, - }, - controller, - }; -} - -function rewritePath(request: Request, pathname: string): Request { - const url = new URL(request.url); - url.pathname = pathname; - return new Request(url, request); -} diff --git a/src/persistence.test.ts b/src/persistence.test.ts deleted file mode 100644 index db48126..0000000 --- a/src/persistence.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { expect, test } from 'vitest'; -import { createMemoryRunStore } from './local/index.js'; - -test('appends and loads journal events in sequence order', async () => { - const store = createMemoryRunStore(); - - const first = await store.append('session-1', { - type: 'xstate.done.invoke.worker', - at: 20, - }); - - const second = await store.append('session-1', { - type: 'xstate.init', - at: 10, - }); - - expect(first.sequence).toBe(1); - expect(second.sequence).toBe(2); - - expect(await store.loadEvents('session-1')).toEqual([ - { - sequence: 1, - type: 'xstate.done.invoke.worker', - at: 20, - }, - { - sequence: 2, - type: 'xstate.init', - at: 10, - }, - ]); - - expect(await store.loadEvents('session-1', 1)).toEqual([ - { - sequence: 2, - at: 10, - type: 'xstate.init', - }, - ]); -}); - -test('loads the most replay-advanced saved snapshot', async () => { - const store = createMemoryRunStore(); - - await store.saveSnapshot({ - sessionId: 'session-1', - afterSequence: 1, - snapshot: { - value: 'idle', - context: { count: 1 }, - messages: [], - status: 'active', - createdAt: 100, - sessionId: 'session-1', - input: { - idle: { count: 1 }, - }, - }, - createdAt: 100, - }); - - await store.saveSnapshot({ - sessionId: 'session-1', - afterSequence: 3, - snapshot: { - value: 'done', - context: { count: 2 }, - messages: [], - status: 'done', - createdAt: 300, - sessionId: 'session-1', - input: { - done: { count: 2 }, - }, - output: { count: 2 }, - }, - createdAt: 300, - }); - - expect(await store.loadLatestSnapshot('session-1')).toEqual({ - sessionId: 'session-1', - afterSequence: 3, - snapshot: { - value: 'done', - context: { count: 2 }, - messages: [], - status: 'done', - createdAt: 300, - sessionId: 'session-1', - input: { - done: { count: 2 }, - }, - output: { count: 2 }, - }, - createdAt: 300, - }); -}); - -test('loads the most replay-advanced snapshot even if saved earlier', async () => { - const store = createMemoryRunStore(); - - await store.saveSnapshot({ - sessionId: 'session-1', - afterSequence: 5, - snapshot: { - value: 'done', - context: { count: 5 }, - messages: [], - status: 'done', - createdAt: 500, - sessionId: 'session-1', - input: { done: { count: 5 } }, - }, - createdAt: 500, - }); - - await store.saveSnapshot({ - sessionId: 'session-1', - afterSequence: 2, - snapshot: { - value: 'review', - context: { count: 2 }, - messages: [], - status: 'active', - createdAt: 200, - sessionId: 'session-1', - input: { review: { count: 2 } }, - }, - createdAt: 200, - }); - - expect(await store.loadLatestSnapshot('session-1')).toEqual({ - sessionId: 'session-1', - afterSequence: 5, - snapshot: { - value: 'done', - context: { count: 5 }, - messages: [], - status: 'done', - createdAt: 500, - sessionId: 'session-1', - input: { done: { count: 5 } }, - }, - createdAt: 500, - }); -}); diff --git a/src/restore.test.ts b/src/restore.test.ts deleted file mode 100644 index 2d8d945..0000000 --- a/src/restore.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from './local/index.js'; -import { z } from 'zod'; -import { - createAgentMachine, -} from './index.js'; - -test('restoreSession reconstructs from the latest snapshot plus replay tail', async () => { - const machine = createAgentMachine({ - id: 'restore-session', - context: () => ({ approved: false, result: null as string | null }), - initial: 'review', - states: { - review: { - on: { - approve: { - target: 'processing', - context: { approved: true }, - }, - }, - }, - processing: { - schemas: { output: z.object({ value: z.string() }) }, - invoke: async ({ context }) => ({ - value: context.approved ? 'approved' : 'rejected', - }), - onDone: ({ output }) => ({ - target: 'done', - context: { result: output.value }, - }), - }, - done: { - type: 'final', - output: ({ context }) => context, - }, - }, - }); - - const baseStore = createMemoryRunStore(); - let snapshotWrites = 0; - const store = { - append: baseStore.append, - loadEvents: baseStore.loadEvents, - loadLatestSnapshot: baseStore.loadLatestSnapshot, - async saveSnapshot(snapshot: Awaited< - ReturnType - > extends infer TSaved - ? Exclude - : never) { - snapshotWrites += 1; - if (snapshotWrites === 1) { - await baseStore.saveSnapshot(snapshot); - } - }, - }; - - const liveRun = await startSession(machine, { store }); - await liveRun.send({ type: 'approve' }); - - expect(await store.loadLatestSnapshot(liveRun.sessionId)).toEqual( - expect.objectContaining({ - afterSequence: 1, - }) - ); - - const restoredRun = await restoreSession(machine, { - sessionId: liveRun.sessionId, - store, - }); - await vi.waitFor(() => { - expect(restoredRun.getSnapshot()).toEqual(liveRun.getSnapshot()); - }); - - expect(restoredRun.getSnapshot()).toEqual(liveRun.getSnapshot()); -}); diff --git a/src/runtime/emitter.ts b/src/runtime/emitter.ts deleted file mode 100644 index 4e9f2d5..0000000 --- a/src/runtime/emitter.ts +++ /dev/null @@ -1,36 +0,0 @@ -type Handler = (event: unknown) => void; - -export interface RunEmitter { - emit(type: string, event: unknown): void; - on(type: string, handler: Handler): () => void; -} - -export function createRunEmitter(): RunEmitter { - const listeners = new Map>(); - - return { - emit(type, event) { - for (const handler of listeners.get(type) ?? []) { - handler(event); - } - }, - - on(type, handler) { - const current = listeners.get(type) ?? new Set(); - current.add(handler); - listeners.set(type, current); - - return () => { - const active = listeners.get(type); - if (!active) { - return; - } - - active.delete(handler); - if (active.size === 0) { - listeners.delete(type); - } - }; - }, - }; -} diff --git a/src/runtime/events.ts b/src/runtime/events.ts deleted file mode 100644 index ee30061..0000000 --- a/src/runtime/events.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface JournalEvent { - type: 'xstate.init' | (string & {}); - at: number; - sessionId?: string; - sequence?: number; - [key: string]: unknown; -} diff --git a/src/runtime/index.test.ts b/src/runtime/index.test.ts deleted file mode 100644 index ed61923..0000000 --- a/src/runtime/index.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { z } from 'zod'; -import { - createAgentMachine, -} from '../index.js'; -import { - createMemoryRunStore, - startSession, - waitForRunDone, - waitForRunSnapshot, -} from '../local/index.js'; - -describe('runtime helpers', () => { - test('waitForRunSnapshot and waitForRunDone observe session lifecycle', async () => { - const machine = createAgentMachine({ - id: 'runtime-helper-test', - schemas: { - events: { - finish: z.object({ value: z.string() }), - }, - }, - context: () => ({ - value: null as string | null, - }), - initial: 'waiting', - states: { - waiting: { - on: { - finish: ({ event }) => ({ - target: 'done', - context: { - value: event.value, - }, - }), - }, - }, - done: { - type: 'final', - output: ({ context }) => ({ - value: context.value, - }), - }, - }, - }); - - const run = await startSession(machine, { - store: createMemoryRunStore(), - }); - const waiting = await waitForRunSnapshot( - run, - (snapshot) => snapshot.status === 'pending' - ); - - expect(waiting.value).toBe('waiting'); - - const donePromise = waitForRunDone(run); - await run.send({ type: 'finish', value: 'ok' }); - - await expect(donePromise).resolves.toEqual( - expect.objectContaining({ - output: { - value: 'ok', - }, - }) - ); - }); -}); diff --git a/src/runtime/index.ts b/src/runtime/index.ts deleted file mode 100644 index 0233285..0000000 --- a/src/runtime/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { - AgentRun, - AgentSnapshot, - StandardSchemaV1, -} from '../types.js'; - -export function waitForRunDone< - TContext extends Record, - TValue extends string, - TEvents extends Record, - TOutput, - TEmitted extends Record, ->( - run: AgentRun -): Promise<{ - output: TOutput; - snapshot: AgentSnapshot; -}> { - return new Promise((resolve, reject) => { - const offDone = run.onDone((event) => { - offDone(); - offError(); - resolve(event); - }); - const offError = run.onError((event) => { - offDone(); - offError(); - reject(event.error); - }); - }); -} - -export function waitForRunSnapshot< - TContext extends Record, - TValue extends string, - TEvents extends Record, - TOutput, - TEmitted extends Record, ->( - run: AgentRun, - predicate: ( - snapshot: AgentSnapshot - ) => boolean, - timeoutMs = 1000 -): Promise> { - const current = run.getSnapshot(); - if (predicate(current)) { - return Promise.resolve(current); - } - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - reject(new Error('Run snapshot did not reach the expected state in time.')); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timeout); - offSnapshot(); - offDone(); - offError(); - }; - - const check = (snapshot: AgentSnapshot) => { - if (predicate(snapshot)) { - cleanup(); - resolve(snapshot); - } - }; - - const offSnapshot = run.onSnapshot(check); - const offDone = run.onDone((event) => { - check(event.snapshot); - }); - const offError = run.onError((event) => { - cleanup(); - reject(event.error); - }); - }); -} diff --git a/src/runtime/memory-store.ts b/src/runtime/memory-store.ts deleted file mode 100644 index f2a6961..0000000 --- a/src/runtime/memory-store.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { AgentSnapshot } from '../types.js'; -import type { JournalEvent } from './events.js'; -import type { - JournalEventRecord, - PersistedSnapshot, - RunStore, -} from './store.js'; - -function compareSnapshots( - a: PersistedSnapshot, - b: PersistedSnapshot -): number { - return a.afterSequence - b.afterSequence || a.createdAt - b.createdAt; -} - -export function createMemoryRunStore< - TSnapshot extends AgentSnapshot = AgentSnapshot, - TEvent extends JournalEvent = JournalEvent, ->(): RunStore { - const journals = new Map>>(); - const snapshots = new Map[]>(); - - return { - async append(sessionId, event) { - const current = journals.get(sessionId) ?? []; - const sequence = current.length === 0 ? 1 : current[current.length - 1]!.sequence + 1; - current.push({ ...event, sequence }); - journals.set(sessionId, current); - return { sequence }; - }, - - async loadEvents(sessionId, afterSequence = 0) { - const events = journals.get(sessionId) ?? []; - return [...events] - .filter((entry) => entry.sequence > afterSequence) - .sort((a, b) => a.sequence - b.sequence); - }, - - async loadLatestSnapshot(sessionId) { - const saved = snapshots.get(sessionId); - if (!saved?.length) { - return null; - } - - const sorted = [...saved].sort(compareSnapshots); - return sorted[sorted.length - 1] ?? null; - }, - - async saveSnapshot(snapshot) { - const current = snapshots.get(snapshot.sessionId) ?? []; - current.push(snapshot); - snapshots.set(snapshot.sessionId, current); - }, - }; -} diff --git a/src/runtime/session.ts b/src/runtime/session.ts deleted file mode 100644 index 8b2d43c..0000000 --- a/src/runtime/session.ts +++ /dev/null @@ -1,446 +0,0 @@ -import { invoke } from '../local/interpreter.js'; -import type { JournalEvent } from './events.js'; -import { createRunEmitter } from './emitter.js'; -import type { - AgentMachine, - AgentRun, - AgentSnapshot, - AgentState, - EmittedPart, - RestoreSessionOptions, - SessionOptions, -} from '../types.js'; -import { isReservedInternalEventType } from '../utils.js'; - -const RESERVED_PUBLIC_ON_TYPES = new Set([ - 'part', - 'done', - 'error', - 'state', - 'machine.event', - 'runtime', -]); - -type SnapshotRuntime = { - sessionId: string; - createdAt: number; -}; - -type RuntimeMachine = AgentMachine & { - __runtime: { - toSnapshot(state: AgentState, runtime: SnapshotRuntime): AgentSnapshot; - withRuntimeMetadata(state: AgentState, runtime: SnapshotRuntime): AgentState; - getEffectEvent( - state: AgentState, - onEmit?: (part: EmittedPart) => void - ): Promise; - resolveEffectTransition( - state: AgentState, - effectEvent: JournalEvent, - onEmit?: (part: EmittedPart) => void - ): { event: JournalEvent; next: AgentState }; - transitionWithEffects( - state: AgentState, - event: { type: string; [key: string]: unknown }, - onEmit?: (part: EmittedPart) => void - ): { next: AgentState; emitted: EmittedPart[] }; - }; -}; - -type RunState = { - current: AgentState; - snapshot: AgentSnapshot; - lastSequence: number; - runtime: SnapshotRuntime; -}; - -function createSessionId(): string { - if ( - typeof globalThis.crypto !== 'undefined' - && typeof globalThis.crypto.randomUUID === 'function' - ) { - return globalThis.crypto.randomUUID(); - } - - return `session-${Math.random().toString(36).slice(2)}`; -} - -function asRuntimeMachine(machine: AgentMachine): RuntimeMachine { - const runtimeMachine = machine as RuntimeMachine; - if (!runtimeMachine.__runtime) { - throw new Error('Machine runtime internals are unavailable'); - } - - return runtimeMachine; -} - -function toJournalEvent( - event: { type: string; [key: string]: unknown } -): JournalEvent { - return { - ...event, - at: typeof event.at === 'number' ? event.at : Date.now(), - }; -} - -function createRun( - machine: AgentMachine, - store: SessionOptions['store'], - runtimeMachine: RuntimeMachine, - runState: RunState, - emitter = createRunEmitter() -): AgentRun { - let releaseStart!: () => void; - let operation = new Promise((resolve) => { - releaseStart = resolve; - }); - let startScheduled = false; - let terminalEmitted = false; - - function emitPart(part: EmittedPart) { - emitter.emit('part', part); - emitter.emit(part.type, part); - } - - function enqueue(op: () => Promise): Promise { - const result = operation.then(op); - operation = result.then( - () => undefined, - () => undefined - ); - - return result; - } - - function emitTerminalIfNeeded() { - if (terminalEmitted) { - return; - } - - if (runState.snapshot.status === 'done') { - terminalEmitted = true; - emitter.emit('runtime', { - type: 'session.completed', - sessionId: runState.runtime.sessionId, - at: Date.now(), - }); - emitter.emit('done', { - output: runState.snapshot.output, - snapshot: runState.snapshot, - }); - return; - } - - if (runState.snapshot.status === 'error') { - terminalEmitted = true; - emitter.emit('runtime', { - type: 'session.failed', - sessionId: runState.runtime.sessionId, - error: runState.snapshot.error, - at: Date.now(), - }); - emitter.emit('error', { - error: runState.snapshot.error, - snapshot: runState.snapshot, - }); - } - } - - async function persistSnapshot() { - runState.snapshot = runtimeMachine.__runtime.toSnapshot( - runState.current, - runState.runtime - ); - - await store.saveSnapshot({ - sessionId: runState.runtime.sessionId, - afterSequence: runState.lastSequence, - snapshot: runState.snapshot, - createdAt: Date.now(), - }); - - emitter.emit('runtime', { - type: 'snapshot.persisted', - sessionId: runState.runtime.sessionId, - afterSequence: runState.lastSequence, - at: Date.now(), - }); - emitter.emit('state', runState.snapshot); - emitTerminalIfNeeded(); - } - - async function appendMachineEvent(event: JournalEvent) { - const record = await store.append(runState.runtime.sessionId, event); - runState.lastSequence = record.sequence; - emitter.emit('machine.event', { - ...event, - sequence: record.sequence, - }); - } - - async function settle() { - while (runState.current.status === 'active') { - const effectEvent = await runtimeMachine.__runtime.getEffectEvent( - runState.current, - emitPart - ); - - if (effectEvent) { - const resolved = runtimeMachine.__runtime.resolveEffectTransition( - runState.current, - effectEvent, - emitPart - ); - - await appendMachineEvent(resolved.event); - runState.current = runtimeMachine.__runtime.withRuntimeMetadata( - resolved.next, - runState.runtime - ); - await persistSnapshot(); - continue; - } - - runState.current = runtimeMachine.__runtime.withRuntimeMetadata( - await invoke(machine, runState.current), - runState.runtime - ); - await persistSnapshot(); - } - } - - function scheduleStart() { - if (startScheduled) { - return; - } - - startScheduled = true; - void enqueue(async () => { - await settle(); - }); - queueMicrotask(() => { - releaseStart(); - }); - } - - return { - get sessionId() { - return runState.runtime.sessionId; - }, - - get status() { - return runState.snapshot.status; - }, - - getSnapshot() { - return runState.snapshot; - }, - - async send(event) { - if (isReservedInternalEventType(event.type)) { - throw new Error( - `Cannot send reserved internal event '${event.type}' to a session` - ); - } - - return enqueue(async () => { - const journalEvent = toJournalEvent(event); - const next = runtimeMachine.__runtime.transitionWithEffects( - runState.current, - journalEvent, - emitPart - ).next; - - await appendMachineEvent(journalEvent); - runState.current = runtimeMachine.__runtime.withRuntimeMetadata( - next, - runState.runtime - ); - await persistSnapshot(); - await settle(); - }); - }, - - on(type, handler) { - if (RESERVED_PUBLIC_ON_TYPES.has(type)) { - throw new Error( - `'${type}' is not an emitted event subscription. Use a dedicated run method instead.` - ); - } - - return emitter.on(type, handler as (event: unknown) => void); - }, - - onEmitted(handler) { - return emitter.on('part', handler as (event: unknown) => void); - }, - - onDone(handler) { - return emitter.on('done', handler as (event: unknown) => void); - }, - - onError(handler) { - return emitter.on('error', handler as (event: unknown) => void); - }, - - onSnapshot(handler) { - return emitter.on('state', handler as (event: unknown) => void); - }, - - onMachineEvent(handler) { - return emitter.on('machine.event', handler as (event: unknown) => void); - }, - - /** @internal */ - async __persistCurrent() { - await persistSnapshot(); - }, - - /** @internal */ - async __settle() { - await enqueue(async () => { - await settle(); - }); - }, - - /** @internal */ - __scheduleStart() { - scheduleStart(); - }, - } as AgentRun; -} - -export async function startSession< - TInput, - TContext extends Record, - TEvents extends Record, - TStates extends Record, - TOutput, - TEmitted extends Record, ->( - machine: AgentMachine, - options: SessionOptions -): Promise> { - const runtimeMachine = asRuntimeMachine(machine); - const initialState = (machine as AgentMachine).getInitialState( - options.input as TInput - ) as AgentState; - const runtime = { - sessionId: options.sessionId ?? createSessionId(), - createdAt: Date.now(), - }; - const runState: RunState = { - current: runtimeMachine.__runtime.withRuntimeMetadata(initialState, runtime), - snapshot: runtimeMachine.__runtime.toSnapshot(initialState, runtime), - lastSequence: 0, - runtime, - }; - - const run = createRun( - machine, - options.store, - runtimeMachine, - runState - ) as AgentRun & { - __persistCurrent(): Promise; - __settle(): Promise; - __scheduleStart(): void; - }; - - const initEvent = { - type: 'xstate.init', - input: options.input, - at: runtime.createdAt, - } satisfies JournalEvent; - const record = await options.store.append(runtime.sessionId, initEvent); - runState.lastSequence = record.sequence; - - await run.__persistCurrent(); - run.__scheduleStart(); - - return run; -} - -export async function restoreSession< - TInput, - TContext extends Record, - TEvents extends Record, - TStates extends Record, - TOutput, - TEmitted extends Record, ->( - machine: AgentMachine, - options: RestoreSessionOptions -): Promise> { - const runtimeMachine = asRuntimeMachine(machine); - const persisted = await options.store.loadLatestSnapshot(options.sessionId); - const allEvents = await options.store.loadEvents(options.sessionId); - const initEvent = allEvents.find( - (event) => event.type === 'xstate.init' - ); - - if (!persisted && !initEvent) { - throw new Error(`No persisted session '${options.sessionId}' found`); - } - - const runtime = { - sessionId: options.sessionId, - createdAt: persisted?.snapshot.createdAt ?? initEvent?.at ?? Date.now(), - }; - const initialState = persisted - ? (machine.resolveState( - persisted.snapshot as AgentSnapshot - ) as AgentState) - : ((machine as AgentMachine).getInitialState(initEvent?.input) as AgentState< - TContext, - keyof TStates & string, - TOutput - >); - const runState: RunState = { - current: runtimeMachine.__runtime.withRuntimeMetadata(initialState, runtime), - snapshot: - persisted?.snapshot - ?? runtimeMachine.__runtime.toSnapshot(initialState, runtime), - lastSequence: persisted?.afterSequence ?? (initEvent?.sequence ?? 0), - runtime, - }; - const run = createRun( - machine, - options.store, - runtimeMachine, - runState - ) as AgentRun & { - __persistCurrent(): Promise; - __settle(): Promise; - __scheduleStart(): void; - }; - - const replayTail = await options.store.loadEvents( - options.sessionId, - runState.lastSequence - ); - let replayed = false; - - for (const event of replayTail) { - runState.current = runtimeMachine.__runtime.withRuntimeMetadata( - machine.transition( - runState.current as AgentState, - event as unknown as import('../types.js').TransitionEvent - ) as AgentState, - runState.runtime - ); - runState.lastSequence = event.sequence; - runState.snapshot = runtimeMachine.__runtime.toSnapshot( - runState.current, - runState.runtime - ); - replayed = true; - } - - if (!persisted || replayed) { - await run.__persistCurrent(); - } - run.__scheduleStart(); - - return run; -} diff --git a/src/runtime/store.ts b/src/runtime/store.ts deleted file mode 100644 index 4446165..0000000 --- a/src/runtime/store.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { AgentSnapshot } from '../types.js'; -import type { JournalEvent } from './events.js'; - -export type JournalEventRecord< - TEvent extends JournalEvent = JournalEvent, -> = TEvent & { sequence: number }; - -export interface PersistedSnapshot< - TSnapshot extends AgentSnapshot = AgentSnapshot, -> { - sessionId: string; - snapshot: TSnapshot; - afterSequence: number; - createdAt: number; -} - -export interface RunStore< - TSnapshot extends AgentSnapshot = AgentSnapshot, - TEvent extends JournalEvent = JournalEvent, -> { - append(sessionId: string, event: TEvent): Promise<{ sequence: number }>; - loadEvents( - sessionId: string, - afterSequence?: number - ): Promise[]>; - loadLatestSnapshot(sessionId: string): Promise | null>; - saveSnapshot(snapshot: PersistedSnapshot): Promise; -} diff --git a/src/session-runtime.test.ts b/src/session-runtime.test.ts deleted file mode 100644 index 33a259b..0000000 --- a/src/session-runtime.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from './local/index.js'; -import { z } from 'zod'; -import { - createAgentMachine, -} from './index.js'; - -function deferred() { - let resolve!: (value: T | PromiseLike) => void; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - - return { promise, resolve }; -} - -test('startSession creates a session, persists xstate.init, and returns before start effects run', async () => { - const machine = createAgentMachine({ - id: 'session-runtime', - context: () => ({ count: 0 }), - initial: 'idle', - states: { - idle: { - on: { - increment: { - target: 'done', - context: { count: 1 }, - }, - }, - }, - done: { - type: 'final', - output: ({ context }) => context, - }, - }, - }); - - const store = createMemoryRunStore(); - const run = await startSession(machine, { store }); - const snapshot = run.getSnapshot(); - const journal = await store.loadEvents(run.sessionId); - const persisted = await store.loadLatestSnapshot(run.sessionId); - - expect(run.sessionId).toBe(snapshot.sessionId); - expect(snapshot).toEqual( - expect.objectContaining({ - sessionId: run.sessionId, - value: 'idle', - status: 'active', - context: { count: 0 }, - input: {}, - }) - ); - await vi.waitFor(() => { - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'idle', - status: 'pending', - }) - ); - }); - expect(journal).toEqual([ - expect.objectContaining({ - sequence: 1, - type: 'xstate.init', - at: expect.any(Number), - }), - ]); - expect(persisted).toEqual( - expect.objectContaining({ - sessionId: run.sessionId, - afterSequence: 1, - snapshot, - }) - ); -}); - -test('serializes concurrent sends so each event applies from the latest snapshot', async () => { - const gates = [deferred(), deferred()]; - let invocations = 0; - const machine = createAgentMachine({ - id: 'serialized-send', - schemas: { - events: { - increment: z.object({ amount: z.number() }), - }, - }, - context: () => ({ count: 0 }), - initial: 'ready', - states: { - ready: { - on: { - increment: ({ event, context }) => ({ - target: 'working', - context: { count: context.count + event.amount }, - }), - }, - }, - working: { - schemas: { output: z.object({ count: z.number() }) }, - invoke: async ({ context }) => { - const gate = gates[invocations++]!; - await gate.promise; - return { count: context.count }; - }, - onDone: ({ output }) => ({ - target: 'ready', - context: { count: output.count }, - }), - }, - }, - }); - - const run = await startSession(machine, { store: createMemoryRunStore() }); - await vi.waitFor(() => { - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'ready', - status: 'pending', - }) - ); - }); - - const first = run.send({ type: 'increment', amount: 1 }); - const second = run.send({ type: 'increment', amount: 10 }); - - await vi.waitFor(() => { - expect(invocations).toBe(1); - }); - - gates[0]!.resolve(); - await first; - await vi.waitFor(() => { - expect(invocations).toBe(2); - }); - - gates[1]!.resolve(); - await second; - - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'ready', - status: 'pending', - context: { count: 11 }, - }) - ); -}); - -test('journals always transitions and persists messages', async () => { - const machine = createAgentMachine({ - id: 'always-session', - context: () => ({ ready: false }), - messages: () => [{ role: 'user', content: 'start' }], - initial: 'checking', - states: { - checking: { - always: ({ messages }) => ({ - target: 'done', - context: { ready: true }, - messages: messages.concat({ role: 'assistant', content: 'done' }), - }), - }, - done: { - type: 'final', - output: ({ context, messages }) => ({ ...context, messages }), - }, - }, - }); - const store = createMemoryRunStore(); - const run = await startSession(machine, { store }); - - await vi.waitFor(() => { - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - context: { ready: true }, - messages: [ - { role: 'user', content: 'start' }, - { role: 'assistant', content: 'done' }, - ], - }) - ); - }); - - await expect(store.loadEvents(run.sessionId)).resolves.toEqual([ - expect.objectContaining({ sequence: 1, type: 'xstate.init' }), - expect.objectContaining({ sequence: 2, type: 'xstate.always.checking' }), - ]); -}); - -test('rejects reserved internal events from run.send', async () => { - const machine = createAgentMachine({ - id: 'reserved-events', - context: () => ({ count: 0 }), - initial: 'ready', - states: { - ready: { - on: { - go: { target: 'done' }, - }, - }, - done: { type: 'final' }, - }, - }); - - const run = await startSession(machine, { store: createMemoryRunStore() }); - await vi.waitFor(() => { - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'ready', - status: 'pending', - }) - ); - }); - - await expect(run.send({ type: 'xstate.init' })).rejects.toThrow( - /reserved internal event/i - ); - await expect( - run.send({ type: 'xstate.done.invoke.worker' }) - ).rejects.toThrow(/reserved internal event/i); - await expect( - run.send({ type: 'xstate.error.invoke.worker' }) - ).rejects.toThrow(/reserved internal event/i); - await expect( - run.send({ type: 'xstate.always.ready' }) - ).rejects.toThrow(/reserved internal event/i); -}); diff --git a/src/session-types.test.ts b/src/session-types.test.ts deleted file mode 100644 index af88cc5..0000000 --- a/src/session-types.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from 'vitest'; -import type { AgentSnapshot, JournalEvent } from './index.js'; - -test('AgentSnapshot includes durable session fields', () => { - const snapshot: AgentSnapshot<{ count: number }, 'idle'> = { - value: 'idle', - context: { count: 1 }, - messages: [], - status: 'active', - createdAt: 123, - sessionId: 'session-1', - input: {}, - }; - - expect(snapshot.sessionId).toBe('session-1'); - expect(snapshot.createdAt).toBe(123); -}); - -test('JournalEvent supports invoke completion events', () => { - const event: JournalEvent = { - type: 'xstate.done.invoke.worker', - at: 456, - }; - - expect(event.type).toBe('xstate.done.invoke.worker'); - expect(event.at).toBe(456); -}); diff --git a/src/setup-agent.test.ts b/src/setup-agent.test.ts new file mode 100644 index 0000000..7b77fff --- /dev/null +++ b/src/setup-agent.test.ts @@ -0,0 +1,781 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { assign, createActor, fromPromise, initialTransition, waitFor } from 'xstate'; +import { + createAgentSchemas, + createTextLogic, + getAvailableEvents, + getAgentEffects, + getEventTools, + setupAgent, + transitionResult, + type AgentTextInput, + type TextLogicInput, + type TextLogicOutput, +} from './index.js'; + +describe('setupAgent', () => { + test('withTasks creates typed task actors from schemas', () => { + const schemas = createAgentSchemas({ + context: z.object({ + prompt: z.string(), + draft: z.object({ body: z.string() }).nullable(), + }), + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + events: { + READY_TO_DRAFT: z.object({}), + NEEDS_INFO: z.object({ question: z.string() }), + }, + }); + + const agent = setupAgent({ schemas }).withTasks({ + draftEmail: { + kind: 'generate', + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + events: ({ input, schemas }) => { + const prompt: string = input.prompt; + schemas.events.READY_TO_DRAFT; + // @ts-expect-error task events input is typed from schemas.input + input.body; + return prompt.length > 0 ? ['READY_TO_DRAFT'] : []; + }, + }, + streamRevision: { + kind: 'stream', + schemas: { + input: z.object({ body: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.body, + }, + }); + + expect(agent.tasks.draftEmail.taskKind).toBe('generate'); + expect(agent.tasks.draftEmail.request({ prompt: 'Draft it.' })).toEqual( + expect.objectContaining({ + model: 'test-model', + prompt: 'Draft it.', + allowedEvents: ['READY_TO_DRAFT'], + }) + ); + + setupAgent({ schemas }).withTasks({ + badKind: { + // @ts-expect-error task kind is constrained + kind: 'foo', + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + }, + }); + + setupAgent({ schemas }).withTasks({ + badEvent: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + // @ts-expect-error events are keyed by machine event schemas + events: ['DRAT_EMAIL_TYPO'], + }, + }); + + setupAgent({ schemas }).withTasks({ + badAllowedEvents: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + // @ts-expect-error use task events, not raw text logic allowedEvents + allowedEvents: ['READY_TO_DRAFT'], + }, + }); + + agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt, draft: null }), + initial: 'drafting', + states: { + drafting: { + // @ts-expect-error task source ids are strongly typed + invoke: { + src: 'dratemaltypo', + input: { prompt: 'Draft it.' }, + }, + }, + }, + }); + + agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt, draft: null }), + initial: 'drafting', + states: { + drafting: { + invoke: { + src: 'draftEmail', + // @ts-expect-error task input is schema-typed + input: { whoopsanything: 42 }, + }, + }, + }, + }); + + const machine = agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt, draft: null }), + initial: 'drafting', + states: { + drafting: { + invoke: { + id: 'draft', + src: 'draftEmail', + input: ({ context }) => ({ prompt: context.prompt }), + onDone: { + target: 'done', + actions: assign({ + draft: ({ event }) => event.output, + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => context.draft ?? { body: '' }, + }, + }, + }); + + const [_snapshot, actions] = initialTransition(machine, { + prompt: 'Draft it.', + }); + const [effect] = getAgentEffects(actions, { + actors: agent.tasks, + }); + + expect(effect).toEqual( + expect.objectContaining({ + kind: 'generate', + input: expect.objectContaining({ allowedEvents: ['READY_TO_DRAFT'] }), + }) + ); + }); + + test('setupAgent preserves typed action guard and delay names', () => { + const schemas = createAgentSchemas({ + context: z.object({ prompt: z.string(), ready: z.boolean() }), + input: z.object({ prompt: z.string() }), + events: { + MARK_READY: z.object({ reason: z.string() }), + }, + }); + + const agent = setupAgent({ + schemas, + actions: { + markReady: assign({ + ready: ({ event }) => { + if (event.type === 'MARK_READY') { + const reason: string = event.reason; + // @ts-expect-error event payload is schema-typed + event.missing; + return reason.length > 0; + } + return false; + }, + }), + }, + guards: { + hasPrompt: ({ context }) => context.prompt.length > 0, + }, + delays: { + shortPause: ({ context }) => { + // @ts-expect-error delay callback context is schema-typed + context.missing; + return context.prompt.length; + }, + }, + }); + + agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt, ready: false }), + initial: 'waiting', + states: { + waiting: { + entry: 'markReady', + always: { guard: 'hasPrompt', target: 'done' }, + after: { shortPause: 'done' }, + on: { + MARK_READY: { actions: 'markReady' }, + }, + }, + done: { type: 'final' }, + }, + }); + + agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt, ready: false }), + initial: 'waiting', + states: { + waiting: { + // @ts-expect-error action names are setup-typed + entry: 'markReadtypo', + }, + }, + }); + + agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt, ready: false }), + initial: 'waiting', + states: { + waiting: { + // @ts-expect-error guard names are setup-typed + always: { + guard: 'hasPromptypo', + target: 'done', + }, + }, + done: { type: 'final' }, + }, + }); + + }); + + test('authors named text logic with typed input and output', () => { + const getSummary = createTextLogic({ + schemas: { + input: z.object({ article: z.string() }), + output: z.object({ summary: z.string() }), + }, + model: 'test-model', + system: 'Summarize articles.', + prompt: ({ input }) => `Summarize:\n${input.article}`, + temperature: ({ input }) => input.article.length > 10 ? 0.2 : 0, + }); + + expect(getSummary.request({ article: 'A long article.' })).toEqual( + expect.objectContaining({ + model: 'test-model', + system: 'Summarize articles.', + prompt: 'Summarize:\nA long article.', + outputSchema: getSummary.schemas.output, + temperature: 0.2, + }) + ); + + const agent = setupAgent({ + context: z.object({ + article: z.string(), + summary: z.string().nullable(), + }), + input: z.object({ article: z.string() }), + output: z.object({ summary: z.string() }), + actors: { getSummary }, + }); + + agent.createMachine({ + initial: 'summarizing', + states: { + summarizing: { + // @ts-expect-error setup actors provide strongly typed source names + invoke: { + src: 'getSummar', + input: { article: 'typo' }, + }, + }, + }, + }); + + agent.createMachine({ + initial: 'summarizing', + states: { + summarizing: { + // @ts-expect-error named text logic input requires article + invoke: { + id: 'getSummary', + src: 'getSummary', + input: ({ context }) => ({ prompt: context.article }), + }, + }, + }, + }); + + const machine = agent.createMachine({ + context: ({ input }) => ({ article: input.article, summary: null }), + initial: 'summarizing', + states: { + summarizing: { + invoke: { + id: 'getSummary', + src: 'getSummary', + input: ({ context }) => ({ article: context.article }), + onDone: { + target: 'done', + actions: assign({ + summary: ({ event }) => { + const summary: string = event.output.summary; + // @ts-expect-error schema-typed output rejects unknown fields + event.output.missingField; + return summary; + }, + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ summary: context.summary ?? '' }), + }, + }, + }); + + let [snapshot, actions] = initialTransition(machine, { + article: 'State machines make agents inspectable.', + }); + const [effect] = getAgentEffects(actions, { + actors: { getSummary }, + }); + + expect(effect).toEqual({ + id: 'getSummary', + src: 'getSummary', + input: expect.objectContaining({ + model: 'test-model', + system: 'Summarize articles.', + prompt: 'Summarize:\nState machines make agents inspectable.', + outputSchema: getSummary.schemas.output, + }), + tools: {}, + events: [], + }); + + [snapshot] = transitionResult(machine, snapshot, effect!, { + summary: 'Agents become inspectable.', + }); + + expect(snapshot.status).toBe('done'); + expect(snapshot.output).toEqual({ summary: 'Agents become inspectable.' }); + }); + + test('named text logic can optionally execute as a promise actor', async () => { + const answerQuestion = createTextLogic( + { + schemas: { + input: z.object({ question: z.string() }), + output: z.object({ answer: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.question, + }, + async ({ input, request, signal }) => { + expect(signal).toBeInstanceOf(AbortSignal); + return { + answer: `${request.model}:${input.question}`, + }; + } + ); + + const agent = setupAgent({ + context: z.object({ + question: z.string(), + answer: z.string().nullable(), + }), + input: z.object({ question: z.string() }), + output: z.object({ answer: z.string() }), + actors: { answerQuestion }, + }); + + const machine = agent.createMachine({ + context: ({ input }) => ({ question: input.question, answer: null }), + initial: 'answering', + states: { + answering: { + invoke: { + src: 'answerQuestion', + input: ({ context }) => ({ question: context.question }), + onDone: { + target: 'done', + actions: assign({ + answer: ({ event }) => event.output.answer, + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ answer: context.answer ?? '' }), + }, + }, + }); + + const actor = createActor(machine, { + input: { question: 'can text logic run?' }, + }); + actor.start(); + await waitFor(actor, (snapshot) => snapshot.status === 'done'); + + expect(actor.getSnapshot().output).toEqual({ + answer: 'test-model:can text logic run?', + }); + }); + + test('named text logic validates executor output', async () => { + const answerQuestion = createTextLogic( + { + schemas: { + input: z.object({ question: z.string() }), + output: z.object({ answer: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.question, + }, + async () => ({ nope: true }) as unknown as { answer: string } + ); + + const agent = setupAgent({ + context: z.object({ + question: z.string(), + error: z.string().nullable(), + }), + input: z.object({ question: z.string() }), + actors: { answerQuestion }, + }); + + const machine = agent.createMachine({ + context: ({ input }) => ({ question: input.question, error: null }), + initial: 'answering', + states: { + answering: { + invoke: { + src: 'answerQuestion', + input: ({ context }) => ({ question: context.question }), + onError: { + target: 'failed', + actions: assign({ + error: ({ event }) => + event.error instanceof Error + ? event.error.message + : String(event.error), + }), + }, + }, + }, + failed: {}, + }, + }); + + const actor = createActor(machine, { + input: { question: 'is output validated?' }, + }); + actor.start(); + await waitFor(actor, (snapshot) => snapshot.matches('failed')); + + expect(actor.getSnapshot().context.error).toContain('expected string'); + }); + + test('authors raw XState machines from the root export', async () => { + const draftSchema = z.object({ + subject: z.string(), + body: z.string(), + }); + const draftEmail = createTextLogic({ + schemas: { + input: z.object({ prompt: z.string() }), + output: draftSchema, + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + metadata: ({ input }) => ({ + temperature: input.prompt.length > 0 ? 0.2 : 0, + traceId: `draft:${input.prompt}`, + }), + }); + + const agent = setupAgent({ + context: z.object({ + prompt: z.string(), + draft: draftSchema.nullable(), + }), + input: z.object({ prompt: z.string() }), + output: draftSchema, + events: { + RETRY: z.object({ prompt: z.string() }), + }, + actors: { draftEmail }, + }); + + agent.createMachine({ + initial: 'drafting', + states: { + drafting: { + // @ts-expect-error registered source ids are strongly typed string literals + invoke: { + id: 'draft', + src: 'draftEmai', + input: { prompt: 'misspelled source' }, + }, + }, + }, + }); + + const machine = agent.createMachine({ + id: 'raw-xstate-email-drafter', + context: ({ input }) => ({ prompt: input.prompt, draft: null }), + initial: 'drafting', + states: { + drafting: { + invoke: { + id: 'draft', + src: 'draftEmail', + input: ({ context }) => ({ prompt: context.prompt }), + onDone: { + target: 'review', + actions: assign({ + draft: ({ event }) => { + const draft = event.output; + const subject: string = draft.subject; + // @ts-expect-error schema-typed output rejects unknown fields + draft.missingField; + return { ...draft, subject }; + }, + }), + }, + }, + }, + review: { + on: { + RETRY: { + target: 'drafting', + actions: assign({ + prompt: ({ event }) => event.prompt, + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => + context.draft ?? { subject: '', body: '' }, + }, + }, + }); + + const calls: AgentTextInput<{ temperature: number; traceId: string }>[] = []; + const actor = createActor( + machine.provide({ + actors: { + draftEmail: fromPromise< + TextLogicOutput, + TextLogicInput + >( + async ({ input }) => { + const request = draftEmail.request(input); + calls.push( + request as AgentTextInput<{ + temperature: number; + traceId: string; + }> + ); + return { + subject: `Re: ${request.prompt}`, + body: 'Typed raw XState machine body.', + }; + } + ), + }, + }), + { input: { prompt: 'launch note' } } + ); + + actor.start(); + + await waitFor(actor, (snapshot) => snapshot.matches('review')); + + expect(actor.getSnapshot().context.draft).toEqual({ + subject: 'Re: launch note', + body: 'Typed raw XState machine body.', + }); + expect(calls).toEqual([ + expect.objectContaining({ + model: 'test-model', + prompt: 'launch note', + outputSchema: draftEmail.schemas.output, + metadata: { temperature: 0.2, traceId: 'draft:launch note' }, + }), + ]); + }); + + test('extracts agent effects from pure XState transitions', () => { + const answerSchema = z.object({ answer: z.string() }); + const answerQuestion = createTextLogic({ + schemas: { + input: z.object({ prompt: z.string() }), + output: answerSchema, + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + temperature: 0.2, + }); + const agent = setupAgent({ + context: z.object({ + prompt: z.string(), + answer: z.string().nullable(), + }), + input: z.object({ prompt: z.string() }), + output: z.object({ answer: z.string() }), + actors: { answerQuestion }, + }); + + const machine = agent.createMachine({ + id: 'pure-agent-loop', + context: ({ input }) => ({ prompt: input.prompt, answer: null }), + initial: 'answering', + states: { + answering: { + invoke: { + id: 'answer', + src: 'answerQuestion', + input: ({ context }) => ({ prompt: context.prompt }), + onDone: { + target: 'done', + actions: assign({ + answer: ({ event }) => event.output.answer, + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ answer: context.answer ?? '' }), + }, + }, + }); + + let [snapshot, actions] = initialTransition(machine, { + prompt: 'why state machines?', + }); + const [effect] = getAgentEffects(actions, { + actors: { answerQuestion }, + }); + + expect(effect).toEqual({ + id: 'answer', + src: 'answerQuestion', + input: expect.objectContaining({ + model: 'test-model', + prompt: 'why state machines?', + temperature: 0.2, + outputSchema: answerQuestion.schemas.output, + }), + tools: {}, + events: [], + }); + + [snapshot, actions] = transitionResult(machine, snapshot, effect!, { + answer: 'Because the workflow matters.', + }); + + expect(getAgentEffects(actions)).toEqual([]); + expect(snapshot.status).toBe('done'); + expect(snapshot.output).toEqual({ + answer: 'Because the workflow matters.', + }); + }); + + test('agent effects expose only whitelisted allowed state events as tools', async () => { + const chooseMove = createTextLogic({ + schemas: { + input: z.object({ prompt: z.string() }), + output: z.string(), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + allowedEvents: ['ATTACK', 'DEFEND', 'HEAL'], + }); + const agent = setupAgent({ + context: z.object({ prompt: z.string() }), + input: z.object({ prompt: z.string() }), + events: { + ATTACK: z.object({ target: z.string() }), + DEFEND: z.object({}), + PAUSE: z.object({}), + }, + actors: { chooseMove }, + }); + + const machine = agent.createMachine({ + id: 'game-agent', + context: ({ input }) => ({ prompt: input.prompt }), + initial: 'choosing', + states: { + choosing: { + invoke: { + id: 'chooseMove', + src: 'chooseMove', + input: ({ context }) => ({ prompt: context.prompt }), + onDone: { target: 'done' }, + }, + on: { + ATTACK: { target: 'done' }, + DEFEND: { target: 'done' }, + PAUSE: { target: 'paused' }, + }, + }, + paused: {}, + done: { type: 'final' }, + }, + }); + + const [snapshot, actions] = initialTransition(machine, { + prompt: 'Choose the next move.', + }); + + expect(getAvailableEvents(snapshot, { + schemas: agent.schemas, + allowedEvents: ['ATTACK', 'DEFEND', 'HEAL'], + })).toEqual([ + expect.objectContaining({ type: 'ATTACK', toolName: 'event.ATTACK' }), + expect.objectContaining({ type: 'DEFEND', toolName: 'event.DEFEND' }), + ]); + + const [effect] = getAgentEffects(actions, { + snapshot, + schemas: agent.schemas, + actors: { chooseMove }, + }); + + expect(effect!.events.map((event) => event.type)).toEqual([ + 'ATTACK', + 'DEFEND', + ]); + expect(Object.keys(effect!.tools)).toEqual([ + 'event.ATTACK', + 'event.DEFEND', + ]); + + const attackTool = effect!.tools['event.ATTACK']!; + if (typeof attackTool === 'function') { + throw new Error('Expected event tool descriptor.'); + } + await expect(attackTool.execute?.({ target: 'orc' })).resolves.toEqual({ + type: 'ATTACK', + target: 'orc', + }); + + expect(Object.keys(getEventTools(snapshot, { + schemas: agent.schemas, + allowedEvents: ['ATTACK', 'DEFEND', 'HEAL'], + }))).toEqual(['event.ATTACK', 'event.DEFEND']); + }); +}); diff --git a/src/setup-agent.ts b/src/setup-agent.ts new file mode 100644 index 0000000..faa6d13 --- /dev/null +++ b/src/setup-agent.ts @@ -0,0 +1,1028 @@ +import { + fromPromise, + getNextTransitions, + setup, + transition, + type AnyActorLogic, + type AnyMachineSnapshot, + type EventObject, + type ExecutableActionsFrom, + type MachineContext, + type MetaObject, + type NonReducibleUnknown, + type ParameterizedObject, + type PromiseActorLogic, + type SnapshotFrom, +} from 'xstate'; +import type { + AgentMessage, + AgentToolChoice, + AgentTools, + EventUnion, + InferOutput, + StandardSchemaV1, +} from './types.js'; +import { validateSchemaSync } from './utils.js'; + +// ─── Built-in text actors ─── +// +// `agent.generate` and `agent.stream` are well-known actor sources +// registered by `setupAgent`. The machine declares the call; the host +// provides the execution (via `machine.provide({ actors })` or a runtime +// adapter). Streaming is a host concern: `agent.stream` resolves with +// the final text once the stream completes — incremental chunks flow +// through the host's side channel (HTTP stream, WebSocket, stdout), never +// through the machine's journal. + +/** Portable LCD input both built-in text actors receive. */ +export interface AgentTextInput { + model: string; + system?: string; + prompt?: string; + messages?: AgentMessage[]; + /** Host/model tools that are always available to this text call. */ + tools?: AgentTools; + toolChoice?: AgentToolChoice; + /** Machine event types to expose as model-call tools for this state. */ + allowedEvents?: readonly string[]; + outputSchema?: StandardSchemaV1; + temperature?: number; + maxTokens?: number; + topP?: number; + topK?: number; + seed?: number; + stopSequences?: string[]; + /** + * Host-owned per-call options. Use this for provider/runtime details such + * as Cloudflare bindings, tracing IDs, SDK provider options, or transport + * hints. The machine carries it; the host decides what it means. + */ + metadata?: TMetadata; +} + +const AGENT_GENERATE_SRC = 'agent.generate' as const; +const AGENT_STREAM_SRC = 'agent.stream' as const; + +// `generateText` output is `any` at the actor level on purpose: generated +// object shapes are runtime data. Keep `onDone` plain XState and validate +// with the shared `input.outputSchema` where you assign/use the value. +type BuiltinTextActors = { + 'agent.generate': PromiseActorLogic; + 'agent.stream': PromiseActorLogic; +}; + +function missingHostActor(src: string): PromiseActorLogic { + return fromPromise(async () => { + throw new Error( + `'${src}' has no host execution. Provide an implementation with ` + + `machine.provide({ actors: { '${src}': ... } }) or run the machine ` + + `through an agent runtime adapter.` + ); + }); +} + +// ─── Message helpers ─── +// +// Messages are plain context state: declare a `messages` field in the +// context schema and update it with `assign`. `addMessages` is a property +// assigner for that idiom — it appends instead of replacing: +// +// actions: assign({ +// messages: addMessages(({ event }) => userMessage(event.prompt)), +// }) + +export { + appendMessages, + assistantMessage, + systemMessage, + userMessage, + validateSchemaSync, +} from './utils.js'; + +export function addMessages< + TContext extends { messages: AgentMessage[] }, + TEvent extends EventObject, +>( + resolve: + | AgentMessage + | AgentMessage[] + | ((args: { context: TContext; event: TEvent }) => AgentMessage | AgentMessage[]), +): (args: { context: TContext; event: TEvent }) => AgentMessage[] { + return (args) => { + const resolved = + typeof resolve === 'function' ? resolve(args) : resolve; + return [ + ...args.context.messages, + ...(Array.isArray(resolved) ? resolved : [resolved]), + ]; + }; +} + +/** Standard schema for an `AgentMessage[]` context field. */ +export const messagesSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'statelyai-agent', + validate(value: unknown) { + const ok = + Array.isArray(value) + && value.every( + (message) => + !!message + && typeof message === 'object' + && typeof (message as AgentMessage).role === 'string' + && typeof (message as AgentMessage).content === 'string' + ); + return ok + ? { value: value as AgentMessage[] } + : { issues: [{ message: 'Expected an array of agent messages' }] }; + }, + }, +}; + +export function parseOutput( + schema: TSchema, + output: unknown +): InferOutput { + return validateSchemaSync>( + schema as StandardSchemaV1>, + output + ); +} + +type ResolveTextLogicValue = + | TValue + | ((args: { input: TInput }) => TValue); + +function resolveTextLogicValue( + value: ResolveTextLogicValue | undefined, + args: { input: TInput } +): TValue | undefined { + return typeof value === 'function' + ? (value as (args: { input: TInput }) => TValue)(args) + : value; +} + +export interface TextLogicConfig< + TInputSchema extends StandardSchemaV1, + TOutputSchema extends StandardSchemaV1, + TMetadata = unknown, +> { + schemas: { + input: TInputSchema; + output: TOutputSchema; + }; + model: ResolveTextLogicValue>; + system?: ResolveTextLogicValue>; + prompt?: ResolveTextLogicValue>; + messages?: ResolveTextLogicValue< + AgentMessage[] | undefined, + InferOutput + >; + tools?: ResolveTextLogicValue>; + toolChoice?: ResolveTextLogicValue< + AgentToolChoice | undefined, + InferOutput + >; + allowedEvents?: ResolveTextLogicValue< + readonly string[] | undefined, + InferOutput + >; + temperature?: ResolveTextLogicValue>; + maxTokens?: ResolveTextLogicValue>; + topP?: ResolveTextLogicValue>; + topK?: ResolveTextLogicValue>; + seed?: ResolveTextLogicValue>; + stopSequences?: ResolveTextLogicValue< + string[] | undefined, + InferOutput + >; + metadata?: ResolveTextLogicValue>; +} + +export interface TextLogicExecuteArgs { + input: TInput; + request: AgentTextInput; + signal: AbortSignal; + system: unknown; + self: unknown; + emit: (emitted: EventObject) => void; +} + +export type TextLogicExecutor< + TInputSchema extends StandardSchemaV1, + TOutputSchema extends StandardSchemaV1, + TMetadata = unknown, +> = ( + args: TextLogicExecuteArgs, TMetadata> +) => PromiseLike>; + +export interface TextLogic< + TInputSchema extends StandardSchemaV1 = StandardSchemaV1, + TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, + TMetadata = unknown, +> extends PromiseActorLogic< + InferOutput, + InferOutput + > { + readonly kind: 'statelyai.textLogic'; + readonly schemas: { + readonly input: TInputSchema; + readonly output: TOutputSchema; + }; + request(input: InferOutput): AgentTextInput; + withExecutor( + execute: TextLogicExecutor + ): TextLogic; +} + +export type AgentTaskKind = 'generate' | 'stream'; + +export interface AgentTaskLogic< + TInputSchema extends StandardSchemaV1 = StandardSchemaV1, + TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, + TMetadata = unknown, +> extends TextLogic { + readonly taskKind: AgentTaskKind; +} + +export type TextLogicInput = + TLogic extends TextLogic + ? InferOutput + : never; + +export type TextLogicOutput = + TLogic extends TextLogic + ? InferOutput + : never; + +export function createTextLogic< + TInputSchema extends StandardSchemaV1, + TOutputSchema extends StandardSchemaV1, + TMetadata = unknown, +>( + config: TextLogicConfig, + execute?: TextLogicExecutor +): TextLogic { + type TInput = InferOutput; + type TOutput = InferOutput; + const request = (input: TInput): AgentTextInput => { + const parsedInput = validateSchemaSync( + config.schemas.input as StandardSchemaV1, + input + ); + const args = { input: parsedInput }; + + return { + model: resolveTextLogicValue(config.model, args)!, + system: resolveTextLogicValue(config.system, args), + prompt: resolveTextLogicValue(config.prompt, args), + messages: resolveTextLogicValue(config.messages, args), + tools: resolveTextLogicValue(config.tools, args), + toolChoice: resolveTextLogicValue(config.toolChoice, args), + allowedEvents: resolveTextLogicValue(config.allowedEvents, args), + outputSchema: config.schemas.output, + temperature: resolveTextLogicValue(config.temperature, args), + maxTokens: resolveTextLogicValue(config.maxTokens, args), + topP: resolveTextLogicValue(config.topP, args), + topK: resolveTextLogicValue(config.topK, args), + seed: resolveTextLogicValue(config.seed, args), + stopSequences: resolveTextLogicValue(config.stopSequences, args), + metadata: resolveTextLogicValue(config.metadata, args), + }; + }; + const logic = fromPromise( + async ({ input, signal, system, self, emit }) => { + const resolvedRequest = request(input); + + if (!execute) { + throw new Error( + 'Text logic has no host execution. Pass an executor as the second ' + + 'argument to createTextLogic(...), provide a runtime adapter, or ' + + 'extract it with getAgentEffects(..., { actors }).' + ); + } + + const output = await execute({ + input, + request: resolvedRequest, + signal, + system, + self, + emit: emit as (emitted: EventObject) => void, + }); + + return validateSchemaSync( + config.schemas.output as StandardSchemaV1, + output + ); + } + ); + + return Object.assign(logic, { + kind: 'statelyai.textLogic' as const, + schemas: config.schemas, + request, + withExecutor( + nextExecute: TextLogicExecutor + ) { + return createTextLogic(config, nextExecute); + }, + }) as TextLogic; +} + +function isTextLogic(value: unknown): value is TextLogic { + return ( + !!value + && typeof value === 'object' + && (value as TextLogic).kind === 'statelyai.textLogic' + && typeof (value as TextLogic).request === 'function' + ); +} + +function isAgentTaskLogic(value: unknown): value is AgentTaskLogic { + return isTextLogic(value) && typeof (value as AgentTaskLogic).taskKind === 'string'; +} + +export type AgentEffectSource = 'agent.generate' | 'agent.stream' | (string & {}); + +export const EVENT_TOOL_PREFIX = 'event.' as const; + +export interface AgentEffect { + id: string; + src: AgentEffectSource; + kind?: AgentTaskKind; + input: TInput; + tools: AgentTools; + events: AgentEventDescriptor[]; +} + +export interface AgentEventDescriptor { + type: string; + toolName: `${typeof EVENT_TOOL_PREFIX}${string}`; + inputSchema?: StandardSchemaV1; +} + +export interface AgentSchemas { + events?: Record; +} + +export interface AgentEffectOptions { + snapshot?: AnyMachineSnapshot; + events?: Record; + schemas?: AgentSchemas; + actors?: Record; +} + +function isAgentEffectSource(src: unknown): src is AgentEffectSource { + return src === AGENT_GENERATE_SRC || src === AGENT_STREAM_SRC; +} + +export function getAvailableEvents( + snapshot: AnyMachineSnapshot, + options: Pick & { + allowedEvents?: readonly string[]; + } = {} +): AgentEventDescriptor[] { + const allowedEvents = + options.allowedEvents === undefined + ? undefined + : new Set(options.allowedEvents); + const seen = new Set(); + + return getNextTransitions(snapshot).flatMap((transitionDefinition) => { + const eventType = transitionDefinition.eventType; + + if ( + !eventType + || eventType === '*' + || eventType.startsWith('xstate.') + || (allowedEvents && !allowedEvents.has(eventType)) + || seen.has(eventType) + ) { + return []; + } + + seen.add(eventType); + return [{ + type: eventType, + toolName: `${EVENT_TOOL_PREFIX}${eventType}` as const, + ...((options.events ?? options.schemas?.events)?.[eventType] + ? { inputSchema: (options.events ?? options.schemas?.events)![eventType] } + : {}), + }]; + }); +} + +export function getEventTools( + snapshot: AnyMachineSnapshot, + options: Pick & { + allowedEvents?: readonly string[]; + } = {} +): AgentTools { + return Object.fromEntries( + getAvailableEvents(snapshot, options).map((event) => [ + event.toolName, + { + description: `Transition with event '${event.type}'.`, + ...(event.inputSchema ? { inputSchema: event.inputSchema } : {}), + execute: async (input: unknown = {}) => ({ + ...(input && typeof input === 'object' ? input : {}), + type: event.type, + }), + }, + ]) + ); +} + +export function getAgentEffects( + actions: readonly { type?: string; params?: unknown }[], + options: AgentEffectOptions = {} +): AgentEffect[] { + return actions.flatMap((action) => { + if (action.type !== 'xstate.spawnChild') { + return []; + } + + const params = action.params as + | { id?: unknown; src?: unknown; input?: unknown } + | undefined; + if (!params || typeof params.src !== 'string') { + return []; + } + + if (typeof params.id !== 'string' || params.id.length === 0) { + throw new Error( + `Agent invoke '${params.src}' must define a durable string id.` + ); + } + + const textLogic = options.actors?.[params.src]; + const input = isAgentEffectSource(params.src) + ? params.input as AgentTextInput + : isTextLogic(textLogic) + ? textLogic.request(params.input as never) + : undefined; + + if (!input) { + return []; + } + + const events = options.snapshot + ? getAvailableEvents(options.snapshot, { + events: options.events, + schemas: options.schemas, + allowedEvents: input.allowedEvents, + }) + : []; + const eventTools = Object.fromEntries( + events.map((event) => [ + event.toolName, + { + description: `Transition with event '${event.type}'.`, + ...(event.inputSchema ? { inputSchema: event.inputSchema } : {}), + execute: async (toolInput: unknown = {}) => ({ + ...(toolInput && typeof toolInput === 'object' ? toolInput : {}), + type: event.type, + }), + }, + ]) + ); + + return [{ + id: params.id, + src: params.src, + ...(isAgentTaskLogic(textLogic) ? { kind: textLogic.taskKind } : {}), + input, + tools: { + ...(input.tools ?? {}), + ...eventTools, + }, + events, + }]; + }); +} + +export function doneEvent( + effect: Pick | string, + output: unknown +): { type: `xstate.done.actor.${string}`; output: unknown } { + const id = typeof effect === 'string' ? effect : effect.id; + return { type: `xstate.done.actor.${id}`, output }; +} + +export function transitionResult( + logic: TLogic, + snapshot: SnapshotFrom, + effect: Pick | string, + output: unknown +): [SnapshotFrom, ExecutableActionsFrom[]] { + return transition(logic, snapshot, doneEvent(effect, output) as never); +} + +// ─── setupAgent ─── + +type Constrain = T extends TConstraint ? T : TConstraint; + +type ContextOf = Constrain< + InferOutput, + MachineContext +>; +type EventsOf> = + Constrain, EventObject>; +type MetaOf = Constrain< + InferOutput, + MetaObject +>; +type SetupActors = { + [K in keyof TActors]: TActors[K] extends PromiseActorLogic + ? PromiseActorLogic + : TActors[K]; +}; +type AgentSetupActors = + SetupActors & BuiltinTextActors; + +export interface AgentSchemaPack< + TContextSchema extends StandardSchemaV1> = StandardSchemaV1>, + TEventSchemas extends Record = Record, + TInputSchema extends StandardSchemaV1 = StandardSchemaV1, + TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, + TMetaSchema extends StandardSchemaV1 = StandardSchemaV1, +> { + context: TContextSchema; + events: TEventSchemas; + input: TInputSchema; + output: TOutputSchema; + meta: TMetaSchema; +} + +type AgentSchemaConfig< + TContextSchema extends StandardSchemaV1>, + TEventSchemas extends Record, + TInputSchema extends StandardSchemaV1, + TOutputSchema extends StandardSchemaV1, + TMetaSchema extends StandardSchemaV1, +> = { + context: TContextSchema; + events?: TEventSchemas; + input?: TInputSchema; + output?: TOutputSchema; + meta?: TMetaSchema; +}; + +export function createAgentSchemas< + TContextSchema extends StandardSchemaV1>, + TEventSchemas extends Record = {}, + TInputSchema extends StandardSchemaV1 = StandardSchemaV1, + TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, + TMetaSchema extends StandardSchemaV1 = StandardSchemaV1, +>( + schemas: AgentSchemaConfig< + TContextSchema, + TEventSchemas, + TInputSchema, + TOutputSchema, + TMetaSchema + > +): AgentSchemaPack< + TContextSchema, + TEventSchemas, + TInputSchema, + TOutputSchema, + TMetaSchema +> { + return { + context: schemas.context, + events: (schemas.events ?? {}) as TEventSchemas, + input: schemas.input as TInputSchema, + output: schemas.output as TOutputSchema, + meta: schemas.meta as TMetaSchema, + }; +} + +type AgentTaskEvents< + TEventSchemas extends Record, + TSchemas extends AgentSchemaPack, + TInputSchema extends StandardSchemaV1, +> = + | readonly (keyof TEventSchemas & string)[] + | ((args: { + input: InferOutput; + schemas: TSchemas; + }) => readonly (keyof TEventSchemas & string)[]); + +export type AgentTaskConfig< + TEventSchemas extends Record, + TSchemas extends AgentSchemaPack, + TInputSchema extends StandardSchemaV1 = StandardSchemaV1, + TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, + TMetadata = unknown, +> = Omit< + TextLogicConfig, + 'allowedEvents' +> & { + kind?: AgentTaskKind; + events?: AgentTaskEvents; +}; + +type AgentTaskSchemaMap = Record< + string, + { + input: StandardSchemaV1; + output: StandardSchemaV1; + } +>; + +type AgentTaskInput< + TTaskSchemas extends AgentTaskSchemaMap, + TEventSchemas extends Record, + TSchemas extends AgentSchemaPack, +> = { + [K in keyof TTaskSchemas]: AgentTaskConfig< + TEventSchemas, + TSchemas, + TTaskSchemas[K]['input'], + TTaskSchemas[K]['output'] + > & { + schemas: TTaskSchemas[K]; + allowedEvents?: never; + }; +}; + +type TaskActors = { + [K in keyof TTaskSchemas]: AgentTaskLogic< + TTaskSchemas[K]['input'], + TTaskSchemas[K]['output'] + >; +}; + +type AgentSetupConfigOptions< + TContextSchema extends StandardSchemaV1>, + TEventSchemas extends Record, + TActors extends { [K in keyof TActors]: AnyActorLogic }, + TInputSchema extends StandardSchemaV1, + TOutputSchema extends StandardSchemaV1, + TMetaSchema extends StandardSchemaV1, + TActions extends Record, + TGuards extends Record, + TDelay extends string, +> = Parameters< + typeof setup< + ContextOf, + EventsOf, + AgentSetupActors, + {}, + TActions, + TGuards, + TDelay, + string, + InferOutput, + Constrain, NonReducibleUnknown>, + EventObject, + MetaOf + > +>[0]; + +type SetupAgentBaseConfig< + TContextSchema extends StandardSchemaV1>, + TEventSchemas extends Record, + TActors extends { [K in keyof TActors]: AnyActorLogic }, + TInputSchema extends StandardSchemaV1, + TOutputSchema extends StandardSchemaV1, + TMetaSchema extends StandardSchemaV1, + TActions extends Record, + TGuards extends Record, + TDelay extends string, +> = ( + | { + schemas: AgentSchemaPack< + TContextSchema, + TEventSchemas, + TInputSchema, + TOutputSchema, + TMetaSchema + >; + } + | AgentSchemaConfig< + TContextSchema, + TEventSchemas, + TInputSchema, + TOutputSchema, + TMetaSchema + > +) & { + actors?: TActors; + actions?: AgentSetupConfigOptions< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay + >['actions']; + guards?: AgentSetupConfigOptions< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay + >['guards']; + delays?: AgentSetupConfigOptions< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay + >['delays']; +}; + +type SetupAgentResult< + TContextSchema extends StandardSchemaV1>, + TEventSchemas extends Record, + TActors extends { [K in keyof TActors]: AnyActorLogic }, + TInputSchema extends StandardSchemaV1, + TOutputSchema extends StandardSchemaV1, + TMetaSchema extends StandardSchemaV1, + TActions extends Record, + TGuards extends Record, + TDelay extends string, + TTasks extends { [K in keyof TTasks]: AgentTaskLogic } = {}, +> = ReturnType< + typeof setup< + ContextOf, + EventsOf, + AgentSetupActors, + {}, + TActions, + TGuards, + TDelay, + string, + InferOutput, + Constrain, NonReducibleUnknown>, + EventObject, + MetaOf + > +> & { + schemas: AgentSchemaPack< + TContextSchema, + TEventSchemas, + TInputSchema, + TOutputSchema, + TMetaSchema + >; + tasks: TTasks; + withTasks( + tasks: AgentTaskInput< + TNextTaskSchemas, + TEventSchemas, + AgentSchemaPack< + TContextSchema, + TEventSchemas, + TInputSchema, + TOutputSchema, + TMetaSchema + > + > + ): SetupAgentResult< + TContextSchema, + TEventSchemas, + TActors & TaskActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay, + TTasks & TaskActors + >; +}; + +/** + * Schema-first `setup(...)` for agent machines. Context, events, machine + * input, machine output, and state/transition meta are all standard + * schemas — no `{} as Type` casts — and are retained on `result.schemas` + * for runtime validation. Registers the well-known `agent.generate` + * and `agent.stream` actors so machines can invoke them with plain + * XState config. + */ +export function setupAgent< + TContextSchema extends StandardSchemaV1>, + TEventSchemas extends Record, + TActors extends { [K in keyof TActors]: AnyActorLogic }, + TInputSchema extends StandardSchemaV1 = StandardSchemaV1, + TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, + TMetaSchema extends StandardSchemaV1 = StandardSchemaV1, + TActions extends Record = {}, + TGuards extends Record = {}, + TDelay extends string = never, +>( + config: SetupAgentBaseConfig< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay + > +): SetupAgentResult< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay, + {} +> { + return createSetupAgent(config, {}); +} + +function createTaskActors< + TEventSchemas extends Record, + TSchemas extends AgentSchemaPack, + TTaskSchemas extends AgentTaskSchemaMap, +>(schemas: TSchemas, tasks: AgentTaskInput): TaskActors { + return Object.fromEntries( + Object.entries(tasks).map(([key, task]) => { + const logic = createTextLogic({ + ...task, + allowedEvents: task.events + ? ({ input }) => + typeof task.events === 'function' + ? task.events({ input, schemas }) + : task.events + : undefined, + } as TextLogicConfig); + + return [ + key, + Object.assign(logic, { + taskKind: task.kind ?? 'generate', + }), + ]; + }) + ) as TaskActors; +} + +function normalizeAgentSchemas< + TContextSchema extends StandardSchemaV1>, + TEventSchemas extends Record, + TInputSchema extends StandardSchemaV1, + TOutputSchema extends StandardSchemaV1, + TMetaSchema extends StandardSchemaV1, +>( + config: + | { + schemas: AgentSchemaPack< + TContextSchema, + TEventSchemas, + TInputSchema, + TOutputSchema, + TMetaSchema + >; + } + | AgentSchemaConfig< + TContextSchema, + TEventSchemas, + TInputSchema, + TOutputSchema, + TMetaSchema + > +): AgentSchemaPack< + TContextSchema, + TEventSchemas, + TInputSchema, + TOutputSchema, + TMetaSchema +> { + return 'schemas' in config + ? config.schemas + : createAgentSchemas(config); +} + +function createSetupAgent< + TContextSchema extends StandardSchemaV1>, + TEventSchemas extends Record, + TActors extends { [K in keyof TActors]: AnyActorLogic }, + TInputSchema extends StandardSchemaV1, + TOutputSchema extends StandardSchemaV1, + TMetaSchema extends StandardSchemaV1, + TActions extends Record, + TGuards extends Record, + TDelay extends string, + TTasks extends { [K in keyof TTasks]: AgentTaskLogic }, +>( + config: SetupAgentBaseConfig< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay + >, + tasks: TTasks +): SetupAgentResult< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay, + TTasks +> { + const schemas = normalizeAgentSchemas(config); + const base = setup< + ContextOf, + EventsOf, + AgentSetupActors, + {}, + TActions, + TGuards, + TDelay, + string, + InferOutput, + Constrain, NonReducibleUnknown>, + EventObject, + MetaOf + >({ + types: {} as { + context: ContextOf; + events: EventsOf; + input: InferOutput; + output: Constrain, NonReducibleUnknown>; + meta: MetaOf; + }, + actors: { + ...config.actors, + [AGENT_GENERATE_SRC]: missingHostActor(AGENT_GENERATE_SRC), + [AGENT_STREAM_SRC]: missingHostActor(AGENT_STREAM_SRC), + } as AgentSetupConfigOptions< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay + >['actors'], + actions: config.actions, + guards: config.guards, + delays: config.delays, + }); + + return Object.assign(base, { + schemas, + tasks, + withTasks( + nextTasks: AgentTaskInput + ) { + const taskActors = createTaskActors(schemas, nextTasks) as TaskActors; + return createSetupAgent({ + ...config, + schemas, + actors: { + ...config.actors, + ...taskActors, + } as TActors & TaskActors, + }, { + ...tasks, + ...taskActors, + } as TTasks & TaskActors); + }, + }) as unknown as SetupAgentResult< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay, + TTasks + >; +} diff --git a/src/stream-snapshot.test.ts b/src/stream-snapshot.test.ts deleted file mode 100644 index ee25527..0000000 --- a/src/stream-snapshot.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { execute, invoke, stream } from './local/index.js'; -import { createAgentMachine } from './index.js'; - -const machine = createAgentMachine({ - id: 'snapshot-machine', - context: () => ({}), - initial: () => ({ - target: 'done', - input: { step: 1 }, - }), - states: { - done: { - type: 'final', - output: () => ({ ok: true }), - }, - }, -}); - -async function collectSnapshots(state = machine.getInitialState()) { - const snaps = []; - for await (const snap of stream(machine, state)) { - snaps.push(snap); - } - - return snaps; -} - -test('stream emits durable snapshots with stable session metadata', async () => { - const snaps = await collectSnapshots(); - - expect(snaps.length).toBeGreaterThanOrEqual(2); - expect(new Set(snaps.map((snap) => snap.sessionId)).size).toBe(1); - expect(new Set(snaps.map((snap) => snap.createdAt)).size).toBe(1); - expect(snaps[0]!.input).toEqual({ done: { step: 1 } }); - expect(snaps[0]).toEqual( - expect.objectContaining({ - sessionId: expect.any(String), - createdAt: expect.any(Number), - value: 'done', - context: {}, - status: 'active', - input: { done: { step: 1 } }, - }) - ); - expect(snaps[snaps.length - 1]).toEqual( - expect.objectContaining({ - sessionId: snaps[0]!.sessionId, - createdAt: snaps[0]!.createdAt, - value: 'done', - context: {}, - status: 'done', - input: { done: { step: 1 } }, - output: { ok: true }, - }) - ); -}); - -test('snapshot roundtrips through resolveState without losing identity', async () => { - const emitted = await collectSnapshots(); - const restored = machine.resolveState(emitted[0]!); - const rerun = await collectSnapshots(restored); - - expect(restored.sessionId).toBe(emitted[0]!.sessionId); - expect(restored.createdAt).toBe(emitted[0]!.createdAt); - expect(restored.input).toEqual(emitted[0]!.input); - expect(rerun[0]!.sessionId).toBe(emitted[0]!.sessionId); - expect(rerun[0]!.createdAt).toBe(emitted[0]!.createdAt); - expect(rerun[0]!.input).toEqual(emitted[0]!.input); -}); - -test('fresh machine executions on the same raw state get distinct session ids', async () => { - const state = machine.getInitialState(); - const firstRun = await collectSnapshots(state); - const secondRun = await collectSnapshots(state); - - expect(firstRun[0]!.sessionId).not.toBe(secondRun[0]!.sessionId); -}); diff --git a/src/streaming.test.ts b/src/streaming.test.ts deleted file mode 100644 index 3a8f2f8..0000000 --- a/src/streaming.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { createMemoryRunStore, restoreSession, startSession, waitForRunDone, waitForRunSnapshot } from './local/index.js'; -import { z } from 'zod'; -import { - createAgentMachine, -} from './index.js'; - -function once( - subscribe: (handler: (event: T) => void) => () => void -) { - return new Promise((resolve) => { - let off = () => {}; - off = subscribe((event) => { - off(); - resolve(event); - }); - }); -} - -test('returns a live run before initial invoke output and emits ephemeral parts', async () => { - const machine = createAgentMachine({ - id: 'streaming-parts', - schemas: { - emitted: { - textPart: z.object({ delta: z.string() }), - }, - }, - context: () => ({ finalText: '' }), - initial: 'writing', - states: { - writing: { - schemas: { output: z.object({ text: z.string() }) }, - invoke: async (_args, enq) => { - enq.emit({ type: 'textPart', delta: 'hel' }); - enq.emit({ type: 'textPart', delta: 'lo' }); - - return { text: 'hello' }; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { finalText: output.text }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ text: context.finalText }), - }, - }, - }); - - const store = createMemoryRunStore(); - const run = await startSession(machine, { store }); - const parts: Array<{ type: string; delta: string }> = []; - const allParts: Array<{ type: string; delta: string }> = []; - const states: string[] = []; - const events: string[] = []; - const done = once(run.onDone.bind(run)); - - const offPart = run.on('textPart', (part) => { - parts.push(part as { type: string; delta: string }); - }); - const offAnyPart = run.onEmitted((part) => { - allParts.push(part); - }); - const offState = run.onSnapshot((snapshot) => { - states.push(snapshot.value); - }); - const offEvent = run.onMachineEvent((event) => { - events.push(event.type); - }); - - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'writing', - status: 'active', - }) - ); - - await done; - - expect(parts).toEqual([ - { type: 'textPart', delta: 'hel' }, - { type: 'textPart', delta: 'lo' }, - ]); - expect(allParts).toEqual([ - { type: 'textPart', delta: 'hel' }, - { type: 'textPart', delta: 'lo' }, - ]); - expect(states.length).toBeGreaterThan(0); - expect(states.every((state) => state === 'done')).toBe(true); - expect(events).toContain('xstate.done.invoke.writing'); - expect(run.getSnapshot().output).toEqual({ text: 'hello' }); - - offPart(); - offAnyPart(); - offState(); - offEvent(); -}); - -test('does not replay prior events to late subscribers', async () => { - const machine = createAgentMachine({ - id: 'late-streaming-subscriber', - schemas: { - emitted: { - textPart: z.object({ delta: z.string() }), - }, - }, - context: () => ({ finalText: '' }), - initial: 'writing', - states: { - writing: { - schemas: { output: z.object({ text: z.string() }) }, - invoke: async (_args, enq) => { - enq.emit({ type: 'textPart', delta: 'hel' }); - enq.emit({ type: 'textPart', delta: 'lo' }); - return { text: 'hello' }; - }, - onDone: ({ output }) => ({ - target: 'done', - context: { finalText: output.text }, - }), - }, - done: { - type: 'final', - output: ({ context }) => ({ text: context.finalText }), - }, - }, - }); - - const run = await startSession(machine, { - store: createMemoryRunStore(), - }); - await once(run.onDone.bind(run)); - - const lateParts: Array<{ type: string; delta: string }> = []; - const replayedStates: string[] = []; - const replayedEvents: string[] = []; - - run.on('textPart', (part) => { - lateParts.push(part); - }); - run.onSnapshot((snapshot) => { - replayedStates.push(snapshot.value); - }); - run.onMachineEvent((event) => { - replayedEvents.push(event.type); - }); - run.onDone(() => { - replayedEvents.push('done'); - }); - - expect(lateParts).toEqual([]); - expect(replayedStates).toEqual([]); - expect(replayedEvents).toEqual([]); -}); - -test('invalid emitted parts are rejected', async () => { - const machine = createAgentMachine({ - id: 'streaming-invalid-parts', - schemas: { - emitted: { - textPart: z.object({ delta: z.string().min(1) }), - }, - }, - context: () => ({ count: 0 }), - initial: 'writing', - states: { - writing: { - invoke: async (_args, enq) => { - enq.emit({ type: 'textPart', delta: '' }); - return { ok: true }; - }, - }, - }, - }); - - const run = await startSession(machine, { - store: createMemoryRunStore(), - }); - await once(run.onError.bind(run)); - - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'writing', - status: 'error', - error: expect.objectContaining({ - message: expect.stringContaining("Invalid emitted part 'textPart'"), - }), - }) - ); -}); - -test('transition handlers can emit live effects without journaling them', async () => { - const machine = createAgentMachine({ - id: 'transition-handler-emits', - schemas: { - emitted: { - textPart: z.object({ delta: z.string() }), - }, - events: { - send: z.object({}), - }, - }, - context: () => ({ sent: false }), - initial: 'ready', - states: { - ready: { - on: { - send: ({ context }, enq) => { - enq.emit({ type: 'textPart', delta: 'sending' }); - - return { - target: 'done', - context: { sent: !context.sent }, - }; - }, - }, - }, - done: { - type: 'final', - output: ({ context }) => context, - }, - }, - }); - - const store = createMemoryRunStore(); - const run = await startSession(machine, { store }); - const parts: string[] = []; - - run.on('textPart', (part) => { - parts.push(part.delta); - }); - - await run.send({ type: 'send' }); - - expect(parts).toEqual(['sending']); - expect(run.getSnapshot()).toEqual( - expect.objectContaining({ - value: 'done', - status: 'done', - context: { sent: true }, - }) - ); - - const journal = await store.loadEvents(run.sessionId); - expect(journal.map((event) => event.type)).toEqual([ - 'xstate.init', - 'send', - ]); -}); diff --git a/src/target-types.assert.ts b/src/target-types.assert.ts deleted file mode 100644 index 11adcf4..0000000 --- a/src/target-types.assert.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { z } from 'zod'; -import { execute, invoke, stream } from './local/index.js'; -import { createAgentMachine } from './machine.js'; - -const machine = createAgentMachine({ - id: 'typed-targets', - context: () => ({ count: 0 }), - initial: 'idle', - states: { - idle: { - on: { - advance: () => ({ - target: 'done', - }), - }, - }, - done: { - type: 'final', - }, - }, - schemas: { - events: { - advance: z.object({ - type: z.literal('advance'), - }), - }, - }, -}); - -machine.transition(machine.getInitialState(), { type: 'advance' }); - -createAgentMachine({ - id: 'typed-target-input', - context: () => ({ count: 0 }), - initial: 'idle', - states: { - idle: { - on: { - advance: () => ({ - target: 'working', - input: { - index: 0, - }, - }), - }, - }, - working: { - schemas: { input: z.object({ - index: z.number(), - }) }, - }, - }, - schemas: { - events: { - advance: z.object({ - type: z.literal('advance'), - }), - }, - }, -}); - -const typedMachine = createAgentMachine({ - id: 'typed-surface', - schemas: { - input: z.object({ - task: z.string(), - }), - events: { - submit: z.object({ - value: z.number(), - }), - }, - output: z.object({ - task: z.string(), - total: z.number(), - }), - }, - context: (input) => ({ - task: input.task, - total: 0, - }), - initial: 'idle', - states: { - idle: { - on: { - submit: ({ event }) => { - event.value satisfies number; - // @ts-expect-error invalid event payload property - event.missing; - return { - target: 'done', - context: { total: event.value }, - }; - }, - }, - }, - done: { - type: 'final', - output: ({ context }) => ({ - task: context.task, - total: context.total, - }), - }, - }, -}); - -typedMachine.getInitialState({ task: 'ship it' }); -// @ts-expect-error missing required input -typedMachine.getInitialState(); -// @ts-expect-error wrong input type -typedMachine.getInitialState({ task: 42 }); - -const typedState = typedMachine.getInitialState({ task: 'infer state values' }); -typedState.value satisfies 'idle' | 'done'; -// @ts-expect-error invalid state literal -typedState.value satisfies 'missing'; - -typedMachine.transition(typedState, { type: 'submit', value: 1 }); -// @ts-expect-error invalid event type -typedMachine.transition(typedState, { type: 'missing' }); -// @ts-expect-error invalid event payload -typedMachine.transition(typedState, { type: 'submit', value: 'nope' }); - -void (async () => { - const result = await execute(typedMachine, - typedMachine.transition(typedState, { type: 'submit', value: 2 }) - ); - - if (result.status === 'done') { - result.output.total satisfies number; - result.output.task satisfies string; - // @ts-expect-error no missing output property - result.output.missing; - } -})(); - -createAgentMachine({ - id: 'missing-required-target-input', - context: () => ({ count: 0 }), - initial: 'idle', - states: { - idle: { - on: { - // @ts-expect-error input should be required when the target has schemas.input - advance: () => ({ - target: 'working', - }), - }, - }, - working: { - schemas: { input: z.object({ - index: z.number(), - }) }, - }, - }, - schemas: { - events: { - advance: z.object({ - type: z.literal('advance'), - }), - }, - }, -}); - -createAgentMachine({ - id: 'invalid-target', - context: () => ({ count: 0 }), - initial: 'idle', - states: { - idle: { - on: { - // @ts-expect-error invalid targets should be rejected at author time - advance: () => ({ - target: 'missing', - }), - }, - }, - done: { - type: 'final', - }, - }, - schemas: { - events: { - advance: z.object({ - type: z.literal('advance'), - }), - }, - }, -}); - -createAgentMachine({ - id: 'unexpected-target-input', - context: () => ({ count: 0 }), - initial: 'idle', - states: { - idle: { - on: { - // @ts-expect-error input should be rejected when the target has no schemas.input - advance: () => ({ - target: 'done', - input: { - anything: true, - }, - }), - }, - }, - done: { - type: 'final', - }, - }, - schemas: { - events: { - advance: z.object({ - type: z.literal('advance'), - }), - }, - }, -}); - -createAgentMachine({ - id: 'invalid-target-input', - context: () => ({ count: 0 }), - initial: 'idle', - states: { - idle: { - on: { - // @ts-expect-error target input should match the target state's input schema - advance: () => ({ - target: 'working', - input: { - wrong: true, - }, - }), - }, - }, - working: { - schemas: { input: z.object({ - index: z.number(), - }) }, - }, - }, - schemas: { - events: { - advance: z.object({ - type: z.literal('advance'), - }), - }, - }, -}); - -createAgentMachine({ - id: 'invalid-target-param-types', - context: () => ({ count: 0 }), - initial: 'idle', - states: { - idle: { - on: { - // @ts-expect-error target input should match the target input field types - advance: () => ({ - target: 'working', - input: { - index: 'hello', - }, - }), - }, - }, - working: { - schemas: { input: z.object({ - index: z.number(), - }) }, - }, - }, - schemas: { - events: { - advance: z.object({ - type: z.literal('advance'), - }), - }, - }, -}); diff --git a/src/types.ts b/src/types.ts index bc31678..80aee45 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,3 @@ -// ─── Standard Schema V1 ─── - export interface StandardSchemaV1 { readonly '~standard': { readonly version: 1; @@ -9,96 +7,52 @@ export interface StandardSchemaV1 { }; } -export type StandardSchemaResult = - | { value: T; issues?: undefined } - | { value?: undefined; issues: ReadonlyArray<{ message: string }> }; - export type InferOutput = T extends StandardSchemaV1 ? O : never; -// ─── Event Helpers ─── - export type EventPayload = T extends Record ? unknown : T; export type EventUnion> = { [K in keyof T & string]: { type: K } & EventPayload>; }[keyof T & string]; -export type EmittedUnion> = EventUnion; - -export type TransitionEvent< - TEvents extends Record, -> = [keyof TEvents & string] extends [never] - ? { type: string; [key: string]: unknown } - : EventUnion; - -export type EmittedPart = { type: string; [key: string]: unknown }; - export type AgentMessage = { role: string; content: string; [key: string]: unknown; }; -export type AgentTools = Record; +export interface AgentToolDescriptor { + description?: string; + inputSchema?: StandardSchemaV1; + outputSchema?: StandardSchemaV1; + execute?: AgentToolExecute; + [key: string]: unknown; +} -export type AgentToolChoice = - | string - | number - | boolean - | null - | readonly unknown[] - | { [key: string]: unknown }; +export type AgentToolExecute = (input?: unknown) => unknown | Promise; -export type AgentResolverSnapshot< - TContext extends Record = Record, -> = Omit< - AgentState, - 'model' | 'prompt' | 'system' | 'tools' | 'toolChoice' ->; +export type AgentTool = AgentToolDescriptor | AgentToolExecute; -export type StateResolverArgs< - TContext extends Record, - TInput = Record, -> = { - snapshot: AgentResolverSnapshot; - context: TContext; - messages: AgentMessage[]; - input: TInput; -}; +export type AgentTools = Record; -export type ResolvableStateValue< - TValue, - TContext extends Record, - TInput = Record, -> = - | TValue - | ((args: StateResolverArgs) => TValue); +export type AgentToolChoice = + | 'auto' + | 'none' + | 'required' + | { type: 'tool'; name: string }; -export interface InvokeEnqueue { - emit(part: EmittedPart): void; +export interface AgentGenerateTextInput { + modelRef?: string; + system?: string; + prompt?: string; + messages: AgentMessage[]; + tools?: AgentTools; + toolChoice?: AgentToolChoice; + outputSchema?: StandardSchemaV1; } -type IsExactlyUnknown = unknown extends T - ? ([T] extends [unknown] ? true : false) - : false; - -// ─── Session Contract ─── - -export type { JournalEvent } from './runtime/events.js'; -export type { JournalEventRecord, PersistedSnapshot, RunStore } from './runtime/store.js'; - -// ─── Adapter ─── - export interface AgentAdapter { - generateText?: (options: { - model?: string; - system?: string; - prompt?: string; - messages: AgentMessage[]; - tools?: AgentTools; - toolChoice?: unknown; - outputSchema?: StandardSchemaV1; - }) => Promise; + generateText?: (options: AgentGenerateTextInput) => Promise; } export interface DecideAdapter { @@ -114,275 +68,14 @@ export interface DecideAdapter { }>; } -// ─── Transition ─── - -export type TransitionResult< - TContext extends Record = Record, - TTarget extends string = string, - TInputByTarget extends Record = {}, -> = - | { - target?: undefined; - context?: Partial; - messages?: AgentMessage[]; - input?: never; - } - | { - [K in TTarget]: { - target: K; - context?: Partial; - messages?: AgentMessage[]; - } & (K extends keyof TInputByTarget - ? IsExactlyUnknown extends true - ? { input?: never } - : { input: TInputByTarget[K] } - : { input?: never }) - }[TTarget]; - -export interface InitialTransitionResult< - TContext extends Record = Record, - TTarget extends string = string, -> { - target: TTarget; - context?: Partial; - messages?: AgentMessage[]; - input?: Record; -} - -// ─── State Config ─── - -export interface StateConfig< - TContext extends Record = Record, - TTarget extends string = string, - TInputByTarget extends Record = {}, -> { - type?: 'final'; - schemas?: { - input?: StandardSchemaV1; - output?: StandardSchemaV1; - }; - invoke?: (args: { - context: TContext; - messages: AgentMessage[]; - input: Record; - signal?: AbortSignal; - }, enq: InvokeEnqueue) => Promise; - onDone?: (args: { output: any; context: TContext; messages: AgentMessage[] }) => TransitionResult; - always?: TransitionResult | ((args: { context: TContext; messages: AgentMessage[]; input: Record }, enq: InvokeEnqueue) => TransitionResult); - on?: Record | ((args: { event: any; context: TContext; messages: AgentMessage[] }, enq: InvokeEnqueue) => TransitionResult)>; - events?: Record; - output?: (args: { context: TContext; messages: AgentMessage[] }) => unknown; - model?: ResolvableStateValue; - adapter?: AgentAdapter; - prompt?: ResolvableStateValue; - system?: ResolvableStateValue; - tools?: ResolvableStateValue; - toolChoice?: ResolvableStateValue; -} - -type OutputForState = TState extends { - output: (...args: any[]) => infer TOutput; -} - ? TOutput - : never; - -export type OutputForStates> = - [OutputForState] extends [never] - ? unknown - : OutputForState; - -// ─── Agent State (POJO) ─── - -export interface AgentState< - TContext extends Record = Record, - TValue extends string = string, - TOutput = unknown, -> { - value: TValue; - context: TContext; - messages: AgentMessage[]; - status: 'active' | 'pending' | 'done' | 'error'; - input: Record>; - sessionId?: string; - createdAt?: number; - output?: TOutput; - error?: unknown; - model?: string; - prompt?: string; - system?: string; - tools?: AgentTools; - toolChoice?: unknown; -} - -// ─── Execute Result ─── - -export type ExecuteResult< - TContext extends Record = Record, - TValue extends string = string, - TEvents extends Record = {}, - TOutput = unknown, -> = - | { status: 'done'; state: AgentState; output: TOutput; context: TContext; messages: AgentMessage[] } - | { status: 'pending'; state: AgentState; value: TValue; events: Record; context: TContext; messages: AgentMessage[] } - | { status: 'error'; state: AgentState; error: unknown }; - -// ─── Snapshot ─── - -export interface AgentSnapshot< - TContext extends Record = Record, - TValue extends string = string, - TOutput = unknown, -> { - value: TValue; - context: TContext; - messages: AgentMessage[]; - status: AgentState['status']; - createdAt: number; - sessionId: string; - input: Record>; - output?: TOutput; - error?: unknown; -} - -// ─── Agent Machine ─── - -export interface AgentMachine< - TInput = unknown, - TContext extends Record = Record, - TEvents extends Record = {}, - TStates extends Record = Record>, - TOutput = OutputForStates, - TEmitted extends Record = {}, -> { - readonly id: string; - /** @internal */ - readonly __config?: unknown; - - getInitialState( - ...args: unknown extends TInput ? [input?: TInput] : [input: TInput] - ): AgentState; - - resolveState( - raw: - | AgentSnapshot - | { - value: string; - context: TContext; - messages?: AgentMessage[]; - input?: Record>; - sessionId?: string; - createdAt?: number; - status?: AgentState['status']; - output?: TOutput; - error?: unknown; - } - ): AgentState; - - transition( - state: AgentState, - event: TransitionEvent - ): AgentState; - - getEvents( - state: - | AgentState - | AgentSnapshot - | (keyof TStates & string) - ): Record; - -} - -export interface AgentRun< - TContext extends Record = Record, - TValue extends string = string, - TEvents extends Record = {}, - TOutput = unknown, - TEmitted extends Record = {}, -> { - readonly sessionId: string; - readonly status: AgentSnapshot['status']; - getSnapshot(): AgentSnapshot; - send(event: TransitionEvent): Promise; - on( - type: TKey, - handler: (event: { type: TKey } & EventPayload>) => void - ): () => void; - onEmitted( - handler: (event: EmittedUnion) => void - ): () => void; - onDone( - handler: (event: { - output: TOutput; - snapshot: AgentSnapshot; - }) => void - ): () => void; - onError( - handler: (event: { - error: unknown; - snapshot: AgentSnapshot; - }) => void - ): () => void; - onSnapshot( - handler: (snapshot: AgentSnapshot) => void - ): () => void; - onMachineEvent( - handler: ( - event: import('./runtime/store.js').JournalEventRecord< - import('./runtime/events.js').JournalEvent - > - ) => void - ): () => void; -} - -export interface SessionOptions< - TInput = unknown, - TSnapshot extends AgentSnapshot = AgentSnapshot, -> { - input?: TInput; - sessionId?: string; - store: import('./runtime/store.js').RunStore; -} - -export interface RestoreSessionOptions< - TSnapshot extends AgentSnapshot = AgentSnapshot, -> { - sessionId: string; - store: import('./runtime/store.js').RunStore; -} - -// ─── Machine Config (internal) ─── - -export interface MachineConfig< - TInput = unknown, - TContext extends Record = Record, - TEvents extends Record = {}, - TStates extends Record = Record>, - TEmitted extends Record = {}, -> { - id: string; - schemas?: { - input?: StandardSchemaV1; - context?: StandardSchemaV1; - events?: TEvents; - emitted?: TEmitted; - output?: StandardSchemaV1; - }; - context: (input: TInput) => TContext; - messages?: AgentMessage[] | ((input: TInput) => AgentMessage[]); - adapter?: AgentAdapter; - externalEvents?: readonly string[]; - initial: - | (keyof TStates & string) - | ((args: { context: TContext }) => { target: keyof TStates & string; input?: Record }); - states: TStates; -} - export type DecideResultFor< TOptions extends Record, > = { [K in keyof TOptions & string]: { choice: K; - data: TOptions[K] extends { schema: StandardSchemaV1 } ? O : Record; + data: TOptions[K] extends { schema: StandardSchemaV1 } + ? O + : Record; reasoning?: string; }; }[keyof TOptions & string]; @@ -413,10 +106,3 @@ export interface ClassifyOptions< examples?: Array<{ input: string; category: keyof TCategories & string }>; reasoning?: boolean; } - -// ─── Trace ─── - -export interface Trace { - state: string; - event: { type: string; timestamp: number; [key: string]: unknown }; -} diff --git a/src/utils.ts b/src/utils.ts index 6984215..e13b150 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,291 +1,38 @@ -import type { - AgentMessage, - AgentState, - AgentToolChoice, - InitialTransitionResult, - MachineConfig, - StandardSchemaResult, - StandardSchemaV1, - TransitionResult, -} from './types.js'; +import type { AgentMessage, StandardSchemaV1 } from './types.js'; -/** - * Validate a value against a Standard Schema synchronously. - */ -export function validateSchemaSync( - schema: StandardSchemaV1, - value: unknown -): T { - const result = schema['~standard'].validate(value); - if (result instanceof Promise) { - throw new Error( - 'Async schema validation is not supported in sync context.' - ); - } - const syncResult = result as StandardSchemaResult; - if (syncResult.issues) { - const messages = syncResult.issues - .map((i: { message: string }) => i.message) - .join(', '); - throw new Error(`Validation failed: ${messages}`); - } - return syncResult.value as T; -} - -/** - * Get the state config for a given state name. - */ -export function resolveStateConfig( - config: MachineConfig, - value: string -): StateConfigAny { - const stateConfig = config.states[value]; - if (!stateConfig) { - throw new Error(`State '${value}' not found`); - } - return stateConfig as StateConfigAny; +export function userMessage(content: string): AgentMessage { + return { role: 'user', content }; } -/** Loose state config for internal runtime use */ -export type StateConfigAny = { - type?: 'final'; - invoke?: ( - args: { - context: Record; - messages: AgentMessage[]; - input: Record; - }, - enq: { emit(part: { type: string; [key: string]: unknown }): void } - ) => Promise; - onDone?: (args: { output: unknown; context: Record; messages: AgentMessage[] }) => TransitionResult; - always?: TransitionResult | ((args: { context: Record; messages: AgentMessage[]; input: Record }, enq: { emit(part: { type: string; [key: string]: unknown }): void }) => TransitionResult); - on?: Record; context: Record; messages: AgentMessage[] }, enq: { emit(part: { type: string; [key: string]: unknown }): void }) => TransitionResult)>; - output?: (args: { context: Record; messages: AgentMessage[] }) => unknown; - schemas?: { - input?: StandardSchemaV1; - output?: StandardSchemaV1; - }; - model?: string | ((args: { - snapshot: AgentState; - context: Record; - messages: AgentMessage[]; - input: Record; - }) => string); - adapter?: { - generateText?: (...args: unknown[]) => Promise; - }; - prompt?: string | ((args: { - snapshot: AgentState; - context: Record; - messages: AgentMessage[]; - input: Record; - }) => string); - system?: string | ((args: { - snapshot: AgentState; - context: Record; - messages: AgentMessage[]; - input: Record; - }) => string); - tools?: Record | ((args: { - snapshot: AgentState; - context: Record; - messages: AgentMessage[]; - input: Record; - }) => Record); - toolChoice?: AgentToolChoice | ((args: { - snapshot: AgentState; - context: Record; - messages: AgentMessage[]; - input: Record; - }) => unknown); - events?: Record; -}; - -/** - * Get the input for the current state. - */ -export function getInput( - value: string, - input: Record> -): Record { - return input[value] ?? {}; -} - -/** - * Resolve an initial transition (string shorthand or function). - */ -export function resolveInitial( - initial: - | string - | ((args: { - context: Record; - input: Record; - }) => InitialTransitionResult), - args: { - context: Record; - input: Record; - } -): InitialTransitionResult { - if (typeof initial === 'string') { - return { target: initial }; - } - return initial(args); +export function assistantMessage(content: string): AgentMessage { + return { role: 'assistant', content }; } -/** - * Apply a transition result to produce a new state. - */ -export function applyTransition( - state: AgentState, - transition: TransitionResult -): AgentState { - let newState = { ...state }; - - if (transition.context) { - newState.context = { ...state.context, ...transition.context }; - } - - if (transition.messages) { - newState.messages = transition.messages; - } - - if (transition.target) { - newState.value = transition.target; - newState.status = 'active'; - - if (transition.input) { - newState.input = { - ...state.input, - [transition.target]: transition.input, - }; - } - } - - return newState; -} - -/** - * Collect available events for a state. - */ -export function getAvailableEvents( - config: MachineConfig, - value: string -): Record { - const events: Record = {}; - - if (config.schemas?.events) { - Object.assign(events, config.schemas.events); - } - - const stateConfig = resolveStateConfig(config, value); - if (stateConfig.events) { - Object.assign(events, stateConfig.events); - } - - if (stateConfig.on) { - const handled = new Set(Object.keys(stateConfig.on)); - const result: Record = {}; - for (const key of handled) { - if (events[key]) { - result[key] = events[key]; - } - } - return result; - } - - return {}; -} - -/** - * Find the event schema for a given event type. - */ -export function findEventSchema( - config: MachineConfig, - value: string, - eventType: string -): StandardSchemaV1 | undefined { - const stateConfig = resolveStateConfig(config, value); - if (stateConfig.events?.[eventType]) { - return stateConfig.events[eventType]; - } - const events = config.schemas?.events as Record | undefined; - return events?.[eventType]; -} - -export function findEmittedSchema( - config: MachineConfig, - eventType: string -): StandardSchemaV1 | undefined { - const emitted = config.schemas?.emitted as - | Record - | undefined; - - return emitted?.[eventType]; -} - -export function formatSchemaIssues( - issues: ReadonlyArray<{ message: string }> -): string { - return issues.map((issue) => issue.message).join(', '); -} - -export function isDoneInvokeEventType( - stateValue: string, - eventType: string -): boolean { - return eventType === `xstate.done.invoke.${stateValue}`; -} - -export function isErrorInvokeEventType( - stateValue: string, - eventType: string -): boolean { - return eventType === `xstate.error.invoke.${stateValue}`; -} - -export function isAlwaysEventType( - stateValue: string, - eventType: string -): boolean { - return eventType === `xstate.always.${stateValue}`; -} - -export function isReservedInternalEventType(eventType: string): boolean { - return ( - eventType === 'xstate.init' - || eventType.startsWith('xstate.done.invoke.') - || eventType.startsWith('xstate.error.invoke.') - || eventType.startsWith('xstate.always.') - ); -} - -export function serializeError(error: unknown): unknown { - if (error instanceof Error) { - return { - name: error.name, - message: error.message, - stack: error.stack, - }; - } - - return error; +export function systemMessage(content: string): AgentMessage { + return { role: 'system', content }; } export function appendMessages( - messages: readonly AgentMessage[], - ...nextMessages: AgentMessage[] + messages: AgentMessage[], + next: AgentMessage | AgentMessage[] ): AgentMessage[] { - return messages.concat(nextMessages); + return messages.concat(Array.isArray(next) ? next : [next]); } -export function userMessage(content: string, extras: Record = {}): AgentMessage { - return { role: 'user', content, ...extras }; -} +export function validateSchemaSync( + schema: StandardSchemaV1, + value: unknown +): T { + const result = schema['~standard'].validate(value); + if (result instanceof Promise) { + throw new Error('Async schema validation is not supported.'); + } -export function assistantMessage(content: string, extras: Record = {}): AgentMessage { - return { role: 'assistant', content, ...extras }; -} + if (result.issues) { + throw new Error( + result.issues.map((issue: { message: string }) => issue.message).join(', ') + ); + } -export function systemMessage(content: string, extras: Record = {}): AgentMessage { - return { role: 'system', content, ...extras }; + return result.value as T; } diff --git a/src/xstate/index.test.ts b/src/xstate/index.test.ts index ee15627..0f7aba5 100644 --- a/src/xstate/index.test.ts +++ b/src/xstate/index.test.ts @@ -1,151 +1,21 @@ import { expect, test } from 'vitest'; -import { z } from 'zod'; -import { createAgentMachine } from '../index.js'; +import { setup } from 'xstate'; import { toXStateMachine, toXStateVisualization } from './index.js'; -test('exports a serializable XState config for visualization', () => { - const machine = createAgentMachine({ - id: 'xstate-export', - schemas: { - events: { - submit: z.object({ - type: z.literal('submit'), - count: z.number(), - }), - }, - }, - context: () => ({ total: 0 }), - initial: 'idle', - states: { - idle: { - on: { - submit: ({ event }) => { - if (event.count > 0) { - return { - target: 'working', - context: { total: event.count }, - input: { index: event.count }, - }; - } - - return { target: 'done' }; - }, - }, - }, - working: { - schemas: { input: z.object({ - index: z.number(), - }), output: z.object({ - ok: z.boolean(), - }) }, - invoke: async () => ({ ok: true }), - onDone: () => ({ - target: 'done', - }), - }, - done: { - type: 'final', - }, - }, +test('returns the serializable XState config for XState setup machines', () => { + const agent = setup({ + types: {} as { context: {} }, }); - - expect(toXStateVisualization(machine)).toEqual({ + const machine = agent.createMachine({ id: 'xstate-export', + context: {}, initial: 'idle', - meta: { - agent: { - format: '@statelyai/agent/xstate-visualization', - runnable: false, - note: 'Generated for visualization. Runtime semantics remain in the agent machine.', - }, - }, states: { - idle: { - on: { - submit: [ - { - target: 'working', - guard: { type: 'event.count > 0' }, - actions: ['assignContext', 'assignInput'], - meta: { - agent: { - event: 'submit', - updates: { - context: true, - input: true, - }, - }, - }, - }, - { - target: 'done', - guard: { type: '!(event.count > 0)' }, - meta: { - agent: { - event: 'submit', - }, - }, - }, - ], - }, - }, - working: { - invoke: { - id: 'invoke.working', - src: 'invoke.working', - onDone: { - target: 'done', - meta: { - agent: { - event: 'done.invoke.working', - }, - }, - }, - }, - meta: { - agent: { - invoke: true, - }, - }, - }, - done: { - type: 'final', - }, + idle: { on: { NEXT: 'done' } }, + done: { type: 'final' }, }, }); - expect(toXStateMachine(machine)).toEqual(toXStateVisualization(machine)); -}); -test('exports always transitions for visualization', () => { - const machine = createAgentMachine({ - id: 'xstate-always', - context: () => ({}), - initial: 'checking', - states: { - checking: { - always: ({ messages }) => ({ - target: 'done', - messages: messages.concat({ role: 'assistant', content: 'ok' }), - }), - }, - done: { - type: 'final', - }, - }, - }); - - expect(toXStateVisualization(machine).states.checking).toEqual({ - always: { - target: 'done', - actions: ['assignMessages'], - meta: { - agent: { - event: '', - updates: { - messages: true, - }, - }, - }, - }, - }); + expect(toXStateVisualization(machine)).toEqual(machine.config); + expect(toXStateMachine(machine)).toEqual(machine.config); }); diff --git a/src/xstate/index.ts b/src/xstate/index.ts index 32ffda8..3020ad0 100644 --- a/src/xstate/index.ts +++ b/src/xstate/index.ts @@ -1,216 +1,9 @@ -import { toGraph, type AgentGraph, type AgentGraphEdge } from '../graph/index.js'; -import type { AgentMachine, MachineConfig, StateConfig } from '../types.js'; +import type { XStateLikeMachine } from '../graph/index.js'; -export interface XStateMachineConfig { - id: string; - initial?: string; - meta: { - agent: { - format: '@statelyai/agent/xstate-visualization'; - runnable: false; - note: string; - }; - }; - states: Record; -} - -export interface XStateStateConfig { - type?: 'final'; - on?: Record; - always?: XStateTransitionConfig | XStateTransitionConfig[]; - invoke?: { - id: string; - src: string; - onDone?: XStateTransitionConfig | XStateTransitionConfig[]; - }; - onDone?: XStateTransitionConfig | XStateTransitionConfig[]; - meta?: { - agent?: { - type?: 'choice'; - invoke?: boolean; - }; - }; -} - -export interface XStateTransitionConfig { - target: string; - guard?: { - type: string; - }; - actions?: string[]; - meta?: { - agent?: { - event?: string; - updates?: { - context?: boolean; - input?: boolean; - messages?: boolean; - }; - }; - }; -} - -type InternalMachine = AgentMachine & { - __config?: MachineConfig; -}; - -/** - * Convert an agent machine to a serializable XState-like machine config for - * visualization. Guards, actions, and invokes are symbolic metadata, so this - * object is not a runnable replacement for the agent machine. - */ -export function toXStateVisualization(machine: AgentMachine): XStateMachineConfig { - const config = (machine as InternalMachine).__config; - if (!config) { - throw new Error('Machine config metadata is unavailable for XState export'); - } - - const graph = toGraph(machine); - const states: Record = {}; - - for (const [stateId, state] of Object.entries(config.states)) { - const stateConfig = state as StateConfig; - const xstateState: XStateStateConfig = {}; - - if (stateConfig.type === 'final') { - xstateState.type = 'final'; - } - - const meta: NonNullable['agent'] = {}; - if (stateConfig.invoke) { - meta.invoke = true; - xstateState.invoke = { - id: `invoke.${stateId}`, - src: `invoke.${stateId}`, - }; - } - - const regularEdges = graph.edges.filter((edge) => - edge.sourceId === stateId - && edge.data.source !== 'invoke.done' - && edge.data.source !== 'always' - ); - - for (const [event, edges] of groupEdgesByEvent(regularEdges)) { - const formatted = formatTransitions(edges); - if (!formatted) { - continue; - } - - xstateState.on ??= {}; - xstateState.on[event] = formatted; - } - - if (stateConfig.always) { - const alwaysEdges = graph.edges.filter((edge) => - edge.sourceId === stateId - && edge.data.source === 'always' - ); - - const formattedAlways = formatTransitions(alwaysEdges); - if (formattedAlways) { - xstateState.always = formattedAlways; - } - } +export type XStateMachineConfig = XStateLikeMachine['config']; - if (stateConfig.onDone) { - const doneEdges = graph.edges.filter((edge) => - edge.sourceId === stateId - && edge.data.source === 'invoke.done' - ); - - const formattedDone = formatTransitions(doneEdges); - if (formattedDone) { - if (xstateState.invoke) { - xstateState.invoke.onDone = formattedDone; - } else { - xstateState.onDone = formattedDone; - } - } - } - - if (Object.keys(meta).length > 0) { - xstateState.meta = { agent: meta }; - } - - states[stateId] = xstateState; - } - - return { - id: machine.id, - ...(typeof graph.initialNodeId === 'string' - ? { initial: graph.initialNodeId } - : {}), - meta: { - agent: { - format: '@statelyai/agent/xstate-visualization', - runnable: false, - note: 'Generated for visualization. Runtime semantics remain in the agent machine.', - }, - }, - states, - }; +export function toXStateVisualization(machine: XStateLikeMachine): XStateMachineConfig { + return machine.config; } -/** - * @deprecated Use `toXStateVisualization(...)` to make the visualization-only - * contract explicit. - */ export const toXStateMachine = toXStateVisualization; - -function groupEdgesByEvent( - edges: AgentGraph['edges'] -): Map { - const grouped = new Map(); - - for (const edge of edges) { - const event = edge.data.event; - if (!event) { - continue; - } - - grouped.set(event, [...(grouped.get(event) ?? []), edge]); - } - - return grouped; -} - -function formatTransitions( - edges: AgentGraphEdge[] -): XStateTransitionConfig | XStateTransitionConfig[] | undefined { - const transitions = edges.map(formatTransition); - - if (transitions.length === 0) { - return undefined; - } - - return transitions.length === 1 ? transitions[0]! : transitions; -} - -function formatTransition(edge: AgentGraphEdge): XStateTransitionConfig { - const actions = [ - ...(edge.data.actions?.context ? ['assignContext'] : []), - ...(edge.data.actions?.input ? ['assignInput'] : []), - ...(edge.data.actions?.messages ? ['assignMessages'] : []), - ]; - - return { - target: edge.targetId, - ...(edge.data.guard ? { guard: edge.data.guard } : {}), - ...(actions.length > 0 ? { actions } : {}), - meta: { - agent: { - event: edge.data.event, - ...(edge.data.actions - ? { - updates: { - ...(edge.data.actions.context ? { context: true } : {}), - ...(edge.data.actions.input ? { input: true } : {}), - ...(edge.data.actions.messages ? { messages: true } : {}), - }, - } - : {}), - }, - }, - }; -} diff --git a/tsdown.config.ts b/tsdown.config.ts index b285901..333bb5f 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -5,7 +5,6 @@ export default defineConfig({ index: 'src/index.ts', 'ai-sdk': 'src/ai-sdk/index.ts', graph: 'src/graph/index.ts', - local: 'src/local/index.ts', xstate: 'src/xstate/index.ts', }, format: ['esm', 'cjs'], From e0c3b6e7949a9464e2ae8f02cfa09b7b04517c49 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 18 Jun 2026 09:48:20 -0700 Subject: [PATCH 38/50] Add agent machine task execution helpers --- .changeset/setup-agent-xstate-cutover.md | 2 +- docs/host-actors.md | 17 +-- examples/setup-agent/hosts/ai-sdk.ts | 21 +-- readme.md | 21 +-- src/examples.test.ts | 4 +- src/index.ts | 4 + src/langgraph-equivalents/raw-xstate.test.ts | 15 +- src/setup-agent.test.ts | 95 ++++++++++++ src/setup-agent.ts | 147 ++++++++++++++++++- 9 files changed, 280 insertions(+), 46 deletions(-) diff --git a/.changeset/setup-agent-xstate-cutover.md b/.changeset/setup-agent-xstate-cutover.md index 4e1aae6..bd882b7 100644 --- a/.changeset/setup-agent-xstate-cutover.md +++ b/.changeset/setup-agent-xstate-cutover.md @@ -8,7 +8,7 @@ Make `setupAgent(...)` the package authoring API for XState-native agent machine - Remove `@statelyai/agent/local`; runtime is now normal XState actors, snapshots, and `machine.provide({ actors })`. - Keep model execution transparent: machines invoke well-known text actors with plain XState `invoke`, while hosts provide Vercel AI SDK, LangChain, Workers AI, or custom implementations. - Add `createAgentSchemas(...)` and `setupAgent(...).withTasks(...)` for schema-first task authoring with typed `invoke.src`, typed invoke input, and typed `onDone.event.output`. -- Add `getAgentEffects(...)`, `doneEvent(...)`, and `transitionResult(...)` for pure XState transition loops where the host/framework owns execution. +- Add `machine.getTasks(...)`, `machine.execute(...)`, `getAgentEffects(...)`, `doneEvent(...)`, and `transitionResult(...)` for pure XState transition loops where the host/framework owns execution. - Add task `events` support so model calls can expose whitelisted legal state events as tools. - Add `parseOutput(...)` for schema-typed model output at assignment boundaries. - Update graph/XState conversion utilities to consume setupAgent/XState machines directly. diff --git a/docs/host-actors.md b/docs/host-actors.md index 0abfe43..8a046ec 100644 --- a/docs/host-actors.md +++ b/docs/host-actors.md @@ -31,7 +31,6 @@ Use named text logic and plain XState `invoke` objects. For maximum framework po ```ts import { createAgentSchemas, - getAgentEffects, setupAgent, transitionResult, } from '@statelyai/agent'; @@ -80,22 +79,20 @@ const machine = agent.createMachine({ let [snapshot, actions] = initialTransition(machine, input); while (snapshot.status !== 'done') { - for (const effect of getAgentEffects(actions, { - snapshot, - schemas: agent.schemas, - actors: { draftText }, - })) { - const output = await generateText({ - ...effect.input, - tools: effect.tools, + for (const task of machine.getTasks(actions, snapshot)) { + const output = await machine.execute(task, { + generateText: (request) => generateText(request), + streamText: (request) => streamText(request), }); - [snapshot, actions] = transitionResult(machine, snapshot, effect, output); + [snapshot, actions] = transitionResult(machine, snapshot, task, output); } } ``` Every agent invoke should have a durable `id`; that ID is used to resume the matching `onDone` transition. +`machine.execute(...)` is convenience only. You can still inspect `task.input`, `task.tools`, and `task.events`, then call any SDK yourself. + ## Allowed Event Tools Use task `events` to expose specific state transitions as tools. `getAgentEffects(...)` validates that those events are legal from the current snapshot and returns event tools separately from the model-call input. diff --git a/examples/setup-agent/hosts/ai-sdk.ts b/examples/setup-agent/hosts/ai-sdk.ts index 805dc60..0e4c3df 100644 --- a/examples/setup-agent/hosts/ai-sdk.ts +++ b/examples/setup-agent/hosts/ai-sdk.ts @@ -21,10 +21,8 @@ import { assign, createActor, initialTransition, toPromise } from 'xstate'; import { z } from 'zod'; import { createAgentSchemas, - getAgentEffects, setupAgent, transitionResult, - type AgentEffect, type AgentTextInput, type TextLogic, } from '../../../src/index.js'; @@ -113,13 +111,6 @@ async function streamWithAiSdk( return await result.text; } -async function executeAgentEffect( - effect: AgentEffect, - options: AiSdkTextHostOptions = {} -) { - return generateWithAiSdk(effect.input, effect.tools, options); -} - export function createAiSdkTextActor( logic: TLogic, options: AiSdkTextHostOptions = {} @@ -214,16 +205,18 @@ export async function runTriagePureTransitionDemo(ticket: string) { let [snapshot, actions] = initialTransition(triageMachine, { ticket }); while (snapshot.status !== 'done') { - const effects = getAgentEffects(actions, { - snapshot, - actors: { triageTicket }, - }); + const effects = triageMachine.getTasks(actions, snapshot); if (effects.length === 0) { throw new Error('Machine is waiting without an agent effect.'); } for (const effect of effects) { - const output = await executeAgentEffect(effect); + const output = await triageMachine.execute(effect, { + generateObject: (request) => + generateWithAiSdk(request, request.tools), + generateText: (request) => + generateWithAiSdk(request, request.tools), + }); [snapshot, actions] = transitionResult( triageMachine, snapshot, diff --git a/readme.md b/readme.md index 83574e3..9348644 100644 --- a/readme.md +++ b/readme.md @@ -23,7 +23,6 @@ Import `createAgentSchemas(...)` and `setupAgent(...)` from `@statelyai/agent`: ```ts import { createAgentSchemas, - getAgentEffects, setupAgent, transitionResult, } from '@statelyai/agent'; @@ -78,23 +77,19 @@ const machine = agent.createMachine({ let [snapshot, actions] = initialTransition(machine, { prompt: 'Why XState?' }); while (snapshot.status !== 'done') { - for (const effect of getAgentEffects(actions, { - snapshot, - schemas: agent.schemas, - actors: { getAnswer }, - })) { - const result = await generateText({ - ...effect.input, - tools: effect.tools, - }); // any SDK/framework - [snapshot, actions] = transitionResult(machine, snapshot, effect, result); + for (const task of machine.getTasks(actions, snapshot)) { + const result = await machine.execute(task, { + generateText: (request) => generateText(request), // any SDK/framework + streamText: (request) => streamText(request), + }); + [snapshot, actions] = transitionResult(machine, snapshot, task, result); } } ``` -This is normal XState underneath: use pure `initialTransition(...)` / `transitionResult(...)`, or use `createActor(...)`, snapshots, persistence, guards, actions, and host-provided actors. `setupAgent(...)` adds schema-derived concrete types and retained schemas; `withTasks(...)` adds reusable typed task construction, strongly typed source names, typed invoke input, typed `event.output`, and `getAgentEffects(...)` extraction. +This is normal XState underneath: use pure `initialTransition(...)` / `transitionResult(...)`, or use `createActor(...)`, snapshots, persistence, guards, actions, and host-provided actors. `setupAgent(...)` adds schema-derived concrete types and retained schemas; `withTasks(...)` adds reusable typed task construction, strongly typed source names, typed invoke input, typed `event.output`, `machine.getTasks(...)`, and `machine.execute(...)`. -When a task declares `events`, `getAgentEffects(...)` returns `event.` tools for those events only if they are currently legal from the snapshot. That lets a model choose legal machine events, such as moves in a game, without exposing every transition. +When a task declares `events`, `machine.getTasks(...)` returns `event.` tools for those events only if they are currently legal from the snapshot. That lets a model choose legal machine events, such as moves in a game, without exposing every transition. ## Examples diff --git a/src/examples.test.ts b/src/examples.test.ts index b4a821a..5c8535e 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -54,13 +54,13 @@ describe('curated XState setup examples', () => { actor.send({ type: 'PROMPT_SUBMITTED', prompt: 'Write a thank you email after the meeting.', - }); + } as never); await waitFor(actor, (snapshot) => snapshot.matches('needsMoreInfo')); actor.send({ type: 'MORE_INFO', details: 'Send it to riley@example.com.', - }); + } as never); await waitFor(actor, (snapshot) => snapshot.matches('reviewing')); expect(actor.getSnapshot().context.draft).toEqual({ diff --git a/src/index.ts b/src/index.ts index c2949a8..f96ac28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,9 +28,13 @@ export type { AgentEffectOptions, AgentEventDescriptor, AgentEffectSource, + AgentMachine, + AgentTask, AgentTextInput, AgentSchemaPack, AgentTaskConfig, + AgentTaskExecutor, + AgentTaskExecutors, AgentTaskKind, AgentTaskLogic, TextLogic, diff --git a/src/langgraph-equivalents/raw-xstate.test.ts b/src/langgraph-equivalents/raw-xstate.test.ts index 83a01cf..9292ffe 100644 --- a/src/langgraph-equivalents/raw-xstate.test.ts +++ b/src/langgraph-equivalents/raw-xstate.test.ts @@ -97,7 +97,9 @@ describe('LangGraph-style workflows authored as raw XState', () => { drafting: { invoke: { src: 'writeDraft', - input: ({ context }) => ({ topic: context.topic }), + input: ({ context }: { context: { topic: string } }) => ({ + topic: context.topic, + }), onDone: { target: 'reviewing', actions: assign({ draft: ({ event }) => event.output }), @@ -320,7 +322,9 @@ describe('LangGraph-style workflows authored as raw XState', () => { drafting: { invoke: { src: 'writeDraft', - input: ({ context }) => ({ topic: context.topic }), + input: ({ context }: { context: { topic: string } }) => ({ + topic: context.topic, + }), onDone: { target: 'reviewing', actions: assign({ draft: ({ event }) => event.output }), @@ -415,11 +419,14 @@ describe('LangGraph-style workflows authored as raw XState', () => { delegating: { invoke: { src: 'child', - input: ({ context }) => ({ topic: context.topic }), + input: ({ context }: { context: { topic: string } }) => ({ + topic: context.topic, + }), onDone: { target: 'done', actions: assign({ - research: ({ event }) => event.output.research, + research: ({ event }) => + (event.output as { research: string }).research, }), }, }, diff --git a/src/setup-agent.test.ts b/src/setup-agent.test.ts index 7b77fff..063d4a5 100644 --- a/src/setup-agent.test.ts +++ b/src/setup-agent.test.ts @@ -10,6 +10,7 @@ import { setupAgent, transitionResult, type AgentTextInput, + type AgentTools, type TextLogicInput, type TextLogicOutput, } from './index.js'; @@ -170,6 +171,100 @@ describe('setupAgent', () => { input: expect.objectContaining({ allowedEvents: ['READY_TO_DRAFT'] }), }) ); + + expect(machine.getTasks(actions)).toEqual([effect]); + }); + + test('agent machines execute generated and streamed tasks with host callbacks', async () => { + const schemas = createAgentSchemas({ + context: z.object({ prompt: z.string(), body: z.string().nullable() }), + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }); + + const agent = setupAgent({ schemas }).withTasks({ + draftEmail: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + }, + streamRevision: { + kind: 'stream', + schemas: { + input: z.object({ body: z.string() }), + output: z.string(), + }, + model: 'test-model', + prompt: ({ input }) => input.body, + }, + }); + + const generateMachine = agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt, body: null }), + initial: 'drafting', + states: { + drafting: { + invoke: { + id: 'draft', + src: 'draftEmail', + input: ({ context }) => ({ prompt: context.prompt }), + }, + }, + }, + }); + const [_generateSnapshot, generateActions] = initialTransition( + generateMachine, + { prompt: 'Draft it.' } + ); + const [generateTask] = generateMachine.getTasks(generateActions); + + await expect( + generateMachine.execute(generateTask!, { + generateObject: async (request: AgentTextInput & { tools: AgentTools }) => { + expect(request).toEqual( + expect.objectContaining({ + model: 'test-model', + prompt: 'Draft it.', + tools: {}, + }) + ); + return { object: { body: 'Generated body.' } }; + }, + generateText: async () => { + throw new Error('generateObject should be preferred for schemas'); + }, + }) + ).resolves.toEqual({ body: 'Generated body.' }); + + const streamMachine = agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt, body: null }), + initial: 'streaming', + states: { + streaming: { + invoke: { + id: 'stream', + src: 'streamRevision', + input: () => ({ body: 'Draft body.' }), + }, + }, + }, + }); + const [_streamSnapshot, streamActions] = initialTransition(streamMachine, { + prompt: 'Revise it.', + }); + const [streamTask] = streamMachine.getTasks(streamActions); + + await expect( + streamMachine.execute(streamTask!, { + streamText: async (request: AgentTextInput & { tools: AgentTools }) => { + expect(request.prompt).toBe('Draft body.'); + return { text: Promise.resolve('Streamed final text.') }; + }, + }) + ).resolves.toBe('Streamed final text.'); }); test('setupAgent preserves typed action guard and delay names', () => { diff --git a/src/setup-agent.ts b/src/setup-agent.ts index faa6d13..2f059d1 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -357,6 +357,9 @@ export interface AgentEffect { events: AgentEventDescriptor[]; } +export type AgentTask = + AgentEffect; + export interface AgentEventDescriptor { type: string; toolName: `${typeof EVENT_TOOL_PREFIX}${string}`; @@ -520,6 +523,94 @@ export function transitionResult( return transition(logic, snapshot, doneEvent(effect, output) as never); } +export type AgentTaskExecutor = ( + request: AgentTextInput & { tools: AgentTools } +) => PromiseLike | unknown; + +export interface AgentTaskExecutors { + generateText?: AgentTaskExecutor; + generateObject?: AgentTaskExecutor; + streamText?: AgentTaskExecutor; +} + +export type AgentMachine = + TMachine & { + provide: (...args: any[]) => AgentMachine; + getTasks( + actions: readonly { type?: string; params?: unknown }[], + snapshot?: AnyMachineSnapshot + ): AgentTask[]; + execute(task: AgentTask, executors: AgentTaskExecutors): Promise; +}; + +async function normalizeTaskExecutionResult(result: unknown): Promise { + const resolved = await result; + + if (!resolved || typeof resolved !== 'object') { + return resolved; + } + + if ('toolResults' in resolved && Array.isArray(resolved.toolResults)) { + const toolOutput = resolved.toolResults.find( + (toolResult) => + toolResult + && typeof toolResult === 'object' + && 'output' in toolResult + )?.output; + + if (toolOutput !== undefined) { + return toolOutput; + } + } + + if ('object' in resolved) { + return await resolved.object; + } + + if ('text' in resolved) { + return await resolved.text; + } + + return resolved; +} + +function createAgentMachine( + machine: TMachine, + options: Pick +): AgentMachine { + return Object.assign(machine, { + getTasks( + actions: readonly { type?: string; params?: unknown }[], + snapshot?: AnyMachineSnapshot + ) { + return getAgentEffects(actions, { + ...options, + snapshot, + }); + }, + async execute(task: AgentTask, executors: AgentTaskExecutors) { + const request = { + ...task.input, + tools: task.tools, + }; + const executor = + task.kind === 'stream' + ? executors.streamText + : task.input.outputSchema && executors.generateObject + ? executors.generateObject + : executors.generateText; + + if (!executor) { + throw new Error( + `No executor provided for ${task.kind === 'stream' ? 'stream' : 'generate'} task '${task.id}'.` + ); + } + + return normalizeTaskExecutionResult(await executor(request)); + }, + }) as AgentMachine; +} + // ─── setupAgent ─── type Constrain = T extends TConstraint ? T : TConstraint; @@ -747,7 +838,7 @@ type SetupAgentBaseConfig< >['delays']; }; -type SetupAgentResult< +type SetupAgentXStateResult< TContextSchema extends StandardSchemaV1>, TEventSchemas extends Record, TActors extends { [K in keyof TActors]: AnyActorLogic }, @@ -757,7 +848,6 @@ type SetupAgentResult< TActions extends Record, TGuards extends Record, TDelay extends string, - TTasks extends { [K in keyof TTasks]: AgentTaskLogic } = {}, > = ReturnType< typeof setup< ContextOf, @@ -773,7 +863,50 @@ type SetupAgentResult< EventObject, MetaOf > +>; + +type SetupAgentResult< + TContextSchema extends StandardSchemaV1>, + TEventSchemas extends Record, + TActors extends { [K in keyof TActors]: AnyActorLogic }, + TInputSchema extends StandardSchemaV1, + TOutputSchema extends StandardSchemaV1, + TMetaSchema extends StandardSchemaV1, + TActions extends Record, + TGuards extends Record, + TDelay extends string, + TTasks extends { [K in keyof TTasks]: AgentTaskLogic } = {}, +> = Omit< + SetupAgentXStateResult< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay + >, + 'createMachine' > & { + createMachine: < + const TConfig extends Parameters< + SetupAgentXStateResult< + TContextSchema, + TEventSchemas, + TActors, + TInputSchema, + TOutputSchema, + TMetaSchema, + TActions, + TGuards, + TDelay + >['createMachine'] + >[0], + >( + config: TConfig + ) => any; schemas: AgentSchemaPack< TContextSchema, TEventSchemas, @@ -993,8 +1126,18 @@ function createSetupAgent< guards: config.guards, delays: config.delays, }); + const createBaseMachine = base.createMachine.bind(base); return Object.assign(base, { + createMachine(machineConfig: Parameters[0]) { + return createAgentMachine(createBaseMachine(machineConfig as never), { + schemas, + actors: { + ...config.actors, + ...tasks, + }, + }); + }, schemas, tasks, withTasks( From f2e5561c4fc497e8827310a87139f68bcdc136b5 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 18 Jun 2026 11:16:44 -0700 Subject: [PATCH 39/50] Use text executors for agent machine tasks --- docs/host-actors.md | 14 +++++++------- examples/setup-agent/hosts/ai-sdk-game.ts | 10 ++++++---- examples/setup-agent/hosts/ai-sdk.ts | 12 ++++++------ src/setup-agent.test.ts | 11 ++++++----- src/setup-agent.ts | 11 ++++++----- 5 files changed, 31 insertions(+), 27 deletions(-) diff --git a/docs/host-actors.md b/docs/host-actors.md index 8a046ec..bf87aec 100644 --- a/docs/host-actors.md +++ b/docs/host-actors.md @@ -125,14 +125,14 @@ When you want XState to execute invokes directly, provide implementations for th ```ts const executableDraftText = agent.tasks.draftText.withExecutor( async ({ request, signal }) => { - const result = await generateObject({ + const result = await generateText({ model: resolveModel(request.model), system: request.system, prompt: request.prompt ?? '', - schema: request.outputSchema as never, + output: Output.object({ schema: request.outputSchema as never }), abortSignal: signal, }); - return result.object; + return result.output; } ); ``` @@ -140,19 +140,19 @@ const executableDraftText = agent.tasks.draftText.withExecutor( For app-level adapters, overriding with `withExecutor(...)` is often cleaner: ```ts -import { generateObject, generateText } from 'ai'; +import { generateText, Output } from 'ai'; const actors = { draftText: agent.tasks.draftText.withExecutor(async ({ request, signal }) => { if (request.outputSchema) { - const result = await generateObject({ + const result = await generateText({ model: resolveModel(request.model), system: request.system, prompt: request.prompt ?? '', - schema: request.outputSchema as never, + output: Output.object({ schema: request.outputSchema as never }), abortSignal: signal, }); - return result.object; + return result.output; } const result = await generateText({ diff --git a/examples/setup-agent/hosts/ai-sdk-game.ts b/examples/setup-agent/hosts/ai-sdk-game.ts index 8420b11..70b9228 100644 --- a/examples/setup-agent/hosts/ai-sdk-game.ts +++ b/examples/setup-agent/hosts/ai-sdk-game.ts @@ -4,7 +4,7 @@ * Run: * OPENAI_API_KEY=... node --import tsx examples/setup-agent/hosts/ai-sdk-game.ts */ -import { generateObject, generateText, stepCountIs, type LanguageModel } from 'ai'; +import { generateText, Output, stepCountIs, type LanguageModel } from 'ai'; import { openai } from '@ai-sdk/openai'; import { initialTransition, transition } from 'xstate'; import { toAiSdkTools } from '../../../src/ai-sdk/index.js'; @@ -52,14 +52,16 @@ async function runGenerateEffect(effect: AgentEffect) { } if (input.outputSchema) { - const { object } = await generateObject({ + const { output } = await generateText({ model, system: input.system, prompt, - schema: input.outputSchema as z.ZodType, + output: Output.object({ + schema: input.outputSchema as z.ZodType, + }), temperature: input.temperature, }); - return { kind: 'output' as const, output: object }; + return { kind: 'output' as const, output }; } const { text } = await generateText({ diff --git a/examples/setup-agent/hosts/ai-sdk.ts b/examples/setup-agent/hosts/ai-sdk.ts index 0e4c3df..baa3346 100644 --- a/examples/setup-agent/hosts/ai-sdk.ts +++ b/examples/setup-agent/hosts/ai-sdk.ts @@ -9,8 +9,8 @@ * Run: OPENAI_API_KEY=... node --import tsx examples/setup-agent/hosts/ai-sdk.ts */ import { - generateObject, generateText as aiGenerateText, + Output, streamText as aiStreamText, type FlexibleSchema, type LanguageModel, @@ -73,11 +73,13 @@ async function generateWithAiSdk( }; if (input.outputSchema) { - const { object } = await generateObject({ + const { output } = await aiGenerateText({ ...common, - schema: input.outputSchema as FlexibleSchema, + output: Output.object({ + schema: input.outputSchema as FlexibleSchema, + }), }); - return object; + return output; } const { text } = await aiGenerateText(common); @@ -212,8 +214,6 @@ export async function runTriagePureTransitionDemo(ticket: string) { for (const effect of effects) { const output = await triageMachine.execute(effect, { - generateObject: (request) => - generateWithAiSdk(request, request.tools), generateText: (request) => generateWithAiSdk(request, request.tools), }); diff --git a/src/setup-agent.test.ts b/src/setup-agent.test.ts index 063d4a5..353a584 100644 --- a/src/setup-agent.test.ts +++ b/src/setup-agent.test.ts @@ -223,18 +223,16 @@ describe('setupAgent', () => { await expect( generateMachine.execute(generateTask!, { - generateObject: async (request: AgentTextInput & { tools: AgentTools }) => { + generateText: async (request: AgentTextInput & { tools: AgentTools }) => { expect(request).toEqual( expect.objectContaining({ model: 'test-model', prompt: 'Draft it.', + outputSchema: agent.tasks.draftEmail.schemas.output, tools: {}, }) ); - return { object: { body: 'Generated body.' } }; - }, - generateText: async () => { - throw new Error('generateObject should be preferred for schemas'); + return { output: { body: 'Generated body.' } }; }, }) ).resolves.toEqual({ body: 'Generated body.' }); @@ -259,6 +257,9 @@ describe('setupAgent', () => { await expect( streamMachine.execute(streamTask!, { + generateText: async () => { + throw new Error('streamText should be used for stream tasks'); + }, streamText: async (request: AgentTextInput & { tools: AgentTools }) => { expect(request.prompt).toBe('Draft body.'); return { text: Promise.resolve('Streamed final text.') }; diff --git a/src/setup-agent.ts b/src/setup-agent.ts index 2f059d1..70e48da 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -528,8 +528,7 @@ export type AgentTaskExecutor = ( ) => PromiseLike | unknown; export interface AgentTaskExecutors { - generateText?: AgentTaskExecutor; - generateObject?: AgentTaskExecutor; + generateText: AgentTaskExecutor; streamText?: AgentTaskExecutor; } @@ -567,6 +566,10 @@ async function normalizeTaskExecutionResult(result: unknown): Promise { return await resolved.object; } + if ('output' in resolved) { + return await resolved.output; + } + if ('text' in resolved) { return await resolved.text; } @@ -596,9 +599,7 @@ function createAgentMachine( const executor = task.kind === 'stream' ? executors.streamText - : task.input.outputSchema && executors.generateObject - ? executors.generateObject - : executors.generateText; + : executors.generateText; if (!executor) { throw new Error( From f33a0c20d5129234a0478ad31af5f60aa1d8cf31 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 18 Jun 2026 11:50:32 -0700 Subject: [PATCH 40/50] Add dinavinter agents equivalence tests --- .../dinavinter-agents.test.ts | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 src/external-equivalents/dinavinter-agents.test.ts diff --git a/src/external-equivalents/dinavinter-agents.test.ts b/src/external-equivalents/dinavinter-agents.test.ts new file mode 100644 index 0000000..53454ae --- /dev/null +++ b/src/external-equivalents/dinavinter-agents.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { + assign, + createActor, + fromCallback, + fromPromise, + initialTransition, + transition, + waitFor, + type EventObject, +} from 'xstate'; +import { + type AgentTask, + assistantMessage, + createAgentSchemas, + setupAgent, + transitionResult, + type AgentTextInput, + type AgentTools, +} from '../index.js'; + +describe('dinavinter/agents-style XState agents', () => { + test('test agent keeps Assistant thread APIs as host actors and streams events into state', async () => { + const calls: unknown[] = []; + const schemas = createAgentSchemas({ + context: z.object({ + request: z.string(), + threadId: z.string().nullable(), + messageId: z.string().nullable(), + chunks: z.array(z.string()), + messages: z.array(z.object({ role: z.string(), content: z.string() })), + }), + input: z.object({ request: z.string() }), + output: z.object({ + threadId: z.string(), + text: z.string(), + }), + events: { + TEXT_DELTA: z.object({ text: z.string() }), + IMAGE_URL: z.object({ url: z.string() }), + STREAM_DONE: z.object({}), + }, + }); + + const agent = setupAgent({ + schemas, + actors: { + createThread: fromPromise( + async ({ input }) => { + calls.push({ actor: 'createThread', request: input.request }); + return 'thread_123'; + } + ), + sendMessage: fromPromise< + string, + { threadId: string; message: string } + >(async ({ input }) => { + calls.push({ actor: 'sendMessage', input }); + return 'message_123'; + }), + streamThread: fromCallback( + ({ input, sendBack }) => { + calls.push({ actor: 'streamThread', input }); + queueMicrotask(() => { + sendBack({ type: 'TEXT_DELTA', text: 'using ' }); + sendBack({ type: 'TEXT_DELTA', text: 'setupAgent' }); + sendBack({ type: 'IMAGE_URL', url: 'https://example.com/test.png' }); + sendBack({ type: 'STREAM_DONE' }); + }); + } + ), + }, + }); + + const machine = agent.createMachine({ + id: 'dinavinter-test-agent', + context: ({ input }) => ({ + request: input.request, + threadId: null, + messageId: null, + chunks: [], + messages: [], + }), + initial: 'creatingThread', + states: { + creatingThread: { + invoke: { + id: 'createThread', + src: 'createThread', + input: ({ context }: { context: { request: string } }) => ({ + request: context.request, + }), + onDone: { + target: 'sendingMessage', + actions: assign({ + threadId: ({ event }) => event.output as string, + }), + }, + }, + }, + sendingMessage: { + invoke: { + id: 'sendMessage', + src: 'sendMessage', + input: ({ + context, + }: { + context: { threadId: string | null; request: string }; + }) => ({ + threadId: context.threadId!, + message: context.request, + }), + onDone: { + target: 'streaming', + actions: assign({ + messageId: ({ event }) => event.output as string, + }), + }, + }, + }, + streaming: { + invoke: { + id: 'streamThread', + src: 'streamThread', + input: ({ context }: { context: { threadId: string | null } }) => ({ + threadId: context.threadId!, + }), + }, + on: { + TEXT_DELTA: { + actions: assign({ + chunks: ({ context, event }) => [...context.chunks, event.text], + }), + }, + IMAGE_URL: { + actions: assign({ + messages: ({ context, event }) => [ + ...context.messages, + assistantMessage(event.url), + ], + }), + }, + STREAM_DONE: { target: 'done' }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + threadId: context.threadId ?? '', + text: context.chunks.join(''), + }), + }, + }, + }); + + const actor = createActor(machine, { + input: { request: 'Generate an API test.' }, + }); + actor.start(); + await waitFor(actor, (snapshot) => snapshot.status === 'done'); + + expect(actor.getSnapshot().output).toEqual({ + threadId: 'thread_123', + text: 'using setupAgent', + }); + expect(actor.getSnapshot().context.messages).toEqual([ + { role: 'assistant', content: 'https://example.com/test.png' }, + ]); + expect(calls).toEqual([ + { actor: 'createThread', request: 'Generate an API test.' }, + { + actor: 'sendMessage', + input: { + threadId: 'thread_123', + message: 'Generate an API test.', + }, + }, + { actor: 'streamThread', input: { threadId: 'thread_123' } }, + ]); + }); + + test('screen-set builder maps streamed object UI drafts to structured task output', async () => { + const fieldSchema = z.object({ + type: z.enum(['text', 'email', 'password', 'submit']), + name: z.string(), + label: z.string(), + }); + const screenDraftSchema = z.object({ + title: z.string(), + fields: z.array(fieldSchema), + }); + const schemas = createAgentSchemas({ + context: z.object({ + request: z.string(), + draft: screenDraftSchema.nullable(), + }), + input: z.object({ request: z.string() }), + output: screenDraftSchema, + }); + const agent = setupAgent({ schemas }).withTasks({ + draftScreen: { + schemas: { + input: z.object({ request: z.string() }), + output: screenDraftSchema, + }, + model: 'openai/gpt-5.4-nano', + system: 'Create a form screen draft from the user request.', + prompt: ({ input }) => input.request, + }, + }); + const machine = agent.createMachine({ + context: ({ input }) => ({ request: input.request, draft: null }), + initial: 'drafting', + states: { + drafting: { + invoke: { + id: 'draftScreen', + src: 'draftScreen', + input: ({ context }) => ({ request: context.request }), + onDone: { + target: 'done', + actions: assign({ draft: ({ event }) => event.output }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => + context.draft ?? { title: '', fields: [] }, + }, + }, + }); + + let [snapshot, actions] = initialTransition(machine, { + request: 'Build a signup wizard.', + }); + const [task] = machine.getTasks(actions, snapshot); + + const output = await machine.execute(task!, { + generateText: async (request: AgentTextInput & { tools: AgentTools }) => { + expect(request.outputSchema).toBe(agent.tasks.draftScreen.schemas.output); + expect(request.prompt).toBe('Build a signup wizard.'); + return { + output: { + title: 'Signup', + fields: [ + { type: 'email', name: 'email', label: 'Email' }, + { type: 'password', name: 'password', label: 'Password' }, + { type: 'submit', name: 'submit', label: 'Create account' }, + ], + }, + }; + }, + }); + + [snapshot, actions] = transitionResult(machine, snapshot, task!, output); + + expect(machine.getTasks(actions, snapshot)).toEqual([]); + expect(snapshot.output).toEqual({ + title: 'Signup', + fields: [ + { type: 'email', name: 'email', label: 'Email' }, + { type: 'password', name: 'password', label: 'Password' }, + { type: 'submit', name: 'submit', label: 'Create account' }, + ], + }); + }); + + test('parallel agent runs independent model tasks as explicit XState invokes', async () => { + const resultSchema = z.object({ + thought: z.string(), + doodleQuery: z.string(), + }); + const schemas = createAgentSchemas({ + context: z.object({ + topic: z.string(), + thought: z.string().nullable(), + doodleQuery: z.string().nullable(), + }), + input: z.object({ topic: z.string() }), + output: resultSchema, + }); + const agent = setupAgent({ schemas }).withTasks({ + think: { + kind: 'stream', + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'openai/gpt-5.4-nano', + prompt: ({ input }) => `Think about ${input.topic}.`, + }, + findDoodle: { + schemas: { + input: z.object({ topic: z.string() }), + output: z.object({ query: z.string() }), + }, + model: 'openai/gpt-5.4-nano', + prompt: ({ input }) => `Find a doodle for ${input.topic}.`, + }, + }); + const machine = agent.createMachine({ + context: ({ input }) => ({ + topic: input.topic, + thought: null, + doodleQuery: null, + }), + type: 'parallel', + states: { + thinking: { + initial: 'active', + states: { + active: { + invoke: { + id: 'think', + src: 'think', + input: ({ context }) => ({ topic: context.topic }), + onDone: { + target: 'done', + actions: assign({ thought: ({ event }) => event.output }), + }, + }, + }, + done: { type: 'final' }, + }, + }, + doodling: { + initial: 'active', + states: { + active: { + invoke: { + id: 'findDoodle', + src: 'findDoodle', + input: ({ context }) => ({ topic: context.topic }), + onDone: { + target: 'done', + actions: assign({ + doodleQuery: ({ event }) => event.output.query, + }), + }, + }, + }, + done: { type: 'final' }, + }, + }, + }, + output: ({ context }) => ({ + thought: context.thought ?? '', + doodleQuery: context.doodleQuery ?? '', + }), + }); + + let [snapshot, actions] = initialTransition(machine, { topic: 'XState' }); + const tasks = machine.getTasks(actions, snapshot); + + expect(tasks.map((task: AgentTask) => [task.id, task.kind])).toEqual([ + ['think', 'stream'], + ['findDoodle', 'generate'], + ]); + + for (const task of tasks) { + const output = await machine.execute(task, { + generateText: async () => ({ output: { query: 'statechart sketch' } }), + streamText: async () => ({ text: 'State machines make flow visible.' }), + }); + [snapshot, actions] = transitionResult(machine, snapshot, task, output); + } + + expect(snapshot.status).toBe('done'); + expect(snapshot.output).toEqual({ + thought: 'State machines make flow visible.', + doodleQuery: 'statechart sketch', + }); + }); +}); From 3e747f69ac0c1130772466becd9d830f76265457 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 18 Jun 2026 12:10:53 -0700 Subject: [PATCH 41/50] Add appendMessages action helper --- docs/langgraph-parity.md | 2 +- examples/setup-agent/email-drafter.ts | 57 ++++++++++++++++----------- src/index.ts | 3 +- src/setup-agent.test.ts | 39 ++++++++++++++++++ src/setup-agent.ts | 42 ++++++++++++++++---- src/utils.ts | 7 ---- 6 files changed, 110 insertions(+), 40 deletions(-) diff --git a/docs/langgraph-parity.md b/docs/langgraph-parity.md index 3e2ed91..8d31904 100644 --- a/docs/langgraph-parity.md +++ b/docs/langgraph-parity.md @@ -63,7 +63,7 @@ Remaining gaps are tracked in [`langgraph-gaps.md`](/Users/davidkpiano/Code/agen | Multi-agent handoffs | Covered | Expressed as supervisor routing to typed child actors; see [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | | SQL/tool-heavy agent workflow | Covered | Query generation, DB execution, and answer synthesis are separate typed states in [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | | ReAct-style agent | Covered | Expressed as explicit observe/think/act states or typed tool actor loops | -| Message-centric chatbot state | Covered | `messagesSchema`, `addMessages(...)`, and plain XState context in [`src/setup-agent.ts`](/Users/davidkpiano/Code/agent/src/setup-agent.ts) | +| Message-centric chatbot state | Covered | `messagesSchema`, `appendMessages(...)`, and plain XState context in [`src/setup-agent.ts`](/Users/davidkpiano/Code/agent/src/setup-agent.ts) | | Retrieval-augmented generation | Covered | Retrieval is a typed host actor; generation is named text logic invoked as `src: 'answerQuestion'` | | HTTP / framework transport | Adapter example | Host XState actors behind HTTP, WebSocket, Cloudflare Agents, or any framework runtime | | Graph export / visualization support | Covered | Authored machines are normal XState machines and can use the XState/Stately visualization path directly | diff --git a/examples/setup-agent/email-drafter.ts b/examples/setup-agent/email-drafter.ts index c88d9a8..d7fa4eb 100644 --- a/examples/setup-agent/email-drafter.ts +++ b/examples/setup-agent/email-drafter.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; import { assign, fromPromise } from 'xstate'; import { - addMessages, type AgentMessage, assistantMessage, setupAgent, @@ -164,13 +163,15 @@ export const emailDrafter = agent.createMachine({ on: { PROMPT_SUBMITTED: { target: 'evaluating', - actions: assign({ - prompt: ({ event }) => event.prompt, - assessment: null, - draft: null, - draftAnyway: false, - messages: addMessages(({ event }) => userMessage(event.prompt)), - }), + actions: [ + assign({ + prompt: ({ event }) => event.prompt, + assessment: null, + draft: null, + draftAnyway: false, + }), + agent.appendMessages(({ event }) => userMessage(event.prompt)), + ], }, }, }, @@ -217,10 +218,13 @@ export const emailDrafter = agent.createMachine({ on: { MORE_INFO: { target: 'evaluating', - actions: assign({ - prompt: ({ context, event }) => `${context.prompt}\n\n${event.details}`, - messages: addMessages(({ event }) => userMessage(event.details)), - }), + actions: [ + assign({ + prompt: ({ context, event }) => + `${context.prompt}\n\n${event.details}`, + }), + agent.appendMessages(({ event }) => userMessage(event.details)), + ], }, DRAFT_ANYWAY: { target: 'drafting', @@ -240,15 +244,17 @@ export const emailDrafter = agent.createMachine({ }), onDone: { target: 'reviewing', - actions: assign({ - draft: ({ event }) => event.output, - messages: addMessages(({ event }) => { + actions: [ + assign({ + draft: ({ event }) => event.output, + }), + agent.appendMessages(({ event }) => { const draft = event.output; return assistantMessage( `To: ${draft.to}\nSubject: ${draft.subject}\n\n${draft.body}` ); }), - }), + ], }, onError: { target: 'failed' }, }, @@ -272,14 +278,16 @@ export const emailDrafter = agent.createMachine({ on: { REQUEST_CHANGES: { target: 'drafting', - actions: assign({ - prompt: ({ context, event }) => - `${context.prompt}\n\nRevision request: ${event.changes}`, - draftAnyway: true, - messages: addMessages(({ event }) => + actions: [ + assign({ + prompt: ({ context, event }) => + `${context.prompt}\n\nRevision request: ${event.changes}`, + draftAnyway: true, + }), + agent.appendMessages(({ event }) => userMessage(`Revision request: ${event.changes}`) ), - }), + ], }, SEND: { target: 'sending' }, }, @@ -412,7 +420,10 @@ agent.createMachine({ input: ({ context }) => ({ prompt: context.prompt }), onDone: { actions: assign({ - messages: addMessages(({ event }) => assistantMessage(event.output)), + messages: ({ context, event }) => [ + ...context.messages, + assistantMessage(event.output), + ], }), }, }, diff --git a/src/index.ts b/src/index.ts index f96ac28..e9daee4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { - addMessages, + appendMessages, createAgentSchemas, createTextLogic, doneEvent, @@ -17,7 +17,6 @@ export { decide, decideResultSchema, requireAdapter } from './decide.js'; export { classify, classifyResultSchema } from './classify.js'; export { createAdapter } from './adapter.js'; export { - appendMessages, assistantMessage, systemMessage, userMessage, diff --git a/src/setup-agent.test.ts b/src/setup-agent.test.ts index 353a584..19ec20a 100644 --- a/src/setup-agent.test.ts +++ b/src/setup-agent.test.ts @@ -7,8 +7,10 @@ import { getAvailableEvents, getAgentEffects, getEventTools, + messagesSchema, setupAgent, transitionResult, + userMessage, type AgentTextInput, type AgentTools, type TextLogicInput, @@ -348,6 +350,43 @@ describe('setupAgent', () => { }); + test('appendMessages creates a typed action for message context', async () => { + const schemas = createAgentSchemas({ + context: z.object({ + messages: messagesSchema, + }), + input: z.object({}), + events: { + USER_REPLIED: z.object({ text: z.string() }), + }, + }); + const agent = setupAgent({ schemas }); + const machine = agent.createMachine({ + context: { messages: [] }, + initial: 'waiting', + states: { + waiting: { + on: { + USER_REPLIED: { + actions: agent.appendMessages(({ event }) => { + const text: string = event.text; + return userMessage(text); + }), + }, + }, + }, + }, + }); + + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'USER_REPLIED', text: 'hello' } as never); + + expect(actor.getSnapshot().context.messages).toEqual([ + { role: 'user', content: 'hello' }, + ]); + }); + test('authors named text logic with typed input and output', () => { const getSummary = createTextLogic({ schemas: { diff --git a/src/setup-agent.ts b/src/setup-agent.ts index 70e48da..0a83be8 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -3,6 +3,7 @@ import { getNextTransitions, setup, transition, + assign, type AnyActorLogic, type AnyMachineSnapshot, type EventObject, @@ -84,22 +85,18 @@ function missingHostActor(src: string): PromiseActorLogic { // ─── Message helpers ─── // // Messages are plain context state: declare a `messages` field in the -// context schema and update it with `assign`. `addMessages` is a property -// assigner for that idiom — it appends instead of replacing: +// context schema and update it with `appendMessages(...)`: // -// actions: assign({ -// messages: addMessages(({ event }) => userMessage(event.prompt)), -// }) +// actions: appendMessages(({ event }) => userMessage(event.prompt)) export { - appendMessages, assistantMessage, systemMessage, userMessage, validateSchemaSync, } from './utils.js'; -export function addMessages< +function addMessages< TContext extends { messages: AgentMessage[] }, TEvent extends EventObject, >( @@ -118,6 +115,20 @@ export function addMessages< }; } +export function appendMessages< + TContext extends { messages: AgentMessage[] }, + TEvent extends EventObject, +>( + resolve: + | AgentMessage + | AgentMessage[] + | ((args: { context: TContext; event: TEvent }) => AgentMessage | AgentMessage[]), +) { + return assign({ + messages: addMessages(resolve), + }) as never; +} + /** Standard schema for an `AgentMessage[]` context field. */ export const messagesSchema: StandardSchemaV1 = { '~standard': { @@ -916,6 +927,20 @@ type SetupAgentResult< TMetaSchema >; tasks: TTasks; + appendMessages( + resolve: + | AgentMessage + | AgentMessage[] + | ((args: { + context: ContextOf & { messages: AgentMessage[] }; + event: any; + }) => AgentMessage | AgentMessage[]) + ): ReturnType< + typeof appendMessages< + ContextOf & { messages: AgentMessage[] }, + EventsOf + > + >; withTasks( tasks: AgentTaskInput< TNextTaskSchemas, @@ -1141,6 +1166,9 @@ function createSetupAgent< }, schemas, tasks, + appendMessages(resolve: Parameters[0]) { + return appendMessages(resolve as never); + }, withTasks( nextTasks: AgentTaskInput ) { diff --git a/src/utils.ts b/src/utils.ts index e13b150..7193414 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,13 +12,6 @@ export function systemMessage(content: string): AgentMessage { return { role: 'system', content }; } -export function appendMessages( - messages: AgentMessage[], - next: AgentMessage | AgentMessage[] -): AgentMessage[] { - return messages.concat(Array.isArray(next) ? next : [next]); -} - export function validateSchemaSync( schema: StandardSchemaV1, value: unknown From efd087d4e6f7adeaf1a85018e9e009f868b96569 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 18 Jun 2026 12:37:04 -0700 Subject: [PATCH 42/50] Harden setupAgent task authoring --- docs/host-actors.md | 23 +- examples_old/chatbot-alt.ts | 57 - examples_old/chatbot.ts | 71 - examples_old/cot.ts | 89 - examples_old/email.ts | 118 -- examples_old/email2.ts | 59 - examples_old/example.ts | 81 - examples_old/executor.ts | 66 - examples_old/goal.ts | 94 - examples_old/helpers/helpers.ts | 29 - examples_old/helpers/loader.ts | 32 - examples_old/helpers/runner.ts | 27 - examples_old/hono/.gitignore | 34 - examples_old/hono/README.md | 21 - examples_old/hono/package.json | 23 - examples_old/hono/pnpm-lock.yaml | 1643 ------------------ examples_old/hono/public/.assetsignore | 1 - examples_old/hono/public/favicon.ico | Bin 15406 -> 0 bytes examples_old/hono/src/agent.ts | 90 - examples_old/hono/src/db.ts | 76 - examples_old/hono/src/events.ts | 32 - examples_old/hono/src/index.ts | 362 ---- examples_old/hono/src/machine.ts | 74 - examples_old/hono/src/style.css | 3 - examples_old/hono/src/utils.ts | 29 - examples_old/hono/tsconfig.json | 15 - examples_old/hono/vite.config.ts | 6 - examples_old/hono/wrangler.jsonc | 6 - examples_old/joke.ts | 227 --- examples_old/multi.ts | 103 -- examples_old/newspaper.ts | 324 ---- examples_old/number.ts | 102 -- examples_old/raffle.ts | 104 -- examples_old/sandbox.ts | 28 - examples_old/simple.ts | 39 - examples_old/support.ts | 147 -- examples_old/ticTacToe.ts | 224 --- examples_old/todo.ts | 137 -- examples_old/tutor.ts | 100 -- examples_old/verify.ts | 120 -- examples_old/weather.ts | 178 -- examples_old/wiki.ts | 42 - examples_old/word.ts | 171 -- readme.md | 15 +- scripts/agent-convert.ts | 6 +- src/burr-equivalents/raw-xstate.test.ts | 222 +-- src/crewai-equivalents/raw-xstate.test.ts | 72 +- src/graph/index.test.ts | 68 +- src/graph/index.ts | 144 +- src/index.ts | 1 + src/langgraph-equivalents/raw-xstate.test.ts | 319 ++-- src/setup-agent.test.ts | 197 ++- src/setup-agent.ts | 176 +- 53 files changed, 764 insertions(+), 5663 deletions(-) delete mode 100644 examples_old/chatbot-alt.ts delete mode 100644 examples_old/chatbot.ts delete mode 100644 examples_old/cot.ts delete mode 100644 examples_old/email.ts delete mode 100644 examples_old/email2.ts delete mode 100644 examples_old/example.ts delete mode 100644 examples_old/executor.ts delete mode 100644 examples_old/goal.ts delete mode 100644 examples_old/helpers/helpers.ts delete mode 100644 examples_old/helpers/loader.ts delete mode 100644 examples_old/helpers/runner.ts delete mode 100644 examples_old/hono/.gitignore delete mode 100644 examples_old/hono/README.md delete mode 100644 examples_old/hono/package.json delete mode 100644 examples_old/hono/pnpm-lock.yaml delete mode 100644 examples_old/hono/public/.assetsignore delete mode 100644 examples_old/hono/public/favicon.ico delete mode 100644 examples_old/hono/src/agent.ts delete mode 100644 examples_old/hono/src/db.ts delete mode 100644 examples_old/hono/src/events.ts delete mode 100644 examples_old/hono/src/index.ts delete mode 100644 examples_old/hono/src/machine.ts delete mode 100644 examples_old/hono/src/style.css delete mode 100644 examples_old/hono/src/utils.ts delete mode 100644 examples_old/hono/tsconfig.json delete mode 100644 examples_old/hono/vite.config.ts delete mode 100644 examples_old/hono/wrangler.jsonc delete mode 100644 examples_old/joke.ts delete mode 100644 examples_old/multi.ts delete mode 100644 examples_old/newspaper.ts delete mode 100644 examples_old/number.ts delete mode 100644 examples_old/raffle.ts delete mode 100644 examples_old/sandbox.ts delete mode 100644 examples_old/simple.ts delete mode 100644 examples_old/support.ts delete mode 100644 examples_old/ticTacToe.ts delete mode 100644 examples_old/todo.ts delete mode 100644 examples_old/tutor.ts delete mode 100644 examples_old/verify.ts delete mode 100644 examples_old/weather.ts delete mode 100644 examples_old/wiki.ts delete mode 100644 examples_old/word.ts diff --git a/docs/host-actors.md b/docs/host-actors.md index bf87aec..a729367 100644 --- a/docs/host-actors.md +++ b/docs/host-actors.md @@ -32,9 +32,8 @@ Use named text logic and plain XState `invoke` objects. For maximum framework po import { createAgentSchemas, setupAgent, - transitionResult, } from '@statelyai/agent'; -import { assign, initialTransition } from 'xstate'; +import { assign } from 'xstate'; const schemas = createAgentSchemas({ context: contextSchema, @@ -76,15 +75,15 @@ const machine = agent.createMachine({ }, }); -let [snapshot, actions] = initialTransition(machine, input); +let step = machine.initial(input); -while (snapshot.status !== 'done') { - for (const task of machine.getTasks(actions, snapshot)) { +while (!step.done) { + for (const task of step.tasks) { const output = await machine.execute(task, { generateText: (request) => generateText(request), streamText: (request) => streamText(request), }); - [snapshot, actions] = transitionResult(machine, snapshot, task, output); + step = machine.resolve(step, task, output); } } ``` @@ -93,6 +92,14 @@ Every agent invoke should have a durable `id`; that ID is used to resume the mat `machine.execute(...)` is convenience only. You can still inspect `task.input`, `task.tools`, and `task.events`, then call any SDK yourself. +For external events, advance the same step object: + +```ts +step = machine.transition(step, { type: 'REVISE', prompt: nextPrompt }); +``` + +Use `initialTransition(...)`, `transition(...)`, and `transitionResult(...)` directly when a host wants to own the full XState action list instead of the `step.tasks` abstraction. + ## Allowed Event Tools Use task `events` to expose specific state transitions as tools. `getAgentEffects(...)` validates that those events are legal from the current snapshot and returns event tools separately from the model-call input. @@ -200,9 +207,9 @@ Streaming chunks should stay in the host side channel: HTTP stream, WebSocket, A The same task logic can be executed with `generateText(...)` or `streamText(...)`; the host decides. -## Low-Level Built-Ins +## Low-Level Primitive -`agent.generate`, `agent.stream`, and `createTextLogic(...)` still exist as low-level escape hatches. Prefer `setupAgent(...).withTasks(...)` for new authoring because it gives reusable request construction, typed source names, typed invoke input, typed `event.output`, and schema-typed machine event tools. +`createTextLogic(...)` exists as a low-level primitive. Prefer `setupAgent(...).withTasks(...)` for new authoring because it gives reusable request construction, typed source names, typed invoke input, typed `event.output`, and schema-typed machine event tools. ## Why This Shape diff --git a/examples_old/chatbot-alt.ts b/examples_old/chatbot-alt.ts deleted file mode 100644 index 6ed04c8..0000000 --- a/examples_old/chatbot-alt.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { z } from 'zod'; -import { createAgent } from '../src'; -import { openai } from '@ai-sdk/openai'; -import { getFromTerminal } from './helpers/helpers'; - -const agent = createAgent({ - name: 'chatbot', - model: openai('gpt-4-turbo'), - events: { - 'agent.respond': z.object({ - response: z.string().describe('The response from the agent'), - }), - 'agent.endConversation': z.object({}).describe('Stop the conversation'), - }, - context: { - userMessage: z.string(), - }, -}); - -async function main() { - let status = 'listening'; - let userMessage = ''; - - while (status !== 'finished') { - switch (status) { - case 'listening': - userMessage = await getFromTerminal('User:'); - status = 'responding'; - break; - - case 'responding': - const decision = await agent.decide({ - messages: agent.getMessages(), - goal: 'Respond to the user, unless they want to end the conversation.', - state: { - value: status, - context: { - userMessage: 'User says: ' + userMessage, - }, - }, - }); - - if (decision?.nextEvent?.type === 'agent.respond') { - console.log(`Agent: ${decision.nextEvent.response}`); - status = 'listening'; - } else if (decision?.nextEvent?.type === 'agent.endConversation') { - status = 'finished'; - } - break; - } - } - - console.log('End of conversation.'); - process.exit(); -} - -main().catch(console.error); diff --git a/examples_old/chatbot.ts b/examples_old/chatbot.ts deleted file mode 100644 index 122f664..0000000 --- a/examples_old/chatbot.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from 'zod'; -import { createAgent, fromDecision } from '../src'; -import { openai } from '@ai-sdk/openai'; -import { assign, createActor, log, setup } from 'xstate'; -import { fromTerminal } from './helpers/helpers'; - -const agent = createAgent({ - name: 'chatbot', - model: openai('gpt-4-turbo'), - events: { - 'agent.respond': z.object({ - response: z.string().describe('The response from the agent'), - }), - 'agent.endConversation': z.object({}).describe('Stop the conversation'), - }, - context: { - userMessage: z.string(), - }, -}); - -const machine = setup({ - types: agent.types, - actors: { agent: fromDecision(agent), getFromTerminal: fromTerminal }, -}).createMachine({ - initial: 'listening', - context: { - userMessage: '', - }, - states: { - listening: { - invoke: { - src: 'getFromTerminal', - input: 'User:', - onDone: { - actions: assign({ - userMessage: ({ event }) => event.output, - }), - target: 'responding', - }, - }, - }, - responding: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context: { - userMessage: 'User says: ' + context.userMessage, - }, - messages: agent.getMessages(), - goal: 'Respond to the user, unless they want to end the conversation.', - }), - }, - on: { - 'agent.respond': { - actions: log(({ event }) => `Agent: ${event.response}`), - target: 'listening', - }, - 'agent.endConversation': 'finished', - }, - }, - finished: { - type: 'final', - }, - }, - exit: () => { - console.log('End of conversation.'); - process.exit(); - }, -}); - -createActor(machine).start(); diff --git a/examples_old/cot.ts b/examples_old/cot.ts deleted file mode 100644 index a48707b..0000000 --- a/examples_old/cot.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { z } from 'zod'; -import { createAgent, fromDecision } from '../src'; -import { openai } from '@ai-sdk/openai'; -import { assign, createActor, log, setup } from 'xstate'; -import { fromTerminal } from './helpers/helpers'; - -const agent = createAgent({ - name: 'chain-of-thought', - model: openai('gpt-4o'), - events: { - 'agent.think': z.object({ - thought: z - .string() - .describe('The thought process to answering the question'), - }), - 'agent.answer': z.object({ - answer: z.string().describe('The answer to the question'), - }), - }, - context: { - question: z.string().nullable(), - thought: z.string().nullable(), - }, -}); - -const machine = setup({ - types: agent.types, - actors: { agent: fromDecision(agent), getFromTerminal: fromTerminal }, -}).createMachine({ - initial: 'asking', - context: { - question: null, - thought: null, - }, - states: { - asking: { - invoke: { - src: 'getFromTerminal', - input: 'What would you like to ask?', - onDone: { - actions: assign({ - question: ({ event }) => event.output, - }), - target: 'thinking', - }, - }, - }, - thinking: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context, - goal: 'Answer the question. Think step-by-step.', - }), - }, - on: { - 'agent.think': { - actions: [ - log(({ event }) => event.thought), - assign({ - thought: ({ event }) => event.thought, - }), - ], - target: 'answering', - }, - }, - }, - answering: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context, - goal: 'Answer the question', - }), - }, - on: { - 'agent.answer': { - actions: [log(({ event }) => event.answer)], - target: 'answered', - }, - }, - }, - answered: { - type: 'final', - }, - }, -}); - -createActor(machine).start(); diff --git a/examples_old/email.ts b/examples_old/email.ts deleted file mode 100644 index e65a88c..0000000 --- a/examples_old/email.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { z } from 'zod'; -import { createAgent, fromDecision } from '../src'; -import { openai } from '@ai-sdk/openai'; -import { assign, createActor, setup } from 'xstate'; -import { fromTerminal } from './helpers/helpers'; - -const agent = createAgent({ - name: 'email', - model: openai('gpt-4'), - events: { - askForClarification: z.object({ - questions: z.array(z.string()).describe('The questions to ask the agent'), - }), - submitEmail: z.object({ - email: z.string().describe('The email to submit'), - }), - }, -}); - -const machine = setup({ - types: { - events: agent.types.events, - input: {} as { - email: string; - instructions: string; - }, - context: {} as { - email: string; - instructions: string; - clarifications: string[]; - replyEmail: string | null; - }, - }, - actors: { agent: fromDecision(agent), getFromTerminal: fromTerminal }, -}).createMachine({ - initial: 'checking', - context: ({ input }) => ({ - email: input.email, - instructions: input.instructions, - clarifications: [], - replyEmail: null, - }), - states: { - checking: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context: { - email: context.email, - instructions: context.instructions, - clarifications: context.clarifications, - }, - messages: agent.getMessages(), - goal: 'Respond to the email given the instructions and the provided clarifications. If not enough information is provided, ask for clarification. Otherwise, if you are absolutely sure that there is no ambiguous or missing information, create and submit a response email.', - }), - }, - on: { - askForClarification: { - actions: ({ event }) => console.log(event.questions.join('\n')), - target: 'clarifying', - }, - submitEmail: { - target: 'submitting', - }, - }, - }, - clarifying: { - invoke: { - src: 'getFromTerminal', - input: `Please provide answers to the questions above`, - onDone: { - actions: assign({ - clarifications: ({ context, event }) => - context.clarifications.concat(event.output), - }), - target: 'checking', - }, - }, - }, - submitting: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context: { - email: context.email, - instructions: context.instructions, - clarifications: context.clarifications, - }, - goal: `Create and submit an email based on the instructions.`, - }), - }, - on: { - submitEmail: { - actions: assign({ - replyEmail: ({ event }) => event.email, - }), - target: 'done', - }, - }, - }, - done: { - type: 'final', - entry: ({ context }) => console.log(context.replyEmail), - }, - }, - exit: () => { - console.log('End of conversation.'); - process.exit(); - }, -}); - -createActor(machine, { - input: { - email: 'That sounds great! When are you available?', - instructions: - 'Tell them exactly when I am available. Address them by his full (first and last) name.', - }, -}).start(); diff --git a/examples_old/email2.ts b/examples_old/email2.ts deleted file mode 100644 index 31ac3f5..0000000 --- a/examples_old/email2.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { setup, SnapshotFrom } from 'xstate'; -import { mapState } from '../src/mapState'; - -const machine = setup({}).createMachine({ - initial: 'checking', - states: { - checking: { - on: { - askForClarification: { - target: 'clarifying', - }, - submitEmail: { - target: 'submitting', - }, - }, - }, - clarifying: { - on: { - provideClarification: { - target: 'checking', - }, - }, - }, - submitting: { - on: { - confirm: { - target: 'done', - }, - }, - }, - done: { - type: 'final', - }, - }, -}); - -function getStuff(snapshot: SnapshotFrom) { - return mapState< - typeof snapshot, - { - goal: string; - } - >(snapshot, { - states: { - checking: { - map: () => ({ - goal: 'Respond to the email given the instructions and the provided clarifications. If not enough information is provided, ask for clarification. Otherwise, if you are absolutely sure that there is no ambiguous or missing information, create and submit a response email.', - }), - }, - submitting: { - map: () => ({ - goal: 'Create and submit an email based on the instructions.', - }), - }, - }, - }); -} - -async function main() {} diff --git a/examples_old/example.ts b/examples_old/example.ts deleted file mode 100644 index 322f044..0000000 --- a/examples_old/example.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { z } from 'zod'; -import { createAgent, fromDecision, fromText } from '../src'; -import { openai } from '@ai-sdk/openai'; -import { assign, createActor, setup } from 'xstate'; - -const agent = createAgent({ - name: 'example', - model: openai('gpt-4-turbo'), - events: { - 'agent.englishSummary': z.object({ - text: z.string().describe('The summary in English'), - }), - 'agent.spanishSummary': z.object({ - text: z.string().describe('The summary in Spanish'), - }), - }, -}); - -const machine = setup({ - types: { - events: agent.types.events, - }, - actors: { agent: fromDecision(agent), summarizer: fromText(agent) }, -}).createMachine({ - initial: 'summarizing', - context: { - patientVisit: - 'During my visit, the doctor explained my condition clearly. She listened to my concerns and recommended a treatment plan. My condition was diagnosed as X after a series of tests. I feel relieved to have a clear path forward with managing my health. Also, the staff were very friendly and helpful at check-in and check-out. Furthermore, the facilities were clean and well-maintained.', - }, - states: { - summarizing: { - invoke: [ - { - src: 'summarizer', - input: ({ context }) => ({ - context, - prompt: - 'Summarize the patient visit in a single sentence. The summary should be in English.', - }), - onDone: { - actions: assign({ - englishSummary: ({ event }) => event.output.text, - }), - }, - }, - { - src: 'summarizer', - input: ({ context }) => ({ - context, - prompt: - 'Summarize the patient visit in a single sentence. The summary should be in Spanish.', - }), - onDone: { - actions: assign({ - spanishSummary: ({ event }) => event.output.text, - }), - }, - }, - ], - always: { - guard: ({ context }) => - context.englishSummary && context.spanishSummary, - target: 'summarized', - }, - }, - summarized: { - entry: ({ context }) => { - console.log(context.englishSummary); - console.log(context.spanishSummary); - }, - }, - }, -}); - -const actor = createActor(machine); - -actor.subscribe((s) => { - console.log(s.context); -}); - -actor.start(); diff --git a/examples_old/executor.ts b/examples_old/executor.ts deleted file mode 100644 index bf56fbe..0000000 --- a/examples_old/executor.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { openai } from '@ai-sdk/openai'; -import { createAgent, fromDecision } from '../src'; -import { assign, createActor, createMachine, fromPromise } from 'xstate'; -import { z } from 'zod'; -import { fromTerminal } from './helpers/helpers'; - -const agent = createAgent({ - model: openai('gpt-5.4-nano'), - events: { - getTime: z.object({}).describe('Get the current time'), - other: z.object({}).describe('Do something else'), - }, -}); - -const machine = createMachine({ - initial: 'start', - context: { - question: null, - }, - states: { - start: { - invoke: { - src: fromTerminal, - input: 'What do you want to do?', - onDone: { - actions: assign({ - question: ({ event }) => event.output, - }), - target: 'deciding', - }, - }, - }, - deciding: { - invoke: { - src: fromDecision(agent), - input: { - goal: 'Satisfy the user question', - context: true, - }, - }, - on: { - getTime: 'gettingTime', - other: 'other', - }, - }, - gettingTime: { - invoke: { - src: fromPromise(async () => { - console.log('Time:', new Date().toLocaleTimeString()); - }), - onDone: 'start', - }, - }, - other: { - entry: () => - console.log( - 'You want me to do something else. I can only tell the time.' - ), - after: { - 1000: 'start', - }, - }, - }, -}); - -createActor(machine).start(); diff --git a/examples_old/goal.ts b/examples_old/goal.ts deleted file mode 100644 index 25f19b4..0000000 --- a/examples_old/goal.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { z } from 'zod'; -import { createAgent, fromDecision } from '../src'; -import { openai } from '@ai-sdk/openai'; -import { assign, createActor, log, setup } from 'xstate'; -import { fromTerminal } from './helpers/helpers'; - -const agent = createAgent({ - name: 'goal', - model: openai('gpt-4-turbo'), - events: { - 'agent.createGoal': z.object({ - goal: z.string().describe('The goal for the conversation'), - }), - 'agent.respond': z.object({ - response: z.string().describe('The response from the agent'), - }), - }, -}); - -const decider = fromDecision(agent); - -const machine = setup({ - types: { - context: {} as { - question: string | null; - goal: string | null; - }, - events: agent.types.events, - }, - actors: { decider, getFromTerminal: fromTerminal }, -}).createMachine({ - initial: 'gettingQuestion', - context: { - question: null, - goal: null, - }, - states: { - gettingQuestion: { - invoke: { - src: 'getFromTerminal', - input: 'What would you like to ask?', - onDone: { - actions: assign({ - question: ({ event }) => event.output, - }), - target: 'makingGoal', - }, - }, - }, - makingGoal: { - invoke: { - src: 'decider', - input: { - context: true, - goal: 'Determine what the user wants to accomplish. What is their ideal goal state? ', - maxRetries: 3, - }, - }, - on: { - 'agent.createGoal': { - actions: [ - assign({ - goal: ({ event }) => event.goal, - }), - log(({ event }) => event), - ], - target: 'responding', - }, - }, - }, - responding: { - invoke: { - src: 'decider', - input: { - context: true, - goal: 'Answer the question to achieve the stated goal, unless the goal is impossible to achieve.', - maxRetries: 3, - }, - }, - on: { - 'agent.respond': { - actions: log(({ event }) => event), - }, - }, - }, - responded: { - type: 'final', - }, - }, -}); - -const actor = createActor(machine); - -actor.start(); diff --git a/examples_old/helpers/helpers.ts b/examples_old/helpers/helpers.ts deleted file mode 100644 index 5db4fe0..0000000 --- a/examples_old/helpers/helpers.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { fromPromise } from 'xstate'; - -export const fromTerminal = fromPromise(async ({ input }) => { - const topic = await new Promise((res) => { - console.log(input + '\n'); - const listener = (data: Buffer) => { - const result = data.toString().trim(); - process.stdin.off('data', listener); - res(result); - }; - process.stdin.on('data', listener); - }); - - return topic; -}); - -export async function getFromTerminal(msg: string) { - const topic = await new Promise((res) => { - console.log(msg + '\n'); - const listener = (data: Buffer) => { - const result = data.toString().trim(); - process.stdin.off('data', listener); - res(result); - }; - process.stdin.on('data', listener); - }); - - return topic; -} diff --git a/examples_old/helpers/loader.ts b/examples_old/helpers/loader.ts deleted file mode 100644 index ca42895..0000000 --- a/examples_old/helpers/loader.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Adapted from https://stackoverflow.com/questions/34848505/how-to-make-a-loading-animation-in-console-application-written-in-javascript-or - -/** - * Create and display a loader in the console. - * - * @param {string} [text=""] Text to display after loader - * @param {array.} [chars=["⠙", "⠘", "⠰", "⠴", "⠤", "⠦", "⠆", "⠃", "⠋", "⠉"]] - * Array of characters representing loader steps - * @param {number} [delay=100] Delay in ms between loader steps - * @example - * let loader = loadingAnimation("Loading…"); - * - * // Stop loader after 1 second - * setTimeout(() => clearInterval(loader), 1000); - * @returns {number} An interval that can be cleared to stop the animation - */ -export function loadingAnimation( - text: string = '', - chars: Array = ['⠙', '⠘', '⠰', '⠴', '⠤', '⠦', '⠆', '⠃', '⠋', '⠉'], - delay: number = 100 -) { - let x = 0; - - const i = setInterval(function () { - process.stdout.write('\r' + chars[x++] + ' ' + text); - x = x % chars.length; - }, delay); - - return { - stop: () => clearInterval(i), - }; -} diff --git a/examples_old/helpers/runner.ts b/examples_old/helpers/runner.ts deleted file mode 100644 index 7809a7c..0000000 --- a/examples_old/helpers/runner.ts +++ /dev/null @@ -1,27 +0,0 @@ -import dotenv from 'dotenv'; -import { existsSync, readdirSync } from 'fs'; -dotenv.config(); - -function showExamples() { - const exampleFiles = readdirSync('./examples', { withFileTypes: true }); - exampleFiles.forEach((file) => { - if (file.isDirectory()) return; - const exampleName = file.name.split('.')[0]; - console.log(`- ${exampleName}`); - }); - process.exit(); -} - -const exampleParams = process.argv.slice(2); -if (exampleParams.length === 0) { - console.error('No example specified, you can choose from:'); - showExamples(); -} -const exampleName = exampleParams[0]; -const filePath = `./examples/${exampleName}.ts`; -if (existsSync(filePath)) { - require(`../${exampleName}.ts`); -} else { - console.error(`Example ${exampleName} does not exist, you can choose from:`); - showExamples(); -} diff --git a/examples_old/hono/.gitignore b/examples_old/hono/.gitignore deleted file mode 100644 index c363919..0000000 --- a/examples_old/hono/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# prod -dist/ -dist-server/ - -# dev -.yarn/ -!.yarn/releases -.vscode/* -!.vscode/launch.json -!.vscode/*.code-snippets -.idea/workspace.xml -.idea/usage.statistics.xml -.idea/shelf - -# deps -node_modules/ -.wrangler - -# env -.env -.env.production -.dev.vars - -# logs -logs/ -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -# misc -.DS_Store diff --git a/examples_old/hono/README.md b/examples_old/hono/README.md deleted file mode 100644 index eba2b1e..0000000 --- a/examples_old/hono/README.md +++ /dev/null @@ -1,21 +0,0 @@ -```txt -npm install -npm run dev -``` - -```txt -npm run deploy -``` - -[For generating/synchronizing types based on your Worker configuration run](https://developers.cloudflare.com/workers/wrangler/commands/#types): - -```txt -npm run cf-typegen -``` - -Pass the `CloudflareBindings` as generics when instantiation `Hono`: - -```ts -// src/index.ts -const app = new Hono<{ Bindings: CloudflareBindings }>() -``` diff --git a/examples_old/hono/package.json b/examples_old/hono/package.json deleted file mode 100644 index 8bd9148..0000000 --- a/examples_old/hono/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "hono", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "$npm_execpath run build && vite preview", - "deploy": "$npm_execpath run build && wrangler deploy", - "cf-typegen": "wrangler types --env-interface CloudflareBindings" - }, - "dependencies": { - "@ai-sdk/openai": "^3.0.24", - "ai": "^6.0.66", - "hono": "^4.11.7", - "xstate": "^5.26.0", - "zod": "^3.23.0" - }, - "devDependencies": { - "@cloudflare/vite-plugin": "^1.2.3", - "vite": "^6.3.5", - "wrangler": "^4.17.0" - } -} \ No newline at end of file diff --git a/examples_old/hono/pnpm-lock.yaml b/examples_old/hono/pnpm-lock.yaml deleted file mode 100644 index 77d37c1..0000000 --- a/examples_old/hono/pnpm-lock.yaml +++ /dev/null @@ -1,1643 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@ai-sdk/openai': - specifier: ^3.0.24 - version: 3.0.24(zod@3.25.76) - ai: - specifier: ^6.0.66 - version: 6.0.66(zod@3.25.76) - hono: - specifier: ^4.11.7 - version: 4.11.7 - xstate: - specifier: ^5.26.0 - version: 5.26.0 - zod: - specifier: ^3.23.0 - version: 3.25.76 - devDependencies: - '@cloudflare/vite-plugin': - specifier: ^1.2.3 - version: 1.22.1(vite@6.4.1)(workerd@1.20260128.0)(wrangler@4.61.1) - vite: - specifier: ^6.3.5 - version: 6.4.1 - wrangler: - specifier: ^4.17.0 - version: 4.61.1 - -packages: - - '@ai-sdk/gateway@3.0.31': - resolution: {integrity: sha512-WActnxPeW46XcfZWWEcJ1FytpjCtKQEo25WZVa2xZSf+u2FgSNVt/dXIvlSZetPnXo6T2P/GhFAPBULMN6siRA==, tarball: https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.31.tgz} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/openai@3.0.24': - resolution: {integrity: sha512-f4d2z4cQpaLnCxlhL5X+/FIpA7u55eYbfCtu7hJxukav7MIQi+5uufy5OAXdCieqPnsdoiGRWaI+VTPh151mZQ==, tarball: https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.24.tgz} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/provider-utils@4.0.12': - resolution: {integrity: sha512-sdC3eUTa5W4r/bISlF3nxmM6zc8mV7Nj3mWI9iUO0cib70h0Zr52Tz5gGzO6HcDirbKVTR2ywmZb61MHU68prA==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.12.tgz} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/provider@3.0.6': - resolution: {integrity: sha512-hSfoJtLtpMd7YxKM+iTqlJ0ZB+kJ83WESMiWuWrNVey3X8gg97x0OdAAaeAeclZByCX3UdPOTqhvJdK8qYA3ww==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.6.tgz} - engines: {node: '>=18'} - - '@cloudflare/kv-asset-handler@0.4.2': - resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==, tarball: https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz} - engines: {node: '>=18.0.0'} - - '@cloudflare/unenv-preset@2.12.0': - resolution: {integrity: sha512-NK4vN+2Z/GbfGS4BamtbbVk1rcu5RmqaYGiyHJQrA09AoxdZPHDF3W/EhgI0YSK8p3vRo/VNCtbSJFPON7FWMQ==, tarball: https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.12.0.tgz} - peerDependencies: - unenv: 2.0.0-rc.24 - workerd: ^1.20260115.0 - peerDependenciesMeta: - workerd: - optional: true - - '@cloudflare/vite-plugin@1.22.1': - resolution: {integrity: sha512-RDWc6WtrdjVDfpBeO3MYcgJIbq+Phg9qBXq1Ixl00qPqM8bgKp9oPLhg8oayynQs8udNnqkV0CjfojvIhhfZWg==, tarball: https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.22.1.tgz} - peerDependencies: - vite: ^6.1.0 || ^7.0.0 - wrangler: ^4.61.1 - - '@cloudflare/workerd-darwin-64@1.20260128.0': - resolution: {integrity: sha512-XJN8zWWNG3JwAUqqwMLNKJ9fZfdlQkx/zTTHW/BB8wHat9LjKD6AzxqCu432YmfjR+NxEKCzUOxMu1YOxlVxmg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260128.0.tgz} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20260128.0': - resolution: {integrity: sha512-vKnRcmnm402GQ5DOdfT5H34qeR2m07nhnTtky8mTkNWP+7xmkz32AMdclwMmfO/iX9ncyKwSqmml2wPG32eq/w==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260128.0.tgz} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - - '@cloudflare/workerd-linux-64@1.20260128.0': - resolution: {integrity: sha512-RiaR+Qugof/c6oI5SagD2J5wJmIfI8wQWaV2Y9905Raj6sAYOFaEKfzkKnoLLLNYb4NlXicBrffJi1j7R/ypUA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260128.0.tgz} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - - '@cloudflare/workerd-linux-arm64@1.20260128.0': - resolution: {integrity: sha512-U39U9vcXLXYDbrJ112Q7D0LDUUnM54oXfAxPgrL2goBwio7Z6RnsM25TRvm+Q06F4+FeDOC4D51JXlFHb9t1OA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260128.0.tgz} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - - '@cloudflare/workerd-windows-64@1.20260128.0': - resolution: {integrity: sha512-fdJwSqRkJsAJFJ7+jy0th2uMO6fwaDA8Ny6+iFCssfzlNkc4dP/twXo+3F66FMLMe/6NIqjzVts0cpiv7ERYbQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260128.0.tgz} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==, tarball: https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz} - engines: {node: '>=12'} - - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==, tarball: https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz} - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.27.0': - resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.27.0': - resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.27.0': - resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.27.0': - resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.27.0': - resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.0': - resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.27.0': - resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.0': - resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.27.0': - resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.27.0': - resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.27.0': - resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.27.0': - resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.27.0': - resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.27.0': - resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.0': - resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.27.0': - resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.27.0': - resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-arm64@0.27.0': - resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.0': - resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.27.0': - resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.0': - resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/openharmony-arm64@0.27.0': - resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.27.0': - resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.27.0': - resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.27.0': - resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.0': - resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==, tarball: https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==, tarball: https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==, tarball: https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==, tarball: https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==, tarball: https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==, tarball: https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==, tarball: https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==, tarball: https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==, tarball: https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==, tarball: https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==, tarball: https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==, tarball: https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==, tarball: https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==, tarball: https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==, tarball: https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==, tarball: https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==, tarball: https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, tarball: https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==, tarball: https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz} - - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz} - - '@opentelemetry/api@1.9.0': - resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==, tarball: https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz} - engines: {node: '>=8.0.0'} - - '@poppinss/colors@4.1.6': - resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==, tarball: https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz} - - '@poppinss/dumper@0.6.5': - resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==, tarball: https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz} - - '@poppinss/exception@1.2.3': - resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==, tarball: https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz} - - '@rollup/rollup-android-arm-eabi@4.57.1': - resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.57.1': - resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.57.1': - resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.57.1': - resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.57.1': - resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.57.1': - resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.57.1': - resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.57.1': - resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.57.1': - resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.57.1': - resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.57.1': - resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.57.1': - resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.57.1': - resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.57.1': - resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.57.1': - resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.57.1': - resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==, tarball: https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.57.1': - resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==, tarball: https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.57.1': - resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.57.1': - resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.57.1': - resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.57.1': - resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz} - cpu: [x64] - os: [win32] - - '@sindresorhus/is@7.2.0': - resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==, tarball: https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz} - engines: {node: '>=18'} - - '@speed-highlight/core@1.2.14': - resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==, tarball: https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, tarball: https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz} - - '@vercel/oidc@3.1.0': - resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==, tarball: https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz} - engines: {node: '>= 20'} - - ai@6.0.66: - resolution: {integrity: sha512-Klnzjlc3JczRykD75t+Qn5Jt5HwUCaLlN9aZku9KrSDjhc/pab54YH0w85huue7FLPlbTVF5zaQrw3NdEwiGpA==, tarball: https://registry.npmjs.org/ai/-/ai-6.0.66.tgz} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - blake3-wasm@2.1.5: - resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==, tarball: https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz} - engines: {node: '>=18'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, tarball: https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz} - engines: {node: '>=8'} - - error-stack-parser-es@1.0.5: - resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==, tarball: https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.27.0: - resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz} - engines: {node: '>=18'} - hasBin: true - - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==, tarball: https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz} - engines: {node: '>=18.0.0'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, tarball: https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - hono@4.11.7: - resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==, tarball: https://registry.npmjs.org/hono/-/hono-4.11.7.tgz} - engines: {node: '>=16.9.0'} - - json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==, tarball: https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz} - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==, tarball: https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz} - engines: {node: '>=6'} - - miniflare@4.20260128.0: - resolution: {integrity: sha512-AVCn3vDRY+YXu1sP4mRn81ssno6VUqxo29uY2QVfgxXU2TMLvhRIoGwm7RglJ3Gzfuidit5R86CMQ6AvdFTGAw==, tarball: https://registry.npmjs.org/miniflare/-/miniflare-4.20260128.0.tgz} - engines: {node: '>=18.0.0'} - hasBin: true - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==, tarball: https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, tarball: https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz} - engines: {node: '>=12'} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz} - engines: {node: ^10 || ^12 || >=14} - - rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==, tarball: https://registry.npmjs.org/semver/-/semver-7.7.3.tgz} - engines: {node: '>=10'} - hasBin: true - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==, tarball: https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz} - engines: {node: '>=0.10.0'} - - supports-color@10.2.2: - resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==, tarball: https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz} - engines: {node: '>=18'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz} - engines: {node: '>=12.0.0'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz} - - undici@7.18.2: - resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==, tarball: https://registry.npmjs.org/undici/-/undici-7.18.2.tgz} - engines: {node: '>=20.18.1'} - - unenv@2.0.0-rc.24: - resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==, tarball: https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz} - - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==, tarball: https://registry.npmjs.org/vite/-/vite-6.4.1.tgz} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - workerd@1.20260128.0: - resolution: {integrity: sha512-EhLJGptSGFi8AEErLiamO3PoGpbRqL+v4Ve36H2B38VxmDgFOSmDhfepBnA14sCQzGf1AEaoZX2DCwZsmO74yQ==, tarball: https://registry.npmjs.org/workerd/-/workerd-1.20260128.0.tgz} - engines: {node: '>=16'} - hasBin: true - - wrangler@4.61.1: - resolution: {integrity: sha512-hfYQ16VLPkNi8xE1/V3052S2stM5e+vq3Idpt83sXoDC3R7R1CLgMkK6M6+Qp3G+9GVDNyHCkvohMPdfFTaD4Q==, tarball: https://registry.npmjs.org/wrangler/-/wrangler-4.61.1.tgz} - engines: {node: '>=20.0.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^4.20260128.0 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==, tarball: https://registry.npmjs.org/ws/-/ws-8.18.0.tgz} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xstate@5.26.0: - resolution: {integrity: sha512-Fvi9VBoqHgsGYLU2NTag8xDTWtKqUC0+ue7EAhBNBb06wf620QEy05upBaEI1VLMzIn63zugLV8nHb69ZUWYAA==, tarball: https://registry.npmjs.org/xstate/-/xstate-5.26.0.tgz} - - youch-core@0.3.3: - resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==, tarball: https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz} - - youch@4.1.0-beta.10: - resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==, tarball: https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz} - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz} - -snapshots: - - '@ai-sdk/gateway@3.0.31(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.12(zod@3.25.76) - '@vercel/oidc': 3.1.0 - zod: 3.25.76 - - '@ai-sdk/openai@3.0.24(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.12(zod@3.25.76) - zod: 3.25.76 - - '@ai-sdk/provider-utils@4.0.12(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.6 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 3.25.76 - - '@ai-sdk/provider@3.0.6': - dependencies: - json-schema: 0.4.0 - - '@cloudflare/kv-asset-handler@0.4.2': {} - - '@cloudflare/unenv-preset@2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260128.0)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20260128.0 - - '@cloudflare/vite-plugin@1.22.1(vite@6.4.1)(workerd@1.20260128.0)(wrangler@4.61.1)': - dependencies: - '@cloudflare/unenv-preset': 2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260128.0) - miniflare: 4.20260128.0 - unenv: 2.0.0-rc.24 - vite: 6.4.1 - wrangler: 4.61.1 - ws: 8.18.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - workerd - - '@cloudflare/workerd-darwin-64@1.20260128.0': - optional: true - - '@cloudflare/workerd-darwin-arm64@1.20260128.0': - optional: true - - '@cloudflare/workerd-linux-64@1.20260128.0': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20260128.0': - optional: true - - '@cloudflare/workerd-windows-64@1.20260128.0': - optional: true - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/aix-ppc64@0.27.0': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.27.0': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-arm@0.27.0': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/android-x64@0.27.0': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.27.0': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.27.0': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.27.0': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.27.0': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.27.0': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-arm@0.27.0': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.27.0': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.27.0': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.27.0': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.27.0': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.27.0': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.27.0': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/linux-x64@0.27.0': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.27.0': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.27.0': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.27.0': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.27.0': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.27.0': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.27.0': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.27.0': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.27.0': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@esbuild/win32-x64@0.27.0': - optional: true - - '@img/colour@1.0.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.8.1 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@opentelemetry/api@1.9.0': {} - - '@poppinss/colors@4.1.6': - dependencies: - kleur: 4.1.5 - - '@poppinss/dumper@0.6.5': - dependencies: - '@poppinss/colors': 4.1.6 - '@sindresorhus/is': 7.2.0 - supports-color: 10.2.2 - - '@poppinss/exception@1.2.3': {} - - '@rollup/rollup-android-arm-eabi@4.57.1': - optional: true - - '@rollup/rollup-android-arm64@4.57.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.57.1': - optional: true - - '@rollup/rollup-darwin-x64@4.57.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.57.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.57.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.57.1': - optional: true - - '@rollup/rollup-openbsd-x64@4.57.1': - optional: true - - '@rollup/rollup-openharmony-arm64@4.57.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.57.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.57.1': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.57.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.57.1': - optional: true - - '@sindresorhus/is@7.2.0': {} - - '@speed-highlight/core@1.2.14': {} - - '@standard-schema/spec@1.1.0': {} - - '@types/estree@1.0.8': {} - - '@vercel/oidc@3.1.0': {} - - ai@6.0.66(zod@3.25.76): - dependencies: - '@ai-sdk/gateway': 3.0.31(zod@3.25.76) - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.12(zod@3.25.76) - '@opentelemetry/api': 1.9.0 - zod: 3.25.76 - - blake3-wasm@2.1.5: {} - - cookie@1.1.1: {} - - detect-libc@2.1.2: {} - - error-stack-parser-es@1.0.5: {} - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - esbuild@0.27.0: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.0 - '@esbuild/android-arm': 0.27.0 - '@esbuild/android-arm64': 0.27.0 - '@esbuild/android-x64': 0.27.0 - '@esbuild/darwin-arm64': 0.27.0 - '@esbuild/darwin-x64': 0.27.0 - '@esbuild/freebsd-arm64': 0.27.0 - '@esbuild/freebsd-x64': 0.27.0 - '@esbuild/linux-arm': 0.27.0 - '@esbuild/linux-arm64': 0.27.0 - '@esbuild/linux-ia32': 0.27.0 - '@esbuild/linux-loong64': 0.27.0 - '@esbuild/linux-mips64el': 0.27.0 - '@esbuild/linux-ppc64': 0.27.0 - '@esbuild/linux-riscv64': 0.27.0 - '@esbuild/linux-s390x': 0.27.0 - '@esbuild/linux-x64': 0.27.0 - '@esbuild/netbsd-arm64': 0.27.0 - '@esbuild/netbsd-x64': 0.27.0 - '@esbuild/openbsd-arm64': 0.27.0 - '@esbuild/openbsd-x64': 0.27.0 - '@esbuild/openharmony-arm64': 0.27.0 - '@esbuild/sunos-x64': 0.27.0 - '@esbuild/win32-arm64': 0.27.0 - '@esbuild/win32-ia32': 0.27.0 - '@esbuild/win32-x64': 0.27.0 - - eventsource-parser@3.0.6: {} - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - fsevents@2.3.3: - optional: true - - hono@4.11.7: {} - - json-schema@0.4.0: {} - - kleur@4.1.5: {} - - miniflare@4.20260128.0: - dependencies: - '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 - undici: 7.18.2 - workerd: 1.20260128.0 - ws: 8.18.0 - youch: 4.1.0-beta.10 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - nanoid@3.3.11: {} - - path-to-regexp@6.3.0: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.3: {} - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - rollup@4.57.1: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.1 - '@rollup/rollup-android-arm64': 4.57.1 - '@rollup/rollup-darwin-arm64': 4.57.1 - '@rollup/rollup-darwin-x64': 4.57.1 - '@rollup/rollup-freebsd-arm64': 4.57.1 - '@rollup/rollup-freebsd-x64': 4.57.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 - '@rollup/rollup-linux-arm-musleabihf': 4.57.1 - '@rollup/rollup-linux-arm64-gnu': 4.57.1 - '@rollup/rollup-linux-arm64-musl': 4.57.1 - '@rollup/rollup-linux-loong64-gnu': 4.57.1 - '@rollup/rollup-linux-loong64-musl': 4.57.1 - '@rollup/rollup-linux-ppc64-gnu': 4.57.1 - '@rollup/rollup-linux-ppc64-musl': 4.57.1 - '@rollup/rollup-linux-riscv64-gnu': 4.57.1 - '@rollup/rollup-linux-riscv64-musl': 4.57.1 - '@rollup/rollup-linux-s390x-gnu': 4.57.1 - '@rollup/rollup-linux-x64-gnu': 4.57.1 - '@rollup/rollup-linux-x64-musl': 4.57.1 - '@rollup/rollup-openbsd-x64': 4.57.1 - '@rollup/rollup-openharmony-arm64': 4.57.1 - '@rollup/rollup-win32-arm64-msvc': 4.57.1 - '@rollup/rollup-win32-ia32-msvc': 4.57.1 - '@rollup/rollup-win32-x64-gnu': 4.57.1 - '@rollup/rollup-win32-x64-msvc': 4.57.1 - fsevents: 2.3.3 - - semver@7.7.3: {} - - sharp@0.34.5: - dependencies: - '@img/colour': 1.0.0 - detect-libc: 2.1.2 - semver: 7.7.3 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - - source-map-js@1.2.1: {} - - supports-color@10.2.2: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - tslib@2.8.1: - optional: true - - undici@7.18.2: {} - - unenv@2.0.0-rc.24: - dependencies: - pathe: 2.0.3 - - vite@6.4.1: - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.57.1 - tinyglobby: 0.2.15 - optionalDependencies: - fsevents: 2.3.3 - - workerd@1.20260128.0: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260128.0 - '@cloudflare/workerd-darwin-arm64': 1.20260128.0 - '@cloudflare/workerd-linux-64': 1.20260128.0 - '@cloudflare/workerd-linux-arm64': 1.20260128.0 - '@cloudflare/workerd-windows-64': 1.20260128.0 - - wrangler@4.61.1: - dependencies: - '@cloudflare/kv-asset-handler': 0.4.2 - '@cloudflare/unenv-preset': 2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260128.0) - blake3-wasm: 2.1.5 - esbuild: 0.27.0 - miniflare: 4.20260128.0 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.24 - workerd: 1.20260128.0 - optionalDependencies: - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - ws@8.18.0: {} - - xstate@5.26.0: {} - - youch-core@0.3.3: - dependencies: - '@poppinss/exception': 1.2.3 - error-stack-parser-es: 1.0.5 - - youch@4.1.0-beta.10: - dependencies: - '@poppinss/colors': 4.1.6 - '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.14 - cookie: 1.1.1 - youch-core: 0.3.3 - - zod@3.25.76: {} diff --git a/examples_old/hono/public/.assetsignore b/examples_old/hono/public/.assetsignore deleted file mode 100644 index 9f1f131..0000000 --- a/examples_old/hono/public/.assetsignore +++ /dev/null @@ -1 +0,0 @@ -.vite \ No newline at end of file diff --git a/examples_old/hono/public/favicon.ico b/examples_old/hono/public/favicon.ico deleted file mode 100644 index 543164354afc72a8b8de19830b6b9c06af58c0ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15406 zcmeHOdr(x@8NZ+)h{!{Yf~ZJHzz|{A<&7-MQ{D(dFls}51Y#7yNHo!C)>p7KjWw~Y zopdtOOgl}d|MZb*XPTxn&X{D(w05SQ{*jsVF=?xvnzqD_F|p#Wzi)TXa#^^*-Cd|{ zcV~Wc?m72-kMo^#&*S^fYFd~!ON)=!n5Jrv&(^ejP190S-TBM}O?#DP7K`Wo{hId9 zB2CL=9g>j3UC!jc<~KVJRbf`VN&J(zL0p_=!=|Y;AhHnq=M>=1vQ{8(50Kjnq_hAm zLsTKYJ`s=DQk2Xq#U1NT;NkP5k-HnkBwr)h^_v8KiKD=1;B+&jcu<@&a z{Q$u7Tlu}nnTTHAS)7R1oCq&HfcHe_+7keuv35V4lhNwmJDjkTJ8`T0IiUOqE;7%r z%-pZ~1P<1mz+dW|NHIt0sm*pY9LFlZXlIB>=9yH&LCE|R`h_eIxCIcd<)BfsaIUi8 zec}`5Z!-M@@jYUPmVwy7<&5Ppdksp%ZTNNg8o&@%zO&(P#2!tfu0i@m6re96AGag( z0T=SP-q?Y~^?bY<)py~_FykhkFYlGM_{ER@X19*UI ztdcQVDtikTozRxyn&v>1SsfB9@j!e9AV{bjf9*EXqm%uS)aa&l@hC@|S@{dsVc)_U zKp=f?B+ICL%@b}~p--&AX|wVdH{z8g4IJZa$AjC=%eOyGTA?SFG~zY0@|U*YwWZvb z306I7UVg~{X}Q1j;JjJ+%QoPgd`|-I-_XH5kCCm%D_^|>Sl;FadF?bSKdlo_%AQl9 z?BLYpx4y_RL)w*{F5Fq!h81Sz-?s(T+*8WF(gpecI?hc^heGZV@83d@mg!q&WlR0E zZp3A7#ck<(7bw5vsmbLxpJsj5_0n~r!5{fTll>Sj640aZ^Ts;JJeO+#A#St2~Isl}=nEpu;W7E}T}uI_6c! z(N=wo`z{Y^j>(>LW`DIOah^ckNd)CvGpjxA9aT4ouQfRX-{+c@9jYI)Krk#IeiFK9 zwMU7NpM*vT{X!N9S>S)v0tll|YN2LDE`4?Ti8qK3h#M|HUJgFw^94S?K&;d9uuMzG zoV&spRwUE!x7!tevfz4{1>mzA^6Z@2^(`hPQHy;HxOBX>!PO~@#R4JCZ1^-JCqny1ZReIlMpkdJ12Owwi_Ltl~V)~H0eq%)+ zRe)hW|5VFD&(Nh$nhX{T4DzZDW_w<@0V_4@ep zEIuNM*9hOZ5&mz-R0hXSB;Ao%hZNo;_!WT#9?0DSUtgI&`U{Qt)+83eG3o;4d`vk( zi$f>WBAaoC&j=_;zx%7NFP+R=i-b305(){`s5cnOri+sr(B_Icu%A^b_Z58g@HfBy zi@dYK{=A3$6)0z$X+;ePlGkH9@0vi5VC%n}@r)%+BS>z-b^~=x7mNO4A3bC}_1E6M zbmDv0^OonO&HnrMeH+MlP5ZJoPt3-X${Om=yzi^K zHso>dGA#QSJ!F3~o<3&EWDQu=HJa~=cDzkFOZ?T>m+syzNatOsYjPhd%>#K}%@~Z_ zIWnWKZ(WIxE1s^j6L;9RXRCGjs0GHJJ}=ls`S)-Jib#h4sq z|K~~n!98q?uMGpqr*Fpmj4pgf^!3FW#-QZBn6F(Y%gfp)>$k+@e0aXQd=q#}GKfFk z%VM1JC}j;xymAz_xnd)x^_RW0w0#sW^3K?X%|X=M%Zf3>HpUN!Bz`y^CW=4$_J!Eq z!I;o$#_t6Z$9G`|-qUzw_6iN+LsmDITYB&Tf$<<`fYvip8%OoeyU$~ak9-3;-5APw zjJ}EK`f&_dcHvctxw^nJqW76)G8W2sR-9~2kT&Ks7OAI|ST65ne3N5mTJA_GlUR2n z*Vc#6Bv$Ofr%eCIGt6x{$H+dij<)q3FDfr>D}UBaYh!%{(q6UgLAjDMooPWoUMC>n z9nC{{C&g+Mza38Mn&hA?Uy_nz8 zfmw`QpJf?bF>LC;_5jfKrO|C@W=vV``Kmg)^yek}r+3^a-6hR(BC=#)xQFrgbX6|* z0q7&SQv4Lpv>wR1_7KO5j2&4YN}q9`cje5h!!;E{SG;B>9XrQy54M#)pPSIdCzs6NUv=G8k-`YFqfNvv~bbe>pBkrRoG^M7CbRbKF072PE-bCVwSL%3Aq#61&p zo!Pg%w@}MF1b-zk5jY2ZWIv46GppU?(&XEyJc2J&aO``*Jx+x{l^wwl@2NjBIk*2z z4Vg>0FMGF&<7O1t2A1FEe)+8JE|2Ti9fOtkfUc-J7~b?KF8O`8e+IjwgX4(*McB{( zB78!)?(#7G9q*FZW>AfQL{*(aTJw&Q)6Q4y{P^aF7qzSA?Xw2 fns@IT_Cadv^H^~AY8cWiWPy+cLKgV{w7|as=smC{ diff --git a/examples_old/hono/src/agent.ts b/examples_old/hono/src/agent.ts deleted file mode 100644 index 602c253..0000000 --- a/examples_old/hono/src/agent.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { generateText, tool } from 'ai'; -import { createOpenAI } from '@ai-sdk/openai'; -import type { StateValue } from 'xstate'; -import { emailMachine } from './machine'; -import { agentEvents } from './events'; -import { getAllTransitions } from './utils'; -import { z } from 'zod'; - -type ObservedState = { - value: StateValue; - context: Record; -}; - -// States where agent makes decisions -export function requiresAgentDecision(stateValue: StateValue): boolean { - return stateValue === 'checking'; -} - -// Build AI SDK tools from Zod events, filtered by available transitions -function buildTools( - resolvedState: ReturnType -) { - const transitions = getAllTransitions(resolvedState); - const availableEventTypes = new Set(transitions.map((t) => t.eventType)); - - const tools: Record> = {}; - - for (const [eventType, schema] of Object.entries(agentEvents)) { - if (!availableEventTypes.has(eventType)) continue; - - tools[eventType] = tool({ - description: schema.description!, - inputSchema: schema, - execute: async (params) => ({ type: eventType, ...params }), - }); - } - - return tools; -} - -export async function getAgentDecision( - observedState: ObservedState, - goal: string, - apiKey: string -): Promise<{ type: string; [key: string]: unknown } | null> { - // Rehydrate state from serialized form - const resolvedState = emailMachine.resolveState(observedState); - const tools = buildTools(resolvedState); - - console.log('tools', tools); - - if (Object.keys(tools).length === 0) { - return null; - } - - const context = observedState.context as { - userRequest: string; - clarifications: string[]; - questions: string[]; - }; - - const systemPrompt = `You are an email assistant helping draft emails. - -User's request: ${context.userRequest} - -${ - context.clarifications.length > 0 - ? `Previous clarifications provided:\n${context.clarifications.join('\n')}` - : '' -} - -${goal} - -If you need more information to write a proper email (recipient, tone, specific details), ask for clarification. -If you have enough information, submit the email with recipient, subject, and body.`; - - const openai = createOpenAI({ apiKey }); - const result = await generateText({ - model: openai.chat('gpt-5-mini'), - system: systemPrompt, - messages: [{ role: 'user', content: goal }], - tools, - toolChoice: 'required', - }); - - const toolResult = result.toolResults[0]; - return ( - (toolResult?.result as { type: string; [key: string]: unknown }) ?? null - ); -} diff --git a/examples_old/hono/src/db.ts b/examples_old/hono/src/db.ts deleted file mode 100644 index 4aa0d30..0000000 --- a/examples_old/hono/src/db.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { StateValue } from 'xstate'; - -export interface StateEntry { - id: string; - value: StateValue; - context: Record; - event: { type: string; [key: string]: unknown } | null; - timestamp: number; -} - -export interface Session { - sessionId: string; - value: StateValue; - context: Record; - history: StateEntry[]; - createdAt: number; -} - -export interface SessionDB { - createSession(initialContext: Record): string; - getSession(sessionId: string): Session | null; - appendState( - sessionId: string, - entry: { - value: StateValue; - context: Record; - event: { type: string; [key: string]: unknown } | null; - } - ): void; -} - -// In-memory implementation -const sessions = new Map(); - -export const db: SessionDB = { - createSession(initialContext) { - const sessionId = crypto.randomUUID(); - const now = Date.now(); - const session: Session = { - sessionId, - value: 'checking', - context: initialContext, - history: [ - { - id: crypto.randomUUID(), - value: 'checking', - context: initialContext, - event: null, - timestamp: now, - }, - ], - createdAt: now, - }; - sessions.set(sessionId, session); - return sessionId; - }, - - getSession(sessionId) { - return sessions.get(sessionId) ?? null; - }, - - appendState(sessionId, entry) { - const session = sessions.get(sessionId); - if (!session) throw new Error('Session not found'); - - const stateEntry: StateEntry = { - id: crypto.randomUUID(), - timestamp: Date.now(), - ...entry, - }; - - session.history.push(stateEntry); - session.value = entry.value; - session.context = entry.context; - }, -}; diff --git a/examples_old/hono/src/events.ts b/examples_old/hono/src/events.ts deleted file mode 100644 index b37788b..0000000 --- a/examples_old/hono/src/events.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod'; - -// Events the agent can choose -export const agentEvents = { - askForClarification: z - .object({ - questions: z - .array(z.string()) - .describe('Questions to ask the user for clarification'), - }) - .describe('Ask the user for more information before drafting email'), - - submitEmail: z - .object({ - recipient: z.string().describe('Email recipient address'), - subject: z.string().describe('Email subject line'), - body: z.string().describe('Email body content'), - }) - .describe('Submit the final drafted email'), -}; - -// Events the user sends -export const userEvents = { - provideClarification: z.object({ - answers: z.string().describe('User answers to clarification questions'), - }), - - confirm: z.object({}).describe('Confirm and send the email'), -}; - -export type AgentEventType = keyof typeof agentEvents; -export type UserEventType = keyof typeof userEvents; diff --git a/examples_old/hono/src/index.ts b/examples_old/hono/src/index.ts deleted file mode 100644 index 09606ac..0000000 --- a/examples_old/hono/src/index.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { Hono } from 'hono'; -import { html, raw } from 'hono/html'; -import { emailMachine } from './machine'; -import { db } from './db'; -import { getAgentDecision, requiresAgentDecision } from './agent'; -import { transition } from 'xstate'; - -type Bindings = { - OPENAI_API_KEY: string; -}; - -const app = new Hono<{ Bindings: Bindings }>(); - -// GET / - Simple UI -app.get('/', async (c) => { - const sessionId = c.req.query('sessionId'); - let initialSession = null; - - if (sessionId) { - const session = db.getSession(sessionId); - if (session) { - initialSession = { - sessionId, - state: { value: session.value, context: session.context }, - }; - } - } - - return c.html(html` - - - - Email Agent - - - -

Email Agent

- -
- - -
- - - - - - - `); -}); - -// POST /sessions - Start new email session -app.post('/sessions', async (c) => { - const body = await c.req.json<{ userRequest: string }>(); - - const sessionId = db.createSession({ - userRequest: body.userRequest, - recipient: '', - subject: '', - body: '', - clarifications: [], - questions: [], - }); - - const session = db.getSession(sessionId)!; - - const response: { - sessionId: string; - state: { value: unknown; context: Record }; - agentResponse?: { type: string; [key: string]: unknown }; - } = { - sessionId, - state: { value: session.value, context: session.context }, - }; - - // Initial state requires agent decision - if (requiresAgentDecision(session.value)) { - console.log('requiresAgentDecision', session.value); - const event = await getAgentDecision( - { value: session.value, context: session.context }, - 'Help the user draft and send an email based on their request.', - c.env.OPENAI_API_KEY - ); - - console.log('event', event); - - if (event) { - const resolvedState = emailMachine.resolveState({ - value: session.value, - context: session.context, - }); - const [nextState] = transition(emailMachine, resolvedState, event as any); - - console.log('nextState', nextState); - - db.appendState(sessionId, { - value: nextState.value, - context: nextState.context, - event, - }); - - response.state = { value: nextState.value, context: nextState.context }; - response.agentResponse = event; - } - } - - return c.json(response); -}); - -// POST /sessions/:id/events - Send event to session -app.post('/sessions/:id/events', async (c) => { - const sessionId = c.req.param('id'); - const session = db.getSession(sessionId); - - if (!session) { - return c.json({ error: 'Session not found' }, 404); - } - - const event = await c.req.json<{ type: string; [key: string]: unknown }>(); - - // Transition with user event - const resolvedState = emailMachine.resolveState({ - value: session.value, - context: session.context, - }); - const [nextState] = transition(emailMachine, resolvedState, event as any); - - db.appendState(sessionId, { - value: nextState.value, - context: nextState.context, - event, - }); - - const response: { - state: { value: unknown; context: Record }; - agentResponse?: { type: string; [key: string]: unknown }; - } = { - state: { value: nextState.value, context: nextState.context }, - }; - - // If new state requires agent, call LLM - if (requiresAgentDecision(nextState.value)) { - console.log('requiresAgentDecision', nextState.value); - const agentEvent = await getAgentDecision( - { value: nextState.value, context: nextState.context }, - 'Continue helping draft the email based on the clarifications provided.', - c.env.OPENAI_API_KEY - ); - - console.log('agentEvent', agentEvent); - - if (agentEvent) { - const [afterAgentState] = transition( - emailMachine, - emailMachine.resolveState({ - value: nextState.value, - context: nextState.context, - }), - agentEvent as any - ); - - console.log('afterAgentState', afterAgentState); - - db.appendState(sessionId, { - value: afterAgentState.value, - context: afterAgentState.context, - event: agentEvent, - }); - - response.state = { - value: afterAgentState.value, - context: afterAgentState.context, - }; - response.agentResponse = agentEvent; - } - } - - return c.json(response); -}); - -// GET /sessions/:id - Get current state -app.get('/sessions/:id', (c) => { - const sessionId = c.req.param('id'); - const session = db.getSession(sessionId); - - if (!session) { - return c.json({ error: 'Session not found' }, 404); - } - - return c.json({ - sessionId, - state: { value: session.value, context: session.context }, - }); -}); - -// GET /sessions/:id/history - Get full append-only history -app.get('/sessions/:id/history', (c) => { - const sessionId = c.req.param('id'); - const session = db.getSession(sessionId); - - if (!session) { - return c.json({ error: 'Session not found' }, 404); - } - - return c.json({ sessionId, history: session.history }); -}); - -export default app; diff --git a/examples_old/hono/src/machine.ts b/examples_old/hono/src/machine.ts deleted file mode 100644 index bdcf230..0000000 --- a/examples_old/hono/src/machine.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { setup, assign } from 'xstate'; - -export const emailMachine = setup({ - types: { - context: {} as { - userRequest: string; - recipient: string; - subject: string; - body: string; - clarifications: string[]; - questions: string[]; - }, - events: {} as - | { type: 'askForClarification'; questions: string[] } - | { type: 'provideClarification'; answers: string } - | { type: 'submitEmail'; recipient: string; subject: string; body: string } - | { type: 'confirm' }, - }, -}).createMachine({ - id: 'emailAgent', - initial: 'checking', - context: { - userRequest: '', - recipient: '', - subject: '', - body: '', - clarifications: [], - questions: [], - }, - states: { - checking: { - // Agent decides: askForClarification or submitEmail - on: { - askForClarification: { - target: 'clarifying', - actions: assign({ - questions: ({ event }) => event.questions, - }), - }, - submitEmail: { - target: 'submitting', - actions: assign({ - recipient: ({ event }) => event.recipient, - subject: ({ event }) => event.subject, - body: ({ event }) => event.body, - }), - }, - }, - }, - clarifying: { - // Wait for user clarification - on: { - provideClarification: { - target: 'checking', - actions: assign({ - clarifications: ({ context, event }) => [ - ...context.clarifications, - event.answers, - ], - }), - }, - }, - }, - submitting: { - // User confirms or edits - on: { - confirm: 'done', - }, - }, - done: { - type: 'final', - }, - }, -}); diff --git a/examples_old/hono/src/style.css b/examples_old/hono/src/style.css deleted file mode 100644 index 50969c8..0000000 --- a/examples_old/hono/src/style.css +++ /dev/null @@ -1,3 +0,0 @@ -h1 { - font-family: Arial, Helvetica, sans-serif; -} diff --git a/examples_old/hono/src/utils.ts b/examples_old/hono/src/utils.ts deleted file mode 100644 index 5d9636b..0000000 --- a/examples_old/hono/src/utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AnyMachineSnapshot, AnyStateNode } from 'xstate'; - -export interface TransitionData { - eventType: string; - description?: string; -} - -export function getAllTransitions(state: AnyMachineSnapshot): TransitionData[] { - const nodes = state._nodes; - const transitions = (nodes as AnyStateNode[]) - .map((node) => [...(node as AnyStateNode).transitions.values()]) - .map((nodeTransitions) => { - return nodeTransitions.map((nodeEventTransitions) => { - return nodeEventTransitions.map((transition) => ({ - eventType: transition.eventType, - description: transition.description, - })); - }); - }) - .flat(2); - - return transitions; -} - -export function randomId() { - const timestamp = Date.now().toString(36); - const random = Math.random().toString(36).substring(2, 9); - return timestamp + random; -} diff --git a/examples_old/hono/tsconfig.json b/examples_old/hono/tsconfig.json deleted file mode 100644 index fe4b04f..0000000 --- a/examples_old/hono/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "lib": [ - "ESNext" - ], - "types": ["vite/client"], - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" - }, -} \ No newline at end of file diff --git a/examples_old/hono/vite.config.ts b/examples_old/hono/vite.config.ts deleted file mode 100644 index f626b72..0000000 --- a/examples_old/hono/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { cloudflare } from '@cloudflare/vite-plugin' -import { defineConfig } from 'vite' - -export default defineConfig({ - plugins: [cloudflare()] -}) diff --git a/examples_old/hono/wrangler.jsonc b/examples_old/hono/wrangler.jsonc deleted file mode 100644 index 8441ec8..0000000 --- a/examples_old/hono/wrangler.jsonc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "hono", - "compatibility_date": "2025-08-03", - "main": "./src/index.ts" -} \ No newline at end of file diff --git a/examples_old/joke.ts b/examples_old/joke.ts deleted file mode 100644 index e9ca1cd..0000000 --- a/examples_old/joke.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { assign, createActor, fromCallback, log, setup } from 'xstate'; -import { createAgent, fromDecision } from '../src'; -import { loadingAnimation } from './helpers/loader'; -import { z } from 'zod'; -import { openai } from '@ai-sdk/openai'; -import { fromTerminal } from './helpers/helpers'; - -export function getRandomFunnyPhrase() { - const funnyPhrases = [ - 'Concocting chuckles...', - 'Brewing belly laughs...', - 'Fabricating funnies...', - 'Assembling amusement...', - 'Molding merriment...', - 'Whipping up wisecracks...', - 'Generating guffaws...', - 'Inventing hilarity...', - 'Cultivating chortles...', - 'Hatching howlers...', - ]; - return funnyPhrases[Math.floor(Math.random() * funnyPhrases.length)]!; -} - -export function getRandomRatingPhrase() { - const ratingPhrases = [ - 'Assessing amusement...', - 'Evaluating hilarity...', - 'Ranking chuckles...', - 'Classifying cackles...', - 'Scoring snickers...', - 'Rating roars...', - 'Judging jollity...', - 'Measuring merriment...', - 'Rating rib-ticklers...', - ]; - return ratingPhrases[Math.floor(Math.random() * ratingPhrases.length)]!; -} - -const loader = fromCallback(({ input }: { input: string }) => { - const anim = loadingAnimation(input); - - return () => { - anim.stop(); - }; -}); - -const agent = createAgent({ - name: 'joke-teller', - model: openai('gpt-4-turbo'), - events: { - askForTopic: z.object({ - topic: z.string().describe('The topic for the joke'), - }), - 'agent.tellJoke': z.object({ - joke: z.string().describe('The joke text'), - }), - 'agent.endJokes': z.object({}).describe('End the jokes'), - 'agent.rateJoke': z.object({ - rating: z.number().min(1).max(10), - explanation: z.string(), - }), - 'agent.continue': z.object({}).describe('Continue'), - 'agent.markRelevancy': z.object({ - relevant: z.boolean().describe('Whether the joke was relevant'), - explanation: z - .string() - .describe('The explanation for why the joke was relevant or not'), - }), - }, - context: { - topic: z.string().describe('The topic for the joke'), - jokes: z.array(z.string()).describe('The jokes told so far'), - desire: z.string().nullable().describe('The user desire'), - lastRating: z.number().nullable().describe('The last joke rating'), - loader: z.string().nullable().describe('The loader text'), - }, -}); - -const jokeMachine = setup({ - types: agent.types, - actors: { - agent: fromDecision(agent), - loader, - getFromTerminal: fromTerminal, - }, -}).createMachine({ - id: 'joke', - context: () => ({ - topic: '', - jokes: [], - desire: null, - lastRating: null, - loader: null, - }), - initial: 'waitingForTopic', - states: { - waitingForTopic: { - invoke: { - src: 'getFromTerminal', - input: 'Give me a joke topic.', - onDone: { - actions: assign({ - topic: ({ event }) => event.output, - }), - target: 'tellingJoke', - }, - }, - }, - tellingJoke: { - invoke: [ - { - src: 'agent', - input: ({ context }) => ({ - context: { - topic: context.topic, - }, - goal: `Tell me a joke about the topic. Do not make any joke that is not relevant to the topic.`, - }), - }, - { - src: 'loader', - input: getRandomFunnyPhrase, - }, - ], - on: { - 'agent.tellJoke': { - actions: [ - assign({ - jokes: ({ context, event }) => [...context.jokes, event.joke], - }), - log(({ event }) => event.joke), - ], - target: 'relevance', - }, - }, - }, - relevance: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context: { - topic: context.topic, - lastJoke: context.jokes.at(-1), - }, - goal: 'An irrelevant joke has no reference to the topic. If the last joke is completely irrelevant to the topic, ask for a new joke topic. Otherwise, continue.', - }), - }, - on: { - 'agent.markRelevancy': [ - { - guard: ({ event }) => !event.relevant, - actions: log( - ({ event }) => 'Irrelevant joke: ' + event.explanation - ), - target: 'waitingForTopic', - description: 'Continue', - }, - { target: 'rateJoke' }, - ], - }, - }, - rateJoke: { - invoke: [ - { - src: 'agent', - input: ({ context }) => ({ - context: { - jokes: context.jokes, - }, - goal: `Rate the last joke on a scale of 1 to 10.`, - }), - }, - { - src: 'loader', - input: getRandomRatingPhrase, - }, - ], - on: { - 'agent.rateJoke': { - actions: [ - assign({ - lastRating: ({ event }) => event.rating, - }), - log( - ({ event }) => `Rating: ${event.rating}\n\n${event.explanation}` - ), - ], - target: 'decide', - }, - }, - }, - decide: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context: { - lastRating: context.lastRating, - }, - goal: `Choose what to do next, given the previous rating of the joke.`, - }), - }, - on: { - askForTopic: { - target: 'waitingForTopic', - actions: log("That joke wasn't good enough. Let's try again."), - description: - 'Ask for a new topic, because the last joke rated 6 or lower', - }, - 'agent.endJokes': { - target: 'end', - actions: log('That joke was good enough. Goodbye!'), - description: 'End the jokes, since the last joke rated 7 or higher', - }, - }, - }, - end: { - type: 'final', - }, - }, - exit: () => { - process.exit(); - }, -}); - -const actor = createActor(jokeMachine); - -actor.start(); diff --git a/examples_old/multi.ts b/examples_old/multi.ts deleted file mode 100644 index 1b5b74e..0000000 --- a/examples_old/multi.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createAgent, fromDecision } from '../src'; -import { z } from 'zod'; -import { assign, createActor, log, setup } from 'xstate'; -import { fromTerminal } from './helpers/helpers'; -import { openai } from '@ai-sdk/openai'; - -const agent = createAgent({ - name: 'multi', - model: openai('gpt-5.4-nano'), - events: { - 'agent.respond': z.object({ - response: z.string().describe('The response from the agent'), - }), - }, -}); - -const machine = setup({ - types: { - context: {} as { - topic: string | null; - discourse: string[]; - }, - }, - actors: { - getFromTerminal: fromTerminal, - agent: fromDecision(agent), - }, -}).createMachine({ - initial: 'asking', - context: { - topic: null, - discourse: [], - }, - states: { - asking: { - invoke: { - src: 'getFromTerminal', - input: 'What is the question?', - onDone: { - actions: assign({ - topic: ({ event }) => event.output, - }), - target: 'positiveResponse', - }, - }, - }, - positiveResponse: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context, - goal: 'Debate the topic, and take the positive position. Respond directly to the last message of the discourse. Keep it short.', - }), - }, - on: { - 'agent.respond': { - actions: [ - assign({ - discourse: ({ context, event }) => - context.discourse.concat(event.response), - }), - log(({ event }) => event.response), - ], - target: 'negativeResponse', - }, - }, - }, - negativeResponse: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - model: openai('gpt-3.5-turbo-16k-0613'), - context, - goal: 'Debate the topic, and take the negative position. Respond directly to the last message of the discourse. Keep it short.', - }), - }, - on: { - 'agent.respond': { - actions: [ - assign({ - discourse: ({ context, event }) => - context.discourse.concat(event.response), - }), - log(({ event }) => event.response), - ], - target: 'positiveResponse', - }, - }, - always: { - guard: ({ context }) => context.discourse.length >= 5, - target: 'debateOver', - }, - }, - debateOver: { - type: 'final', - }, - }, - exit: () => { - process.exit(); - }, -}); - -createActor(machine).start(); diff --git a/examples_old/newspaper.ts b/examples_old/newspaper.ts deleted file mode 100644 index 3671a25..0000000 --- a/examples_old/newspaper.ts +++ /dev/null @@ -1,324 +0,0 @@ -// Based on GPT Newspaper: -// https://github.com/assafelovic/gpt-newspaper -// https://gist.github.com/TheGreatBonnie/58dc21ebbeeb8cbb08df665db762738c - -import { TavilySearchAPIRetriever } from '@langchain/community/retrievers/tavily_search_api'; -import { ChatOpenAI } from '@langchain/openai'; -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; -import { assign, createActor, fromPromise, setup } from 'xstate'; - -interface AgentState { - topic: string; - searchResults?: string; - article?: string; - critique?: string; - revisionCount: number; -} - -function model() { - return new ChatOpenAI({ - temperature: 0, - modelName: 'gpt-4-1106-preview', - openAIApiKey: process.env.OPENAI_API_KEY, - }); -} - -async function search({ topic }: Pick): Promise { - const retriever = new TavilySearchAPIRetriever({ - k: 10, - apiKey: process.env.TAVILY_API_KEY, - }); - // let topic = state.agentState.topic; - // must be at least 5 characters long - if (topic.length < 5) { - topic = 'topic: ' + topic; - } - const docs = await retriever.invoke(topic); - return JSON.stringify(docs); -} - -async function curate( - input: Pick -): Promise { - const response = await model().invoke( - [ - new SystemMessage( - `You are a personal newspaper editor. - Your sole task is to return a list of URLs of the 5 most relevant articles for the provided topic or query as a JSON list of strings - in this format: - { - urls: ["url1", "url2", "url3", "url4", "url5"] - } - .`.replace(/\s+/g, ' ') - ), - new HumanMessage( - `Today's date is ${new Date().toLocaleDateString('en-GB')}. - Topic or Query: ${input.topic} - - Here is a list of articles: - ${input.searchResults}`.replace(/\s+/g, ' ') - ), - ], - { - response_format: { - type: 'json_object', - }, - } - ); - const urls = JSON.parse(response.content as string).urls; - const searchResults = JSON.parse(input.searchResults!); - const newSearchResults = searchResults.filter((result: any) => { - return urls.includes(result.metadata.source); - }); - return JSON.stringify(newSearchResults); -} - -async function critique( - input: Pick -): Promise { - let feedbackInstructions = ''; - if (input.critique) { - feedbackInstructions = - `The writer has revised the article based on your previous critique: ${input.critique} - The writer might have left feedback for you encoded between tags. - The feedback is only for you to see and will be removed from the final article. - `.replace(/\s+/g, ' '); - } - const response = await model().invoke([ - new SystemMessage( - `You are a personal newspaper writing critique. Your sole purpose is to provide short feedback on a written - article so the writer will know what to fix. - Today's date is ${new Date().toLocaleDateString('en-GB')} - Your task is to provide a really short feedback on the article only if necessary. - if you think the article is good, please return [DONE]. - you can provide feedback on the revised article or just - return [DONE] if you think the article is good. - Please return a string of your critique or [DONE].`.replace(/\s+/g, ' ') - ), - new HumanMessage( - `${feedbackInstructions} - This is the article: ${input.article}` - ), - ]); - const content = response.content as string; - console.log('critique:', content); - return content.includes('[DONE]') ? undefined : content; -} - -async function write( - input: Pick -): Promise { - const response = await model().invoke([ - new SystemMessage( - `You are a personal newspaper writer. Your sole purpose is to write a well-written article about a - topic using a list of articles. Write 5 paragraphs in markdown.`.replace( - /\s+/g, - ' ' - ) - ), - new HumanMessage( - `Today's date is ${new Date().toLocaleDateString('en-GB')}. - Your task is to write a critically acclaimed article for me about the provided query or - topic based on the sources. - Here is a list of articles: ${input.searchResults} - This is the topic: ${input.topic} - Please return a well-written article based on the provided information.`.replace( - /\s+/g, - ' ' - ) - ), - ]); - const content = response.content as string; - return content; -} - -async function revise( - input: Pick -): Promise { - const response = await model().invoke([ - new SystemMessage( - `You are a personal newspaper editor. Your sole purpose is to edit a well-written article about a - topic based on given critique.`.replace(/\s+/g, ' ') - ), - new HumanMessage( - `Your task is to edit the article based on the critique given. - This is the article: ${input.article} - This is the critique: ${input.critique} - Please return the edited article based on the critique given. - You may leave feedback about the critique encoded between tags like this: - here goes the feedback ...`.replace(/\s+/g, ' ') - ), - ]); - const content = response.content as string; - return content; -} - -const machine = setup({ - types: { - context: {} as AgentState, - }, - actors: { - search: fromPromise(({ input }: { input: Pick }) => { - return search(input); - }), - curate: fromPromise( - ({ input }: { input: Pick }) => { - return curate(input); - } - ), - critique: fromPromise( - ({ input }: { input: Pick }) => { - return critique(input); - } - ), - write: fromPromise( - ({ input }: { input: Pick }) => { - return write(input); - } - ), - revise: fromPromise( - ({ input }: { input: Pick }) => { - return revise(input); - } - ), - }, -}).createMachine({ - context: { - topic: 'Orlando', - revisionCount: 0, - }, - initial: 'search', - states: { - search: { - invoke: { - src: 'search', - input: ({ context }) => ({ - topic: context.topic, - }), - onDone: { - actions: assign({ - searchResults: ({ event }) => event.output, - }), - target: 'curate', - }, - }, - }, - curate: { - invoke: { - src: 'curate', - input: ({ context }) => ({ - topic: context.topic, - searchResults: context.searchResults!, - }), - onDone: { - actions: assign({ - searchResults: ({ event }) => event.output, - }), - target: 'write', - }, - }, - }, - write: { - invoke: { - src: 'write', - input: ({ context }) => ({ - topic: context.topic, - searchResults: context.searchResults!, - }), - onDone: { - actions: assign({ - article: ({ event }) => event.output, - }), - target: 'critique', - }, - }, - }, - critique: { - invoke: { - src: 'critique', - input: ({ context }) => ({ - article: context.article!, - critique: context.critique, - }), - onDone: [ - { - guard: ({ event }) => event.output === undefined, - target: 'done', - }, - { - actions: assign({ - article: ({ event }) => event.output, - }), - target: 'revise', - }, - ], - }, - }, - revise: { - always: { - guard: ({ context }) => context.revisionCount > 3, - target: 'done', - }, - entry: assign({ - revisionCount: ({ context }) => context.revisionCount + 1, - }), - invoke: { - src: 'revise', - input: ({ context }) => ({ - article: context.article!, - critique: context.critique, - }), - onDone: { - actions: assign({ - article: ({ event }) => event.output, - }), - target: 'revise', - reenter: true, - }, - }, - }, - done: { - type: 'final', - }, - }, - output: ({ context }) => context.article, -}); - -const actor = createActor(machine, { - // inspect: (inspEv) => { - // if (inspEv.type === '@xstate.event') { - // console.log(JSON.stringify(inspEv.event, null, 2)); - // } - // }, -}); - -actor.subscribe({ - next: (s) => { - console.log('State:', s.value); - console.log( - 'Context:', - JSON.stringify( - s.context, - (k, v) => { - if (typeof v === 'string') { - // truncate if longer than 50 chars - return v.length > 50 ? `${v.slice(0, 50)}...` : v; - } - return v; - }, - 2 - ) - ); - }, - complete: () => { - console.log(actor.getSnapshot().output); - }, - error: (err) => { - console.error(err); - }, -}); - -actor.start(); - -// keep the process alive by invoking a promise that never resolves -new Promise(() => {}); diff --git a/examples_old/number.ts b/examples_old/number.ts deleted file mode 100644 index e2afe85..0000000 --- a/examples_old/number.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { createAgent, fromDecision } from '../src'; -import { assign, createActor, log, setup } from 'xstate'; -import { z } from 'zod'; -import { openai } from '@ai-sdk/openai'; -import { fromTerminal } from './helpers/helpers'; - -const agent = createAgent({ - name: 'number-guesser', - model: openai('gpt-3.5-turbo-1106'), - events: { - 'agent.guess': z.object({ - number: z.number().min(1).max(10).describe('The number guessed'), - }), - }, -}); - -const machine = setup({ - types: { - context: {} as { - previousGuesses: number[]; - answer: number | null; - }, - events: agent.types.events, - }, - actors: { - agent: fromDecision(agent), - getFromTerminal: fromTerminal, - }, -}).createMachine({ - context: { - answer: null, - previousGuesses: [], - }, - initial: 'providing', - states: { - providing: { - invoke: { - src: 'getFromTerminal', - input: 'Enter a number between 1 and 10', - onDone: { - actions: assign({ - answer: ({ event }) => +event.output, - }), - target: 'guessing', - }, - }, - }, - guessing: { - always: { - guard: ({ context }) => - context.answer === context.previousGuesses.at(-1), - target: 'winner', - }, - invoke: { - src: 'agent', - input: ({ context }) => ({ - goal: ` - Guess the number between 1 and 10. The previous guesses were ${ - context.previousGuesses.length - ? context.previousGuesses.join(', ') - : 'not made yet' - } and the last result was ${ - context.previousGuesses.length === 0 - ? 'not given yet' - : context.previousGuesses.at(-1)! - context.answer! > 0 - ? 'too high' - : 'too low' - }. - `, - }), - }, - on: { - 'agent.guess': { - actions: [ - assign({ - previousGuesses: ({ context, event }) => [ - ...context.previousGuesses, - event.number, - ], - }), - log(({ event }) => event.number), - ], - target: 'guessing', - reenter: true, - }, - }, - }, - winner: { - entry: log('You guessed the correct number!'), - type: 'final', - }, - }, - exit: () => { - process.exit(); - }, -}); - -const actor = createActor(machine, { - input: { answer: 4 }, -}); - -actor.start(); diff --git a/examples_old/raffle.ts b/examples_old/raffle.ts deleted file mode 100644 index 323672f..0000000 --- a/examples_old/raffle.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { z } from 'zod'; -import { createAgent, fromDecision } from '../src'; -import { openai } from '@ai-sdk/openai'; -import { assign, createActor, log, setup } from 'xstate'; -import { fromTerminal } from './helpers/helpers'; - -const agent = createAgent({ - name: 'raffle-chooser', - model: openai('gpt-4-turbo'), - events: { - 'agent.collectEntries': z.object({}).describe('Collect more entries'), - 'agent.draw': z.object({}).describe('Draw a winner'), - 'agent.reportWinner': z.object({ - winningEntry: z.string().describe('The winning entry'), - firstRunnerUp: z.string().describe('The first runner up entry'), - secondRunnerUp: z.string().describe('The second runner up entry'), - explanation: z - .string() - .describe('Explanation for why you chose the winning entry'), - }), - }, -}); - -const machine = setup({ - types: { - context: {} as { - lastInput: string | null; - entries: string[]; - }, - events: {} as typeof agent.types.events | { type: 'draw' }, - }, - actors: { agent: fromDecision(agent), getFromTerminal: fromTerminal }, -}).createMachine({ - context: { - lastInput: null, - entries: [], - }, - initial: 'entering', - states: { - entering: { - entry: log(({ context }) => context.entries), - invoke: { - src: 'getFromTerminal', - input: 'What technology are you most interested in right now?', - onDone: [ - { - actions: assign({ - lastInput: ({ event }) => event.output, - }), - target: 'determining', - }, - ], - }, - }, - determining: { - invoke: { - src: 'agent', - input: { - context: true, - goal: 'If the last input explicitly says to end the drawing and/or choose a winner, start the drawing process. Otherwise, get more entries.', - }, - }, - on: { - 'agent.collectEntries': { - target: 'entering', - actions: assign({ - entries: ({ context }) => [...context.entries, context.lastInput!], - lastInput: null, - }), - }, - 'agent.draw': 'drawing', - }, - }, - drawing: { - entry: log('And the winner is...'), - invoke: { - src: 'agent', - input: { - context: true, - goal: 'Choose the technology that sounds most exciting to you from the entries. Be as unbiased as possible in your choice. Explain why you chose the winning entry.', - }, - }, - on: { - 'agent.reportWinner': { - actions: log( - ({ event }) => - `\n🎉🎉🎉 ${event.winningEntry} 🎉🎉🎉\n\n${event.explanation}` - ), - target: 'winner', - }, - }, - }, - winner: { - type: 'final', - }, - }, - exit: () => { - process.exit(0); - }, -}); - -const actor = createActor(machine); - -actor.start(); diff --git a/examples_old/sandbox.ts b/examples_old/sandbox.ts deleted file mode 100644 index 4bbb62d..0000000 --- a/examples_old/sandbox.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { z } from 'zod'; -import { createAgent } from '../src'; -import { openai } from '@ai-sdk/openai'; -import { createMachine } from 'xstate'; - -const agent = createAgent({ - model: openai('gpt-4o'), - events: { - doSomething: z.object({}).describe('Do something'), - }, -}); - -async function main() { - const machine = createMachine({ - on: { - doSomething: {}, - }, - }); - const result = await agent.decide({ - goal: 'Do not do anything', - state: { value: {}, context: {} }, - machine, - }); - - console.log(result); -} - -main(); diff --git a/examples_old/simple.ts b/examples_old/simple.ts deleted file mode 100644 index 25c8bab..0000000 --- a/examples_old/simple.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createAgent, fromDecision } from '../src'; -import { z } from 'zod'; -import { setup, createActor } from 'xstate'; -import { openai } from '@ai-sdk/openai'; - -const agent = createAgent({ - name: 'simple', - model: openai('gpt-3.5-turbo-16k-0613'), - events: { - 'agent.thought': z.object({ - text: z.string().describe('The text of the thought'), - }), - }, -}); - -const machine = setup({ - actors: { agent: fromDecision(agent) }, -}).createMachine({ - initial: 'thinking', - states: { - thinking: { - invoke: { - src: 'agent', - input: 'Think about a random topic, and then share that thought.', - }, - on: { - 'agent.thought': { - actions: ({ event }) => console.log(event.text), - target: 'thought', - }, - }, - }, - thought: { - type: 'final', - }, - }, -}); - -const actor = createActor(machine).start(); diff --git a/examples_old/support.ts b/examples_old/support.ts deleted file mode 100644 index 7f23725..0000000 --- a/examples_old/support.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { openai } from '@ai-sdk/openai'; -import { createAgent, fromDecision } from '../src'; -import { z } from 'zod'; -import { createActor, log, setup } from 'xstate'; - -const agent = createAgent({ - name: 'support-agent', - model: openai('gpt-4-1106-preview'), - events: { - 'agent.respond': z.object({ - response: z.string().describe('The response from the agent'), - }), - 'agent.frontline.classify': z.object({ - category: z - .enum(['billing', 'technical', 'other']) - .describe('The category of the customer issue'), - }), - 'agent.refund': z - .object({ - response: z.string().describe('The response from the agent'), - }) - .describe('The agent wants to refund the user'), - 'agent.technical.solve': z.object({ - solution: z - .string() - .describe('The solution provided by the technical agent'), - }), - 'agent.endConversation': z - .object({ - response: z.string().describe('The response from the agent'), - }) - .describe('The agent ends the conversation'), - }, -}); - -const machine = setup({ - types: { - events: agent.types.events, - input: {} as string, - context: {} as { - customerIssue: string; - }, - }, - actors: { agent: fromDecision(agent) }, -}).createMachine({ - initial: 'frontline', - context: ({ input }) => ({ - customerIssue: input, - }), - states: { - frontline: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context, - system: `You are frontline support staff for LangCorp, a company that sells computers. - Be concise in your responses. - You can chat with customers and help them with basic questions, but if the customer is having a billing or technical problem, - do not try to answer the question directly or gather information. - Instead, immediately transfer them to the billing or technical team by asking the user to hold for a moment. - Otherwise, just respond conversationally.`, - goal: `The previous conversation is an interaction between a customer support representative and a user. - Classify whether the representative is routing the user to a billing or technical team, or whether they are just responding conversationally.`, - }), - }, - on: { - 'agent.frontline.classify': [ - { - actions: log(({ event }) => event), - guard: ({ event }) => event.category === 'billing', - target: 'billing', - }, - { - actions: log(({ event }) => event), - guard: ({ event }) => event.category === 'technical', - target: 'technical', - }, - { - actions: log(({ event }) => event), - target: 'conversational', - }, - ], - }, - }, - billing: { - invoke: { - src: 'agent', - input: { - system: - 'Your job is to detect whether a billing support representative wants to refund the user.', - goal: `The following text is a response from a customer support representative. Extract whether they want to refund the user or not.`, - }, - }, - on: { - 'agent.refund': { - actions: log(({ event }) => event), - target: 'refund', - }, - }, - }, - technical: { - invoke: { - src: 'agent', - input: { - context: true, - system: `You are an expert at diagnosing technical computer issues. You work for a company called LangCorp that sells computers. Help the user to the best of your ability, but be concise in your responses.`, - goal: 'Solve the customer issue.', - }, - }, - on: { - 'agent.technical.solve': { - actions: log(({ event }) => event), - target: 'conversational', - }, - }, - }, - conversational: { - invoke: { - src: 'agent', - input: { - goal: 'You are a customer support agent that is ending the conversation with the customer. Respond politely and thank them for their time.', - }, - }, - on: { - 'agent.endConversation': { - actions: log(({ event }) => event), - target: 'end', - }, - }, - }, - refund: { - entry: () => console.log('Refunding...'), - after: { - 1000: { target: 'conversational' }, - }, - }, - end: { - type: 'final', - }, - }, -}); - -const actor = createActor(machine, { - input: `I've changed my mind and I want a refund for order #182818!`, -}); - -actor.start(); diff --git a/examples_old/ticTacToe.ts b/examples_old/ticTacToe.ts deleted file mode 100644 index 3feb843..0000000 --- a/examples_old/ticTacToe.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { assign, setup, assertEvent, createActor } from 'xstate'; -import { z } from 'zod'; -import { createAgent, fromDecision, fromTextStream } from '../src'; -import { openai } from '@ai-sdk/openai'; - -const agent = createAgent({ - name: 'tic-tac-toe-bot', - model: openai('gpt-4-0125-preview'), - events: { - 'agent.x.play': z.object({ - index: z - .number() - .min(0) - .max(8) - .describe('The index of the cell to play on'), - }), - 'agent.o.play': z.object({ - index: z - .number() - .min(0) - .max(8) - .describe('The index of the cell to play on'), - }), - reset: z.object({}).describe('Reset the game to the initial state'), - }, - context: { - board: z - .array(z.union([z.literal(null), z.literal('x'), z.literal('o')])) - .describe('The 3x3 board represented as a 9-element array.'), - moves: z - .number() - .min(0) - .max(9) - .describe('The number of moves made in the game.'), - player: z - .union([z.literal('x'), z.literal('o')]) - .describe('The current player (x or o)'), - gameReport: z.string(), - }, -}); - -type Player = 'x' | 'o'; - -const initialContext = { - board: Array(9).fill(null) as Array, - moves: 0, - player: 'x' as Player, - gameReport: '', -} satisfies typeof agent.types.context; - -function getWinner(board: typeof initialContext.board): Player | null { - const lines = [ - [0, 1, 2], - [3, 4, 5], - [6, 7, 8], - [0, 3, 6], - [1, 4, 7], - [2, 5, 8], - [0, 4, 8], - [2, 4, 6], - ] as const; - for (const [a, b, c] of lines) { - if (board[a] !== null && board[a] === board[b] && board[a] === board[c]) { - return board[a]!; - } - } - return null; -} - -export const ticTacToeMachine = setup({ - types: { - context: agent.types.context, - events: agent.types.events, - }, - actors: { - agent: fromDecision(agent), - gameReporter: fromTextStream(agent), - }, - actions: { - updateBoard: assign({ - board: ({ context, event }) => { - assertEvent(event, ['agent.x.play', 'agent.o.play']); - const updatedBoard = [...context.board]; - updatedBoard[event.index] = context.player; - return updatedBoard; - }, - moves: ({ context }) => context.moves + 1, - player: ({ context }) => (context.player === 'x' ? 'o' : 'x'), - }), - resetGame: assign(initialContext), - printBoard: ({ context }) => { - // Print the context.board in a 3 x 3 grid format - let boardString = ''; - for (let i = 0; i < context.board.length; i++) { - if ([0, 3, 6].includes(i)) { - boardString += context.board[i] ?? ' '; - } else { - boardString += ' | ' + (context.board[i] ?? ' '); - if ([2, 5].includes(i)) { - boardString += '\n--+---+--\n'; - } - } - } - - console.log(boardString); - }, - }, - guards: { - checkWin: ({ context }) => { - const winner = getWinner(context.board); - - return !!winner; - }, - checkDraw: ({ context }) => { - return context.moves === 9; - }, - isValidMove: ({ context, event }) => { - try { - assertEvent(event, ['agent.o.play', 'agent.x.play']); - } catch { - return false; - } - - return context.board[event.index] === null; - }, - }, -}).createMachine({ - initial: 'playing', - context: initialContext, - states: { - playing: { - always: [ - { target: 'gameOver.winner', guard: 'checkWin' }, - { target: 'gameOver.draw', guard: 'checkDraw' }, - ], - initial: 'x', - states: { - x: { - entry: 'printBoard', - on: { - 'agent.x.play': [ - { - target: 'o', - guard: 'isValidMove', - actions: 'updateBoard', - }, - { target: 'x', reenter: true }, - ], - }, - }, - o: { - entry: 'printBoard', - on: { - 'agent.o.play': [ - { - target: 'x', - guard: 'isValidMove', - actions: 'updateBoard', - }, - { target: 'o', reenter: true }, - ], - }, - }, - }, - }, - gameOver: { - initial: 'winner', - invoke: { - src: 'gameReporter', - input: ({ context }) => ({ - context: { - events: agent.getObservations().map((o) => o.event), - board: context.board, - }, - prompt: 'Provide a short game report analyzing the game.', - }), - onSnapshot: { - actions: assign({ - gameReport: ({ context, event }) => { - console.log( - context.gameReport + (event.snapshot.context?.textDelta ?? '') - ); - return ( - context.gameReport + (event.snapshot.context?.textDelta ?? '') - ); - }, - }), - }, - }, - states: { - winner: { - tags: 'winner', - }, - draw: { - tags: 'draw', - }, - }, - on: { - reset: { - target: 'playing', - actions: 'resetGame', - }, - }, - }, - }, -}); - -const actor = createActor(ticTacToeMachine); - -agent.interact(actor, (observed) => { - if (observed.state.matches('playing')) { - return { - goal: `You are playing a game of tic tac toe. This is the current game state. The 3x3 board is represented by a 9-element array. The first element is the top-left cell, the second element is the top-middle cell, the third element is the top-right cell, the fourth element is the middle-left cell, and so on. The value of each cell is either null, x, or o. The value of null means that the cell is empty. The value of x means that the cell is occupied by an x. The value of o means that the cell is occupied by an o. - -${JSON.stringify(observed.state.context, null, 2)} - -Execute the single best next move to try to win the game. Do not play on an existing cell.`, - }; - } - - return; -}); - -actor.start(); diff --git a/examples_old/todo.ts b/examples_old/todo.ts deleted file mode 100644 index 80e9e13..0000000 --- a/examples_old/todo.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { assign, setup, assertEvent, createActor, createMachine } from 'xstate'; -import { z } from 'zod'; -import { createAgent, fromDecision } from '../src'; -import { openai } from '@ai-sdk/openai'; -import { fromTerminal } from './helpers/helpers'; - -const agent = createAgent({ - name: 'todo', - model: openai('gpt-4o'), - events: { - addTodo: z.object({ - title: z.string().min(1).max(100).describe('The title of the todo'), - content: z.string().min(1).max(100).describe('The content of the todo'), - }), - deleteTodo: z.object({ - index: z.number().describe('The index of the todo to delete'), - }), - toggleTodo: z - .object({ - index: z.number().describe('The index of the todo to toggle'), - }) - .describe('Toggle whether the todo item is done or not'), - doNothing: z.object({}).describe('Do nothing'), - }, -}); - -interface Todo { - title: string; - content: string; - done: boolean; -} - -const machine = setup({ - types: { - context: {} as { - todos: Todo[]; - command: string | null; - }, - events: {} as - | typeof agent.types.events - | { type: 'assist'; command: string }, - }, - actors: { agent: fromDecision(agent), getFromTerminal: fromTerminal }, -}).createMachine({ - context: { - command: null, - todos: [], - }, - on: { - addTodo: { - actions: assign({ - todos: ({ context, event }) => [ - ...context.todos, - { - title: event.title, - content: event.content, - done: false, - }, - ], - command: null, - }), - target: '.idle', - }, - deleteTodo: { - actions: assign({ - todos: ({ context, event }) => { - const todos = [...context.todos]; - todos.splice(event.index, 1); - return todos; - }, - command: null, - }), - target: '.idle', - }, - toggleTodo: { - actions: assign({ - todos: ({ context, event }) => { - const todos = context.todos.map((todo, i) => { - if (i === event.index) { - return { - ...todo, - done: !todo.done, - }; - } - return todo; - }); - - return todos; - }, - command: null, - }), - target: '.idle', - }, - doNothing: { target: '.idle' }, - }, - initial: 'idle', - states: { - idle: { - invoke: { - src: 'getFromTerminal', - input: '\nEnter a command:', - onDone: { - actions: assign({ - command: ({ event }) => event.output, - }), - target: 'assisting', - }, - }, - on: { - assist: { - target: 'assisting', - actions: assign({ - command: ({ event }) => event.command, - }), - }, - }, - }, - assisting: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context: { - command: context.command, - todos: context.todos, - }, - goal: 'Interpret the command as an action for this todo list; for example, "I need donuts" would add a todo item with the message "Get donuts".', - }), - }, - }, - }, -}); - -const actor = createActor(machine); -actor.subscribe((s) => { - console.log(s.context.todos); -}); -actor.start(); diff --git a/examples_old/tutor.ts b/examples_old/tutor.ts deleted file mode 100644 index 3a11f6b..0000000 --- a/examples_old/tutor.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { assign, createActor, log, setup } from 'xstate'; -import { fromTerminal } from './helpers/helpers'; -import { createAgent, fromDecision } from '../src'; -import { z } from 'zod'; -import { openai } from '@ai-sdk/openai'; - -const agent = createAgent({ - name: 'tutor', - model: openai('gpt-4-1106-preview'), - events: { - teach: z.object({ - instruction: z - .string() - .describe( - 'The feedback to give the human, correcting any grammatical errors, misspellings, etc.' - ), - }), - respond: z.object({ - response: z.string().describe('The response to the human in Spanish'), - }), - }, - system: - 'You are an expert Spanish tutor. You will respond to the human in Spanish.', -}); - -const machine = setup({ - types: { - context: {} as { - conversation: string[]; - }, - events: agent.types.events, - }, - actors: { agent: fromDecision(agent), getFromTerminal: fromTerminal }, -}).createMachine({ - initial: 'human', - context: { - conversation: [], - }, - states: { - human: { - invoke: { - src: 'getFromTerminal', - input: 'Say something in Spanish:', - onDone: { - actions: assign({ - conversation: ({ context, event }) => - context.conversation.concat(`User: ` + event.output), - }), - target: 'ai', - }, - }, - }, - ai: { - initial: 'teaching', - states: { - teaching: { - invoke: { - src: 'agent', - input: () => ({ - context: true, - goal: 'Give brief feedback to the human based on the most recent response of the conversation', - maxTokens: 100, - }), - }, - on: { - teach: { - actions: ({ event }) => console.log(event.instruction), - target: 'responding', - }, - }, - }, - responding: { - invoke: { - src: 'agent', - input: () => ({ - context: true, - goal: 'Respond to the last message of the conversation in Spanish', - }), - }, - on: { - respond: { - actions: [ - assign({ - conversation: ({ context, event }) => - context.conversation.concat(`Agent: ` + event.response), - }), - log(({ event }) => event.response), - ], - target: 'done', - }, - }, - }, - done: { type: 'final' }, - }, - onDone: { target: 'human' }, - }, - }, -}); - -createActor(machine).start(); diff --git a/examples_old/verify.ts b/examples_old/verify.ts deleted file mode 100644 index 240b60c..0000000 --- a/examples_old/verify.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { assign, createActor, setup, log } from 'xstate'; -import { fromTerminal } from './helpers/helpers'; -import { createAgent, fromDecision } from '../src'; -import { z } from 'zod'; -import { openai } from '@ai-sdk/openai'; - -const agent = createAgent({ - name: 'verifier', - model: openai('gpt-3.5-turbo-16k-0613'), - events: { - 'agent.validateAnswer': z.object({ - isValid: z.boolean(), - feedback: z.string(), - }), - 'agent.answerQuestion': z.object({ - answer: z.string().describe('The answer from the agent'), - }), - 'agent.validateQuestion': z.object({ - isValid: z - .boolean() - .describe( - 'Whether the question is a valid question; that is, is it possible to even answer this question in a verifiably correct way?' - ), - explanation: z - .string() - .describe('An explanation for why the question is or is not valid'), - }), - }, -}); - -const machine = setup({ - types: { - context: {} as { - question: string | null; - answer: string | null; - validation: string | null; - }, - events: agent.types.events, - }, - actors: { - getFromTerminal: fromTerminal, - agent: fromDecision(agent), - }, -}).createMachine({ - initial: 'askQuestion', - context: { question: null, answer: null, validation: null }, - states: { - askQuestion: { - invoke: { - src: 'getFromTerminal', - input: 'Ask a (potentially silly) question', - onDone: { - actions: assign({ - question: ({ event }) => event.output, - }), - target: 'validateQuestion', - }, - }, - }, - validateQuestion: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - goal: `Validate this question: ${context.question!}`, - }), - }, - on: { - 'agent.validateQuestion': [ - { - target: 'askQuestion', - guard: ({ event }) => !event.isValid, - actions: log(({ event }) => event.explanation), - }, - { - target: 'answerQuestion', - }, - ], - }, - }, - answerQuestion: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - goal: `Answer this question: ${context.question}`, - }), - }, - on: { - 'agent.answerQuestion': { - actions: assign({ - answer: ({ event }) => event.answer, - }), - target: 'validateAnswer', - }, - }, - }, - validateAnswer: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - goal: `Validate if this is a good answer to the question: ${context.question}\nAnswer provided: ${context.answer}`, - }), - }, - on: { - 'agent.validateAnswer': { - actions: assign({ - validation: ({ event }) => event.feedback, - }), - }, - }, - }, - }, -}); - -const actor = createActor(machine, {}); - -actor.subscribe((s) => { - console.log(s.value, s.context); -}); - -actor.start(); diff --git a/examples_old/weather.ts b/examples_old/weather.ts deleted file mode 100644 index b918bfe..0000000 --- a/examples_old/weather.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { createAgent, fromDecision } from '../src'; -import { assign, createActor, fromPromise, log, setup } from 'xstate'; -import { fromTerminal } from './helpers/helpers'; -import { z } from 'zod'; -import { openai } from '@ai-sdk/openai'; - -async function searchTavily( - input: string, - options: { - maxResults?: number; - apiKey: string; - } -) { - const body: Record = { - query: input, - max_results: options.maxResults, - api_key: options.apiKey, - }; - - const response = await fetch('https://api.tavily.com/search', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(body), - }); - - const json = await response.json(); - if (!response.ok) { - throw new Error( - `Request failed with status code ${response.status}: ${json.error}` - ); - } - if (!Array.isArray(json.results)) { - throw new Error(`Could not parse Tavily results. Please try again.`); - } - return JSON.stringify(json.results); -} - -const getWeather = fromPromise(async ({ input }: { input: string }) => { - const results = await searchTavily( - `Get the weather for this location: ${input}`, - { - maxResults: 5, - apiKey: process.env.TAVILY_API_KEY!, - } - ); - return results; -}); - -const agent = createAgent({ - name: 'weather', - model: openai('gpt-4-1106-preview'), - events: { - 'agent.getWeather': z.object({ - location: z.string().describe('The location to get the weather for'), - }), - 'agent.reportWeather': z.object({ - location: z - .string() - .describe('The location the weather is being reported for'), - highF: z.number().describe('The high temperature today in Fahrenheit'), - lowF: z.number().describe('The low temperature today in Fahrenheit'), - summary: z.string().describe('A summary of the weather conditions'), - }), - 'agent.doSomethingElse': z - .object({}) - .describe( - 'Do something else, because the user did not provide a location' - ), - }, -}); - -const machine = setup({ - types: { - context: {} as { - location: string; - history: string[]; - count: number; - }, - events: agent.types.events, - }, - actors: { - agent: fromDecision(agent), - getWeather, - getFromTerminal: fromTerminal, - }, -}).createMachine({ - initial: 'getLocation', - context: { - location: '', - count: 0, - history: [], - }, - states: { - getLocation: { - invoke: { - src: 'getFromTerminal', - input: 'Location?', - onDone: { - actions: assign({ - location: ({ event }) => event.output, - }), - target: 'decide', - }, - }, - always: { - guard: ({ context }) => context.count >= 3, - target: 'stopped', - }, - }, - decide: { - entry: log('Deciding...'), - invoke: { - src: 'agent', - input: ({ context }) => ({ - context: { - location: context.location, - }, - goal: `Decide what to do based on the given location, which may or may not be a location`, - }), - }, - on: { - 'agent.getWeather': { - actions: log(({ event }) => event), - target: 'gettingWeather', - }, - 'agent.doSomethingElse': 'getLocation', - }, - }, - gettingWeather: { - entry: log('Getting weather...'), - invoke: { - src: 'getWeather', - input: ({ context }) => context.location, - onDone: { - actions: [ - log(({ event }) => event.output), - assign({ - count: ({ context }) => context.count + 1, - }), - ], - target: 'reportWeather', - }, - }, - }, - reportWeather: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - goal: 'Report the weather', // TODO - }), - }, - on: { - 'agent.reportWeather': { - actions: log(({ event }) => event), - target: 'getLocation', - }, - }, - }, - stopped: { - entry: log('You have used up your search quota. Goodbye!'), - }, - }, - exit: () => { - process.exit(); - }, -}); - -const actor = createActor(machine, { - input: { - location: 'New York', - }, -}); -actor.subscribe((s) => { - console.log(s.value); -}); -actor.start(); diff --git a/examples_old/wiki.ts b/examples_old/wiki.ts deleted file mode 100644 index 849c13e..0000000 --- a/examples_old/wiki.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { z } from 'zod'; -import { createAgent } from '../src'; -import { openai } from '@ai-sdk/openai'; -import { CoreMessage, generateText, streamText, tool } from 'ai'; - -const agent = createAgent({ - name: 'wiki', - model: openai('gpt-4-turbo'), - events: { - provideAnswer: z.object({ - answer: z.string().describe('The answer'), - }), - }, -}); - -agent.onMessage((msg) => { - console.log(msg); -}); - -async function main() { - await generateText({ - model: agent.model, - prompt: 'When was Deadpool 2 released?', - }); - - const response2 = await streamText({ - model: agent.model, - messages: (agent.getMessages() as CoreMessage[]).concat({ - role: 'user', - content: 'What about the first one?', - }), - }); - - let text = ''; - - for await (const t of response2.textStream) { - text += t; - console.log(text); - } -} - -main(); diff --git a/examples_old/word.ts b/examples_old/word.ts deleted file mode 100644 index d392919..0000000 --- a/examples_old/word.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { assign, createActor, log, setup } from 'xstate'; -import { fromTerminal } from './helpers/helpers'; -import { createAgent, fromDecision } from '../src'; -import { z } from 'zod'; -import { openai } from '@ai-sdk/openai'; - -const context = { - word: null as string | null, - guessedWord: null as string | null, - lettersGuessed: [] as string[], -}; - -const agent = createAgent({ - name: 'word', - model: openai('gpt-4o'), - events: { - 'agent.guessLetter': z.object({ - letter: z.string().min(1).max(1).describe('The letter guessed'), - reasoning: z.string().describe('The reasoning behind the guess'), - }), - - 'agent.guessWord': z.object({ - word: z.string().describe('The word guessed'), - }), - - 'agent.respond': z.object({ - response: z - .string() - .describe( - 'The response from the agent, detailing why the guess was correct or incorrect based on the letters guessed.' - ), - }), - }, -}); - -const wordGuesserMachine = setup({ - types: { - context: {} as typeof context, - events: agent.types.events, - }, - actors: { - agent: fromDecision(agent), - getFromTerminal: fromTerminal, - }, - actions: { - resetContext: assign(context), - }, -}).createMachine({ - initial: 'providingWord', - context, - states: { - providingWord: { - entry: 'resetContext', - invoke: { - src: 'getFromTerminal', - input: 'Enter a word, and an agent will try to guess it.', - onDone: { - actions: assign({ - word: ({ event }) => event.output, - }), - target: 'guessing', - }, - }, - }, - guessing: { - always: { - guard: ({ context }) => context.lettersGuessed.length > 10, - target: 'finalGuess', - }, - invoke: { - src: 'agent', - input: ({ context }) => ({ - context: { - wordLength: context.word!.length, - lettersGuessed: context.lettersGuessed, - lettersMatched: context - .word!.split('') - .map((letter) => - context.lettersGuessed.includes(letter.toUpperCase()) - ? letter.toUpperCase() - : '_' - ) - .join(''), - }, - goal: `You are trying to guess the word. Please make your next guess - guess a letter or, if you think you know the word, guess the full word. You can only make 10 total guesses. If you are confident you know the word, it is better to guess the word.`, - }), - }, - on: { - 'agent.guessLetter': { - actions: [ - assign({ - lettersGuessed: ({ context, event }) => { - return [...context.lettersGuessed, event.letter.toUpperCase()]; - }, - }), - log(({ event }) => event), - ], - target: 'guessing', - reenter: true, - }, - 'agent.guessWord': { - actions: [ - assign({ - guessedWord: ({ event }) => event.word, - }), - log(({ event }) => event), - ], - target: 'gameOver', - }, - }, - }, - finalGuess: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context: { - lettersGuessed: context.lettersGuessed, - }, - goal: `You have used all 10 guesses. These letters matched: ${context - .word!.split('') - .map((letter) => - context.lettersGuessed.includes(letter.toUpperCase()) - ? letter.toUpperCase() - : '_' - ) - .join('')}. Guess the word.`, - }), - }, - on: { - 'agent.guessWord': { - actions: [ - assign({ - guessedWord: ({ event }) => event.word, - }), - log(({ event }) => event), - ], - target: 'gameOver', - }, - }, - }, - gameOver: { - invoke: { - src: 'agent', - input: ({ context }) => ({ - context, - goal: `Why do you think you won or lost?`, - }), - }, - entry: log(({ context }) => { - if ( - context.guessedWord?.toUpperCase() === context.word?.toUpperCase() - ) { - return 'The agent won!'; - } else { - return 'The agent lost! The word was ' + context.word; - } - }), - on: { - 'agent.respond': { - actions: log(({ event }) => event.response), - target: 'providingWord', - }, - }, - }, - }, - exit: () => process.exit(), -}); - -const game = createActor(wordGuesserMachine); - -game.start(); diff --git a/readme.md b/readme.md index 9348644..c48c6f5 100644 --- a/readme.md +++ b/readme.md @@ -24,9 +24,8 @@ Import `createAgentSchemas(...)` and `setupAgent(...)` from `@statelyai/agent`: import { createAgentSchemas, setupAgent, - transitionResult, } from '@statelyai/agent'; -import { assign, initialTransition } from 'xstate'; +import { assign } from 'xstate'; import { z } from 'zod'; const contextSchema = z.object({ @@ -74,20 +73,20 @@ const machine = agent.createMachine({ }, }); -let [snapshot, actions] = initialTransition(machine, { prompt: 'Why XState?' }); +let step = machine.initial({ prompt: 'Why XState?' }); -while (snapshot.status !== 'done') { - for (const task of machine.getTasks(actions, snapshot)) { +while (!step.done) { + for (const task of step.tasks) { const result = await machine.execute(task, { generateText: (request) => generateText(request), // any SDK/framework streamText: (request) => streamText(request), }); - [snapshot, actions] = transitionResult(machine, snapshot, task, result); + step = machine.resolve(step, task, result); } } ``` -This is normal XState underneath: use pure `initialTransition(...)` / `transitionResult(...)`, or use `createActor(...)`, snapshots, persistence, guards, actions, and host-provided actors. `setupAgent(...)` adds schema-derived concrete types and retained schemas; `withTasks(...)` adds reusable typed task construction, strongly typed source names, typed invoke input, typed `event.output`, `machine.getTasks(...)`, and `machine.execute(...)`. +This is normal XState underneath: use `machine.initial(...)`, `machine.transition(...)`, and `machine.resolve(...)` for the blessed step loop; drop down to pure `initialTransition(...)` / `transitionResult(...)`; or use `createActor(...)`, snapshots, persistence, guards, actions, and host-provided actors. `setupAgent(...)` adds schema-derived concrete types and retained schemas; `withTasks(...)` adds reusable typed task construction, strongly typed source names, typed invoke input, typed `event.output`, `step.tasks`, `machine.getTasks(...)`, and `machine.execute(...)`. When a task declares `events`, `machine.getTasks(...)` returns `event.` tools for those events only if they are currently legal from the snapshot. That lets a model choose legal machine events, such as moves in a game, without exposing every transition. @@ -119,6 +118,6 @@ Burr parity is tracked in [`docs/burr-parity.md`](/Users/davidkpiano/Code/agent/ ## Runtime -Runtime is normal XState. Use pure `initialTransition(...)` / `transitionResult(...)` when a framework wants to own execution, or use `createActor(...)`, `toPromise(...)`, snapshots, persisted snapshots, `machine.provide({ actors })`, and your framework transport of choice. Model/tool execution stays under your control. +Runtime is normal XState. Use the agent step helpers when you want the package to collect tasks for you, pure `initialTransition(...)` / `transitionResult(...)` when a framework wants to own every transition detail, or `createActor(...)`, `toPromise(...)`, snapshots, persisted snapshots, `machine.provide({ actors })`, and your framework transport of choice. Model/tool execution stays under your control. **Read the documentation: [stately.ai/docs/agents](https://stately.ai/docs/agents)** diff --git a/scripts/agent-convert.ts b/scripts/agent-convert.ts index 85d3898..ae630ff 100644 --- a/scripts/agent-convert.ts +++ b/scripts/agent-convert.ts @@ -47,10 +47,14 @@ async function main() { function writeWarnings(warnings: AgentGraphWarning[]) { for (const warning of warnings) { + const location = warning.event + ? `${warning.state} on ${warning.event}:` + : `${warning.state}:`; process.stderr.write( [ '[agent:convert]', - `${warning.state} on ${warning.event}:`, + warning.code, + location, warning.message, ].join(' ') + '\n' ); diff --git a/src/burr-equivalents/raw-xstate.test.ts b/src/burr-equivalents/raw-xstate.test.ts index c25e6b5..b4374cc 100644 --- a/src/burr-equivalents/raw-xstate.test.ts +++ b/src/burr-equivalents/raw-xstate.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; import { assign, createActor, fromPromise, toPromise, waitFor } from 'xstate'; -import { createTextLogic, setupAgent } from '../index.js'; +import { setupAgent } from '../index.js'; describe('Burr-style examples authored as XState setup machines', () => { test('hello-world-counter uses explicit state and guarded looping', async () => { @@ -52,24 +52,6 @@ describe('Burr-style examples authored as XState setup machines', () => { }); test('conversational RAG stores memory in machine context before answering', async () => { - const answerWithDocuments = createTextLogic({ - schemas: { - input: z.object({ - question: z.string(), - documents: z.array(z.string()), - memory: z.array(z.string()), - }), - output: z.string(), - }, - model: 'rag-answerer', - prompt: ({ input }) => - [ - `Q: ${input.question}`, - `Memory: ${input.memory.join(' | ')}`, - `Docs: ${input.documents.join(' | ')}`, - ].join('\n'), - }); - const agent = setupAgent({ context: z.object({ question: z.string(), @@ -83,11 +65,28 @@ describe('Burr-style examples authored as XState setup machines', () => { }), output: z.object({ answer: z.string(), memory: z.array(z.string()) }), actors: { - answerWithDocuments, retrieve: fromPromise( async ({ input }) => [`doc:${input.question}`, 'doc:remembered-state'] ), }, + }).withTasks({ + answerWithDocuments: { + schemas: { + input: z.object({ + question: z.string(), + documents: z.array(z.string()), + memory: z.array(z.string()), + }), + output: z.string(), + }, + model: 'rag-answerer', + prompt: ({ input }) => + [ + `Q: ${input.question}`, + `Memory: ${input.memory.join(' | ')}`, + `Docs: ${input.documents.join(' | ')}`, + ].join('\n'), + }, }); const machine = agent.createMachine({ @@ -144,8 +143,9 @@ describe('Burr-style examples authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - answerWithDocuments: answerWithDocuments.withExecutor(async ({ input }) => - `answer:${input.documents.join(',')}:memory=${input.memory.length}` + answerWithDocuments: agent.tasks.answerWithDocuments.withExecutor( + async ({ input }) => + `answer:${input.documents.join(',')}:memory=${input.memory.length}` ), }, }), @@ -168,23 +168,6 @@ describe('Burr-style examples authored as XState setup machines', () => { const modeSchema = z.object({ mode: z.enum(['answer_question', 'generate_code', 'generate_image', 'unknown']), }); - const chooseMode = createTextLogic({ - schemas: { - input: z.object({ prompt: z.string() }), - output: modeSchema, - }, - model: 'mode-router', - system: 'Choose the response mode.', - prompt: ({ input }) => input.prompt, - }); - const answerPrompt = createTextLogic({ - schemas: { - input: z.object({ prompt: z.string(), mode: modeSchema.shape.mode }), - output: z.string(), - }, - model: 'streaming-writer', - prompt: ({ input }) => `${input.mode}:${input.prompt}`, - }); const agent = setupAgent({ context: z.object({ @@ -195,7 +178,25 @@ describe('Burr-style examples authored as XState setup machines', () => { }), input: z.object({ prompt: z.string() }), output: z.object({ response: z.string() }), - actors: { chooseMode, answerPrompt }, + }).withTasks({ + chooseMode: { + schemas: { + input: z.object({ prompt: z.string() }), + output: modeSchema, + }, + model: 'mode-router', + system: 'Choose the response mode.', + prompt: ({ input }) => input.prompt, + }, + answerPrompt: { + kind: 'stream', + schemas: { + input: z.object({ prompt: z.string(), mode: modeSchema.shape.mode }), + output: z.string(), + }, + model: 'streaming-writer', + prompt: ({ input }) => `${input.mode}:${input.prompt}`, + }, }); const machine = agent.createMachine({ @@ -265,12 +266,16 @@ describe('Burr-style examples authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - chooseMode: chooseMode.withExecutor(async () => ({ mode: 'generate_code' })), - answerPrompt: answerPrompt.withExecutor(async ({ input }) => { - chunks.push('chunk:1'); - chunks.push('chunk:2'); - return `response:${input.mode}:${input.prompt}`; - }), + chooseMode: agent.tasks.chooseMode.withExecutor( + async () => ({ mode: 'generate_code' }) + ), + answerPrompt: agent.tasks.answerPrompt.withExecutor( + async ({ input }) => { + chunks.push('chunk:1'); + chunks.push('chunk:2'); + return `response:${input.mode}:${input.prompt}`; + } + ), }, }), { input: { prompt: 'write a TypeScript function' } } @@ -285,49 +290,27 @@ describe('Burr-style examples authored as XState setup machines', () => { }); test('tool-calling separates tool selection, tool execution, and final formatting', async () => { - const selectTool = createTextLogic({ - schemas: { - input: z.object({ query: z.string() }), - output: z.discriminatedUnion('tool', [ - z.object({ - tool: z.literal('queryWeather'), - parameters: z.object({ latitude: z.number(), longitude: z.number() }), - }), - z.object({ - tool: z.literal('fallback'), - parameters: z.object({ response: z.string() }), - }), - ]), - }, - model: 'tool-router', - system: 'Select exactly one tool.', - prompt: ({ input }) => input.query, - }); - const formatResult = createTextLogic({ - schemas: { - input: z.object({ - query: z.string(), - rawResponse: z.record(z.string(), z.unknown()), - }), - output: z.string(), - }, - model: 'formatter', - prompt: ({ input }) => - `Question: ${input.query}\nData: ${JSON.stringify(input.rawResponse)}`, - }); + const selectedToolSchema = z.discriminatedUnion('tool', [ + z.object({ + tool: z.literal('queryWeather'), + parameters: z.object({ latitude: z.number(), longitude: z.number() }), + }), + z.object({ + tool: z.literal('fallback'), + parameters: z.object({ response: z.string() }), + }), + ]); const agent = setupAgent({ context: z.object({ query: z.string(), - selected: selectTool.schemas.output.nullable(), + selected: selectedToolSchema.nullable(), rawResponse: z.record(z.string(), z.unknown()).nullable(), finalOutput: z.string().nullable(), }), input: z.object({ query: z.string() }), output: z.object({ finalOutput: z.string() }), actors: { - selectTool, - formatResult, queryWeather: fromPromise< Record, { latitude: number; longitude: number } @@ -339,6 +322,28 @@ describe('Burr-style examples authored as XState setup machines', () => { async ({ input }) => ({ response: input.response }) ), }, + }).withTasks({ + selectTool: { + schemas: { + input: z.object({ query: z.string() }), + output: selectedToolSchema, + }, + model: 'tool-router', + system: 'Select exactly one tool.', + prompt: ({ input }) => input.query, + }, + formatResult: { + schemas: { + input: z.object({ + query: z.string(), + rawResponse: z.record(z.string(), z.unknown()), + }), + output: z.string(), + }, + model: 'formatter', + prompt: ({ input }) => + `Question: ${input.query}\nData: ${JSON.stringify(input.rawResponse)}`, + }, }); const machine = agent.createMachine({ @@ -419,11 +424,15 @@ describe('Burr-style examples authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - selectTool: selectTool.withExecutor(async () => ({ - tool: 'queryWeather', - parameters: { latitude: 37.77, longitude: -122.42 }, - })), - formatResult: formatResult.withExecutor(async ({ input }) => `formatted:${input.rawResponse.forecast}`), + selectTool: agent.tasks.selectTool.withExecutor( + async () => ({ + tool: 'queryWeather', + parameters: { latitude: 37.77, longitude: -122.42 }, + }) + ), + formatResult: agent.tasks.formatResult.withExecutor( + async ({ input }) => `formatted:${input.rawResponse.forecast}` + ), }, }), { input: { query: 'weather in San Francisco' } } @@ -447,15 +456,6 @@ describe('Burr-style examples authored as XState setup machines', () => { concepts: z.array(conceptSchema), keyTakeaways: z.array(z.string()), }); - const generatePost = createTextLogic({ - schemas: { - input: z.object({ transcript: z.string() }), - output: postSchema, - }, - model: 'post-writer', - system: 'Generate a social media post from the transcript.', - prompt: ({ input }) => input.transcript, - }); const agent = setupAgent({ context: z.object({ @@ -466,11 +466,20 @@ describe('Burr-style examples authored as XState setup machines', () => { input: z.object({ youtubeUrl: z.string() }), output: z.object({ post: postSchema }), actors: { - generatePost, getTranscript: fromPromise( async ({ input }) => `transcript:${input.youtubeUrl}` ), }, + }).withTasks({ + generatePost: { + schemas: { + input: z.object({ transcript: z.string() }), + output: postSchema, + }, + model: 'post-writer', + system: 'Generate a social media post from the transcript.', + prompt: ({ input }) => input.transcript, + }, }); const machine = agent.createMachine({ @@ -520,7 +529,7 @@ describe('Burr-style examples authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - generatePost: generatePost.withExecutor(async ({ input }) => ({ + generatePost: agent.tasks.generatePost.withExecutor(async ({ input }) => ({ topic: 'Burr', hook: 'Stateful AI apps need structure.', body: input.transcript, @@ -545,13 +554,8 @@ describe('Burr-style examples authored as XState setup machines', () => { }); test('multi-agent collaboration is supervisor routing over typed workers', async () => { - const routeWork = createTextLogic({ - schemas: { - input: z.object({ task: z.string() }), - output: z.object({ route: z.enum(['researcher', 'chartGenerator']) }), - }, - model: 'supervisor', - prompt: ({ input }) => input.task, + const routeSchema = z.object({ + route: z.enum(['researcher', 'chartGenerator']), }); const agent = setupAgent({ @@ -563,7 +567,6 @@ describe('Burr-style examples authored as XState setup machines', () => { input: z.object({ task: z.string() }), output: z.object({ result: z.string() }), actors: { - routeWork, researcher: fromPromise( async ({ input }) => `research:${input.task}` ), @@ -571,6 +574,15 @@ describe('Burr-style examples authored as XState setup machines', () => { async ({ input }) => `chart:${input.task}` ), }, + }).withTasks({ + routeWork: { + schemas: { + input: z.object({ task: z.string() }), + output: routeSchema, + }, + model: 'supervisor', + prompt: ({ input }) => input.task, + }, }); const machine = agent.createMachine({ @@ -631,7 +643,9 @@ describe('Burr-style examples authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - routeWork: routeWork.withExecutor(async () => ({ route: 'chartGenerator' })), + routeWork: agent.tasks.routeWork.withExecutor( + async () => ({ route: 'chartGenerator' }) + ), }, }), { input: { task: 'plot revenue' } } diff --git a/src/crewai-equivalents/raw-xstate.test.ts b/src/crewai-equivalents/raw-xstate.test.ts index c80562c..520f1bc 100644 --- a/src/crewai-equivalents/raw-xstate.test.ts +++ b/src/crewai-equivalents/raw-xstate.test.ts @@ -1,29 +1,10 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; import { assign, createActor, fromPromise, toPromise } from 'xstate'; -import { createTextLogic, setupAgent } from '../index.js'; +import { setupAgent } from '../index.js'; describe('CrewAI-style flows authored as XState setup machines', () => { test('content creator routes and generates specialized content', async () => { - const routeContent = createTextLogic({ - schemas: { - input: z.object({ request: z.string() }), - output: z.object({ route: z.enum(['linkedin', 'blog']) }), - }, - model: 'router', - prompt: ({ input }) => input.request, - }); - const createContent = createTextLogic({ - schemas: { - input: z.object({ - route: z.enum(['linkedin', 'blog']), - request: z.string(), - }), - output: z.string(), - }, - model: 'writer', - prompt: ({ input }) => `${input.route}:${input.request}`, - }); const agent = setupAgent({ context: z.object({ request: z.string(), @@ -32,7 +13,26 @@ describe('CrewAI-style flows authored as XState setup machines', () => { }), input: z.object({ request: z.string() }), output: z.object({ route: z.enum(['linkedin', 'blog']), content: z.string() }), - actors: { routeContent, createContent }, + }).withTasks({ + routeContent: { + schemas: { + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['linkedin', 'blog']) }), + }, + model: 'router', + prompt: ({ input }) => input.request, + }, + createContent: { + schemas: { + input: z.object({ + route: z.enum(['linkedin', 'blog']), + request: z.string(), + }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => `${input.route}:${input.request}`, + }, }); const machine = agent.createMachine({ @@ -76,10 +76,10 @@ describe('CrewAI-style flows authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - routeContent: routeContent.withExecutor( + routeContent: agent.tasks.routeContent.withExecutor( async () => ({ route: 'linkedin' }) ), - createContent: createContent.withExecutor( + createContent: agent.tasks.createContent.withExecutor( async ({ input }) => `Post for ${input.route}:${input.request}`, ), }, @@ -96,17 +96,6 @@ describe('CrewAI-style flows authored as XState setup machines', () => { }); test('write-a-book fans out chapter workers and compiles a manuscript', async () => { - const outlineBook = createTextLogic({ - schemas: { - input: z.object({ brief: z.string() }), - output: z.object({ - title: z.string(), - chapters: z.array(z.string()), - }), - }, - model: 'outliner', - prompt: ({ input }) => input.brief, - }); const agent = setupAgent({ context: z.object({ brief: z.string(), @@ -117,12 +106,23 @@ describe('CrewAI-style flows authored as XState setup machines', () => { input: z.object({ brief: z.string() }), output: z.object({ title: z.string(), manuscript: z.string() }), actors: { - outlineBook, writeChapters: fromPromise( async ({ input }) => input.chapters.map((chapter: string) => `${chapter}: body`) ), }, + }).withTasks({ + outlineBook: { + schemas: { + input: z.object({ brief: z.string() }), + output: z.object({ + title: z.string(), + chapters: z.array(z.string()), + }), + }, + model: 'outliner', + prompt: ({ input }) => input.brief, + }, }); const machine = agent.createMachine({ @@ -173,7 +173,7 @@ describe('CrewAI-style flows authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - outlineBook: outlineBook.withExecutor( + outlineBook: agent.tasks.outlineBook.withExecutor( async () => ({ title: 'The Workflow Book', chapters: ['Intro', 'Runtime'] }) ), }, diff --git a/src/graph/index.test.ts b/src/graph/index.test.ts index 9ac15e3..ed81af1 100644 --- a/src/graph/index.test.ts +++ b/src/graph/index.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; import { setup } from 'xstate'; -import { toGraph, toMermaid } from './index.js'; +import { analyzeGraph, toGraph, toMermaid } from './index.js'; test('exports finite states and transition edges from XState setup machines', () => { const agent = setup({ @@ -60,3 +60,69 @@ test('exports Mermaid from XState setup machines', () => { expect(toMermaid(machine)).toContain('stateDiagram-v2'); expect(toMermaid(machine)).toContain('a --> b'); }); + +test('warns about invalid graph structure', () => { + const analysis = analyzeGraph({ + id: 'warning-export', + config: { + initial: 'idle', + states: { + idle: { + on: { + NEXT: { target: 'missing' }, + }, + }, + invoking: { + invoke: { + src: 'draftEmail', + onDone: { target: 'done' }, + }, + }, + orphan: {}, + done: { type: 'final' }, + }, + }, + }); + + expect(analysis.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'dangling-target', + state: 'idle', + event: 'NEXT', + target: 'missing', + }), + expect.objectContaining({ + code: 'missing-invoke-id', + state: 'invoking', + }), + expect.objectContaining({ + code: 'unreachable-state', + state: 'orphan', + }), + expect.objectContaining({ + code: 'dead-end-state', + state: 'orphan', + }), + ]) + ); +}); + +test('warns about missing initial state', () => { + const analysis = analyzeGraph({ + id: 'missing-initial-export', + config: { + initial: 'unknown', + states: { + idle: {}, + }, + }, + }); + + expect(analysis.warnings).toContainEqual( + expect.objectContaining({ + code: 'missing-initial', + state: 'unknown', + }) + ); +}); diff --git a/src/graph/index.ts b/src/graph/index.ts index 768192c..4574468 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -25,9 +25,18 @@ export interface AgentGraphEdgeData { export interface AgentGraphData {} +export type AgentGraphWarningCode = + | 'missing-initial' + | 'dangling-target' + | 'unreachable-state' + | 'dead-end-state' + | 'missing-invoke-id'; + export interface AgentGraphWarning { + code: AgentGraphWarningCode; state: string; - event: string; + event?: string; + target?: string; message: string; } @@ -76,21 +85,71 @@ export function analyzeGraph(machine: XStateLikeMachine): AgentGraphAnalysis { }) ); const edges: Array> = []; + const warnings: AgentGraphWarning[] = []; + const stateIds = new Set(stateEntries.map(([id]) => id)); + const initial = typeof config.initial === 'string' ? config.initial : undefined; let edgeIndex = 0; + if (initial && !stateIds.has(initial)) { + warnings.push({ + code: 'missing-initial', + state: initial, + message: `Initial state '${initial}' does not exist.`, + }); + } + for (const [sourceId, state] of stateEntries) { for (const target of collectTargets(state.always)) { edges.push(edge(edgeIndex++, sourceId, target, 'always', '')); + warnIfDanglingTarget(warnings, stateIds, sourceId, target, ''); } for (const [event, transition] of Object.entries(normalizeOn(state.on))) { for (const target of collectTargets(transition)) { edges.push(edge(edgeIndex++, sourceId, target, 'event', event)); + warnIfDanglingTarget(warnings, stateIds, sourceId, target, event); + } + } + + for (const invoke of normalizeInvokes(state.invoke)) { + if (!hasDurableInvokeId(invoke)) { + warnings.push({ + code: 'missing-invoke-id', + state: sourceId, + event: 'invoke', + message: `Invoke in state '${sourceId}' is missing a durable id.`, + }); } } for (const target of collectInvokeDoneTargets(state.invoke)) { edges.push(edge(edgeIndex++, sourceId, target, 'invoke.done', `done.invoke.${sourceId}`)); + warnIfDanglingTarget( + warnings, + stateIds, + sourceId, + target, + `done.invoke.${sourceId}` + ); + } + } + + const reachable = collectReachableStates(initial, stateEntries, edges, stateIds); + for (const [stateId, state] of stateEntries) { + if (initial && stateId !== initial && !reachable.has(stateId)) { + warnings.push({ + code: 'unreachable-state', + state: stateId, + message: `State '${stateId}' is not reachable from initial state '${initial}'.`, + }); + } + + if (state.type !== 'final' && isDeadEndState(state)) { + warnings.push({ + code: 'dead-end-state', + state: stateId, + message: `State '${stateId}' has no outgoing transitions, invokes, or event handlers.`, + }); } } @@ -101,7 +160,7 @@ export function analyzeGraph(machine: XStateLikeMachine): AgentGraphAnalysis { nodes, edges, }), - warnings: [], + warnings, }; } @@ -133,14 +192,26 @@ function normalizeOn(on: XStateLikeState['on']): Record { } function collectInvokeDoneTargets(invoke: unknown): string[] { - const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : []; - return invokes.flatMap((item) => + return normalizeInvokes(invoke).flatMap((item) => item && typeof item === 'object' ? collectTargets((item as { onDone?: unknown }).onDone) : [] ); } +function normalizeInvokes(invoke: unknown): unknown[] { + return Array.isArray(invoke) ? invoke : invoke ? [invoke] : []; +} + +function hasDurableInvokeId(invoke: unknown): boolean { + return ( + !!invoke + && typeof invoke === 'object' + && typeof (invoke as { id?: unknown }).id === 'string' + && (invoke as { id: string }).id.length > 0 + ); +} + function collectTargets(value: unknown): string[] { if (!value) { return []; @@ -170,6 +241,71 @@ function stripTarget(target: string): string { return target.replace(/^#/, '').split('.').at(-1) ?? target; } +function warnIfDanglingTarget( + warnings: AgentGraphWarning[], + stateIds: Set, + state: string, + target: string, + event?: string +) { + if (stateIds.has(target)) { + return; + } + + warnings.push({ + code: 'dangling-target', + state, + event: event || undefined, + target, + message: `Transition from '${state}' targets missing state '${target}'.`, + }); +} + +function collectReachableStates( + initial: string | undefined, + stateEntries: [string, XStateLikeState][], + edges: Array>, + stateIds: Set +): Set { + const firstState = stateEntries[0]?.[0]; + const start = initial && stateIds.has(initial) ? initial : firstState; + const reachable = new Set(); + + if (!start) { + return reachable; + } + + const adjacency = new Map(); + for (const edge of edges) { + if (!stateIds.has(edge.targetId)) { + continue; + } + const targets = adjacency.get(edge.sourceId) ?? []; + targets.push(edge.targetId); + adjacency.set(edge.sourceId, targets); + } + + const queue = [start]; + while (queue.length > 0) { + const state = queue.shift()!; + if (reachable.has(state)) { + continue; + } + reachable.add(state); + queue.push(...(adjacency.get(state) ?? [])); + } + + return reachable; +} + +function isDeadEndState(state: XStateLikeState): boolean { + return ( + Object.keys(normalizeOn(state.on)).length === 0 + && collectTargets(state.always).length === 0 + && normalizeInvokes(state.invoke).length === 0 + ); +} + function toMermaidStateGraph(graph: AgentGraph): MermaidStateGraph { const nodes: Array> = graph.nodes.map((node) => ({ id: node.id, diff --git a/src/index.ts b/src/index.ts index e9daee4..dd2aba3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export type { AgentTask, AgentTextInput, AgentSchemaPack, + AgentStep, AgentTaskConfig, AgentTaskExecutor, AgentTaskExecutors, diff --git a/src/langgraph-equivalents/raw-xstate.test.ts b/src/langgraph-equivalents/raw-xstate.test.ts index 9292ffe..4651f8e 100644 --- a/src/langgraph-equivalents/raw-xstate.test.ts +++ b/src/langgraph-equivalents/raw-xstate.test.ts @@ -1,18 +1,10 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; import { assign, createActor, fromPromise, toPromise, waitFor } from 'xstate'; -import { createTextLogic, setupAgent } from '../index.js'; +import { setupAgent } from '../index.js'; describe('LangGraph-style workflows authored as raw XState', () => { test('conditional routing uses declarative text actor input', async () => { - const routeRequest = createTextLogic({ - schemas: { - input: z.object({ request: z.string() }), - output: z.object({ route: z.enum(['answer', 'escalate']) }), - }, - model: 'classifier', - prompt: ({ input }) => input.request, - }); const agent = setupAgent({ context: z.object({ request: z.string(), @@ -20,7 +12,15 @@ describe('LangGraph-style workflows authored as raw XState', () => { }), input: z.object({ request: z.string() }), output: z.object({ route: z.enum(['answer', 'escalate']) }), - actors: { routeRequest }, + }).withTasks({ + routeRequest: { + schemas: { + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['answer', 'escalate']) }), + }, + model: 'classifier', + prompt: ({ input }) => input.request, + }, }); const machine = agent.createMachine({ @@ -52,7 +52,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - routeRequest: routeRequest.withExecutor( + routeRequest: agent.tasks.routeRequest.withExecutor( async () => ({ route: 'escalate' }) ), }, @@ -67,14 +67,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { }); test('human-in-the-loop approval uses typed external events', async () => { - const writeDraft = createTextLogic({ - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), - }, - model: 'writer', - prompt: ({ input }) => input.topic, - }); const agent = setupAgent({ context: z.object({ topic: z.string(), @@ -86,7 +78,15 @@ describe('LangGraph-style workflows authored as raw XState', () => { APPROVE: z.object({}), REJECT: z.object({ reason: z.string() }), }, - actors: { writeDraft }, + }).withTasks({ + writeDraft: { + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => input.topic, + }, }); const machine = agent.createMachine({ @@ -131,7 +131,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - writeDraft: writeDraft.withExecutor( + writeDraft: agent.tasks.writeDraft.withExecutor( async ({ input }) => `Draft: ${input.topic}` ), }, @@ -154,14 +154,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { const planSchema = z.object({ steps: z.array(z.string()), }); - const planTask = createTextLogic({ - schemas: { - input: z.object({ task: z.string() }), - output: planSchema, - }, - model: 'planner', - prompt: ({ input }) => input.task, - }); const agent = setupAgent({ context: z.object({ @@ -172,11 +164,19 @@ describe('LangGraph-style workflows authored as raw XState', () => { input: z.object({ task: z.string() }), output: z.object({ results: z.array(z.string()) }), actors: { - planTask, runStep: fromPromise( async ({ input }) => `done:${input.step}` ), }, + }).withTasks({ + planTask: { + schemas: { + input: z.object({ task: z.string() }), + output: planSchema, + }, + model: 'planner', + prompt: ({ input }) => input.task, + }, }); const machine = agent.createMachine({ @@ -223,7 +223,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - planTask: planTask.withExecutor( + planTask: agent.tasks.planTask.withExecutor( async () => ({ steps: ['research', 'write'] }) ), }, @@ -293,14 +293,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { }); test('persistence restores from XState snapshots without a custom runtime', async () => { - const writeDraft = createTextLogic({ - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), - }, - model: 'writer', - prompt: ({ input }) => input.topic, - }); const agent = setupAgent({ context: z.object({ topic: z.string(), @@ -311,7 +303,15 @@ describe('LangGraph-style workflows authored as raw XState', () => { events: { APPROVE: z.object({}), }, - actors: { writeDraft }, + }).withTasks({ + writeDraft: { + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => input.topic, + }, }); const machine = agent.createMachine({ @@ -342,7 +342,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { }); const actors = { - writeDraft: writeDraft.withExecutor( + writeDraft: agent.tasks.writeDraft.withExecutor( async ({ input }) => `Draft: ${input.topic}` ), }; @@ -369,19 +369,19 @@ describe('LangGraph-style workflows authored as raw XState', () => { }); test('subflows compose as typed child actors', async () => { - const researchTopic = createTextLogic({ - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), - }, - model: 'researcher', - prompt: ({ input }) => input.topic, - }); const childAgent = setupAgent({ context: z.object({ topic: z.string(), research: z.string().nullable() }), input: z.object({ topic: z.string() }), output: z.object({ research: z.string() }), - actors: { researchTopic }, + }).withTasks({ + researchTopic: { + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'researcher', + prompt: ({ input }) => input.topic, + }, }); const childMachine = childAgent.createMachine({ id: 'raw-xstate-child-research', @@ -443,7 +443,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { actors: { child: childMachine.provide({ actors: { - researchTopic: researchTopic.withExecutor( + researchTopic: childAgent.tasks.researchTopic.withExecutor( async ({ input }) => `Research: ${input.topic}` ), }, @@ -459,14 +459,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { }); test('supervisor handoff is explicit typed routing', async () => { - const routeRequest = createTextLogic({ - schemas: { - input: z.object({ request: z.string() }), - output: z.object({ route: z.enum(['research', 'write']) }), - }, - model: 'router', - prompt: ({ input }) => input.request, - }); const agent = setupAgent({ context: z.object({ request: z.string(), @@ -476,7 +468,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { input: z.object({ request: z.string() }), output: z.object({ result: z.string() }), actors: { - routeRequest, research: fromPromise( async ({ input }) => `research:${input.request}` ), @@ -484,6 +475,15 @@ describe('LangGraph-style workflows authored as raw XState', () => { async ({ input }) => `write:${input.request}` ), }, + }).withTasks({ + routeRequest: { + schemas: { + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['research', 'write']) }), + }, + model: 'router', + prompt: ({ input }) => input.request, + }, }); const machine = agent.createMachine({ @@ -541,7 +541,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - routeRequest: routeRequest.withExecutor( + routeRequest: agent.tasks.routeRequest.withExecutor( async () => ({ route: 'research' }) ), }, @@ -557,14 +557,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { }); test('map-reduce fan-out uses typed local actors and normal JavaScript concurrency', async () => { - const reduceSummaries = createTextLogic({ - schemas: { - input: z.object({ summaries: z.array(z.string()) }), - output: z.string(), - }, - model: 'reducer', - prompt: ({ input }) => input.summaries.join('\n'), - }); const agent = setupAgent({ context: z.object({ sections: z.array(z.string()), @@ -574,7 +566,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { input: z.object({ sections: z.array(z.string()) }), output: z.object({ final: z.string() }), actors: { - reduceSummaries, summarizeAll: fromPromise( async ({ input }) => Promise.all( @@ -582,6 +573,15 @@ describe('LangGraph-style workflows authored as raw XState', () => { ) ), }, + }).withTasks({ + reduceSummaries: { + schemas: { + input: z.object({ summaries: z.array(z.string()) }), + output: z.string(), + }, + model: 'reducer', + prompt: ({ input }) => input.summaries.join('\n'), + }, }); const machine = agent.createMachine({ @@ -623,7 +623,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - reduceSummaries: reduceSummaries.withExecutor( + reduceSummaries: agent.tasks.reduceSummaries.withExecutor( async ({ input }) => `reduced:${input.summaries.join('\n')}` ), }, @@ -639,17 +639,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { }); test('RAG keeps retrieval as a typed host actor before generation', async () => { - const answerQuestion = createTextLogic({ - schemas: { - input: z.object({ - question: z.string(), - documents: z.array(z.string()), - }), - output: z.string(), - }, - model: 'answerer', - prompt: ({ input }) => `Q: ${input.question}\nDocs:\n${input.documents.join('\n')}`, - }); const agent = setupAgent({ context: z.object({ question: z.string(), @@ -659,11 +648,22 @@ describe('LangGraph-style workflows authored as raw XState', () => { input: z.object({ question: z.string() }), output: z.object({ answer: z.string() }), actors: { - answerQuestion, retrieve: fromPromise( async ({ input }) => [`doc:${input.question}`, 'doc:typed state'] ), }, + }).withTasks({ + answerQuestion: { + schemas: { + input: z.object({ + question: z.string(), + documents: z.array(z.string()), + }), + output: z.string(), + }, + model: 'answerer', + prompt: ({ input }) => `Q: ${input.question}\nDocs:\n${input.documents.join('\n')}`, + }, }); const machine = agent.createMachine({ @@ -708,7 +708,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - answerQuestion: answerQuestion.withExecutor( + answerQuestion: agent.tasks.answerQuestion.withExecutor( async ({ input }) => `answer from Q: ${input.question}\nDocs:\n${input.documents.join('\n')}` ), @@ -731,28 +731,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { approved: z.boolean(), feedback: z.string(), }); - const writeDraft = createTextLogic({ - schemas: { - input: z.object({ - prompt: z.string(), - feedback: z.string().nullable(), - }), - output: z.string(), - }, - model: 'writer', - prompt: ({ input }) => - input.feedback - ? `${input.prompt}\nRevise: ${input.feedback}` - : input.prompt, - }); - const critiqueDraft = createTextLogic({ - schemas: { - input: z.object({ draft: z.string() }), - output: critiqueSchema, - }, - model: 'critic', - prompt: ({ input }) => input.draft, - }); let critiqueCount = 0; const agent = setupAgent({ context: z.object({ @@ -763,7 +741,29 @@ describe('LangGraph-style workflows authored as raw XState', () => { }), input: z.object({ prompt: z.string() }), output: z.object({ draft: z.string() }), - actors: { writeDraft, critiqueDraft }, + }).withTasks({ + writeDraft: { + schemas: { + input: z.object({ + prompt: z.string(), + feedback: z.string().nullable(), + }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => + input.feedback + ? `${input.prompt}\nRevise: ${input.feedback}` + : input.prompt, + }, + critiqueDraft: { + schemas: { + input: z.object({ draft: z.string() }), + output: critiqueSchema, + }, + model: 'critic', + prompt: ({ input }) => input.draft, + }, }); const machine = agent.createMachine({ @@ -818,14 +818,14 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - writeDraft: writeDraft.withExecutor( + writeDraft: agent.tasks.writeDraft.withExecutor( async ({ input }) => `draft:${ input.feedback ? `${input.prompt}\nRevise: ${input.feedback}` : input.prompt }` ), - critiqueDraft: critiqueDraft.withExecutor( + critiqueDraft: agent.tasks.critiqueDraft.withExecutor( async () => { critiqueCount += 1; return { @@ -855,22 +855,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { }) ), }); - const planWork = createTextLogic({ - schemas: { - input: z.object({ goal: z.string() }), - output: planSchema, - }, - model: 'planner', - prompt: ({ input }) => input.goal, - }); - const solveWork = createTextLogic({ - schemas: { - input: z.object({ evidence: z.record(z.string(), z.string()) }), - output: z.string(), - }, - model: 'solver', - prompt: ({ input }) => JSON.stringify(input.evidence), - }); const agent = setupAgent({ context: z.object({ goal: z.string(), @@ -884,8 +868,6 @@ describe('LangGraph-style workflows authored as raw XState', () => { evidence: z.record(z.string(), z.string()), }), actors: { - planWork, - solveWork, executePlan: fromPromise< Record, { steps: Array<{ id: string; task: string }> } @@ -898,6 +880,23 @@ describe('LangGraph-style workflows authored as raw XState', () => { ) ), }, + }).withTasks({ + planWork: { + schemas: { + input: z.object({ goal: z.string() }), + output: planSchema, + }, + model: 'planner', + prompt: ({ input }) => input.goal, + }, + solveWork: { + schemas: { + input: z.object({ evidence: z.record(z.string(), z.string()) }), + output: z.string(), + }, + model: 'solver', + prompt: ({ input }) => JSON.stringify(input.evidence), + }, }); const machine = agent.createMachine({ @@ -953,10 +952,10 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - planWork: planWork.withExecutor( + planWork: agent.tasks.planWork.withExecutor( async ({ input }) => ({ steps: [{ id: 'E1', task: input.goal }] }) ), - solveWork: solveWork.withExecutor( + solveWork: agent.tasks.solveWork.withExecutor( async ({ input }) => `answer:${JSON.stringify(input.evidence)}` ), }, @@ -970,26 +969,10 @@ describe('LangGraph-style workflows authored as raw XState', () => { answer: 'answer:{"E1":"result:compare tools"}', evidence: { E1: 'result:compare tools' }, }); - }); + }); test('SQL-style agents keep query generation, execution, and answer synthesis explicit', async () => { const querySchema = z.object({ sql: z.string() }); - const writeQuery = createTextLogic({ - schemas: { - input: z.object({ question: z.string() }), - output: querySchema, - }, - model: 'sql-writer', - prompt: ({ input }) => input.question, - }); - const answerRows = createTextLogic({ - schemas: { - input: z.object({ rows: z.array(z.record(z.string(), z.string())) }), - output: z.string(), - }, - model: 'answerer', - prompt: ({ input }) => JSON.stringify(input.rows), - }); const agent = setupAgent({ context: z.object({ question: z.string(), @@ -1000,13 +983,28 @@ describe('LangGraph-style workflows authored as raw XState', () => { input: z.object({ question: z.string() }), output: z.object({ sql: z.string(), answer: z.string() }), actors: { - writeQuery, - answerRows, queryDatabase: fromPromise< Array>, { sql: string } >(async ({ input }) => [{ total: '42', sql: input.sql }]), }, + }).withTasks({ + writeQuery: { + schemas: { + input: z.object({ question: z.string() }), + output: querySchema, + }, + model: 'sql-writer', + prompt: ({ input }) => input.question, + }, + answerRows: { + schemas: { + input: z.object({ rows: z.array(z.record(z.string(), z.string())) }), + output: z.string(), + }, + model: 'answerer', + prompt: ({ input }) => JSON.stringify(input.rows), + }, }); const machine = agent.createMachine({ @@ -1062,10 +1060,10 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - writeQuery: writeQuery.withExecutor( + writeQuery: agent.tasks.writeQuery.withExecutor( async () => ({ sql: 'select count(*) as total from users' }) ), - answerRows: answerRows.withExecutor( + answerRows: agent.tasks.answerRows.withExecutor( async ({ input }) => `final:${JSON.stringify(input.rows)}` ), }, @@ -1163,19 +1161,20 @@ describe('LangGraph-style workflows authored as raw XState', () => { test('streaming keeps chunks in the host side channel', async () => { const chunks: string[] = []; - const streamTopic = createTextLogic({ - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), - }, - model: 'writer', - prompt: ({ input }) => input.topic, - }); const agent = setupAgent({ context: z.object({ topic: z.string(), text: z.string().nullable() }), input: z.object({ topic: z.string() }), output: z.object({ text: z.string() }), - actors: { streamTopic }, + }).withTasks({ + streamTopic: { + kind: 'stream', + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => input.topic, + }, }); const machine = agent.createMachine({ @@ -1203,7 +1202,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - streamTopic: streamTopic.withExecutor( + streamTopic: agent.tasks.streamTopic.withExecutor( async ({ input }) => { chunks.push('hello'); chunks.push(input.topic); diff --git a/src/setup-agent.test.ts b/src/setup-agent.test.ts index 19ec20a..15ac6b6 100644 --- a/src/setup-agent.test.ts +++ b/src/setup-agent.test.ts @@ -13,8 +13,6 @@ import { userMessage, type AgentTextInput, type AgentTools, - type TextLogicInput, - type TextLogicOutput, } from './index.js'; describe('setupAgent', () => { @@ -65,7 +63,7 @@ describe('setupAgent', () => { expect.objectContaining({ model: 'test-model', prompt: 'Draft it.', - allowedEvents: ['READY_TO_DRAFT'], + eventTypes: ['READY_TO_DRAFT'], }) ); @@ -96,15 +94,15 @@ describe('setupAgent', () => { }); setupAgent({ schemas }).withTasks({ - badAllowedEvents: { + badEventTypes: { schemas: { input: z.object({ prompt: z.string() }), output: z.object({ body: z.string() }), }, model: 'test-model', prompt: ({ input }) => input.prompt, - // @ts-expect-error use task events, not raw text logic allowedEvents - allowedEvents: ['READY_TO_DRAFT'], + // @ts-expect-error use task events, not raw text logic eventTypes + eventTypes: ['READY_TO_DRAFT'], }, }); @@ -170,7 +168,7 @@ describe('setupAgent', () => { expect(effect).toEqual( expect.objectContaining({ kind: 'generate', - input: expect.objectContaining({ allowedEvents: ['READY_TO_DRAFT'] }), + input: expect.objectContaining({ eventTypes: ['READY_TO_DRAFT'] }), }) ); @@ -387,17 +385,27 @@ describe('setupAgent', () => { ]); }); - test('authors named text logic with typed input and output', () => { - const getSummary = createTextLogic({ - schemas: { - input: z.object({ article: z.string() }), - output: z.object({ summary: z.string() }), + test('authors named tasks with typed input and output', () => { + const agent = setupAgent({ + context: z.object({ + article: z.string(), + summary: z.string().nullable(), + }), + input: z.object({ article: z.string() }), + output: z.object({ summary: z.string() }), + }).withTasks({ + getSummary: { + schemas: { + input: z.object({ article: z.string() }), + output: z.object({ summary: z.string() }), + }, + model: 'test-model', + system: 'Summarize articles.', + prompt: ({ input }) => `Summarize:\n${input.article}`, + temperature: ({ input }) => input.article.length > 10 ? 0.2 : 0, }, - model: 'test-model', - system: 'Summarize articles.', - prompt: ({ input }) => `Summarize:\n${input.article}`, - temperature: ({ input }) => input.article.length > 10 ? 0.2 : 0, }); + const { getSummary } = agent.tasks; expect(getSummary.request({ article: 'A long article.' })).toEqual( expect.objectContaining({ @@ -409,16 +417,6 @@ describe('setupAgent', () => { }) ); - const agent = setupAgent({ - context: z.object({ - article: z.string(), - summary: z.string().nullable(), - }), - input: z.object({ article: z.string() }), - output: z.object({ summary: z.string() }), - actors: { getSummary }, - }); - agent.createMachine({ initial: 'summarizing', states: { @@ -479,12 +477,13 @@ describe('setupAgent', () => { article: 'State machines make agents inspectable.', }); const [effect] = getAgentEffects(actions, { - actors: { getSummary }, + actors: agent.tasks, }); expect(effect).toEqual({ id: 'getSummary', src: 'getSummary', + kind: 'generate', input: expect.objectContaining({ model: 'test-model', system: 'Summarize articles.', @@ -624,18 +623,6 @@ describe('setupAgent', () => { subject: z.string(), body: z.string(), }); - const draftEmail = createTextLogic({ - schemas: { - input: z.object({ prompt: z.string() }), - output: draftSchema, - }, - model: 'test-model', - prompt: ({ input }) => input.prompt, - metadata: ({ input }) => ({ - temperature: input.prompt.length > 0 ? 0.2 : 0, - traceId: `draft:${input.prompt}`, - }), - }); const agent = setupAgent({ context: z.object({ @@ -647,8 +634,21 @@ describe('setupAgent', () => { events: { RETRY: z.object({ prompt: z.string() }), }, - actors: { draftEmail }, + }).withTasks({ + draftEmail: { + schemas: { + input: z.object({ prompt: z.string() }), + output: draftSchema, + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + metadata: ({ input }) => ({ + temperature: input.prompt.length > 0 ? 0.2 : 0, + traceId: `draft:${input.prompt}`, + }), + }, }); + const { draftEmail } = agent.tasks; agent.createMachine({ initial: 'drafting', @@ -710,24 +710,18 @@ describe('setupAgent', () => { const actor = createActor( machine.provide({ actors: { - draftEmail: fromPromise< - TextLogicOutput, - TextLogicInput - >( - async ({ input }) => { - const request = draftEmail.request(input); - calls.push( - request as AgentTextInput<{ - temperature: number; - traceId: string; - }> - ); - return { - subject: `Re: ${request.prompt}`, - body: 'Typed raw XState machine body.', - }; - } - ), + draftEmail: draftEmail.withExecutor(async ({ request }) => { + calls.push( + request as AgentTextInput<{ + temperature: number; + traceId: string; + }> + ); + return { + subject: `Re: ${request.prompt}`, + body: 'Typed raw XState machine body.', + }; + }), }, }), { input: { prompt: 'launch note' } } @@ -751,17 +745,8 @@ describe('setupAgent', () => { ]); }); - test('extracts agent effects from pure XState transitions', () => { + test('extracts agent effects from pure XState transitions', async () => { const answerSchema = z.object({ answer: z.string() }); - const answerQuestion = createTextLogic({ - schemas: { - input: z.object({ prompt: z.string() }), - output: answerSchema, - }, - model: 'test-model', - prompt: ({ input }) => input.prompt, - temperature: 0.2, - }); const agent = setupAgent({ context: z.object({ prompt: z.string(), @@ -769,8 +754,18 @@ describe('setupAgent', () => { }), input: z.object({ prompt: z.string() }), output: z.object({ answer: z.string() }), - actors: { answerQuestion }, + }).withTasks({ + answerQuestion: { + schemas: { + input: z.object({ prompt: z.string() }), + output: answerSchema, + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + temperature: 0.2, + }, }); + const { answerQuestion } = agent.tasks; const machine = agent.createMachine({ id: 'pure-agent-loop', @@ -801,12 +796,13 @@ describe('setupAgent', () => { prompt: 'why state machines?', }); const [effect] = getAgentEffects(actions, { - actors: { answerQuestion }, + actors: agent.tasks, }); expect(effect).toEqual({ id: 'answer', src: 'answerQuestion', + kind: 'generate', input: expect.objectContaining({ model: 'test-model', prompt: 'why state machines?', @@ -826,18 +822,35 @@ describe('setupAgent', () => { expect(snapshot.output).toEqual({ answer: 'Because the workflow matters.', }); - }); - test('agent effects expose only whitelisted allowed state events as tools', async () => { - const chooseMove = createTextLogic({ - schemas: { - input: z.object({ prompt: z.string() }), - output: z.string(), - }, - model: 'test-model', - prompt: ({ input }) => input.prompt, - allowedEvents: ['ATTACK', 'DEFEND', 'HEAL'], + let step = machine.initial({ + prompt: 'why agent machines?', }); + expect(step.done).toBe(false); + expect(step.tasks).toHaveLength(1); + expect(step.tasks[0]).toEqual( + expect.objectContaining({ + id: 'answer', + src: 'answerQuestion', + }) + ); + + const output = await machine.execute(step.tasks[0]!, { + generateText: (request: AgentTextInput & { tools: AgentTools }) => ({ + object: { + answer: `Answered: ${request.prompt}`, + }, + }), + }); + step = machine.resolve(step, step.tasks[0]!, output); + + expect(step.done).toBe(true); + expect(step.snapshot.output).toEqual({ + answer: 'Answered: why agent machines?', + }); + }); + + test('agent effects expose only selected state events as tools', async () => { const agent = setupAgent({ context: z.object({ prompt: z.string() }), input: z.object({ prompt: z.string() }), @@ -846,7 +859,16 @@ describe('setupAgent', () => { DEFEND: z.object({}), PAUSE: z.object({}), }, - actors: { chooseMove }, + }).withTasks({ + chooseMove: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.string(), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + events: ['ATTACK', 'DEFEND'], + }, }); const machine = agent.createMachine({ @@ -875,10 +897,17 @@ describe('setupAgent', () => { const [snapshot, actions] = initialTransition(machine, { prompt: 'Choose the next move.', }); + const initialStep = machine.initial({ prompt: 'Choose the next move.' }); + const attackStep = machine.transition(initialStep, { + type: 'ATTACK', + target: 'orc', + }); + + expect(attackStep.done).toBe(true); expect(getAvailableEvents(snapshot, { schemas: agent.schemas, - allowedEvents: ['ATTACK', 'DEFEND', 'HEAL'], + eventTypes: ['ATTACK', 'DEFEND', 'HEAL'], })).toEqual([ expect.objectContaining({ type: 'ATTACK', toolName: 'event.ATTACK' }), expect.objectContaining({ type: 'DEFEND', toolName: 'event.DEFEND' }), @@ -887,7 +916,7 @@ describe('setupAgent', () => { const [effect] = getAgentEffects(actions, { snapshot, schemas: agent.schemas, - actors: { chooseMove }, + actors: agent.tasks, }); expect(effect!.events.map((event) => event.type)).toEqual([ @@ -910,7 +939,7 @@ describe('setupAgent', () => { expect(Object.keys(getEventTools(snapshot, { schemas: agent.schemas, - allowedEvents: ['ATTACK', 'DEFEND', 'HEAL'], + eventTypes: ['ATTACK', 'DEFEND', 'HEAL'], }))).toEqual(['event.ATTACK', 'event.DEFEND']); }); }); diff --git a/src/setup-agent.ts b/src/setup-agent.ts index 0a83be8..b5223c0 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -1,6 +1,7 @@ import { fromPromise, getNextTransitions, + initialTransition, setup, transition, assign, @@ -25,18 +26,8 @@ import type { } from './types.js'; import { validateSchemaSync } from './utils.js'; -// ─── Built-in text actors ─── -// -// `agent.generate` and `agent.stream` are well-known actor sources -// registered by `setupAgent`. The machine declares the call; the host -// provides the execution (via `machine.provide({ actors })` or a runtime -// adapter). Streaming is a host concern: `agent.stream` resolves with -// the final text once the stream completes — incremental chunks flow -// through the host's side channel (HTTP stream, WebSocket, stdout), never -// through the machine's journal. - -/** Portable LCD input both built-in text actors receive. */ -export interface AgentTextInput { +/** Portable LCD input text tasks pass to host executors. */ +export interface AgentTextInput> { model: string; system?: string; prompt?: string; @@ -45,7 +36,7 @@ export interface AgentTextInput { tools?: AgentTools; toolChoice?: AgentToolChoice; /** Machine event types to expose as model-call tools for this state. */ - allowedEvents?: readonly string[]; + eventTypes?: readonly string[]; outputSchema?: StandardSchemaV1; temperature?: number; maxTokens?: number; @@ -61,27 +52,6 @@ export interface AgentTextInput { metadata?: TMetadata; } -const AGENT_GENERATE_SRC = 'agent.generate' as const; -const AGENT_STREAM_SRC = 'agent.stream' as const; - -// `generateText` output is `any` at the actor level on purpose: generated -// object shapes are runtime data. Keep `onDone` plain XState and validate -// with the shared `input.outputSchema` where you assign/use the value. -type BuiltinTextActors = { - 'agent.generate': PromiseActorLogic; - 'agent.stream': PromiseActorLogic; -}; - -function missingHostActor(src: string): PromiseActorLogic { - return fromPromise(async () => { - throw new Error( - `'${src}' has no host execution. Provide an implementation with ` + - `machine.provide({ actors: { '${src}': ... } }) or run the machine ` + - `through an agent runtime adapter.` - ); - }); -} - // ─── Message helpers ─── // // Messages are plain context state: declare a `messages` field in the @@ -177,7 +147,7 @@ function resolveTextLogicValue( export interface TextLogicConfig< TInputSchema extends StandardSchemaV1, TOutputSchema extends StandardSchemaV1, - TMetadata = unknown, + TMetadata = Record, > { schemas: { input: TInputSchema; @@ -195,7 +165,7 @@ export interface TextLogicConfig< AgentToolChoice | undefined, InferOutput >; - allowedEvents?: ResolveTextLogicValue< + events?: ResolveTextLogicValue< readonly string[] | undefined, InferOutput >; @@ -211,7 +181,7 @@ export interface TextLogicConfig< metadata?: ResolveTextLogicValue>; } -export interface TextLogicExecuteArgs { +export interface TextLogicExecuteArgs> { input: TInput; request: AgentTextInput; signal: AbortSignal; @@ -231,7 +201,7 @@ export type TextLogicExecutor< export interface TextLogic< TInputSchema extends StandardSchemaV1 = StandardSchemaV1, TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, - TMetadata = unknown, + TMetadata = Record, > extends PromiseActorLogic< InferOutput, InferOutput @@ -252,7 +222,7 @@ export type AgentTaskKind = 'generate' | 'stream'; export interface AgentTaskLogic< TInputSchema extends StandardSchemaV1 = StandardSchemaV1, TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, - TMetadata = unknown, + TMetadata = Record, > extends TextLogic { readonly taskKind: AgentTaskKind; } @@ -270,7 +240,7 @@ export type TextLogicOutput = export function createTextLogic< TInputSchema extends StandardSchemaV1, TOutputSchema extends StandardSchemaV1, - TMetadata = unknown, + TMetadata = Record, >( config: TextLogicConfig, execute?: TextLogicExecutor @@ -291,7 +261,7 @@ export function createTextLogic< messages: resolveTextLogicValue(config.messages, args), tools: resolveTextLogicValue(config.tools, args), toolChoice: resolveTextLogicValue(config.toolChoice, args), - allowedEvents: resolveTextLogicValue(config.allowedEvents, args), + eventTypes: resolveTextLogicValue(config.events, args), outputSchema: config.schemas.output, temperature: resolveTextLogicValue(config.temperature, args), maxTokens: resolveTextLogicValue(config.maxTokens, args), @@ -355,7 +325,7 @@ function isAgentTaskLogic(value: unknown): value is AgentTaskLogic { return isTextLogic(value) && typeof (value as AgentTaskLogic).taskKind === 'string'; } -export type AgentEffectSource = 'agent.generate' | 'agent.stream' | (string & {}); +export type AgentEffectSource = string & {}; export const EVENT_TOOL_PREFIX = 'event.' as const; @@ -388,20 +358,16 @@ export interface AgentEffectOptions { actors?: Record; } -function isAgentEffectSource(src: unknown): src is AgentEffectSource { - return src === AGENT_GENERATE_SRC || src === AGENT_STREAM_SRC; -} - export function getAvailableEvents( snapshot: AnyMachineSnapshot, options: Pick & { - allowedEvents?: readonly string[]; + eventTypes?: readonly string[]; } = {} ): AgentEventDescriptor[] { - const allowedEvents = - options.allowedEvents === undefined + const eventTypes = + options.eventTypes === undefined ? undefined - : new Set(options.allowedEvents); + : new Set(options.eventTypes); const seen = new Set(); return getNextTransitions(snapshot).flatMap((transitionDefinition) => { @@ -411,7 +377,7 @@ export function getAvailableEvents( !eventType || eventType === '*' || eventType.startsWith('xstate.') - || (allowedEvents && !allowedEvents.has(eventType)) + || (eventTypes && !eventTypes.has(eventType)) || seen.has(eventType) ) { return []; @@ -431,7 +397,7 @@ export function getAvailableEvents( export function getEventTools( snapshot: AnyMachineSnapshot, options: Pick & { - allowedEvents?: readonly string[]; + eventTypes?: readonly string[]; } = {} ): AgentTools { return Object.fromEntries( @@ -472,11 +438,9 @@ export function getAgentEffects( } const textLogic = options.actors?.[params.src]; - const input = isAgentEffectSource(params.src) - ? params.input as AgentTextInput - : isTextLogic(textLogic) - ? textLogic.request(params.input as never) - : undefined; + const input = isTextLogic(textLogic) + ? textLogic.request(params.input as never) + : undefined; if (!input) { return []; @@ -486,7 +450,7 @@ export function getAgentEffects( ? getAvailableEvents(options.snapshot, { events: options.events, schemas: options.schemas, - allowedEvents: input.allowedEvents, + eventTypes: input.eventTypes, }) : []; const eventTools = Object.fromEntries( @@ -543,9 +507,26 @@ export interface AgentTaskExecutors { streamText?: AgentTaskExecutor; } +export interface AgentStep { + snapshot: TSnapshot; + actions: readonly { type?: string; params?: unknown }[]; + tasks: AgentTask[]; + done: boolean; +} + export type AgentMachine = TMachine & { provide: (...args: any[]) => AgentMachine; + initial(input?: unknown): AgentStep>; + transition( + snapshotOrStep: SnapshotFrom | AgentStep>, + event: EventObject + ): AgentStep>; + resolve( + step: AgentStep>, + task: Pick | string, + output: unknown + ): AgentStep>; getTasks( actions: readonly { type?: string; params?: unknown }[], snapshot?: AnyMachineSnapshot @@ -592,7 +573,35 @@ function createAgentMachine( machine: TMachine, options: Pick ): AgentMachine { - return Object.assign(machine, { + const originalTransition = machine.transition.bind(machine); + const agentMachine = Object.assign(machine, { + initial(input?: unknown) { + const [snapshot, actions] = initialTransition(agentMachine, input as never); + return createAgentStep(agentMachine, snapshot, actions); + }, + transition( + snapshotOrStep: SnapshotFrom | AgentStep>, + event: EventObject, + actorScope?: unknown + ) { + if (actorScope !== undefined) { + return originalTransition(snapshotOrStep as never, event as never, actorScope as never); + } + + const snapshot = isAgentStep(snapshotOrStep) + ? snapshotOrStep.snapshot + : snapshotOrStep; + const [nextSnapshot, actions] = transition(agentMachine, snapshot, event as never); + return createAgentStep(agentMachine, nextSnapshot, actions); + }, + resolve( + step: AgentStep>, + task: Pick | string, + output: unknown + ) { + const [snapshot, actions] = transitionResult(agentMachine, step.snapshot, task, output); + return createAgentStep(agentMachine, snapshot, actions); + }, getTasks( actions: readonly { type?: string; params?: unknown }[], snapshot?: AnyMachineSnapshot @@ -621,6 +630,33 @@ function createAgentMachine( return normalizeTaskExecutionResult(await executor(request)); }, }) as AgentMachine; + + return agentMachine; +} + +function createAgentStep( + machine: AgentMachine, + snapshot: SnapshotFrom, + actions: readonly { type?: string; params?: unknown }[] +): AgentStep> { + return { + snapshot, + actions, + tasks: machine.getTasks(actions, snapshot), + done: (snapshot as AnyMachineSnapshot).status === 'done', + }; +} + +function isAgentStep( + value: unknown +): value is AgentStep { + return ( + !!value + && typeof value === 'object' + && 'snapshot' in value + && 'actions' in value + && 'tasks' in value + ); } // ─── setupAgent ─── @@ -642,8 +678,6 @@ type SetupActors = { ? PromiseActorLogic : TActors[K]; }; -type AgentSetupActors = - SetupActors & BuiltinTextActors; export interface AgentSchemaPack< TContextSchema extends StandardSchemaV1> = StandardSchemaV1>, @@ -719,10 +753,10 @@ export type AgentTaskConfig< TSchemas extends AgentSchemaPack, TInputSchema extends StandardSchemaV1 = StandardSchemaV1, TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, - TMetadata = unknown, + TMetadata = Record, > = Omit< TextLogicConfig, - 'allowedEvents' + 'events' > & { kind?: AgentTaskKind; events?: AgentTaskEvents; @@ -748,7 +782,7 @@ type AgentTaskInput< TTaskSchemas[K]['output'] > & { schemas: TTaskSchemas[K]; - allowedEvents?: never; + eventTypes?: never; }; }; @@ -773,7 +807,7 @@ type AgentSetupConfigOptions< typeof setup< ContextOf, EventsOf, - AgentSetupActors, + SetupActors, {}, TActions, TGuards, @@ -864,7 +898,7 @@ type SetupAgentXStateResult< typeof setup< ContextOf, EventsOf, - AgentSetupActors, + SetupActors, {}, TActions, TGuards, @@ -971,9 +1005,7 @@ type SetupAgentResult< * Schema-first `setup(...)` for agent machines. Context, events, machine * input, machine output, and state/transition meta are all standard * schemas — no `{} as Type` casts — and are retained on `result.schemas` - * for runtime validation. Registers the well-known `agent.generate` - * and `agent.stream` actors so machines can invoke them with plain - * XState config. + * for runtime validation. */ export function setupAgent< TContextSchema extends StandardSchemaV1>, @@ -1021,7 +1053,7 @@ function createTaskActors< Object.entries(tasks).map(([key, task]) => { const logic = createTextLogic({ ...task, - allowedEvents: task.events + events: task.events ? ({ input }) => typeof task.events === 'function' ? task.events({ input, schemas }) @@ -1115,7 +1147,7 @@ function createSetupAgent< const base = setup< ContextOf, EventsOf, - AgentSetupActors, + SetupActors, {}, TActions, TGuards, @@ -1135,8 +1167,6 @@ function createSetupAgent< }, actors: { ...config.actors, - [AGENT_GENERATE_SRC]: missingHostActor(AGENT_GENERATE_SRC), - [AGENT_STREAM_SRC]: missingHostActor(AGENT_STREAM_SRC), } as AgentSetupConfigOptions< TContextSchema, TEventSchemas, From 01c24f63c3bad4345cff8b43a156fa7bba4db517 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 18 Jun 2026 14:52:29 -0700 Subject: [PATCH 43/50] Separate host examples from authoring --- examples/README.md | 6 +-- examples/setup-agent/hosts/ai-sdk-game.ts | 41 +++++------------- examples/setup-agent/hosts/ai-sdk.ts | 33 ++++++-------- .../hosts/cloudflare-workers-ai.ts | 40 +++++------------ examples/setup-agent/hosts/tanstack-ai.ts | 43 +++++-------------- 5 files changed, 47 insertions(+), 116 deletions(-) diff --git a/examples/README.md b/examples/README.md index 7ef2346..08db196 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,9 +19,9 @@ These use `setupAgent(...)` and `withTasks(...)` from `@statelyai/agent`. The ru - [`setup-agent/game-agent.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/game-agent.ts): turn-based game workflow with whitelisted event tools - [`setup-agent/smoke.mts`](/Users/davidkpiano/Code/agent/examples/setup-agent/smoke.mts): deterministic local XState runtime smoke test - [`setup-agent/hosts/ai-sdk.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/ai-sdk.ts): Vercel AI SDK host actors -- [`setup-agent/hosts/ai-sdk-game.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/ai-sdk-game.ts): Vercel AI SDK pure-transition game runner -- [`setup-agent/hosts/cloudflare-workers-ai.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/cloudflare-workers-ai.ts): Cloudflare Workers AI pure-transition runner -- [`setup-agent/hosts/tanstack-ai.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/tanstack-ai.ts): TanStack AI pure-transition runner sketch +- [`setup-agent/hosts/ai-sdk-game.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/ai-sdk-game.ts): Vercel AI SDK step runner +- [`setup-agent/hosts/cloudflare-workers-ai.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/cloudflare-workers-ai.ts): Cloudflare Workers AI step runner +- [`setup-agent/hosts/tanstack-ai.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/tanstack-ai.ts): TanStack AI step runner sketch - [`setup-agent/hosts/cloudflare-agent.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/hosts/cloudflare-agent.ts): Cloudflare Agents host sketch ## Parity Tracking diff --git a/examples/setup-agent/hosts/ai-sdk-game.ts b/examples/setup-agent/hosts/ai-sdk-game.ts index 70b9228..3e74e94 100644 --- a/examples/setup-agent/hosts/ai-sdk-game.ts +++ b/examples/setup-agent/hosts/ai-sdk-game.ts @@ -1,24 +1,19 @@ /** - * Vercel AI SDK pure-transition host for a non-trivial game workflow. + * Vercel AI SDK step host for a non-trivial game workflow. * * Run: * OPENAI_API_KEY=... node --import tsx examples/setup-agent/hosts/ai-sdk-game.ts */ import { generateText, Output, stepCountIs, type LanguageModel } from 'ai'; import { openai } from '@ai-sdk/openai'; -import { initialTransition, transition } from 'xstate'; +import { z } from 'zod'; import { toAiSdkTools } from '../../../src/ai-sdk/index.js'; import { - getAgentEffects, - transitionResult, type AgentEffect, type AgentTextInput, } from '../../../src/index.js'; import { - chooseMove, gameMachine, - gameSchemas, - summarizeTurn, turnSummarySchema, } from '../game-agent.js'; @@ -74,38 +69,24 @@ async function runGenerateEffect(effect: AgentEffect) { } export async function runAiSdkGameTurn(input = { playerHp: 20, enemyHp: 15 }) { - let [snapshot, actions] = initialTransition(gameMachine, input); + let step = gameMachine.initial(input); - while (snapshot.status !== 'done') { - const [effect] = getAgentEffects(actions, { - snapshot, - schemas: gameSchemas, - actors: { chooseMove, summarizeTurn }, - }); - - if (!effect) { - throw new Error('Machine is waiting without an agent effect.'); + while (!step.done) { + const [task] = step.tasks; + if (!task) { + throw new Error('Machine is waiting without an agent task.'); } - const result = await runGenerateEffect(effect); + const result = await runGenerateEffect(task); if (result.kind === 'event') { - [snapshot, actions] = transition( - gameMachine, - snapshot, - result.event as never - ); + step = gameMachine.transition(step, result.event as never); } else { - [snapshot, actions] = transitionResult( - gameMachine, - snapshot, - effect, - result.output - ); + step = gameMachine.resolve(step, task, result.output); } } - return snapshot.output; + return step.snapshot.output; } async function main() { diff --git a/examples/setup-agent/hosts/ai-sdk.ts b/examples/setup-agent/hosts/ai-sdk.ts index baa3346..fcfc5f5 100644 --- a/examples/setup-agent/hosts/ai-sdk.ts +++ b/examples/setup-agent/hosts/ai-sdk.ts @@ -17,17 +17,16 @@ import { type ModelMessage, } from 'ai'; import { openai } from '@ai-sdk/openai'; -import { assign, createActor, initialTransition, toPromise } from 'xstate'; +import { assign, createActor, toPromise } from 'xstate'; import { z } from 'zod'; import { createAgentSchemas, setupAgent, - transitionResult, type AgentTextInput, type TextLogic, } from '../../../src/index.js'; -// ─── The host adapter: named text logic, implemented with the AI SDK ─── +// ─── Host Adapter: AI SDK execution ─── interface AiSdkTextHostOptions { resolveModel?: (modelRef: string) => LanguageModel; @@ -131,7 +130,7 @@ export function createAiSdkStreamingTextActor( ); } -// ─── Demo 1: generateText with an object output schema ─── +// ─── Authored Agent: ticket triage ─── const triageSchema = z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), @@ -203,33 +202,27 @@ export async function runTriageDemo(ticket: string) { return output; // machine output, typed by the output schema } -export async function runTriagePureTransitionDemo(ticket: string) { - let [snapshot, actions] = initialTransition(triageMachine, { ticket }); +export async function runTriageStepDemo(ticket: string) { + let step = triageMachine.initial({ ticket }); - while (snapshot.status !== 'done') { - const effects = triageMachine.getTasks(actions, snapshot); - if (effects.length === 0) { - throw new Error('Machine is waiting without an agent effect.'); + while (!step.done) { + if (step.tasks.length === 0) { + throw new Error('Machine is waiting without an agent task.'); } - for (const effect of effects) { - const output = await triageMachine.execute(effect, { + for (const task of step.tasks) { + const output = await triageMachine.execute(task, { generateText: (request) => generateWithAiSdk(request, request.tools), }); - [snapshot, actions] = transitionResult( - triageMachine, - snapshot, - effect, - output - ); + step = triageMachine.resolve(step, task, output); } } - return snapshot.output; + return step.snapshot.output; } -// ─── Demo 2: streamText actually streaming ─── +// ─── Authored Agent: streaming joke ─── const jokeAgent = setupAgent({ schemas: createAgentSchemas({ diff --git a/examples/setup-agent/hosts/cloudflare-workers-ai.ts b/examples/setup-agent/hosts/cloudflare-workers-ai.ts index 652ee2b..d54bb44 100644 --- a/examples/setup-agent/hosts/cloudflare-workers-ai.ts +++ b/examples/setup-agent/hosts/cloudflare-workers-ai.ts @@ -1,21 +1,15 @@ /** - * Cloudflare Workers AI pure-transition host for the game workflow. + * Cloudflare Workers AI step host for the game workflow. * * Run with Wrangler in a Worker that has an `AI` binding. Workers AI does not * expose the same tool-calling shape as the Vercel AI SDK binding path, so this * host serializes allowed event tools into the prompt and accepts JSON output. */ -import { initialTransition, transition } from 'xstate'; import { - getAgentEffects, - transitionResult, type AgentEffect, } from '../../../src/index.js'; import { - chooseMove, gameMachine, - gameSchemas, - summarizeTurn, } from '../game-agent.js'; interface Env { @@ -73,38 +67,24 @@ export async function runCloudflareGameTurn( env: Env, input = { playerHp: 20, enemyHp: 15 } ) { - let [snapshot, actions] = initialTransition(gameMachine, input); + let step = gameMachine.initial(input); - while (snapshot.status !== 'done') { - const [effect] = getAgentEffects(actions, { - snapshot, - schemas: gameSchemas, - actors: { chooseMove, summarizeTurn }, - }); - - if (!effect) { - throw new Error('Machine is waiting without an agent effect.'); + while (!step.done) { + const [task] = step.tasks; + if (!task) { + throw new Error('Machine is waiting without an agent task.'); } - const result = await runWorkersAiEffect(env, effect); + const result = await runWorkersAiEffect(env, task); if (result.kind === 'event') { - [snapshot, actions] = transition( - gameMachine, - snapshot, - result.event as never - ); + step = gameMachine.transition(step, result.event as never); } else { - [snapshot, actions] = transitionResult( - gameMachine, - snapshot, - effect, - result.output - ); + step = gameMachine.resolve(step, task, result.output); } } - return snapshot.output; + return step.snapshot.output; } export default { diff --git a/examples/setup-agent/hosts/tanstack-ai.ts b/examples/setup-agent/hosts/tanstack-ai.ts index 36b8889..15d646c 100644 --- a/examples/setup-agent/hosts/tanstack-ai.ts +++ b/examples/setup-agent/hosts/tanstack-ai.ts @@ -1,22 +1,16 @@ /** - * TanStack AI pure-transition host for the game workflow. + * TanStack AI step host for the game workflow. * * Install peer SDKs in an app: * pnpm add @tanstack/ai @tanstack/ai-openai * * Then run with an OpenAI-compatible TanStack adapter. */ -import { initialTransition, transition } from 'xstate'; import { - getAgentEffects, - transitionResult, type AgentEffect, } from '../../../src/index.js'; import { - chooseMove, gameMachine, - gameSchemas, - summarizeTurn, } from '../game-agent.js'; type TanStackChat = (options: { @@ -69,43 +63,26 @@ export async function runTanStackGameTurn(args: { adapter: unknown; input?: { playerHp: number; enemyHp: number }; }) { - let [snapshot, actions] = initialTransition( - gameMachine, - args.input ?? { playerHp: 20, enemyHp: 15 } - ); + let step = gameMachine.initial(args.input ?? { playerHp: 20, enemyHp: 15 }); - while (snapshot.status !== 'done') { - const [effect] = getAgentEffects(actions, { - snapshot, - schemas: gameSchemas, - actors: { chooseMove, summarizeTurn }, - }); - - if (!effect) { - throw new Error('Machine is waiting without an agent effect.'); + while (!step.done) { + const [task] = step.tasks; + if (!task) { + throw new Error('Machine is waiting without an agent task.'); } const result = await runTanStackEffect({ chat: args.chat, adapter: args.adapter, - effect, + effect: task, }); if (result.kind === 'event') { - [snapshot, actions] = transition( - gameMachine, - snapshot, - result.event as never - ); + step = gameMachine.transition(step, result.event as never); } else { - [snapshot, actions] = transitionResult( - gameMachine, - snapshot, - effect, - result.output - ); + step = gameMachine.resolve(step, task, result.output); } } - return snapshot.output; + return step.snapshot.output; } From c81ed60558b1bee426e7a0f748a08a6cb1d2b319 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 18 Jun 2026 15:14:27 -0700 Subject: [PATCH 44/50] Split AI SDK authored examples from host --- examples/index.ts | 11 +++ examples/setup-agent/hosts/ai-sdk.ts | 117 +-------------------------- examples/setup-agent/joke.ts | 54 +++++++++++++ examples/setup-agent/triage.ts | 61 ++++++++++++++ 4 files changed, 129 insertions(+), 114 deletions(-) create mode 100644 examples/setup-agent/joke.ts create mode 100644 examples/setup-agent/triage.ts diff --git a/examples/index.ts b/examples/index.ts index be12716..71f5c25 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -11,3 +11,14 @@ export { summarizeTurn, turnSummarySchema, } from './setup-agent/game-agent.js'; +export { + jokeMachine, + jokeSchemas, + tellJoke, +} from './setup-agent/joke.js'; +export { + triageMachine, + triageSchemas, + triageSchema, + triageTicket, +} from './setup-agent/triage.js'; diff --git a/examples/setup-agent/hosts/ai-sdk.ts b/examples/setup-agent/hosts/ai-sdk.ts index fcfc5f5..d71a4a4 100644 --- a/examples/setup-agent/hosts/ai-sdk.ts +++ b/examples/setup-agent/hosts/ai-sdk.ts @@ -17,14 +17,13 @@ import { type ModelMessage, } from 'ai'; import { openai } from '@ai-sdk/openai'; -import { assign, createActor, toPromise } from 'xstate'; -import { z } from 'zod'; +import { createActor, toPromise } from 'xstate'; import { - createAgentSchemas, - setupAgent, type AgentTextInput, type TextLogic, } from '../../../src/index.js'; +import { jokeMachine, tellJoke } from '../joke.js'; +import { triageMachine, triageTicket } from '../triage.js'; // ─── Host Adapter: AI SDK execution ─── @@ -130,64 +129,6 @@ export function createAiSdkStreamingTextActor( ); } -// ─── Authored Agent: ticket triage ─── - -const triageSchema = z.object({ - sentiment: z.enum(['positive', 'neutral', 'negative']), - category: z.enum(['billing', 'technical', 'other']), - reply: z.string(), -}); - -const triageAgent = setupAgent({ - schemas: createAgentSchemas({ - context: z.object({ - ticket: z.string(), - triage: triageSchema.nullable(), - }), - input: z.object({ ticket: z.string() }), - output: triageSchema, - }), -}).withTasks({ - triageTicket: { - schemas: { - input: z.object({ ticket: z.string() }), - output: triageSchema, - }, - model: 'openai/gpt-5.4-nano', - system: - 'Triage the support ticket: sentiment, category, and a short suggested reply.', - prompt: ({ input }) => input.ticket, - }, -}); - -const { triageTicket } = triageAgent.tasks; - -const triageMachine = triageAgent.createMachine({ - id: 'ticket-triage', - context: ({ input }) => ({ ticket: input.ticket, triage: null }), - initial: 'triaging', - states: { - triaging: { - invoke: { - id: 'triage', - src: 'triageTicket', - input: ({ context }) => ({ ticket: context.ticket }), - onDone: { - target: 'done', - actions: assign({ - triage: ({ event }) => event.output, - }), - }, - }, - }, - done: { - type: 'final', - output: ({ context }) => - context.triage ?? { sentiment: 'neutral', category: 'other', reply: '' }, - }, - }, -}); - export async function runTriageDemo(ticket: string) { const actor = createActor( triageMachine.provide({ @@ -222,58 +163,6 @@ export async function runTriageStepDemo(ticket: string) { return step.snapshot.output; } -// ─── Authored Agent: streaming joke ─── - -const jokeAgent = setupAgent({ - schemas: createAgentSchemas({ - context: z.object({ - topic: z.string(), - joke: z.string().nullable(), - }), - input: z.object({ topic: z.string() }), - output: z.object({ joke: z.string() }), - }), -}).withTasks({ - tellJoke: { - kind: 'stream', - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), - }, - model: 'openai/gpt-5.4-nano', - system: 'You tell short, punchy jokes.', - prompt: ({ input }) => `Tell a joke about ${input.topic}.`, - }, -}); - -const { tellJoke } = jokeAgent.tasks; - -const jokeMachine = jokeAgent.createMachine({ - id: 'joke-streamer', - context: ({ input }) => ({ topic: input.topic, joke: null }), - // The no-helper route to typed machine output: a root-level `output` - // mapper, which XState types against the output schema natively. Final - // states stay bare. (`agent.final` is only needed when each final state - // computes a DIFFERENT output.) - output: ({ context }) => ({ joke: context.joke ?? '' }), - initial: 'streaming', - states: { - streaming: { - invoke: { - id: 'joke', - src: 'tellJoke', - input: ({ context }) => ({ topic: context.topic }), - onDone: { - target: 'done', - // event.output is the FINAL streamed text (string) - actions: assign({ joke: ({ event }) => event.output }), - }, - }, - }, - done: { type: 'final' }, - }, -}); - export async function runStreamingDemo(topic: string) { const actor = createActor( jokeMachine.provide({ diff --git a/examples/setup-agent/joke.ts b/examples/setup-agent/joke.ts new file mode 100644 index 0000000..620d94f --- /dev/null +++ b/examples/setup-agent/joke.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; +import { assign } from 'xstate'; +import { createAgentSchemas, setupAgent } from '../../src/index.js'; + +const jokeSchema = z.object({ + joke: z.string(), +}); + +const schemas = createAgentSchemas({ + context: z.object({ + topic: z.string(), + joke: z.string().nullable(), + }), + input: z.object({ topic: z.string() }), + output: jokeSchema, +}); + +const jokeAgent = setupAgent({ schemas }).withTasks({ + tellJoke: { + kind: 'stream', + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'openai/gpt-5.4-nano', + system: 'You tell short, punchy jokes.', + prompt: ({ input }) => `Tell a joke about ${input.topic}.`, + }, +}); + +export const { tellJoke } = jokeAgent.tasks; + +export const jokeSchemas = jokeAgent.schemas; + +export const jokeMachine = jokeAgent.createMachine({ + id: 'joke-streamer', + context: ({ input }) => ({ topic: input.topic, joke: null }), + output: ({ context }) => ({ joke: context.joke ?? '' }), + initial: 'streaming', + states: { + streaming: { + invoke: { + id: 'joke', + src: 'tellJoke', + input: ({ context }) => ({ topic: context.topic }), + onDone: { + target: 'done', + actions: assign({ joke: ({ event }) => event.output }), + }, + }, + }, + done: { type: 'final' }, + }, +}); diff --git a/examples/setup-agent/triage.ts b/examples/setup-agent/triage.ts new file mode 100644 index 0000000..74a73c8 --- /dev/null +++ b/examples/setup-agent/triage.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; +import { assign } from 'xstate'; +import { createAgentSchemas, setupAgent } from '../../src/index.js'; + +export const triageSchema = z.object({ + sentiment: z.enum(['positive', 'neutral', 'negative']), + category: z.enum(['billing', 'technical', 'other']), + reply: z.string(), +}); + +const schemas = createAgentSchemas({ + context: z.object({ + ticket: z.string(), + triage: triageSchema.nullable(), + }), + input: z.object({ ticket: z.string() }), + output: triageSchema, +}); + +const triageAgent = setupAgent({ schemas }).withTasks({ + triageTicket: { + schemas: { + input: z.object({ ticket: z.string() }), + output: triageSchema, + }, + model: 'openai/gpt-5.4-nano', + system: + 'Triage the support ticket: sentiment, category, and a short suggested reply.', + prompt: ({ input }) => input.ticket, + }, +}); + +export const { triageTicket } = triageAgent.tasks; + +export const triageSchemas = triageAgent.schemas; + +export const triageMachine = triageAgent.createMachine({ + id: 'ticket-triage', + context: ({ input }) => ({ ticket: input.ticket, triage: null }), + initial: 'triaging', + states: { + triaging: { + invoke: { + id: 'triage', + src: 'triageTicket', + input: ({ context }) => ({ ticket: context.ticket }), + onDone: { + target: 'done', + actions: assign({ + triage: ({ event }) => event.output, + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => + context.triage ?? { sentiment: 'neutral', category: 'other', reply: '' }, + }, + }, +}); From 8fc9681a05b2365f9982f2ca3e190f0b908f4413 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 19 Jun 2026 07:16:01 -0700 Subject: [PATCH 45/50] Fix v2 agent API seams --- package.json | 12 +--- readme.md | 2 - src/ai-sdk/index.test.ts | 119 ++++++++++++++------------------------- src/ai-sdk/index.ts | 117 ++++++-------------------------------- src/classify.ts | 67 ---------------------- src/decide.ts | 87 ---------------------------- src/index.ts | 7 --- src/setup-agent.test.ts | 73 ++++++++++++++++++++++++ src/setup-agent.ts | 16 +++++- src/types.ts | 52 ----------------- 10 files changed, 147 insertions(+), 405 deletions(-) delete mode 100644 src/classify.ts delete mode 100644 src/decide.ts diff --git a/package.json b/package.json index 7aa1ad5..e61b29a 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,7 @@ "ai", "state machine", "agent", - "statechart", - "classify", - "decide" + "statechart" ], "author": "David Khourshid ", "license": "MIT", @@ -87,14 +85,6 @@ "vitest": "^2.1.2", "zod": "^4.3.6" }, - "peerDependencies": { - "zod": "^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - }, "publishConfig": { "access": "public" }, diff --git a/readme.md b/readme.md index c48c6f5..00f7740 100644 --- a/readme.md +++ b/readme.md @@ -110,8 +110,6 @@ Start here: - LangGraph parity: [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts), [`docs/langgraph-parity.md`](/Users/davidkpiano/Code/agent/docs/langgraph-parity.md), [`docs/langgraph-gaps.md`](/Users/davidkpiano/Code/agent/docs/langgraph-gaps.md) - Burr parity: [`src/burr-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/burr-equivalents/raw-xstate.test.ts), [`docs/burr-parity.md`](/Users/davidkpiano/Code/agent/docs/burr-parity.md) -Use `classify(...)` when the result is just "what kind of thing is this?" Use `decide(...)` when the result is "what should happen next?" and the chosen branch may need structured data. - CrewAI Flow parity is tracked in [`docs/crewai-parity.md`](/Users/davidkpiano/Code/agent/docs/crewai-parity.md), the same way LangGraph parity is tracked separately. Burr parity is tracked in [`docs/burr-parity.md`](/Users/davidkpiano/Code/agent/docs/burr-parity.md), focused on action-like authoring patterns without adopting Burr's runtime. diff --git a/src/ai-sdk/index.test.ts b/src/ai-sdk/index.test.ts index 046559f..3b04f37 100644 --- a/src/ai-sdk/index.test.ts +++ b/src/ai-sdk/index.test.ts @@ -1,85 +1,9 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; -import { createAiSdkAdapter, createAiSdkDecisionAdapter, toAiSdkTools } from './index.js'; +import { createAiSdkAdapter, toAiSdkGenerateTextOptions, toAiSdkTools } from './index.js'; +import type { StandardSchemaV1 } from '../types.js'; describe('createAiSdkAdapter', () => { - test('resolves schema-less choices with a custom model resolver', async () => { - const seen: Array<{ model: unknown; prompt: unknown }> = []; - const adapter = createAiSdkDecisionAdapter({ - resolveModel: (model) => ({ providerResolved: model }) as never, - generateText: async (options) => { - seen.push({ - model: options.model, - prompt: options.prompt, - }); - - return { - output: 'billing', - } as never; - }, - }); - - const result = await adapter.decide({ - model: 'openai/gpt-5.4-nano', - prompt: 'Refund request for last month.', - options: { - billing: { description: 'Billing support' }, - general: { description: 'General support' }, - }, - }); - - expect(result).toEqual({ - choice: 'billing', - data: {}, - }); - expect(seen).toEqual([ - { - model: { providerResolved: 'openai/gpt-5.4-nano' }, - prompt: 'Refund request for last month.', - }, - ]); - }); - - test('returns structured decision payloads for schema-backed options', async () => { - const adapter = createAiSdkDecisionAdapter({ - generateText: async () => - ({ - output: { - decision: 'research', - data: { - query: 'latest cloudflare agents docs', - }, - reasoning: 'Need the newest API details.', - }, - }) as never, - }); - - const result = await adapter.decide({ - model: 'openai/gpt-5.4-nano', - prompt: 'Find the current Cloudflare Agents docs.', - reasoning: true, - options: { - research: { - description: 'Do external research first.', - schema: z.object({ - query: z.string(), - }), - }, - answer: { - description: 'Answer directly.', - }, - }, - }); - - expect(result).toEqual({ - choice: 'research', - data: { - query: 'latest cloudflare agents docs', - }, - reasoning: 'Need the newest API details.', - }); - }); - test('creates a generation-only machine adapter', async () => { const adapter = createAiSdkAdapter({ generateText: async (options) => @@ -98,6 +22,32 @@ describe('createAiSdkAdapter', () => { expect('decide' in adapter).toBe(false); }); + test('passes standard JSON schemas through to AI SDK output', async () => { + const outputSchema = standardJsonSchema({ + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + additionalProperties: false, + }); + + const options = toAiSdkGenerateTextOptions({ + modelRef: 'openai/gpt-5.4-nano', + messages: [], + prompt: 'reply', + outputSchema, + }); + + await expect(options.output?.responseFormat).resolves.toEqual({ + type: 'json', + schema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + additionalProperties: false, + }, + }); + }); + test('does not send prompt and messages together', async () => { const seen: Array<{ prompt?: unknown; messages?: unknown }> = []; const adapter = createAiSdkAdapter({ @@ -144,3 +94,16 @@ describe('createAiSdkAdapter', () => { ); }); }); + +function standardJsonSchema(jsonSchema: Record): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => jsonSchema, + }, + }, + } as unknown as StandardSchemaV1; +} diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index 788dcf1..5c9cd15 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -1,10 +1,8 @@ -import { generateText, Output, tool } from 'ai'; -import { z } from 'zod'; +import { generateText, Output, tool, type FlexibleSchema } from 'ai'; import type { AgentAdapter, AgentGenerateTextInput, AgentTools, - DecideAdapter, StandardSchemaV1, } from '../types.js'; @@ -50,7 +48,7 @@ export function toAiSdkGenerateTextOptions( ...(outputSchema ? { output: Output.object({ - schema: toZodSchema(outputSchema), + schema: outputSchema as FlexibleSchema, }), } : {}), @@ -76,7 +74,7 @@ export function toAiSdkTools(tools: AgentTools) { return [[ name, tool({ - inputSchema: z.unknown(), + inputSchema: unknownSchema, execute: descriptor as any, } as any), ]]; @@ -87,7 +85,9 @@ export function toAiSdkTools(tools: AgentTools) { ?? (descriptor.schemas as { input?: StandardSchemaV1 } | undefined)?.input; const toolOptions: Record = { description: descriptor.description, - inputSchema: inputSchema ? toZodSchema(inputSchema) : z.unknown(), + inputSchema: inputSchema + ? inputSchema as FlexibleSchema + : unknownSchema, execute: descriptor.execute as any, }; @@ -108,100 +108,6 @@ function toAiSdkToolChoice(toolChoice: AgentGenerateTextInput['toolChoice']) { return toolChoice; } -/** - * Create a decision helper adapter for decide(...) and classify(...). - */ -export function createAiSdkDecisionAdapter( - config: CreateAiSdkAdapterOptions = {} -): DecideAdapter { - const generate = config.generateText ?? generateText; - - return { - async decide({ model, prompt, options, reasoning }) { - const optionKeys = Object.keys(options); - const allSchemaLess = Object.values(options).every((option) => !option.schema); - - if (allSchemaLess && !reasoning) { - const optionDescriptions = Object.entries(options) - .map(([key, opt]) => `- ${key}: ${opt.description}`) - .join('\n'); - - const result = await generate({ - model: resolveModel(model, config.resolveModel), - system: `You must choose exactly one of the following options:\n${optionDescriptions}`, - prompt, - output: Output.choice({ - options: optionKeys, - }), - }); - - return { - choice: result.output, - data: {}, - }; - } - - const optionSchemas: z.ZodTypeAny[] = []; - for (const [key, opt] of Object.entries(options)) { - optionSchemas.push( - z.object({ - decision: z.literal(key), - data: opt.schema ? toZodSchema(opt.schema) : z.object({}), - ...(reasoning ? { reasoning: z.string() } : {}), - }) - ); - } - - const schemas = optionSchemas; - const schema = - schemas.length === 1 - ? schemas[0]! - : z.union(schemas as [z.ZodType, z.ZodType, ...z.ZodType[]]); - - const optionDescriptions = Object.entries(options) - .map(([key, opt]) => `- ${key}: ${opt.description}`) - .join('\n'); - - const systemPrompt = `You must choose exactly one of the following options:\n${optionDescriptions}\n\nRespond with structured output containing the chosen decision and any required data.`; - - const result = await generate({ - model: resolveModel(model, config.resolveModel), - system: systemPrompt, - prompt, - output: Output.object({ - schema, - }), - }); - - const obj = result.output as { - decision: string; - data: Record; - reasoning?: string; - }; - - return { - choice: obj.decision, - data: obj.data ?? {}, - reasoning: obj.reasoning, - }; - }, - }; -} - -/** - * Convert a StandardSchemaV1 to a zod schema. - * If it's already a zod schema, return as-is. - * Otherwise, fall back to z.record for basic compatibility. - */ -function toZodSchema(schema: StandardSchemaV1): z.ZodType { - // Check if it's already a zod schema (has _zod property in v4) - if ('_zod' in schema || '_def' in schema) { - return schema as unknown as z.ZodType; - } - // Fallback: accept any object - return z.record(z.string(), z.unknown()); -} - /** * Resolve a portable model ref to an AI SDK model. * Supports custom resolution when users prefer provider helpers such as @@ -217,3 +123,14 @@ function resolveModel( return modelRef as any; } + +const unknownSchema = { + '~standard': { + version: 1, + vendor: 'statelyai-agent', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => ({}), + }, + }, +} as unknown as StandardSchemaV1 & FlexibleSchema; diff --git a/src/classify.ts b/src/classify.ts deleted file mode 100644 index 12f2c0e..0000000 --- a/src/classify.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { z } from 'zod'; -import { decide } from './decide.js'; -import type { - ClassifyOptions, - ClassifyResultFor, - StandardSchemaV1, -} from './types.js'; - -export async function classify< - const TCategories extends Record, ->( - options: ClassifyOptions -): Promise> { - const result = await decide({ - adapter: options.adapter, - model: options.model, - prompt: buildClassificationPrompt(options.prompt, options.examples), - options: options.into, - reasoning: options.reasoning, - }); - - return { - category: result.choice as keyof TCategories & string, - }; -} - -function buildClassificationPrompt( - prompt: string, - examples: Array<{ input: string; category: string }> | undefined -): string { - if (!examples?.length) { - return prompt; - } - - return [ - prompt, - '', - 'Examples:', - ...examples.map((example) => `${example.category}: ${example.input}`), - ].join('\n'); -} - -export function classifyResultSchema< - const TCategories extends Record, ->( - into: TCategories -): StandardSchemaV1> { - const categories = Object.keys(into); - if (categories.length === 0) { - throw new Error('classifyResultSchema requires at least one category'); - } - - const categorySchema = - categories.length === 1 - ? z.literal(categories[0]!) - : z.union( - categories.map((category) => z.literal(category)) as [ - z.ZodLiteral, - z.ZodLiteral, - ...z.ZodLiteral[], - ] - ); - - return z.object({ - category: categorySchema, - }) as unknown as StandardSchemaV1>; -} diff --git a/src/decide.ts b/src/decide.ts deleted file mode 100644 index 10f62d7..0000000 --- a/src/decide.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { z } from 'zod'; -import { validateSchemaSync } from './utils.js'; -import type { - DecideAdapter, - DecideOptions, - DecideResultFor, - StandardSchemaV1, -} from './types.js'; - -export async function decide< - const TOptions extends Record, ->( - options: DecideOptions -): Promise> { - const adapter = requireAdapter(options.adapter, 'decide()'); - const result = await adapter.decide({ - model: options.model, - prompt: options.prompt, - options: options.options, - reasoning: options.reasoning, - }); - - const chosen = options.options[result.choice]; - if (!chosen) { - throw new Error( - `Adapter returned unknown decision '${result.choice}' for model '${options.model}'` - ); - } - - const data = chosen.schema - ? validateSchemaSync(chosen.schema, result.data) - : {}; - - return { - choice: result.choice, - data, - reasoning: result.reasoning, - } as DecideResultFor; -} - -export function requireAdapter( - adapter: DecideAdapter | undefined, - label: string -): DecideAdapter { - if (!adapter) { - throw new Error(`No adapter configured for ${label}`); - } - - return adapter; -} - -export function decideResultSchema< - const TOptions extends Record, ->( - options: TOptions, - config: { reasoning?: boolean } = {} -): StandardSchemaV1> { - const schemas = Object.entries(options).map(([choice, option]) => - z.object({ - choice: z.literal(choice), - data: option.schema ? toZodSchema(option.schema) : z.object({}), - ...(config.reasoning ? { reasoning: z.string().optional() } : {}), - }) - ); - - if (schemas.length === 0) { - throw new Error('decideResultSchema requires at least one option'); - } - - return (schemas.length === 1 - ? schemas[0]! - : z.union( - schemas as unknown as [ - z.ZodTypeAny, - z.ZodTypeAny, - ...z.ZodTypeAny[], - ] - )) as unknown as StandardSchemaV1>; -} - -function toZodSchema(schema: StandardSchemaV1): z.ZodTypeAny { - if ('_zod' in schema || '_def' in schema) { - return schema as unknown as z.ZodTypeAny; - } - - return z.record(z.string(), z.unknown()); -} diff --git a/src/index.ts b/src/index.ts index dd2aba3..3ec8c21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,8 +13,6 @@ export { transitionResult, validateSchemaSync, } from './setup-agent.js'; -export { decide, decideResultSchema, requireAdapter } from './decide.js'; -export { classify, classifyResultSchema } from './classify.js'; export { createAdapter } from './adapter.js'; export { assistantMessage, @@ -54,11 +52,6 @@ export type { AgentToolDescriptor, AgentToolExecute, AgentTools, - ClassifyOptions, - ClassifyResultFor, - DecideAdapter, - DecideOptions, - DecideResultFor, EventPayload, EventUnion, InferOutput, diff --git a/src/setup-agent.test.ts b/src/setup-agent.test.ts index 15ac6b6..e8650b7 100644 --- a/src/setup-agent.test.ts +++ b/src/setup-agent.test.ts @@ -268,6 +268,79 @@ describe('setupAgent', () => { ).resolves.toBe('Streamed final text.'); }); + test('provided agent machines preserve step helpers', () => { + const agent = setupAgent({ + context: z.object({ prompt: z.string() }), + input: z.object({ prompt: z.string() }), + }).withTasks({ + answer: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ answer: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + }, + }); + + const machine = agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt }), + initial: 'answering', + states: { + answering: { + invoke: { + id: 'answer', + src: 'answer', + input: ({ context }) => ({ prompt: context.prompt }), + }, + }, + }, + }); + const provided = machine.provide({ actors: {} }); + const step = provided.initial({ prompt: 'hello' }); + + expect(provided.getTasks(step.actions, step.snapshot)).toHaveLength(1); + expect(typeof provided.execute).toBe('function'); + expect(typeof provided.resolve).toBe('function'); + }); + + test('agent machine step execution validates task output schemas', async () => { + const agent = setupAgent({ + context: z.object({ prompt: z.string() }), + input: z.object({ prompt: z.string() }), + }).withTasks({ + answer: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ answer: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + }, + }); + + const machine = agent.createMachine({ + context: ({ input }) => ({ prompt: input.prompt }), + initial: 'answering', + states: { + answering: { + invoke: { + id: 'answer', + src: 'answer', + input: ({ context }) => ({ prompt: context.prompt }), + }, + }, + }, + }); + const step = machine.initial({ prompt: 'hello' }); + + await expect( + machine.execute(step.tasks[0]!, { + generateText: () => ({ answer: 123 }), + }) + ).rejects.toThrow('expected string'); + }); + test('setupAgent preserves typed action guard and delay names', () => { const schemas = createAgentSchemas({ context: z.object({ prompt: z.string(), ready: z.boolean() }), diff --git a/src/setup-agent.ts b/src/setup-agent.ts index b5223c0..f8277e2 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -574,7 +574,17 @@ function createAgentMachine( options: Pick ): AgentMachine { const originalTransition = machine.transition.bind(machine); + const originalProvide = 'provide' in machine + ? (machine.provide as (...args: any[]) => TMachine).bind(machine) + : undefined; const agentMachine = Object.assign(machine, { + provide(...args: any[]) { + if (!originalProvide) { + throw new Error('This actor logic does not support provide(...).'); + } + + return createAgentMachine(originalProvide(...args), options); + }, initial(input?: unknown) { const [snapshot, actions] = initialTransition(agentMachine, input as never); return createAgentStep(agentMachine, snapshot, actions); @@ -627,7 +637,11 @@ function createAgentMachine( ); } - return normalizeTaskExecutionResult(await executor(request)); + const output = await normalizeTaskExecutionResult(await executor(request)); + + return task.input.outputSchema + ? validateSchemaSync(task.input.outputSchema, output) + : output; }, }) as AgentMachine; diff --git a/src/types.ts b/src/types.ts index 80aee45..5ba5faa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,55 +54,3 @@ export interface AgentGenerateTextInput { export interface AgentAdapter { generateText?: (options: AgentGenerateTextInput) => Promise; } - -export interface DecideAdapter { - decide: (options: { - model: string; - prompt: string; - options: Record; - reasoning?: boolean; - }) => Promise<{ - choice: string; - data: Record; - reasoning?: string; - }>; -} - -export type DecideResultFor< - TOptions extends Record, -> = { - [K in keyof TOptions & string]: { - choice: K; - data: TOptions[K] extends { schema: StandardSchemaV1 } - ? O - : Record; - reasoning?: string; - }; -}[keyof TOptions & string]; - -export interface DecideOptions< - TOptions extends Record = Record, -> { - adapter?: DecideAdapter; - model: string; - prompt: string; - options: TOptions; - reasoning?: boolean; -} - -export interface ClassifyResultFor< - TCategories extends Record = Record, -> { - category: keyof TCategories & string; -} - -export interface ClassifyOptions< - TCategories extends Record = Record, -> { - adapter?: DecideAdapter; - model: string; - prompt: string; - into: TCategories; - examples?: Array<{ input: string; category: keyof TCategories & string }>; - reasoning?: boolean; -} From c149431f5e187524fe203c38b7fd7451ec2914a7 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 19 Jun 2026 07:56:35 -0700 Subject: [PATCH 46/50] Prune stale adapter API --- examples/setup-agent/hosts/ai-sdk.ts | 20 ++++-- examples/setup-agent/smoke.mts | 4 +- package.json | 4 +- src/adapter.ts | 8 --- src/ai-sdk/index.test.ts | 89 +------------------------- src/ai-sdk/index.ts | 94 +--------------------------- src/index.ts | 3 - src/setup-agent.ts | 4 +- src/types.ts | 14 ----- 9 files changed, 25 insertions(+), 215 deletions(-) delete mode 100644 src/adapter.ts diff --git a/examples/setup-agent/hosts/ai-sdk.ts b/examples/setup-agent/hosts/ai-sdk.ts index d71a4a4..a0948c3 100644 --- a/examples/setup-agent/hosts/ai-sdk.ts +++ b/examples/setup-agent/hosts/ai-sdk.ts @@ -20,8 +20,10 @@ import { openai } from '@ai-sdk/openai'; import { createActor, toPromise } from 'xstate'; import { type AgentTextInput, - type TextLogic, + type AgentTools, + type TextLogicExecutor, } from '../../../src/index.js'; +import { toAiSdkTools } from '../../../src/ai-sdk/index.js'; import { jokeMachine, tellJoke } from '../joke.js'; import { triageMachine, triageTicket } from '../triage.js'; @@ -66,8 +68,10 @@ async function generateWithAiSdk( topP: input.topP, seed: input.seed, stopSequences: input.stopSequences, - tools, - toolChoice: input.toolChoice, + tools: tools ? toAiSdkTools(tools) : undefined, + toolChoice: typeof input.toolChoice === 'object' + ? { type: 'tool' as const, toolName: input.toolChoice.name } + : input.toolChoice, }; if (input.outputSchema) { @@ -111,7 +115,11 @@ async function streamWithAiSdk( return await result.text; } -export function createAiSdkTextActor( +type ExecutableTextLogic = { + withExecutor(execute: TextLogicExecutor): unknown; +}; + +export function createAiSdkTextActor( logic: TLogic, options: AiSdkTextHostOptions = {} ) { @@ -120,7 +128,7 @@ export function createAiSdkTextActor( ); } -export function createAiSdkStreamingTextActor( +export function createAiSdkStreamingTextActor( logic: TLogic, options: AiSdkTextHostOptions = {} ) { @@ -153,7 +161,7 @@ export async function runTriageStepDemo(ticket: string) { for (const task of step.tasks) { const output = await triageMachine.execute(task, { - generateText: (request) => + generateText: (request: AgentTextInput & { tools: AgentTools }) => generateWithAiSdk(request, request.tools), }); step = triageMachine.resolve(step, task, output); diff --git a/examples/setup-agent/smoke.mts b/examples/setup-agent/smoke.mts index d181823..e4d8e3e 100644 --- a/examples/setup-agent/smoke.mts +++ b/examples/setup-agent/smoke.mts @@ -27,11 +27,11 @@ const machine = emailDrafter.provide({ const actor = createActor(machine); actor.start(); -actor.send({ type: 'PROMPT_SUBMITTED', prompt: 'email sam' }); +actor.send({ type: 'PROMPT_SUBMITTED', prompt: 'email sam' } as any); await waitFor(actor, (s) => s.matches('needsMoreInfo')); console.log('1. needsMoreInfo meta:', JSON.stringify(actor.getSnapshot().getMeta(), null, 0).slice(0, 80), '…'); -actor.send({ type: 'MORE_INFO', details: 'sam@example.com, say hello' }); +actor.send({ type: 'MORE_INFO', details: 'sam@example.com, say hello' } as any); await waitFor(actor, (s) => s.matches('reviewing')); console.log('2. reviewing, draft:', actor.getSnapshot().context.draft); console.log('3. messages:', actor.getSnapshot().context.messages.map((m: any) => m.role)); diff --git a/package.json b/package.json index e61b29a..5cd5845 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,9 @@ "scripts": { "agent:convert": "tsx scripts/agent-convert.ts", "build": "tsdown", - "lint": "tsc --noEmit", + "lint": "pnpm run lint:src && pnpm run lint:examples", + "lint:src": "tsc --noEmit", + "lint:examples": "tsc -p examples/setup-agent/tsconfig.json --noEmit", "test": "vitest", "test:ci": "vitest --run", "prepublishOnly": "tsdown", diff --git a/src/adapter.ts b/src/adapter.ts deleted file mode 100644 index 5edb2a3..0000000 --- a/src/adapter.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { AgentAdapter } from './types.js'; - -/** - * Create a custom adapter for model execution. - */ -export function createAdapter(impl: AgentAdapter): AgentAdapter { - return impl; -} diff --git a/src/ai-sdk/index.test.ts b/src/ai-sdk/index.test.ts index 3b04f37..bb4b3a8 100644 --- a/src/ai-sdk/index.test.ts +++ b/src/ai-sdk/index.test.ts @@ -1,80 +1,8 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; -import { createAiSdkAdapter, toAiSdkGenerateTextOptions, toAiSdkTools } from './index.js'; -import type { StandardSchemaV1 } from '../types.js'; - -describe('createAiSdkAdapter', () => { - test('creates a generation-only machine adapter', async () => { - const adapter = createAiSdkAdapter({ - generateText: async (options) => - ({ - text: `generated ${options.prompt}`, - }) as never, - }); - - await expect( - adapter.generateText?.({ - modelRef: 'openai/gpt-5.4-nano', - messages: [], - prompt: 'reply', - }) - ).resolves.toBe('generated reply'); - expect('decide' in adapter).toBe(false); - }); - - test('passes standard JSON schemas through to AI SDK output', async () => { - const outputSchema = standardJsonSchema({ - type: 'object', - properties: { answer: { type: 'string' } }, - required: ['answer'], - additionalProperties: false, - }); - - const options = toAiSdkGenerateTextOptions({ - modelRef: 'openai/gpt-5.4-nano', - messages: [], - prompt: 'reply', - outputSchema, - }); - - await expect(options.output?.responseFormat).resolves.toEqual({ - type: 'json', - schema: { - type: 'object', - properties: { answer: { type: 'string' } }, - required: ['answer'], - additionalProperties: false, - }, - }); - }); - - test('does not send prompt and messages together', async () => { - const seen: Array<{ prompt?: unknown; messages?: unknown }> = []; - const adapter = createAiSdkAdapter({ - generateText: async (options) => { - seen.push({ - prompt: options.prompt, - messages: options.messages, - }); - - return { text: 'ok' } as never; - }, - }); - - await adapter.generateText?.({ - modelRef: 'openai/gpt-5.4-nano', - prompt: 'reply', - messages: [{ role: 'user', content: 'reply' }], - }); - - expect(seen).toEqual([ - { - prompt: undefined, - messages: [{ role: 'user', content: 'reply' }], - }, - ]); - }); +import { toAiSdkTools } from './index.js'; +describe('toAiSdkTools', () => { test('converts agent tool descriptors to AI SDK tools', () => { const inputSchema = z.object({ target: z.string() }); const tools = toAiSdkTools({ @@ -94,16 +22,3 @@ describe('createAiSdkAdapter', () => { ); }); }); - -function standardJsonSchema(jsonSchema: Record): StandardSchemaV1 { - return { - '~standard': { - version: 1, - vendor: 'test', - validate: (value: unknown) => ({ value }), - jsonSchema: { - input: () => jsonSchema, - }, - }, - } as unknown as StandardSchemaV1; -} diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index 5c9cd15..a237b03 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -1,67 +1,5 @@ -import { generateText, Output, tool, type FlexibleSchema } from 'ai'; -import type { - AgentAdapter, - AgentGenerateTextInput, - AgentTools, - StandardSchemaV1, -} from '../types.js'; - -type AiSdkGenerateText = typeof generateText; -type AiSdkModel = Parameters[0]['model']; - -export interface CreateAiSdkAdapterOptions { - resolveModel?: (modelRef: string) => AiSdkModel; - generateText?: AiSdkGenerateText; -} - -/** - * Create an adapter that uses the Vercel AI SDK for generative states. - * By default, model refs are passed straight through to the AI SDK. - * For provider helpers such as `openai(...)`, pass `resolveModel`. - */ -export function createAiSdkAdapter( - config: CreateAiSdkAdapterOptions = {} -): AgentAdapter { - const generate = config.generateText ?? generateText; - - return { - async generateText(input) { - const result = await generate(toAiSdkGenerateTextOptions(input, { - resolveModel: config.resolveModel, - })); - - const output = result as { output?: unknown; text?: string }; - return output.output ?? output.text ?? result; - }, - }; -} - -export function toAiSdkGenerateTextOptions( - { modelRef, system, prompt, messages, tools, toolChoice, outputSchema }: AgentGenerateTextInput, - config: Pick = {} -): Parameters[0] { - const options: any = { - model: resolveModel(modelRef ?? 'default', config.resolveModel), - system, - tools: tools ? toAiSdkTools(tools) : undefined, - toolChoice: toAiSdkToolChoice(toolChoice), - ...(outputSchema - ? { - output: Output.object({ - schema: outputSchema as FlexibleSchema, - }), - } - : {}), - }; - - if (messages.length > 0) { - options.messages = messages as any; - } else { - options.prompt = prompt ?? ''; - } - - return options; -} +import { tool, type FlexibleSchema } from 'ai'; +import type { AgentTools, StandardSchemaV1 } from '../types.js'; export function toAiSdkTools(tools: AgentTools) { return Object.fromEntries( @@ -96,34 +34,6 @@ export function toAiSdkTools(tools: AgentTools) { ); } -function toAiSdkToolChoice(toolChoice: AgentGenerateTextInput['toolChoice']) { - if (!toolChoice) { - return undefined; - } - - if (typeof toolChoice === 'object') { - return { type: 'tool' as const, toolName: toolChoice.name }; - } - - return toolChoice; -} - -/** - * Resolve a portable model ref to an AI SDK model. - * Supports custom resolution when users prefer provider helpers such as - * `openai('gpt-5.4-nano')`. - */ -function resolveModel( - modelRef: string, - resolver?: (modelRef: string) => AiSdkModel -): AiSdkModel { - if (resolver) { - return resolver(modelRef); - } - - return modelRef as any; -} - const unknownSchema = { '~standard': { version: 1, diff --git a/src/index.ts b/src/index.ts index 3ec8c21..fa385aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,6 @@ export { transitionResult, validateSchemaSync, } from './setup-agent.js'; -export { createAdapter } from './adapter.js'; export { assistantMessage, systemMessage, @@ -44,8 +43,6 @@ export type { } from './setup-agent.js'; export type { - AgentAdapter, - AgentGenerateTextInput, AgentMessage, AgentTool, AgentToolChoice, diff --git a/src/setup-agent.ts b/src/setup-agent.ts index f8277e2..6779052 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -643,7 +643,7 @@ function createAgentMachine( ? validateSchemaSync(task.input.outputSchema, output) : output; }, - }) as AgentMachine; + }) as unknown as AgentMachine; return agentMachine; } @@ -966,7 +966,7 @@ type SetupAgentResult< >[0], >( config: TConfig - ) => any; + ) => AgentMachine; schemas: AgentSchemaPack< TContextSchema, TEventSchemas, diff --git a/src/types.ts b/src/types.ts index 5ba5faa..8f95134 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,17 +40,3 @@ export type AgentToolChoice = | 'none' | 'required' | { type: 'tool'; name: string }; - -export interface AgentGenerateTextInput { - modelRef?: string; - system?: string; - prompt?: string; - messages: AgentMessage[]; - tools?: AgentTools; - toolChoice?: AgentToolChoice; - outputSchema?: StandardSchemaV1; -} - -export interface AgentAdapter { - generateText?: (options: AgentGenerateTextInput) => Promise; -} From c291a141f28531cb9557f91b02f3809d7d365c57 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Tue, 23 Jun 2026 23:47:14 -0400 Subject: [PATCH 47/50] Add static agent workflow config support --- docs/host-actors.md | 34 + package.json | 6 +- readme.md | 73 +++ schemas/agent-workflow.json | 519 +++++++++++++++ src/index.ts | 9 + .../agent-spec-config.test.ts | 287 +++++++++ src/setup-agent.test.ts | 213 +++++++ src/setup-agent.ts | 603 ++++++++++++++++++ 8 files changed, 1742 insertions(+), 2 deletions(-) create mode 100644 schemas/agent-workflow.json create mode 100644 src/oracle-equivalents/agent-spec-config.test.ts diff --git a/docs/host-actors.md b/docs/host-actors.md index a729367..3c20459 100644 --- a/docs/host-actors.md +++ b/docs/host-actors.md @@ -100,6 +100,40 @@ step = machine.transition(step, { type: 'REVISE', prompt: nextPrompt }); Use `initialTransition(...)`, `transition(...)`, and `transitionResult(...)` directly when a host wants to own the full XState action list instead of the `step.tasks` abstraction. +## User Input + +Use `agent.userInput` when workflow logic needs to wait for a human. It is a normal invoked actor; the host owns how the request is delivered and resumed. + +```ts +import { USER_INPUT_ACTOR } from '@statelyai/agent'; +import { fromPromise } from 'xstate'; + +const machine = setupAgent.fromConfig(config).provide({ + actors: { + [USER_INPUT_ACTOR]: fromPromise(async ({ input }) => { + return showFormAndWaitForSubmit(input); + }), + }, +}); +``` + +Static config uses the same actor source: + +```yaml +invoke: + src: agent.userInput + input: + prompt: "Who should receive this email?" + schema: + type: object + properties: + recipient: { type: string } + required: [recipient] + onDone: + assign: + recipient: "{{ event.output.recipient }}" +``` + ## Allowed Event Tools Use task `events` to expose specific state transitions as tools. `getAgentEffects(...)` validates that those events are legal from the current snapshot and returns event tools separately from the model-call input. diff --git a/package.json b/package.json index 5cd5845..83a5ce5 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,12 @@ "types": "./dist/xstate.d.cts", "default": "./dist/xstate.cjs" } - } + }, + "./agent-workflow.json": "./schemas/agent-workflow.json" }, "files": [ - "dist" + "dist", + "schemas" ], "scripts": { "agent:convert": "tsx scripts/agent-convert.ts", diff --git a/readme.md b/readme.md index 00f7740..9c9125f 100644 --- a/readme.md +++ b/readme.md @@ -5,6 +5,7 @@ Stately Agent is the state machine authoring layer for AI agents. Author your AI The package owns one first-class authoring primitive: - `setupAgent(...).withTasks(...)`: schema-first, SDK-agnostic agent task authoring. +- `setupAgent.fromConfig(...)`: static workflow config lowered to the same agent machine shape. Use `setupAgent(...)` for schema-first control flow. Use normal host code for runtime execution. Stately Agent adds the batteries: reusable text logic, message helpers, examples, retained schemas, and visualization/export affordances. @@ -90,6 +91,78 @@ This is normal XState underneath: use `machine.initial(...)`, `machine.transitio When a task declares `events`, `machine.getTasks(...)` returns `event.` tools for those events only if they are currently legal from the snapshot. That lets a model choose legal machine events, such as moves in a game, without exposing every transition. +## Static Workflow Definitions + + + +The package also publishes a JSON Schema for static, declarative agent workflow definitions: + +```ts +import workflowSchema from '@statelyai/agent/agent-workflow.json'; +``` + +Use `setupAgent.fromConfig(...)` to lower static definitions to the same agent machine shape as TS-first `setupAgent(...).withTasks(...)` authoring. Static definitions separate model tasks from XState-like control flow: + +```yaml +tasks: + answerQuestion: + model: openai/gpt-4.1 + system: "You answer for {{ context.userName }}." + prompt: "Question: {{ input.question }}" + input: + type: object + properties: + question: { type: string } + required: [question] + output: + type: object + properties: + answer: { type: string } + required: [answer] + +initial: thinking +states: + thinking: + invoke: + src: answerQuestion + input: + question: "{{ context.question }}" + onDone: + target: done + assign: + answer: "{{ event.output.answer }}" + done: + type: final +``` + +Then: + +```ts +const machine = setupAgent.fromConfig(config); +``` + +Values wrapped as whole strings, such as `"{{ context.question }}"`, are typed expressions. Text fields like `system` and `prompt` are templates and may embed `{{ }}` expressions inside larger strings. The current lowering supports simple dot-path expressions over `input`, `context`, and `event`. + +Human input is a normal host-provided actor. Static workflows can invoke `agent.userInput`; the host decides whether that means a CLI prompt, UI form, Slack interaction, or webhook pause: + +```yaml +states: + askRecipient: + invoke: + src: agent.userInput + input: + prompt: "Who should receive this email?" + schema: + type: object + properties: + recipient: { type: string } + required: [recipient] + onDone: + target: drafting + assign: + recipient: "{{ event.output.recipient }}" +``` + ## Examples diff --git a/schemas/agent-workflow.json b/schemas/agent-workflow.json new file mode 100644 index 0000000..93074b5 --- /dev/null +++ b/schemas/agent-workflow.json @@ -0,0 +1,519 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stately.ai/schemas/agent-workflow.json", + "title": "Stately Agent Workflow Definition", + "description": "Static, declarative agent workflow definition that can be lowered to a setupAgent(...).withTasks(...) XState machine.", + "type": "object", + "required": ["initial", "states"], + "properties": { + "key": { + "description": "Stable workflow key used by tools, storage, and visual editors.", + "type": "string", + "minLength": 1 + }, + "id": { + "description": "Optional XState machine id.", + "type": "string", + "minLength": 1 + }, + "version": { + "description": "Definition version chosen by the author.", + "type": "string" + }, + "description": { + "type": "string" + }, + "queryLanguage": { + "description": "Expression language used inside {{ }} expressions. The built-in setupAgent.fromConfig lowering currently supports simple dot-path expressions over input, context, and event.", + "type": "string", + "default": "path" + }, + "schemas": { + "$ref": "#/$defs/AgentSchemas" + }, + "context": { + "description": "Initial XState context. Values may be JSON literals or whole-string {{ }} expressions evaluated with machine input.", + "$ref": "#/$defs/ExpressionObject" + }, + "tasks": { + "description": "Named model tasks that become typed invoke sources.", + "type": "object", + "propertyNames": { + "$ref": "#/$defs/Identifier" + }, + "additionalProperties": { + "$ref": "#/$defs/Task" + }, + "default": {} + }, + "initial": { + "description": "Initial child state key.", + "type": "string" + }, + "states": { + "description": "Root state nodes.", + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/Identifier" + }, + "additionalProperties": { + "$ref": "#/$defs/State" + } + }, + "meta": { + "$ref": "#/$defs/JsonObject" + } + }, + "additionalProperties": false, + "$defs": { + "Identifier": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z_$][A-Za-z0-9_$.-]*$" + }, + "ExpressionString": { + "description": "Whole-string expression delimited by {{ }}.", + "type": "string", + "pattern": "^\\{\\{[\\s\\S]*\\}\\}$" + }, + "TemplateString": { + "description": "String that may contain {{ }} template expressions. Escape literal delimiters according to the selected expression/template evaluator.", + "type": "string" + }, + "JsonValue": { + "anyOf": [ + { "type": "null" }, + { "type": "boolean" }, + { "type": "number" }, + { "type": "string" }, + { + "type": "array", + "items": { + "$ref": "#/$defs/JsonValue" + } + }, + { + "$ref": "#/$defs/JsonObject" + } + ] + }, + "JsonObject": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + } + }, + "ExpressionValue": { + "description": "JSON value or whole-string {{ }} expression. Use this for typed values, not prompt templating.", + "anyOf": [ + { "$ref": "#/$defs/ExpressionString" }, + { "type": "null" }, + { "type": "boolean" }, + { "type": "number" }, + { "type": "string" }, + { + "type": "array", + "items": { + "$ref": "#/$defs/ExpressionValue" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/ExpressionValue" + } + } + ] + }, + "ExpressionObject": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/ExpressionValue" + } + }, + "JsonSchema": { + "description": "JSON Schema object.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": true + }, + "AgentSchemas": { + "type": "object", + "properties": { + "input": { + "$ref": "#/$defs/JsonSchema" + }, + "context": { + "$ref": "#/$defs/JsonSchema" + }, + "events": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/Identifier" + }, + "additionalProperties": { + "$ref": "#/$defs/JsonSchema" + } + }, + "output": { + "$ref": "#/$defs/JsonSchema" + }, + "meta": { + "$ref": "#/$defs/JsonSchema" + } + }, + "additionalProperties": false + }, + "Task": { + "type": "object", + "required": ["model", "input", "output"], + "properties": { + "kind": { + "type": "string", + "enum": ["generate", "stream"], + "default": "generate" + }, + "description": { + "type": "string" + }, + "model": { + "$ref": "#/$defs/TemplateString" + }, + "system": { + "$ref": "#/$defs/TemplateString" + }, + "prompt": { + "$ref": "#/$defs/TemplateString" + }, + "messages": { + "anyOf": [ + { "$ref": "#/$defs/ExpressionString" }, + { + "type": "array", + "items": { + "$ref": "#/$defs/Message" + } + } + ] + }, + "input": { + "$ref": "#/$defs/JsonSchema" + }, + "output": { + "$ref": "#/$defs/JsonSchema" + }, + "events": { + "description": "Machine event types this task may expose as host/model tools.", + "anyOf": [ + { "$ref": "#/$defs/ExpressionString" }, + { + "type": "array", + "items": { + "$ref": "#/$defs/Identifier" + } + } + ] + }, + "tools": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/Identifier" + }, + "additionalProperties": { + "$ref": "#/$defs/Tool" + } + }, + "toolChoice": { + "anyOf": [ + { + "type": "string", + "enum": ["auto", "none", "required"] + }, + { + "type": "object", + "required": ["type", "name"], + "properties": { + "type": { + "const": "tool" + }, + "name": { + "$ref": "#/$defs/Identifier" + } + }, + "additionalProperties": false + }, + { "$ref": "#/$defs/ExpressionString" } + ] + }, + "temperature": { + "$ref": "#/$defs/ExpressionValue" + }, + "maxTokens": { + "$ref": "#/$defs/ExpressionValue" + }, + "topP": { + "$ref": "#/$defs/ExpressionValue" + }, + "topK": { + "$ref": "#/$defs/ExpressionValue" + }, + "seed": { + "$ref": "#/$defs/ExpressionValue" + }, + "stopSequences": { + "anyOf": [ + { "$ref": "#/$defs/ExpressionString" }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "metadata": { + "$ref": "#/$defs/ExpressionValue" + } + }, + "additionalProperties": false + }, + "Message": { + "type": "object", + "required": ["role", "content"], + "properties": { + "role": { + "$ref": "#/$defs/TemplateString" + }, + "content": { + "$ref": "#/$defs/TemplateString" + } + }, + "additionalProperties": { + "$ref": "#/$defs/ExpressionValue" + } + }, + "Tool": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "input": { + "$ref": "#/$defs/JsonSchema" + }, + "output": { + "$ref": "#/$defs/JsonSchema" + } + }, + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + } + }, + "State": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["parallel", "history", "final"] + }, + "initial": { + "type": "string" + }, + "states": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/Identifier" + }, + "additionalProperties": { + "$ref": "#/$defs/State" + } + }, + "invoke": { + "anyOf": [ + { "$ref": "#/$defs/Invoke" }, + { + "type": "array", + "items": { + "$ref": "#/$defs/Invoke" + } + } + ] + }, + "on": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/TransitionOrArray" + } + }, + "always": { + "$ref": "#/$defs/TransitionOrArray" + }, + "onDone": { + "$ref": "#/$defs/TransitionOrArray" + }, + "after": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/TransitionOrArray" + } + }, + "entry": { + "$ref": "#/$defs/ActionOrArray" + }, + "exit": { + "$ref": "#/$defs/ActionOrArray" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "$ref": "#/$defs/ExpressionValue" + }, + "meta": { + "$ref": "#/$defs/ExpressionObject" + } + }, + "additionalProperties": false + }, + "Invoke": { + "type": "object", + "required": ["src"], + "properties": { + "id": { + "type": "string" + }, + "src": { + "$ref": "#/$defs/Identifier" + }, + "input": { + "$ref": "#/$defs/ExpressionValue" + }, + "onDone": { + "$ref": "#/$defs/TransitionOrArray" + }, + "onError": { + "$ref": "#/$defs/TransitionOrArray" + }, + "meta": { + "$ref": "#/$defs/ExpressionObject" + } + }, + "additionalProperties": false + }, + "TransitionOrArray": { + "anyOf": [ + { "$ref": "#/$defs/Transition" }, + { + "type": "array", + "items": { + "$ref": "#/$defs/Transition" + } + } + ] + }, + "Transition": { + "type": "object", + "properties": { + "target": { + "anyOf": [ + { "type": "string" }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "guard": { + "anyOf": [ + { "$ref": "#/$defs/ExpressionString" }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/ExpressionValue" + } + }, + "additionalProperties": false + } + ] + }, + "assign": { + "description": "Context assignments applied when this transition is taken.", + "$ref": "#/$defs/ExpressionObject" + }, + "actions": { + "$ref": "#/$defs/ActionOrArray" + }, + "description": { + "type": "string" + }, + "reenter": { + "type": "boolean" + }, + "meta": { + "$ref": "#/$defs/ExpressionObject" + } + }, + "additionalProperties": false + }, + "ActionOrArray": { + "anyOf": [ + { "$ref": "#/$defs/Action" }, + { + "type": "array", + "items": { + "$ref": "#/$defs/Action" + } + } + ] + }, + "Action": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/ExpressionValue" + }, + "assign": { + "$ref": "#/$defs/ExpressionObject" + } + }, + "additionalProperties": { + "$ref": "#/$defs/ExpressionValue" + } + } + } +} diff --git a/src/index.ts b/src/index.ts index fa385aa..219fd6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { parseOutput, setupAgent, transitionResult, + USER_INPUT_ACTOR, validateSchemaSync, } from './setup-agent.js'; export { @@ -25,6 +26,14 @@ export type { AgentEventDescriptor, AgentEffectSource, AgentMachine, + AgentUserInput, + AgentWorkflowActionConfig, + AgentWorkflowActorConfig, + AgentWorkflowConfig, + AgentWorkflowInvokeConfig, + AgentWorkflowStateConfig, + AgentWorkflowTaskConfig, + AgentWorkflowTransitionConfig, AgentTask, AgentTextInput, AgentSchemaPack, diff --git a/src/oracle-equivalents/agent-spec-config.test.ts b/src/oracle-equivalents/agent-spec-config.test.ts new file mode 100644 index 0000000..96dbe09 --- /dev/null +++ b/src/oracle-equivalents/agent-spec-config.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, test } from 'vitest'; +import { createActor, fromPromise, waitFor } from 'xstate'; +import { setupAgent } from '../index.js'; + +describe('Oracle Agent Spec-style static workflows', () => { + test('adapts branching and data-flow edges to state guards and assignments', () => { + // Adapted from Oracle Agent Spec + // pyagentspec/tests/agentspec_configs/example_serialized_flow_with_branching_node.yaml + // Oracle Agent Spec is distributed under Apache-2.0 or UPL-1.0. + const machine = setupAgent.fromConfig({ + id: 'oracle-branching-equivalent', + schemas: { + input: { + type: 'object', + properties: { + input1: { type: 'string' }, + input2: { type: 'string' }, + }, + required: ['input1', 'input2'], + }, + context: { + type: 'object', + properties: { + input1: { type: 'string' }, + input2: { type: 'string' }, + input1IsYes: { type: 'boolean' }, + input1IsNo: { type: 'boolean' }, + input2IsYes: { type: 'boolean' }, + input2IsNo: { type: 'boolean' }, + result: { type: 'string' }, + }, + required: ['input1', 'input2'], + }, + output: { + type: 'object', + properties: { + result: { type: 'string' }, + }, + required: ['result'], + }, + }, + context: { + input1: '{{ input.input1 }}', + input2: '{{ input.input2 }}', + input1IsYes: '{{ input.input1IsYes }}', + input1IsNo: '{{ input.input1IsNo }}', + input2IsYes: '{{ input.input2IsYes }}', + input2IsNo: '{{ input.input2IsNo }}', + }, + initial: 'branchInput1', + states: { + branchInput1: { + always: [ + { + guard: '{{ context.input1IsYes }}', + target: 'yesEnd', + }, + { + guard: '{{ context.input1IsNo }}', + target: 'noEnd', + }, + { + target: 'branchInput2', + }, + ], + }, + branchInput2: { + always: [ + { + guard: '{{ context.input2IsYes }}', + target: 'nestedYesEnd', + }, + { + guard: '{{ context.input2IsNo }}', + target: 'noEnd', + }, + { + target: 'defaultEnd', + }, + ], + }, + yesEnd: { + type: 'final', + output: { + result: '{{ context.input1 }}', + }, + }, + nestedYesEnd: { + type: 'final', + output: { + result: '{{ context.input2 }}', + }, + }, + noEnd: { + type: 'final', + output: { + result: 'no', + }, + }, + defaultEnd: { + type: 'final', + output: { + result: 'default', + }, + }, + }, + }); + + const yesStep = machine.initial({ + input1: 'yes', + input2: 'no', + input1IsYes: true, + input1IsNo: false, + input2IsYes: false, + input2IsNo: true, + }); + expect(yesStep.done).toBe(true); + expect(yesStep.snapshot.output).toEqual({ result: 'yes' }); + + const nestedStep = machine.initial({ + input1: 'maybe', + input2: 'yes', + input1IsYes: false, + input1IsNo: false, + input2IsYes: true, + input2IsNo: false, + }); + expect(nestedStep.done).toBe(true); + expect(nestedStep.snapshot.output).toEqual({ result: 'yes' }); + }); + + test('adapts sequential LLM and tool nodes to invoked tasks and host actors', async () => { + // Adapted from Oracle Agent Spec + // pyagentspec/tests/agentspec_configs/example_serialized_flow.yaml + // Oracle Agent Spec is distributed under Apache-2.0 or UPL-1.0. + const machine = setupAgent.fromConfig({ + id: 'oracle-linear-flow-equivalent', + schemas: { + context: { + type: 'object', + properties: { + firstText: { type: 'string' }, + secondText: { type: 'string' }, + forecast: { type: 'string' }, + }, + }, + output: { + type: 'object', + properties: { + forecast: { type: 'string' }, + }, + required: ['forecast'], + }, + }, + context: {}, + tasks: { + node12: { + model: 'agi_model1', + prompt: 'something something', + input: { + type: 'object', + properties: {}, + }, + output: { + type: 'object', + properties: { + generated_text: { type: 'string' }, + }, + required: ['generated_text'], + }, + }, + node3: { + model: 'agi_model1', + prompt: 'something something else', + input: { + type: 'object', + properties: { + previous: { type: 'string' }, + }, + required: ['previous'], + }, + output: { + type: 'object', + properties: { + generated_text: { type: 'string' }, + }, + required: ['generated_text'], + }, + }, + }, + actors: { + toolNode: { + input: { + type: 'object', + properties: { + city_name: { type: 'string' }, + }, + required: ['city_name'], + }, + output: { + type: 'object', + properties: { + forecast: { type: 'string' }, + }, + required: ['forecast'], + }, + }, + }, + initial: 'node12', + states: { + node12: { + invoke: { + id: 'node12', + src: 'node12', + input: {}, + onDone: { + target: 'node3', + assign: { + firstText: '{{ event.output.generated_text }}', + }, + }, + }, + }, + node3: { + invoke: { + id: 'node3', + src: 'node3', + input: { + previous: '{{ context.firstText }}', + }, + onDone: { + target: 'toolNode', + assign: { + secondText: '{{ event.output.generated_text }}', + }, + }, + }, + }, + toolNode: { + invoke: { + id: 'toolNode', + src: 'toolNode', + input: { + city_name: 'zurich', + }, + onDone: { + target: 'done', + assign: { + forecast: '{{ event.output.forecast }}', + }, + }, + }, + }, + done: { + type: 'final', + output: { + forecast: '{{ context.forecast }}', + }, + }, + }, + }).provide({ + actors: { + node12: fromPromise(async () => ({ + generated_text: 'first generated text', + })), + node3: fromPromise(async ({ input }) => { + expect(input).toEqual({ previous: 'first generated text' }); + return { generated_text: 'second generated text' }; + }), + toolNode: fromPromise(async ({ input }) => { + expect(input).toEqual({ city_name: 'zurich' }); + return { forecast: 'sunny' }; + }), + }, + }); + + const actor = createActor(machine).start(); + await waitFor(actor, (snapshot) => snapshot.status === 'done'); + + expect(actor.getSnapshot().context).toEqual({ + firstText: 'first generated text', + secondText: 'second generated text', + forecast: 'sunny', + }); + expect(actor.getSnapshot().output).toEqual({ forecast: 'sunny' }); + }); +}); diff --git a/src/setup-agent.test.ts b/src/setup-agent.test.ts index e8650b7..0cdf279 100644 --- a/src/setup-agent.test.ts +++ b/src/setup-agent.test.ts @@ -10,6 +10,7 @@ import { messagesSchema, setupAgent, transitionResult, + USER_INPUT_ACTOR, userMessage, type AgentTextInput, type AgentTools, @@ -1015,4 +1016,216 @@ describe('setupAgent', () => { eventTypes: ['ATTACK', 'DEFEND', 'HEAL'], }))).toEqual(['event.ATTACK', 'event.DEFEND']); }); + + test('fromConfig lowers static task workflows to agent machine steps', async () => { + const machine = setupAgent.fromConfig({ + id: 'static-answer', + schemas: { + input: { + type: 'object', + properties: { + question: { type: 'string' }, + }, + required: ['question'], + }, + context: { + type: 'object', + properties: { + question: { type: 'string' }, + answer: { type: 'string' }, + }, + required: ['question'], + }, + output: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + required: ['answer'], + }, + }, + context: { + question: '{{ input.question }}', + }, + tasks: { + answerQuestion: { + model: 'test-model', + prompt: 'Question: {{ input.question }}', + input: { + type: 'object', + properties: { + question: { type: 'string' }, + }, + required: ['question'], + }, + output: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + required: ['answer'], + }, + }, + }, + initial: 'answering', + states: { + answering: { + invoke: { + id: 'answer', + src: 'answerQuestion', + input: { + question: '{{ context.question }}', + }, + onDone: { + target: 'done', + assign: { + answer: '{{ event.output.answer }}', + }, + }, + }, + }, + done: { + type: 'final', + output: { + answer: '{{ context.answer }}', + }, + }, + }, + }); + + let step = machine.initial({ question: 'Why statecharts?' }); + + expect(step.tasks).toEqual([ + expect.objectContaining({ + id: 'answer', + src: 'answerQuestion', + input: expect.objectContaining({ + model: 'test-model', + prompt: 'Question: Why statecharts?', + }), + }), + ]); + + const output = await machine.execute(step.tasks[0]!, { + generateText: async () => ({ output: { answer: 'Because logic matters.' } }), + }); + step = machine.resolve(step, step.tasks[0]!, output); + + expect(step.done).toBe(true); + expect(step.snapshot.output).toEqual({ answer: 'Because logic matters.' }); + }); + + test('agent.userInput is a blessed host-provided actor for static workflows', async () => { + const machine = setupAgent.fromConfig({ + id: 'static-user-input', + schemas: { + input: { + type: 'object', + properties: {}, + }, + context: { + type: 'object', + properties: { + recipient: { type: 'string' }, + draft: { type: 'string' }, + }, + }, + output: { + type: 'object', + properties: { + draft: { type: 'string' }, + }, + required: ['draft'], + }, + }, + context: {}, + tasks: { + draftEmail: { + model: 'writer', + prompt: 'Draft email to {{ input.recipient }}', + input: { + type: 'object', + properties: { + recipient: { type: 'string' }, + }, + required: ['recipient'], + }, + output: { + type: 'object', + properties: { + draft: { type: 'string' }, + }, + required: ['draft'], + }, + }, + }, + initial: 'askRecipient', + states: { + askRecipient: { + invoke: { + id: 'recipient', + src: USER_INPUT_ACTOR, + input: { + prompt: 'Who should receive this email?', + schema: { + type: 'object', + properties: { + recipient: { type: 'string' }, + }, + required: ['recipient'], + }, + }, + onDone: { + target: 'draftEmail', + assign: { + recipient: '{{ event.output.recipient }}', + }, + }, + }, + }, + draftEmail: { + invoke: { + id: 'draft', + src: 'draftEmail', + input: { + recipient: '{{ context.recipient }}', + }, + onDone: { + target: 'done', + assign: { + draft: '{{ event.output.draft }}', + }, + }, + }, + }, + done: { + type: 'final', + output: { + draft: '{{ context.draft }}', + }, + }, + }, + }).provide({ + actors: { + [USER_INPUT_ACTOR]: fromPromise(async ({ input }) => { + expect(input).toEqual( + expect.objectContaining({ + prompt: 'Who should receive this email?', + schema: expect.objectContaining({ type: 'object' }), + }) + ); + return { recipient: 'Ada' }; + }), + draftEmail: fromPromise(async ({ input }) => { + expect(input).toEqual({ recipient: 'Ada' }); + return { draft: 'Hello Ada.' }; + }), + }, + }); + + const actor = createActor(machine, { input: {} }).start(); + await waitFor(actor, (snapshot) => snapshot.status === 'done'); + + expect(actor.getSnapshot().output).toEqual({ draft: 'Hello Ada.' }); + }); }); diff --git a/src/setup-agent.ts b/src/setup-agent.ts index 6779052..9cd8c6d 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -26,6 +26,8 @@ import type { } from './types.js'; import { validateSchemaSync } from './utils.js'; +export const USER_INPUT_ACTOR = 'agent.userInput' as const; + /** Portable LCD input text tasks pass to host executors. */ export interface AgentTextInput> { model: string; @@ -52,6 +54,193 @@ export interface AgentTextInput> { metadata?: TMetadata; } +export interface AgentUserInput> { + prompt?: string; + schema?: StandardSchemaV1; + metadata?: TMetadata; +} + +type BuiltinAgentActors = { + [USER_INPUT_ACTOR]: PromiseActorLogic; +}; + +const userInputActor = fromPromise(async () => { + throw new Error( + `'${USER_INPUT_ACTOR}' has no host execution. Provide an implementation ` + + `with machine.provide({ actors: { '${USER_INPUT_ACTOR}': ... } }).` + ); +}); + +function missingActor(src: string): PromiseActorLogic { + return fromPromise(async () => { + throw new Error( + `'${src}' has no host execution. Provide an implementation with ` + + `machine.provide({ actors: { '${src}': ... } }).` + ); + }); +} + +type JsonSchemaObject = { + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: JsonSchemaObject; + enum?: unknown[]; + const?: unknown; + additionalProperties?: unknown; + [key: string]: unknown; +}; + +function jsonSchemaToStandardSchema( + schema: JsonSchemaObject | undefined, + name = 'schema' +): StandardSchemaV1 { + const resolvedSchema = schema ?? {}; + + return { + '~standard': { + version: 1, + vendor: 'statelyai-agent-json-schema', + validate(value: unknown) { + const issues: { message: string }[] = []; + validateJsonSchemaValue(resolvedSchema, value, name, issues); + return issues.length > 0 ? { issues } : { value: value as T }; + }, + }, + }; +} + +function validateJsonSchemaValue( + schema: JsonSchemaObject, + value: unknown, + path: string, + issues: { message: string }[] +) { + if (schema.const !== undefined && value !== schema.const) { + issues.push({ message: `${path} must equal ${JSON.stringify(schema.const)}` }); + return; + } + + if (schema.enum && !schema.enum.some((item) => item === value)) { + issues.push({ message: `${path} must be one of ${schema.enum.join(', ')}` }); + return; + } + + const type = Array.isArray(schema.type) ? schema.type[0] : schema.type; + if (!type) { + return; + } + + if (type === 'object') { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + issues.push({ message: `${path} must be an object` }); + return; + } + + const objectValue = value as Record; + for (const requiredKey of schema.required ?? []) { + if (!(requiredKey in objectValue)) { + issues.push({ message: `${path}.${requiredKey} is required` }); + } + } + + for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) { + if (key in objectValue) { + validateJsonSchemaValue( + propertySchema, + objectValue[key], + `${path}.${key}`, + issues + ); + } + } + return; + } + + if (type === 'array') { + if (!Array.isArray(value)) { + issues.push({ message: `${path} must be an array` }); + return; + } + + if (schema.items) { + value.forEach((item, index) => + validateJsonSchemaValue(schema.items!, item, `${path}[${index}]`, issues) + ); + } + return; + } + + const ok = + (type === 'string' && typeof value === 'string') + || (type === 'number' && typeof value === 'number') + || (type === 'integer' && Number.isInteger(value)) + || (type === 'boolean' && typeof value === 'boolean') + || (type === 'null' && value === null); + + if (!ok) { + issues.push({ message: `${path} must be ${type}` }); + } +} + +const wholeExpressionPattern = /^\{\{\s*([\s\S]*?)\s*\}\}$/; +const templateExpressionPattern = /\{\{\s*([\s\S]*?)\s*\}\}/g; + +type ExpressionScope = { + context?: unknown; + event?: unknown; + input?: unknown; + output?: unknown; +}; + +function evaluatePathExpression(expression: string, scope: ExpressionScope): unknown { + const parts = expression.trim().split('.').filter(Boolean); + let current: unknown = scope; + + for (const part of parts) { + if (!current || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[part]; + } + + return current; +} + +function evaluateExpressionValue(value: unknown, scope: ExpressionScope): unknown { + if (typeof value === 'string') { + const wholeMatch = value.match(wholeExpressionPattern); + if (wholeMatch?.[1]) { + return evaluatePathExpression(wholeMatch[1], scope); + } + + return value.replace(templateExpressionPattern, (_match, expression: string) => { + const resolved = evaluatePathExpression(expression, scope); + return resolved === undefined || resolved === null ? '' : String(resolved); + }); + } + + if (Array.isArray(value)) { + return value.map((item) => evaluateExpressionValue(item, scope)); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + evaluateExpressionValue(item, scope), + ]) + ); + } + + return value; +} + +function isJsonObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + + // ─── Message helpers ─── // // Messages are plain context state: declare a `messages` field in the @@ -1058,6 +1247,419 @@ export function setupAgent< return createSetupAgent(config, {}); } +export interface AgentWorkflowConfig { + key?: string; + id?: string; + version?: string; + description?: string; + schemas?: { + input?: JsonSchemaObject; + context?: JsonSchemaObject; + events?: Record; + output?: JsonSchemaObject; + meta?: JsonSchemaObject; + }; + context?: Record; + tasks?: Record; + actors?: Record; + initial: string; + states: Record; + meta?: Record; +} + +export interface AgentWorkflowTaskConfig { + kind?: AgentTaskKind; + description?: string; + model: unknown; + system?: unknown; + prompt?: unknown; + messages?: unknown; + input: JsonSchemaObject; + output: JsonSchemaObject; + events?: unknown; + tools?: AgentTools; + toolChoice?: AgentToolChoice | unknown; + temperature?: unknown; + maxTokens?: unknown; + topP?: unknown; + topK?: unknown; + seed?: unknown; + stopSequences?: unknown; + metadata?: unknown; +} + +export interface AgentWorkflowActorConfig { + input?: JsonSchemaObject; + output?: JsonSchemaObject; + description?: string; +} + +export interface AgentWorkflowStateConfig { + description?: string; + type?: 'parallel' | 'history' | 'final'; + initial?: string; + states?: Record; + invoke?: AgentWorkflowInvokeConfig | AgentWorkflowInvokeConfig[]; + on?: Record; + always?: AgentWorkflowTransitionConfig | AgentWorkflowTransitionConfig[]; + onDone?: AgentWorkflowTransitionConfig | AgentWorkflowTransitionConfig[]; + after?: Record; + entry?: AgentWorkflowActionConfig | AgentWorkflowActionConfig[]; + exit?: AgentWorkflowActionConfig | AgentWorkflowActionConfig[]; + tags?: string[]; + output?: unknown; + meta?: Record; +} + +export interface AgentWorkflowInvokeConfig { + id?: string; + src: string; + input?: unknown; + onDone?: AgentWorkflowTransitionConfig | AgentWorkflowTransitionConfig[]; + onError?: AgentWorkflowTransitionConfig | AgentWorkflowTransitionConfig[]; + meta?: Record; +} + +export interface AgentWorkflowTransitionConfig { + target?: string | string[]; + guard?: unknown; + assign?: Record; + actions?: AgentWorkflowActionConfig | AgentWorkflowActionConfig[]; + description?: string; + reenter?: boolean; + meta?: Record; +} + +export interface AgentWorkflowActionConfig { + type: string; + params?: unknown; + assign?: Record; + [key: string]: unknown; +} + +function createSchemasFromWorkflowConfig( + config: AgentWorkflowConfig +): AgentSchemaPack< + StandardSchemaV1>, + Record, + StandardSchemaV1, + StandardSchemaV1, + StandardSchemaV1 +> { + return createAgentSchemas({ + context: jsonSchemaToStandardSchema>( + config.schemas?.context ?? { type: 'object' }, + 'context' + ), + events: Object.fromEntries( + Object.entries(config.schemas?.events ?? {}).map(([key, schema]) => [ + key, + jsonSchemaToStandardSchema(schema, `event.${key}`), + ]) + ), + input: jsonSchemaToStandardSchema(config.schemas?.input, 'input'), + output: jsonSchemaToStandardSchema(config.schemas?.output, 'output'), + meta: jsonSchemaToStandardSchema(config.schemas?.meta, 'meta'), + }); +} + +function createTasksFromWorkflowConfig( + config: AgentWorkflowConfig +): AgentTaskInput< + Record, + Record, + AgentSchemaPack, any, any, any> +> { + return Object.fromEntries( + Object.entries(config.tasks ?? {}).map(([key, task]) => [ + key, + { + kind: task.kind, + description: task.description, + schemas: { + input: jsonSchemaToStandardSchema(task.input, `${key}.input`), + output: jsonSchemaToStandardSchema(task.output, `${key}.output`), + }, + model: ({ input }) => + String(evaluateExpressionValue(task.model, { input }) ?? ''), + system: task.system === undefined + ? undefined + : ({ input }) => + evaluateExpressionValue(task.system, { input }) as string | undefined, + prompt: task.prompt === undefined + ? undefined + : ({ input }) => + evaluateExpressionValue(task.prompt, { input }) as string | undefined, + messages: task.messages === undefined + ? undefined + : ({ input }) => + evaluateExpressionValue(task.messages, { input }) as + | AgentMessage[] + | undefined, + tools: task.tools, + toolChoice: task.toolChoice as AgentToolChoice | undefined, + events: task.events === undefined + ? undefined + : ({ input }) => { + const events = evaluateExpressionValue(task.events, { input }); + return Array.isArray(events) + ? events.filter((event): event is string => typeof event === 'string') + : []; + }, + temperature: task.temperature === undefined + ? undefined + : ({ input }) => + evaluateExpressionValue(task.temperature, { input }) as + | number + | undefined, + maxTokens: task.maxTokens === undefined + ? undefined + : ({ input }) => + evaluateExpressionValue(task.maxTokens, { input }) as number | undefined, + topP: task.topP === undefined + ? undefined + : ({ input }) => + evaluateExpressionValue(task.topP, { input }) as number | undefined, + topK: task.topK === undefined + ? undefined + : ({ input }) => + evaluateExpressionValue(task.topK, { input }) as number | undefined, + seed: task.seed === undefined + ? undefined + : ({ input }) => + evaluateExpressionValue(task.seed, { input }) as number | undefined, + stopSequences: task.stopSequences === undefined + ? undefined + : ({ input }) => + evaluateExpressionValue(task.stopSequences, { input }) as + | string[] + | undefined, + metadata: task.metadata === undefined + ? undefined + : ({ input }) => evaluateExpressionValue(task.metadata, { input }), + }, + ]) + ) as AgentTaskInput< + Record, + Record, + AgentSchemaPack, any, any, any> + >; +} + +function createActorPlaceholdersFromWorkflowConfig(config: AgentWorkflowConfig) { + return Object.fromEntries( + Object.keys(config.actors ?? {}).map((key) => [key, missingActor(key)]) + ) as Record>; +} + +function createAssignAction(assignConfig: Record) { + return assign( + Object.fromEntries( + Object.entries(assignConfig).map(([key, value]) => [ + key, + ({ context, event }: { context: unknown; event: unknown }) => + evaluateExpressionValue(value, { context, event }), + ]) + ) as never + ); +} + +function lowerWorkflowActions( + actionConfig: AgentWorkflowActionConfig | AgentWorkflowActionConfig[] | undefined +) { + if (!actionConfig) { + return undefined; + } + + const actions = Array.isArray(actionConfig) ? actionConfig : [actionConfig]; + return actions.map((action) => + action.assign + ? createAssignAction(action.assign) + : { + type: action.type, + params: ({ context, event }: { context: unknown; event: unknown }) => + evaluateExpressionValue(action.params, { context, event }), + } + ); +} + +function lowerWorkflowTransition( + transitionConfig: AgentWorkflowTransitionConfig +) { + const actions = [ + ...(transitionConfig.assign ? [createAssignAction(transitionConfig.assign)] : []), + ...(lowerWorkflowActions(transitionConfig.actions) ?? []), + ]; + + return { + ...(transitionConfig.target !== undefined + ? { target: transitionConfig.target } + : {}), + ...(transitionConfig.guard !== undefined + ? { + guard: + typeof transitionConfig.guard === 'string' + ? ({ context, event }: { context: unknown; event: unknown }) => + Boolean(evaluateExpressionValue(transitionConfig.guard, { + context, + event, + })) + : transitionConfig.guard, + } + : {}), + ...(actions.length > 0 ? { actions } : {}), + ...(transitionConfig.description !== undefined + ? { description: transitionConfig.description } + : {}), + ...(transitionConfig.reenter !== undefined + ? { reenter: transitionConfig.reenter } + : {}), + ...(transitionConfig.meta !== undefined ? { meta: transitionConfig.meta } : {}), + }; +} + +function lowerWorkflowTransitionOrArray( + transitionConfig: + | AgentWorkflowTransitionConfig + | AgentWorkflowTransitionConfig[] + | undefined +) { + if (!transitionConfig) { + return undefined; + } + + return Array.isArray(transitionConfig) + ? transitionConfig.map(lowerWorkflowTransition) + : lowerWorkflowTransition(transitionConfig); +} + +function lowerWorkflowInvoke( + invokeConfig: AgentWorkflowInvokeConfig +) { + return { + ...(invokeConfig.id !== undefined ? { id: invokeConfig.id } : {}), + src: invokeConfig.src, + ...(invokeConfig.input !== undefined + ? { + input: ({ context, event }: { context: unknown; event: unknown }) => + evaluateExpressionValue(invokeConfig.input, { context, event }), + } + : {}), + ...(invokeConfig.onDone !== undefined + ? { onDone: lowerWorkflowTransitionOrArray(invokeConfig.onDone) } + : {}), + ...(invokeConfig.onError !== undefined + ? { onError: lowerWorkflowTransitionOrArray(invokeConfig.onError) } + : {}), + ...(invokeConfig.meta !== undefined ? { meta: invokeConfig.meta } : {}), + }; +} + +function lowerWorkflowState(stateConfig: AgentWorkflowStateConfig): Record { + return { + ...(stateConfig.description !== undefined + ? { description: stateConfig.description } + : {}), + ...(stateConfig.type !== undefined ? { type: stateConfig.type } : {}), + ...(stateConfig.initial !== undefined ? { initial: stateConfig.initial } : {}), + ...(stateConfig.states !== undefined + ? { + states: Object.fromEntries( + Object.entries(stateConfig.states).map(([key, child]) => [ + key, + lowerWorkflowState(child), + ]) + ), + } + : {}), + ...(stateConfig.invoke !== undefined + ? { + invoke: Array.isArray(stateConfig.invoke) + ? stateConfig.invoke.map(lowerWorkflowInvoke) + : lowerWorkflowInvoke(stateConfig.invoke), + } + : {}), + ...(stateConfig.on !== undefined + ? { + on: Object.fromEntries( + Object.entries(stateConfig.on).map(([eventType, transitionConfig]) => [ + eventType, + lowerWorkflowTransitionOrArray(transitionConfig), + ]) + ), + } + : {}), + ...(stateConfig.always !== undefined + ? { always: lowerWorkflowTransitionOrArray(stateConfig.always) } + : {}), + ...(stateConfig.onDone !== undefined + ? { onDone: lowerWorkflowTransitionOrArray(stateConfig.onDone) } + : {}), + ...(stateConfig.after !== undefined + ? { + after: Object.fromEntries( + Object.entries(stateConfig.after).map(([delay, transitionConfig]) => [ + delay, + lowerWorkflowTransitionOrArray(transitionConfig), + ]) + ), + } + : {}), + ...(stateConfig.entry !== undefined + ? { entry: lowerWorkflowActions(stateConfig.entry) } + : {}), + ...(stateConfig.exit !== undefined + ? { exit: lowerWorkflowActions(stateConfig.exit) } + : {}), + ...(stateConfig.tags !== undefined ? { tags: stateConfig.tags } : {}), + ...(stateConfig.output !== undefined + ? { + output: ({ context, event }: { context: unknown; event: unknown }) => + evaluateExpressionValue(stateConfig.output, { context, event }), + } + : {}), + ...(stateConfig.meta !== undefined ? { meta: stateConfig.meta } : {}), + }; +} + +function setupAgentFromConfig(config: AgentWorkflowConfig): AgentMachine { + const schemas = createSchemasFromWorkflowConfig(config); + const tasks = createTasksFromWorkflowConfig(config); + const actors = createActorPlaceholdersFromWorkflowConfig(config); + const agent = setupAgent({ + schemas, + actors, + }).withTasks(tasks); + + return agent.createMachine({ + ...(config.id !== undefined ? { id: config.id } : {}), + ...(config.description !== undefined ? { description: config.description } : {}), + ...(config.context !== undefined + ? { + context: ({ input }: { input: unknown }) => + validateSchemaSync( + schemas.context, + evaluateExpressionValue(config.context, { input }) + ), + } + : {}), + initial: config.initial, + states: Object.fromEntries( + Object.entries(config.states).map(([key, state]) => [ + key, + lowerWorkflowState(state), + ]) + ), + ...(config.meta !== undefined ? { meta: config.meta } : {}), + } as never); +} + +export namespace setupAgent { + export function fromConfig(config: AgentWorkflowConfig): AgentMachine { + return setupAgentFromConfig(config); + } +} + function createTaskActors< TEventSchemas extends Record, TSchemas extends AgentSchemaPack, @@ -1180,6 +1782,7 @@ function createSetupAgent< meta: MetaOf; }, actors: { + [USER_INPUT_ACTOR]: userInputActor, ...config.actors, } as AgentSetupConfigOptions< TContextSchema, From 0ff0b8c419d3683596dc080090879db682768749 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Wed, 24 Jun 2026 14:03:35 -0400 Subject: [PATCH 48/50] Refine reusable text logic API --- docs/burr-parity.md | 2 +- docs/crewai-parity.md | 10 +- docs/host-actors.md | 82 ++++++---- docs/langgraph-gaps.md | 4 +- docs/langgraph-parity.md | 10 +- examples/README.md | 6 +- examples/setup-agent/email-drafter.ts | 92 ++++++----- examples/setup-agent/game-agent.ts | 85 +++++----- examples/setup-agent/joke.ts | 27 +-- examples/setup-agent/triage.ts | 27 +-- readme.md | 34 ++-- src/index.ts | 1 - src/setup-agent.test.ts | 217 ++++++++++++++++++++++-- src/setup-agent.ts | 227 +++++++++++++++++++++++--- 14 files changed, 604 insertions(+), 220 deletions(-) diff --git a/docs/burr-parity.md b/docs/burr-parity.md index 85b5e20..f720b52 100644 --- a/docs/burr-parity.md +++ b/docs/burr-parity.md @@ -36,7 +36,7 @@ As of June 18, 2026, the upstream Burr examples directory includes examples such Burr action definitions are runtime-owned executable steps. `@statelyai/agent` keeps those steps as portable authoring contracts: -- `withTasks(...)` owns typed request construction. +- Built-in text actor sources own inline model-call requests; `createTextLogic(...)` owns reusable typed request construction. - `setupAgent(...)` owns typed machine authoring. - Hosts own model providers, streaming, persistence, tracing, and deployment. diff --git a/docs/crewai-parity.md b/docs/crewai-parity.md index a635998..fe54bb9 100644 --- a/docs/crewai-parity.md +++ b/docs/crewai-parity.md @@ -41,16 +41,16 @@ Primary sources: | CrewAI Flow example | Status | Agent equivalent | | --- | --- | --- | | Content Creator Flow | Covered | [`src/crewai-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/raw-xstate.test.ts) | -| Email Auto Responder Flow | Covered | Same `setupAgent(...).withTasks(...)`/XState primitives as content routing plus persisted XState snapshots | -| Lead Score Flow | Covered | Same `setupAgent(...).withTasks(...)`/XState primitives as HITL review plus typed worker actors | -| Meeting Assistant Flow | Covered | Same `setupAgent(...).withTasks(...)`/XState primitives as fan-out worker actors | -| Self Evaluation Loop Flow | Covered | Same `setupAgent(...).withTasks(...)`/XState primitives as guarded retry/re-entry | +| Email Auto Responder Flow | Covered | Same `setupAgent(...)`/XState primitives as content routing plus persisted XState snapshots | +| Lead Score Flow | Covered | Same `setupAgent(...)`/XState primitives as HITL review plus typed worker actors | +| Meeting Assistant Flow | Covered | Same `setupAgent(...)`/XState primitives as fan-out worker actors | +| Self Evaluation Loop Flow | Covered | Same `setupAgent(...)`/XState primitives as guarded retry/re-entry | | Write a Book with Flows | Covered | [`src/crewai-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/crewai-equivalents/raw-xstate.test.ts) | ## Notes - CrewAI’s `content_creator_flow/` directory in the current examples repo clone is empty, so that equivalence is based on the current official descriptions: multi-format content routing across blog, LinkedIn, and research outputs. -- Several of these patterns overlap with LangGraph-style examples; they are represented with `setupAgent(...).withTasks(...)`/XState tests so the parity surface is explicit without maintaining duplicate legacy example files. +- Several of these patterns overlap with LangGraph-style examples; they are represented with `setupAgent(...)`/XState tests so the parity surface is explicit without maintaining duplicate legacy example files. ## Differences diff --git a/docs/host-actors.md b/docs/host-actors.md index 3c20459..5eef82a 100644 --- a/docs/host-actors.md +++ b/docs/host-actors.md @@ -1,6 +1,6 @@ # Host Actors -`setupAgent(...).withTasks(...)` describes model work as named tasks. The host still owns execution. +`setupAgent(...)` auto-provides built-in `agent.generateText` and `agent.streamText` actor sources. `createTextLogic(...)` describes reusable named model work. The host still owns execution. The text logic declares: @@ -31,6 +31,7 @@ Use named text logic and plain XState `invoke` objects. For maximum framework po ```ts import { createAgentSchemas, + parseOutput, setupAgent, } from '@statelyai/agent'; import { assign } from 'xstate'; @@ -42,18 +43,7 @@ const schemas = createAgentSchemas({ events: eventSchemas, }); -const agent = setupAgent({ schemas }).withTasks({ - draftText: { - schemas: { - input: draftInputSchema, - output: resultSchema, - }, - model: 'openai/gpt-5.4-nano', - prompt: ({ input }) => input.prompt, - temperature: 0.2, - events: ['APPROVE', 'REVISE'], - }, -}); +const agent = setupAgent({ schemas }); const machine = agent.createMachine({ initial: 'generating', @@ -61,12 +51,18 @@ const machine = agent.createMachine({ generating: { invoke: { id: 'draft', - src: 'draftText', - input: ({ context }) => ({ prompt: context.prompt }), + src: 'agent.generateText', + input: ({ context }) => ({ + model: 'openai/gpt-5.4-nano', + prompt: context.prompt, + outputSchema: resultSchema, + temperature: 0.2, + eventTypes: ['APPROVE', 'REVISE'], + }), onDone: { target: 'done', actions: assign({ - result: ({ event }) => event.output, + result: ({ event }) => parseOutput(resultSchema, event.output), }), }, }, @@ -105,12 +101,11 @@ Use `initialTransition(...)`, `transition(...)`, and `transitionResult(...)` dir Use `agent.userInput` when workflow logic needs to wait for a human. It is a normal invoked actor; the host owns how the request is delivered and resumed. ```ts -import { USER_INPUT_ACTOR } from '@statelyai/agent'; import { fromPromise } from 'xstate'; const machine = setupAgent.fromConfig(config).provide({ actors: { - [USER_INPUT_ACTOR]: fromPromise(async ({ input }) => { + 'agent.userInput': fromPromise(async ({ input }) => { return showFormAndWaitForSubmit(input); }), }, @@ -161,10 +156,10 @@ Only events listed in task `events` are exposed. If an event is listed but is no ## Actor Runtime -When you want XState to execute invokes directly, provide implementations for the named task actors with `logic.withExecutor(...)`. The lower-level `createTextLogic(...)` primitive also accepts an executor, but `withTasks(...)` is the preferred authoring path. +When you want XState to execute named text invokes directly, provide implementations with `logic.withExecutor(...)`. Use direct `agent.generateText` / `agent.streamText` invokes when the request belongs at the state node; use `createTextLogic(...)` when the model-call shape should be named and reused. ```ts -const executableDraftText = agent.tasks.draftText.withExecutor( +const executableDraftText = draftText.withExecutor( async ({ request, signal }) => { const result = await generateText({ model: resolveModel(request.model), @@ -184,7 +179,7 @@ For app-level adapters, overriding with `withExecutor(...)` is often cleaner: import { generateText, Output } from 'ai'; const actors = { - draftText: agent.tasks.draftText.withExecutor(async ({ request, signal }) => { + draftText: draftText.withExecutor(async ({ request, signal }) => { if (request.outputSchema) { const result = await generateText({ model: resolveModel(request.model), @@ -218,17 +213,23 @@ createActor(machine.provide({ actors }), { input }).start(); Use `metadata` for host-specific details. It is intentionally not interpreted by `@statelyai/agent`. ```ts -const agent = setupAgent({ schemas }).withTasks({ - draftText: { - schemas: { - input: draftInputSchema, - output: resultSchema, - }, - model: 'openai/gpt-5.4-nano', - prompt: ({ input }) => input.prompt, - metadata: ({ input }) => ({ - traceId: input.requestId, - }), +const draftText = createTextLogic({ + kind: 'generate', + schemas: { + input: draftInputSchema, + output: resultSchema, + }, + model: 'openai/gpt-5.4-nano', + prompt: ({ input }) => input.prompt, + metadata: ({ input }) => ({ + traceId: input.requestId, + }), +}); + +const agent = setupAgent({ + schemas, + actors: { + draftText, }, }); ``` @@ -243,7 +244,22 @@ The same task logic can be executed with `generateText(...)` or `streamText(...) ## Low-Level Primitive -`createTextLogic(...)` exists as a low-level primitive. Prefer `setupAgent(...).withTasks(...)` for new authoring because it gives reusable request construction, typed source names, typed invoke input, typed `event.output`, and schema-typed machine event tools. +Use `createTextLogic(...)` for reusable named model calls with typed source names, typed invoke input, typed `event.output`, and schema-typed machine event tools. + +Standalone inspection: + +```ts +const request = draftText.request({ prompt: 'Draft a launch email.' }); +``` + +Standalone execution: + +```ts +const output = await draftText.execute( + { prompt: 'Draft a launch email.' }, + { generateText, streamText } +); +``` ## Why This Shape diff --git a/docs/langgraph-gaps.md b/docs/langgraph-gaps.md index 8c7ee2a..bd4d286 100644 --- a/docs/langgraph-gaps.md +++ b/docs/langgraph-gaps.md @@ -9,7 +9,7 @@ This tracks remaining gaps one by one. The goal is not to clone LangGraph; it is | Checkpoint adapters | LangGraph users expect durable threads/checkpoints without inventing storage glue. | Example first, then optional packages for SQLite/Postgres/Redis using XState persisted snapshots. | | UI streaming transports | Demos need to feel complete in React/Svelte/HTTP/WebSocket apps. | Host-side stream examples using AI SDK UI streams and WebSocket/SSE. | | Interrupt/resume helpers | HITL is expressible today, but LangGraph has explicit interrupt ergonomics. | Small helpers/patterns around states, events, and persisted snapshots. | -| Prebuilt supervisor/swarm helpers | Current tests prove expressibility, but some users want a shortcut. | Additive helpers built on `setupAgent(...).withTasks(...)`, not a separate runtime. | +| Prebuilt supervisor/swarm helpers | Current tests prove expressibility, but some users want a shortcut. | Additive helpers built on `setupAgent(...)` and `createTextLogic(...)`, not a separate runtime. | | Long-term memory/store examples | RAG is covered as host actors; storage ownership needs clearer examples. | Retrieval/storage actors with local and hosted backend examples. | | Observability/tracing | Visualization covers static structure; runtime traces are separate. | XState inspection hooks plus OpenTelemetry/LangSmith-style host examples. | | LangGraph migration tooling | Parity is manual today. | Documented recipes first; optional graph-to-XState codemod later. | @@ -18,7 +18,7 @@ This tracks remaining gaps one by one. The goal is not to clone LangGraph; it is ## Coverage Status - Covered in tests: branching, HITL, tool calling, streaming, persistence, subflows, supervisor routing, map-reduce, RAG, reflection, ReWOO, SQL-style agents, persistent multi-agent networks. -- Covered by package surface: typed `setupAgent(...).withTasks(...)`, host-provided execution, XState snapshots, graph/mermaid export. +- Covered by package surface: typed `setupAgent(...)`, built-in text actor sources, reusable named text actors with `createTextLogic(...)`, host-provided execution, XState snapshots, graph/mermaid export. - Not yet covered by polished examples: checkpoint storage adapters, UI streaming transports, memory backends, tracing, migration guide. ## Recommended Order diff --git a/docs/langgraph-parity.md b/docs/langgraph-parity.md index 8d31904..7821ee2 100644 --- a/docs/langgraph-parity.md +++ b/docs/langgraph-parity.md @@ -7,7 +7,7 @@ This document tracks where authored `@statelyai/agent` machines can model the pr It is intentionally scoped to: - state-machine authoring concepts -- full type-safe XState authoring through `setupAgent(...).withTasks(...)` +- full type-safe XState authoring through `setupAgent(...)`, built-in text actor sources, and reusable `createTextLogic(...)` actors - XState actor, snapshot, and host-adapter behavior - adapter and transport example behavior - runnable examples and tests in this repo @@ -38,7 +38,7 @@ The strongest reason to choose `@statelyai/agent` over LangGraph is that the wor - visualization comes from the authored machine, not a separate reconstruction - model and tool execution stay in host code, so Vercel AI SDK, LangChain, Workers AI, SQL clients, and local functions remain swappable -The strongest reason to choose it over handrolling is that agent control flow is usually the product. Once a workflow needs branching, review gates, retries, persistence, subflows, or multi-agent routing, plain async functions become implicit state machines. `withTasks(...)` makes model steps individually testable, and `setupAgent(...)` makes the state machine explicit without taking over the runtime. +The strongest reason to choose it over handrolling is that agent control flow is usually the product. Once a workflow needs branching, review gates, retries, persistence, subflows, or multi-agent routing, plain async functions become implicit state machines. `createTextLogic(...)` makes model steps individually testable, and `setupAgent(...)` makes the state machine explicit without taking over the runtime. Remaining gaps are tracked in [`langgraph-gaps.md`](/Users/davidkpiano/Code/agent/docs/langgraph-gaps.md). @@ -48,7 +48,7 @@ Remaining gaps are tracked in [`langgraph-gaps.md`](/Users/davidkpiano/Code/agen | LangGraphJS concept | Status | Agent equivalent | | --- | --- | --- | -| Graph/state-machine authoring with typed state/events | Covered | `setupAgent(...).withTasks(...)`, [`examples/setup-agent/email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/email-drafter.ts), [`src/setup-agent.test.ts`](/Users/davidkpiano/Code/agent/src/setup-agent.test.ts), [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | +| Graph/state-machine authoring with typed state/events | Covered | `setupAgent(...)`, built-in `agent.generateText` / `agent.streamText`, `createTextLogic(...)`, [`examples/setup-agent/email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/email-drafter.ts), [`src/setup-agent.test.ts`](/Users/davidkpiano/Code/agent/src/setup-agent.test.ts), [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | | Branching / conditional routing | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | | Subgraphs / nested flows | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | | Human-in-the-loop / approval gate | Covered | [`src/langgraph-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/langgraph-equivalents/raw-xstate.test.ts) | @@ -70,7 +70,7 @@ Remaining gaps are tracked in [`langgraph-gaps.md`](/Users/davidkpiano/Code/agen ## XState coverage -`setupAgent(...).withTasks(...)` is the first-class path. Current tests cover these applicable LangGraph example shapes with typed XState machines and local `createActor(...)` execution: +`setupAgent(...)` is the first-class path; built-in text actor sources cover inline model calls, while `createTextLogic(...)` covers reusable named model calls. Current tests cover these applicable LangGraph example shapes with typed XState machines and local `createActor(...)` execution: - conditional routing - human-in-the-loop approval @@ -93,7 +93,7 @@ Remaining gaps are tracked in [`langgraph-gaps.md`](/Users/davidkpiano/Code/agen These are currently deliberate, not gaps: - Logic stays pure: `(state, event) -> { nextState, effects }`. -- Developers author normal XState with `setupAgent(...).withTasks(...)`; LangGraph-style workflows map without giving up runtime control. +- Developers author normal XState with `setupAgent(...)`; LangGraph-style workflows map without giving up runtime control. - Emitted events are live runtime effects, not durable journal entries. - Session behavior is based on first-class snapshot + event contracts; production durability belongs in adapters. - `run.on(...)` is reserved for emitted events only; terminal/runtime hooks use dedicated methods like `run.onDone(...)`. diff --git a/examples/README.md b/examples/README.md index 08db196..a751b99 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ -This directory is organized around the preferred authoring path: `setupAgent(...).withTasks(...)` machines. +This directory is organized around `setupAgent(...)` machines with built-in text actor sources or reusable text actors. ## Start Here @@ -13,7 +13,7 @@ This directory is organized around the preferred authoring path: `setupAgent(... ## XState Examples -These use `setupAgent(...)` and `withTasks(...)` from `@statelyai/agent`. The runtime is flexible: use `createActor(...)` locally, provide different host actors in apps, or persist XState snapshots in a platform adapter. +These use `setupAgent(...)` from `@statelyai/agent`. The runtime is flexible: use `createActor(...)` locally, provide different host actors in apps, or persist XState snapshots in a platform adapter. - [`setup-agent/email-drafter.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/email-drafter.ts): typed email workflow with independently testable text logic - [`setup-agent/game-agent.ts`](/Users/davidkpiano/Code/agent/examples/setup-agent/game-agent.ts): turn-based game workflow with whitelisted event tools @@ -31,4 +31,4 @@ These use `setupAgent(...)` and `withTasks(...)` from `@statelyai/agent`. The ru - [`../docs/crewai-parity.md`](/Users/davidkpiano/Code/agent/docs/crewai-parity.md) - [`../docs/burr-parity.md`](/Users/davidkpiano/Code/agent/docs/burr-parity.md) -The parity docs track end-result coverage and remaining gaps. New examples should use `withTasks(...)` for named LLM work and `setupAgent(...)` for schema-first machine authoring. +The parity docs track end-result coverage and remaining gaps. New examples should use built-in `agent.generateText` / `agent.streamText` for inline LLM work, `createTextLogic(...)` for reusable LLM work, and `setupAgent(...)` for schema-first machine authoring. diff --git a/examples/setup-agent/email-drafter.ts b/examples/setup-agent/email-drafter.ts index d7fa4eb..51d6550 100644 --- a/examples/setup-agent/email-drafter.ts +++ b/examples/setup-agent/email-drafter.ts @@ -3,6 +3,7 @@ import { assign, fromPromise } from 'xstate'; import { type AgentMessage, assistantMessage, + createTextLogic, setupAgent, userMessage, } from '../../src/index.js'; @@ -78,6 +79,50 @@ const eventSchemas = { const outputSchema = z.object({ sentEmails: z.array(emailDraftSchema) }); +export const evaluatePrompt = createTextLogic({ + schemas: { + input: z.object({ prompt: z.string() }), + output: promptAssessmentSchema, + }, + model: 'openai/gpt-5.4-nano', + system: + 'Evaluate an email drafting request. Require recipient, subject, and body details. Return missing fields and one question per gap.', + prompt: ({ input }) => input.prompt, +}); + +export const draftEmail = createTextLogic({ + schemas: { + input: z.object({ + prompt: z.string(), + draftAnyway: z.boolean(), + messages: z.custom((value) => Array.isArray(value)), + }), + output: emailDraftSchema, + }, + model: 'openai/gpt-5.4-nano', + system: ({ input }) => + [ + 'Draft a polished email from the request.', + input.draftAnyway + ? 'Infer reasonable details only because the user chose to draft anyway.' + : 'Use the provided details without inventing missing essentials.', + ].join('\n'), + messages: ({ input }) => [ + ...input.messages, + userMessage(input.prompt), + ], +}); + +export const streamDraft = createTextLogic({ + kind: 'stream', + schemas: { + input: z.object({ prompt: z.string() }), + output: z.string(), + }, + model: 'openai/gpt-5.4-nano', + prompt: ({ input }) => input.prompt, +}); + const agent = setupAgent({ context: contextSchema, events: eventSchemas, @@ -90,53 +135,12 @@ const agent = setupAgent({ return { sent: true }; } ), - }, -}).withTasks({ - evaluatePrompt: { - schemas: { - input: z.object({ prompt: z.string() }), - output: promptAssessmentSchema, - }, - model: 'openai/gpt-5.4-nano', - system: - 'Evaluate an email drafting request. Require recipient, subject, and body details. Return missing fields and one question per gap.', - prompt: ({ input }) => input.prompt, - }, - draftEmail: { - schemas: { - input: z.object({ - prompt: z.string(), - draftAnyway: z.boolean(), - messages: z.custom((value) => Array.isArray(value)), - }), - output: emailDraftSchema, - }, - model: 'openai/gpt-5.4-nano', - system: ({ input }) => - [ - 'Draft a polished email from the request.', - input.draftAnyway - ? 'Infer reasonable details only because the user chose to draft anyway.' - : 'Use the provided details without inventing missing essentials.', - ].join('\n'), - messages: ({ input }) => [ - ...input.messages, - userMessage(input.prompt), - ], - }, - streamDraft: { - kind: 'stream', - schemas: { - input: z.object({ prompt: z.string() }), - output: z.string(), - }, - model: 'openai/gpt-5.4-nano', - prompt: ({ input }) => input.prompt, + evaluatePrompt, + draftEmail, + streamDraft, }, }); -export const { evaluatePrompt, draftEmail, streamDraft } = agent.tasks; - export const emailDrafterSchemas = agent.schemas; export const emailDrafter = agent.createMachine({ diff --git a/examples/setup-agent/game-agent.ts b/examples/setup-agent/game-agent.ts index da074b0..012986e 100644 --- a/examples/setup-agent/game-agent.ts +++ b/examples/setup-agent/game-agent.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { assign } from 'xstate'; -import { createAgentSchemas, setupAgent } from '../../src/index.js'; +import { createAgentSchemas, createTextLogic, setupAgent } from '../../src/index.js'; export const turnSummarySchema = z.object({ summary: z.string(), @@ -41,49 +41,54 @@ const schemas = createAgentSchemas({ events: eventSchemas, }); -const gameAgent = setupAgent({ schemas }).withTasks({ - chooseMove: { - schemas: { - input: z.object({ - playerHp: z.number(), - enemyHp: z.number(), - }), - output: z.string(), - }, - model: 'openai/gpt-5.4-nano', - system: 'You are playing a turn-based game. Choose exactly one legal event tool.', - prompt: ({ input }) => - [ - `Player HP: ${input.playerHp}`, - `Enemy HP: ${input.enemyHp}`, - 'Pick the best legal move.', - ].join('\n'), - events: ({ input }) => - input.playerHp <= 6 - ? ['ATTACK', 'DEFEND', 'HEAL', 'FLEE'] - : ['ATTACK', 'DEFEND', 'FLEE'], +export const chooseMove = createTextLogic({ + schemas: { + input: z.object({ + playerHp: z.number(), + enemyHp: z.number(), + }), + output: z.string(), }, - summarizeTurn: { - schemas: { - input: z.object({ - playerHp: z.number(), - enemyHp: z.number(), - defended: z.boolean(), - }), - output: turnSummarySchema, - }, - model: 'openai/gpt-5.4-nano', - system: 'Narrate the turn and return updated HP totals.', - prompt: ({ input }) => - [ - `Player HP: ${input.playerHp}`, - `Enemy HP: ${input.enemyHp}`, - `Defended: ${input.defended}`, - ].join('\n'), + model: 'openai/gpt-5.4-nano', + system: 'You are playing a turn-based game. Choose exactly one legal event tool.', + prompt: ({ input }) => + [ + `Player HP: ${input.playerHp}`, + `Enemy HP: ${input.enemyHp}`, + 'Pick the best legal move.', + ].join('\n'), + events: ({ input }) => + input.playerHp <= 6 + ? ['ATTACK', 'DEFEND', 'HEAL', 'FLEE'] + : ['ATTACK', 'DEFEND', 'FLEE'], +}); + +export const summarizeTurn = createTextLogic({ + schemas: { + input: z.object({ + playerHp: z.number(), + enemyHp: z.number(), + defended: z.boolean(), + }), + output: turnSummarySchema, }, + model: 'openai/gpt-5.4-nano', + system: 'Narrate the turn and return updated HP totals.', + prompt: ({ input }) => + [ + `Player HP: ${input.playerHp}`, + `Enemy HP: ${input.enemyHp}`, + `Defended: ${input.defended}`, + ].join('\n'), }); -export const { chooseMove, summarizeTurn } = gameAgent.tasks; +const gameAgent = setupAgent({ + schemas, + actors: { + chooseMove, + summarizeTurn, + }, +}); export const gameSchemas = gameAgent.schemas; diff --git a/examples/setup-agent/joke.ts b/examples/setup-agent/joke.ts index 620d94f..0e51001 100644 --- a/examples/setup-agent/joke.ts +++ b/examples/setup-agent/joke.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { assign } from 'xstate'; -import { createAgentSchemas, setupAgent } from '../../src/index.js'; +import { createAgentSchemas, createTextLogic, setupAgent } from '../../src/index.js'; const jokeSchema = z.object({ joke: z.string(), @@ -15,20 +15,23 @@ const schemas = createAgentSchemas({ output: jokeSchema, }); -const jokeAgent = setupAgent({ schemas }).withTasks({ - tellJoke: { - kind: 'stream', - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), - }, - model: 'openai/gpt-5.4-nano', - system: 'You tell short, punchy jokes.', - prompt: ({ input }) => `Tell a joke about ${input.topic}.`, +export const tellJoke = createTextLogic({ + kind: 'stream', + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), }, + model: 'openai/gpt-5.4-nano', + system: 'You tell short, punchy jokes.', + prompt: ({ input }) => `Tell a joke about ${input.topic}.`, }); -export const { tellJoke } = jokeAgent.tasks; +const jokeAgent = setupAgent({ + schemas, + actors: { + tellJoke, + }, +}); export const jokeSchemas = jokeAgent.schemas; diff --git a/examples/setup-agent/triage.ts b/examples/setup-agent/triage.ts index 74a73c8..01b58ee 100644 --- a/examples/setup-agent/triage.ts +++ b/examples/setup-agent/triage.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { assign } from 'xstate'; -import { createAgentSchemas, setupAgent } from '../../src/index.js'; +import { createAgentSchemas, createTextLogic, setupAgent } from '../../src/index.js'; export const triageSchema = z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), @@ -17,20 +17,23 @@ const schemas = createAgentSchemas({ output: triageSchema, }); -const triageAgent = setupAgent({ schemas }).withTasks({ - triageTicket: { - schemas: { - input: z.object({ ticket: z.string() }), - output: triageSchema, - }, - model: 'openai/gpt-5.4-nano', - system: - 'Triage the support ticket: sentiment, category, and a short suggested reply.', - prompt: ({ input }) => input.ticket, +export const triageTicket = createTextLogic({ + schemas: { + input: z.object({ ticket: z.string() }), + output: triageSchema, }, + model: 'openai/gpt-5.4-nano', + system: + 'Triage the support ticket: sentiment, category, and a short suggested reply.', + prompt: ({ input }) => input.ticket, }); -export const { triageTicket } = triageAgent.tasks; +const triageAgent = setupAgent({ + schemas, + actors: { + triageTicket, + }, +}); export const triageSchemas = triageAgent.schemas; diff --git a/readme.md b/readme.md index 9c9125f..6f47aa3 100644 --- a/readme.md +++ b/readme.md @@ -2,9 +2,10 @@ Stately Agent is the state machine authoring layer for AI agents. Author your AI agents as state machines. Run them anywhere. -The package owns one first-class authoring primitive: +The package owns these first-class authoring surfaces: -- `setupAgent(...).withTasks(...)`: schema-first, SDK-agnostic agent task authoring. +- `createTextLogic(...)`: reusable, schema-typed model-call actors. +- `agent.generateText` / `agent.streamText`: built-in model-call actor sources auto-provided by `setupAgent(...)`. - `setupAgent.fromConfig(...)`: static workflow config lowered to the same agent machine shape. Use `setupAgent(...)` for schema-first control flow. Use normal host code for runtime execution. Stately Agent adds the batteries: reusable text logic, message helpers, examples, retained schemas, and visualization/export affordances. @@ -13,7 +14,7 @@ You can still call the Vercel AI SDK, LangChain, Workers AI, or any other model/ Choose this over LangGraph when you want agent workflows to be explicit state machines instead of framework-owned graphs: same workflow shapes, strong TypeScript for machine context/events/actors, first-class XState snapshots/guards, visualization by default, and no required runtime backend. Choose it over handrolled workflows when the control flow is important enough to inspect, persist, replay, test, and diagram. -For SDK integration, define named tasks with `setupAgent({ schemas }).withTasks(...)`. The machine declares `invoke: { src: 'getSummary', input, onDone }`; your host reads that task and calls Vercel AI SDK, Cloudflare Workers AI, LangChain, local models, or custom code. Source ids, invoke input, `event.output`, and machine schemas are typed from the registered tasks and schemas. See [`docs/host-actors.md`](/Users/davidkpiano/Code/agent/docs/host-actors.md). +For SDK integration, invoke the built-in `agent.generateText` / `agent.streamText` actors directly or register reusable `createTextLogic(...)` actors. Your host reads returned tasks and calls Vercel AI SDK, Cloudflare Workers AI, LangChain, local models, or custom code. Reusable text actors can also be tested standalone with `logic.request(input)` and `logic.execute(input, executors)`. See [`docs/host-actors.md`](/Users/davidkpiano/Code/agent/docs/host-actors.md). ## Agent Machines @@ -24,6 +25,7 @@ Import `createAgentSchemas(...)` and `setupAgent(...)` from `@statelyai/agent`: ```ts import { createAgentSchemas, + parseOutput, setupAgent, } from '@statelyai/agent'; import { assign } from 'xstate'; @@ -41,17 +43,7 @@ const schemas = createAgentSchemas({ input: inputSchema, output: answerSchema, }); - -const agent = setupAgent({ schemas }).withTasks({ - getAnswer: { - schemas: { - input: z.object({ prompt: z.string() }), - output: answerSchema, - }, - model: 'writer', - prompt: ({ input }) => input.prompt, - }, -}); +const agent = setupAgent({ schemas }); const machine = agent.createMachine({ context: ({ input }) => ({ prompt: input.prompt, answer: null }), @@ -60,12 +52,16 @@ const machine = agent.createMachine({ answering: { invoke: { id: 'answer', - src: 'getAnswer', - input: ({ context }) => ({ prompt: context.prompt }), + src: 'agent.generateText', + input: ({ context }) => ({ + model: 'writer', + prompt: context.prompt, + outputSchema: answerSchema, + }), onDone: { target: 'done', actions: assign({ - answer: ({ event }) => event.output.answer, + answer: ({ event }) => parseOutput(answerSchema, event.output).answer, }), }, }, @@ -87,7 +83,7 @@ while (!step.done) { } ``` -This is normal XState underneath: use `machine.initial(...)`, `machine.transition(...)`, and `machine.resolve(...)` for the blessed step loop; drop down to pure `initialTransition(...)` / `transitionResult(...)`; or use `createActor(...)`, snapshots, persistence, guards, actions, and host-provided actors. `setupAgent(...)` adds schema-derived concrete types and retained schemas; `withTasks(...)` adds reusable typed task construction, strongly typed source names, typed invoke input, typed `event.output`, `step.tasks`, `machine.getTasks(...)`, and `machine.execute(...)`. +This is normal XState underneath: use `machine.initial(...)`, `machine.transition(...)`, and `machine.resolve(...)` for the blessed step loop; drop down to pure `initialTransition(...)` / `transitionResult(...)`; or use `createActor(...)`, snapshots, persistence, guards, actions, and host-provided actors. `setupAgent(...)` adds schema-derived concrete types, retained schemas, built-in text actor sources, reusable text actors, `step.tasks`, `machine.getTasks(...)`, and `machine.execute(...)`. When a task declares `events`, `machine.getTasks(...)` returns `event.` tools for those events only if they are currently legal from the snapshot. That lets a model choose legal machine events, such as moves in a game, without exposing every transition. @@ -101,7 +97,7 @@ The package also publishes a JSON Schema for static, declarative agent workflow import workflowSchema from '@statelyai/agent/agent-workflow.json'; ``` -Use `setupAgent.fromConfig(...)` to lower static definitions to the same agent machine shape as TS-first `setupAgent(...).withTasks(...)` authoring. Static definitions separate model tasks from XState-like control flow: +Use `setupAgent.fromConfig(...)` to lower static definitions to the same agent machine shape as TS-first `setupAgent(...)` authoring. Static definitions separate model tasks from XState-like control flow: ```yaml tasks: diff --git a/src/index.ts b/src/index.ts index 219fd6b..ad16a25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,6 @@ export { parseOutput, setupAgent, transitionResult, - USER_INPUT_ACTOR, validateSchemaSync, } from './setup-agent.js'; export { diff --git a/src/setup-agent.test.ts b/src/setup-agent.test.ts index 0cdf279..cc89c46 100644 --- a/src/setup-agent.test.ts +++ b/src/setup-agent.test.ts @@ -8,9 +8,9 @@ import { getAgentEffects, getEventTools, messagesSchema, + parseOutput, setupAgent, transitionResult, - USER_INPUT_ACTOR, userMessage, type AgentTextInput, type AgentTools, @@ -269,6 +269,125 @@ describe('setupAgent', () => { ).resolves.toBe('Streamed final text.'); }); + test('setupAgent auto-provides built-in generateText and streamText sources', async () => { + const answerSchema = z.object({ answer: z.string() }); + const schemas = createAgentSchemas({ + context: z.object({ + prompt: z.string(), + answer: z.string().nullable(), + streamed: z.string().nullable(), + }), + input: z.object({ prompt: z.string() }), + output: z.object({ + answer: z.string(), + streamed: z.string(), + }), + }); + const agent = setupAgent({ schemas }); + const machine = agent.createMachine({ + context: ({ input }) => ({ + prompt: input.prompt, + answer: null, + streamed: null, + }), + initial: 'answering', + states: { + answering: { + invoke: { + id: 'answer', + src: 'agent.generateText', + input: ({ context }) => ({ + model: 'test-model', + prompt: context.prompt, + outputSchema: answerSchema, + temperature: 0.2, + }), + onDone: { + target: 'streaming', + actions: assign({ + answer: ({ event }) => parseOutput(answerSchema, event.output).answer, + }), + }, + }, + }, + streaming: { + invoke: { + id: 'stream', + src: 'agent.streamText', + input: ({ context }) => ({ + model: 'test-model', + prompt: `Expand ${context.answer}`, + }), + onDone: { + target: 'done', + actions: assign({ + streamed: ({ event }) => event.output as string, + }), + }, + }, + }, + done: { + type: 'final', + output: ({ context }) => ({ + answer: context.answer ?? '', + streamed: context.streamed ?? '', + }), + }, + }, + }); + + let step = machine.initial({ prompt: 'Why machines?' }); + expect(step.tasks).toEqual([ + expect.objectContaining({ + kind: 'generate', + id: 'answer', + src: 'agent.generateText', + input: expect.objectContaining({ + model: 'test-model', + prompt: 'Why machines?', + outputSchema: answerSchema, + temperature: 0.2, + }), + }), + ]); + + const answer = await machine.execute(step.tasks[0]!, { + generateText: async (request: AgentTextInput & { tools: AgentTools }) => { + expect(request.tools).toEqual({}); + return { output: { answer: `Answered ${request.prompt}` } }; + }, + }); + step = machine.resolve(step, step.tasks[0]!, answer); + + expect(step.tasks).toEqual([ + expect.objectContaining({ + kind: 'stream', + id: 'stream', + src: 'agent.streamText', + input: expect.objectContaining({ + model: 'test-model', + prompt: 'Expand Answered Why machines?', + }), + }), + ]); + + const streamed = await machine.execute(step.tasks[0]!, { + generateText: async () => { + throw new Error('generateText should not be used for stream tasks'); + }, + streamText: async (request: AgentTextInput & { tools: AgentTools }) => ({ + text: `Streamed ${request.prompt}`, + }), + }); + step = machine.resolve(step, step.tasks[0]!, streamed); + + expect(step.done).toBe(true); + expect(step.snapshot.output).toEqual({ + answer: 'Answered Why machines?', + streamed: 'Streamed Expand Answered Why machines?', + }); + }); + test('provided agent machines preserve step helpers', () => { const agent = setupAgent({ context: z.object({ prompt: z.string() }), @@ -459,7 +578,18 @@ describe('setupAgent', () => { ]); }); - test('authors named tasks with typed input and output', () => { + test('authors reusable text actors with typed input and output', async () => { + const getSummary = createTextLogic({ + kind: 'generate', + schemas: { + input: z.object({ article: z.string() }), + output: z.object({ summary: z.string() }), + }, + model: 'test-model', + system: 'Summarize articles.', + prompt: ({ input }) => `Summarize:\n${input.article}`, + temperature: ({ input }) => input.article.length > 10 ? 0.2 : 0, + }); const agent = setupAgent({ context: z.object({ article: z.string(), @@ -467,19 +597,10 @@ describe('setupAgent', () => { }), input: z.object({ article: z.string() }), output: z.object({ summary: z.string() }), - }).withTasks({ - getSummary: { - schemas: { - input: z.object({ article: z.string() }), - output: z.object({ summary: z.string() }), - }, - model: 'test-model', - system: 'Summarize articles.', - prompt: ({ input }) => `Summarize:\n${input.article}`, - temperature: ({ input }) => input.article.length > 10 ? 0.2 : 0, + actors: { + getSummary, }, }); - const { getSummary } = agent.tasks; expect(getSummary.request({ article: 'A long article.' })).toEqual( expect.objectContaining({ @@ -551,7 +672,7 @@ describe('setupAgent', () => { article: 'State machines make agents inspectable.', }); const [effect] = getAgentEffects(actions, { - actors: agent.tasks, + actors: { getSummary }, }); expect(effect).toEqual({ @@ -574,6 +695,70 @@ describe('setupAgent', () => { expect(snapshot.status).toBe('done'); expect(snapshot.output).toEqual({ summary: 'Agents become inspectable.' }); + + await expect(getSummary.execute({ article: 'A long article.' }, { + generateText: async (request: AgentTextInput & { tools: AgentTools }) => { + expect(request.prompt).toBe('Summarize:\nA long article.'); + expect(request.tools).toEqual({}); + return { output: { summary: 'Standalone summary.' } }; + }, + })).resolves.toEqual({ summary: 'Standalone summary.' }); + }); + + test('reusable stream text actors execute with streamText', async () => { + const streamSummary = createTextLogic({ + kind: 'stream', + schemas: { + input: z.object({ article: z.string() }), + output: z.string(), + }, + model: 'test-model', + prompt: ({ input }) => `Stream:\n${input.article}`, + }); + const agent = setupAgent({ + context: z.object({ article: z.string() }), + input: z.object({ article: z.string() }), + actors: { streamSummary }, + }); + const machine = agent.createMachine({ + context: ({ input }) => ({ article: input.article }), + initial: 'streaming', + states: { + streaming: { + invoke: { + id: 'streamSummary', + src: 'streamSummary', + input: ({ context }) => ({ article: context.article }), + }, + }, + }, + }); + const step = machine.initial({ article: 'State machines.' }); + + expect(step.tasks[0]).toEqual(expect.objectContaining({ + kind: 'stream', + src: 'streamSummary', + })); + await expect(machine.execute(step.tasks[0]!, { + generateText: async () => { + throw new Error('generateText should not be used'); + }, + streamText: async (request: AgentTextInput & { tools: AgentTools }) => { + expect(request.prompt).toBe('Stream:\nState machines.'); + return { text: 'streamed summary' }; + }, + })).resolves.toBe('streamed summary'); + + await expect(streamSummary.execute({ article: 'State machines.' }, { + generateText: async () => { + throw new Error('generateText should not be used'); + }, + streamText: async (request: AgentTextInput & { tools: AgentTools }) => { + expect(request.prompt).toBe('Stream:\nState machines.'); + expect(request.tools).toEqual({}); + return { text: 'standalone stream' }; + }, + })).resolves.toBe('standalone stream'); }); test('named text logic can optionally execute as a promise actor', async () => { @@ -1164,7 +1349,7 @@ describe('setupAgent', () => { askRecipient: { invoke: { id: 'recipient', - src: USER_INPUT_ACTOR, + src: 'agent.userInput', input: { prompt: 'Who should receive this email?', schema: { @@ -1207,7 +1392,7 @@ describe('setupAgent', () => { }, }).provide({ actors: { - [USER_INPUT_ACTOR]: fromPromise(async ({ input }) => { + 'agent.userInput': fromPromise(async ({ input }) => { expect(input).toEqual( expect.objectContaining({ prompt: 'Who should receive this email?', diff --git a/src/setup-agent.ts b/src/setup-agent.ts index 9cd8c6d..cb36b7c 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -26,7 +26,11 @@ import type { } from './types.js'; import { validateSchemaSync } from './utils.js'; -export const USER_INPUT_ACTOR = 'agent.userInput' as const; +const USER_INPUT_ACTOR = 'agent.userInput' as const; +const GENERATE_TEXT_ACTOR = 'agent.generateText' as const; +const STREAM_TEXT_ACTOR = 'agent.streamText' as const; + +export type AgentTaskKind = 'generate' | 'stream'; /** Portable LCD input text tasks pass to host executors. */ export interface AgentTextInput> { @@ -61,9 +65,134 @@ export interface AgentUserInput> { } type BuiltinAgentActors = { + [GENERATE_TEXT_ACTOR]: PromiseActorLogic; + [STREAM_TEXT_ACTOR]: PromiseActorLogic; [USER_INPUT_ACTOR]: PromiseActorLogic; }; +const agentTextInputSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'statelyai-agent', + validate(value: unknown) { + const ok = + !!value + && typeof value === 'object' + && typeof (value as AgentTextInput).model === 'string'; + + return ok + ? { value: value as AgentTextInput } + : { issues: [{ message: 'Expected agent text input with a model' }] }; + }, + }, +}; + +const unknownOutputSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'statelyai-agent', + validate(value: unknown) { + return { value }; + }, + }, +}; + +const stringOutputSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'statelyai-agent', + validate(value: unknown) { + return typeof value === 'string' + ? { value } + : { issues: [{ message: 'Expected string output' }] }; + }, + }, +}; + +function createBuiltinTextActor( + src: typeof GENERATE_TEXT_ACTOR | typeof STREAM_TEXT_ACTOR, + taskKind: AgentTaskKind, + outputSchema: StandardSchemaV1 +): AgentTaskLogic, StandardSchemaV1> { + const logic = fromPromise(async () => { + throw new Error( + `'${src}' has no host execution. Provide an implementation with ` + + `machine.provide({ actors: { '${src}': ... } }) or execute the ` + + `returned agent task with machine.execute(...).` + ); + }); + + return Object.assign(logic, { + kind: 'statelyai.textLogic' as const, + taskKind, + schemas: { + input: agentTextInputSchema, + output: outputSchema, + }, + request(input: AgentTextInput) { + return validateSchemaSync(agentTextInputSchema, input); + }, + async execute(input: AgentTextInput, executors: AgentTaskExecutors) { + const output = await executeAgentTextRequest( + taskKind, + src, + validateSchemaSync(agentTextInputSchema, input), + executors + ); + + return validateSchemaSync(outputSchema, output); + }, + withExecutor( + execute: TextLogicExecutor< + StandardSchemaV1, + StandardSchemaV1, + Record + > + ) { + return Object.assign(createTextLogic({ + kind: taskKind, + schemas: { + input: agentTextInputSchema, + output: outputSchema, + }, + model: ({ input }) => input.model, + system: ({ input }) => input.system, + prompt: ({ input }) => input.prompt, + messages: ({ input }) => input.messages, + tools: ({ input }) => input.tools, + toolChoice: ({ input }) => input.toolChoice, + events: ({ input }) => input.eventTypes, + temperature: ({ input }) => input.temperature, + maxTokens: ({ input }) => input.maxTokens, + topP: ({ input }) => input.topP, + topK: ({ input }) => input.topK, + seed: ({ input }) => input.seed, + stopSequences: ({ input }) => input.stopSequences, + metadata: ({ input }) => input.metadata, + }, execute), { taskKind }); + }, + }) as AgentTaskLogic< + StandardSchemaV1, + StandardSchemaV1 + >; +} + +const builtinTextActors = { + [GENERATE_TEXT_ACTOR]: createBuiltinTextActor( + GENERATE_TEXT_ACTOR, + 'generate', + unknownOutputSchema + ), + [STREAM_TEXT_ACTOR]: createBuiltinTextActor( + STREAM_TEXT_ACTOR, + 'stream', + stringOutputSchema + ), +} satisfies Pick< + BuiltinAgentActors, + typeof GENERATE_TEXT_ACTOR | typeof STREAM_TEXT_ACTOR +>; + const userInputActor = fromPromise(async () => { throw new Error( `'${USER_INPUT_ACTOR}' has no host execution. Provide an implementation ` + @@ -338,6 +467,7 @@ export interface TextLogicConfig< TOutputSchema extends StandardSchemaV1, TMetadata = Record, > { + kind?: AgentTaskKind; schemas: { input: TInputSchema; output: TOutputSchema; @@ -396,18 +526,21 @@ export interface TextLogic< InferOutput > { readonly kind: 'statelyai.textLogic'; + readonly taskKind: AgentTaskKind; readonly schemas: { readonly input: TInputSchema; readonly output: TOutputSchema; }; request(input: InferOutput): AgentTextInput; + execute( + input: InferOutput, + executors: AgentTaskExecutors + ): Promise>; withExecutor( execute: TextLogicExecutor ): TextLogic; } -export type AgentTaskKind = 'generate' | 'stream'; - export interface AgentTaskLogic< TInputSchema extends StandardSchemaV1 = StandardSchemaV1, TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, @@ -491,8 +624,22 @@ export function createTextLogic< return Object.assign(logic, { kind: 'statelyai.textLogic' as const, + taskKind: config.kind ?? 'generate', schemas: config.schemas, request, + async execute(input: TInput, executors: AgentTaskExecutors) { + const output = await executeAgentTextRequest( + config.kind ?? 'generate', + 'textLogic', + request(input), + executors + ); + + return validateSchemaSync( + config.schemas.output as StandardSchemaV1, + output + ); + }, withExecutor( nextExecute: TextLogicExecutor ) { @@ -758,6 +905,34 @@ async function normalizeTaskExecutionResult(result: unknown): Promise { return resolved; } +async function executeAgentTextRequest( + taskKind: AgentTaskKind, + id: string, + input: AgentTextInput, + executors: AgentTaskExecutors, + tools: AgentTools = {} +): Promise { + const request = { + ...input, + tools: { + ...(input.tools ?? {}), + ...tools, + }, + }; + const executor = + taskKind === 'stream' + ? executors.streamText + : executors.generateText; + + if (!executor) { + throw new Error( + `No executor provided for ${taskKind === 'stream' ? 'stream' : 'generate'} task '${id}'.` + ); + } + + return normalizeTaskExecutionResult(await executor(request)); +} + function createAgentMachine( machine: TMachine, options: Pick @@ -811,22 +986,13 @@ function createAgentMachine( }); }, async execute(task: AgentTask, executors: AgentTaskExecutors) { - const request = { - ...task.input, - tools: task.tools, - }; - const executor = - task.kind === 'stream' - ? executors.streamText - : executors.generateText; - - if (!executor) { - throw new Error( - `No executor provided for ${task.kind === 'stream' ? 'stream' : 'generate'} task '${task.id}'.` - ); - } - - const output = await normalizeTaskExecutionResult(await executor(request)); + const output = await executeAgentTextRequest( + task.kind ?? 'generate', + task.id, + task.input, + executors, + task.tools + ); return task.input.outputSchema ? validateSchemaSync(task.input.outputSchema, output) @@ -881,6 +1047,8 @@ type SetupActors = { ? PromiseActorLogic : TActors[K]; }; +type AgentSetupActors = + TActors & BuiltinAgentActors; export interface AgentSchemaPack< TContextSchema extends StandardSchemaV1> = StandardSchemaV1>, @@ -1010,7 +1178,7 @@ type AgentSetupConfigOptions< typeof setup< ContextOf, EventsOf, - SetupActors, + SetupActors>, {}, TActions, TGuards, @@ -1101,7 +1269,7 @@ type SetupAgentXStateResult< typeof setup< ContextOf, EventsOf, - SetupActors, + SetupActors>, {}, TActions, TGuards, @@ -1625,11 +1793,15 @@ function lowerWorkflowState(stateConfig: AgentWorkflowStateConfig): Record { const logic = createTextLogic({ ...task, + kind: task.kind ?? 'generate', events: task.events ? ({ input }) => typeof task.events === 'function' @@ -1679,9 +1852,7 @@ function createTaskActors< return [ key, - Object.assign(logic, { - taskKind: task.kind ?? 'generate', - }), + logic, ]; }) ) as TaskActors; @@ -1763,7 +1934,7 @@ function createSetupAgent< const base = setup< ContextOf, EventsOf, - SetupActors, + SetupActors>, {}, TActions, TGuards, @@ -1782,6 +1953,7 @@ function createSetupAgent< meta: MetaOf; }, actors: { + ...builtinTextActors, [USER_INPUT_ACTOR]: userInputActor, ...config.actors, } as AgentSetupConfigOptions< @@ -1806,6 +1978,7 @@ function createSetupAgent< return createAgentMachine(createBaseMachine(machineConfig as never), { schemas, actors: { + ...builtinTextActors, ...config.actors, ...tasks, }, From f55b781ff4d77afa23b0b40194d9b4577f678307 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Wed, 24 Jun 2026 20:56:40 -0400 Subject: [PATCH 49/50] Add setupAgent requests API --- docs/burr-parity.md | 2 +- docs/host-actors.md | 58 +- examples/setup-agent/email-drafter.ts | 2 +- examples/setup-agent/hosts/ai-sdk-game.ts | 25 +- examples/setup-agent/hosts/ai-sdk.ts | 22 +- .../hosts/cloudflare-workers-ai.ts | 44 +- examples/setup-agent/hosts/tanstack-ai.ts | 38 +- examples/setup-agent/joke.ts | 2 +- examples/setup-agent/smoke.mts | 4 +- readme.md | 52 +- schemas/agent-workflow.json | 14 +- src/burr-equivalents/raw-xstate.test.ts | 276 +++---- src/crewai-equivalents/raw-xstate.test.ts | 88 ++- src/examples.test.ts | 10 +- .../dinavinter-agents.test.ts | 118 +-- src/index.ts | 22 +- src/langgraph-equivalents/raw-xstate.test.ts | 468 ++++++------ .../agent-spec-config.test.ts | 4 +- src/setup-agent.test.ts | 714 ++++++++++-------- src/setup-agent.ts | 428 +++++------ 20 files changed, 1285 insertions(+), 1106 deletions(-) diff --git a/docs/burr-parity.md b/docs/burr-parity.md index f720b52..9a44935 100644 --- a/docs/burr-parity.md +++ b/docs/burr-parity.md @@ -26,7 +26,7 @@ As of June 18, 2026, the upstream Burr examples directory includes examples such | Burr example pattern | Status | Agent equivalent | | --- | --- | --- | | Hello world counter / guarded loop | Covered | Explicit XState state, guarded loop, and final output in [`src/burr-equivalents/raw-xstate.test.ts`](/Users/davidkpiano/Code/agent/src/burr-equivalents/raw-xstate.test.ts) | -| Conversational RAG with memory in state | Covered | Retrieval as typed host actor, memory in machine context, answer as named task logic | +| Conversational RAG with memory in state | Covered | Retrieval as typed host actor, memory in machine context, answer as named request logic | | Streaming overview router | Covered | Safety check, mode routing, streaming side channel, final text transition | | Tool calling | Covered | Tool selection as structured text logic, local tool actors, final formatter text logic | | Typed state / structured output | Covered | Schema-derived context/output plus named structured text logic | diff --git a/docs/host-actors.md b/docs/host-actors.md index 5eef82a..cc951e9 100644 --- a/docs/host-actors.md +++ b/docs/host-actors.md @@ -1,6 +1,6 @@ # Host Actors -`setupAgent(...)` auto-provides built-in `agent.generateText` and `agent.streamText` actor sources. `createTextLogic(...)` describes reusable named model work. The host still owns execution. +`setupAgent(...)` accepts schema-bound `requests` and auto-provides built-in `agent.generateText` and `agent.streamText` actor sources. `createTextLogic(...)` describes reusable named model work. The host still owns execution. The text logic declares: @@ -26,7 +26,7 @@ The host provides: ## Blessed Pattern -Use named text logic and plain XState `invoke` objects. For maximum framework portability, run the machine with XState's pure transition functions and execute returned agent effects yourself. +Use named request configs and plain XState `invoke` objects. For maximum framework portability, run the machine with XState's pure transition functions and execute returned agent requests yourself. ```ts import { @@ -43,7 +43,21 @@ const schemas = createAgentSchemas({ events: eventSchemas, }); -const agent = setupAgent({ schemas }); +const agent = setupAgent({ + schemas, + requests: { + draftText: { + schemas: { + input: z.object({ prompt: z.string() }), + output: resultSchema, + }, + model: 'openai/gpt-5.4-nano', + prompt: ({ input }) => input.prompt, + temperature: 0.2, + events: ['APPROVE', 'REVISE'], + }, + }, +}); const machine = agent.createMachine({ initial: 'generating', @@ -51,14 +65,8 @@ const machine = agent.createMachine({ generating: { invoke: { id: 'draft', - src: 'agent.generateText', - input: ({ context }) => ({ - model: 'openai/gpt-5.4-nano', - prompt: context.prompt, - outputSchema: resultSchema, - temperature: 0.2, - eventTypes: ['APPROVE', 'REVISE'], - }), + src: 'draftText', + input: ({ context }) => ({ prompt: context.prompt }), onDone: { target: 'done', actions: assign({ @@ -74,19 +82,19 @@ const machine = agent.createMachine({ let step = machine.initial(input); while (!step.done) { - for (const task of step.tasks) { - const output = await machine.execute(task, { + for (const request of step.requests) { + const output = await machine.execute(request, { generateText: (request) => generateText(request), streamText: (request) => streamText(request), }); - step = machine.resolve(step, task, output); + step = machine.resolve(step, request, output); } } ``` Every agent invoke should have a durable `id`; that ID is used to resume the matching `onDone` transition. -`machine.execute(...)` is convenience only. You can still inspect `task.input`, `task.tools`, and `task.events`, then call any SDK yourself. +`machine.execute(...)` is convenience only. You can still inspect `request.input`, `request.tools`, and `request.events`, then call any SDK yourself. For external events, advance the same step object: @@ -94,7 +102,7 @@ For external events, advance the same step object: step = machine.transition(step, { type: 'REVISE', prompt: nextPrompt }); ``` -Use `initialTransition(...)`, `transition(...)`, and `transitionResult(...)` directly when a host wants to own the full XState action list instead of the `step.tasks` abstraction. +Use `initialTransition(...)`, `transition(...)`, and `transitionResult(...)` directly when a host wants to own the full XState action list instead of the `step.requests` abstraction. ## User Input @@ -131,28 +139,30 @@ invoke: ## Allowed Event Tools -Use task `events` to expose specific state transitions as tools. `getAgentEffects(...)` validates that those events are legal from the current snapshot and returns event tools separately from the model-call input. +Use request `events` to expose specific state transitions as tools. `getAgentRequests(...)` validates that those events are legal from the current snapshot and returns event tools separately from the model-call input. ```ts -const effects = getAgentEffects(actions, { +const requests = getAgentRequests(actions, { snapshot, schemas: agent.schemas, actors: { chooseMove }, }); -const effect = effects[0]; -Object.keys(effect.tools); +const request = requests[0]; +Object.keys(request.tools); // ['event.ATTACK', 'event.DEFEND'] ``` Each event tool returns the event object: ```ts -await effect.tools['event.ATTACK'].execute({ target: 'orc' }); +await request.tools['event.ATTACK'].execute({ target: 'orc' }); // { type: 'ATTACK', target: 'orc' } ``` -Only events listed in task `events` are exposed. If an event is listed but is not legal from the current state, it is omitted. +Only events listed in request `events` are exposed. If an event is listed but is not legal from the current state, it is omitted. + +Use `events` when authoring `setupAgent({ requests })` or `createTextLogic(...)`. `eventTypes` is the lowered `AgentTextRequest` field and the low-level input for direct `agent.generateText` / `agent.streamText` invokes. ## Actor Runtime @@ -214,7 +224,7 @@ Use `metadata` for host-specific details. It is intentionally not interpreted by ```ts const draftText = createTextLogic({ - kind: 'generate', + mode: 'generate', schemas: { input: draftInputSchema, output: resultSchema, @@ -240,7 +250,7 @@ This is different from XState `meta`. XState `meta` describes state nodes and tr Streaming chunks should stay in the host side channel: HTTP stream, WebSocket, AI SDK UI stream, stdout, tracing callback, etc. The machine transitions on the final text. That keeps snapshots deterministic and replayable. -The same task logic can be executed with `generateText(...)` or `streamText(...)`; the host decides. +The same request logic can be executed with `generateText(...)` or `streamText(...)`; the host decides. ## Low-Level Primitive diff --git a/examples/setup-agent/email-drafter.ts b/examples/setup-agent/email-drafter.ts index 51d6550..d9792ae 100644 --- a/examples/setup-agent/email-drafter.ts +++ b/examples/setup-agent/email-drafter.ts @@ -114,7 +114,7 @@ export const draftEmail = createTextLogic({ }); export const streamDraft = createTextLogic({ - kind: 'stream', + mode: 'stream', schemas: { input: z.object({ prompt: z.string() }), output: z.string(), diff --git a/examples/setup-agent/hosts/ai-sdk-game.ts b/examples/setup-agent/hosts/ai-sdk-game.ts index 3e74e94..e1c7667 100644 --- a/examples/setup-agent/hosts/ai-sdk-game.ts +++ b/examples/setup-agent/hosts/ai-sdk-game.ts @@ -9,23 +9,20 @@ import { openai } from '@ai-sdk/openai'; import { z } from 'zod'; import { toAiSdkTools } from '../../../src/ai-sdk/index.js'; import { - type AgentEffect, - type AgentTextInput, + type AgentRequest, + type AgentTextRequest, } from '../../../src/index.js'; -import { - gameMachine, - turnSummarySchema, -} from '../game-agent.js'; +import { gameMachine, turnSummarySchema } from '../game-agent.js'; function resolveModel(modelRef: string): LanguageModel { return openai(modelRef.replace(/^openai\//, '')); } -async function runGenerateEffect(effect: AgentEffect) { - const input = effect.input as AgentTextInput; +async function runGenerateRequest(request: AgentRequest) { + const input = request.input as AgentTextRequest; const model = resolveModel(input.model); const prompt = input.prompt ?? ''; - const tools = toAiSdkTools(effect.tools); + const tools = toAiSdkTools(request.tools); if (Object.keys(tools).length > 0) { const result = await generateText({ @@ -72,17 +69,17 @@ export async function runAiSdkGameTurn(input = { playerHp: 20, enemyHp: 15 }) { let step = gameMachine.initial(input); while (!step.done) { - const [task] = step.tasks; - if (!task) { - throw new Error('Machine is waiting without an agent task.'); + const [request] = step.requests; + if (!request) { + throw new Error('Machine is waiting without an agent request.'); } - const result = await runGenerateEffect(task); + const result = await runGenerateRequest(request); if (result.kind === 'event') { step = gameMachine.transition(step, result.event as never); } else { - step = gameMachine.resolve(step, task, result.output); + step = gameMachine.resolve(step, request, result.output); } } diff --git a/examples/setup-agent/hosts/ai-sdk.ts b/examples/setup-agent/hosts/ai-sdk.ts index a0948c3..2bb3c35 100644 --- a/examples/setup-agent/hosts/ai-sdk.ts +++ b/examples/setup-agent/hosts/ai-sdk.ts @@ -19,7 +19,7 @@ import { import { openai } from '@ai-sdk/openai'; import { createActor, toPromise } from 'xstate'; import { - type AgentTextInput, + type AgentTextRequest, type AgentTools, type TextLogicExecutor, } from '../../../src/index.js'; @@ -43,7 +43,7 @@ function resolveAiSdkModel( : openai(modelRef.replace(/^openai\//, '')); } -function toModelMessages(input: AgentTextInput): ModelMessage[] | undefined { +function toModelMessages(input: AgentTextRequest): ModelMessage[] | undefined { return input.messages?.map((message) => ({ role: message.role as 'user' | 'assistant' | 'system', content: message.content, @@ -51,8 +51,8 @@ function toModelMessages(input: AgentTextInput): ModelMessage[] | undefined { } async function generateWithAiSdk( - input: AgentTextInput, - tools: AgentTextInput['tools'] = input.tools, + input: AgentTextRequest, + tools: AgentTextRequest['tools'] = input.tools, options: AiSdkTextHostOptions = {}, signal?: AbortSignal ) { @@ -89,7 +89,7 @@ async function generateWithAiSdk( } async function streamWithAiSdk( - input: AgentTextInput, + input: AgentTextRequest, options: AiSdkTextHostOptions = {}, signal?: AbortSignal ) { @@ -155,16 +155,16 @@ export async function runTriageStepDemo(ticket: string) { let step = triageMachine.initial({ ticket }); while (!step.done) { - if (step.tasks.length === 0) { - throw new Error('Machine is waiting without an agent task.'); + if (step.requests.length === 0) { + throw new Error('Machine is waiting without an agent request.'); } - for (const task of step.tasks) { - const output = await triageMachine.execute(task, { - generateText: (request: AgentTextInput & { tools: AgentTools }) => + for (const request of step.requests) { + const output = await triageMachine.execute(request, { + generateText: (request: AgentTextRequest & { tools: AgentTools }) => generateWithAiSdk(request, request.tools), }); - step = triageMachine.resolve(step, task, output); + step = triageMachine.resolve(step, request, output); } } diff --git a/examples/setup-agent/hosts/cloudflare-workers-ai.ts b/examples/setup-agent/hosts/cloudflare-workers-ai.ts index d54bb44..2ebb260 100644 --- a/examples/setup-agent/hosts/cloudflare-workers-ai.ts +++ b/examples/setup-agent/hosts/cloudflare-workers-ai.ts @@ -5,12 +5,8 @@ * expose the same tool-calling shape as the Vercel AI SDK binding path, so this * host serializes allowed event tools into the prompt and accepts JSON output. */ -import { - type AgentEffect, -} from '../../../src/index.js'; -import { - gameMachine, -} from '../game-agent.js'; +import { type AgentRequest } from '../../../src/index.js'; +import { gameMachine } from '../game-agent.js'; interface Env { AI: { @@ -18,17 +14,17 @@ interface Env { }; } -function promptWithAllowedEvents(effect: AgentEffect): string { - const legalEvents = effect.events +function promptWithAllowedEvents(request: AgentRequest): string { + const legalEvents = request.events .map((event) => `- ${event.type}`) .join('\n'); if (!legalEvents) { - return effect.input.prompt ?? ''; + return request.input.prompt ?? ''; } return [ - effect.input.prompt ?? '', + request.input.prompt ?? '', '', 'Choose exactly one legal event and respond as JSON.', 'Legal events:', @@ -37,13 +33,13 @@ function promptWithAllowedEvents(effect: AgentEffect): string { ].join('\n'); } -async function runWorkersAiEffect(env: Env, effect: AgentEffect) { - const response = await env.AI.run(effect.input.model, { - system: effect.input.system, +async function runWorkersAiRequest(env: Env, request: AgentRequest) { + const response = (await env.AI.run(request.input.model, { + system: request.input.system, prompt: promptWithAllowedEvents(effect), - temperature: effect.input.temperature, - max_tokens: effect.input.maxTokens, - }) as { response?: string } | string | Record; + temperature: request.input.temperature, + max_tokens: request.input.maxTokens, + })) as { response?: string } | string | Record; const text = typeof response === 'string' @@ -52,11 +48,11 @@ async function runWorkersAiEffect(env: Env, effect: AgentEffect) { ? response.response : JSON.stringify(response); - if (effect.events.length > 0) { + if (request.events.length > 0) { return { kind: 'event' as const, event: JSON.parse(text) }; } - if (effect.input.outputSchema) { + if (request.input.outputSchema) { return { kind: 'output' as const, output: JSON.parse(text) }; } @@ -65,22 +61,22 @@ async function runWorkersAiEffect(env: Env, effect: AgentEffect) { export async function runCloudflareGameTurn( env: Env, - input = { playerHp: 20, enemyHp: 15 } + input = { playerHp: 20, enemyHp: 15 }, ) { let step = gameMachine.initial(input); while (!step.done) { - const [task] = step.tasks; - if (!task) { - throw new Error('Machine is waiting without an agent task.'); + const [request] = step.requests; + if (!request) { + throw new Error('Machine is waiting without an agent request.'); } - const result = await runWorkersAiEffect(env, task); + const result = await runWorkersAiRequest(env, request); if (result.kind === 'event') { step = gameMachine.transition(step, result.event as never); } else { - step = gameMachine.resolve(step, task, result.output); + step = gameMachine.resolve(step, request, result.output); } } diff --git a/examples/setup-agent/hosts/tanstack-ai.ts b/examples/setup-agent/hosts/tanstack-ai.ts index 15d646c..a956692 100644 --- a/examples/setup-agent/hosts/tanstack-ai.ts +++ b/examples/setup-agent/hosts/tanstack-ai.ts @@ -6,12 +6,8 @@ * * Then run with an OpenAI-compatible TanStack adapter. */ -import { - type AgentEffect, -} from '../../../src/index.js'; -import { - gameMachine, -} from '../game-agent.js'; +import { type AgentRequest } from '../../../src/index.js'; +import { gameMachine } from '../game-agent.js'; type TanStackChat = (options: { adapter: unknown; @@ -21,8 +17,8 @@ type TanStackChat = (options: { stream?: false; }) => Promise; -function toTanStackTools(effect: AgentEffect) { - return effect.events.map((event) => ({ +function toTanStackTools(request: AgentRequest) { + return request.events.map((event) => ({ name: event.toolName, description: `Transition with event '${event.type}'.`, inputSchema: event.inputSchema, @@ -33,22 +29,22 @@ function toTanStackTools(effect: AgentEffect) { })); } -async function runTanStackEffect(args: { +async function runTanStackRequest(args: { chat: TanStackChat; adapter: unknown; - effect: AgentEffect; + request: AgentRequest; }) { const result = await args.chat({ adapter: args.adapter, stream: false, messages: [ - ...(args.effect.input.system - ? [{ role: 'system' as const, content: args.effect.input.system }] + ...(args.request.input.system + ? [{ role: 'system' as const, content: args.request.input.system }] : []), - { role: 'user', content: args.effect.input.prompt ?? '' }, + { role: 'user', content: args.request.input.prompt ?? '' }, ], - tools: toTanStackTools(args.effect), - outputSchema: args.effect.input.outputSchema, + tools: toTanStackTools(args.request), + outputSchema: args.request.input.outputSchema, }); if (result && typeof result === 'object' && 'type' in result) { @@ -66,21 +62,21 @@ export async function runTanStackGameTurn(args: { let step = gameMachine.initial(args.input ?? { playerHp: 20, enemyHp: 15 }); while (!step.done) { - const [task] = step.tasks; - if (!task) { - throw new Error('Machine is waiting without an agent task.'); + const [request] = step.requests; + if (!request) { + throw new Error('Machine is waiting without an agent request.'); } - const result = await runTanStackEffect({ + const result = await runTanStackRequest({ chat: args.chat, adapter: args.adapter, - effect: task, + request, }); if (result.kind === 'event') { step = gameMachine.transition(step, result.event as never); } else { - step = gameMachine.resolve(step, task, result.output); + step = gameMachine.resolve(step, request, result.output); } } diff --git a/examples/setup-agent/joke.ts b/examples/setup-agent/joke.ts index 0e51001..0b709f3 100644 --- a/examples/setup-agent/joke.ts +++ b/examples/setup-agent/joke.ts @@ -16,7 +16,7 @@ const schemas = createAgentSchemas({ }); export const tellJoke = createTextLogic({ - kind: 'stream', + mode: 'stream', schemas: { input: z.object({ topic: z.string() }), output: z.string(), diff --git a/examples/setup-agent/smoke.mts b/examples/setup-agent/smoke.mts index e4d8e3e..2ffb621 100644 --- a/examples/setup-agent/smoke.mts +++ b/examples/setup-agent/smoke.mts @@ -4,9 +4,9 @@ import { emailDrafter, evaluatePrompt, } from './email-drafter.js'; -import type { AgentTextInput } from '../../src/index.js'; +import type { AgentTextRequest } from '../../src/index.js'; -const calls: AgentTextInput[] = []; +const calls: AgentTextRequest[] = []; const machine = emailDrafter.provide({ actors: { diff --git a/readme.md b/readme.md index 6f47aa3..70b4891 100644 --- a/readme.md +++ b/readme.md @@ -4,17 +4,18 @@ Stately Agent is the state machine authoring layer for AI agents. Author your AI The package owns these first-class authoring surfaces: +- `setupAgent({ requests })`: schema-bound, event-typed model request definitions. - `createTextLogic(...)`: reusable, schema-typed model-call actors. - `agent.generateText` / `agent.streamText`: built-in model-call actor sources auto-provided by `setupAgent(...)`. - `setupAgent.fromConfig(...)`: static workflow config lowered to the same agent machine shape. Use `setupAgent(...)` for schema-first control flow. Use normal host code for runtime execution. Stately Agent adds the batteries: reusable text logic, message helpers, examples, retained schemas, and visualization/export affordances. -You can still call the Vercel AI SDK, LangChain, Workers AI, or any other model/tool runtime yourself. The machine only declares behavior; hosts can either execute effects from pure XState transitions or provide actors with `machine.provide({ actors })`. That keeps runtime transparency while making the workflow typed, inspectable, and visualizable. +You can still call the Vercel AI SDK, LangChain, Workers AI, or any other model/tool runtime yourself. The machine only declares behavior; hosts can either execute requests from pure XState transitions or provide actors with `machine.provide({ actors })`. That keeps runtime transparency while making the workflow typed, inspectable, and visualizable. Choose this over LangGraph when you want agent workflows to be explicit state machines instead of framework-owned graphs: same workflow shapes, strong TypeScript for machine context/events/actors, first-class XState snapshots/guards, visualization by default, and no required runtime backend. Choose it over handrolled workflows when the control flow is important enough to inspect, persist, replay, test, and diagram. -For SDK integration, invoke the built-in `agent.generateText` / `agent.streamText` actors directly or register reusable `createTextLogic(...)` actors. Your host reads returned tasks and calls Vercel AI SDK, Cloudflare Workers AI, LangChain, local models, or custom code. Reusable text actors can also be tested standalone with `logic.request(input)` and `logic.execute(input, executors)`. See [`docs/host-actors.md`](/Users/davidkpiano/Code/agent/docs/host-actors.md). +For SDK integration, define request configs under `setupAgent({ requests })`, invoke the built-in `agent.generateText` / `agent.streamText` actors directly, or register reusable `createTextLogic(...)` actors. Your host reads returned requests and calls Vercel AI SDK, Cloudflare Workers AI, LangChain, local models, or custom code. Setup-bound requests can be tested standalone with `agent.requests.name.request(input)` and `agent.requests.name.execute(input, executors)`. Reusable text actors can also be tested standalone with `logic.request(input)` and `logic.execute(input, executors)`. See [`docs/host-actors.md`](/Users/davidkpiano/Code/agent/docs/host-actors.md). ## Agent Machines @@ -43,7 +44,19 @@ const schemas = createAgentSchemas({ input: inputSchema, output: answerSchema, }); -const agent = setupAgent({ schemas }); +const agent = setupAgent({ + schemas, + requests: { + answerQuestion: { + schemas: { + input: z.object({ prompt: z.string() }), + output: answerSchema, + }, + model: 'writer', + prompt: ({ input }) => input.prompt, + }, + }, +}); const machine = agent.createMachine({ context: ({ input }) => ({ prompt: input.prompt, answer: null }), @@ -52,12 +65,8 @@ const machine = agent.createMachine({ answering: { invoke: { id: 'answer', - src: 'agent.generateText', - input: ({ context }) => ({ - model: 'writer', - prompt: context.prompt, - outputSchema: answerSchema, - }), + src: 'answerQuestion', + input: ({ context }) => ({ prompt: context.prompt }), onDone: { target: 'done', actions: assign({ @@ -73,19 +82,28 @@ const machine = agent.createMachine({ let step = machine.initial({ prompt: 'Why XState?' }); while (!step.done) { - for (const task of step.tasks) { - const result = await machine.execute(task, { + for (const request of step.requests) { + const result = await machine.execute(request, { generateText: (request) => generateText(request), // any SDK/framework streamText: (request) => streamText(request), }); - step = machine.resolve(step, task, result); + step = machine.resolve(step, request, result); } } ``` -This is normal XState underneath: use `machine.initial(...)`, `machine.transition(...)`, and `machine.resolve(...)` for the blessed step loop; drop down to pure `initialTransition(...)` / `transitionResult(...)`; or use `createActor(...)`, snapshots, persistence, guards, actions, and host-provided actors. `setupAgent(...)` adds schema-derived concrete types, retained schemas, built-in text actor sources, reusable text actors, `step.tasks`, `machine.getTasks(...)`, and `machine.execute(...)`. +Test a single setup-bound request without running the machine: + +```ts +await agent.requests.answerQuestion.execute( + { prompt: 'Why XState?' }, + { generateText } +); +``` + +This is normal XState underneath: use `machine.initial(...)`, `machine.transition(...)`, and `machine.resolve(...)` for the blessed step loop; drop down to pure `initialTransition(...)` / `transitionResult(...)`; or use `createActor(...)`, snapshots, persistence, guards, actions, and host-provided actors. `setupAgent(...)` adds schema-derived concrete types, retained schemas, built-in text actor sources, reusable text actors, `step.requests`, `machine.getRequests(...)`, and `machine.execute(...)`. -When a task declares `events`, `machine.getTasks(...)` returns `event.` tools for those events only if they are currently legal from the snapshot. That lets a model choose legal machine events, such as moves in a game, without exposing every transition. +When a request declares `events`, `machine.getRequests(...)` returns `event.` tools for those events only if they are currently legal from the snapshot. That lets a model choose legal machine events, such as moves in a game, without exposing every transition. ## Static Workflow Definitions @@ -97,10 +115,10 @@ The package also publishes a JSON Schema for static, declarative agent workflow import workflowSchema from '@statelyai/agent/agent-workflow.json'; ``` -Use `setupAgent.fromConfig(...)` to lower static definitions to the same agent machine shape as TS-first `setupAgent(...)` authoring. Static definitions separate model tasks from XState-like control flow: +Use `setupAgent.fromConfig(...)` to lower static definitions to the same agent machine shape as TS-first `setupAgent(...)` authoring. Static definitions separate model requests from XState-like control flow: ```yaml -tasks: +requests: answerQuestion: model: openai/gpt-4.1 system: "You answer for {{ context.userName }}." @@ -185,6 +203,6 @@ Burr parity is tracked in [`docs/burr-parity.md`](/Users/davidkpiano/Code/agent/ ## Runtime -Runtime is normal XState. Use the agent step helpers when you want the package to collect tasks for you, pure `initialTransition(...)` / `transitionResult(...)` when a framework wants to own every transition detail, or `createActor(...)`, `toPromise(...)`, snapshots, persisted snapshots, `machine.provide({ actors })`, and your framework transport of choice. Model/tool execution stays under your control. +Runtime is normal XState. Use the agent step helpers when you want the package to collect requests for you, pure `initialTransition(...)` / `transitionResult(...)` when a framework wants to own every transition detail, or `createActor(...)`, `toPromise(...)`, snapshots, persisted snapshots, `machine.provide({ actors })`, and your framework transport of choice. Model/tool execution stays under your control. **Read the documentation: [stately.ai/docs/agents](https://stately.ai/docs/agents)** diff --git a/schemas/agent-workflow.json b/schemas/agent-workflow.json index 93074b5..d72dde0 100644 --- a/schemas/agent-workflow.json +++ b/schemas/agent-workflow.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://stately.ai/schemas/agent-workflow.json", "title": "Stately Agent Workflow Definition", - "description": "Static, declarative agent workflow definition that can be lowered to a setupAgent(...).withTasks(...) XState machine.", + "description": "Static, declarative agent workflow definition that can be lowered to a setupAgent(...) XState machine.", "type": "object", "required": ["initial", "states"], "properties": { @@ -35,14 +35,14 @@ "description": "Initial XState context. Values may be JSON literals or whole-string {{ }} expressions evaluated with machine input.", "$ref": "#/$defs/ExpressionObject" }, - "tasks": { - "description": "Named model tasks that become typed invoke sources.", + "requests": { + "description": "Named model requests that become typed invoke sources.", "type": "object", "propertyNames": { "$ref": "#/$defs/Identifier" }, "additionalProperties": { - "$ref": "#/$defs/Task" + "$ref": "#/$defs/Request" }, "default": {} }, @@ -176,11 +176,11 @@ }, "additionalProperties": false }, - "Task": { + "Request": { "type": "object", "required": ["model", "input", "output"], "properties": { - "kind": { + "mode": { "type": "string", "enum": ["generate", "stream"], "default": "generate" @@ -215,7 +215,7 @@ "$ref": "#/$defs/JsonSchema" }, "events": { - "description": "Machine event types this task may expose as host/model tools.", + "description": "Machine event types this request may expose as host/model tools.", "anyOf": [ { "$ref": "#/$defs/ExpressionString" }, { diff --git a/src/burr-equivalents/raw-xstate.test.ts b/src/burr-equivalents/raw-xstate.test.ts index b4374cc..e96c8fd 100644 --- a/src/burr-equivalents/raw-xstate.test.ts +++ b/src/burr-equivalents/raw-xstate.test.ts @@ -11,7 +11,7 @@ describe('Burr-style examples authored as XState setup machines', () => { output: z.object({ counter: z.number() }), actors: { increment: fromPromise( - async ({ input }) => input.counter + 1 + async ({ input }) => input.counter + 1, ), }, }); @@ -33,7 +33,10 @@ describe('Burr-style examples authored as XState setup machines', () => { }, checking: { always: [ - { guard: ({ context }) => context.counter < context.countUpTo, target: 'counter' }, + { + guard: ({ context }) => context.counter < context.countUpTo, + target: 'counter', + }, { target: 'result' }, ], }, @@ -66,26 +69,30 @@ describe('Burr-style examples authored as XState setup machines', () => { output: z.object({ answer: z.string(), memory: z.array(z.string()) }), actors: { retrieve: fromPromise( - async ({ input }) => [`doc:${input.question}`, 'doc:remembered-state'] + async ({ input }) => [ + `doc:${input.question}`, + 'doc:remembered-state', + ], ), }, - }).withTasks({ - answerWithDocuments: { - schemas: { - input: z.object({ - question: z.string(), - documents: z.array(z.string()), - memory: z.array(z.string()), - }), - output: z.string(), - }, - model: 'rag-answerer', - prompt: ({ input }) => - [ - `Q: ${input.question}`, - `Memory: ${input.memory.join(' | ')}`, - `Docs: ${input.documents.join(' | ')}`, - ].join('\n'), + requests: { + answerWithDocuments: { + schemas: { + input: z.object({ + question: z.string(), + documents: z.array(z.string()), + memory: z.array(z.string()), + }), + output: z.string(), + }, + model: 'rag-answerer', + prompt: ({ input }) => + [ + `Q: ${input.question}`, + `Memory: ${input.memory.join(' | ')}`, + `Docs: ${input.documents.join(' | ')}`, + ].join('\n'), + }, }, }); @@ -143,13 +150,13 @@ describe('Burr-style examples authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - answerWithDocuments: agent.tasks.answerWithDocuments.withExecutor( + answerWithDocuments: agent.requests.answerWithDocuments.withExecutor( async ({ input }) => - `answer:${input.documents.join(',')}:memory=${input.memory.length}` + `answer:${input.documents.join(',')}:memory=${input.memory.length}`, ), }, }), - { input: { question: 'why burr?', memory: ['prior turn'] } } + { input: { question: 'why burr?', memory: ['prior turn'] } }, ); actor.start(); await toPromise(actor); @@ -166,9 +173,13 @@ describe('Burr-style examples authored as XState setup machines', () => { test('streaming-overview router keeps safety and mode as explicit states', async () => { const modeSchema = z.object({ - mode: z.enum(['answer_question', 'generate_code', 'generate_image', 'unknown']), + mode: z.enum([ + 'answer_question', + 'generate_code', + 'generate_image', + 'unknown', + ]), }); - const agent = setupAgent({ context: z.object({ prompt: z.string(), @@ -178,24 +189,28 @@ describe('Burr-style examples authored as XState setup machines', () => { }), input: z.object({ prompt: z.string() }), output: z.object({ response: z.string() }), - }).withTasks({ - chooseMode: { - schemas: { - input: z.object({ prompt: z.string() }), - output: modeSchema, - }, - model: 'mode-router', - system: 'Choose the response mode.', - prompt: ({ input }) => input.prompt, - }, - answerPrompt: { - kind: 'stream', - schemas: { - input: z.object({ prompt: z.string(), mode: modeSchema.shape.mode }), - output: z.string(), - }, - model: 'streaming-writer', - prompt: ({ input }) => `${input.mode}:${input.prompt}`, + requests: { + chooseMode: { + schemas: { + input: z.object({ prompt: z.string() }), + output: modeSchema, + }, + model: 'mode-router', + system: 'Choose the response mode.', + prompt: ({ input }) => input.prompt, + }, + answerPrompt: { + mode: 'stream', + schemas: { + input: z.object({ + prompt: z.string(), + mode: modeSchema.shape.mode, + }), + output: z.string(), + }, + model: 'streaming-writer', + prompt: ({ input }) => `${input.mode}:${input.prompt}`, + }, }, }); @@ -230,7 +245,10 @@ describe('Burr-style examples authored as XState setup machines', () => { }, route: { always: [ - { guard: ({ context }) => context.mode === 'unknown', target: 'promptForMore' }, + { + guard: ({ context }) => context.mode === 'unknown', + target: 'promptForMore', + }, { target: 'answering' }, ], }, @@ -266,19 +284,19 @@ describe('Burr-style examples authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - chooseMode: agent.tasks.chooseMode.withExecutor( - async () => ({ mode: 'generate_code' }) - ), - answerPrompt: agent.tasks.answerPrompt.withExecutor( + chooseMode: agent.requests.chooseMode.withExecutor(async () => ({ + mode: 'generate_code', + })), + answerPrompt: agent.requests.answerPrompt.withExecutor( async ({ input }) => { chunks.push('chunk:1'); chunks.push('chunk:2'); return `response:${input.mode}:${input.prompt}`; - } + }, ), }, }), - { input: { prompt: 'write a TypeScript function' } } + { input: { prompt: 'write a TypeScript function' } }, ); actor.start(); await toPromise(actor); @@ -300,7 +318,6 @@ describe('Burr-style examples authored as XState setup machines', () => { parameters: z.object({ response: z.string() }), }), ]); - const agent = setupAgent({ context: z.object({ query: z.string(), @@ -319,30 +336,31 @@ describe('Burr-style examples authored as XState setup machines', () => { location: `${input.latitude},${input.longitude}`, })), fallback: fromPromise, { response: string }>( - async ({ input }) => ({ response: input.response }) + async ({ input }) => ({ response: input.response }), ), }, - }).withTasks({ - selectTool: { - schemas: { - input: z.object({ query: z.string() }), - output: selectedToolSchema, - }, - model: 'tool-router', - system: 'Select exactly one tool.', - prompt: ({ input }) => input.query, - }, - formatResult: { - schemas: { - input: z.object({ - query: z.string(), - rawResponse: z.record(z.string(), z.unknown()), - }), - output: z.string(), + requests: { + selectTool: { + schemas: { + input: z.object({ query: z.string() }), + output: selectedToolSchema, + }, + model: 'tool-router', + system: 'Select exactly one tool.', + prompt: ({ input }) => input.query, + }, + formatResult: { + schemas: { + input: z.object({ + query: z.string(), + rawResponse: z.record(z.string(), z.unknown()), + }), + output: z.string(), + }, + model: 'formatter', + prompt: ({ input }) => + `Question: ${input.query}\nData: ${JSON.stringify(input.rawResponse)}`, }, - model: 'formatter', - prompt: ({ input }) => - `Question: ${input.query}\nData: ${JSON.stringify(input.rawResponse)}`, }, }); @@ -424,23 +442,23 @@ describe('Burr-style examples authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - selectTool: agent.tasks.selectTool.withExecutor( - async () => ({ - tool: 'queryWeather', - parameters: { latitude: 37.77, longitude: -122.42 }, - }) - ), - formatResult: agent.tasks.formatResult.withExecutor( - async ({ input }) => `formatted:${input.rawResponse.forecast}` + selectTool: agent.requests.selectTool.withExecutor(async () => ({ + tool: 'queryWeather', + parameters: { latitude: 37.77, longitude: -122.42 }, + })), + formatResult: agent.requests.formatResult.withExecutor( + async ({ input }) => `formatted:${input.rawResponse.forecast}`, ), }, }), - { input: { query: 'weather in San Francisco' } } + { input: { query: 'weather in San Francisco' } }, ); actor.start(); await toPromise(actor); - expect(actor.getSnapshot().output).toEqual({ finalOutput: 'formatted:sunny' }); + expect(actor.getSnapshot().output).toEqual({ + finalOutput: 'formatted:sunny', + }); }); test('typed-state structured output remains schema-derived and testable', async () => { @@ -456,7 +474,6 @@ describe('Burr-style examples authored as XState setup machines', () => { concepts: z.array(conceptSchema), keyTakeaways: z.array(z.string()), }); - const agent = setupAgent({ context: z.object({ youtubeUrl: z.string(), @@ -467,18 +484,19 @@ describe('Burr-style examples authored as XState setup machines', () => { output: z.object({ post: postSchema }), actors: { getTranscript: fromPromise( - async ({ input }) => `transcript:${input.youtubeUrl}` + async ({ input }) => `transcript:${input.youtubeUrl}`, ), }, - }).withTasks({ - generatePost: { - schemas: { - input: z.object({ transcript: z.string() }), - output: postSchema, - }, - model: 'post-writer', - system: 'Generate a social media post from the transcript.', - prompt: ({ input }) => input.transcript, + requests: { + generatePost: { + schemas: { + input: z.object({ transcript: z.string() }), + output: postSchema, + }, + model: 'post-writer', + system: 'Generate a social media post from the transcript.', + prompt: ({ input }) => input.transcript, + }, }, }); @@ -529,16 +547,20 @@ describe('Burr-style examples authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - generatePost: agent.tasks.generatePost.withExecutor(async ({ input }) => ({ - topic: 'Burr', - hook: 'Stateful AI apps need structure.', - body: input.transcript, - concepts: [{ term: 'state', definition: 'durable memory', timestamp: 1 }], - keyTakeaways: ['Keep state explicit'], - })), + generatePost: agent.requests.generatePost.withExecutor( + async ({ input }) => ({ + topic: 'Burr', + hook: 'Stateful AI apps need structure.', + body: input.transcript, + concepts: [ + { term: 'state', definition: 'durable memory', timestamp: 1 }, + ], + keyTakeaways: ['Keep state explicit'], + }), + ), }, }), - { input: { youtubeUrl: 'https://youtube.test/watch?v=abc' } } + { input: { youtubeUrl: 'https://youtube.test/watch?v=abc' } }, ); actor.start(); await toPromise(actor); @@ -549,7 +571,7 @@ describe('Burr-style examples authored as XState setup machines', () => { concepts: [ { term: 'state', definition: 'durable memory', timestamp: 1 }, ], - }) + }), ); }); @@ -557,38 +579,38 @@ describe('Burr-style examples authored as XState setup machines', () => { const routeSchema = z.object({ route: z.enum(['researcher', 'chartGenerator']), }); - const agent = setupAgent({ context: z.object({ - task: z.string(), + request: z.string(), route: z.enum(['researcher', 'chartGenerator']).nullable(), result: z.string().nullable(), }), - input: z.object({ task: z.string() }), + input: z.object({ request: z.string() }), output: z.object({ result: z.string() }), actors: { - researcher: fromPromise( - async ({ input }) => `research:${input.task}` + researcher: fromPromise( + async ({ input }) => `research:${input.request}`, ), - chartGenerator: fromPromise( - async ({ input }) => `chart:${input.task}` + chartGenerator: fromPromise( + async ({ input }) => `chart:${input.request}`, ), }, - }).withTasks({ - routeWork: { - schemas: { - input: z.object({ task: z.string() }), - output: routeSchema, - }, - model: 'supervisor', - prompt: ({ input }) => input.task, + requests: { + routeWork: { + schemas: { + input: z.object({ request: z.string() }), + output: routeSchema, + }, + model: 'supervisor', + prompt: ({ input }) => input.request, + }, }, }); const machine = agent.createMachine({ id: 'burr-multi-agent-collaboration-xstate', context: ({ input }) => ({ - task: input.task, + request: input.request, route: null, result: null, }), @@ -597,7 +619,7 @@ describe('Burr-style examples authored as XState setup machines', () => { supervising: { invoke: { src: 'routeWork', - input: ({ context }) => ({ task: context.task }), + input: ({ context }) => ({ request: context.request }), onDone: { target: 'dispatch', actions: assign({ route: ({ event }) => event.output.route }), @@ -616,7 +638,7 @@ describe('Burr-style examples authored as XState setup machines', () => { researching: { invoke: { src: 'researcher', - input: ({ context }) => ({ task: context.task }), + input: ({ context }) => ({ request: context.request }), onDone: { target: 'done', actions: assign({ result: ({ event }) => event.output }), @@ -626,7 +648,7 @@ describe('Burr-style examples authored as XState setup machines', () => { charting: { invoke: { src: 'chartGenerator', - input: ({ context }) => ({ task: context.task }), + input: ({ context }) => ({ request: context.request }), onDone: { target: 'done', actions: assign({ result: ({ event }) => event.output }), @@ -643,16 +665,18 @@ describe('Burr-style examples authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - routeWork: agent.tasks.routeWork.withExecutor( - async () => ({ route: 'chartGenerator' }) - ), + routeWork: agent.requests.routeWork.withExecutor(async () => ({ + route: 'chartGenerator', + })), }, }), - { input: { task: 'plot revenue' } } + { input: { request: 'plot revenue' } }, ); actor.start(); await toPromise(actor); - expect(actor.getSnapshot().output).toEqual({ result: 'chart:plot revenue' }); + expect(actor.getSnapshot().output).toEqual({ + result: 'chart:plot revenue', + }); }); }); diff --git a/src/crewai-equivalents/raw-xstate.test.ts b/src/crewai-equivalents/raw-xstate.test.ts index 520f1bc..aa3a147 100644 --- a/src/crewai-equivalents/raw-xstate.test.ts +++ b/src/crewai-equivalents/raw-xstate.test.ts @@ -12,32 +12,40 @@ describe('CrewAI-style flows authored as XState setup machines', () => { content: z.string().nullable(), }), input: z.object({ request: z.string() }), - output: z.object({ route: z.enum(['linkedin', 'blog']), content: z.string() }), - }).withTasks({ - routeContent: { - schemas: { - input: z.object({ request: z.string() }), - output: z.object({ route: z.enum(['linkedin', 'blog']) }), + output: z.object({ + route: z.enum(['linkedin', 'blog']), + content: z.string(), + }), + requests: { + routeContent: { + schemas: { + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['linkedin', 'blog']) }), + }, + model: 'router', + prompt: ({ input }) => input.request, }, - model: 'router', - prompt: ({ input }) => input.request, - }, - createContent: { - schemas: { - input: z.object({ - route: z.enum(['linkedin', 'blog']), - request: z.string(), - }), - output: z.string(), + createContent: { + schemas: { + input: z.object({ + route: z.enum(['linkedin', 'blog']), + request: z.string(), + }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => `${input.route}:${input.request}`, }, - model: 'writer', - prompt: ({ input }) => `${input.route}:${input.request}`, }, }); const machine = agent.createMachine({ id: 'crewai-content-creator-xstate', - context: ({ input }) => ({ request: input.request, route: null, content: null }), + context: ({ input }) => ({ + request: input.request, + route: null, + content: null, + }), initial: 'routing', states: { routing: { @@ -76,15 +84,15 @@ describe('CrewAI-style flows authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - routeContent: agent.tasks.routeContent.withExecutor( - async () => ({ route: 'linkedin' }) - ), - createContent: agent.tasks.createContent.withExecutor( + routeContent: agent.requests.routeContent.withExecutor(async () => ({ + route: 'linkedin', + })), + createContent: agent.requests.createContent.withExecutor( async ({ input }) => `Post for ${input.route}:${input.request}`, ), }, }), - { input: { request: 'launch update' } } + { input: { request: 'launch update' } }, ); actor.start(); await toPromise(actor); @@ -108,20 +116,21 @@ describe('CrewAI-style flows authored as XState setup machines', () => { actors: { writeChapters: fromPromise( async ({ input }) => - input.chapters.map((chapter: string) => `${chapter}: body`) + input.chapters.map((chapter: string) => `${chapter}: body`), ), }, - }).withTasks({ - outlineBook: { - schemas: { - input: z.object({ brief: z.string() }), - output: z.object({ - title: z.string(), - chapters: z.array(z.string()), - }), + requests: { + outlineBook: { + schemas: { + input: z.object({ brief: z.string() }), + output: z.object({ + title: z.string(), + chapters: z.array(z.string()), + }), + }, + model: 'outliner', + prompt: ({ input }) => input.brief, }, - model: 'outliner', - prompt: ({ input }) => input.brief, }, }); @@ -173,12 +182,13 @@ describe('CrewAI-style flows authored as XState setup machines', () => { const actor = createActor( machine.provide({ actors: { - outlineBook: agent.tasks.outlineBook.withExecutor( - async () => ({ title: 'The Workflow Book', chapters: ['Intro', 'Runtime'] }) - ), + outlineBook: agent.requests.outlineBook.withExecutor(async () => ({ + title: 'The Workflow Book', + chapters: ['Intro', 'Runtime'], + })), }, }), - { input: { brief: 'state machines for agents' } } + { input: { brief: 'state machines for agents' } }, ); actor.start(); await toPromise(actor); diff --git a/src/examples.test.ts b/src/examples.test.ts index 5c8535e..a23971d 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -11,15 +11,15 @@ import { summarizeTurn, } from '../examples/index.js'; import { - getAgentEffects, - type AgentTextInput, + getAgentRequests, + type AgentTextRequest, transitionResult, } from './index.js'; import { initialTransition, transition } from 'xstate'; describe('curated XState setup examples', () => { test('email drafter follows prompt, revise, send loop with normal XState runtime', async () => { - const calls: AgentTextInput[] = []; + const calls: AgentTextRequest[] = []; const sent: unknown[] = []; const machine = emailDrafter.provide({ actors: { @@ -128,7 +128,7 @@ describe('curated XState setup examples', () => { enemyHp: 15, }); - const [chooseMove] = getAgentEffects(actions, { + const [chooseMove] = getAgentRequests(actions, { snapshot, schemas: gameSchemas, actors: { chooseMove: chooseMoveLogic, summarizeTurn }, @@ -147,7 +147,7 @@ describe('curated XState setup examples', () => { const attackEvent = await attackTool.execute?.({ target: 'goblin' }); [snapshot, actions] = transition(gameMachine, snapshot, attackEvent as never); - const [summarize] = getAgentEffects(actions, { + const [summarize] = getAgentRequests(actions, { snapshot, schemas: gameSchemas, actors: { chooseMove: chooseMoveLogic, summarizeTurn }, diff --git a/src/external-equivalents/dinavinter-agents.test.ts b/src/external-equivalents/dinavinter-agents.test.ts index 53454ae..adea704 100644 --- a/src/external-equivalents/dinavinter-agents.test.ts +++ b/src/external-equivalents/dinavinter-agents.test.ts @@ -11,12 +11,12 @@ import { type EventObject, } from 'xstate'; import { - type AgentTask, + type AgentRequest, assistantMessage, createAgentSchemas, setupAgent, transitionResult, - type AgentTextInput, + type AgentTextRequest, type AgentTools, } from '../index.js'; @@ -50,25 +50,27 @@ describe('dinavinter/agents-style XState agents', () => { async ({ input }) => { calls.push({ actor: 'createThread', request: input.request }); return 'thread_123'; - } + }, + ), + sendMessage: fromPromise( + async ({ input }) => { + calls.push({ actor: 'sendMessage', input }); + return 'message_123'; + }, ), - sendMessage: fromPromise< - string, - { threadId: string; message: string } - >(async ({ input }) => { - calls.push({ actor: 'sendMessage', input }); - return 'message_123'; - }), streamThread: fromCallback( ({ input, sendBack }) => { calls.push({ actor: 'streamThread', input }); queueMicrotask(() => { sendBack({ type: 'TEXT_DELTA', text: 'using ' }); sendBack({ type: 'TEXT_DELTA', text: 'setupAgent' }); - sendBack({ type: 'IMAGE_URL', url: 'https://example.com/test.png' }); + sendBack({ + type: 'IMAGE_URL', + url: 'https://example.com/test.png', + }); sendBack({ type: 'STREAM_DONE' }); }); - } + }, ), }, }); @@ -180,7 +182,7 @@ describe('dinavinter/agents-style XState agents', () => { ]); }); - test('screen-set builder maps streamed object UI drafts to structured task output', async () => { + test('screen-set builder maps streamed object UI drafts to structured request output', async () => { const fieldSchema = z.object({ type: z.enum(['text', 'email', 'password', 'submit']), name: z.string(), @@ -198,15 +200,18 @@ describe('dinavinter/agents-style XState agents', () => { input: z.object({ request: z.string() }), output: screenDraftSchema, }); - const agent = setupAgent({ schemas }).withTasks({ - draftScreen: { - schemas: { - input: z.object({ request: z.string() }), - output: screenDraftSchema, + const agent = setupAgent({ + schemas, + requests: { + draftScreen: { + schemas: { + input: z.object({ request: z.string() }), + output: screenDraftSchema, + }, + model: 'openai/gpt-5.4-nano', + system: 'Create a form screen draft from the user request.', + prompt: ({ input }) => input.request, }, - model: 'openai/gpt-5.4-nano', - system: 'Create a form screen draft from the user request.', - prompt: ({ input }) => input.request, }, }); const machine = agent.createMachine({ @@ -226,8 +231,7 @@ describe('dinavinter/agents-style XState agents', () => { }, done: { type: 'final', - output: ({ context }) => - context.draft ?? { title: '', fields: [] }, + output: ({ context }) => context.draft ?? { title: '', fields: [] }, }, }, }); @@ -235,11 +239,15 @@ describe('dinavinter/agents-style XState agents', () => { let [snapshot, actions] = initialTransition(machine, { request: 'Build a signup wizard.', }); - const [task] = machine.getTasks(actions, snapshot); + const [request] = machine.getRequests(actions, snapshot); - const output = await machine.execute(task!, { - generateText: async (request: AgentTextInput & { tools: AgentTools }) => { - expect(request.outputSchema).toBe(agent.tasks.draftScreen.schemas.output); + const output = await machine.execute(request!, { + generateText: async ( + request: AgentTextRequest & { tools: AgentTools }, + ) => { + expect(request.outputSchema).toBe( + agent.requests.draftScreen.schemas.output, + ); expect(request.prompt).toBe('Build a signup wizard.'); return { output: { @@ -254,9 +262,9 @@ describe('dinavinter/agents-style XState agents', () => { }, }); - [snapshot, actions] = transitionResult(machine, snapshot, task!, output); + [snapshot, actions] = transitionResult(machine, snapshot, request!, output); - expect(machine.getTasks(actions, snapshot)).toEqual([]); + expect(machine.getRequests(actions, snapshot)).toEqual([]); expect(snapshot.output).toEqual({ title: 'Signup', fields: [ @@ -267,7 +275,7 @@ describe('dinavinter/agents-style XState agents', () => { }); }); - test('parallel agent runs independent model tasks as explicit XState invokes', async () => { + test('parallel agent runs independent model requests as explicit XState invokes', async () => { const resultSchema = z.object({ thought: z.string(), doodleQuery: z.string(), @@ -281,23 +289,26 @@ describe('dinavinter/agents-style XState agents', () => { input: z.object({ topic: z.string() }), output: resultSchema, }); - const agent = setupAgent({ schemas }).withTasks({ - think: { - kind: 'stream', - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), + const agent = setupAgent({ + schemas, + requests: { + think: { + mode: 'stream', + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'openai/gpt-5.4-nano', + prompt: ({ input }) => `Think about ${input.topic}.`, }, - model: 'openai/gpt-5.4-nano', - prompt: ({ input }) => `Think about ${input.topic}.`, - }, - findDoodle: { - schemas: { - input: z.object({ topic: z.string() }), - output: z.object({ query: z.string() }), + findDoodle: { + schemas: { + input: z.object({ topic: z.string() }), + output: z.object({ query: z.string() }), + }, + model: 'openai/gpt-5.4-nano', + prompt: ({ input }) => `Find a doodle for ${input.topic}.`, }, - model: 'openai/gpt-5.4-nano', - prompt: ({ input }) => `Find a doodle for ${input.topic}.`, }, }); const machine = agent.createMachine({ @@ -352,19 +363,26 @@ describe('dinavinter/agents-style XState agents', () => { }); let [snapshot, actions] = initialTransition(machine, { topic: 'XState' }); - const tasks = machine.getTasks(actions, snapshot); + const requests = machine.getRequests(actions, snapshot); - expect(tasks.map((task: AgentTask) => [task.id, task.kind])).toEqual([ + expect( + requests.map((request: AgentRequest) => [request.id, request.mode]), + ).toEqual([ ['think', 'stream'], ['findDoodle', 'generate'], ]); - for (const task of tasks) { - const output = await machine.execute(task, { + for (const request of requests) { + const output = await machine.execute(request, { generateText: async () => ({ output: { query: 'statechart sketch' } }), streamText: async () => ({ text: 'State machines make flow visible.' }), }); - [snapshot, actions] = transitionResult(machine, snapshot, task, output); + [snapshot, actions] = transitionResult( + machine, + snapshot, + request, + output, + ); } expect(snapshot.status).toBe('done'); diff --git a/src/index.ts b/src/index.ts index ad16a25..d85b37b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ export { doneEvent, EVENT_TOOL_PREFIX, getAvailableEvents, - getAgentEffects, + getAgentRequests, getEventTools, messagesSchema, parseOutput, @@ -20,10 +20,10 @@ export { } from './utils.js'; export type { - AgentEffect, - AgentEffectOptions, + AgentRequest, + AgentRequestOptions, AgentEventDescriptor, - AgentEffectSource, + AgentRequestSource, AgentMachine, AgentUserInput, AgentWorkflowActionConfig, @@ -31,17 +31,15 @@ export type { AgentWorkflowConfig, AgentWorkflowInvokeConfig, AgentWorkflowStateConfig, - AgentWorkflowTaskConfig, + AgentWorkflowRequestConfig, AgentWorkflowTransitionConfig, - AgentTask, - AgentTextInput, + AgentTextRequest, AgentSchemaPack, AgentStep, - AgentTaskConfig, - AgentTaskExecutor, - AgentTaskExecutors, - AgentTaskKind, - AgentTaskLogic, + AgentRequestExecutor, + AgentRequestExecutors, + AgentRequestMode, + AgentRequestLogic, TextLogic, TextLogicConfig, TextLogicExecuteArgs, diff --git a/src/langgraph-equivalents/raw-xstate.test.ts b/src/langgraph-equivalents/raw-xstate.test.ts index 4651f8e..c16aeac 100644 --- a/src/langgraph-equivalents/raw-xstate.test.ts +++ b/src/langgraph-equivalents/raw-xstate.test.ts @@ -12,14 +12,15 @@ describe('LangGraph-style workflows authored as raw XState', () => { }), input: z.object({ request: z.string() }), output: z.object({ route: z.enum(['answer', 'escalate']) }), - }).withTasks({ - routeRequest: { - schemas: { - input: z.object({ request: z.string() }), - output: z.object({ route: z.enum(['answer', 'escalate']) }), - }, - model: 'classifier', - prompt: ({ input }) => input.request, + requests: { + routeRequest: { + schemas: { + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['answer', 'escalate']) }), + }, + model: 'classifier', + prompt: ({ input }) => input.request, + }, }, }); @@ -40,24 +41,33 @@ describe('LangGraph-style workflows authored as raw XState', () => { }, routing: { always: [ - { guard: ({ context }) => context.route === 'escalate', target: 'escalated' }, + { + guard: ({ context }) => context.route === 'escalate', + target: 'escalated', + }, { target: 'answered' }, ], }, - answered: { type: 'final', output: () => ({ route: 'answer' as const }) }, - escalated: { type: 'final', output: () => ({ route: 'escalate' as const }) }, + answered: { + type: 'final', + output: () => ({ route: 'answer' as const }), + }, + escalated: { + type: 'final', + output: () => ({ route: 'escalate' as const }), + }, }, }); const actor = createActor( machine.provide({ actors: { - routeRequest: agent.tasks.routeRequest.withExecutor( - async () => ({ route: 'escalate' }) - ), + routeRequest: agent.requests.routeRequest.withExecutor(async () => ({ + route: 'escalate', + })), }, }), - { input: { request: 'billing is broken' } } + { input: { request: 'billing is broken' } }, ); actor.start(); @@ -78,14 +88,15 @@ describe('LangGraph-style workflows authored as raw XState', () => { APPROVE: z.object({}), REJECT: z.object({ reason: z.string() }), }, - }).withTasks({ - writeDraft: { - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), - }, - model: 'writer', - prompt: ({ input }) => input.topic, + requests: { + writeDraft: { + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => input.topic, + }, }, }); @@ -131,12 +142,12 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - writeDraft: agent.tasks.writeDraft.withExecutor( - async ({ input }) => `Draft: ${input.topic}` + writeDraft: agent.requests.writeDraft.withExecutor( + async ({ input }) => `Draft: ${input.topic}`, ), }, }), - { input: { topic: 'release notes' } } + { input: { topic: 'release notes' } }, ); actor.start(); @@ -154,40 +165,44 @@ describe('LangGraph-style workflows authored as raw XState', () => { const planSchema = z.object({ steps: z.array(z.string()), }); - const agent = setupAgent({ context: z.object({ - task: z.string(), + request: z.string(), steps: z.array(z.string()), results: z.array(z.string()), }), - input: z.object({ task: z.string() }), + input: z.object({ request: z.string() }), output: z.object({ results: z.array(z.string()) }), actors: { runStep: fromPromise( - async ({ input }) => `done:${input.step}` + async ({ input }) => `done:${input.step}`, ), }, - }).withTasks({ - planTask: { - schemas: { - input: z.object({ task: z.string() }), - output: planSchema, - }, - model: 'planner', - prompt: ({ input }) => input.task, + requests: { + planTask: { + schemas: { + input: z.object({ request: z.string() }), + output: planSchema, + }, + model: 'planner', + prompt: ({ input }) => input.request, + }, }, }); const machine = agent.createMachine({ id: 'raw-xstate-plan-and-execute', - context: ({ input }) => ({ task: input.task, steps: [], results: [] }), + context: ({ input }) => ({ + request: input.request, + steps: [], + results: [], + }), initial: 'planning', states: { planning: { invoke: { src: 'planTask', - input: ({ context }) => ({ task: context.task }), + input: ({ context }) => ({ request: context.request }), onDone: { target: 'running', actions: assign({ steps: ({ event }) => event.output.steps }), @@ -202,14 +217,20 @@ describe('LangGraph-style workflows authored as raw XState', () => { target: 'checking', actions: assign({ steps: ({ context }) => context.steps.slice(1), - results: ({ context, event }) => [...context.results, event.output], + results: ({ context, event }) => [ + ...context.results, + event.output, + ], }), }, }, }, checking: { always: [ - { guard: ({ context }) => context.steps.length > 0, target: 'running' }, + { + guard: ({ context }) => context.steps.length > 0, + target: 'running', + }, { target: 'done' }, ], }, @@ -223,12 +244,12 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - planTask: agent.tasks.planTask.withExecutor( - async () => ({ steps: ['research', 'write'] }) - ), + planTask: agent.requests.planTask.withExecutor(async () => ({ + steps: ['research', 'write'], + })), }, }), - { input: { task: 'make a brief' } } + { input: { request: 'make a brief' } }, ); actor.start(); @@ -303,14 +324,15 @@ describe('LangGraph-style workflows authored as raw XState', () => { events: { APPROVE: z.object({}), }, - }).withTasks({ - writeDraft: { - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), - }, - model: 'writer', - prompt: ({ input }) => input.topic, + requests: { + writeDraft: { + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => input.topic, + }, }, }); @@ -342,8 +364,8 @@ describe('LangGraph-style workflows authored as raw XState', () => { }); const actors = { - writeDraft: agent.tasks.writeDraft.withExecutor( - async ({ input }) => `Draft: ${input.topic}` + writeDraft: agent.requests.writeDraft.withExecutor( + async ({ input }) => `Draft: ${input.topic}`, ), }; const first = createActor(machine.provide({ actors }), { @@ -373,14 +395,15 @@ describe('LangGraph-style workflows authored as raw XState', () => { context: z.object({ topic: z.string(), research: z.string().nullable() }), input: z.object({ topic: z.string() }), output: z.object({ research: z.string() }), - }).withTasks({ - researchTopic: { - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), - }, - model: 'researcher', - prompt: ({ input }) => input.topic, + requests: { + researchTopic: { + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'researcher', + prompt: ({ input }) => input.topic, + }, }, }); const childMachine = childAgent.createMachine({ @@ -443,19 +466,21 @@ describe('LangGraph-style workflows authored as raw XState', () => { actors: { child: childMachine.provide({ actors: { - researchTopic: childAgent.tasks.researchTopic.withExecutor( - async ({ input }) => `Research: ${input.topic}` + researchTopic: childAgent.requests.researchTopic.withExecutor( + async ({ input }) => `Research: ${input.topic}`, ), }, }), }, }), - { input: { topic: 'agents' } } + { input: { topic: 'agents' } }, ); actor.start(); await toPromise(actor); - expect(actor.getSnapshot().output).toEqual({ research: 'Research: agents' }); + expect(actor.getSnapshot().output).toEqual({ + research: 'Research: agents', + }); }); test('supervisor handoff is explicit typed routing', async () => { @@ -469,20 +494,21 @@ describe('LangGraph-style workflows authored as raw XState', () => { output: z.object({ result: z.string() }), actors: { research: fromPromise( - async ({ input }) => `research:${input.request}` + async ({ input }) => `research:${input.request}`, ), write: fromPromise( - async ({ input }) => `write:${input.request}` + async ({ input }) => `write:${input.request}`, ), }, - }).withTasks({ - routeRequest: { - schemas: { - input: z.object({ request: z.string() }), - output: z.object({ route: z.enum(['research', 'write']) }), - }, - model: 'router', - prompt: ({ input }) => input.request, + requests: { + routeRequest: { + schemas: { + input: z.object({ request: z.string() }), + output: z.object({ route: z.enum(['research', 'write']) }), + }, + model: 'router', + prompt: ({ input }) => input.request, + }, }, }); @@ -507,7 +533,10 @@ describe('LangGraph-style workflows authored as raw XState', () => { }, dispatch: { always: [ - { guard: ({ context }) => context.route === 'research', target: 'researching' }, + { + guard: ({ context }) => context.route === 'research', + target: 'researching', + }, { target: 'writing' }, ], }, @@ -541,12 +570,12 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - routeRequest: agent.tasks.routeRequest.withExecutor( - async () => ({ route: 'research' }) - ), + routeRequest: agent.requests.routeRequest.withExecutor(async () => ({ + route: 'research', + })), }, }), - { input: { request: 'compare frameworks' } } + { input: { request: 'compare frameworks' } }, ); actor.start(); await toPromise(actor); @@ -569,18 +598,21 @@ describe('LangGraph-style workflows authored as raw XState', () => { summarizeAll: fromPromise( async ({ input }) => Promise.all( - input.sections.map(async (section: string) => `summary:${section}`) - ) + input.sections.map( + async (section: string) => `summary:${section}`, + ), + ), ), }, - }).withTasks({ - reduceSummaries: { - schemas: { - input: z.object({ summaries: z.array(z.string()) }), - output: z.string(), - }, - model: 'reducer', - prompt: ({ input }) => input.summaries.join('\n'), + requests: { + reduceSummaries: { + schemas: { + input: z.object({ summaries: z.array(z.string()) }), + output: z.string(), + }, + model: 'reducer', + prompt: ({ input }) => input.summaries.join('\n'), + }, }, }); @@ -623,12 +655,12 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - reduceSummaries: agent.tasks.reduceSummaries.withExecutor( - async ({ input }) => `reduced:${input.summaries.join('\n')}` + reduceSummaries: agent.requests.reduceSummaries.withExecutor( + async ({ input }) => `reduced:${input.summaries.join('\n')}`, ), }, }), - { input: { sections: ['a', 'b'] } } + { input: { sections: ['a', 'b'] } }, ); actor.start(); await toPromise(actor); @@ -649,20 +681,22 @@ describe('LangGraph-style workflows authored as raw XState', () => { output: z.object({ answer: z.string() }), actors: { retrieve: fromPromise( - async ({ input }) => [`doc:${input.question}`, 'doc:typed state'] + async ({ input }) => [`doc:${input.question}`, 'doc:typed state'], ), }, - }).withTasks({ - answerQuestion: { - schemas: { - input: z.object({ - question: z.string(), - documents: z.array(z.string()), - }), - output: z.string(), + requests: { + answerQuestion: { + schemas: { + input: z.object({ + question: z.string(), + documents: z.array(z.string()), + }), + output: z.string(), + }, + model: 'answerer', + prompt: ({ input }) => + `Q: ${input.question}\nDocs:\n${input.documents.join('\n')}`, }, - model: 'answerer', - prompt: ({ input }) => `Q: ${input.question}\nDocs:\n${input.documents.join('\n')}`, }, }); @@ -708,13 +742,13 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - answerQuestion: agent.tasks.answerQuestion.withExecutor( + answerQuestion: agent.requests.answerQuestion.withExecutor( async ({ input }) => - `answer from Q: ${input.question}\nDocs:\n${input.documents.join('\n')}` + `answer from Q: ${input.question}\nDocs:\n${input.documents.join('\n')}`, ), }, }), - { input: { question: 'why xstate agents?' } } + { input: { question: 'why xstate agents?' } }, ); actor.start(); await toPromise(actor); @@ -722,7 +756,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { expect(actor.getSnapshot().output).toEqual( expect.objectContaining({ answer: expect.stringContaining('doc:typed state'), - }) + }), ); }); @@ -741,28 +775,29 @@ describe('LangGraph-style workflows authored as raw XState', () => { }), input: z.object({ prompt: z.string() }), output: z.object({ draft: z.string() }), - }).withTasks({ - writeDraft: { - schemas: { - input: z.object({ - prompt: z.string(), - feedback: z.string().nullable(), - }), - output: z.string(), - }, - model: 'writer', - prompt: ({ input }) => - input.feedback - ? `${input.prompt}\nRevise: ${input.feedback}` - : input.prompt, - }, - critiqueDraft: { - schemas: { - input: z.object({ draft: z.string() }), - output: critiqueSchema, + requests: { + writeDraft: { + schemas: { + input: z.object({ + prompt: z.string(), + feedback: z.string().nullable(), + }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => + input.feedback + ? `${input.prompt}\nRevise: ${input.feedback}` + : input.prompt, + }, + critiqueDraft: { + schemas: { + input: z.object({ draft: z.string() }), + output: critiqueSchema, + }, + model: 'critic', + prompt: ({ input }) => input.draft, }, - model: 'critic', - prompt: ({ input }) => input.draft, }, }); @@ -818,25 +853,24 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - writeDraft: agent.tasks.writeDraft.withExecutor( - async ({ input }) => `draft:${ - input.feedback - ? `${input.prompt}\nRevise: ${input.feedback}` - : input.prompt - }` - ), - critiqueDraft: agent.tasks.critiqueDraft.withExecutor( - async () => { - critiqueCount += 1; - return { - approved: critiqueCount > 1, - feedback: critiqueCount > 1 ? 'ship' : 'add evidence', - }; - } + writeDraft: agent.requests.writeDraft.withExecutor( + async ({ input }) => + `draft:${ + input.feedback + ? `${input.prompt}\nRevise: ${input.feedback}` + : input.prompt + }`, ), + critiqueDraft: agent.requests.critiqueDraft.withExecutor(async () => { + critiqueCount += 1; + return { + approved: critiqueCount > 1, + feedback: critiqueCount > 1 ? 'ship' : 'add evidence', + }; + }), }, }), - { input: { prompt: 'make the case' } } + { input: { prompt: 'make the case' } }, ); actor.start(); await toPromise(actor); @@ -851,14 +885,14 @@ describe('LangGraph-style workflows authored as raw XState', () => { steps: z.array( z.object({ id: z.string(), - task: z.string(), - }) + request: z.string(), + }), ), }); const agent = setupAgent({ context: z.object({ goal: z.string(), - steps: z.array(z.object({ id: z.string(), task: z.string() })), + steps: z.array(z.object({ id: z.string(), request: z.string() })), evidence: z.record(z.string(), z.string()), answer: z.string().nullable(), }), @@ -870,32 +904,33 @@ describe('LangGraph-style workflows authored as raw XState', () => { actors: { executePlan: fromPromise< Record, - { steps: Array<{ id: string; task: string }> } + { steps: Array<{ id: string; request: string }> } >(async ({ input }) => Object.fromEntries( - input.steps.map((step: { id: string; task: string }) => [ + input.steps.map((step: { id: string; request: string }) => [ step.id, - `result:${step.task}`, - ]) - ) + `result:${step.request}`, + ]), + ), ), }, - }).withTasks({ - planWork: { - schemas: { - input: z.object({ goal: z.string() }), - output: planSchema, - }, - model: 'planner', - prompt: ({ input }) => input.goal, - }, - solveWork: { - schemas: { - input: z.object({ evidence: z.record(z.string(), z.string()) }), - output: z.string(), + requests: { + planWork: { + schemas: { + input: z.object({ goal: z.string() }), + output: planSchema, + }, + model: 'planner', + prompt: ({ input }) => input.goal, + }, + solveWork: { + schemas: { + input: z.object({ evidence: z.record(z.string(), z.string()) }), + output: z.string(), + }, + model: 'solver', + prompt: ({ input }) => JSON.stringify(input.evidence), }, - model: 'solver', - prompt: ({ input }) => JSON.stringify(input.evidence), }, }); @@ -952,15 +987,15 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - planWork: agent.tasks.planWork.withExecutor( - async ({ input }) => ({ steps: [{ id: 'E1', task: input.goal }] }) - ), - solveWork: agent.tasks.solveWork.withExecutor( - async ({ input }) => `answer:${JSON.stringify(input.evidence)}` + planWork: agent.requests.planWork.withExecutor(async ({ input }) => ({ + steps: [{ id: 'E1', request: input.goal }], + })), + solveWork: agent.requests.solveWork.withExecutor( + async ({ input }) => `answer:${JSON.stringify(input.evidence)}`, ), }, }), - { input: { goal: 'compare tools' } } + { input: { goal: 'compare tools' } }, ); actor.start(); await toPromise(actor); @@ -969,7 +1004,7 @@ describe('LangGraph-style workflows authored as raw XState', () => { answer: 'answer:{"E1":"result:compare tools"}', evidence: { E1: 'result:compare tools' }, }); - }); + }); test('SQL-style agents keep query generation, execution, and answer synthesis explicit', async () => { const querySchema = z.object({ sql: z.string() }); @@ -988,22 +1023,25 @@ describe('LangGraph-style workflows authored as raw XState', () => { { sql: string } >(async ({ input }) => [{ total: '42', sql: input.sql }]), }, - }).withTasks({ - writeQuery: { - schemas: { - input: z.object({ question: z.string() }), - output: querySchema, - }, - model: 'sql-writer', - prompt: ({ input }) => input.question, - }, - answerRows: { - schemas: { - input: z.object({ rows: z.array(z.record(z.string(), z.string())) }), - output: z.string(), + requests: { + writeQuery: { + schemas: { + input: z.object({ question: z.string() }), + output: querySchema, + }, + model: 'sql-writer', + prompt: ({ input }) => input.question, + }, + answerRows: { + schemas: { + input: z.object({ + rows: z.array(z.record(z.string(), z.string())), + }), + output: z.string(), + }, + model: 'answerer', + prompt: ({ input }) => JSON.stringify(input.rows), }, - model: 'answerer', - prompt: ({ input }) => JSON.stringify(input.rows), }, }); @@ -1060,22 +1098,23 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - writeQuery: agent.tasks.writeQuery.withExecutor( - async () => ({ sql: 'select count(*) as total from users' }) - ), - answerRows: agent.tasks.answerRows.withExecutor( - async ({ input }) => `final:${JSON.stringify(input.rows)}` + writeQuery: agent.requests.writeQuery.withExecutor(async () => ({ + sql: 'select count(*) as total from users', + })), + answerRows: agent.requests.answerRows.withExecutor( + async ({ input }) => `final:${JSON.stringify(input.rows)}`, ), }, }), - { input: { question: 'how many users?' } } + { input: { question: 'how many users?' } }, ); actor.start(); await toPromise(actor); expect(actor.getSnapshot().output).toEqual({ sql: 'select count(*) as total from users', - answer: 'final:[{"total":"42","sql":"select count(*) as total from users"}]', + answer: + 'final:[{"total":"42","sql":"select count(*) as total from users"}]', }); }); @@ -1093,10 +1132,10 @@ describe('LangGraph-style workflows authored as raw XState', () => { }, actors: { research: fromPromise( - async ({ input }) => `research:${input.topic}` + async ({ input }) => `research:${input.topic}`, ), write: fromPromise( - async ({ input }) => `draft:${input.research}` + async ({ input }) => `draft:${input.research}`, ), }, }); @@ -1165,15 +1204,16 @@ describe('LangGraph-style workflows authored as raw XState', () => { context: z.object({ topic: z.string(), text: z.string().nullable() }), input: z.object({ topic: z.string() }), output: z.object({ text: z.string() }), - }).withTasks({ - streamTopic: { - kind: 'stream', - schemas: { - input: z.object({ topic: z.string() }), - output: z.string(), - }, - model: 'writer', - prompt: ({ input }) => input.topic, + requests: { + streamTopic: { + mode: 'stream', + schemas: { + input: z.object({ topic: z.string() }), + output: z.string(), + }, + model: 'writer', + prompt: ({ input }) => input.topic, + }, }, }); @@ -1202,16 +1242,16 @@ describe('LangGraph-style workflows authored as raw XState', () => { const actor = createActor( machine.provide({ actors: { - streamTopic: agent.tasks.streamTopic.withExecutor( + streamTopic: agent.requests.streamTopic.withExecutor( async ({ input }) => { chunks.push('hello'); chunks.push(input.topic); return chunks.join(' '); - } + }, ), }, }), - { input: { topic: 'agents' } } + { input: { topic: 'agents' } }, ); actor.start(); await toPromise(actor); diff --git a/src/oracle-equivalents/agent-spec-config.test.ts b/src/oracle-equivalents/agent-spec-config.test.ts index 96dbe09..10f0df2 100644 --- a/src/oracle-equivalents/agent-spec-config.test.ts +++ b/src/oracle-equivalents/agent-spec-config.test.ts @@ -129,7 +129,7 @@ describe('Oracle Agent Spec-style static workflows', () => { expect(nestedStep.snapshot.output).toEqual({ result: 'yes' }); }); - test('adapts sequential LLM and tool nodes to invoked tasks and host actors', async () => { + test('adapts sequential LLM and tool nodes to invoked requests and host actors', async () => { // Adapted from Oracle Agent Spec // pyagentspec/tests/agentspec_configs/example_serialized_flow.yaml // Oracle Agent Spec is distributed under Apache-2.0 or UPL-1.0. @@ -153,7 +153,7 @@ describe('Oracle Agent Spec-style static workflows', () => { }, }, context: {}, - tasks: { + requests: { node12: { model: 'agi_model1', prompt: 'something something', diff --git a/src/setup-agent.test.ts b/src/setup-agent.test.ts index cc89c46..6e389f2 100644 --- a/src/setup-agent.test.ts +++ b/src/setup-agent.test.ts @@ -1,23 +1,30 @@ import { describe, expect, test } from 'vitest'; import { z } from 'zod'; -import { assign, createActor, fromPromise, initialTransition, waitFor } from 'xstate'; +import { + assign, + createActor, + fromPromise, + initialTransition, + waitFor, +} from 'xstate'; import { createAgentSchemas, createTextLogic, getAvailableEvents, - getAgentEffects, + getAgentRequests, getEventTools, messagesSchema, parseOutput, setupAgent, transitionResult, userMessage, - type AgentTextInput, + type AgentTextRequest, type AgentTools, + type AgentEventDescriptor, } from './index.js'; describe('setupAgent', () => { - test('withTasks creates typed task actors from schemas', () => { + test('setupAgent accepts schema-bound request configs', async () => { const schemas = createAgentSchemas({ context: z.object({ prompt: z.string(), @@ -31,79 +38,82 @@ describe('setupAgent', () => { }, }); - const agent = setupAgent({ schemas }).withTasks({ - draftEmail: { - kind: 'generate', - schemas: { - input: z.object({ prompt: z.string() }), - output: z.object({ body: z.string() }), - }, - model: 'test-model', - prompt: ({ input }) => input.prompt, - events: ({ input, schemas }) => { - const prompt: string = input.prompt; - schemas.events.READY_TO_DRAFT; - // @ts-expect-error task events input is typed from schemas.input - input.body; - return prompt.length > 0 ? ['READY_TO_DRAFT'] : []; + const agent = setupAgent({ + schemas, + requests: { + draftEmail: { + mode: 'generate', + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + events: ({ input, schemas }) => { + const prompt: string = input.prompt; + schemas.events.READY_TO_DRAFT; + // @ts-expect-error request events input is typed from schemas.input + input.body; + return prompt.length > 0 ? ['READY_TO_DRAFT'] : []; + }, }, - }, - streamRevision: { - kind: 'stream', - schemas: { - input: z.object({ body: z.string() }), - output: z.object({ body: z.string() }), + streamRevision: { + mode: 'stream', + schemas: { + input: z.object({ body: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.body, }, - model: 'test-model', - prompt: ({ input }) => input.body, }, }); - expect(agent.tasks.draftEmail.taskKind).toBe('generate'); - expect(agent.tasks.draftEmail.request({ prompt: 'Draft it.' })).toEqual( - expect.objectContaining({ - model: 'test-model', - prompt: 'Draft it.', - eventTypes: ['READY_TO_DRAFT'], - }) - ); - - setupAgent({ schemas }).withTasks({ - badKind: { - // @ts-expect-error task kind is constrained - kind: 'foo', - schemas: { - input: z.object({ prompt: z.string() }), - output: z.object({ body: z.string() }), + setupAgent({ + schemas, + requests: { + badKind: { + // @ts-expect-error request mode is constrained + mode: 'foo', + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, }, - model: 'test-model', - prompt: ({ input }) => input.prompt, }, }); - setupAgent({ schemas }).withTasks({ - badEvent: { - schemas: { - input: z.object({ prompt: z.string() }), - output: z.object({ body: z.string() }), + setupAgent({ + schemas, + requests: { + badEvent: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + // @ts-expect-error events are keyed by machine event schemas + events: ['DRAT_EMAIL_TYPO'], }, - model: 'test-model', - prompt: ({ input }) => input.prompt, - // @ts-expect-error events are keyed by machine event schemas - events: ['DRAT_EMAIL_TYPO'], }, }); - setupAgent({ schemas }).withTasks({ - badEventTypes: { - schemas: { - input: z.object({ prompt: z.string() }), - output: z.object({ body: z.string() }), + setupAgent({ + schemas, + requests: { + badEventTypes: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + // @ts-expect-error use request events, not raw text logic eventTypes + eventTypes: ['READY_TO_DRAFT'], }, - model: 'test-model', - prompt: ({ input }) => input.prompt, - // @ts-expect-error use task events, not raw text logic eventTypes - eventTypes: ['READY_TO_DRAFT'], }, }); @@ -112,7 +122,7 @@ describe('setupAgent', () => { initial: 'drafting', states: { drafting: { - // @ts-expect-error task source ids are strongly typed + // @ts-expect-error request source ids are strongly typed invoke: { src: 'dratemaltypo', input: { prompt: 'Draft it.' }, @@ -128,7 +138,7 @@ describe('setupAgent', () => { drafting: { invoke: { src: 'draftEmail', - // @ts-expect-error task input is schema-typed + // @ts-expect-error request input is schema-typed input: { whoopsanything: 42 }, }, }, @@ -162,44 +172,66 @@ describe('setupAgent', () => { const [_snapshot, actions] = initialTransition(machine, { prompt: 'Draft it.', }); - const [effect] = getAgentEffects(actions, { - actors: agent.tasks, - }); + const [request] = machine.getRequests(actions); + + expect(agent.requests.draftEmail.mode).toBe('generate'); + expect(agent.requests.draftEmail.request({ prompt: 'Draft it.' })).toEqual( + expect.objectContaining({ + model: 'test-model', + prompt: 'Draft it.', + eventTypes: ['READY_TO_DRAFT'], + }), + ); - expect(effect).toEqual( + expect(request).toEqual( expect.objectContaining({ - kind: 'generate', + mode: 'generate', input: expect.objectContaining({ eventTypes: ['READY_TO_DRAFT'] }), - }) + }), ); - expect(machine.getTasks(actions)).toEqual([effect]); + expect(machine.getRequests(actions)).toEqual([request]); + + await expect( + agent.requests.draftEmail.execute( + { prompt: 'Draft it.' }, + { + generateText: async (request) => { + expect(request.prompt).toBe('Draft it.'); + expect(request.eventTypes).toEqual(['READY_TO_DRAFT']); + return { output: { body: 'Standalone body.' } }; + }, + }, + ), + ).resolves.toEqual({ body: 'Standalone body.' }); }); - test('agent machines execute generated and streamed tasks with host callbacks', async () => { + test('agent machines execute generated and streamed requests with host callbacks', async () => { const schemas = createAgentSchemas({ context: z.object({ prompt: z.string(), body: z.string().nullable() }), input: z.object({ prompt: z.string() }), output: z.object({ body: z.string() }), }); - - const agent = setupAgent({ schemas }).withTasks({ - draftEmail: { - schemas: { - input: z.object({ prompt: z.string() }), - output: z.object({ body: z.string() }), + const agent = setupAgent({ + schemas, + requests: { + draftEmail: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ body: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, }, - model: 'test-model', - prompt: ({ input }) => input.prompt, - }, - streamRevision: { - kind: 'stream', - schemas: { - input: z.object({ body: z.string() }), - output: z.string(), + streamRevision: { + mode: 'stream', + schemas: { + input: z.object({ body: z.string() }), + output: z.string(), + }, + model: 'test-model', + prompt: ({ input }) => input.body, }, - model: 'test-model', - prompt: ({ input }) => input.body, }, }); @@ -218,24 +250,26 @@ describe('setupAgent', () => { }); const [_generateSnapshot, generateActions] = initialTransition( generateMachine, - { prompt: 'Draft it.' } + { prompt: 'Draft it.' }, ); - const [generateTask] = generateMachine.getTasks(generateActions); + const [generateTask] = generateMachine.getRequests(generateActions); await expect( generateMachine.execute(generateTask!, { - generateText: async (request: AgentTextInput & { tools: AgentTools }) => { + generateText: async ( + request: AgentTextRequest & { tools: AgentTools }, + ) => { expect(request).toEqual( expect.objectContaining({ model: 'test-model', prompt: 'Draft it.', - outputSchema: agent.tasks.draftEmail.schemas.output, + outputSchema: agent.requests.draftEmail.schemas.output, tools: {}, - }) + }), ); return { output: { body: 'Generated body.' } }; }, - }) + }), ).resolves.toEqual({ body: 'Generated body.' }); const streamMachine = agent.createMachine({ @@ -254,18 +288,20 @@ describe('setupAgent', () => { const [_streamSnapshot, streamActions] = initialTransition(streamMachine, { prompt: 'Revise it.', }); - const [streamTask] = streamMachine.getTasks(streamActions); + const [streamTask] = streamMachine.getRequests(streamActions); await expect( streamMachine.execute(streamTask!, { generateText: async () => { - throw new Error('streamText should be used for stream tasks'); + throw new Error('streamText should be used for stream requests'); }, - streamText: async (request: AgentTextInput & { tools: AgentTools }) => { + streamText: async ( + request: AgentTextRequest & { tools: AgentTools }, + ) => { expect(request.prompt).toBe('Draft body.'); return { text: Promise.resolve('Streamed final text.') }; }, - }) + }), ).resolves.toBe('Streamed final text.'); }); @@ -305,7 +341,8 @@ describe('setupAgent', () => { onDone: { target: 'streaming', actions: assign({ - answer: ({ event }) => parseOutput(answerSchema, event.output).answer, + answer: ({ event }) => + parseOutput(answerSchema, event.output).answer, }), }, }, @@ -337,9 +374,9 @@ describe('setupAgent', () => { }); let step = machine.initial({ prompt: 'Why machines?' }); - expect(step.tasks).toEqual([ + expect(step.requests).toEqual([ expect.objectContaining({ - kind: 'generate', + mode: 'generate', id: 'answer', src: 'agent.generateText', input: expect.objectContaining({ @@ -351,17 +388,19 @@ describe('setupAgent', () => { }), ]); - const answer = await machine.execute(step.tasks[0]!, { - generateText: async (request: AgentTextInput & { tools: AgentTools }) => { + const answer = await machine.execute(step.requests[0]!, { + generateText: async ( + request: AgentTextRequest & { tools: AgentTools }, + ) => { expect(request.tools).toEqual({}); return { output: { answer: `Answered ${request.prompt}` } }; }, }); - step = machine.resolve(step, step.tasks[0]!, answer); + step = machine.resolve(step, step.requests[0]!, answer); - expect(step.tasks).toEqual([ + expect(step.requests).toEqual([ expect.objectContaining({ - kind: 'stream', + mode: 'stream', id: 'stream', src: 'agent.streamText', input: expect.objectContaining({ @@ -371,15 +410,17 @@ describe('setupAgent', () => { }), ]); - const streamed = await machine.execute(step.tasks[0]!, { + const streamed = await machine.execute(step.requests[0]!, { generateText: async () => { - throw new Error('generateText should not be used for stream tasks'); + throw new Error('generateText should not be used for stream requests'); }, - streamText: async (request: AgentTextInput & { tools: AgentTools }) => ({ + streamText: async ( + request: AgentTextRequest & { tools: AgentTools }, + ) => ({ text: `Streamed ${request.prompt}`, }), }); - step = machine.resolve(step, step.tasks[0]!, streamed); + step = machine.resolve(step, step.requests[0]!, streamed); expect(step.done).toBe(true); expect(step.snapshot.output).toEqual({ @@ -392,14 +433,15 @@ describe('setupAgent', () => { const agent = setupAgent({ context: z.object({ prompt: z.string() }), input: z.object({ prompt: z.string() }), - }).withTasks({ - answer: { - schemas: { - input: z.object({ prompt: z.string() }), - output: z.object({ answer: z.string() }), + requests: { + answer: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ answer: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, }, - model: 'test-model', - prompt: ({ input }) => input.prompt, }, }); @@ -419,23 +461,24 @@ describe('setupAgent', () => { const provided = machine.provide({ actors: {} }); const step = provided.initial({ prompt: 'hello' }); - expect(provided.getTasks(step.actions, step.snapshot)).toHaveLength(1); + expect(provided.getRequests(step.actions, step.snapshot)).toHaveLength(1); expect(typeof provided.execute).toBe('function'); expect(typeof provided.resolve).toBe('function'); }); - test('agent machine step execution validates task output schemas', async () => { + test('agent machine step execution validates request output schemas', async () => { const agent = setupAgent({ context: z.object({ prompt: z.string() }), input: z.object({ prompt: z.string() }), - }).withTasks({ - answer: { - schemas: { - input: z.object({ prompt: z.string() }), - output: z.object({ answer: z.string() }), + requests: { + answer: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.object({ answer: z.string() }), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, }, - model: 'test-model', - prompt: ({ input }) => input.prompt, }, }); @@ -455,9 +498,9 @@ describe('setupAgent', () => { const step = machine.initial({ prompt: 'hello' }); await expect( - machine.execute(step.tasks[0]!, { + machine.execute(step.requests[0]!, { generateText: () => ({ answer: 123 }), - }) + }), ).rejects.toThrow('expected string'); }); @@ -538,7 +581,6 @@ describe('setupAgent', () => { done: { type: 'final' }, }, }); - }); test('appendMessages creates a typed action for message context', async () => { @@ -580,7 +622,7 @@ describe('setupAgent', () => { test('authors reusable text actors with typed input and output', async () => { const getSummary = createTextLogic({ - kind: 'generate', + mode: 'generate', schemas: { input: z.object({ article: z.string() }), output: z.object({ summary: z.string() }), @@ -588,7 +630,7 @@ describe('setupAgent', () => { model: 'test-model', system: 'Summarize articles.', prompt: ({ input }) => `Summarize:\n${input.article}`, - temperature: ({ input }) => input.article.length > 10 ? 0.2 : 0, + temperature: ({ input }) => (input.article.length > 10 ? 0.2 : 0), }); const agent = setupAgent({ context: z.object({ @@ -609,7 +651,7 @@ describe('setupAgent', () => { prompt: 'Summarize:\nA long article.', outputSchema: getSummary.schemas.output, temperature: 0.2, - }) + }), ); agent.createMachine({ @@ -671,14 +713,14 @@ describe('setupAgent', () => { let [snapshot, actions] = initialTransition(machine, { article: 'State machines make agents inspectable.', }); - const [effect] = getAgentEffects(actions, { + const [request] = getAgentRequests(actions, { actors: { getSummary }, }); - expect(effect).toEqual({ + expect(request).toEqual({ id: 'getSummary', src: 'getSummary', - kind: 'generate', + mode: 'generate', input: expect.objectContaining({ model: 'test-model', system: 'Summarize articles.', @@ -689,25 +731,32 @@ describe('setupAgent', () => { events: [], }); - [snapshot] = transitionResult(machine, snapshot, effect!, { + [snapshot] = transitionResult(machine, snapshot, request!, { summary: 'Agents become inspectable.', }); expect(snapshot.status).toBe('done'); expect(snapshot.output).toEqual({ summary: 'Agents become inspectable.' }); - await expect(getSummary.execute({ article: 'A long article.' }, { - generateText: async (request: AgentTextInput & { tools: AgentTools }) => { - expect(request.prompt).toBe('Summarize:\nA long article.'); - expect(request.tools).toEqual({}); - return { output: { summary: 'Standalone summary.' } }; - }, - })).resolves.toEqual({ summary: 'Standalone summary.' }); + await expect( + getSummary.execute( + { article: 'A long article.' }, + { + generateText: async ( + request: AgentTextRequest & { tools: AgentTools }, + ) => { + expect(request.prompt).toBe('Summarize:\nA long article.'); + expect(request.tools).toEqual({}); + return { output: { summary: 'Standalone summary.' } }; + }, + }, + ), + ).resolves.toEqual({ summary: 'Standalone summary.' }); }); test('reusable stream text actors execute with streamText', async () => { const streamSummary = createTextLogic({ - kind: 'stream', + mode: 'stream', schemas: { input: z.object({ article: z.string() }), output: z.string(), @@ -735,30 +784,43 @@ describe('setupAgent', () => { }); const step = machine.initial({ article: 'State machines.' }); - expect(step.tasks[0]).toEqual(expect.objectContaining({ - kind: 'stream', - src: 'streamSummary', - })); - await expect(machine.execute(step.tasks[0]!, { - generateText: async () => { - throw new Error('generateText should not be used'); - }, - streamText: async (request: AgentTextInput & { tools: AgentTools }) => { - expect(request.prompt).toBe('Stream:\nState machines.'); - return { text: 'streamed summary' }; - }, - })).resolves.toBe('streamed summary'); + expect(step.requests[0]).toEqual( + expect.objectContaining({ + mode: 'stream', + src: 'streamSummary', + }), + ); + await expect( + machine.execute(step.requests[0]!, { + generateText: async () => { + throw new Error('generateText should not be used'); + }, + streamText: async ( + request: AgentTextRequest & { tools: AgentTools }, + ) => { + expect(request.prompt).toBe('Stream:\nState machines.'); + return { text: 'streamed summary' }; + }, + }), + ).resolves.toBe('streamed summary'); - await expect(streamSummary.execute({ article: 'State machines.' }, { - generateText: async () => { - throw new Error('generateText should not be used'); - }, - streamText: async (request: AgentTextInput & { tools: AgentTools }) => { - expect(request.prompt).toBe('Stream:\nState machines.'); - expect(request.tools).toEqual({}); - return { text: 'standalone stream' }; - }, - })).resolves.toBe('standalone stream'); + await expect( + streamSummary.execute( + { article: 'State machines.' }, + { + generateText: async () => { + throw new Error('generateText should not be used'); + }, + streamText: async ( + request: AgentTextRequest & { tools: AgentTools }, + ) => { + expect(request.prompt).toBe('Stream:\nState machines.'); + expect(request.tools).toEqual({}); + return { text: 'standalone stream' }; + }, + }, + ), + ).resolves.toBe('standalone stream'); }); test('named text logic can optionally execute as a promise actor', async () => { @@ -776,7 +838,7 @@ describe('setupAgent', () => { return { answer: `${request.model}:${input.question}`, }; - } + }, ); const agent = setupAgent({ @@ -833,7 +895,7 @@ describe('setupAgent', () => { model: 'test-model', prompt: ({ input }) => input.question, }, - async () => ({ nope: true }) as unknown as { answer: string } + async () => ({ nope: true }) as unknown as { answer: string }, ); const agent = setupAgent({ @@ -882,7 +944,6 @@ describe('setupAgent', () => { subject: z.string(), body: z.string(), }); - const agent = setupAgent({ context: z.object({ prompt: z.string(), @@ -893,21 +954,22 @@ describe('setupAgent', () => { events: { RETRY: z.object({ prompt: z.string() }), }, - }).withTasks({ - draftEmail: { - schemas: { - input: z.object({ prompt: z.string() }), - output: draftSchema, + requests: { + draftEmail: { + schemas: { + input: z.object({ prompt: z.string() }), + output: draftSchema, + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + metadata: ({ input }) => ({ + temperature: input.prompt.length > 0 ? 0.2 : 0, + traceId: `draft:${input.prompt}`, + }), }, - model: 'test-model', - prompt: ({ input }) => input.prompt, - metadata: ({ input }) => ({ - temperature: input.prompt.length > 0 ? 0.2 : 0, - traceId: `draft:${input.prompt}`, - }), }, }); - const { draftEmail } = agent.tasks; + const { draftEmail } = agent.requests; agent.createMachine({ initial: 'drafting', @@ -959,22 +1021,22 @@ describe('setupAgent', () => { }, done: { type: 'final', - output: ({ context }) => - context.draft ?? { subject: '', body: '' }, + output: ({ context }) => context.draft ?? { subject: '', body: '' }, }, }, }); - const calls: AgentTextInput<{ temperature: number; traceId: string }>[] = []; + const calls: AgentTextRequest<{ temperature: number; traceId: string }>[] = + []; const actor = createActor( machine.provide({ actors: { draftEmail: draftEmail.withExecutor(async ({ request }) => { calls.push( - request as AgentTextInput<{ + request as AgentTextRequest<{ temperature: number; traceId: string; - }> + }>, ); return { subject: `Re: ${request.prompt}`, @@ -983,7 +1045,7 @@ describe('setupAgent', () => { }), }, }), - { input: { prompt: 'launch note' } } + { input: { prompt: 'launch note' } }, ); actor.start(); @@ -1004,7 +1066,7 @@ describe('setupAgent', () => { ]); }); - test('extracts agent effects from pure XState transitions', async () => { + test('extracts agent requests from pure XState transitions', async () => { const answerSchema = z.object({ answer: z.string() }); const agent = setupAgent({ context: z.object({ @@ -1013,18 +1075,19 @@ describe('setupAgent', () => { }), input: z.object({ prompt: z.string() }), output: z.object({ answer: z.string() }), - }).withTasks({ - answerQuestion: { - schemas: { - input: z.object({ prompt: z.string() }), - output: answerSchema, + requests: { + answerQuestion: { + schemas: { + input: z.object({ prompt: z.string() }), + output: answerSchema, + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + temperature: 0.2, }, - model: 'test-model', - prompt: ({ input }) => input.prompt, - temperature: 0.2, }, }); - const { answerQuestion } = agent.tasks; + const { answerQuestion } = agent.requests; const machine = agent.createMachine({ id: 'pure-agent-loop', @@ -1054,14 +1117,12 @@ describe('setupAgent', () => { let [snapshot, actions] = initialTransition(machine, { prompt: 'why state machines?', }); - const [effect] = getAgentEffects(actions, { - actors: agent.tasks, - }); + const [request] = machine.getRequests(actions); - expect(effect).toEqual({ + expect(request).toEqual({ id: 'answer', src: 'answerQuestion', - kind: 'generate', + mode: 'generate', input: expect.objectContaining({ model: 'test-model', prompt: 'why state machines?', @@ -1072,11 +1133,11 @@ describe('setupAgent', () => { events: [], }); - [snapshot, actions] = transitionResult(machine, snapshot, effect!, { + [snapshot, actions] = transitionResult(machine, snapshot, request!, { answer: 'Because the workflow matters.', }); - expect(getAgentEffects(actions)).toEqual([]); + expect(getAgentRequests(actions)).toEqual([]); expect(snapshot.status).toBe('done'); expect(snapshot.output).toEqual({ answer: 'Because the workflow matters.', @@ -1086,22 +1147,22 @@ describe('setupAgent', () => { prompt: 'why agent machines?', }); expect(step.done).toBe(false); - expect(step.tasks).toHaveLength(1); - expect(step.tasks[0]).toEqual( + expect(step.requests).toHaveLength(1); + expect(step.requests[0]).toEqual( expect.objectContaining({ id: 'answer', src: 'answerQuestion', - }) + }), ); - const output = await machine.execute(step.tasks[0]!, { - generateText: (request: AgentTextInput & { tools: AgentTools }) => ({ + const output = await machine.execute(step.requests[0]!, { + generateText: (request: AgentTextRequest & { tools: AgentTools }) => ({ object: { answer: `Answered: ${request.prompt}`, }, }), }); - step = machine.resolve(step, step.tasks[0]!, output); + step = machine.resolve(step, step.requests[0]!, output); expect(step.done).toBe(true); expect(step.snapshot.output).toEqual({ @@ -1109,7 +1170,7 @@ describe('setupAgent', () => { }); }); - test('agent effects expose only selected state events as tools', async () => { + test('agent requests expose only selected state events as tools', async () => { const agent = setupAgent({ context: z.object({ prompt: z.string() }), input: z.object({ prompt: z.string() }), @@ -1118,15 +1179,16 @@ describe('setupAgent', () => { DEFEND: z.object({}), PAUSE: z.object({}), }, - }).withTasks({ - chooseMove: { - schemas: { - input: z.object({ prompt: z.string() }), - output: z.string(), + requests: { + chooseMove: { + schemas: { + input: z.object({ prompt: z.string() }), + output: z.string(), + }, + model: 'test-model', + prompt: ({ input }) => input.prompt, + events: ['ATTACK', 'DEFEND'], }, - model: 'test-model', - prompt: ({ input }) => input.prompt, - events: ['ATTACK', 'DEFEND'], }, }); @@ -1164,30 +1226,30 @@ describe('setupAgent', () => { expect(attackStep.done).toBe(true); - expect(getAvailableEvents(snapshot, { - schemas: agent.schemas, - eventTypes: ['ATTACK', 'DEFEND', 'HEAL'], - })).toEqual([ + expect( + getAvailableEvents(snapshot, { + schemas: agent.schemas, + eventTypes: ['ATTACK', 'DEFEND', 'HEAL'], + }), + ).toEqual([ expect.objectContaining({ type: 'ATTACK', toolName: 'event.ATTACK' }), expect.objectContaining({ type: 'DEFEND', toolName: 'event.DEFEND' }), ]); - const [effect] = getAgentEffects(actions, { - snapshot, - schemas: agent.schemas, - actors: agent.tasks, - }); + const [request] = machine.getRequests(actions, snapshot); - expect(effect!.events.map((event) => event.type)).toEqual([ + expect( + request!.events.map((event: AgentEventDescriptor) => event.type), + ).toEqual([ 'ATTACK', 'DEFEND', ]); - expect(Object.keys(effect!.tools)).toEqual([ + expect(Object.keys(request!.tools)).toEqual([ 'event.ATTACK', 'event.DEFEND', ]); - const attackTool = effect!.tools['event.ATTACK']!; + const attackTool = request!.tools['event.ATTACK']!; if (typeof attackTool === 'function') { throw new Error('Expected event tool descriptor.'); } @@ -1196,13 +1258,17 @@ describe('setupAgent', () => { target: 'orc', }); - expect(Object.keys(getEventTools(snapshot, { - schemas: agent.schemas, - eventTypes: ['ATTACK', 'DEFEND', 'HEAL'], - }))).toEqual(['event.ATTACK', 'event.DEFEND']); + expect( + Object.keys( + getEventTools(snapshot, { + schemas: agent.schemas, + eventTypes: ['ATTACK', 'DEFEND', 'HEAL'], + }), + ), + ).toEqual(['event.ATTACK', 'event.DEFEND']); }); - test('fromConfig lowers static task workflows to agent machine steps', async () => { + test('fromConfig lowers static request workflows to agent machine steps', async () => { const machine = setupAgent.fromConfig({ id: 'static-answer', schemas: { @@ -1232,7 +1298,7 @@ describe('setupAgent', () => { context: { question: '{{ input.question }}', }, - tasks: { + requests: { answerQuestion: { model: 'test-model', prompt: 'Question: {{ input.question }}', @@ -1280,7 +1346,7 @@ describe('setupAgent', () => { let step = machine.initial({ question: 'Why statecharts?' }); - expect(step.tasks).toEqual([ + expect(step.requests).toEqual([ expect.objectContaining({ id: 'answer', src: 'answerQuestion', @@ -1291,49 +1357,32 @@ describe('setupAgent', () => { }), ]); - const output = await machine.execute(step.tasks[0]!, { - generateText: async () => ({ output: { answer: 'Because logic matters.' } }), + const output = await machine.execute(step.requests[0]!, { + generateText: async () => ({ + output: { answer: 'Because logic matters.' }, + }), }); - step = machine.resolve(step, step.tasks[0]!, output); + step = machine.resolve(step, step.requests[0]!, output); expect(step.done).toBe(true); expect(step.snapshot.output).toEqual({ answer: 'Because logic matters.' }); }); test('agent.userInput is a blessed host-provided actor for static workflows', async () => { - const machine = setupAgent.fromConfig({ - id: 'static-user-input', - schemas: { - input: { - type: 'object', - properties: {}, - }, - context: { - type: 'object', - properties: { - recipient: { type: 'string' }, - draft: { type: 'string' }, - }, - }, - output: { - type: 'object', - properties: { - draft: { type: 'string' }, - }, - required: ['draft'], - }, - }, - context: {}, - tasks: { - draftEmail: { - model: 'writer', - prompt: 'Draft email to {{ input.recipient }}', + const machine = setupAgent + .fromConfig({ + id: 'static-user-input', + schemas: { input: { + type: 'object', + properties: {}, + }, + context: { type: 'object', properties: { recipient: { type: 'string' }, + draft: { type: 'string' }, }, - required: ['recipient'], }, output: { type: 'object', @@ -1343,70 +1392,91 @@ describe('setupAgent', () => { required: ['draft'], }, }, - }, - initial: 'askRecipient', - states: { - askRecipient: { - invoke: { - id: 'recipient', - src: 'agent.userInput', + context: {}, + requests: { + draftEmail: { + model: 'writer', + prompt: 'Draft email to {{ input.recipient }}', input: { - prompt: 'Who should receive this email?', - schema: { - type: 'object', - properties: { - recipient: { type: 'string' }, - }, - required: ['recipient'], + type: 'object', + properties: { + recipient: { type: 'string' }, }, + required: ['recipient'], }, - onDone: { - target: 'draftEmail', - assign: { - recipient: '{{ event.output.recipient }}', + output: { + type: 'object', + properties: { + draft: { type: 'string' }, }, + required: ['draft'], }, }, }, - draftEmail: { - invoke: { - id: 'draft', - src: 'draftEmail', - input: { - recipient: '{{ context.recipient }}', + initial: 'askRecipient', + states: { + askRecipient: { + invoke: { + id: 'recipient', + src: 'agent.userInput', + input: { + prompt: 'Who should receive this email?', + schema: { + type: 'object', + properties: { + recipient: { type: 'string' }, + }, + required: ['recipient'], + }, + }, + onDone: { + target: 'draftEmail', + assign: { + recipient: '{{ event.output.recipient }}', + }, + }, }, - onDone: { - target: 'done', - assign: { - draft: '{{ event.output.draft }}', + }, + draftEmail: { + invoke: { + id: 'draft', + src: 'draftEmail', + input: { + recipient: '{{ context.recipient }}', + }, + onDone: { + target: 'done', + assign: { + draft: '{{ event.output.draft }}', + }, }, }, }, - }, - done: { - type: 'final', - output: { - draft: '{{ context.draft }}', + done: { + type: 'final', + output: { + draft: '{{ context.draft }}', + }, }, }, - }, - }).provide({ - actors: { - 'agent.userInput': fromPromise(async ({ input }) => { - expect(input).toEqual( - expect.objectContaining({ - prompt: 'Who should receive this email?', - schema: expect.objectContaining({ type: 'object' }), - }) - ); - return { recipient: 'Ada' }; - }), - draftEmail: fromPromise(async ({ input }) => { - expect(input).toEqual({ recipient: 'Ada' }); - return { draft: 'Hello Ada.' }; - }), - }, - }); + }) + .provide({ + actors: { + 'agent.userInput': fromPromise(async ({ input }) => { + expect(input).toEqual( + expect.objectContaining({ + prompt: 'Who should receive this email?', + schema: expect.objectContaining({ type: 'object' }), + }), + ); + return { recipient: 'Ada' }; + }), + draftEmail: fromPromise(async ({ input }) => { + expect(input).toEqual({ recipient: 'Ada' }); + return { draft: 'Hello Ada.' }; + }), + }, + }); const actor = createActor(machine, { input: {} }).start(); await waitFor(actor, (snapshot) => snapshot.status === 'done'); diff --git a/src/setup-agent.ts b/src/setup-agent.ts index cb36b7c..9565532 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -30,10 +30,10 @@ const USER_INPUT_ACTOR = 'agent.userInput' as const; const GENERATE_TEXT_ACTOR = 'agent.generateText' as const; const STREAM_TEXT_ACTOR = 'agent.streamText' as const; -export type AgentTaskKind = 'generate' | 'stream'; +export type AgentRequestMode = 'generate' | 'stream'; -/** Portable LCD input text tasks pass to host executors. */ -export interface AgentTextInput> { +/** Portable LCD input text requests pass to host executors. */ +export interface AgentTextRequest> { model: string; system?: string; prompt?: string; @@ -65,12 +65,12 @@ export interface AgentUserInput> { } type BuiltinAgentActors = { - [GENERATE_TEXT_ACTOR]: PromiseActorLogic; - [STREAM_TEXT_ACTOR]: PromiseActorLogic; + [GENERATE_TEXT_ACTOR]: PromiseActorLogic; + [STREAM_TEXT_ACTOR]: PromiseActorLogic; [USER_INPUT_ACTOR]: PromiseActorLogic; }; -const agentTextInputSchema: StandardSchemaV1 = { +const agentTextInputSchema: StandardSchemaV1 = { '~standard': { version: 1, vendor: 'statelyai-agent', @@ -78,10 +78,10 @@ const agentTextInputSchema: StandardSchemaV1 = { const ok = !!value && typeof value === 'object' - && typeof (value as AgentTextInput).model === 'string'; + && typeof (value as AgentTextRequest).model === 'string'; return ok - ? { value: value as AgentTextInput } + ? { value: value as AgentTextRequest } : { issues: [{ message: 'Expected agent text input with a model' }] }; }, }, @@ -111,30 +111,30 @@ const stringOutputSchema: StandardSchemaV1 = { function createBuiltinTextActor( src: typeof GENERATE_TEXT_ACTOR | typeof STREAM_TEXT_ACTOR, - taskKind: AgentTaskKind, + mode: AgentRequestMode, outputSchema: StandardSchemaV1 -): AgentTaskLogic, StandardSchemaV1> { - const logic = fromPromise(async () => { +): AgentRequestLogic, StandardSchemaV1> { + const logic = fromPromise(async () => { throw new Error( `'${src}' has no host execution. Provide an implementation with ` + `machine.provide({ actors: { '${src}': ... } }) or execute the ` + - `returned agent task with machine.execute(...).` + `returned agent request with machine.execute(...).` ); }); return Object.assign(logic, { kind: 'statelyai.textLogic' as const, - taskKind, + mode, schemas: { input: agentTextInputSchema, output: outputSchema, }, - request(input: AgentTextInput) { + request(input: AgentTextRequest) { return validateSchemaSync(agentTextInputSchema, input); }, - async execute(input: AgentTextInput, executors: AgentTaskExecutors) { + async execute(input: AgentTextRequest, executors: AgentRequestExecutors) { const output = await executeAgentTextRequest( - taskKind, + mode, src, validateSchemaSync(agentTextInputSchema, input), executors @@ -144,13 +144,13 @@ function createBuiltinTextActor( }, withExecutor( execute: TextLogicExecutor< - StandardSchemaV1, + StandardSchemaV1, StandardSchemaV1, Record > ) { return Object.assign(createTextLogic({ - kind: taskKind, + mode, schemas: { input: agentTextInputSchema, output: outputSchema, @@ -169,10 +169,10 @@ function createBuiltinTextActor( seed: ({ input }) => input.seed, stopSequences: ({ input }) => input.stopSequences, metadata: ({ input }) => input.metadata, - }, execute), { taskKind }); + }, execute)); }, - }) as AgentTaskLogic< - StandardSchemaV1, + }) as AgentRequestLogic< + StandardSchemaV1, StandardSchemaV1 >; } @@ -467,7 +467,7 @@ export interface TextLogicConfig< TOutputSchema extends StandardSchemaV1, TMetadata = Record, > { - kind?: AgentTaskKind; + mode?: AgentRequestMode; schemas: { input: TInputSchema; output: TOutputSchema; @@ -502,7 +502,7 @@ export interface TextLogicConfig< export interface TextLogicExecuteArgs> { input: TInput; - request: AgentTextInput; + request: AgentTextRequest; signal: AbortSignal; system: unknown; self: unknown; @@ -526,27 +526,26 @@ export interface TextLogic< InferOutput > { readonly kind: 'statelyai.textLogic'; - readonly taskKind: AgentTaskKind; + readonly mode: AgentRequestMode; readonly schemas: { readonly input: TInputSchema; readonly output: TOutputSchema; }; - request(input: InferOutput): AgentTextInput; + request(input: InferOutput): AgentTextRequest; execute( input: InferOutput, - executors: AgentTaskExecutors + executors: AgentRequestExecutors ): Promise>; withExecutor( execute: TextLogicExecutor ): TextLogic; } -export interface AgentTaskLogic< +export interface AgentRequestLogic< TInputSchema extends StandardSchemaV1 = StandardSchemaV1, TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, TMetadata = Record, > extends TextLogic { - readonly taskKind: AgentTaskKind; } export type TextLogicInput = @@ -569,7 +568,7 @@ export function createTextLogic< ): TextLogic { type TInput = InferOutput; type TOutput = InferOutput; - const request = (input: TInput): AgentTextInput => { + const request = (input: TInput): AgentTextRequest => { const parsedInput = validateSchemaSync( config.schemas.input as StandardSchemaV1, input @@ -602,7 +601,7 @@ export function createTextLogic< throw new Error( 'Text logic has no host execution. Pass an executor as the second ' + 'argument to createTextLogic(...), provide a runtime adapter, or ' + - 'extract it with getAgentEffects(..., { actors }).' + 'extract it with getAgentRequests(..., { actors }).' ); } @@ -624,12 +623,12 @@ export function createTextLogic< return Object.assign(logic, { kind: 'statelyai.textLogic' as const, - taskKind: config.kind ?? 'generate', + mode: config.mode ?? 'generate', schemas: config.schemas, request, - async execute(input: TInput, executors: AgentTaskExecutors) { + async execute(input: TInput, executors: AgentRequestExecutors) { const output = await executeAgentTextRequest( - config.kind ?? 'generate', + config.mode ?? 'generate', 'textLogic', request(input), executors @@ -657,26 +656,23 @@ function isTextLogic(value: unknown): value is TextLogic { ); } -function isAgentTaskLogic(value: unknown): value is AgentTaskLogic { - return isTextLogic(value) && typeof (value as AgentTaskLogic).taskKind === 'string'; +function isAgentRequestLogic(value: unknown): value is AgentRequestLogic { + return isTextLogic(value); } -export type AgentEffectSource = string & {}; +export type AgentRequestSource = string & {}; export const EVENT_TOOL_PREFIX = 'event.' as const; -export interface AgentEffect { +export interface AgentRequest { id: string; - src: AgentEffectSource; - kind?: AgentTaskKind; + src: AgentRequestSource; + mode?: AgentRequestMode; input: TInput; tools: AgentTools; events: AgentEventDescriptor[]; } -export type AgentTask = - AgentEffect; - export interface AgentEventDescriptor { type: string; toolName: `${typeof EVENT_TOOL_PREFIX}${string}`; @@ -687,7 +683,7 @@ export interface AgentSchemas { events?: Record; } -export interface AgentEffectOptions { +export interface AgentRequestOptions { snapshot?: AnyMachineSnapshot; events?: Record; schemas?: AgentSchemas; @@ -696,7 +692,7 @@ export interface AgentEffectOptions { export function getAvailableEvents( snapshot: AnyMachineSnapshot, - options: Pick & { + options: Pick & { eventTypes?: readonly string[]; } = {} ): AgentEventDescriptor[] { @@ -732,7 +728,7 @@ export function getAvailableEvents( export function getEventTools( snapshot: AnyMachineSnapshot, - options: Pick & { + options: Pick & { eventTypes?: readonly string[]; } = {} ): AgentTools { @@ -751,10 +747,10 @@ export function getEventTools( ); } -export function getAgentEffects( +export function getAgentRequests( actions: readonly { type?: string; params?: unknown }[], - options: AgentEffectOptions = {} -): AgentEffect[] { + options: AgentRequestOptions = {} +): AgentRequest[] { return actions.flatMap((action) => { if (action.type !== 'xstate.spawnChild') { return []; @@ -806,7 +802,7 @@ export function getAgentEffects( return [{ id: params.id, src: params.src, - ...(isAgentTaskLogic(textLogic) ? { kind: textLogic.taskKind } : {}), + ...(isAgentRequestLogic(textLogic) ? { mode: textLogic.mode } : {}), input, tools: { ...(input.tools ?? {}), @@ -818,35 +814,45 @@ export function getAgentEffects( } export function doneEvent( - effect: Pick | string, + request: Pick | string, output: unknown ): { type: `xstate.done.actor.${string}`; output: unknown } { - const id = typeof effect === 'string' ? effect : effect.id; + const id = typeof request === 'string' ? request : request.id; return { type: `xstate.done.actor.${id}`, output }; } export function transitionResult( logic: TLogic, snapshot: SnapshotFrom, - effect: Pick | string, + request: Pick | string, output: unknown ): [SnapshotFrom, ExecutableActionsFrom[]] { - return transition(logic, snapshot, doneEvent(effect, output) as never); + return transition(logic, snapshot, doneEvent(request, output) as never); } -export type AgentTaskExecutor = ( - request: AgentTextInput & { tools: AgentTools } -) => PromiseLike | unknown; +export type AgentRequestExecutorResult = + | { output: unknown } + | { object: unknown } + | { text: string } + | { toolResults: Array<{ output?: unknown }> } + | unknown; + +export type AgentRequestExecutor = ( + request: AgentTextRequest & { tools: AgentTools } +) => PromiseLike | TResult; -export interface AgentTaskExecutors { - generateText: AgentTaskExecutor; - streamText?: AgentTaskExecutor; +export interface AgentRequestExecutors< + TGenerateResult = AgentRequestExecutorResult, + TStreamResult = AgentRequestExecutorResult, +> { + generateText: AgentRequestExecutor; + streamText?: AgentRequestExecutor; } export interface AgentStep { snapshot: TSnapshot; actions: readonly { type?: string; params?: unknown }[]; - tasks: AgentTask[]; + requests: AgentRequest[]; done: boolean; } @@ -860,17 +866,17 @@ export type AgentMachine = ): AgentStep>; resolve( step: AgentStep>, - task: Pick | string, + request: Pick | string, output: unknown ): AgentStep>; - getTasks( + getRequests( actions: readonly { type?: string; params?: unknown }[], snapshot?: AnyMachineSnapshot - ): AgentTask[]; - execute(task: AgentTask, executors: AgentTaskExecutors): Promise; + ): AgentRequest[]; + execute(request: AgentRequest, executors: AgentRequestExecutors): Promise; }; -async function normalizeTaskExecutionResult(result: unknown): Promise { +async function normalizeRequestExecutionResult(result: unknown): Promise { const resolved = await result; if (!resolved || typeof resolved !== 'object') { @@ -906,10 +912,10 @@ async function normalizeTaskExecutionResult(result: unknown): Promise { } async function executeAgentTextRequest( - taskKind: AgentTaskKind, + mode: AgentRequestMode, id: string, - input: AgentTextInput, - executors: AgentTaskExecutors, + input: AgentTextRequest, + executors: AgentRequestExecutors, tools: AgentTools = {} ): Promise { const request = { @@ -920,22 +926,22 @@ async function executeAgentTextRequest( }, }; const executor = - taskKind === 'stream' + mode === 'stream' ? executors.streamText : executors.generateText; if (!executor) { throw new Error( - `No executor provided for ${taskKind === 'stream' ? 'stream' : 'generate'} task '${id}'.` + `No executor provided for ${mode === 'stream' ? 'stream' : 'generate'} request '${id}'.` ); } - return normalizeTaskExecutionResult(await executor(request)); + return normalizeRequestExecutionResult(await executor(request)); } function createAgentMachine( machine: TMachine, - options: Pick + options: Pick ): AgentMachine { const originalTransition = machine.transition.bind(machine); const originalProvide = 'provide' in machine @@ -970,32 +976,32 @@ function createAgentMachine( }, resolve( step: AgentStep>, - task: Pick | string, + request: Pick | string, output: unknown ) { - const [snapshot, actions] = transitionResult(agentMachine, step.snapshot, task, output); + const [snapshot, actions] = transitionResult(agentMachine, step.snapshot, request, output); return createAgentStep(agentMachine, snapshot, actions); }, - getTasks( + getRequests( actions: readonly { type?: string; params?: unknown }[], snapshot?: AnyMachineSnapshot ) { - return getAgentEffects(actions, { + return getAgentRequests(actions, { ...options, snapshot, }); }, - async execute(task: AgentTask, executors: AgentTaskExecutors) { + async execute(request: AgentRequest, executors: AgentRequestExecutors) { const output = await executeAgentTextRequest( - task.kind ?? 'generate', - task.id, - task.input, + request.mode ?? 'generate', + request.id, + request.input, executors, - task.tools + request.tools ); - return task.input.outputSchema - ? validateSchemaSync(task.input.outputSchema, output) + return request.input.outputSchema + ? validateSchemaSync(request.input.outputSchema, output) : output; }, }) as unknown as AgentMachine; @@ -1011,7 +1017,7 @@ function createAgentStep( return { snapshot, actions, - tasks: machine.getTasks(actions, snapshot), + requests: machine.getRequests(actions, snapshot), done: (snapshot as AnyMachineSnapshot).status === 'done', }; } @@ -1024,7 +1030,7 @@ function isAgentStep( && typeof value === 'object' && 'snapshot' in value && 'actions' in value - && 'tasks' in value + && 'requests' in value ); } @@ -1108,7 +1114,7 @@ export function createAgentSchemas< }; } -type AgentTaskEvents< +type AgentRequestEvents< TEventSchemas extends Record, TSchemas extends AgentSchemaPack, TInputSchema extends StandardSchemaV1, @@ -1119,7 +1125,7 @@ type AgentTaskEvents< schemas: TSchemas; }) => readonly (keyof TEventSchemas & string)[]); -export type AgentTaskConfig< +type AgentRequestConfig< TEventSchemas extends Record, TSchemas extends AgentSchemaPack, TInputSchema extends StandardSchemaV1 = StandardSchemaV1, @@ -1129,11 +1135,11 @@ export type AgentTaskConfig< TextLogicConfig, 'events' > & { - kind?: AgentTaskKind; - events?: AgentTaskEvents; + mode?: AgentRequestMode; + events?: AgentRequestEvents; }; -type AgentTaskSchemaMap = Record< +type AgentRequestSchemaMap = Record< string, { input: StandardSchemaV1; @@ -1141,29 +1147,34 @@ type AgentTaskSchemaMap = Record< } >; -type AgentTaskInput< - TTaskSchemas extends AgentTaskSchemaMap, +type AgentRequestInput< + TRequestSchemas extends AgentRequestSchemaMap, TEventSchemas extends Record, TSchemas extends AgentSchemaPack, > = { - [K in keyof TTaskSchemas]: AgentTaskConfig< + [K in keyof TRequestSchemas]: AgentRequestConfig< TEventSchemas, TSchemas, - TTaskSchemas[K]['input'], - TTaskSchemas[K]['output'] + TRequestSchemas[K]['input'], + TRequestSchemas[K]['output'] > & { - schemas: TTaskSchemas[K]; + schemas: TRequestSchemas[K]; eventTypes?: never; }; }; -type TaskActors = { - [K in keyof TTaskSchemas]: AgentTaskLogic< - TTaskSchemas[K]['input'], - TTaskSchemas[K]['output'] +type RequestActors = { + [K in keyof TRequestSchemas]: AgentRequestLogic< + TRequestSchemas[K]['input'], + TRequestSchemas[K]['output'] >; }; +type AgentAllActors< + TActors extends { [K in keyof TActors]: AnyActorLogic }, + TRequestSchemas extends AgentRequestSchemaMap, +> = TActors & RequestActors; + type AgentSetupConfigOptions< TContextSchema extends StandardSchemaV1>, TEventSchemas extends Record, @@ -1174,11 +1185,12 @@ type AgentSetupConfigOptions< TActions extends Record, TGuards extends Record, TDelay extends string, + TRequestSchemas extends AgentRequestSchemaMap, > = Parameters< typeof setup< ContextOf, EventsOf, - SetupActors>, + SetupActors>>, {}, TActions, TGuards, @@ -1201,6 +1213,7 @@ type SetupAgentBaseConfig< TActions extends Record, TGuards extends Record, TDelay extends string, + TRequestSchemas extends AgentRequestSchemaMap, > = ( | { schemas: AgentSchemaPack< @@ -1220,6 +1233,17 @@ type SetupAgentBaseConfig< > ) & { actors?: TActors; + requests?: AgentRequestInput< + TRequestSchemas, + TEventSchemas, + AgentSchemaPack< + TContextSchema, + TEventSchemas, + TInputSchema, + TOutputSchema, + TMetaSchema + > + >; actions?: AgentSetupConfigOptions< TContextSchema, TEventSchemas, @@ -1229,7 +1253,8 @@ type SetupAgentBaseConfig< TMetaSchema, TActions, TGuards, - TDelay + TDelay, + TRequestSchemas >['actions']; guards?: AgentSetupConfigOptions< TContextSchema, @@ -1240,7 +1265,8 @@ type SetupAgentBaseConfig< TMetaSchema, TActions, TGuards, - TDelay + TDelay, + TRequestSchemas >['guards']; delays?: AgentSetupConfigOptions< TContextSchema, @@ -1251,7 +1277,8 @@ type SetupAgentBaseConfig< TMetaSchema, TActions, TGuards, - TDelay + TDelay, + TRequestSchemas >['delays']; }; @@ -1259,6 +1286,7 @@ type SetupAgentXStateResult< TContextSchema extends StandardSchemaV1>, TEventSchemas extends Record, TActors extends { [K in keyof TActors]: AnyActorLogic }, + TRequestSchemas extends AgentRequestSchemaMap, TInputSchema extends StandardSchemaV1, TOutputSchema extends StandardSchemaV1, TMetaSchema extends StandardSchemaV1, @@ -1269,7 +1297,7 @@ type SetupAgentXStateResult< typeof setup< ContextOf, EventsOf, - SetupActors>, + SetupActors>>, {}, TActions, TGuards, @@ -1286,18 +1314,19 @@ type SetupAgentResult< TContextSchema extends StandardSchemaV1>, TEventSchemas extends Record, TActors extends { [K in keyof TActors]: AnyActorLogic }, + TRequestSchemas extends AgentRequestSchemaMap, TInputSchema extends StandardSchemaV1, TOutputSchema extends StandardSchemaV1, TMetaSchema extends StandardSchemaV1, TActions extends Record, TGuards extends Record, TDelay extends string, - TTasks extends { [K in keyof TTasks]: AgentTaskLogic } = {}, > = Omit< SetupAgentXStateResult< TContextSchema, TEventSchemas, TActors, + TRequestSchemas, TInputSchema, TOutputSchema, TMetaSchema, @@ -1313,6 +1342,7 @@ type SetupAgentResult< TContextSchema, TEventSchemas, TActors, + TRequestSchemas, TInputSchema, TOutputSchema, TMetaSchema, @@ -1331,7 +1361,7 @@ type SetupAgentResult< TOutputSchema, TMetaSchema >; - tasks: TTasks; + readonly requests: RequestActors; appendMessages( resolve: | AgentMessage @@ -1346,30 +1376,6 @@ type SetupAgentResult< EventsOf > >; - withTasks( - tasks: AgentTaskInput< - TNextTaskSchemas, - TEventSchemas, - AgentSchemaPack< - TContextSchema, - TEventSchemas, - TInputSchema, - TOutputSchema, - TMetaSchema - > - > - ): SetupAgentResult< - TContextSchema, - TEventSchemas, - TActors & TaskActors, - TInputSchema, - TOutputSchema, - TMetaSchema, - TActions, - TGuards, - TDelay, - TTasks & TaskActors - >; }; /** @@ -1382,6 +1388,7 @@ export function setupAgent< TContextSchema extends StandardSchemaV1>, TEventSchemas extends Record, TActors extends { [K in keyof TActors]: AnyActorLogic }, + TRequestSchemas extends AgentRequestSchemaMap = {}, TInputSchema extends StandardSchemaV1 = StandardSchemaV1, TOutputSchema extends StandardSchemaV1 = StandardSchemaV1, TMetaSchema extends StandardSchemaV1 = StandardSchemaV1, @@ -1398,21 +1405,22 @@ export function setupAgent< TMetaSchema, TActions, TGuards, - TDelay + TDelay, + TRequestSchemas > ): SetupAgentResult< TContextSchema, TEventSchemas, TActors, + TRequestSchemas, TInputSchema, TOutputSchema, TMetaSchema, TActions, TGuards, - TDelay, - {} + TDelay > { - return createSetupAgent(config, {}); + return createSetupAgent(config); } export interface AgentWorkflowConfig { @@ -1428,15 +1436,15 @@ export interface AgentWorkflowConfig { meta?: JsonSchemaObject; }; context?: Record; - tasks?: Record; + requests?: Record; actors?: Record; initial: string; states: Record; meta?: Record; } -export interface AgentWorkflowTaskConfig { - kind?: AgentTaskKind; +export interface AgentWorkflowRequestConfig { + mode?: AgentRequestMode; description?: string; model: unknown; system?: unknown; @@ -1531,83 +1539,83 @@ function createSchemasFromWorkflowConfig( }); } -function createTasksFromWorkflowConfig( +function createRequestsFromWorkflowConfig( config: AgentWorkflowConfig -): AgentTaskInput< +): AgentRequestInput< Record, Record, AgentSchemaPack, any, any, any> > { return Object.fromEntries( - Object.entries(config.tasks ?? {}).map(([key, task]) => [ + Object.entries(config.requests ?? {}).map(([key, request]) => [ key, { - kind: task.kind, - description: task.description, + mode: request.mode, + description: request.description, schemas: { - input: jsonSchemaToStandardSchema(task.input, `${key}.input`), - output: jsonSchemaToStandardSchema(task.output, `${key}.output`), + input: jsonSchemaToStandardSchema(request.input, `${key}.input`), + output: jsonSchemaToStandardSchema(request.output, `${key}.output`), }, model: ({ input }) => - String(evaluateExpressionValue(task.model, { input }) ?? ''), - system: task.system === undefined + String(evaluateExpressionValue(request.model, { input }) ?? ''), + system: request.system === undefined ? undefined : ({ input }) => - evaluateExpressionValue(task.system, { input }) as string | undefined, - prompt: task.prompt === undefined + evaluateExpressionValue(request.system, { input }) as string | undefined, + prompt: request.prompt === undefined ? undefined : ({ input }) => - evaluateExpressionValue(task.prompt, { input }) as string | undefined, - messages: task.messages === undefined + evaluateExpressionValue(request.prompt, { input }) as string | undefined, + messages: request.messages === undefined ? undefined : ({ input }) => - evaluateExpressionValue(task.messages, { input }) as + evaluateExpressionValue(request.messages, { input }) as | AgentMessage[] | undefined, - tools: task.tools, - toolChoice: task.toolChoice as AgentToolChoice | undefined, - events: task.events === undefined + tools: request.tools, + toolChoice: request.toolChoice as AgentToolChoice | undefined, + events: request.events === undefined ? undefined : ({ input }) => { - const events = evaluateExpressionValue(task.events, { input }); + const events = evaluateExpressionValue(request.events, { input }); return Array.isArray(events) ? events.filter((event): event is string => typeof event === 'string') : []; }, - temperature: task.temperature === undefined + temperature: request.temperature === undefined ? undefined : ({ input }) => - evaluateExpressionValue(task.temperature, { input }) as + evaluateExpressionValue(request.temperature, { input }) as | number | undefined, - maxTokens: task.maxTokens === undefined + maxTokens: request.maxTokens === undefined ? undefined : ({ input }) => - evaluateExpressionValue(task.maxTokens, { input }) as number | undefined, - topP: task.topP === undefined + evaluateExpressionValue(request.maxTokens, { input }) as number | undefined, + topP: request.topP === undefined ? undefined : ({ input }) => - evaluateExpressionValue(task.topP, { input }) as number | undefined, - topK: task.topK === undefined + evaluateExpressionValue(request.topP, { input }) as number | undefined, + topK: request.topK === undefined ? undefined : ({ input }) => - evaluateExpressionValue(task.topK, { input }) as number | undefined, - seed: task.seed === undefined + evaluateExpressionValue(request.topK, { input }) as number | undefined, + seed: request.seed === undefined ? undefined : ({ input }) => - evaluateExpressionValue(task.seed, { input }) as number | undefined, - stopSequences: task.stopSequences === undefined + evaluateExpressionValue(request.seed, { input }) as number | undefined, + stopSequences: request.stopSequences === undefined ? undefined : ({ input }) => - evaluateExpressionValue(task.stopSequences, { input }) as + evaluateExpressionValue(request.stopSequences, { input }) as | string[] | undefined, - metadata: task.metadata === undefined + metadata: request.metadata === undefined ? undefined - : ({ input }) => evaluateExpressionValue(task.metadata, { input }), + : ({ input }) => evaluateExpressionValue(request.metadata, { input }), }, ]) - ) as AgentTaskInput< + ) as AgentRequestInput< Record, Record, AgentSchemaPack, any, any, any> @@ -1792,14 +1800,14 @@ function lowerWorkflowState(stateConfig: AgentWorkflowStateConfig): Record, TSchemas extends AgentSchemaPack, - TTaskSchemas extends AgentTaskSchemaMap, ->(schemas: TSchemas, tasks: AgentTaskInput): TaskActors { + TRequestSchemas extends AgentRequestSchemaMap, +>(schemas: TSchemas, requests: AgentRequestInput): RequestActors { return Object.fromEntries( - Object.entries(tasks).map(([key, task]) => { + Object.entries(requests).map(([key, request]) => { const logic = createTextLogic({ - ...task, - kind: task.kind ?? 'generate', - events: task.events + ...request, + mode: request.mode ?? 'generate', + events: request.events ? ({ input }) => - typeof task.events === 'function' - ? task.events({ input, schemas }) - : task.events + typeof request.events === 'function' + ? request.events({ input, schemas }) + : request.events : undefined, } as TextLogicConfig); @@ -1855,7 +1863,7 @@ function createTaskActors< logic, ]; }) - ) as TaskActors; + ) as RequestActors; } function normalizeAgentSchemas< @@ -1898,13 +1906,13 @@ function createSetupAgent< TContextSchema extends StandardSchemaV1>, TEventSchemas extends Record, TActors extends { [K in keyof TActors]: AnyActorLogic }, + TRequestSchemas extends AgentRequestSchemaMap, TInputSchema extends StandardSchemaV1, TOutputSchema extends StandardSchemaV1, TMetaSchema extends StandardSchemaV1, TActions extends Record, TGuards extends Record, TDelay extends string, - TTasks extends { [K in keyof TTasks]: AgentTaskLogic }, >( config: SetupAgentBaseConfig< TContextSchema, @@ -1915,26 +1923,34 @@ function createSetupAgent< TMetaSchema, TActions, TGuards, - TDelay - >, - tasks: TTasks + TDelay, + TRequestSchemas + > ): SetupAgentResult< TContextSchema, TEventSchemas, TActors, + TRequestSchemas, TInputSchema, TOutputSchema, TMetaSchema, TActions, TGuards, - TDelay, - TTasks + TDelay > { const schemas = normalizeAgentSchemas(config); + const requestActors = createRequestActors( + schemas, + (config.requests ?? {}) as AgentRequestInput< + TRequestSchemas, + TEventSchemas, + typeof schemas + > + ); const base = setup< ContextOf, EventsOf, - SetupActors>, + SetupActors>>, {}, TActions, TGuards, @@ -1956,6 +1972,7 @@ function createSetupAgent< ...builtinTextActors, [USER_INPUT_ACTOR]: userInputActor, ...config.actors, + ...requestActors, } as AgentSetupConfigOptions< TContextSchema, TEventSchemas, @@ -1965,7 +1982,8 @@ function createSetupAgent< TMetaSchema, TActions, TGuards, - TDelay + TDelay, + TRequestSchemas >['actors'], actions: config.actions, guards: config.guards, @@ -1980,41 +1998,25 @@ function createSetupAgent< actors: { ...builtinTextActors, ...config.actors, - ...tasks, + ...requestActors, }, }); }, schemas, - tasks, + requests: requestActors, appendMessages(resolve: Parameters[0]) { return appendMessages(resolve as never); }, - withTasks( - nextTasks: AgentTaskInput - ) { - const taskActors = createTaskActors(schemas, nextTasks) as TaskActors; - return createSetupAgent({ - ...config, - schemas, - actors: { - ...config.actors, - ...taskActors, - } as TActors & TaskActors, - }, { - ...tasks, - ...taskActors, - } as TTasks & TaskActors); - }, }) as unknown as SetupAgentResult< TContextSchema, TEventSchemas, TActors, + TRequestSchemas, TInputSchema, TOutputSchema, TMetaSchema, TActions, TGuards, - TDelay, - TTasks + TDelay >; } From 5eaf035c627da2ec079a3de99096ba3963b15b44 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Wed, 24 Jun 2026 22:42:15 -0400 Subject: [PATCH 50/50] Add configurable event tool names --- docs/host-actors.md | 4 +-- readme.md | 2 +- src/ai-sdk/index.test.ts | 4 +-- src/examples.test.ts | 2 +- src/index.ts | 1 + src/setup-agent.test.ts | 30 +++++++++++++---- src/setup-agent.ts | 69 ++++++++++++++++++++++++++++++++++++---- 7 files changed, 93 insertions(+), 19 deletions(-) diff --git a/docs/host-actors.md b/docs/host-actors.md index cc951e9..904f5cc 100644 --- a/docs/host-actors.md +++ b/docs/host-actors.md @@ -150,13 +150,13 @@ const requests = getAgentRequests(actions, { const request = requests[0]; Object.keys(request.tools); -// ['event.ATTACK', 'event.DEFEND'] +// ['send_event_ATTACK', 'send_event_DEFEND'] ``` Each event tool returns the event object: ```ts -await request.tools['event.ATTACK'].execute({ target: 'orc' }); +await request.tools.send_event_ATTACK.execute({ target: 'orc' }); // { type: 'ATTACK', target: 'orc' } ``` diff --git a/readme.md b/readme.md index 70b4891..2aa2fb0 100644 --- a/readme.md +++ b/readme.md @@ -103,7 +103,7 @@ await agent.requests.answerQuestion.execute( This is normal XState underneath: use `machine.initial(...)`, `machine.transition(...)`, and `machine.resolve(...)` for the blessed step loop; drop down to pure `initialTransition(...)` / `transitionResult(...)`; or use `createActor(...)`, snapshots, persistence, guards, actions, and host-provided actors. `setupAgent(...)` adds schema-derived concrete types, retained schemas, built-in text actor sources, reusable text actors, `step.requests`, `machine.getRequests(...)`, and `machine.execute(...)`. -When a request declares `events`, `machine.getRequests(...)` returns `event.` tools for those events only if they are currently legal from the snapshot. That lets a model choose legal machine events, such as moves in a game, without exposing every transition. +When a request declares `events`, `machine.getRequests(...)` returns `send_event_` tools for those events only if they are currently legal from the snapshot. That lets a model choose legal machine events, such as moves in a game, without exposing every transition. ## Static Workflow Definitions diff --git a/src/ai-sdk/index.test.ts b/src/ai-sdk/index.test.ts index bb4b3a8..4803abe 100644 --- a/src/ai-sdk/index.test.ts +++ b/src/ai-sdk/index.test.ts @@ -6,14 +6,14 @@ describe('toAiSdkTools', () => { test('converts agent tool descriptors to AI SDK tools', () => { const inputSchema = z.object({ target: z.string() }); const tools = toAiSdkTools({ - 'event.ATTACK': { + send_event_ATTACK: { description: 'Attack a target.', inputSchema, execute: async (input) => ({ type: 'ATTACK', ...input as object }), }, }); - expect(tools['event.ATTACK']).toEqual( + expect(tools.send_event_ATTACK).toEqual( expect.objectContaining({ description: 'Attack a target.', inputSchema, diff --git a/src/examples.test.ts b/src/examples.test.ts index a23971d..73efd8a 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -140,7 +140,7 @@ describe('curated XState setup examples', () => { 'FLEE', ]); - const attackTool = chooseMove!.tools['event.ATTACK']!; + const attackTool = chooseMove!.tools.send_event_ATTACK!; if (typeof attackTool === 'function') { throw new Error('Expected event tool descriptor.'); } diff --git a/src/index.ts b/src/index.ts index d85b37b..1851416 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export type { AgentRequest, AgentRequestOptions, AgentEventDescriptor, + AgentEventToolNameResolver, AgentRequestSource, AgentMachine, AgentUserInput, diff --git a/src/setup-agent.test.ts b/src/setup-agent.test.ts index 6e389f2..5298b7d 100644 --- a/src/setup-agent.test.ts +++ b/src/setup-agent.test.ts @@ -1232,11 +1232,25 @@ describe('setupAgent', () => { eventTypes: ['ATTACK', 'DEFEND', 'HEAL'], }), ).toEqual([ - expect.objectContaining({ type: 'ATTACK', toolName: 'event.ATTACK' }), - expect.objectContaining({ type: 'DEFEND', toolName: 'event.DEFEND' }), + expect.objectContaining({ type: 'ATTACK', toolName: 'send_event_ATTACK' }), + expect.objectContaining({ type: 'DEFEND', toolName: 'send_event_DEFEND' }), + ]); + + expect( + getAvailableEvents(snapshot, { + schemas: agent.schemas, + eventTypes: ['ATTACK'], + eventToolName: ({ eventType }) => `machine_${eventType.toLowerCase()}`, + }), + ).toEqual([ + expect.objectContaining({ type: 'ATTACK', toolName: 'machine_attack' }), ]); const [request] = machine.getRequests(actions, snapshot); + const [customNamedRequest] = machine.getRequests(actions, snapshot, { + eventToolName: ({ eventType }: { eventType: string }) => + `machine_${eventType.toLowerCase()}`, + }); expect( request!.events.map((event: AgentEventDescriptor) => event.type), @@ -1245,11 +1259,15 @@ describe('setupAgent', () => { 'DEFEND', ]); expect(Object.keys(request!.tools)).toEqual([ - 'event.ATTACK', - 'event.DEFEND', + 'send_event_ATTACK', + 'send_event_DEFEND', + ]); + expect(Object.keys(customNamedRequest!.tools)).toEqual([ + 'machine_attack', + 'machine_defend', ]); - const attackTool = request!.tools['event.ATTACK']!; + const attackTool = request!.tools['send_event_ATTACK']!; if (typeof attackTool === 'function') { throw new Error('Expected event tool descriptor.'); } @@ -1265,7 +1283,7 @@ describe('setupAgent', () => { eventTypes: ['ATTACK', 'DEFEND', 'HEAL'], }), ), - ).toEqual(['event.ATTACK', 'event.DEFEND']); + ).toEqual(['send_event_ATTACK', 'send_event_DEFEND']); }); test('fromConfig lowers static request workflows to agent machine steps', async () => { diff --git a/src/setup-agent.ts b/src/setup-agent.ts index 9565532..30d1594 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -662,7 +662,50 @@ function isAgentRequestLogic(value: unknown): value is AgentRequestLogic { export type AgentRequestSource = string & {}; -export const EVENT_TOOL_PREFIX = 'event.' as const; +export const EVENT_TOOL_PREFIX = 'send_event_' as const; + +export type AgentEventToolNameResolver = (args: { + eventType: string; + defaultToolName: string; +}) => string; + +function hashString(value: string): string { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = (hash * 33) ^ value.charCodeAt(i); + } + return (hash >>> 0).toString(36); +} + +function sanitizeEventToolName(eventType: string): `${typeof EVENT_TOOL_PREFIX}${string}` { + const sanitizedType = eventType.replace(/[^a-zA-Z0-9_-]/g, '_') || 'event'; + const base = `${EVENT_TOOL_PREFIX}${sanitizedType}`; + + if (base.length <= 64) { + return base as `${typeof EVENT_TOOL_PREFIX}${string}`; + } + + const hash = hashString(eventType); + const prefixLength = 64 - hash.length - 1; + return `${base.slice(0, prefixLength)}_${hash}` as `${typeof EVENT_TOOL_PREFIX}${string}`; +} + +function disambiguateEventToolName( + toolName: string, + eventType: string, + usedToolNames: Set +): string { + if (!usedToolNames.has(toolName)) { + usedToolNames.add(toolName); + return toolName; + } + + const hash = hashString(eventType); + const suffix = `_${hash}`; + const uniqueToolName = `${toolName.slice(0, 64 - suffix.length)}${suffix}`; + usedToolNames.add(uniqueToolName); + return uniqueToolName; +} export interface AgentRequest { id: string; @@ -675,7 +718,7 @@ export interface AgentRequest; schemas?: AgentSchemas; actors?: Record; + /** Customize machine-event tool names. Defaults to send_event_. */ + eventToolName?: AgentEventToolNameResolver; } export function getAvailableEvents( snapshot: AnyMachineSnapshot, - options: Pick & { + options: Pick & { eventTypes?: readonly string[]; } = {} ): AgentEventDescriptor[] { @@ -701,6 +746,7 @@ export function getAvailableEvents( ? undefined : new Set(options.eventTypes); const seen = new Set(); + const usedToolNames = new Set(); return getNextTransitions(snapshot).flatMap((transitionDefinition) => { const eventType = transitionDefinition.eventType; @@ -716,9 +762,14 @@ export function getAvailableEvents( } seen.add(eventType); + const defaultToolName = sanitizeEventToolName(eventType); + const toolName = options.eventToolName + ? options.eventToolName({ eventType, defaultToolName }) + : disambiguateEventToolName(defaultToolName, eventType, usedToolNames); + return [{ type: eventType, - toolName: `${EVENT_TOOL_PREFIX}${eventType}` as const, + toolName, ...((options.events ?? options.schemas?.events)?.[eventType] ? { inputSchema: (options.events ?? options.schemas?.events)![eventType] } : {}), @@ -728,7 +779,7 @@ export function getAvailableEvents( export function getEventTools( snapshot: AnyMachineSnapshot, - options: Pick & { + options: Pick & { eventTypes?: readonly string[]; } = {} ): AgentTools { @@ -783,6 +834,7 @@ export function getAgentRequests( events: options.events, schemas: options.schemas, eventTypes: input.eventTypes, + eventToolName: options.eventToolName, }) : []; const eventTools = Object.fromEntries( @@ -871,7 +923,8 @@ export type AgentMachine = ): AgentStep>; getRequests( actions: readonly { type?: string; params?: unknown }[], - snapshot?: AnyMachineSnapshot + snapshot?: AnyMachineSnapshot, + options?: Pick ): AgentRequest[]; execute(request: AgentRequest, executors: AgentRequestExecutors): Promise; }; @@ -984,10 +1037,12 @@ function createAgentMachine( }, getRequests( actions: readonly { type?: string; params?: unknown }[], - snapshot?: AnyMachineSnapshot + snapshot?: AnyMachineSnapshot, + requestOptions: Pick = {} ) { return getAgentRequests(actions, { ...options, + ...requestOptions, snapshot, }); },