Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e06a099a88 | |||
| cc437b9e9a | |||
| 064c34b25a | |||
| 2d9015c30d | |||
| c16bba8bc0 | |||
| 5085a13b0e | |||
| 05b934616b | |||
| 2726a74203 | |||
| bc427b11b7 | |||
| dc75ea0cc0 | |||
| 55aa8cce44 | |||
| 394c37084b | |||
| 79275e60fb | |||
| cbcc67b1e2 | |||
| a3bfba809f | |||
| ba12049ca5 | |||
| acc1742859 | |||
| 1448f248fd | |||
| 71ffc73272 | |||
| 14527d2047 | |||
| 3a6621c5fc | |||
| caa5f28cc9 | |||
| cad83ab53e | |||
| b0aa6bfb61 | |||
| 49f20b6a30 |
@@ -603,6 +603,7 @@
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"acorn": "8.15.0",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bonjour-service": "1.3.0",
|
||||
@@ -636,6 +637,7 @@
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"typescript": "catalog:",
|
||||
"ulid": "catalog:",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
@@ -664,7 +666,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
},
|
||||
@@ -3009,7 +3010,7 @@
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
@@ -5691,6 +5692,8 @@
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
|
||||
|
||||
"@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
@@ -5901,6 +5904,8 @@
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
@@ -6147,6 +6152,8 @@
|
||||
|
||||
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
|
||||
"astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
|
||||
|
||||
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
|
||||
@@ -6237,6 +6244,8 @@
|
||||
|
||||
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
|
||||
|
||||
"esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -6301,6 +6310,8 @@
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
@@ -6425,6 +6436,8 @@
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
|
||||
@@ -6443,6 +6456,8 @@
|
||||
|
||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
"unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
@@ -6457,6 +6472,8 @@
|
||||
|
||||
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||
|
||||
"vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
|
||||
@@ -6875,6 +6892,8 @@
|
||||
|
||||
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2"
|
||||
},
|
||||
@@ -109,6 +108,7 @@
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"acorn": "8.15.0",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bonjour-service": "1.3.0",
|
||||
@@ -142,6 +142,7 @@
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"typescript": "catalog:",
|
||||
"ulid": "catalog:",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
|
||||
@@ -43,6 +43,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
|
||||
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
||||
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
|
||||
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
|
||||
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
|
||||
@@ -159,6 +159,12 @@ export interface Interface {
|
||||
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
||||
/**
|
||||
* Raw MCP tool definitions keyed identically to {@link tools} (`toolName(client, name)`).
|
||||
* Unlike {@link tools}, these retain the original `inputSchema`/`outputSchema`, which code
|
||||
* mode uses to render tool signatures (including return types) to the model.
|
||||
*/
|
||||
readonly defs: () => Effect.Effect<Record<string, MCPToolDef>>
|
||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||
readonly resourceTemplates: (
|
||||
@@ -680,6 +686,18 @@ export const layer = Layer.effect(
|
||||
return result
|
||||
})
|
||||
|
||||
const defs = Effect.fn("MCP.defs")(function* () {
|
||||
const result: Record<string, MCPToolDef> = {}
|
||||
const s = yield* InstanceState.get(state)
|
||||
for (const [clientName, listed] of Object.entries(s.defs)) {
|
||||
if (s.status[clientName]?.status !== "connected") continue
|
||||
for (const mcpTool of listed) {
|
||||
result[McpCatalog.toolName(clientName, mcpTool.name)] = mcpTool
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function collectFromConnected<T extends { name: string }>(
|
||||
s: State,
|
||||
listFn: (c: Client, timeout?: number) => Promise<T[]>,
|
||||
@@ -982,6 +1000,7 @@ export const layer = Layer.effect(
|
||||
clients,
|
||||
instructions,
|
||||
tools,
|
||||
defs,
|
||||
prompts,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
|
||||
@@ -0,0 +1,832 @@
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Rune } from "./rune/rune"
|
||||
import type { ExecutionLimits, LogEntry } from "./rune/rune"
|
||||
import type { HostTools } from "./rune/tool-runtime"
|
||||
|
||||
export const CODE_MODE_TOOL = "execute"
|
||||
|
||||
/**
|
||||
* Execution limits for the Rune interpreter. `maxDataBytes` is raised well above
|
||||
* the Rune default (256KB) because code mode forwards base64 media attachments,
|
||||
* and the timeout matches the default MCP request timeout.
|
||||
*/
|
||||
const CODE_LIMITS: ExecutionLimits = {
|
||||
maxDataBytes: 10_000_000,
|
||||
timeoutMs: 30_000,
|
||||
}
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
code: Schema.String.annotate({
|
||||
description: "JavaScript to run. Discover tools with `tools.$rune.search`/`tools.$rune.describe`, call them, and `return` the final value.",
|
||||
}),
|
||||
})
|
||||
|
||||
/** One child tool call, surfaced live so the UI can render a per-call line that
|
||||
* updates as the program runs. `tool` is the dotted path (e.g. `github.create_issue`). */
|
||||
export type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
|
||||
|
||||
type Metadata = {
|
||||
toolCalls: CallEntry[]
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A real attachment: identical to a session `FilePart` (minus the ids) and carrying
|
||||
* the actual bytes (`url`, often a base64 `data:` URL), so it lowers 1:1 into
|
||||
* `Tool.ExecuteResult.attachments`. This never crosses into the sandbox — the program
|
||||
* only ever sees the opaque {@link AttachmentHandle}.
|
||||
*/
|
||||
export type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number]
|
||||
|
||||
/**
|
||||
* The opaque, model-facing view of an attachment: metadata only, no bytes. A program
|
||||
* can inspect `mime`/`filename`/`bytes`, propagate the handle (return it to show the
|
||||
* user) or drop it, but can NOT read or leak the contents — so a stray `return`/log
|
||||
* can never dump a base64 blob back into the conversation.
|
||||
*/
|
||||
export type AttachmentHandle = { type: "file"; id: string; mime: string; filename?: string; bytes?: number }
|
||||
|
||||
/** The envelope every tool call resolves to, and the shape a program should `return`. */
|
||||
export type Envelope = { result: unknown; attachments?: AttachmentHandle[] }
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
const SEARCH = "search"
|
||||
const DESCRIBE = "describe"
|
||||
// The runtime's own capabilities live under `tools.$rune.*`, separated from the
|
||||
// MCP server namespaces. `$` can never appear in a sanitized server name, so this
|
||||
// namespace is collision-proof.
|
||||
const RUNE_NS = "$rune"
|
||||
|
||||
type CatalogEntry = {
|
||||
path: string
|
||||
key: string
|
||||
server: string
|
||||
local: string
|
||||
description: string
|
||||
tool: AITool
|
||||
outputSchema?: JSONSchema7
|
||||
}
|
||||
|
||||
const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim()
|
||||
const brief = (text: string | undefined, max = 120) => {
|
||||
const line = firstLine(text)
|
||||
return line.length > max ? line.slice(0, max - 1) + "…" : line
|
||||
}
|
||||
|
||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
if (typeof input === "object" && !Array.isArray(input)) {
|
||||
const value = input as Record<string, unknown>
|
||||
if (Object.keys(value).length > 0) return value
|
||||
return
|
||||
}
|
||||
return { input }
|
||||
}
|
||||
|
||||
/** Re-join accessed segments into the flat catalog key (`server_tool`). The
|
||||
* server/tool split is cosmetic, so `tools.a.b`, `tools["a.b"]`, `a/b`, and `a_b`
|
||||
* all resolve to the same key — the model never has to guess the separator. */
|
||||
const toKey = (segments: readonly string[]) => segments.join("_").replace(/[./]/g, "_")
|
||||
|
||||
/**
|
||||
* Group the flat `server_tool` catalog into per-server namespaces. `servers` are
|
||||
* the sanitized MCP client names; the longest matching prefix wins so a server
|
||||
* named `a_b` beats `a` for the key `a_b_tool`. `mcpDefs` carries the raw MCP
|
||||
* definitions (keyed identically) so each entry retains its `outputSchema`.
|
||||
*/
|
||||
export function groupByServer(
|
||||
mcpTools: Record<string, AITool>,
|
||||
servers: readonly string[],
|
||||
mcpDefs: Record<string, MCPToolDef> = {},
|
||||
): Map<string, CatalogEntry[]> {
|
||||
const byLongest = [...servers].sort((a, b) => b.length - a.length)
|
||||
const groups = new Map<string, CatalogEntry[]>()
|
||||
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
|
||||
const server = byLongest.find((name) => key.startsWith(name + "_")) ?? key.slice(0, key.indexOf("_"))
|
||||
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
|
||||
const output = mcpDefs[key]?.outputSchema as JSONSchema7 | undefined
|
||||
const entry: CatalogEntry = {
|
||||
path: `${server}.${local}`,
|
||||
key,
|
||||
server,
|
||||
local,
|
||||
description: mcpTools[key]!.description ?? "",
|
||||
tool: mcpTools[key]!,
|
||||
outputSchema: output,
|
||||
}
|
||||
groups.set(server, [...(groups.get(server) ?? []), entry])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
const access = (segment: string) => (IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)
|
||||
|
||||
/** An object property name, bare when it is a valid identifier, else quoted. */
|
||||
const propKey = (name: string) => (IDENTIFIER.test(name) ? name : JSON.stringify(name))
|
||||
|
||||
/** Join type strings into a union, de-duplicating members and preserving order. An
|
||||
* empty set of members (e.g. an empty `enum`/`anyOf`) is `never`, never the empty string. */
|
||||
const asUnion = (parts: string[]) => {
|
||||
const unique = [...new Set(parts)]
|
||||
return unique.length > 0 ? unique.join(" | ") : "never"
|
||||
}
|
||||
|
||||
/** Resolve a local JSON-Schema `$ref` (`#/$defs/Foo`) against the document root.
|
||||
* Returns undefined for external or unresolvable refs. */
|
||||
function resolveRef(ref: string, root: JSONSchema7 | undefined): JSONSchema7 | undefined {
|
||||
if (!root || !ref.startsWith("#/")) return undefined
|
||||
let node: unknown = root
|
||||
for (const raw of ref.slice(2).split("/")) {
|
||||
const segment = raw.replace(/~1/g, "/").replace(/~0/g, "~")
|
||||
if (!node || typeof node !== "object") return undefined
|
||||
node = (node as Record<string, unknown>)[segment]
|
||||
}
|
||||
return node && typeof node === "object" ? (node as JSONSchema7) : undefined
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 8
|
||||
|
||||
/** Options for {@link renderType}. `root` anchors `$ref` resolution (defaults to the
|
||||
* top-level schema); `pretty` switches from a single-line type to an indented,
|
||||
* JSDoc-annotated block used by `describe`. */
|
||||
export type RenderOptions = { root?: JSONSchema7; pretty?: boolean }
|
||||
|
||||
/**
|
||||
* Render a JSON Schema as a TypeScript type string for model-facing signatures.
|
||||
* Total (never throws — falls back to `any`/`object`) and cycle-safe: local `$ref`s
|
||||
* are inlined, self-referential ones collapse to the ref name. Compact by default
|
||||
* (single line, no docs); `pretty` produces an indented block with `/** … *\/` docs
|
||||
* on described fields. Handles enums, `const`, `anyOf`/`oneOf` unions, `allOf`
|
||||
* intersections (the common Pydantic `allOf: [{ $ref }]` shape), nullable `type`
|
||||
* arrays, tuples, and `additionalProperties`.
|
||||
*/
|
||||
export function renderType(
|
||||
def: JSONSchema7 | boolean | undefined,
|
||||
options: RenderOptions = {},
|
||||
depth = 0,
|
||||
seen: ReadonlySet<JSONSchema7> = new Set(),
|
||||
): string {
|
||||
if (!def || typeof def === "boolean") return "any"
|
||||
// Absolute recursion ceiling. Object/array recursion increments `depth`, and so do
|
||||
// the union/nullable branches below, so this bounds every recursion path — including
|
||||
// pure-union structural cycles that the `$ref` `seen` guard cannot see. Keeps the
|
||||
// "never throws" contract even for pathological (non-JSON-transport) input.
|
||||
if (depth > MAX_DEPTH) return "any"
|
||||
const root = options.root ?? def
|
||||
const opts: RenderOptions = { ...options, root }
|
||||
|
||||
if (typeof def.$ref === "string") {
|
||||
const target = resolveRef(def.$ref, root)
|
||||
const name = def.$ref.split("/").pop() || "any"
|
||||
if (!target) return "any"
|
||||
if (seen.has(target)) return name // recursive type: reference by name rather than loop
|
||||
return renderType(target, opts, depth, new Set([...seen, target]))
|
||||
}
|
||||
if (Array.isArray(def.enum)) return asUnion(def.enum.map((value) => JSON.stringify(value)))
|
||||
if (def.const !== undefined) return JSON.stringify(def.const)
|
||||
|
||||
// allOf = intersection. The dominant Pydantic/FastMCP shape is `allOf: [{ $ref }]`
|
||||
// with a sibling description/default, so a single member renders as just that member;
|
||||
// any base `properties` on `def` itself are intersected in.
|
||||
if (Array.isArray(def.allOf) && def.allOf.length > 0) {
|
||||
const base = def.properties || def.additionalProperties !== undefined ? renderObject(def, opts, depth, seen) : undefined
|
||||
const members = def.allOf.map((member) => renderType(member as JSONSchema7, opts, depth + 1, seen))
|
||||
const parts = [...(base ? [base] : []), ...members].filter((part) => part !== "any")
|
||||
return parts.length === 0 ? "any" : parts.length === 1 ? parts[0]! : parts.join(" & ")
|
||||
}
|
||||
|
||||
// Nullable / multi-type: `["string","null"]` -> `string | null` (don't drop members).
|
||||
if (Array.isArray(def.type)) {
|
||||
return asUnion(def.type.map((type) => renderType({ ...def, type }, opts, depth + 1, seen)))
|
||||
}
|
||||
|
||||
switch (def.type) {
|
||||
case "integer":
|
||||
return "number"
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
case "null":
|
||||
return def.type
|
||||
case "array": {
|
||||
const items = Array.isArray(def.items) ? def.items[0] : def.items
|
||||
const inner = renderType(items as JSONSchema7 | undefined, opts, depth + 1, seen)
|
||||
return /[ |&]/.test(inner) ? `(${inner})[]` : `${inner}[]`
|
||||
}
|
||||
}
|
||||
|
||||
if (def.type === "object" || def.properties || def.additionalProperties !== undefined) {
|
||||
return renderObject(def, opts, depth, seen)
|
||||
}
|
||||
|
||||
// anyOf / oneOf union — checked after object handling so a base object paired with a
|
||||
// `require one of` anyOf still renders its properties instead of collapsing to a union.
|
||||
const union = def.anyOf ?? def.oneOf
|
||||
if (Array.isArray(union)) return asUnion(union.map((alt) => renderType(alt as JSONSchema7, opts, depth + 1, seen)))
|
||||
return "any"
|
||||
}
|
||||
|
||||
/** Schema constraints that a TypeScript type can't express natively but a model
|
||||
* benefits from, surfaced as JSDoc tags (`@default`, `@format`, `@deprecated`, …). */
|
||||
function docTags(schema: JSONSchema7 | boolean | undefined): string[] {
|
||||
if (!schema || typeof schema === "boolean") return []
|
||||
// `deprecated` is a later JSON-Schema draft than the `ai` JSONSchema7 type models.
|
||||
const s = schema as JSONSchema7 & { deprecated?: boolean }
|
||||
const tags: string[] = []
|
||||
if (s.deprecated === true) tags.push("@deprecated")
|
||||
if (s.default !== undefined) {
|
||||
try {
|
||||
tags.push(`@default ${JSON.stringify(s.default)}`)
|
||||
} catch {
|
||||
// unserializable default: skip rather than emit a broken tag
|
||||
}
|
||||
}
|
||||
if (typeof s.format === "string") tags.push(`@format ${s.format}`)
|
||||
if (typeof s.minItems === "number") tags.push(`@minItems ${s.minItems}`)
|
||||
if (typeof s.maxItems === "number") tags.push(`@maxItems ${s.maxItems}`)
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
|
||||
* preserving multi-line text (a single line stays `/** … *\/`; multiple lines become a
|
||||
* `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
|
||||
* blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
|
||||
* callers can prepend it directly to the field line.
|
||||
*/
|
||||
function jsdoc(description: string | undefined, tags: string[], pad: string): string {
|
||||
const lines = [...(description ? description.split("\n") : []), ...tags].map((line) =>
|
||||
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
|
||||
)
|
||||
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
|
||||
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
|
||||
if (lines.length === 0) return ""
|
||||
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
|
||||
const body = lines.map((line) => `${pad} *${line ? ` ${line}` : ""}`).join("\n")
|
||||
return `${pad}/**\n${body}\n${pad} */\n`
|
||||
}
|
||||
|
||||
function renderObject(
|
||||
def: JSONSchema7,
|
||||
opts: RenderOptions,
|
||||
depth: number,
|
||||
seen: ReadonlySet<JSONSchema7>,
|
||||
): string {
|
||||
const props = (def.properties ?? {}) as Record<string, JSONSchema7>
|
||||
const names = Object.keys(props)
|
||||
const additional = def.additionalProperties
|
||||
const indexType =
|
||||
additional === true ? "any" : additional && typeof additional === "object" ? renderType(additional, opts, depth + 1, seen) : undefined
|
||||
if (names.length === 0) return indexType ? `{ [key: string]: ${indexType} }` : "object"
|
||||
if (depth >= MAX_DEPTH) return "object"
|
||||
|
||||
const required = new Set(Array.isArray(def.required) ? def.required : [])
|
||||
const field = (name: string) => `${propKey(name)}${required.has(name) ? "" : "?"}: ${renderType(props[name], opts, depth + 1, seen)}`
|
||||
|
||||
if (!opts.pretty) {
|
||||
const fields = names.map(field)
|
||||
if (indexType) fields.push(`[key: string]: ${indexType}`)
|
||||
return `{ ${fields.join("; ")} }`
|
||||
}
|
||||
|
||||
const pad = " ".repeat(depth + 1)
|
||||
const lines = names.map((name) => `${jsdoc(props[name]?.description, docTags(props[name]), pad)}${pad}${field(name)}`)
|
||||
if (indexType) lines.push(`${pad}[key: string]: ${indexType}`)
|
||||
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
|
||||
}
|
||||
|
||||
function inputType(tool: AITool): string {
|
||||
try {
|
||||
const schema = asSchema(tool.inputSchema).jsonSchema as JSONSchema7 | undefined
|
||||
if (!schema?.properties || typeof schema.properties !== "object") return "input"
|
||||
return renderType(schema)
|
||||
} catch {
|
||||
return "input"
|
||||
}
|
||||
}
|
||||
|
||||
/** The `T` in `Result<T>`: the structured `outputSchema` (when the MCP server declares
|
||||
* one), else `unknown` — an untyped result has no guaranteed shape and must be inspected,
|
||||
* not assumed. `Result<T>` itself is defined once in the tool description prose. */
|
||||
const resultType = (outputSchema: JSONSchema7 | undefined) => (outputSchema ? renderType(outputSchema) : "unknown")
|
||||
|
||||
/** The full, awaited call type shown by `tools.$rune.describe`. */
|
||||
const returnType = (outputSchema: JSONSchema7 | undefined) => `Promise<Result<${resultType(outputSchema)}>>`
|
||||
|
||||
const signatureFor = (entry: CatalogEntry) =>
|
||||
`tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): ${returnType(entry.outputSchema)}`
|
||||
|
||||
/** The directly-callable signature for the inline preview. Unlike the full `describe`
|
||||
* form it drops the uniform `Promise<…>` wrapper (calls are always awaited) but DOES
|
||||
* show the awaited `Result<T>` — so the model sees each tool's return shape without a
|
||||
* discovery round-trip. */
|
||||
const previewSignature = (entry: CatalogEntry) =>
|
||||
`tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): Result<${resultType(entry.outputSchema)}>`
|
||||
|
||||
/**
|
||||
* Character budget for the inline signature preview in the tool description. All
|
||||
* namespaces are always listed; per-tool call signatures are previewed (cheapest
|
||||
* first, server by server) until this many characters are used, after which the
|
||||
* remaining namespaces show counts only. This front-loads a directly-callable slice
|
||||
* of the catalog — cutting discovery round-trips — without dumping every signature.
|
||||
*/
|
||||
const PREVIEW_BUDGET = 2000
|
||||
|
||||
/**
|
||||
* The execute tool description: the calling convention, the discovery API, and the
|
||||
* list of namespaces. A budgeted preview of per-tool call signatures is inlined; the
|
||||
* full typed signature + schemas are fetched on demand with `tools.$rune.describe`,
|
||||
* and any tool not previewed must be found via `tools.$rune.search` first.
|
||||
*/
|
||||
export function describe(groups: Map<string, CatalogEntry[]>): string {
|
||||
const lines = [
|
||||
"Execute JavaScript with access to connected MCP tools, grouped into namespaces (one per MCP server).",
|
||||
"",
|
||||
"The runtime provides two discovery capabilities under `tools.$rune` (its own namespace, separate",
|
||||
"from your MCP servers):",
|
||||
"- `await tools.$rune.search(query, { namespace?, limit? })` -> `{ items: [{ path, description }], total }`",
|
||||
"- `await tools.$rune.describe(path)` -> `{ path, description, signature, input, output? }` (types as TypeScript)",
|
||||
"",
|
||||
"Call a tool by its path: `await tools.<server>.<tool>(input)`. Every call — and your final `return` —",
|
||||
"uses the same envelope: `type Result<T> = { result: T; attachments?: Attachment[] }`. The signatures",
|
||||
"below (and `tools.$rune.describe`) show each tool's `T` as its return type.",
|
||||
"",
|
||||
"`result` (the `T`) is the tool's own payload. It is typed `unknown` unless the server declares an output",
|
||||
"schema — an `unknown` result has NO guaranteed shape, so inspect it (e.g. `return` it to see it, or read",
|
||||
"it defensively) before assuming any fields.",
|
||||
"",
|
||||
"`attachments` are files a tool produced (an image, a document, …), given to you as references you hold",
|
||||
"but don't read inline: `type Attachment = { type: 'file'; mime: string; filename?: string; bytes?: number }`.",
|
||||
"To actually SEE a file — e.g. look at a screenshot before deciding your next step — include it in what you",
|
||||
"`return` (e.g. `return { result: summary, attachments: shot.attachments }`): returned attachments come back",
|
||||
"into the conversation as real viewable images/files, so both YOU (on your next turn) and the user can see",
|
||||
"them. Omit an attachment to discard it. You route whole attachment handles; you don't read their raw bytes.",
|
||||
"",
|
||||
"Only what you `return` re-enters the conversation — `result` becomes text; everything else in the sandbox",
|
||||
"stays there. Compose multiple calls in one program and `return` the final value. Use `tools.$rune.search('', { namespace })` to list a namespace.",
|
||||
]
|
||||
if (groups.size === 0) {
|
||||
lines.push("", "No MCP servers are currently connected.")
|
||||
return lines.join("\n")
|
||||
}
|
||||
// Select which signatures fit the budget (cheapest first within each server,
|
||||
// servers alphabetical) before emitting, so the list can state exactly how
|
||||
// comprehensive it is — overall and per namespace.
|
||||
const ordered = [...groups].sort(([a], [b]) => a.localeCompare(b))
|
||||
const shown = new Map<string, string[]>()
|
||||
let used = 0
|
||||
let budgetLeft = true
|
||||
let totalTools = 0
|
||||
let totalShown = 0
|
||||
for (const [server, entries] of ordered) {
|
||||
totalTools += entries.length
|
||||
const picked: string[] = []
|
||||
if (budgetLeft) {
|
||||
for (const entry of entries) {
|
||||
const line = ` - ${previewSignature(entry)}`
|
||||
if (used + line.length > PREVIEW_BUDGET) {
|
||||
budgetLeft = false
|
||||
break
|
||||
}
|
||||
picked.push(line)
|
||||
used += line.length
|
||||
}
|
||||
}
|
||||
shown.set(server, picked)
|
||||
totalShown += picked.length
|
||||
}
|
||||
|
||||
const complete = totalShown === totalTools
|
||||
lines.push(
|
||||
"",
|
||||
complete
|
||||
? "This is the COMPLETE list of available tools — every connected tool is shown below with its call signature. Use `tools.$rune.describe(path)` for a tool's full types."
|
||||
: `This is a PARTIAL list — ${totalShown} of ${totalTools} tools are shown below. Any tool not listed must be found with \`tools.$rune.search\` first; use \`tools.$rune.describe(path)\` for full types.`,
|
||||
)
|
||||
for (const [server, entries] of ordered) {
|
||||
const picked = shown.get(server)!
|
||||
const total = entries.length
|
||||
const count = `${total} tool${total === 1 ? "" : "s"}`
|
||||
// Annotate only when a namespace is not fully shown, so a comprehensive
|
||||
// namespace reads cleanly and a truncated one is unambiguous.
|
||||
const label =
|
||||
picked.length === total ? count : picked.length === 0 ? `${count}, none shown` : `${count}, ${picked.length} shown`
|
||||
lines.push(`- ${server} (${label})`)
|
||||
for (const line of picked) lines.push(line)
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const lastSegment = (uri: string) => {
|
||||
const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
|
||||
const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1)
|
||||
return segment.length > 0 ? segment : undefined
|
||||
}
|
||||
|
||||
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
|
||||
|
||||
/** Decoded byte length of a `data:` URL's base64 payload, or undefined for a
|
||||
* non-data URL (e.g. an external `resource_link`) whose size we don't know. */
|
||||
function dataUrlBytes(url: string): number | undefined {
|
||||
if (!url.startsWith("data:")) return undefined
|
||||
const comma = url.indexOf(",")
|
||||
if (comma === -1) return undefined
|
||||
const base64 = url.slice(comma + 1)
|
||||
if (base64.length === 0) return 0
|
||||
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0
|
||||
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding)
|
||||
}
|
||||
|
||||
/** Functions for converting between real attachments and the opaque handles the
|
||||
* sandbox sees. See {@link attachmentTable}. */
|
||||
export type AttachmentTable = {
|
||||
/** Register a real attachment, returning the opaque handle to hand to the program. */
|
||||
seal: (attachment: Attachment) => AttachmentHandle
|
||||
/** Resolve a handle the program returned back to its real attachment, or undefined
|
||||
* if it isn't one this table issued (a fabricated or stale handle is dropped). */
|
||||
resolve: (handle: unknown) => Attachment | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-execution table that keeps real attachment bytes host-side and only ever
|
||||
* exposes opaque handles to the sandbox. The bytes never enter the program's context,
|
||||
* so a program cannot read or accidentally re-emit them; on `return`, a propagated
|
||||
* handle is looked up here to recover the real attachment for the user.
|
||||
*/
|
||||
export function attachmentTable(): AttachmentTable {
|
||||
const real = new Map<string, Attachment>()
|
||||
let seq = 0
|
||||
return {
|
||||
seal(attachment) {
|
||||
const id = `att_${++seq}`
|
||||
real.set(id, attachment)
|
||||
const bytes = dataUrlBytes(attachment.url)
|
||||
return {
|
||||
type: "file",
|
||||
id,
|
||||
mime: attachment.mime,
|
||||
...(attachment.filename ? { filename: attachment.filename } : {}),
|
||||
...(bytes !== undefined ? { bytes } : {}),
|
||||
}
|
||||
},
|
||||
resolve(handle) {
|
||||
if (!handle || typeof handle !== "object") return undefined
|
||||
const id = (handle as Record<string, unknown>).id
|
||||
return typeof id === "string" ? real.get(id) : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an MCP tool result to the `{ result, attachments? }` envelope. `result`
|
||||
* is the structured content (or joined text); media blocks (image/audio/resource)
|
||||
* become opaque attachment handles via `seal` (the bytes stay host-side). Lenient —
|
||||
* never throws on unexpected shapes.
|
||||
*/
|
||||
export function toEnvelope(result: unknown, seal: AttachmentTable["seal"]): Envelope {
|
||||
if (result === null || typeof result !== "object") return { result }
|
||||
const record = result as { structuredContent?: unknown; content?: unknown }
|
||||
const attachments: AttachmentHandle[] = []
|
||||
const push = (attachment: Attachment) => attachments.push(seal(attachment))
|
||||
const text: string[] = []
|
||||
const content = Array.isArray(record.content) ? record.content : []
|
||||
for (const item of content) {
|
||||
if (!item || typeof item !== "object") continue
|
||||
const block = item as Record<string, unknown>
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
if (typeof block.text === "string") text.push(block.text)
|
||||
break
|
||||
case "image":
|
||||
case "audio":
|
||||
if (typeof block.data === "string" && typeof block.mimeType === "string") {
|
||||
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
|
||||
}
|
||||
break
|
||||
case "resource": {
|
||||
const res = block.resource as Record<string, unknown> | undefined
|
||||
if (res && typeof res === "object") {
|
||||
const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream"
|
||||
const uri = typeof res.uri === "string" ? res.uri : undefined
|
||||
if (typeof res.blob === "string") {
|
||||
push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined })
|
||||
} else if (typeof res.text === "string") {
|
||||
text.push(res.text)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "resource_link":
|
||||
if (typeof block.uri === "string") {
|
||||
push({
|
||||
type: "file",
|
||||
mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream",
|
||||
url: block.uri,
|
||||
filename: typeof block.name === "string" ? block.name : lastSegment(block.uri),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const value =
|
||||
record.structuredContent !== undefined && record.structuredContent !== null
|
||||
? record.structuredContent
|
||||
: text.length > 0
|
||||
? text.join("\n")
|
||||
: content.length > 0
|
||||
? undefined // media-only result
|
||||
: result
|
||||
|
||||
return attachments.length > 0 ? { result: value, attachments } : { result: value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Append captured `console.*` output to the model-facing text as a trailing `Logs:` section,
|
||||
* so a program's diagnostics ride back alongside its result (and errors). Each line is
|
||||
* `[level] message`; returns the text unchanged when nothing was logged. This is the sandbox's
|
||||
* only stdout-like channel — it goes to the model, not the user.
|
||||
*/
|
||||
export function withLogs(output: string, logs: ReadonlyArray<LogEntry>): string {
|
||||
if (logs.length === 0) return output
|
||||
const section = "Logs:\n" + logs.map((entry) => `[${entry.level}] ${entry.message}`).join("\n")
|
||||
return output.length > 0 ? `${output}\n\n${section}` : section
|
||||
}
|
||||
|
||||
/** Coerce the program's return value to model-facing text without ever failing on shape. */
|
||||
export function formatValue(value: unknown): string {
|
||||
if (typeof value === "string") return value
|
||||
if (value === undefined) return "undefined"
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower the program's return value into model-facing output + attachments. The value
|
||||
* is treated as a `{ result, attachments? }` envelope when it has a `result` key;
|
||||
* otherwise the whole value is the result. Attachments are model-curated: each returned
|
||||
* handle is resolved back to its real bytes via `resolve`; anything that isn't a handle
|
||||
* this run issued is dropped.
|
||||
*/
|
||||
export function fromReturn(
|
||||
value: unknown,
|
||||
resolve: AttachmentTable["resolve"],
|
||||
): { output: string; attachments?: Attachment[] } {
|
||||
if (value !== null && typeof value === "object" && "result" in value) {
|
||||
const env = value as { result: unknown; attachments?: unknown }
|
||||
const attachments = Array.isArray(env.attachments)
|
||||
? env.attachments.map(resolve).filter((a): a is Attachment => a !== undefined)
|
||||
: []
|
||||
return attachments.length > 0
|
||||
? { output: formatValue(env.result), attachments }
|
||||
: { output: formatValue(env.result) }
|
||||
}
|
||||
return { output: formatValue(value) }
|
||||
}
|
||||
|
||||
/** A search-indexed catalog entry: the fields ranking matches against, with
|
||||
* `searchText` (path + description + parameter names/descriptions) precomputed. */
|
||||
export type SearchEntry = { path: string; server: string; description: string; searchText: string }
|
||||
|
||||
/** The lowercased searchable text for a tool: its path, description, and the name
|
||||
* (and description, when present) of each input parameter. */
|
||||
function searchTextFor(entry: CatalogEntry): string {
|
||||
const parts = [entry.path, entry.description]
|
||||
try {
|
||||
const schema = asSchema(entry.tool.inputSchema).jsonSchema as JSONSchema7 | undefined
|
||||
const props = schema?.properties
|
||||
if (props && typeof props === "object") {
|
||||
for (const [name, value] of Object.entries(props)) {
|
||||
parts.push(name)
|
||||
const desc = (value as JSONSchema7 | undefined)?.description
|
||||
if (typeof desc === "string") parts.push(desc)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall back to path + description only
|
||||
}
|
||||
return parts.join("\n").toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a query into lowercased search terms. camelCase boundaries are split
|
||||
* (`resolveLibrary` -> `resolve library`) and `_ - . /` are treated as separators,
|
||||
* so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all tokenize
|
||||
* alike. Empties and the `*` wildcard are dropped.
|
||||
*/
|
||||
const tokenize = (query: string) =>
|
||||
query
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((term) => term.length > 0 && term !== "*")
|
||||
|
||||
/**
|
||||
* Rank catalog entries against a query using tokenized, field-weighted scoring
|
||||
* (adapted from the deferred-tool-search bridge). Each term contributes per field:
|
||||
* exact tool name (20) > path substring (8) > description (4) > any searchable text (2),
|
||||
* summed across terms. Because paths are `server.tool`, the exact tier matches a
|
||||
* whole path segment (e.g. the term `search` matches `github.search`). An empty
|
||||
* query lists everything (alphabetical). Results are ranked by score, tie-broken by path.
|
||||
*/
|
||||
export function rankTools(
|
||||
entries: ReadonlyArray<SearchEntry>,
|
||||
query: string,
|
||||
namespace?: string,
|
||||
limit = 25,
|
||||
): { items: { path: string; description: string }[]; total: number } {
|
||||
const terms = tokenize(query)
|
||||
const scoped = namespace ? entries.filter((entry) => entry.server === namespace) : entries
|
||||
const ranked = scoped
|
||||
.map((entry) => {
|
||||
const path = entry.path.toLowerCase()
|
||||
const description = entry.description.toLowerCase()
|
||||
const score = terms.reduce(
|
||||
(total, term) =>
|
||||
total +
|
||||
(path === term || path.endsWith(`.${term}`) ? 20 : 0) +
|
||||
(path.includes(term) ? 8 : 0) +
|
||||
(description.includes(term) ? 4 : 0) +
|
||||
(entry.searchText.includes(term) ? 2 : 0),
|
||||
0,
|
||||
)
|
||||
return { entry, score }
|
||||
})
|
||||
.filter((item) => terms.length === 0 || item.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.entry.path.localeCompare(b.entry.path))
|
||||
return {
|
||||
items: ranked.slice(0, limit).map(({ entry }) => ({ path: entry.path, description: brief(entry.description) })),
|
||||
total: ranked.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function define(
|
||||
mcpTools: Record<string, AITool>,
|
||||
mcpDefs: Record<string, MCPToolDef>,
|
||||
servers: readonly string[],
|
||||
) {
|
||||
const groups = groupByServer(mcpTools, servers, mcpDefs)
|
||||
const catalog: CatalogEntry[] = [...groups.values()].flat()
|
||||
const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const))
|
||||
const index: SearchEntry[] = catalog.map((entry) => ({
|
||||
path: entry.path,
|
||||
server: entry.server,
|
||||
description: entry.description,
|
||||
searchText: searchTextFor(entry),
|
||||
}))
|
||||
|
||||
const search = (query: unknown, options: unknown) => {
|
||||
const q = typeof query === "string" ? query : ""
|
||||
const opts = (options ?? {}) as { namespace?: unknown; limit?: unknown }
|
||||
const namespace = typeof opts.namespace === "string" ? opts.namespace : undefined
|
||||
const limit = typeof opts.limit === "number" && opts.limit > 0 ? Math.floor(opts.limit) : 25
|
||||
return rankTools(index, q, namespace, limit)
|
||||
}
|
||||
|
||||
const describeTool = (path: unknown) => {
|
||||
if (typeof path !== "string") return { error: { code: "invalid_path", message: "describe expects a tool path string." } }
|
||||
const entry = byKey.get(toKey([path]))
|
||||
if (!entry) {
|
||||
// Fuzzy "did you mean": rank the leaf name within its namespace, then fall
|
||||
// back to a global search. Split only on namespace separators (`. _ /`) so a
|
||||
// hyphenated tool name (e.g. `resolve-library-id`) stays one searchable leaf.
|
||||
const segments = path.split(/[._/]+/).filter((s) => s.length > 0)
|
||||
const leaf = segments.at(-1) ?? path
|
||||
const namespace = segments.length > 1 ? segments[0] : undefined
|
||||
const scoped = namespace ? rankTools(index, leaf, namespace, 5).items : []
|
||||
const suggestions = (scoped.length > 0 ? scoped : rankTools(index, leaf, undefined, 5).items).map((i) => i.path)
|
||||
return { error: { code: "tool_not_found", message: `No tool at '${path}'.`, suggestions } }
|
||||
}
|
||||
// Everything the model sees is TypeScript: `signature` is the compact one-line
|
||||
// call form; `input`/`output` are the detailed types (multi-line, with JSDoc for
|
||||
// any described fields and literal unions for enums) that raw JSON Schema used to
|
||||
// carry. `output` is present only when the server declares an outputSchema.
|
||||
let input = "unknown"
|
||||
try {
|
||||
const schema = asSchema(entry.tool.inputSchema).jsonSchema as JSONSchema7 | undefined
|
||||
input = renderType(schema, { pretty: true })
|
||||
} catch {
|
||||
input = "unknown"
|
||||
}
|
||||
return {
|
||||
path: entry.path,
|
||||
description: entry.description,
|
||||
signature: signatureFor(entry),
|
||||
input,
|
||||
...(entry.outputSchema ? { output: renderType(entry.outputSchema, { pretty: true }) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
return Tool.define(
|
||||
CODE_MODE_TOOL,
|
||||
Effect.succeed<Tool.DefWithoutID<typeof Parameters, Metadata>>({
|
||||
description: describe(groups),
|
||||
parameters: Parameters,
|
||||
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
|
||||
const calls: CallEntry[] = []
|
||||
// Real attachment bytes stay in this table for the life of the call; the sandbox
|
||||
// only ever handles opaque references to them (see attachmentTable).
|
||||
const files = attachmentTable()
|
||||
// Stream the current call list to the UI. Sent on every status change so the
|
||||
// tool part shows each child call appearing and resolving while the program runs.
|
||||
const publish = (error?: boolean) =>
|
||||
ctx.metadata({ title: "execute", metadata: { toolCalls: calls.map((c) => ({ ...c })), ...(error ? { error } : {}) } })
|
||||
const mark = (index: number, status: CallEntry["status"]) =>
|
||||
Effect.suspend(() => {
|
||||
calls[index] = { ...calls[index]!, status }
|
||||
return publish()
|
||||
})
|
||||
const tracked = <A, E, R>(tool: string, input: unknown, effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const index = calls.length
|
||||
const childInput = displayInput(input)
|
||||
calls.push({ tool, status: "running", ...(childInput ? { input: childInput } : {}) })
|
||||
yield* publish()
|
||||
return yield* effect.pipe(
|
||||
Effect.tap(() => mark(index, "completed")),
|
||||
Effect.tapError(() => mark(index, "error")),
|
||||
)
|
||||
})
|
||||
|
||||
// One host function per MCP tool: gate on permission, dispatch to the native
|
||||
// MCP tool, and coerce the result into the { result, attachments? } envelope.
|
||||
// A failure (e.g. an MCP isError) fails the Effect, which the interpreter
|
||||
// surfaces as a catchable in-program error.
|
||||
const callTool = (entry: CatalogEntry) => (input: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({ permission: entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
return yield* tracked(entry.path, input, Effect.tryPromise({
|
||||
try: () =>
|
||||
Promise.resolve(
|
||||
entry.tool.execute!(input ?? {}, {
|
||||
toolCallId: ctx.callID ?? entry.key,
|
||||
abortSignal: ctx.abort,
|
||||
messages: [],
|
||||
}),
|
||||
),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.map((raw) => toEnvelope(raw, files.seal)),
|
||||
))
|
||||
})
|
||||
|
||||
// The Rune host-tool tree: per-server namespaces (`tools.<server>.<tool>`)
|
||||
// plus the runtime's own discovery capabilities under `tools.$rune.*`. The
|
||||
// interpreter resolves and invokes these; approving `execute` does not
|
||||
// approve any child call.
|
||||
const tools: HostTools = {
|
||||
[RUNE_NS]: {
|
||||
[SEARCH]: (query: unknown, options: unknown) =>
|
||||
tracked(
|
||||
"$rune.search",
|
||||
{ query, ...(typeof options === "object" && options !== null && !Array.isArray(options) ? options : {}) },
|
||||
Effect.succeed(search(query, options)),
|
||||
),
|
||||
[DESCRIBE]: (path: unknown) => tracked("$rune.describe", { path }, Effect.succeed(describeTool(path))),
|
||||
},
|
||||
}
|
||||
for (const entry of catalog) {
|
||||
if (!entry.tool.execute) continue
|
||||
let namespace = tools[entry.server] as HostTools | undefined
|
||||
if (!namespace) {
|
||||
namespace = {}
|
||||
tools[entry.server] = namespace
|
||||
}
|
||||
namespace[entry.local] = callTool(entry)
|
||||
}
|
||||
|
||||
const result = yield* Rune.execute({
|
||||
code: params.code,
|
||||
tools: tools as unknown as Record<string, never>,
|
||||
limits: CODE_LIMITS,
|
||||
})
|
||||
|
||||
if (result.ok) {
|
||||
const { output, attachments } = fromReturn(result.value, files.resolve)
|
||||
return {
|
||||
title: "execute",
|
||||
metadata: { toolCalls: calls },
|
||||
output: withLogs(output, result.logs),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}
|
||||
// Point the model at discovery when it references a tool that does not exist.
|
||||
const hint =
|
||||
result.error.kind === "UnknownCapability"
|
||||
? "\nUse tools.$rune.search(query) to discover available tools."
|
||||
: ""
|
||||
return {
|
||||
title: "execute",
|
||||
metadata: { toolCalls: calls, error: true },
|
||||
output: withLogs(result.error.message + hint, result.logs),
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1237,6 +1237,8 @@ export const layer = Layer.effect(
|
||||
Effect.provideService(ToolRegistry.Service, registry),
|
||||
Effect.provideService(MCP.Service, mcp),
|
||||
Effect.provideService(Truncate.Service, truncate),
|
||||
Effect.provideService(Agent.Service, agents),
|
||||
Effect.provideService(RuntimeFlags.Service, flags),
|
||||
)
|
||||
|
||||
if (lastUser.format?.type === "json_schema") {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
/** Safe operational refusal from a standard capability pack, reported as `CapabilityFailure`. */
|
||||
export class CapabilityError extends Schema.TaggedErrorClass<CapabilityError>()("CapabilityError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export const capabilityError = (message: string, cause?: unknown): CapabilityError =>
|
||||
new CapabilityError({ message, ...(cause === undefined ? {} : { cause }) })
|
||||
@@ -0,0 +1,243 @@
|
||||
# Rune
|
||||
|
||||
A sandboxed JavaScript interpreter. It runs untrusted model-authored code that calls
|
||||
host-provided tools and transforms plain JSON. It is **not** a JS engine: it walks an
|
||||
AST and hand-implements an allowlisted subset of the language. Anything not explicitly
|
||||
implemented throws.
|
||||
|
||||
## How it works
|
||||
|
||||
### Pipeline (`Rune.execute`)
|
||||
1. **Wrap** the source in `async function __rune__() { ... }`.
|
||||
2. **Transpile** via the TypeScript compiler (`transpileModule`, target ESNext) — strips
|
||||
types only. Runtime-agnostic (no `Bun.Transpiler`, no Node `module` APIs).
|
||||
3. **Parse** the stripped body with `acorn` → ESTree AST. (Acorn is a parser only: it
|
||||
provides zero runtime/stdlib.)
|
||||
4. **Interpret**: a tree-walking evaluator (`Interpreter`) executes the AST as an
|
||||
`Effect`. Host tool calls are async; everything else is synchronous evaluation.
|
||||
|
||||
### Values
|
||||
- Only **Data Values** cross boundaries: `null`, `undefined`, `boolean`, `number`,
|
||||
`string`, and null-prototype objects/arrays of Data Values.
|
||||
- `copyIn`/`copyOut` deep-clone at every boundary (tool args, tool results, return).
|
||||
Runtime references (functions, `tools`) never leak into data.
|
||||
- Objects are null-prototype (no inherited `Object` methods, no prototype pollution).
|
||||
|
||||
### Tools
|
||||
- The host passes a tree of functions under `tools`. The program calls
|
||||
`await tools.<path>(...)`; the interpreter resolves the path and invokes the host
|
||||
function, copying args in and the result back.
|
||||
- Unknown path → `UnknownCapability`. Host failures surface as catchable in-program
|
||||
errors.
|
||||
|
||||
### Result
|
||||
`{ ok: true, value, toolCalls, logs }` or `{ ok: false, error: { kind, message, location?,
|
||||
suggestions? }, toolCalls, logs }`. `value` is the program's `return`. Uncaught `throw`
|
||||
becomes `ok: false`. `logs` is the captured `console.*` output (see below), present on
|
||||
every path — including timeout and failure — so logs emitted before a crash survive.
|
||||
|
||||
### Limits (`ExecutionLimits`, enforced; defaults)
|
||||
| Limit | Default | Bounds |
|
||||
|---|---|---|
|
||||
| `maxOperations` | 100,000 | interpreter steps (kills infinite loops) |
|
||||
| `timeoutMs` | 10,000 | wall-clock |
|
||||
| `maxToolCalls` | 100 | tool invocations |
|
||||
| `maxConcurrency` | 8 | in-flight `Promise.all` calls |
|
||||
| `maxSourceBytes` | 32,000 | source size |
|
||||
| `maxDataBytes` | 256,000 | any single data value (args/result/assignment/return) |
|
||||
| `maxAuditBytes` | 1,000,000 | cumulative tool-call audit trail |
|
||||
| `maxValueDepth` | 32 | nesting depth of a data value |
|
||||
| `maxCollectionLength` | 10,000 | elements/keys in one array/object |
|
||||
|
||||
Bytes are measured ~`JSON.stringify(value).byteLength`. `maxDataBytes` is per-value,
|
||||
not cumulative. (Code mode overrides `maxDataBytes`→10MB, `timeoutMs`→30s.)
|
||||
|
||||
## Standard library (allowlist — everything else throws)
|
||||
|
||||
- **Globals**: `tools`, `console`, `Promise`, `Object`, `Math`, `JSON`, `Number`, `String`,
|
||||
`Boolean`, `Array`, `parseInt`, `parseFloat`, `undefined`.
|
||||
- **console**: `log`/`warn`/`error`/`info`/`debug`. Each formats its args (strings verbatim,
|
||||
objects/arrays as JSON, space-joined) and appends a line to `logs`; it returns `undefined`,
|
||||
is **not** a tool call (spends no tool-call budget), and any other member throws. Formatting
|
||||
is charged to `maxOperations`, and total captured output is bounded by `maxAuditBytes`.
|
||||
- **String**: case/trim, split, slice/substring/substr, includes/startsWith/endsWith,
|
||||
indexOf/lastIndexOf, replace/replaceAll, repeat, padStart/padEnd, charAt/at,
|
||||
charCodeAt/codePointAt, concat. **String args only.**
|
||||
- **Array**: map/filter/reduce/reduceRight/forEach/find/findIndex/some/every/sort/
|
||||
toSorted/slice/concat/flat/flatMap/reverse/toReversed/with/join/includes/indexOf/at +
|
||||
push/pop/shift/unshift.
|
||||
- **Object**: keys/values/entries/hasOwn/assign/fromEntries.
|
||||
- **Math**: max/min/abs/floor/ceil/round/trunc/sign/sqrt/cbrt/pow/hypot/log/log2/log10/
|
||||
exp, PI, E.
|
||||
- **Number**: isInteger/isFinite/isNaN/isSafeInteger/parseInt/parseFloat; instance
|
||||
toFixed/toExponential/toPrecision/toString(radix).
|
||||
- **JSON**: parse, stringify (no replacer).
|
||||
- **Syntax**: arrow + `function` (hoisted), closures, default/rest params, destructuring,
|
||||
spread, optional chaining, template literals, conditionals, switch, loops, `for...of`,
|
||||
try/catch, ternary, `in`, logical assignment, bitwise ops, `await`, `Promise.all`.
|
||||
|
||||
## JavaScript semantics (parity with JS, so defensive code doesn't crash)
|
||||
|
||||
Reading a value behaves like JS even when a key/name is absent — it yields `undefined` rather
|
||||
than throwing, so idiomatic defensive code (`a?.b ?? c`, feature detection, merges) works:
|
||||
|
||||
- **Unknown property reads yield `undefined`.** `"s".foo`, `(5).foo`, `[1,2].foo`, and
|
||||
`obj.missing` are all `undefined` — including under `?.` (which only guards `null`/`undefined`
|
||||
receivers). MCP results are often JSON *strings*, so `result?.field ?? result` reads the field
|
||||
when present and falls back to the raw string otherwise instead of crashing. (Only the method
|
||||
*allowlist* still errors — e.g. calling an unsupported `arr.splice(...)` gives a rewrite hint.)
|
||||
- **`typeof undeclaredIdentifier` is `"undefined"`**, never a reference error, so
|
||||
`typeof x !== "undefined"` guards are safe. (A bare `x` reference still throws.)
|
||||
- **`{ ...null }` / `{ ...undefined }` is a no-op**, so `{ ...maybeOpts, override }` merges work
|
||||
when the operand is absent.
|
||||
- **Builtin coercions are valid array callbacks**: `filter(Boolean)`, `map(String)`, `map(Number)`.
|
||||
- **`NaN`/`Infinity` flow as ordinary values** (and are bindable identifiers), so `Number(x)` /
|
||||
`parseInt(x) || 0` / averages / counters don't crash mid-expression — guards like
|
||||
`Number.isNaN(x)` get to run. A non-finite number is normalized to `null` only when it crosses
|
||||
out of the sandbox (final `return` or a tool argument), exactly as `JSON.stringify` would.
|
||||
|
||||
## What is missing
|
||||
|
||||
- **`Date`** — no dates or time.
|
||||
- **`RegExp` / regex literals** — none. `replace`/`split` take plain strings only.
|
||||
- **`Map` / `Set` / `WeakMap` / `WeakSet`** — none.
|
||||
- **`Promise`** — only `Promise.all`. No `new Promise`, `race`, `allSettled`, `resolve`,
|
||||
`reject`.
|
||||
- **`new`** — only the `Error` family (`Error`, `TypeError`, `RangeError`, `SyntaxError`,
|
||||
`ReferenceError`, `EvalError`, `URIError`), and they yield plain `{ name, message }`
|
||||
data objects, not real `Error` instances.
|
||||
- **Classes, generators, `async function*`, `for await...of`, labeled break/continue** —
|
||||
rejected as `UnsupportedSyntax`.
|
||||
- **`Symbol`, `BigInt`** — none.
|
||||
- **Partial built-ins** — e.g. `JSON.stringify` ignores replacers; `Array.from` takes no
|
||||
map fn. (`NaN`/`Infinity` are usable in-sandbox but serialize to `null` at the boundary — see
|
||||
JavaScript semantics above.)
|
||||
- **No I/O** — no fetch, fs, timers, env, or any ambient capability. The only outside
|
||||
contact is host `tools`.
|
||||
|
||||
# Code mode (discovery layer)
|
||||
|
||||
Code mode (`session/code-mode.ts`) is a *consumer* of Rune, not part of the interpreter. It
|
||||
exposes connected MCP tools to the program as `tools.<server>.<tool>(input)` and adds its own
|
||||
discovery capabilities under `tools.$rune.*`. The design deliberately tolerates the mistakes
|
||||
weaker models commonly make rather than punishing them. The trade-offs below are intentional.
|
||||
|
||||
## tools.$rune.search(query, { namespace?, limit? })
|
||||
|
||||
Ranked, in-memory search over the tool catalog, recomputed per call (no persistent index).
|
||||
Returns `{ items: [{ path, description }], total }`.
|
||||
|
||||
- **Weighted, tokenized scoring** (`rankTools`): per query term, exact path segment (20) >
|
||||
path substring (8) > description (4) > any indexed text (2), summed across terms. The
|
||||
indexed text is the path, the description, and each input parameter's name + description —
|
||||
so a query can match a tool by one of its argument names.
|
||||
- **camelCase / separator-resistant tokenizer** (`tokenize`): splits camelCase boundaries and
|
||||
treats `_ - . /` as separators, so `library`, `resolveLibraryId`, and `resolve-library-id`
|
||||
all tokenize alike. Models phrase queries inconsistently; this keeps recall up without the
|
||||
query having to match a tool's exact casing or punctuation.
|
||||
- **Namespace scoping**: optional `namespace` filters to one server. An empty query (or bare
|
||||
`*`) lists everything alphabetically. `limit` caps `items` (default 25); `total` always
|
||||
reports the full match count so the model knows when results were truncated.
|
||||
|
||||
## tools.$rune.describe(path)
|
||||
|
||||
Returns `{ path, description, signature, input, output? }` for one tool. **Everything is
|
||||
TypeScript — no raw JSON Schema is ever surfaced to the model.**
|
||||
|
||||
- **Compact signature + detailed TS types.** `signature` is the one-line call form, e.g.
|
||||
`tools.github.create_issue(input: { title: string; body?: string }): Promise<Result<unknown>>`,
|
||||
where `type Result<T> = { result: T; attachments?: Attachment[] }` is defined once in the tool
|
||||
description prose so signatures stay short.
|
||||
`input` (and `output`, when the server declares an `outputSchema`) are the *detailed* types
|
||||
rendered by `renderType` in pretty mode: an indented block with `/** … */` JSDoc on described
|
||||
fields and literal unions for enums. This carries everything the raw JSON Schema used to —
|
||||
parameter docs, enums, required-ness — now expressed as TypeScript rather than a schema blob.
|
||||
A field's `description` becomes a JSDoc comment (single-line `/** … */`, or a multi-line
|
||||
block, preserved as written) because a bare type has nowhere else to hold it; `*/` in a
|
||||
description is neutralized so it cannot close the comment early. `describe` is on-demand, so
|
||||
keeping the docs costs nothing on the hot path.
|
||||
- **`renderType` is a total, cycle-safe JSON-Schema → TS renderer.** Local `$ref`s
|
||||
(`#/$defs/…`) are resolved against the document root; a self-referential ref collapses to its
|
||||
name rather than looping. It renders enums/`const` as literals, `anyOf`/`oneOf` and nullable
|
||||
`type` arrays (`["string","null"]` → `string | null`) as unions, `allOf` as an intersection
|
||||
(unwrapping the common Pydantic `allOf: [{ $ref }]` shape), tuples, and
|
||||
`additionalProperties` as an index signature. It never throws — unknown shapes fall back to
|
||||
`any`/`object`, and depth is capped. (Rune's own Effect-schema renderer in `rune/tool.ts`,
|
||||
`renderSchema`, is separate and has known gaps — no `$ref` cycle guard, and unions containing
|
||||
a number collapse to `number`; code mode does not use it.)
|
||||
- **Return type shown ahead of the call — in the preview too.** Every tool resolves to
|
||||
`Result<T>`, where `T` is the structured `outputSchema` when the server declares one, else
|
||||
`unknown`. The `T` is surfaced not just by `describe` but in the budgeted inline preview in the
|
||||
tool description (`tools.x.y(input: …): Result<T>`), so the model sees a tool's result shape
|
||||
without a discovery round-trip. `unknown` is deliberate — an untyped result has no guaranteed
|
||||
shape (many MCP servers return plain text), so the prose directs the model to inspect it (e.g.
|
||||
`return` it) before assuming fields, rather than pretending a shape we can't verify.
|
||||
|
||||
## Attachments are opaque handles — bytes never enter the sandbox
|
||||
|
||||
A tool's media (image/audio/resource content blocks) becomes an `attachments` array on the
|
||||
result envelope, but the program only ever sees an **opaque handle**, not the bytes:
|
||||
`type Attachment = { type: 'file'; id: string; mime: string; filename?: string; bytes?: number }`.
|
||||
The real bytes (a base64 `data:` URL) are kept host-side in a per-execution `attachmentTable`
|
||||
keyed by `id` (`code-mode.ts`); the handle carries only metadata. The program can inspect
|
||||
`mime`/`bytes`, **propagate** a handle (return it under `attachments` to show the user), or
|
||||
**drop** it — but it cannot read or re-emit the contents. On `return`, each propagated handle is
|
||||
resolved back to its real attachment via the table; a fabricated or stale handle resolves to
|
||||
nothing and is dropped.
|
||||
|
||||
This is a deliberate divergence from the prior art we studied (both expose the base64 directly
|
||||
and lean on prompt guidance plus output truncation). Making the handle opaque means a careless
|
||||
`return`/log **cannot** dump a base64 blob back into the conversation — the leak is structurally
|
||||
impossible rather than merely discouraged. The trade-off: a program can no longer read attachment
|
||||
bytes to route them into another tool's input; if that need arises it would be an explicit host
|
||||
call (e.g. a `readAttachment(handle)`), not the always-on default. Not implemented yet.
|
||||
|
||||
## console output is surfaced to the model as a trailing `Logs:` section
|
||||
|
||||
The sandbox has no stdout, but `console.log`/`warn`/`error`/`info`/`debug` are available (an
|
||||
interpreter builtin, not a tool). Code mode reads the run's `logs` and, when non-empty, appends
|
||||
them to the model-facing text as a trailing section — one `[level] message` line each:
|
||||
|
||||
```
|
||||
<result text>
|
||||
|
||||
Logs:
|
||||
[log] resolved 3 candidates
|
||||
[warn] falling back to first match
|
||||
```
|
||||
|
||||
This holds on the error path too, so a program's diagnostics ride back with the failure that
|
||||
followed them. Logs go to the **model only** (not the user) — they are the program's scratch
|
||||
channel for narrating what it did, distinct from the `return` value (the answer) and returned
|
||||
`attachments` (media for the model + user). A program that logs nothing gets no section.
|
||||
|
||||
## Path handling — separator-tolerant
|
||||
|
||||
The flat catalog key is `server_tool`, but the model is never required to guess the
|
||||
separator. `toKey` normalizes `. / _` to the same key, so `tools.context7.resolve-library-id`,
|
||||
`describe('context7/resolve-library-id')`, and `describe('context7_resolve-library-id')` all
|
||||
resolve to the same tool. A slash-vs-dot mismatch previously made `describe` silently miss
|
||||
(returning a soft error the model then destructured into `{}`); normalizing the separator
|
||||
removes that whole failure mode.
|
||||
|
||||
## Tool-call errors throw; discovery errors are soft
|
||||
|
||||
A **tool call** that fails (an MCP `isError`, a transport failure, or a call to a path that
|
||||
doesn't exist) throws inside the program, so the model uses ordinary `try`/`catch` — the same
|
||||
control flow it already writes for any async call. We deliberately do *not* wrap results in an
|
||||
`{ ok, error }` value: a uniform success envelope (`{ result, attachments? }`) plus normal
|
||||
exceptions is simpler to reason about and to type than forcing every call site to branch on a
|
||||
discriminated union. An uncaught tool error fails the whole `execute` run and is reported back.
|
||||
|
||||
The **discovery helpers** are the exception — see below.
|
||||
|
||||
## Discovery errors are soft, with "did you mean" — never thrown
|
||||
|
||||
`tools.$rune.search` and `tools.$rune.describe` never throw. An unknown `describe(path)`
|
||||
returns `{ error: { code: 'tool_not_found', message, suggestions } }`. Suggestions come from a
|
||||
fuzzy fallback: rank the *leaf* name within its namespace, then fall back to a global search,
|
||||
returning real callable paths (e.g. `context7/resolve-library` → suggests
|
||||
`context7.resolve-library-id`). This avoids derailing a whole program over one typo — the model
|
||||
branches on `result.error` and retries with a suggestion. (Tool
|
||||
*calls* on a genuinely unknown path still surface as a catchable in-program error via Rune's
|
||||
`UnknownCapability`; only the discovery helpers return soft errors.)
|
||||
@@ -0,0 +1,2888 @@
|
||||
import { parse } from "acorn"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
import {
|
||||
copyIn,
|
||||
copyOut,
|
||||
dataByteLength,
|
||||
isBlockedMember,
|
||||
ToolReference,
|
||||
ToolRuntime,
|
||||
ToolRuntimeError,
|
||||
type HostTools,
|
||||
type SafeObject,
|
||||
type ToolCall,
|
||||
type ToolDescription,
|
||||
type Services,
|
||||
} from "./tool-runtime.js"
|
||||
import type { Definition } from "./tool.js"
|
||||
import { CapabilityError } from "./capability-error.js"
|
||||
|
||||
export type { ToolCall, ToolDescription } from "./tool-runtime.js"
|
||||
export { Tool } from "./tool.js"
|
||||
export { CapabilityError } from "./capability-error.js"
|
||||
|
||||
/** Resource budgets enforced during each Rune Program execution. */
|
||||
export type ExecutionLimits = {
|
||||
readonly maxOperations?: number
|
||||
readonly maxToolCalls?: number
|
||||
readonly maxConcurrency?: number
|
||||
readonly maxSourceBytes?: number
|
||||
readonly maxDataBytes?: number
|
||||
readonly maxAuditBytes?: number
|
||||
readonly maxValueDepth?: number
|
||||
readonly maxCollectionLength?: number
|
||||
readonly timeoutMs?: number
|
||||
}
|
||||
|
||||
type CapabilityTree<R = never> = {
|
||||
readonly [name: string]: Definition<R> | CapabilityTree<R>
|
||||
}
|
||||
|
||||
type ResolvedExecutionLimits = {
|
||||
readonly maxOperations: number
|
||||
readonly maxToolCalls: number
|
||||
readonly maxConcurrency: number
|
||||
readonly maxSourceBytes: number
|
||||
readonly maxDataBytes: number
|
||||
readonly maxAuditBytes: number
|
||||
readonly maxValueDepth: number
|
||||
readonly maxCollectionLength: number
|
||||
readonly timeoutMs: number
|
||||
}
|
||||
|
||||
export type ExecuteOptions<Tools extends Record<string, unknown> = {}> = {
|
||||
code: string
|
||||
tools?: Tools & CapabilityTree<any>
|
||||
limits?: ExecutionLimits
|
||||
}
|
||||
|
||||
/** One captured `console.*` line: the method used and the formatted message. */
|
||||
export type LogEntry = {
|
||||
readonly level: "log" | "warn" | "error" | "info" | "debug"
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
export type ExecuteResult =
|
||||
| {
|
||||
ok: true
|
||||
value: unknown
|
||||
toolCalls: ReadonlyArray<ToolCall>
|
||||
logs: ReadonlyArray<LogEntry>
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
error: {
|
||||
kind: DiagnosticKind
|
||||
message: string
|
||||
location?: { readonly line: number; readonly column: number }
|
||||
suggestions?: ReadonlyArray<string>
|
||||
}
|
||||
toolCalls: ReadonlyArray<ToolCall>
|
||||
logs: ReadonlyArray<LogEntry>
|
||||
}
|
||||
|
||||
export type RuneOptions<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "code">
|
||||
|
||||
/** Input schema for the single agent-facing tool produced by `rune.asTool()`. */
|
||||
export const CodeInput = Schema.Struct({ code: Schema.String })
|
||||
|
||||
const DiagnosticKindSchema = Schema.Literals([
|
||||
"ParseError", "UnsupportedSyntax", "UnknownCapability", "InvalidToolInput", "InvalidToolOutput", "InvalidDataValue",
|
||||
"OperationLimitExceeded", "ToolCallLimitExceeded", "AuditLimitExceeded", "ConcurrencyLimitExceeded", "TimeoutExceeded",
|
||||
"CapabilityFailure", "ExecutionFailure",
|
||||
])
|
||||
|
||||
/** Structured success or diagnostic result schema returned by Rune execution. */
|
||||
export const ExecuteResultSchema = Schema.Union([
|
||||
Schema.Struct({
|
||||
ok: Schema.Literal(true),
|
||||
value: Schema.Unknown,
|
||||
toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })),
|
||||
logs: Schema.Array(Schema.Struct({ level: Schema.String, message: Schema.String })),
|
||||
}),
|
||||
Schema.Struct({
|
||||
ok: Schema.Literal(false),
|
||||
error: Schema.Struct({
|
||||
kind: DiagnosticKindSchema,
|
||||
message: Schema.String,
|
||||
location: Schema.optional(Schema.Struct({ line: Schema.Number, column: Schema.Number })),
|
||||
suggestions: Schema.optional(Schema.Array(Schema.String)),
|
||||
}),
|
||||
toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })),
|
||||
logs: Schema.Array(Schema.Struct({ level: Schema.String, message: Schema.String })),
|
||||
}),
|
||||
])
|
||||
|
||||
export type CodeTool<R = never> = {
|
||||
readonly name: "code"
|
||||
readonly description: string
|
||||
readonly input: typeof CodeInput
|
||||
readonly execute: (input: { readonly code: string }) => Effect.Effect<ExecuteResult, never, R>
|
||||
}
|
||||
|
||||
export type Rune<R = never> = {
|
||||
/** Lists schema-described capability paths provided by the host. */
|
||||
readonly catalog: () => ReadonlyArray<ToolDescription>
|
||||
/** Builds model-facing syntax guidance and visible capability signatures. */
|
||||
readonly instructions: () => string
|
||||
/** Projects the configured runtime as one agent-facing `code` tool. */
|
||||
readonly asTool: () => CodeTool<R>
|
||||
/** Executes a program using this runtime's configured host tools. */
|
||||
readonly run: (code: string) => Effect.Effect<ExecuteResult, never, R>
|
||||
}
|
||||
|
||||
type SourcePosition = {
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
type SourceLocation = {
|
||||
start: SourcePosition
|
||||
end: SourcePosition
|
||||
}
|
||||
|
||||
type AstNode = {
|
||||
type: string
|
||||
loc?: SourceLocation
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type ProgramNode = AstNode & {
|
||||
type: "Program"
|
||||
body: Array<AstNode>
|
||||
}
|
||||
|
||||
type Binding = {
|
||||
mutable: boolean
|
||||
value: unknown
|
||||
// Absent means initialized. `false` marks a parameter binding seeded into its scope but not
|
||||
// yet bound, so a default that forward-references a later parameter sees a TDZ error (as in JS)
|
||||
// rather than silently resolving to an outer binding of the same name.
|
||||
initialized?: boolean
|
||||
}
|
||||
|
||||
type StatementResult =
|
||||
| { kind: "none" }
|
||||
| { kind: "value"; value: unknown }
|
||||
| { kind: "return"; value: unknown }
|
||||
| { kind: "break" }
|
||||
| { kind: "continue" }
|
||||
|
||||
type MemberReference = {
|
||||
target: SafeObject | Array<unknown>
|
||||
key: string | number
|
||||
}
|
||||
|
||||
class RuneFunction {
|
||||
constructor(
|
||||
readonly parameters: ReadonlyArray<AstNode>,
|
||||
readonly body: AstNode,
|
||||
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
|
||||
) {}
|
||||
}
|
||||
|
||||
class IntrinsicReference {
|
||||
constructor(
|
||||
readonly receiver: unknown,
|
||||
readonly name: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
// A read-only computed member (e.g. `str.length`, a character index) — not assignable.
|
||||
class ComputedValue {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
class PromiseNamespace {}
|
||||
|
||||
class PromiseAllReference {}
|
||||
|
||||
// A built-in global namespace (`Object`, `Math`, `JSON`, `Array`); members resolve to a
|
||||
// GlobalMethodReference, except known constants (e.g. `Math.PI`) which resolve to a value.
|
||||
class GlobalNamespace {
|
||||
constructor(readonly name: "Object" | "Math" | "JSON" | "Array") {}
|
||||
}
|
||||
|
||||
class GlobalMethodReference {
|
||||
constructor(readonly namespace: "Object" | "Math" | "JSON" | "Array" | "Number" | "String", readonly name: string) {}
|
||||
}
|
||||
|
||||
// A built-in callable global (`Number`, `String`, `Boolean`, `parseInt`, `parseFloat`).
|
||||
class CoercionFunction {
|
||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
|
||||
}
|
||||
|
||||
// The `console` builtin. `console.<level>` resolves to a ConsoleMethodReference; calling it
|
||||
// formats its arguments and appends a line to the run's shared LogCollector. Logging is a pure
|
||||
// side effect on the host — it never dispatches a tool, spends the tool-call budget, or returns
|
||||
// a value the program can branch on (every method returns undefined, like JS `console`).
|
||||
class ConsoleReference {}
|
||||
|
||||
const consoleLevels = new Set(["log", "warn", "error", "info", "debug"] as const)
|
||||
|
||||
class ConsoleMethodReference {
|
||||
constructor(readonly level: LogEntry["level"]) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects `console.*` output for one execution. Shared by reference across parallel
|
||||
* interpreter forks (like the operation budget), so logs emitted inside `Promise.all`
|
||||
* / `.map` callbacks are captured too. Bounded by a total character budget — once spent,
|
||||
* further lines are dropped (the last accepted line is truncated to fit) so a logging loop
|
||||
* can't grow memory without also spending the operation budget.
|
||||
*/
|
||||
class LogCollector {
|
||||
readonly entries: LogEntry[] = []
|
||||
private used = 0
|
||||
constructor(private readonly maxChars: number) {}
|
||||
push(level: LogEntry["level"], message: string): void {
|
||||
if (this.used >= this.maxChars) return
|
||||
const remaining = this.maxChars - this.used
|
||||
const text = message.length > remaining ? message.slice(0, remaining) : message
|
||||
this.used += text.length
|
||||
this.entries.push({ level, message: text })
|
||||
}
|
||||
}
|
||||
|
||||
/** Format one `console.*` argument the way `console` roughly does: strings verbatim,
|
||||
* objects/arrays as JSON, opaque runtime references as a placeholder (never leaking their
|
||||
* internals), everything else via String(). Never throws. */
|
||||
const formatLogArg = (value: unknown): string => {
|
||||
if (typeof value === "string") return value
|
||||
if (value === null) return "null"
|
||||
if (value === undefined) return "undefined"
|
||||
if (isRuntimeReference(value)) return "[runtime reference]"
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value) ?? coerceToString(value)
|
||||
} catch {
|
||||
return coerceToString(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
class ProgramThrow {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
export type DiagnosticKind =
|
||||
| "ParseError"
|
||||
| "UnsupportedSyntax"
|
||||
| "UnknownCapability"
|
||||
| "InvalidToolInput"
|
||||
| "InvalidToolOutput"
|
||||
| "InvalidDataValue"
|
||||
| "OperationLimitExceeded"
|
||||
| "ToolCallLimitExceeded"
|
||||
| "AuditLimitExceeded"
|
||||
| "ConcurrencyLimitExceeded"
|
||||
| "TimeoutExceeded"
|
||||
| "CapabilityFailure"
|
||||
| "ExecutionFailure"
|
||||
|
||||
const arrayMethods = new Set([
|
||||
"map", "filter", "find", "findIndex", "findLast", "findLastIndex", "some", "every", "includes", "join",
|
||||
"reduce", "reduceRight", "flatMap", "forEach", "sort", "toSorted", "slice", "concat", "indexOf", "lastIndexOf",
|
||||
"at", "flat", "reverse", "toReversed", "with", "push", "pop", "shift", "unshift",
|
||||
])
|
||||
const retryableArrayMethods = new Set(["splice", "fill", "copyWithin", "keys", "values", "entries"])
|
||||
|
||||
/**
|
||||
* Array methods whose cost is O(1) (or bounded by the argument count), so they must
|
||||
* NOT be charged the receiver's length. Charging `push` per element would make an
|
||||
* accumulation loop quadratic in the operation budget and trip it on legitimate code.
|
||||
*/
|
||||
const cheapArrayMethods = new Set(["push", "pop", "at"])
|
||||
|
||||
const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"])
|
||||
|
||||
const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"])
|
||||
|
||||
const stringMethods = new Set([
|
||||
"toLowerCase", "toUpperCase", "trim", "trimStart", "trimEnd", "split", "slice", "substring", "substr",
|
||||
"includes", "startsWith", "endsWith", "indexOf", "lastIndexOf", "replace", "replaceAll",
|
||||
"repeat", "padStart", "padEnd", "charAt", "charCodeAt", "codePointAt", "at", "concat", "toString",
|
||||
])
|
||||
|
||||
const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"])
|
||||
|
||||
const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"])
|
||||
|
||||
const stringStatics = new Set(["fromCharCode", "fromCodePoint"])
|
||||
|
||||
const errorConstructors = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "EvalError", "URIError"])
|
||||
|
||||
const OptionalShortCircuit: unique symbol = Symbol("rune.optional-short-circuit")
|
||||
|
||||
const supportedSyntaxMessage =
|
||||
"Supported orchestration syntax: tools.* calls, data literals, destructuring, optional chaining, template literals, conditionals, switch, loops, arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods, Object/Math/JSON helpers, and Promise.all([tool calls]) or Promise.all(items.map((item) => tool call)) for parallel tool calls."
|
||||
|
||||
const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
|
||||
new InterpreterRuntimeError(`Syntax '${kind}' is not supported in Rune. ${supportedSyntaxMessage}`, node, "UnsupportedSyntax", [supportedSyntaxMessage])
|
||||
|
||||
export const defaultExecutionLimits = (): ResolvedExecutionLimits => ({
|
||||
maxOperations: 100_000,
|
||||
maxToolCalls: 100,
|
||||
maxConcurrency: 8,
|
||||
maxSourceBytes: 32_000,
|
||||
maxDataBytes: 256_000,
|
||||
maxAuditBytes: 1_000_000,
|
||||
maxValueDepth: 32,
|
||||
maxCollectionLength: 10_000,
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
|
||||
export const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({
|
||||
...defaultExecutionLimits(),
|
||||
...limits,
|
||||
})
|
||||
|
||||
class InterpreterRuntimeError extends Error {
|
||||
readonly node?: AstNode
|
||||
|
||||
constructor(message: string, node?: AstNode, readonly kind: DiagnosticKind = "ExecutionFailure", readonly suggestions?: ReadonlyArray<string>) {
|
||||
super(message)
|
||||
this.name = "InterpreterRuntimeError"
|
||||
|
||||
if (node) {
|
||||
this.node = node
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null
|
||||
|
||||
const asNode = (value: unknown, context: string): AstNode => {
|
||||
if (!isRecord(value) || typeof value.type !== "string") {
|
||||
throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`)
|
||||
}
|
||||
|
||||
return value as AstNode
|
||||
}
|
||||
|
||||
const getArray = (node: AstNode, key: string): Array<unknown> => {
|
||||
const value = node[key]
|
||||
if (!Array.isArray(value)) {
|
||||
throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const getString = (node: AstNode, key: string): string => {
|
||||
const value = node[key]
|
||||
if (typeof value !== "string") {
|
||||
throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const getBoolean = (node: AstNode, key: string): boolean => {
|
||||
const value = node[key]
|
||||
if (typeof value !== "boolean") {
|
||||
throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => {
|
||||
const value = node[key]
|
||||
if (value === undefined || value === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return asNode(value, key)
|
||||
}
|
||||
|
||||
const getNode = (node: AstNode, key: string): AstNode => {
|
||||
const value = node[key]
|
||||
return asNode(value, key)
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): ProgramNode => {
|
||||
const transpiled = transpileModule(`async function __rune__() {\n${code}\n}`, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
|
||||
if (diagnostic) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
|
||||
undefined,
|
||||
"ParseError",
|
||||
)
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
const parsed = parse(executableCode, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
allowAwaitOutsideFunction: true,
|
||||
locations: true,
|
||||
}) as unknown
|
||||
|
||||
if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) {
|
||||
throw new InterpreterRuntimeError("Failed to parse script as a Program node.")
|
||||
}
|
||||
|
||||
return parsed as ProgramNode
|
||||
}
|
||||
|
||||
const formatLocation = (node?: AstNode): string => {
|
||||
if (!node || !node.loc) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const location = sourceLocation(node)
|
||||
return ` (line ${location.line}, col ${location.column})`
|
||||
}
|
||||
|
||||
const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({
|
||||
line: Math.max(1, (node.loc?.start.line ?? 2) - 1),
|
||||
column: Math.max(1, (node.loc?.start.column ?? 4) - 3),
|
||||
})
|
||||
|
||||
type Diagnostic = Extract<ExecuteResult, { ok: false }>['error']
|
||||
|
||||
const publicErrorMessage = (message: string): string =>
|
||||
message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "<redacted-path>")
|
||||
|
||||
const normalizeError = (error: unknown): Diagnostic => {
|
||||
if (error instanceof InterpreterRuntimeError) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: `${error.message}${formatLocation(error.node)}`,
|
||||
...(error.node?.loc ? { location: sourceLocation(error.node) } : {}),
|
||||
...(error.suggestions ? { suggestions: error.suggestions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ToolRuntimeError) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: error.message,
|
||||
...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof CapabilityError) {
|
||||
return { kind: "CapabilityFailure", message: publicErrorMessage(error.message) }
|
||||
}
|
||||
|
||||
if (error instanceof ProgramThrow) {
|
||||
const value = error.value
|
||||
let message: string
|
||||
if (containsRuntimeReference(value)) {
|
||||
// A thrown capability/function reference must not leak its internal structure.
|
||||
message = "a non-data value"
|
||||
} else if (typeof value === "string") {
|
||||
message = value
|
||||
} else if (value !== null && typeof value === "object" && typeof (value as { message?: unknown }).message === "string") {
|
||||
message = (value as { message: string }).message
|
||||
} else {
|
||||
try {
|
||||
message = JSON.stringify(copyOut(value)) ?? String(value)
|
||||
} catch {
|
||||
message = String(value)
|
||||
}
|
||||
}
|
||||
return { kind: "ExecutionFailure", message: `Uncaught: ${message}` }
|
||||
}
|
||||
|
||||
if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) {
|
||||
return {
|
||||
kind: "ExecutionFailure",
|
||||
message: "Execution exceeded the maximum nesting depth.",
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure",
|
||||
message: publicErrorMessage(error.message),
|
||||
}
|
||||
}
|
||||
|
||||
// A non-Error thrown by a host tool (raw string / number / Symbol) still routes through
|
||||
// path redaction so filesystem paths can never leak through the catch-all branch.
|
||||
return {
|
||||
kind: "ExecutionFailure",
|
||||
message: publicErrorMessage(String(error)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Built-in method/global implementations ───────────────────────────────────
|
||||
// These mirror the corresponding JavaScript operations over Data Values. They are
|
||||
// pure (string/Object/Math/JSON/coercion) and so live as free functions; array
|
||||
// methods that run Rune callbacks live on the interpreter (they need invokeFunction).
|
||||
|
||||
const boundedData = (value: unknown, label: string, node: AstNode, limits: ResolvedExecutionLimits): unknown => {
|
||||
const copied = copyIn(value, label, limits)
|
||||
if (dataByteLength(copied) > limits.maxDataBytes) {
|
||||
throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue")
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof RuneFunction || value instanceof ToolReference || value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalNamespace || value instanceof GlobalMethodReference || value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseAllReference || value instanceof CoercionFunction ||
|
||||
value instanceof ConsoleReference || value instanceof ConsoleMethodReference
|
||||
|
||||
const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
const contains = Array.isArray(value)
|
||||
? value.some((item) => containsRuntimeReference(item, seen))
|
||||
: Object.values(value).some((item) => containsRuntimeReference(item, seen))
|
||||
seen.delete(value)
|
||||
return contains
|
||||
}
|
||||
|
||||
const runtimeValueBytes = (
|
||||
value: unknown,
|
||||
label: string,
|
||||
node: AstNode,
|
||||
limits: ResolvedExecutionLimits,
|
||||
depth = 0,
|
||||
seen = new Set<object>(),
|
||||
): number => {
|
||||
if (depth > limits.maxValueDepth) {
|
||||
throw new InterpreterRuntimeError(`${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`, node, "InvalidDataValue")
|
||||
}
|
||||
if (isRuntimeReference(value)) return 0
|
||||
if (value === null || value === undefined || typeof value === "string" || typeof value === "boolean" || typeof value === "number") {
|
||||
return dataByteLength(value)
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
throw new InterpreterRuntimeError(`${label} must contain data or Rune references only.`, node, "InvalidDataValue")
|
||||
}
|
||||
if (seen.has(value)) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
seen.add(value)
|
||||
let bytes = 2
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue")
|
||||
}
|
||||
for (const item of value) bytes += runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 1
|
||||
} else {
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length > limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue")
|
||||
}
|
||||
for (const [key, item] of entries) {
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`${label} contains blocked property '${key}'.`, node, "InvalidDataValue")
|
||||
bytes += dataByteLength(key) + runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 1
|
||||
}
|
||||
}
|
||||
seen.delete(value)
|
||||
return bytes
|
||||
}
|
||||
|
||||
const boundedProgramValue = (value: unknown, label: string, node: AstNode, limits: ResolvedExecutionLimits): unknown => {
|
||||
if (runtimeValueBytes(value, label, node, limits) > limits.maxDataBytes) {
|
||||
throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// A cheap proxy for the work an O(n) built-in performed, used to charge the operation budget.
|
||||
const workUnits = (value: unknown): number => {
|
||||
if (typeof value === "string" || Array.isArray(value)) return value.length
|
||||
if (value !== null && typeof value === "object") return Object.keys(value).length
|
||||
return 1
|
||||
}
|
||||
|
||||
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode, limits: ResolvedExecutionLimits): unknown => {
|
||||
const str = (index: number): string => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "string") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
||||
return arg
|
||||
}
|
||||
const num = (index: number): number => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
||||
return arg
|
||||
}
|
||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
||||
const byteLength = (text: string): number => new TextEncoder().encode(text).byteLength
|
||||
const limitString = (bytes: number): void => {
|
||||
if (bytes > limits.maxDataBytes) {
|
||||
throw new InterpreterRuntimeError(`String.${name} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue")
|
||||
}
|
||||
}
|
||||
const replacementCount = (search: string): number => {
|
||||
if (search === "") return value.length + 1
|
||||
let count = 0
|
||||
let offset = 0
|
||||
while ((offset = value.indexOf(search, offset)) !== -1) {
|
||||
count += 1
|
||||
offset += search.length
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
case "toLowerCase": result = value.toLowerCase(); break
|
||||
case "toUpperCase": result = value.toUpperCase(); break
|
||||
case "trim": result = value.trim(); break
|
||||
case "trimStart": result = value.trimStart(); break
|
||||
case "trimEnd": result = value.trimEnd(); break
|
||||
case "split": {
|
||||
if (args.length === 0) {
|
||||
result = [value]
|
||||
break
|
||||
}
|
||||
const separator = str(0)
|
||||
const requestedLimit = optNum(1)
|
||||
const effectiveLimit = requestedLimit === undefined ? undefined : requestedLimit >>> 0
|
||||
const maximumParts = separator === "" ? value.length : replacementCount(separator) + 1
|
||||
const parts = effectiveLimit === undefined ? maximumParts : Math.min(maximumParts, effectiveLimit)
|
||||
if (parts > limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`String.split exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue")
|
||||
}
|
||||
result = value.split(separator, effectiveLimit)
|
||||
break
|
||||
}
|
||||
case "slice": result = value.slice(optNum(0), optNum(1)); break
|
||||
case "includes": result = value.includes(str(0), optNum(1)); break
|
||||
case "startsWith": result = value.startsWith(str(0), optNum(1)); break
|
||||
case "endsWith": result = value.endsWith(str(0), optNum(1)); break
|
||||
case "indexOf": result = value.indexOf(str(0), optNum(1)); break
|
||||
case "lastIndexOf": result = value.lastIndexOf(str(0), optNum(1)); break
|
||||
case "replace": result = value.replace(str(0), str(1)); break
|
||||
case "replaceAll": {
|
||||
const search = str(0)
|
||||
const replacement = str(1)
|
||||
const growth = Math.max(0, byteLength(replacement) - byteLength(search))
|
||||
limitString(byteLength(value) + replacementCount(search) * growth)
|
||||
result = value.replaceAll(search, replacement)
|
||||
break
|
||||
}
|
||||
case "repeat": {
|
||||
const count = num(0)
|
||||
if (!Number.isFinite(count) || count < 0) throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
||||
limitString(byteLength(value) * Math.floor(count))
|
||||
result = value.repeat(count)
|
||||
break
|
||||
}
|
||||
case "padStart": {
|
||||
const length = num(0)
|
||||
limitString(Math.max(0, length))
|
||||
result = value.padStart(length, optStr(1))
|
||||
break
|
||||
}
|
||||
case "padEnd": {
|
||||
const length = num(0)
|
||||
limitString(Math.max(0, length))
|
||||
result = value.padEnd(length, optStr(1))
|
||||
break
|
||||
}
|
||||
case "charAt": result = value.charAt(optNum(0) ?? 0); break
|
||||
case "at": result = value.at(optNum(0) ?? 0); break
|
||||
case "substring": result = value.substring(optNum(0) ?? 0, optNum(1)); break
|
||||
case "substr": result = value.substr(optNum(0) ?? 0, optNum(1)); break
|
||||
// JS charCodeAt returns NaN out of range, but Rune forbids NaN as a Data Value;
|
||||
// yield undefined instead, matching codePointAt and `at` (the other absent-slot sentinels).
|
||||
case "charCodeAt": { const code = value.charCodeAt(optNum(0) ?? 0); result = Number.isNaN(code) ? undefined : code; break }
|
||||
case "codePointAt": result = value.codePointAt(optNum(0) ?? 0); break
|
||||
case "toString": result = value; break
|
||||
case "concat": {
|
||||
const pieces = args.map((_, index) => str(index))
|
||||
limitString(byteLength(value) + pieces.reduce((size, piece) => size + byteLength(piece), 0))
|
||||
result = value.concat(...pieces)
|
||||
break
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`String method '${name}' is not available in Rune.`, node)
|
||||
}
|
||||
return boundedData(result, `String.${name} result`, node, limits)
|
||||
}
|
||||
|
||||
const invokeNumberMethod = (value: number, name: string, args: Array<unknown>, node: AstNode, limits: ResolvedExecutionLimits): unknown => {
|
||||
const optNum = (index: number): number | undefined => {
|
||||
const arg = args[index]
|
||||
if (arg === undefined) return undefined
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node)
|
||||
return arg
|
||||
}
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
case "toFixed": result = value.toFixed(optNum(0)); break
|
||||
case "toExponential": result = value.toExponential(optNum(0)); break
|
||||
case "toPrecision": {
|
||||
const digits = optNum(0)
|
||||
result = digits === undefined ? value.toString() : value.toPrecision(digits)
|
||||
break
|
||||
}
|
||||
case "toString": {
|
||||
const radix = optNum(0)
|
||||
if (radix !== undefined && (radix < 2 || radix > 36)) {
|
||||
throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node)
|
||||
}
|
||||
result = value.toString(radix)
|
||||
break
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`Number method '${name}' is not available in Rune.`, node)
|
||||
}
|
||||
return boundedData(result, `Number.${name} result`, node, limits)
|
||||
}
|
||||
|
||||
// JavaScript's String(...) without tripping over Rune's null-prototype data objects.
|
||||
const coerceToString = (value: unknown): string => {
|
||||
if (value === null) return "null"
|
||||
if (value === undefined) return "undefined"
|
||||
if (typeof value === "object") {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
|
||||
: "[object Object]"
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const coerceToNumber = (value: unknown): number =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
|
||||
|
||||
const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode, limits: ResolvedExecutionLimits): unknown => {
|
||||
const value = boundedData(args[0], `${ref.name} input`, node, limits)
|
||||
if (ref.name === "Number") return coerceToNumber(value)
|
||||
if (ref.name === "Boolean") return Boolean(value)
|
||||
if (ref.name === "parseInt") {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number") throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
|
||||
return parseInt(coerceToString(value), radix)
|
||||
}
|
||||
if (ref.name === "parseFloat") return parseFloat(coerceToString(value))
|
||||
return coerceToString(value)
|
||||
}
|
||||
|
||||
const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode, limits: ResolvedExecutionLimits): unknown => {
|
||||
const requireObject = (): Record<string, unknown> => {
|
||||
const value = boundedData(args[0], `Object.${name} input`, node, limits)
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, node)
|
||||
out[key] = item
|
||||
}
|
||||
switch (name) {
|
||||
case "keys": return Object.keys(requireObject())
|
||||
case "values": return Object.values(requireObject())
|
||||
case "entries": return Object.entries(requireObject()).map(([key, item]) => [key, item])
|
||||
case "hasOwn": return Object.hasOwn(requireObject(), String(args[1]))
|
||||
case "assign": {
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const source of args) {
|
||||
if (source === null || source === undefined) continue
|
||||
const value = boundedData(source, "Object.assign input", node, limits)
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
for (const [key, item] of Object.entries(value)) guardedSet(out, key, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
case "fromEntries": {
|
||||
const pairs = boundedData(args[0], "Object.fromEntries input", node, limits)
|
||||
if (!Array.isArray(pairs)) throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const pair of pairs) {
|
||||
if (!Array.isArray(pair)) throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node)
|
||||
guardedSet(out, String(pair[0]), pair[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`Object.${name} is not available in Rune.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||
const nums = args.map((arg) => {
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
|
||||
return arg
|
||||
})
|
||||
const [a = Number.NaN, b = Number.NaN] = nums
|
||||
switch (name) {
|
||||
case "max": return Math.max(...nums)
|
||||
case "min": return Math.min(...nums)
|
||||
case "abs": return Math.abs(a)
|
||||
case "floor": return Math.floor(a)
|
||||
case "ceil": return Math.ceil(a)
|
||||
case "round": return Math.round(a)
|
||||
case "trunc": return Math.trunc(a)
|
||||
case "sign": return Math.sign(a)
|
||||
case "sqrt": return Math.sqrt(a)
|
||||
case "cbrt": return Math.cbrt(a)
|
||||
case "pow": return Math.pow(a, b)
|
||||
case "hypot": return Math.hypot(...nums)
|
||||
case "log": return Math.log(a)
|
||||
case "log2": return Math.log2(a)
|
||||
case "log10": return Math.log10(a)
|
||||
case "exp": return Math.exp(a)
|
||||
default: throw new InterpreterRuntimeError(`Math.${name} is not available in Rune.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode, limits: ResolvedExecutionLimits): unknown => {
|
||||
switch (name) {
|
||||
case "stringify": {
|
||||
const replacer = args[1]
|
||||
if (Array.isArray(replacer) || replacer instanceof RuneFunction) {
|
||||
throw new InterpreterRuntimeError("JSON.stringify replacers are not supported in Rune.", node, "UnsupportedSyntax", [supportedSyntaxMessage])
|
||||
}
|
||||
const space = args[2]
|
||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||
// copyIn first so only Data Values serialize — never a RuneFunction/ToolReference.
|
||||
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value", limits)), null, indent)
|
||||
}
|
||||
case "parse": {
|
||||
const text = args[0]
|
||||
if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError("JSON.parse received invalid JSON.", node)
|
||||
}
|
||||
return copyIn(parsed, "JSON.parse result", limits)
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`JSON.${name} is not available in Rune.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode, limits: ResolvedExecutionLimits): unknown => {
|
||||
switch (name) {
|
||||
case "isArray":
|
||||
return Array.isArray(args[0])
|
||||
case "of":
|
||||
return [...args]
|
||||
case "from": {
|
||||
if (args.length > 1) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from(...) does not support a map function in Rune; call .map() on the result instead.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
const source = boundedData(args[0], "Array.from input", node, limits)
|
||||
if (typeof source === "string") return Array.from(source)
|
||||
if (Array.isArray(source)) return [...source]
|
||||
if (source !== null && typeof source === "object" && typeof (source as { length?: unknown }).length === "number") {
|
||||
return Array.from(source as ArrayLike<unknown>)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Array.from expects an array, string, or array-like value.", node)
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Array.${name} is not available in Rune.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeNumberStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const value = args[0]
|
||||
switch (name) {
|
||||
case "isInteger": return Number.isInteger(value)
|
||||
case "isFinite": return Number.isFinite(value)
|
||||
case "isNaN": return Number.isNaN(value)
|
||||
case "isSafeInteger": return Number.isSafeInteger(value)
|
||||
case "parseInt": {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number") throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
|
||||
return parseInt(coerceToString(value), radix)
|
||||
}
|
||||
case "parseFloat": return parseFloat(coerceToString(value))
|
||||
default: throw new InterpreterRuntimeError(`Number.${name} is not available in Rune.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeStringStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const codes = args.map((arg) => {
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node)
|
||||
return arg
|
||||
})
|
||||
switch (name) {
|
||||
case "fromCharCode": return String.fromCharCode(...codes)
|
||||
case "fromCodePoint": return String.fromCodePoint(...codes)
|
||||
default: throw new InterpreterRuntimeError(`String.${name} is not available in Rune.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode, limits: ResolvedExecutionLimits): unknown => {
|
||||
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node, limits)
|
||||
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node, limits)
|
||||
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
|
||||
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
|
||||
return invokeJsonMethod(ref.name, args, node, limits)
|
||||
}
|
||||
|
||||
// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run.
|
||||
const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<string> => {
|
||||
switch (pattern.type) {
|
||||
case "Identifier":
|
||||
out.push(getString(pattern, "name"))
|
||||
break
|
||||
case "AssignmentPattern":
|
||||
collectPatternNames(getNode(pattern, "left"), out)
|
||||
break
|
||||
case "RestElement":
|
||||
collectPatternNames(getNode(pattern, "argument"), out)
|
||||
break
|
||||
case "ArrayPattern":
|
||||
for (const element of getArray(pattern, "elements")) {
|
||||
if (element !== null) collectPatternNames(asNode(element, "elements"), out)
|
||||
}
|
||||
break
|
||||
case "ObjectPattern":
|
||||
for (const property of getArray(pattern, "properties")) {
|
||||
const prop = asNode(property, "properties")
|
||||
collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out)
|
||||
}
|
||||
break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
class Interpreter<R> {
|
||||
private scopes: Array<Map<string, Binding>>
|
||||
private readonly limits: ResolvedExecutionLimits
|
||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
private readonly budget: { operations: number }
|
||||
// Shared by reference with any parallel forks (see forkForParallelCallback) so every
|
||||
// console.* line lands in one ordered collection regardless of which fork emitted it.
|
||||
private readonly logs: LogCollector
|
||||
private lastValue: unknown
|
||||
// Cached byte size (and, for objects, key count) of each live container, maintained incrementally
|
||||
// by the mutation helpers so appending in a loop is O(1)/op rather than re-walking the whole
|
||||
// container each time (which made push/index-assign/key-assign loops O(n^2) — a CPU DoS). These
|
||||
// are a fast path under the authoritative copyIn/copyOut boundary checks, never a replacement.
|
||||
private readonly containerSizes = new WeakMap<object, number>()
|
||||
private readonly objectCounts = new WeakMap<object, number>()
|
||||
|
||||
constructor(
|
||||
limits: ResolvedExecutionLimits,
|
||||
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
budget: { operations: number } = { operations: 0 },
|
||||
logs: LogCollector = new LogCollector(limits.maxAuditBytes),
|
||||
) {
|
||||
const globalScope = new Map<string, Binding>()
|
||||
this.scopes = [globalScope]
|
||||
this.limits = limits
|
||||
this.invokeTool = invokeTool
|
||||
this.budget = budget
|
||||
this.logs = logs
|
||||
this.lastValue = undefined
|
||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
||||
globalScope.set("console", { mutable: false, value: new ConsoleReference() })
|
||||
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
||||
globalScope.set("undefined", { mutable: false, value: undefined })
|
||||
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
|
||||
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
|
||||
globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") })
|
||||
globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") })
|
||||
globalScope.set("String", { mutable: false, value: new CoercionFunction("String") })
|
||||
globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") })
|
||||
globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") })
|
||||
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
|
||||
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
|
||||
// Non-finite numbers flow as ordinary values inside the sandbox (normalized to null at the
|
||||
// boundary — see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`.
|
||||
globalScope.set("NaN", { mutable: false, value: NaN })
|
||||
globalScope.set("Infinity", { mutable: false, value: Infinity })
|
||||
}
|
||||
|
||||
run(program: ProgramNode): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
// Run the program body in its own module scope on top of the builtin global scope, so
|
||||
// top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like
|
||||
// JS module scope, instead of colliding with the seeded globals.
|
||||
this.pushScope()
|
||||
return Effect.gen(function*() {
|
||||
self.hoistFunctions(program.body)
|
||||
for (const statement of program.body) {
|
||||
const result = yield* self.evaluateStatement(statement)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result.value
|
||||
}
|
||||
|
||||
if (result.kind === "break" || result.kind === "continue") {
|
||||
throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
}
|
||||
|
||||
return self.lastValue
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
private evaluateStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
this.recordOperation(node)
|
||||
|
||||
switch (node.type) {
|
||||
case "ExpressionStatement":
|
||||
return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value }))
|
||||
case "VariableDeclaration":
|
||||
return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" }))
|
||||
case "ReturnStatement": {
|
||||
const argumentNode = getOptionalNode(node, "argument")
|
||||
return argumentNode
|
||||
? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value }))
|
||||
: Effect.succeed({ kind: "return", value: undefined })
|
||||
}
|
||||
case "BlockStatement":
|
||||
return this.evaluateBlock(node)
|
||||
case "IfStatement":
|
||||
return this.evaluateIfStatement(node)
|
||||
case "SwitchStatement":
|
||||
return this.evaluateSwitchStatement(node)
|
||||
case "WhileStatement":
|
||||
return this.evaluateWhileStatement(node)
|
||||
case "DoWhileStatement":
|
||||
return this.evaluateDoWhileStatement(node)
|
||||
case "ForStatement":
|
||||
return this.evaluateForStatement(node)
|
||||
case "ForOfStatement":
|
||||
return this.evaluateForOfStatement(node)
|
||||
case "BreakStatement":
|
||||
return Effect.succeed(this.evaluateBreakStatement(node))
|
||||
case "ContinueStatement":
|
||||
return Effect.succeed(this.evaluateContinueStatement(node))
|
||||
case "ThrowStatement":
|
||||
return this.evaluateThrowStatement(node)
|
||||
case "TryStatement":
|
||||
return this.evaluateTryStatement(node)
|
||||
case "EmptyStatement":
|
||||
return Effect.succeed({ kind: "none" })
|
||||
case "FunctionDeclaration":
|
||||
return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions
|
||||
default:
|
||||
throw unsupportedSyntax(node.type, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateBlock(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
this.pushScope()
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const body = getArray(node, "body")
|
||||
self.hoistFunctions(body)
|
||||
|
||||
for (const statementValue of body) {
|
||||
const statement = asNode(statementValue, "body")
|
||||
const result = yield* self.evaluateStatement(statement)
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind !== "none") {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
private createFunction(node: AstNode): RuneFunction {
|
||||
if (node.generator === true) {
|
||||
throw new InterpreterRuntimeError("Generator functions are not supported in Rune.", node, "UnsupportedSyntax", [supportedSyntaxMessage])
|
||||
}
|
||||
return new RuneFunction(
|
||||
getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
|
||||
getNode(node, "body"),
|
||||
this.scopes.slice(),
|
||||
)
|
||||
}
|
||||
|
||||
// Function declarations are hoisted: bound in their scope before the body runs, so a
|
||||
// program can call a helper defined further down (matching JavaScript).
|
||||
private hoistFunctions(statements: Array<unknown>): void {
|
||||
for (const statementValue of statements) {
|
||||
if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue
|
||||
const node = statementValue as AstNode
|
||||
this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateIfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const testNode = getNode(node, "test")
|
||||
const consequentNode = getNode(node, "consequent")
|
||||
const alternateNode = getOptionalNode(node, "alternate")
|
||||
|
||||
return Effect.flatMap(this.evaluateExpression(testNode), (test) =>
|
||||
test ? this.evaluateStatement(consequentNode) : alternateNode ? this.evaluateStatement(alternateNode) : Effect.succeed({ kind: "none" }))
|
||||
}
|
||||
|
||||
private evaluateSwitchStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const self = this
|
||||
this.pushScope()
|
||||
return Effect.gen(function*() {
|
||||
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
|
||||
if (containsRuntimeReference(discriminant)) {
|
||||
throw new InterpreterRuntimeError("Switch discriminants must be data values in Rune.", node, "InvalidDataValue")
|
||||
}
|
||||
const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`))
|
||||
let defaultIndex: number | undefined
|
||||
let selected: number | undefined
|
||||
for (const [index, branch] of cases.entries()) {
|
||||
const test = getOptionalNode(branch, "test")
|
||||
if (!test) {
|
||||
defaultIndex = index
|
||||
continue
|
||||
}
|
||||
const candidate = yield* self.evaluateExpression(test)
|
||||
if (containsRuntimeReference(candidate)) {
|
||||
throw new InterpreterRuntimeError("Switch case values must be data values in Rune.", test, "InvalidDataValue")
|
||||
}
|
||||
if (candidate === discriminant) {
|
||||
selected = index
|
||||
break
|
||||
}
|
||||
}
|
||||
const start = selected ?? defaultIndex
|
||||
if (start === undefined) return { kind: "none" } satisfies StatementResult
|
||||
for (let index = start; index < cases.length; index += 1) {
|
||||
for (const statementValue of getArray(cases[index]!, "consequent")) {
|
||||
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
|
||||
if (result.kind === "break") return { kind: "none" } satisfies StatementResult
|
||||
if (result.kind === "return" || result.kind === "continue") return result
|
||||
if (result.kind === "value") self.lastValue = result.value
|
||||
}
|
||||
}
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const testNode = getNode(node, "test")
|
||||
const bodyNode = getNode(node, "body")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
while (yield* self.evaluateExpression(testNode)) {
|
||||
const result = yield* self.evaluateStatement(bodyNode)
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateDoWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const bodyNode = getNode(node, "body")
|
||||
const testNode = getNode(node, "test")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
do {
|
||||
const result = yield* self.evaluateStatement(bodyNode)
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
} while (yield* self.evaluateExpression(testNode))
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateForStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
this.pushScope()
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const initNode = getOptionalNode(node, "init")
|
||||
const testNode = getOptionalNode(node, "test")
|
||||
const updateNode = getOptionalNode(node, "update")
|
||||
const bodyNode = getNode(node, "body")
|
||||
|
||||
if (initNode) {
|
||||
if (initNode.type === "VariableDeclaration") {
|
||||
yield* self.evaluateVariableDeclaration(initNode)
|
||||
} else {
|
||||
yield* self.evaluateExpression(initNode)
|
||||
}
|
||||
}
|
||||
|
||||
const perIterationBindings = initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var"
|
||||
? Array.from(self.currentScope().keys())
|
||||
: []
|
||||
|
||||
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
|
||||
let iterationScope: Map<string, Binding> | undefined
|
||||
if (perIterationBindings.length > 0) {
|
||||
iterationScope = new Map(perIterationBindings.map((name) => {
|
||||
const binding = self.currentScope().get(name)!
|
||||
return [name, { ...binding }]
|
||||
}))
|
||||
self.scopes.push(iterationScope)
|
||||
}
|
||||
const result = yield* self.evaluateStatement(bodyNode).pipe(
|
||||
Effect.ensuring(Effect.sync(() => {
|
||||
if (iterationScope) self.popScope()
|
||||
})),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
|
||||
if (iterationScope) {
|
||||
const loopScope = self.currentScope()
|
||||
for (const name of perIterationBindings) {
|
||||
loopScope.set(name, { ...iterationScope.get(name)! })
|
||||
}
|
||||
}
|
||||
|
||||
if (updateNode) {
|
||||
yield* self.evaluateExpression(updateNode)
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
private evaluateForOfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
if (getBoolean(node, "await")) {
|
||||
throw new InterpreterRuntimeError("for await...of is not supported.", node)
|
||||
}
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const left = getNode(node, "left")
|
||||
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
const body = getNode(node, "body")
|
||||
|
||||
if (!Array.isArray(right)) {
|
||||
throw new InterpreterRuntimeError("for...of requires an array value in Rune.", node)
|
||||
}
|
||||
|
||||
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
|
||||
let assignmentName: string | undefined
|
||||
|
||||
if (left.type === "VariableDeclaration") {
|
||||
const declarations = getArray(left, "declarations")
|
||||
if (declarations.length !== 1) {
|
||||
throw new InterpreterRuntimeError("for...of supports one declared binding.", left)
|
||||
}
|
||||
|
||||
const declarator = asNode(declarations[0], "declarations[0]")
|
||||
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
|
||||
} else if (left.type === "Identifier") {
|
||||
assignmentName = getString(left, "name")
|
||||
} else {
|
||||
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
||||
}
|
||||
|
||||
for (const value of right) {
|
||||
if (declaration) {
|
||||
self.pushScope()
|
||||
yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left)
|
||||
} else if (assignmentName) {
|
||||
self.setIdentifierValue(assignmentName, value, left)
|
||||
}
|
||||
|
||||
const result = yield* self.evaluateStatement(body).pipe(
|
||||
Effect.ensuring(Effect.sync(() => {
|
||||
if (declaration) self.popScope()
|
||||
})),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" }
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" }
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateBreakStatement(node: AstNode): StatementResult {
|
||||
const labelNode = getOptionalNode(node, "label")
|
||||
|
||||
if (labelNode) {
|
||||
throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node)
|
||||
}
|
||||
|
||||
return { kind: "break" }
|
||||
}
|
||||
|
||||
private evaluateContinueStatement(node: AstNode): StatementResult {
|
||||
const labelNode = getOptionalNode(node, "label")
|
||||
|
||||
if (labelNode) {
|
||||
throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node)
|
||||
}
|
||||
|
||||
return { kind: "continue" }
|
||||
}
|
||||
|
||||
private evaluateThrowStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const argument = getNode(node, "argument")
|
||||
return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value)))
|
||||
}
|
||||
|
||||
private evaluateTryStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const body = getNode(node, "block")
|
||||
const handler = getOptionalNode(node, "handler")
|
||||
const finalizer = getOptionalNode(node, "finalizer")
|
||||
const self = this
|
||||
|
||||
const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), {
|
||||
onFailure: (cause) => {
|
||||
if (cause.reasons.some(Cause.isInterruptReason) || !handler) {
|
||||
return Effect.failCause(cause)
|
||||
}
|
||||
|
||||
const thrown = Cause.squash(cause)
|
||||
// The program sees a plain { message } error. Use the interpreter error's raw message so
|
||||
// the program never sees the transpiled-source "(line N, col N)" coordinates that
|
||||
// normalizeError appends — without disturbing a host/tool message that legitimately ends
|
||||
// that way. Other error kinds carry no appended location, so normalizeError is used as-is.
|
||||
const caught = thrown instanceof ProgramThrow
|
||||
? thrown.value
|
||||
: Object.assign(Object.create(null) as SafeObject, {
|
||||
message: thrown instanceof InterpreterRuntimeError ? thrown.message : normalizeError(thrown).message,
|
||||
})
|
||||
const parameter = getOptionalNode(handler, "param")
|
||||
self.pushScope()
|
||||
return Effect.gen(function*() {
|
||||
if (parameter) yield* self.declarePattern(parameter, caught, true, handler)
|
||||
return yield* self.evaluateStatement(getNode(handler, "body"))
|
||||
}).pipe(
|
||||
Effect.ensuring(Effect.sync(() => self.popScope())),
|
||||
)
|
||||
},
|
||||
onSuccess: Effect.succeed,
|
||||
})
|
||||
|
||||
if (!finalizer) return attempted
|
||||
|
||||
const isAbrupt = (result: StatementResult): boolean =>
|
||||
result.kind === "return" || result.kind === "break" || result.kind === "continue"
|
||||
|
||||
return Effect.matchCauseEffect(attempted, {
|
||||
onFailure: (cause) =>
|
||||
cause.reasons.some(Cause.isInterruptReason)
|
||||
? Effect.failCause(cause)
|
||||
: Effect.flatMap(this.evaluateStatement(finalizer), (final) =>
|
||||
isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause)),
|
||||
onSuccess: (result) =>
|
||||
Effect.flatMap(this.evaluateStatement(finalizer), (final) =>
|
||||
isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result)),
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateVariableDeclaration(node: AstNode): Effect.Effect<void, unknown, R> {
|
||||
const kind = getString(node, "kind")
|
||||
const declarations = getArray(node, "declarations")
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
for (const declarationValue of declarations) {
|
||||
const declaration = asNode(declarationValue, "declarations")
|
||||
|
||||
if (declaration.type !== "VariableDeclarator") {
|
||||
throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration)
|
||||
}
|
||||
|
||||
const init = getOptionalNode(declaration, "init")
|
||||
const value = init ? yield* self.evaluateExpression(init) : undefined
|
||||
yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private declarePattern(pattern: AstNode, value: unknown, mutable: boolean, node: AstNode): Effect.Effect<void, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
if (pattern.type === "Identifier") {
|
||||
self.declare(getString(pattern, "name"), value, mutable, node)
|
||||
return
|
||||
}
|
||||
|
||||
// Default values: `x = expr` / `{ a = 1 }` — the default is evaluated only when the value is undefined.
|
||||
if (pattern.type === "AssignmentPattern") {
|
||||
const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value
|
||||
yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node)
|
||||
return
|
||||
}
|
||||
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
|
||||
throw new InterpreterRuntimeError("Object destructuring requires a data object value.", pattern, "InvalidDataValue")
|
||||
}
|
||||
|
||||
const consumed = new Set<string>()
|
||||
for (const propertyValue of getArray(pattern, "properties")) {
|
||||
const property = asNode(propertyValue, "properties")
|
||||
|
||||
// Object rest: `{ a, ...others }` — gather the not-yet-consumed own keys.
|
||||
if (property.type === "RestElement") {
|
||||
const rest: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, item] of Object.entries(value as SafeObject)) {
|
||||
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
|
||||
}
|
||||
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property)
|
||||
continue
|
||||
}
|
||||
|
||||
if (property.type !== "Property" || getBoolean(property, "computed") || getString(property, "kind") !== "init") {
|
||||
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
|
||||
}
|
||||
|
||||
const keyNode = getNode(property, "key")
|
||||
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
|
||||
if (isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, keyNode)
|
||||
}
|
||||
consumed.add(key)
|
||||
yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pattern.type === "ArrayPattern") {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
|
||||
}
|
||||
|
||||
for (const [index, item] of getArray(pattern, "elements").entries()) {
|
||||
if (item === null) continue
|
||||
const element = asNode(item, `elements[${index}]`)
|
||||
// Array rest: `[head, ...tail]` — binds the remaining elements (must be last).
|
||||
if (element.type === "RestElement") {
|
||||
yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element)
|
||||
break
|
||||
}
|
||||
yield* self.declarePattern(element, value[index], mutable, pattern)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
this.recordOperation(node)
|
||||
|
||||
switch (node.type) {
|
||||
case "Literal":
|
||||
return Effect.sync(() => boundedData(node.value, "Literal", node, this.limits))
|
||||
case "Identifier":
|
||||
return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node))
|
||||
case "BinaryExpression":
|
||||
return this.evaluateBinaryExpression(node)
|
||||
case "LogicalExpression":
|
||||
return this.evaluateLogicalExpression(node)
|
||||
case "UnaryExpression":
|
||||
return this.evaluateUnaryExpression(node)
|
||||
case "AssignmentExpression":
|
||||
return this.evaluateAssignmentExpression(node)
|
||||
case "CallExpression":
|
||||
return this.evaluateCallExpression(node)
|
||||
case "ArrowFunctionExpression":
|
||||
case "FunctionExpression":
|
||||
return Effect.sync(() => this.createFunction(node))
|
||||
case "MemberExpression":
|
||||
return this.readMember(node)
|
||||
case "ChainExpression":
|
||||
return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) =>
|
||||
value === OptionalShortCircuit ? undefined : value)
|
||||
case "ObjectExpression":
|
||||
return this.evaluateObjectExpression(node)
|
||||
case "ArrayExpression":
|
||||
return this.evaluateArrayExpression(node)
|
||||
case "TemplateLiteral":
|
||||
return this.evaluateTemplateLiteral(node)
|
||||
case "ConditionalExpression":
|
||||
return this.evaluateConditionalExpression(node)
|
||||
case "UpdateExpression":
|
||||
return this.evaluateUpdateExpression(node)
|
||||
case "AwaitExpression": {
|
||||
return this.evaluateExpression(getNode(node, "argument"))
|
||||
}
|
||||
case "NewExpression":
|
||||
return this.evaluateNewExpression(node)
|
||||
default:
|
||||
throw unsupportedSyntax(node.type, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateNewExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const callee = getNode(node, "callee")
|
||||
if (callee.type !== "Identifier" || !errorConstructors.has(getString(callee, "name"))) {
|
||||
throw unsupportedSyntax("NewExpression", node)
|
||||
}
|
||||
const name = getString(callee, "name")
|
||||
const argNodes = getArray(node, "arguments")
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const arg = argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined
|
||||
const message = arg === undefined ? "" : coerceToString(arg)
|
||||
const errorValue: SafeObject = Object.create(null) as SafeObject
|
||||
errorValue.name = name
|
||||
errorValue.message = message
|
||||
return errorValue
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateBinaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const lhs = (yield* self.evaluateExpression(getNode(node, "left"))) as any
|
||||
const rhs = (yield* self.evaluateExpression(getNode(node, "right"))) as any
|
||||
if (containsRuntimeReference(lhs) || containsRuntimeReference(rhs)) {
|
||||
throw new InterpreterRuntimeError("Binary operators require data values in Rune.", node, "InvalidDataValue")
|
||||
}
|
||||
// Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host
|
||||
// "No default value" TypeError when an operator coerces them. Coerce to their JS string
|
||||
// form first (as String(x) / template literals do) so operators behave like JavaScript.
|
||||
// Identity (=== / !==) and the right operand of `in` keep their raw object value.
|
||||
const coerceOperand = (operand: unknown): unknown =>
|
||||
operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
|
||||
const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object"
|
||||
const l = coerceOperand(lhs) as any
|
||||
const r = coerceOperand(rhs) as any
|
||||
let result: unknown
|
||||
switch (operator) {
|
||||
case "+": result = l + r; break
|
||||
case "-": result = l - r; break
|
||||
case "*": result = l * r; break
|
||||
case "/": result = l / r; break
|
||||
case "%": result = l % r; break
|
||||
case "**": result = l ** r; break
|
||||
// Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces.
|
||||
case "==": result = bothObjects ? lhs === rhs : l == r; break
|
||||
case "===": result = lhs === rhs; break
|
||||
case "!=": result = bothObjects ? lhs !== rhs : l != r; break
|
||||
case "!==": result = lhs !== rhs; break
|
||||
case "<": result = l < r; break
|
||||
case "<=": result = l <= r; break
|
||||
case ">": result = l > r; break
|
||||
case ">=": result = l >= r; break
|
||||
case "&": result = l & r; break
|
||||
case "|": result = l | r; break
|
||||
case "^": result = l ^ r; break
|
||||
case "<<": result = l << r; break
|
||||
case ">>": result = l >> r; break
|
||||
case ">>>": result = l >>> r; break
|
||||
case "in":
|
||||
if (rhs === null || typeof rhs !== "object") {
|
||||
throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
}
|
||||
// Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...).
|
||||
result = Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey); break
|
||||
default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
|
||||
}
|
||||
return boundedData(result, "Binary expression result", node, self.limits)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateLogicalExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => {
|
||||
if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left)
|
||||
if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right"))
|
||||
if (operator === "??") return left !== null && left !== undefined ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right"))
|
||||
throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateUnaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
const argument = getNode(node, "argument")
|
||||
// `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so
|
||||
// feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before
|
||||
// evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw.
|
||||
if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) {
|
||||
return Effect.succeed("undefined")
|
||||
}
|
||||
return Effect.map(this.evaluateExpression(argument), (value) => {
|
||||
if (containsRuntimeReference(value)) {
|
||||
throw new InterpreterRuntimeError("Unary operators require data values in Rune.", node, "InvalidDataValue")
|
||||
}
|
||||
const rhs = value as any
|
||||
// Numeric/bitwise unary operators ToPrimitive their operand; coerce null-prototype
|
||||
// data objects/arrays to their JS string form first (see evaluateBinaryExpression).
|
||||
// `!` and `typeof` operate on the raw value (no ToPrimitive, no crash).
|
||||
const operand =
|
||||
(operator === "+" || operator === "-" || operator === "~") && rhs !== null && typeof rhs === "object"
|
||||
? (coerceToString(rhs) as any)
|
||||
: rhs
|
||||
let result: unknown
|
||||
switch (operator) {
|
||||
case "+": result = +operand; break
|
||||
case "-": result = -operand; break
|
||||
case "!": result = !rhs; break
|
||||
case "typeof": result = typeof rhs; break
|
||||
case "~": result = ~operand; break
|
||||
default: throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node)
|
||||
}
|
||||
return boundedData(result, "Unary expression result", node, this.limits)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateAssignmentExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const left = getNode(node, "left")
|
||||
const operator = getString(node, "operator")
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
if (operator === "??=" || operator === "||=" || operator === "&&=") {
|
||||
return yield* self.evaluateLogicalAssignment(node, left, operator)
|
||||
}
|
||||
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
if (left.type === "Identifier") {
|
||||
const name = getString(left, "name")
|
||||
if (operator === "=") return self.setIdentifierValue(name, rightValue, left)
|
||||
const next = boundedData(self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), "Assignment result", node, self.limits)
|
||||
return self.setIdentifierValue(name, next, left)
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
if (operator === "=") return yield* self.writeMember(left, rightValue)
|
||||
return yield* self.modifyMember(left, (current) => {
|
||||
const next = boundedData(self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result", node, self.limits)
|
||||
return Effect.succeed({ write: true, next, result: next })
|
||||
})
|
||||
}
|
||||
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateLogicalAssignment(node: AstNode, left: AstNode, operator: string): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
const shouldAssign = (current: unknown): boolean =>
|
||||
operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current)
|
||||
if (left.type === "Identifier") {
|
||||
const name = getString(left, "name")
|
||||
return Effect.gen(function*() {
|
||||
const current = self.getIdentifierValue(name, left)
|
||||
if (!shouldAssign(current)) return current
|
||||
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
return self.setIdentifierValue(name, rightValue, left)
|
||||
})
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
// Resolve the member exactly once; evaluate the RHS only if we actually assign.
|
||||
return self.modifyMember(left, (current) =>
|
||||
shouldAssign(current)
|
||||
? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ write: true, next: rightValue, result: rightValue }))
|
||||
: Effect.succeed({ write: false, next: current, result: current }))
|
||||
}
|
||||
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
}
|
||||
|
||||
private evaluateUpdateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
const argument = getNode(node, "argument")
|
||||
const prefix = getBoolean(node, "prefix")
|
||||
|
||||
const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined
|
||||
|
||||
if (increment === undefined) {
|
||||
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
|
||||
}
|
||||
|
||||
if (argument.type === "Identifier") {
|
||||
return Effect.sync(() => {
|
||||
const name = getString(argument, "name")
|
||||
const current = Number(this.getIdentifierValue(name, argument))
|
||||
const next = boundedData(current + increment, "Update result", node, this.limits) as number
|
||||
this.setIdentifierValue(name, next, argument)
|
||||
return prefix ? next : current
|
||||
})
|
||||
}
|
||||
|
||||
if (argument.type === "MemberExpression") {
|
||||
return this.modifyMember(argument, (current) => {
|
||||
const value = Number(current)
|
||||
const next = boundedData(value + increment, "Update result", node, this.limits) as number
|
||||
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
||||
})
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument)
|
||||
}
|
||||
|
||||
private evaluateCallExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const callee = getNode(node, "callee")
|
||||
const argNodes = getArray(node, "arguments")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const callable = yield* self.evaluateExpression(callee)
|
||||
if (callable === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit
|
||||
if (callable instanceof PromiseAllReference) {
|
||||
if (argNodes.length !== 1) {
|
||||
throw new InterpreterRuntimeError(`Promise.all expects exactly one collection expression. ${supportedSyntaxMessage}`, node)
|
||||
}
|
||||
const argument = asNode(argNodes[0], "arguments[0]")
|
||||
return yield* self.evaluatePromiseAll(argument, node)
|
||||
}
|
||||
|
||||
const args = yield* self.evaluateCallArguments(argNodes)
|
||||
|
||||
if (callable instanceof ToolReference) {
|
||||
if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee)
|
||||
return yield* self.invokeTool(callable.path, args)
|
||||
}
|
||||
if (callable instanceof RuneFunction) {
|
||||
return yield* self.invokeFunction(callable, args)
|
||||
}
|
||||
if (callable instanceof IntrinsicReference) {
|
||||
return yield* self.invokeIntrinsic(callable, args, node)
|
||||
}
|
||||
if (callable instanceof GlobalMethodReference) {
|
||||
const globalResult = invokeGlobalMethod(callable, args, node, self.limits)
|
||||
self.recordWork(workUnits(globalResult), node)
|
||||
return boundedData(globalResult, `${callable.namespace}.${callable.name} result`, node, self.limits)
|
||||
}
|
||||
if (callable instanceof CoercionFunction) {
|
||||
const coercionResult = invokeCoercion(callable, args, node, self.limits)
|
||||
self.recordWork(workUnits(coercionResult), node)
|
||||
return boundedData(coercionResult, `${callable.name} result`, node, self.limits)
|
||||
}
|
||||
if (callable instanceof ConsoleMethodReference) {
|
||||
const message = args.map(formatLogArg).join(" ")
|
||||
// Charge formatting to the operation budget so a console.log loop is bounded by
|
||||
// maxOperations, not just the wall-clock timeout.
|
||||
self.recordWork(message.length, node)
|
||||
self.logs.push(callable.level, message)
|
||||
return undefined
|
||||
}
|
||||
throw new InterpreterRuntimeError("Only tool capabilities are callable in Rune.", callee)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateCallArguments(argNodes: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const args: Array<unknown> = []
|
||||
for (const [index, arg] of argNodes.entries()) {
|
||||
const argNode = asNode(arg, `arguments[${index}]`)
|
||||
if (argNode.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(argNode, "argument"))
|
||||
const items = Array.isArray(spread) ? spread : typeof spread === "string" ? Array.from(spread) : undefined
|
||||
if (items === undefined) throw new InterpreterRuntimeError("Spread arguments require an array or string in Rune.", argNode)
|
||||
if (args.length + items.length > self.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Call arguments exceed the maximum collection length of ${self.limits.maxCollectionLength}.`, argNode, "InvalidDataValue")
|
||||
}
|
||||
args.push(...items)
|
||||
self.recordWork(items.length, argNode)
|
||||
} else {
|
||||
args.push(yield* self.evaluateExpression(argNode))
|
||||
if (args.length > self.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Call arguments exceed the maximum collection length of ${self.limits.maxCollectionLength}.`, argNode, "InvalidDataValue")
|
||||
}
|
||||
}
|
||||
}
|
||||
return args
|
||||
})
|
||||
}
|
||||
|
||||
private evaluatePromiseAll(argument: AstNode, node: AstNode): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
if (argument.type === "ArrayExpression") {
|
||||
const elements = getArray(argument, "elements")
|
||||
if (elements.length > this.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Promise.all exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "ConcurrencyLimitExceeded")
|
||||
}
|
||||
const calls = elements.map((value, index) => {
|
||||
if (value === null) {
|
||||
throw new InterpreterRuntimeError(`Promise.all array elements must be direct Tool Capability calls. ${supportedSyntaxMessage}`, argument)
|
||||
}
|
||||
const element = asNode(value, `elements[${index}]`)
|
||||
if (element.type === "SpreadElement") {
|
||||
throw new InterpreterRuntimeError(`Promise.all does not support spread elements yet. ${supportedSyntaxMessage}`, element)
|
||||
}
|
||||
if (!this.isToolCallExpression(element)) {
|
||||
throw new InterpreterRuntimeError(`Promise.all array elements must be direct Tool Capability calls. ${supportedSyntaxMessage}`, element)
|
||||
}
|
||||
return element.type === "AwaitExpression" ? getNode(element, "argument") : element
|
||||
})
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const prepared: Array<{ readonly path: ReadonlyArray<string>; readonly args: Array<unknown> }> = []
|
||||
for (const call of calls) {
|
||||
const callable = yield* self.evaluateExpression(getNode(call, "callee"))
|
||||
if (!(callable instanceof ToolReference) || callable.path.length === 0) {
|
||||
throw new InterpreterRuntimeError("Promise.all expects direct Tool Capability calls.", call)
|
||||
}
|
||||
const args = yield* self.evaluateCallArguments(getArray(call, "arguments"))
|
||||
prepared.push({ path: callable.path, args })
|
||||
}
|
||||
const values = yield* Effect.all(prepared.map(({ path, args }) => self.invokeTool(path, args)), { concurrency: self.limits.maxConcurrency })
|
||||
return boundedProgramValue(values, "Promise.all result", node, self.limits) as Array<unknown>
|
||||
})
|
||||
}
|
||||
|
||||
if (argument.type === "CallExpression") {
|
||||
return this.evaluateParallelMap(argument, node)
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError(`Promise.all supports an array literal or a direct .map(...) expression. ${supportedSyntaxMessage}`, node)
|
||||
}
|
||||
|
||||
private evaluateParallelMap(call: AstNode, node: AstNode): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
const callee = getNode(call, "callee")
|
||||
const args = getArray(call, "arguments")
|
||||
if (callee.type !== "MemberExpression" || args.length !== 1) {
|
||||
throw new InterpreterRuntimeError(`Promise.all supports direct items.map((item) => tools.path(item)) expressions. ${supportedSyntaxMessage}`, node)
|
||||
}
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const method = yield* self.evaluateExpression(callee)
|
||||
const callback = yield* self.evaluateExpression(asNode(args[0], "arguments[0]"))
|
||||
if (!(method instanceof IntrinsicReference) || method.name !== "map" || !(callback instanceof RuneFunction)) {
|
||||
throw new InterpreterRuntimeError(`Promise.all supports direct items.map((item) => tools.path(item)) expressions. ${supportedSyntaxMessage}`, node)
|
||||
}
|
||||
if (!self.isToolCallExpression(callback.body)) {
|
||||
throw new InterpreterRuntimeError(`Promise.all mapped callbacks must directly call a Tool Capability. ${supportedSyntaxMessage}`, node)
|
||||
}
|
||||
const items = method.receiver as Array<unknown>
|
||||
if (items.length > self.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Promise.all exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "ConcurrencyLimitExceeded")
|
||||
}
|
||||
|
||||
const values = yield* Effect.all(
|
||||
items.map((item, index) => Effect.suspend(() => self.forkForParallelCallback().invokeFunction(callback, [item, index]))),
|
||||
{ concurrency: self.limits.maxConcurrency },
|
||||
)
|
||||
return boundedProgramValue(values, "Promise.all result", node, self.limits) as Array<unknown>
|
||||
})
|
||||
}
|
||||
|
||||
private isToolCallExpression(node: AstNode): boolean {
|
||||
const expression = node.type === "AwaitExpression" ? getNode(node, "argument") : node
|
||||
return expression.type === "CallExpression" && this.isToolPath(getNode(expression, "callee"))
|
||||
}
|
||||
|
||||
private isToolPath(node: AstNode): boolean {
|
||||
if (node.type === "Identifier") return getString(node, "name") === "tools"
|
||||
return node.type === "MemberExpression" && this.isToolPath(getNode(node, "object"))
|
||||
}
|
||||
|
||||
private invokeFunction(fn: RuneFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
return Effect.suspend(() => {
|
||||
const savedScopes = self.scopes
|
||||
self.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
||||
const run = Effect.gen(function*() {
|
||||
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
||||
// references another parameter resolves to that (uninitialized) param rather than
|
||||
// silently falling through to an outer binding of the same name — matching JS.
|
||||
const paramScope = self.currentScope()
|
||||
for (const parameter of fn.parameters) {
|
||||
for (const name of collectPatternNames(parameter)) {
|
||||
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
|
||||
}
|
||||
}
|
||||
for (const [index, parameter] of fn.parameters.entries()) {
|
||||
if (parameter.type === "RestElement") {
|
||||
yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter)
|
||||
break
|
||||
}
|
||||
yield* self.declarePattern(parameter, args[index], true, parameter)
|
||||
}
|
||||
|
||||
if (fn.body.type === "BlockStatement") {
|
||||
const result = yield* self.evaluateStatement(fn.body)
|
||||
return result.kind === "return" || result.kind === "value" ? result.value : undefined
|
||||
}
|
||||
|
||||
return yield* self.evaluateExpression(fn.body)
|
||||
})
|
||||
return run.pipe(Effect.ensuring(Effect.sync(() => { self.scopes = savedScopes })))
|
||||
})
|
||||
}
|
||||
|
||||
private invokeIntrinsic(ref: IntrinsicReference, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
if (typeof ref.receiver === "string") {
|
||||
this.recordWork(ref.receiver.length, node)
|
||||
const result = invokeStringMethod(ref.receiver, ref.name, args, node, this.limits)
|
||||
if (typeof result === "string") this.recordWork(result.length, node)
|
||||
return Effect.succeed(result)
|
||||
}
|
||||
if (typeof ref.receiver === "number") {
|
||||
return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node, this.limits))
|
||||
}
|
||||
if (Array.isArray(ref.receiver)) {
|
||||
if (!cheapArrayMethods.has(ref.name)) this.recordWork(ref.receiver.length, node)
|
||||
const self = this
|
||||
return Effect.map(this.invokeArrayMethod(ref.receiver, ref.name, args, node), (result) => {
|
||||
if (Array.isArray(result)) self.recordWork(result.length, node)
|
||||
return result
|
||||
})
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in Rune.`, node)
|
||||
}
|
||||
|
||||
private invokeArrayMethod(target: Array<unknown>, name: string, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const boundedCollection = (items: Array<unknown>): Array<unknown> => {
|
||||
if (items.length > this.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Array.${name} exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue")
|
||||
}
|
||||
return boundedProgramValue(items, `Array.${name} result`, node, this.limits) as Array<unknown>
|
||||
}
|
||||
const optNumber = (value: unknown, label: string): number | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== "number") throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
|
||||
return value
|
||||
}
|
||||
switch (name) {
|
||||
case "join": {
|
||||
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
|
||||
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
|
||||
}
|
||||
const input = boundedData(target, "Array.join input", node, this.limits) as Array<unknown>
|
||||
return Effect.succeed(boundedData(input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : args[0] as string), "Array.join result", node, this.limits))
|
||||
}
|
||||
case "includes":
|
||||
if (args.length === 0 || args.length > 2) throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
|
||||
return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index")))
|
||||
case "indexOf":
|
||||
return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index")))
|
||||
case "lastIndexOf":
|
||||
return Effect.succeed(args[1] === undefined ? target.lastIndexOf(args[0]) : target.lastIndexOf(args[0], optNumber(args[1], "start index")))
|
||||
case "at":
|
||||
return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0))
|
||||
case "slice":
|
||||
return Effect.succeed(boundedCollection(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))))
|
||||
case "concat":
|
||||
return Effect.succeed(boundedCollection(target.concat(...args)))
|
||||
case "flat":
|
||||
return Effect.succeed(boundedCollection(target.flat(optNumber(args[0], "depth") ?? 1)))
|
||||
case "reverse":
|
||||
return Effect.succeed(boundedCollection([...target].reverse()))
|
||||
case "sort":
|
||||
case "toSorted":
|
||||
return this.sortArray(target, args[0], node)
|
||||
case "toReversed":
|
||||
return Effect.succeed(boundedCollection([...target].reverse()))
|
||||
case "with": {
|
||||
const index = optNumber(args[0], "index") ?? 0
|
||||
const resolved = index < 0 ? target.length + index : index
|
||||
if (resolved < 0 || resolved >= target.length) {
|
||||
throw new InterpreterRuntimeError("Array.with index is out of range.", node)
|
||||
}
|
||||
const copied = [...target]
|
||||
copied[resolved] = args[1]
|
||||
return Effect.succeed(boundedCollection(copied))
|
||||
}
|
||||
case "push": {
|
||||
if (target.length + args.length > this.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Array.push exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue")
|
||||
}
|
||||
// Validate before mutating (so no rollback is needed) and charge only the new elements,
|
||||
// keeping a push loop O(1)/element instead of re-walking the whole array each call.
|
||||
let added = 0
|
||||
for (const item of args) {
|
||||
this.rejectCircularInsertion(target, item, "Array.push result", node)
|
||||
added += this.nestedValueBytes(item, "Array.push result", node) + 1
|
||||
}
|
||||
this.growContainerBytes(target, added, node, "Array.push result")
|
||||
target.push(...args)
|
||||
return Effect.succeed(target.length)
|
||||
}
|
||||
case "unshift": {
|
||||
if (target.length + args.length > this.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Array.unshift exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue")
|
||||
}
|
||||
let added = 0
|
||||
for (const item of args) {
|
||||
this.rejectCircularInsertion(target, item, "Array.unshift result", node)
|
||||
added += this.nestedValueBytes(item, "Array.unshift result", node) + 1
|
||||
}
|
||||
this.growContainerBytes(target, added, node, "Array.unshift result")
|
||||
target.unshift(...args)
|
||||
return Effect.succeed(target.length)
|
||||
}
|
||||
// Removals only shrink the array; drop the cached size so the next growth recomputes it.
|
||||
case "pop":
|
||||
this.containerSizes.delete(target)
|
||||
return Effect.succeed(target.pop())
|
||||
case "shift":
|
||||
this.containerSizes.delete(target)
|
||||
return Effect.succeed(target.shift())
|
||||
}
|
||||
|
||||
const callback = args[0]
|
||||
if (!(callback instanceof RuneFunction) && !(callback instanceof CoercionFunction)) {
|
||||
throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node)
|
||||
}
|
||||
const self = this
|
||||
// Accept a user arrow function or a builtin coercion callable (Boolean/String/Number), so the
|
||||
// idioms `filter(Boolean)` / `map(String)` / `map(Number)` work as in JS. Coercions are
|
||||
// synchronous; only RuneFunctions can await tool calls.
|
||||
const apply = (callbackArgs: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node, self.limits))
|
||||
: self.invokeFunction(callback, callbackArgs)
|
||||
return Effect.gen(function*() {
|
||||
// Iterate a snapshot taken at call time so a callback that mutates the array can't
|
||||
// self-extend the loop — matching JS, where elements appended during iteration are not visited.
|
||||
const items = target.slice()
|
||||
switch (name) {
|
||||
case "map": {
|
||||
const values: Array<unknown> = []
|
||||
for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items]))
|
||||
return boundedCollection(values)
|
||||
}
|
||||
case "flatMap": {
|
||||
const values: Array<unknown> = []
|
||||
for (const [index, item] of items.entries()) {
|
||||
const mapped = yield* apply([item, index, items])
|
||||
if (Array.isArray(mapped)) values.push(...mapped)
|
||||
else values.push(mapped)
|
||||
boundedCollection(values)
|
||||
}
|
||||
return boundedCollection(values)
|
||||
}
|
||||
case "filter": {
|
||||
const values: Array<unknown> = []
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (yield* apply([item, index, items])) values.push(item)
|
||||
}
|
||||
return boundedCollection(values)
|
||||
}
|
||||
case "find":
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (yield* apply([item, index, items])) return item
|
||||
}
|
||||
return undefined
|
||||
case "findIndex":
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (yield* apply([item, index, items])) return index
|
||||
}
|
||||
return -1
|
||||
case "some":
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (yield* apply([item, index, items])) return true
|
||||
}
|
||||
return false
|
||||
case "every":
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (!(yield* apply([item, index, items]))) return false
|
||||
}
|
||||
return true
|
||||
case "forEach":
|
||||
for (const [index, item] of items.entries()) yield* apply([item, index, items])
|
||||
return undefined
|
||||
case "reduce": {
|
||||
let accumulator: unknown
|
||||
let start: number
|
||||
if (args.length >= 2) {
|
||||
accumulator = args[1]
|
||||
start = 0
|
||||
} else {
|
||||
if (items.length === 0) throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
|
||||
accumulator = items[0]
|
||||
start = 1
|
||||
}
|
||||
for (let index = start; index < items.length; index += 1) {
|
||||
accumulator = yield* apply([accumulator, items[index], index, items])
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
case "reduceRight": {
|
||||
let accumulator: unknown
|
||||
let start: number
|
||||
if (args.length >= 2) {
|
||||
accumulator = args[1]
|
||||
start = items.length - 1
|
||||
} else {
|
||||
if (items.length === 0) throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
|
||||
accumulator = items[items.length - 1]
|
||||
start = items.length - 2
|
||||
}
|
||||
for (let index = start; index >= 0; index -= 1) {
|
||||
accumulator = yield* apply([accumulator, items[index], index, items])
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
case "findLast":
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
if (yield* apply([items[index], index, items])) return items[index]
|
||||
}
|
||||
return undefined
|
||||
case "findLastIndex":
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
if (yield* apply([items[index], index, items])) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Array method '${name}' is not available in Rune.`, node)
|
||||
})
|
||||
}
|
||||
|
||||
private sortArray(target: Array<unknown>, comparator: unknown, node: AstNode): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
if (comparator !== undefined && !(comparator instanceof RuneFunction)) {
|
||||
throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node)
|
||||
}
|
||||
if (!(comparator instanceof RuneFunction)) {
|
||||
return Effect.sync(() => boundedProgramValue(
|
||||
[...target].sort((a, b) => {
|
||||
const left = coerceToString(a)
|
||||
const right = coerceToString(b)
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}),
|
||||
"Array.sort result",
|
||||
node,
|
||||
this.limits,
|
||||
) as Array<unknown>)
|
||||
}
|
||||
const self = this
|
||||
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
|
||||
if (items.length <= 1) return Effect.succeed(items)
|
||||
const midpoint = Math.floor(items.length / 2)
|
||||
return Effect.gen(function*() {
|
||||
const left = yield* mergeSort(items.slice(0, midpoint))
|
||||
const right = yield* mergeSort(items.slice(midpoint))
|
||||
const merged: Array<unknown> = []
|
||||
let leftIndex = 0
|
||||
let rightIndex = 0
|
||||
while (leftIndex < left.length && rightIndex < right.length) {
|
||||
// Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host
|
||||
// crash) and treat NaN as 0 — the spec's "no consistent order" → keep the left element.
|
||||
const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]]))
|
||||
if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++])
|
||||
else merged.push(right[rightIndex++])
|
||||
}
|
||||
return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)]
|
||||
})
|
||||
}
|
||||
// Per spec, undefined elements sort to the end and the comparator is never called on them.
|
||||
const defined = target.filter((item) => item !== undefined)
|
||||
const undefinedCount = target.length - defined.length
|
||||
return Effect.map(mergeSort(defined), (items) =>
|
||||
boundedProgramValue([...items, ...Array(undefinedCount).fill(undefined)], "Array.sort result", node, this.limits) as Array<unknown>)
|
||||
}
|
||||
|
||||
private evaluateObjectExpression(node: AstNode): Effect.Effect<Record<string, unknown>, unknown, R> {
|
||||
const objectValue: Record<string, unknown> = Object.create(null) as Record<string, unknown>
|
||||
const keys = new Set<string>()
|
||||
const properties = getArray(node, "properties")
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
for (const propertyValue of properties) {
|
||||
const property = asNode(propertyValue, "properties")
|
||||
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
|
||||
// JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common
|
||||
// `{ ...maybeOpts, override }` merge works when the operand is absent.
|
||||
if (spread === null || spread === undefined) continue
|
||||
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
||||
throw new InterpreterRuntimeError("Object spread requires a data object in Rune.", property, "InvalidDataValue")
|
||||
}
|
||||
for (const [key, value] of Object.entries(spread)) {
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, property)
|
||||
objectValue[key] = value
|
||||
keys.add(key)
|
||||
if (keys.size > self.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Object expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, property, "InvalidDataValue")
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (property.type !== "Property") {
|
||||
throw new InterpreterRuntimeError("Only standard object properties are supported.", property)
|
||||
}
|
||||
|
||||
if (getString(property, "kind") !== "init") {
|
||||
throw new InterpreterRuntimeError("Only init object properties are supported.", property)
|
||||
}
|
||||
|
||||
const keyNode = getNode(property, "key")
|
||||
const valueNode = getNode(property, "value")
|
||||
const computed = getBoolean(property, "computed")
|
||||
|
||||
let key: PropertyKey
|
||||
|
||||
if (computed) {
|
||||
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
|
||||
} else if (keyNode.type === "Identifier") {
|
||||
key = getString(keyNode, "name")
|
||||
} else if (keyNode.type === "Literal") {
|
||||
key = self.toPropertyKey(keyNode.value, keyNode)
|
||||
} else {
|
||||
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
|
||||
}
|
||||
|
||||
if (isBlockedMember(String(key))) {
|
||||
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in Rune.`, keyNode)
|
||||
}
|
||||
objectValue[String(key)] = yield* self.evaluateExpression(valueNode)
|
||||
keys.add(String(key))
|
||||
if (keys.size > self.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Object expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, property, "InvalidDataValue")
|
||||
}
|
||||
}
|
||||
|
||||
return boundedProgramValue(objectValue, "Object expression result", node, self.limits) as Record<string, unknown>
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateArrayExpression(node: AstNode): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
const elements = getArray(node, "elements")
|
||||
const values: Array<unknown> = []
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
for (const elementValue of elements) {
|
||||
if (elementValue === null) {
|
||||
values.push(undefined)
|
||||
if (values.length > self.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Array expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "InvalidDataValue")
|
||||
}
|
||||
continue
|
||||
}
|
||||
const element = asNode(elementValue, "elements")
|
||||
if (element.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(element, "argument"))
|
||||
const items = Array.isArray(spread) ? spread : typeof spread === "string" ? Array.from(spread) : undefined
|
||||
if (items === undefined) throw new InterpreterRuntimeError("Array spread requires an array or string in Rune.", element)
|
||||
values.push(...items)
|
||||
self.recordWork(items.length, element)
|
||||
} else {
|
||||
values.push(yield* self.evaluateExpression(element))
|
||||
}
|
||||
if (values.length > self.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Array expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "InvalidDataValue")
|
||||
}
|
||||
}
|
||||
return boundedProgramValue(values, "Array expression result", node, self.limits) as Array<unknown>
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateTemplateLiteral(node: AstNode): Effect.Effect<string, unknown, R> {
|
||||
const quasis = getArray(node, "quasis")
|
||||
const expressions = getArray(node, "expressions")
|
||||
|
||||
let output = ""
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
for (let index = 0; index < quasis.length; index += 1) {
|
||||
const quasi = asNode(quasis[index], "quasis")
|
||||
const rawValue = quasi.value
|
||||
|
||||
if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") {
|
||||
throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi)
|
||||
}
|
||||
|
||||
output += rawValue.cooked
|
||||
boundedData(output, "Template literal result", node, self.limits)
|
||||
|
||||
if (index < expressions.length) {
|
||||
const value = boundedData(yield* self.evaluateExpression(asNode(expressions[index], "expressions")), "Template interpolation", node, self.limits)
|
||||
output += coerceToString(value)
|
||||
boundedData(output, "Template literal result", node, self.limits)
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateConditionalExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) =>
|
||||
this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")))
|
||||
}
|
||||
|
||||
private applyCompoundAssignment(
|
||||
operator: string,
|
||||
current: unknown,
|
||||
incoming: unknown,
|
||||
node: AstNode,
|
||||
): unknown {
|
||||
const lhs = current as any
|
||||
const rhs = incoming as any
|
||||
|
||||
switch (operator) {
|
||||
case "+=":
|
||||
return lhs + rhs
|
||||
case "-=":
|
||||
return lhs - rhs
|
||||
case "*=":
|
||||
return lhs * rhs
|
||||
case "/=":
|
||||
return lhs / rhs
|
||||
case "%=":
|
||||
return lhs % rhs
|
||||
case "**=":
|
||||
return lhs ** rhs
|
||||
case "&=":
|
||||
return lhs & rhs
|
||||
case "|=":
|
||||
return lhs | rhs
|
||||
case "^=":
|
||||
return lhs ^ rhs
|
||||
case "<<=":
|
||||
return lhs << rhs
|
||||
case ">>=":
|
||||
return lhs >> rhs
|
||||
case ">>>=":
|
||||
return lhs >>> rhs
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
private getMemberReference(node: AstNode): Effect.Effect<MemberReference | ToolReference | PromiseAllReference | IntrinsicReference | GlobalMethodReference | ConsoleMethodReference | ComputedValue | typeof OptionalShortCircuit | undefined, unknown, R> {
|
||||
const objectNode = getNode(node, "object")
|
||||
const propertyNode = getNode(node, "property")
|
||||
const computed = getBoolean(node, "computed")
|
||||
const optional = node.optional === true
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const objectValue = yield* self.evaluateExpression(objectNode)
|
||||
if (objectValue === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit
|
||||
|
||||
const key = computed
|
||||
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
|
||||
: propertyNode.type === "Identifier"
|
||||
? getString(propertyNode, "name")
|
||||
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
|
||||
|
||||
if (objectValue instanceof ToolReference) {
|
||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode)
|
||||
}
|
||||
return new ToolReference([...objectValue.path, key])
|
||||
}
|
||||
|
||||
if (objectValue instanceof PromiseNamespace) {
|
||||
if (key === "all") return new PromiseAllReference()
|
||||
throw new InterpreterRuntimeError(`Promise.${String(key)} is not available in Rune. Use Promise.all(...) for parallel Tool Capabilities.`, propertyNode)
|
||||
}
|
||||
|
||||
if (objectValue instanceof ConsoleReference) {
|
||||
if (typeof key === "string" && consoleLevels.has(key as LogEntry["level"])) {
|
||||
return new ConsoleMethodReference(key as LogEntry["level"])
|
||||
}
|
||||
throw new InterpreterRuntimeError(`console.${String(key)} is not available in Rune. Use log, warn, error, info, or debug.`, propertyNode)
|
||||
}
|
||||
|
||||
if (objectValue instanceof GlobalNamespace) {
|
||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`${objectValue.name}.${String(key)} is not available in Rune.`, propertyNode)
|
||||
}
|
||||
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
||||
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
||||
}
|
||||
return new GlobalMethodReference(objectValue.name, key)
|
||||
}
|
||||
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return new ComputedValue(objectValue.length)
|
||||
if (typeof key === "number") return new ComputedValue(objectValue[key])
|
||||
if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)])
|
||||
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
// Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`),
|
||||
// instead of throwing — so defensive access like `result?.login ?? result` on a JSON-string
|
||||
// tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a
|
||||
// real string still reaches here.) Only the method allowlist above yields callables.
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
if (typeof objectValue === "number") {
|
||||
if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
// Unknown property on a number reads as `undefined`, matching JS, rather than throwing.
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
// Number / String expose a small allowlist of statics; everything else stays opaque.
|
||||
if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) {
|
||||
if (objectValue.name === "Number" && numberConstants.has(key)) {
|
||||
return new ComputedValue((Number as unknown as Record<string, number>)[key])
|
||||
}
|
||||
if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key)
|
||||
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
|
||||
}
|
||||
|
||||
if (isRuntimeReference(objectValue)) {
|
||||
throw new InterpreterRuntimeError("Rune runtime references are opaque and do not expose properties.", objectNode, "InvalidDataValue")
|
||||
}
|
||||
|
||||
if (typeof objectValue !== "object" || objectValue === null) {
|
||||
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
|
||||
}
|
||||
|
||||
if (typeof key === "string" && isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, propertyNode)
|
||||
}
|
||||
|
||||
if (Array.isArray(objectValue)) {
|
||||
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && (typeof key !== "number" && !/^\d+$/.test(key))) {
|
||||
if (typeof key === "string" && retryableArrayMethods.has(key)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Array.${key}(...) is not supported in Rune. Rewrite using map/filter/find/some/every/includes/join or a for...of loop.`,
|
||||
propertyNode,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
// Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`),
|
||||
// instead of throwing — so defensive access under optional chaining behaves as expected. The
|
||||
// retryable-method hint above still fires for real methods we don't support (e.g. splice).
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
return { target: objectValue, key }
|
||||
}
|
||||
|
||||
return { target: objectValue as SafeObject, key }
|
||||
})
|
||||
}
|
||||
|
||||
private readMember(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
return Effect.map(this.getMemberReference(node), (reference) => {
|
||||
if (reference === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if (reference instanceof ComputedValue) return reference.value
|
||||
if (
|
||||
reference === undefined ||
|
||||
reference instanceof ToolReference ||
|
||||
reference instanceof PromiseAllReference ||
|
||||
reference instanceof IntrinsicReference ||
|
||||
reference instanceof GlobalMethodReference ||
|
||||
reference instanceof ConsoleMethodReference
|
||||
) return reference
|
||||
if (Array.isArray(reference.target)) {
|
||||
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
|
||||
return new IntrinsicReference(reference.target, reference.key)
|
||||
}
|
||||
return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
|
||||
}
|
||||
return reference.target[String(reference.key)]
|
||||
})
|
||||
}
|
||||
|
||||
private writeMember(node: AstNode, value: unknown): Effect.Effect<unknown, unknown, R> {
|
||||
return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
|
||||
}
|
||||
|
||||
// Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression
|
||||
// runs once), then lets `compute` decide whether to write — enabling compound assignment,
|
||||
// updates, plain writes, and short-circuiting logical assignment to share one safe path.
|
||||
private modifyMember(
|
||||
node: AstNode,
|
||||
compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const reference = yield* self.getMemberReference(node)
|
||||
if (
|
||||
reference === OptionalShortCircuit ||
|
||||
reference instanceof ComputedValue ||
|
||||
reference === undefined ||
|
||||
reference instanceof ToolReference ||
|
||||
reference instanceof PromiseAllReference ||
|
||||
reference instanceof IntrinsicReference ||
|
||||
reference instanceof GlobalMethodReference ||
|
||||
reference instanceof ConsoleMethodReference
|
||||
) {
|
||||
throw new InterpreterRuntimeError("Only data fields may be assigned in Rune.", node)
|
||||
}
|
||||
if (Array.isArray(reference.target)) {
|
||||
if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned in Rune.", node)
|
||||
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
|
||||
throw new InterpreterRuntimeError("Array methods cannot be assigned in Rune.", node)
|
||||
}
|
||||
}
|
||||
const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
|
||||
const current = (reference.target as Record<PropertyKey, unknown>)[key]
|
||||
const { write, next, result } = yield* compute(current)
|
||||
if (write) self.assignToReference(reference, key, next, node)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
// Writes `next` to a resolved member, enforcing index/capacity/byte limits and rolling
|
||||
// back the mutation if the bound is exceeded (so a caught error can't leave it grown).
|
||||
// Byte size of a container, cached after the first walk and maintained incrementally by the
|
||||
// mutation helpers. O(1) on a cache hit; O(container) once on the first touch.
|
||||
private cachedContainerBytes(container: object, node: AstNode): number {
|
||||
const cached = this.containerSizes.get(container)
|
||||
if (cached !== undefined) return cached
|
||||
const bytes = runtimeValueBytes(container, "value", node, this.limits)
|
||||
this.recordWork(workUnits(container), node)
|
||||
this.containerSizes.set(container, bytes)
|
||||
return bytes
|
||||
}
|
||||
|
||||
// Bytes a value contributes when nested one level inside a container; also enforces that the
|
||||
// nested value's depth stays within maxValueDepth. O(value), independent of the container size.
|
||||
private nestedValueBytes(value: unknown, label: string, node: AstNode): number {
|
||||
return runtimeValueBytes(value, label, node, this.limits, 1)
|
||||
}
|
||||
|
||||
private rejectCircularInsertion(container: object, value: unknown, label: string, node: AstNode, seen = new Set<object>()): void {
|
||||
if (value === container) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return
|
||||
seen.add(value)
|
||||
const items = Array.isArray(value) ? value : Object.values(value)
|
||||
for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen)
|
||||
seen.delete(value)
|
||||
}
|
||||
|
||||
// Add `addedBytes` of new entries to a container, rejecting (before any mutation) if that would
|
||||
// exceed maxDataBytes, then record the container's new cached size.
|
||||
private growContainerBytes(container: object, addedBytes: number, node: AstNode, label: string): void {
|
||||
const next = this.cachedContainerBytes(container, node) + addedBytes
|
||||
if (next > this.limits.maxDataBytes) {
|
||||
throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue")
|
||||
}
|
||||
this.containerSizes.set(container, next)
|
||||
}
|
||||
|
||||
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
|
||||
if (Array.isArray(reference.target)) {
|
||||
const target = reference.target
|
||||
const index = key as number
|
||||
if (!Number.isInteger(index) || index < 0 || index >= this.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Array assignment index must be between 0 and ${this.limits.maxCollectionLength - 1}.`, node, "InvalidDataValue")
|
||||
}
|
||||
this.rejectCircularInsertion(target, next, "Array assignment result", node)
|
||||
const addedBytes = this.nestedValueBytes(next, "Array assignment result", node)
|
||||
if (index === target.length) {
|
||||
// Append — the hot path; O(1) incremental size update (this is the O(n^2)-loop fix).
|
||||
this.growContainerBytes(target, addedBytes + 1, node, "Array assignment result")
|
||||
target[index] = next
|
||||
} else if (index < target.length) {
|
||||
// Replace an existing slot (value or hole): adjust by the byte delta.
|
||||
const oldBytes = this.nestedValueBytes(target[index], "Array assignment result", node)
|
||||
const nextSize = this.cachedContainerBytes(target, node) + addedBytes - oldBytes
|
||||
if (nextSize > this.limits.maxDataBytes) {
|
||||
throw new InterpreterRuntimeError(`Array assignment result exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue")
|
||||
}
|
||||
this.containerSizes.set(target, nextSize)
|
||||
target[index] = next
|
||||
} else {
|
||||
// index > length introduces holes; fall back to a full revalidation and reset the cache.
|
||||
const previousLength = target.length
|
||||
target[index] = next
|
||||
try {
|
||||
boundedProgramValue(target, "Array assignment result", node, this.limits)
|
||||
this.containerSizes.set(target, runtimeValueBytes(target, "value", node, this.limits))
|
||||
} catch (error) {
|
||||
delete target[index]
|
||||
target.length = previousLength
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const target = reference.target as SafeObject
|
||||
const objectKey = key as string
|
||||
this.rejectCircularInsertion(target, next, "Object assignment result", node)
|
||||
const addedBytes = this.nestedValueBytes(next, "Object assignment result", node)
|
||||
if (Object.hasOwn(target, objectKey)) {
|
||||
const oldBytes = this.nestedValueBytes(target[objectKey], "Object assignment result", node)
|
||||
const nextSize = this.cachedContainerBytes(target, node) + addedBytes - oldBytes
|
||||
if (nextSize > this.limits.maxDataBytes) {
|
||||
throw new InterpreterRuntimeError(`Object assignment result exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue")
|
||||
}
|
||||
this.containerSizes.set(target, nextSize)
|
||||
target[objectKey] = next
|
||||
return
|
||||
}
|
||||
const count = (this.objectCounts.get(target) ?? Object.keys(target).length) + 1
|
||||
if (count > this.limits.maxCollectionLength) {
|
||||
throw new InterpreterRuntimeError(`Object assignment exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue")
|
||||
}
|
||||
this.growContainerBytes(target, dataByteLength(objectKey) + addedBytes + 1, node, "Object assignment result")
|
||||
this.objectCounts.set(target, count)
|
||||
target[objectKey] = next
|
||||
}
|
||||
|
||||
private toPropertyKey(value: unknown, node: AstNode): string | number {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return value
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError("Property key must be a string or number.", node)
|
||||
}
|
||||
|
||||
private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
|
||||
const scope = this.currentScope()
|
||||
|
||||
// A pre-seeded parameter slot (initialized === false) is being bound for the first time;
|
||||
// anything else already present is a genuine duplicate declaration.
|
||||
const existing = scope.get(name)
|
||||
if (existing && existing.initialized !== false) {
|
||||
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
||||
}
|
||||
|
||||
scope.set(name, { mutable, value, initialized: true })
|
||||
}
|
||||
|
||||
private getIdentifierValue(name: string, node: AstNode): unknown {
|
||||
const binding = this.resolveBinding(name)
|
||||
|
||||
if (!binding) {
|
||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node)
|
||||
}
|
||||
|
||||
// A parameter default that forward-references a later (not-yet-bound) parameter — JS TDZ.
|
||||
if (binding.initialized === false) {
|
||||
throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node)
|
||||
}
|
||||
|
||||
return binding.value
|
||||
}
|
||||
|
||||
private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown {
|
||||
const binding = this.resolveBinding(name)
|
||||
|
||||
if (!binding) {
|
||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node)
|
||||
}
|
||||
|
||||
if (!binding.mutable) {
|
||||
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node)
|
||||
}
|
||||
|
||||
binding.value = value
|
||||
return value
|
||||
}
|
||||
|
||||
private resolveBinding(name: string): Binding | undefined {
|
||||
for (let index = this.scopes.length - 1; index >= 0; index -= 1) {
|
||||
const scope = this.scopes[index]
|
||||
const binding = scope?.get(name)
|
||||
|
||||
if (binding) {
|
||||
return binding
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private currentScope(): Map<string, Binding> {
|
||||
const scope = this.scopes[this.scopes.length - 1]
|
||||
|
||||
if (!scope) {
|
||||
throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
private pushScope(): void {
|
||||
this.scopes.push(new Map())
|
||||
}
|
||||
|
||||
private popScope(): void {
|
||||
this.scopes.pop()
|
||||
}
|
||||
|
||||
private forkForParallelCallback(): Interpreter<R> {
|
||||
const fork = new Interpreter(this.limits, this.invokeTool, this.budget, this.logs)
|
||||
fork.scopes.splice(
|
||||
0,
|
||||
fork.scopes.length,
|
||||
...this.scopes.map((scope) => new Map(Array.from(scope, ([name, binding]) => [name, { ...binding }]))),
|
||||
)
|
||||
return fork
|
||||
}
|
||||
|
||||
private recordOperation(node: AstNode): void {
|
||||
this.recordWork(1, node)
|
||||
}
|
||||
|
||||
// Charge `units` of work to the operation budget so O(n) built-ins (collection/string
|
||||
// walks and spreads) are bounded by maxOperations, not only by the wall-clock timeout.
|
||||
private recordWork(units: number, node?: AstNode): void {
|
||||
this.budget.operations += Math.max(1, Math.ceil(units))
|
||||
|
||||
if (this.budget.operations > this.limits.maxOperations) {
|
||||
throw new InterpreterRuntimeError(`Execution exceeded its operation limit of ${this.limits.maxOperations}.`, node, "OperationLimitExceeded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes one Effect-native Rune Program without constructing a reusable runtime.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = yield* Rune.execute({
|
||||
* tools: { lookup },
|
||||
* code: `return await tools.lookup({ id: "order_42" })`,
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const execute = <const Tools extends Record<string, unknown>>(options: ExecuteOptions<Tools>): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
ToolRuntime.assertValidTools((options.tools ?? {}) as HostTools<Services<Tools>>)
|
||||
const tools = ToolRuntime.make((options.tools ?? {}) as HostTools<Services<Tools>>, limits.maxToolCalls, limits)
|
||||
// Lives in the outer scope (not the interpreter) so console output is surfaced on every
|
||||
// result path, including timeout and failure where the interpreter instance is unreachable.
|
||||
const logs = new LogCollector(limits.maxAuditBytes)
|
||||
|
||||
if (new TextEncoder().encode(options.code).byteLength > limits.maxSourceBytes) {
|
||||
return Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "InvalidDataValue", message: `Code exceeds the maximum source size of ${limits.maxSourceBytes} bytes.` },
|
||||
toolCalls: tools.calls,
|
||||
logs: logs.entries,
|
||||
})
|
||||
}
|
||||
|
||||
if (options.code.trim().length === 0) {
|
||||
return Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "ParseError", message: "Code cannot be empty." },
|
||||
toolCalls: tools.calls,
|
||||
logs: logs.entries,
|
||||
})
|
||||
}
|
||||
|
||||
const operation = Effect.gen(function*() {
|
||||
const program = parseProgram(options.code)
|
||||
const interpreter = new Interpreter<Services<Tools>>(limits, tools.invoke, undefined, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
const copied = copyIn(value, "Execution result", limits)
|
||||
if (dataByteLength(copied) > limits.maxDataBytes) {
|
||||
throw new InterpreterRuntimeError(`Execution result exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, undefined, "InvalidDataValue")
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: copyOut(copied),
|
||||
toolCalls: tools.calls,
|
||||
logs: logs.entries,
|
||||
} satisfies ExecuteResult
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: limits.timeoutMs,
|
||||
orElse: () => Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${limits.timeoutMs}ms.` },
|
||||
toolCalls: tools.calls,
|
||||
logs: logs.entries,
|
||||
} satisfies ExecuteResult),
|
||||
}),
|
||||
)
|
||||
|
||||
return operation.pipe(
|
||||
Effect.matchCause({
|
||||
onFailure: (cause): ExecuteResult => ({
|
||||
ok: false,
|
||||
error: normalizeError(Cause.squash(cause)),
|
||||
toolCalls: tools.calls,
|
||||
logs: logs.entries,
|
||||
}),
|
||||
onSuccess: (result): ExecuteResult => result,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Effect-native runtime over explicit, schema-described capabilities.
|
||||
*
|
||||
* Use `run` for host-driven execution or `asTool` to expose one confined code tool to an
|
||||
* agent framework. Capability requirements remain in the returned Effect environment.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const rune = Rune.make({ tools: { orders: { lookup } } })
|
||||
* const code = rune.asTool()
|
||||
* ```
|
||||
*/
|
||||
export const make = <const Tools extends Record<string, unknown> = {}>(options: RuneOptions<Tools> = {} as RuneOptions<Tools>): Rune<Services<Tools>> => {
|
||||
ToolRuntime.assertValidTools((options.tools ?? {}) as HostTools<Services<Tools>>)
|
||||
const run = (code: string) => execute<Tools>({ ...options, code })
|
||||
|
||||
return {
|
||||
catalog: () => ToolRuntime.catalog((options.tools ?? {}) as HostTools<Services<Tools>>),
|
||||
instructions: () => ToolRuntime.instructions((options.tools ?? {}) as HostTools<Services<Tools>>),
|
||||
asTool: () => ({
|
||||
name: "code",
|
||||
description: ToolRuntime.instructions((options.tools ?? {}) as HostTools<Services<Tools>>),
|
||||
input: CodeInput,
|
||||
execute: ({ code }) => run(code),
|
||||
}),
|
||||
run,
|
||||
}
|
||||
}
|
||||
|
||||
export const Rune = { make, execute }
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { isDefinition as isToolDefinition, toTypeScript, type Definition } from "./tool.js"
|
||||
|
||||
export type HostTool<R = never> = (...args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
|
||||
export type HostTools<R = never> = {
|
||||
[name: string]: HostTool<R> | Definition<R> | HostTools<R>
|
||||
}
|
||||
|
||||
export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
|
||||
? R
|
||||
: Tools extends { readonly _tag: "RuneTool"; readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R> }
|
||||
? R
|
||||
: Tools extends object
|
||||
? string extends keyof Tools ? never : Services<Tools[keyof Tools]>
|
||||
: never
|
||||
|
||||
export type ToolCall = {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export type ToolDescription = {
|
||||
readonly path: string
|
||||
readonly description: string
|
||||
readonly signature: string
|
||||
}
|
||||
|
||||
export type SafeObject = Record<string, unknown>
|
||||
|
||||
export class ToolReference {
|
||||
constructor(readonly path: ReadonlyArray<string>) {}
|
||||
}
|
||||
|
||||
export type DataLimits = {
|
||||
readonly maxValueDepth: number
|
||||
readonly maxCollectionLength: number
|
||||
readonly maxDataBytes: number
|
||||
readonly maxAuditBytes: number
|
||||
}
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
constructor(
|
||||
readonly kind: "UnknownCapability" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded" | "AuditLimitExceeded",
|
||||
message: string,
|
||||
readonly suggestions: ReadonlyArray<string> = [],
|
||||
) {
|
||||
super(message)
|
||||
this.name = "ToolRuntimeError"
|
||||
}
|
||||
}
|
||||
|
||||
const isDefinition = <R>(value: HostTool<R> | Definition<R> | HostTools<R>): value is Definition<R> =>
|
||||
isToolDefinition<R>(value)
|
||||
|
||||
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
|
||||
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
||||
|
||||
export const copyIn = (value: unknown, label: string, limits?: DataLimits, depth = 0, seen = new Set<object>()): unknown => {
|
||||
if (limits && depth > limits.maxValueDepth) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`)
|
||||
}
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
// NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real
|
||||
// engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are
|
||||
// normalized to `null` when the value leaves the sandbox — see copyOut — exactly as
|
||||
// JSON.stringify already does at any tool boundary.
|
||||
typeof value === "number"
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
|
||||
}
|
||||
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (limits && value.length > limits.maxCollectionLength) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`)
|
||||
}
|
||||
const copied = value.map((item) => copyIn(item, label, limits, depth + 1, seen))
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`)
|
||||
}
|
||||
|
||||
const copied: SafeObject = Object.create(null) as SafeObject
|
||||
const entries = Object.entries(value)
|
||||
if (limits && entries.length > limits.maxCollectionLength) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`)
|
||||
}
|
||||
for (const [key, item] of entries) {
|
||||
if (isBlockedMember(key)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||
}
|
||||
copied[key] = copyIn(item, label, limits, depth + 1, seen)
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
|
||||
export const copyOut = (value: unknown): unknown => {
|
||||
// Normalize non-finite numbers to null as the value crosses out of the sandbox (final return
|
||||
// and tool-call arguments both funnel through here), matching JSON semantics — NaN/Infinity
|
||||
// have no JSON representation, so JSON.stringify would produce null anyway.
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(copyOut)
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item)]))
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const definitions = <R>(tools: HostTools<R>, path: ReadonlyArray<string> = []): Array<{ path: string; definition: Definition<R> }> => {
|
||||
const entries: Array<{ path: string; definition: Definition<R> }> = []
|
||||
for (const [name, value] of Object.entries(tools)) {
|
||||
const next = [...path, name]
|
||||
if (isDefinition(value)) entries.push({ path: next.join("."), definition: value })
|
||||
else if (typeof value !== "function") entries.push(...definitions(value, next))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
|
||||
path,
|
||||
description: definition.description,
|
||||
signature: `tools.${path}(input: ${toTypeScript(definition.input)}): Promise<${toTypeScript(definition.output, true)}>`,
|
||||
})
|
||||
|
||||
const visibleDefinitions = <R>(tools: HostTools<R>) =>
|
||||
definitions(tools).flatMap(({ path, definition }) => {
|
||||
const description = describeDefinition(path, definition)
|
||||
return [{ path, definition, description }]
|
||||
})
|
||||
|
||||
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
|
||||
visibleDefinitions(tools).map(({ description }) => description)
|
||||
|
||||
// Discovery is provided by the embedder as ordinary host tools (e.g. under a
|
||||
// `$rune` namespace), not by the runtime, so there are no reserved namespaces.
|
||||
export const assertValidTools = <R>(_tools: HostTools<R>): void => {}
|
||||
|
||||
// Generic Rune-language instructions. Discovery (e.g. searching a large or dynamic
|
||||
// catalog) is not a runtime feature; an embedder that wants it registers ordinary
|
||||
// host tools and documents them itself, so nothing is hardcoded here.
|
||||
export const instructions = <R>(tools: HostTools<R>): string => {
|
||||
const described = catalog(tools)
|
||||
const lines = [
|
||||
"Write a Rune Program to answer the request. Return code only.",
|
||||
"Rune Programs can call explicit tools.* capabilities and transform plain data.",
|
||||
"Tool Capability calls are async; prefer explicit await unless the call is inside Promise.all(...).",
|
||||
"",
|
||||
"Available Tool Capabilities:",
|
||||
...described.map((tool) => `- ${tool.signature} // ${tool.description}`),
|
||||
"",
|
||||
"Common syntax: arrow functions and `function` declarations (hoisted) with closures, default/rest parameters, destructuring (incl. rest/defaults), optional chaining, template literals, conditionals, switch, loops, spread (arrays/objects/strings), try/catch, ternary, the `in` operator, logical assignment (??=/||=/&&=), and bitwise operators (& | ^ ~ << >> >>>). Signal failure with `throw` (any value) or `throw new Error(message)`.",
|
||||
"Transform data with array methods (map/filter/reduce/reduceRight/flatMap/forEach/find/findIndex/findLast/findLastIndex/sort/toSorted/slice/concat/indexOf/at/flat/reverse/toReversed/with/includes/join, plus push/pop/shift/unshift for accumulation), string methods (toLowerCase/toUpperCase/trim/split/slice/substring/replace/replaceAll/includes/startsWith/endsWith/indexOf/padStart/padEnd/repeat/charCodeAt), number methods (toFixed/toString(radix)/toPrecision), Object.keys/values/entries/fromEntries/hasOwn, Math.* (incl. PI/E), JSON.parse/stringify, Array.from/isArray/of, Number.isInteger/isNaN/parseInt, String.fromCharCode, parseInt/parseFloat, and Number/String/Boolean.",
|
||||
"Use Promise.all([...]) for parallel tool calls (a direct array of calls, or items.map((item) => tool call)).",
|
||||
]
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): HostTool<R> | Definition<R> => {
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
|
||||
for (const segment of path) {
|
||||
if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) {
|
||||
throw new ToolRuntimeError("UnknownCapability", `Unknown tool '${path.join(".")}'.`, ["Call a capability by its exact tools.* path."])
|
||||
}
|
||||
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
|
||||
}
|
||||
|
||||
if (typeof value !== "function" && !isDefinition(value)) {
|
||||
throw new ToolRuntimeError("UnknownCapability", `Tool '${path.join(".")}' is not callable.`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export type ToolRuntime<R = never> = {
|
||||
readonly root: ToolReference
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
}
|
||||
|
||||
export const dataByteLength = (value: unknown): number =>
|
||||
new TextEncoder().encode(JSON.stringify(value) ?? "").byteLength
|
||||
|
||||
export const make = <R>(
|
||||
tools: HostTools<R>,
|
||||
maxToolCalls: number,
|
||||
dataLimits: DataLimits,
|
||||
): ToolRuntime<R> => {
|
||||
const calls: Array<ToolCall> = []
|
||||
let auditBytes = 0
|
||||
|
||||
const checkedCopyIn = (value: unknown, label: string): unknown => {
|
||||
const copied = copyIn(value, label, dataLimits)
|
||||
if (dataByteLength(copied) > dataLimits.maxDataBytes) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds ${dataLimits.maxDataBytes} bytes.`)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
const recordCall = (call: ToolCall): void => {
|
||||
if (calls.length >= maxToolCalls) {
|
||||
throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`)
|
||||
}
|
||||
const auditEntryBytes = dataByteLength(call)
|
||||
if (auditBytes + auditEntryBytes > dataLimits.maxAuditBytes) {
|
||||
throw new ToolRuntimeError("AuditLimitExceeded", `Execution exceeds its audit-trail limit of ${dataLimits.maxAuditBytes} bytes.`)
|
||||
}
|
||||
auditBytes += auditEntryBytes
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
return {
|
||||
root: new ToolReference([]),
|
||||
calls,
|
||||
invoke: (path, args) =>
|
||||
Effect.gen(function*() {
|
||||
const name = path.join(".")
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`, dataLimits)))
|
||||
const argumentBytes = dataByteLength(externalArgs)
|
||||
if (argumentBytes > dataLimits.maxDataBytes) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `Arguments for tool '${name}' exceed ${dataLimits.maxDataBytes} bytes.`)
|
||||
}
|
||||
const call = { name }
|
||||
const tool = resolve(tools, path)
|
||||
let describedInput: unknown
|
||||
if (isDefinition(tool)) {
|
||||
if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
describedInput = yield* Effect.try({
|
||||
try: () => Schema.decodeUnknownSync(tool.input)(externalArgs[0]),
|
||||
catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||
})
|
||||
}
|
||||
recordCall(call)
|
||||
if (isDefinition(tool)) {
|
||||
const raw = yield* tool.run(describedInput)
|
||||
const result = yield* Effect.try({
|
||||
try: () => Schema.decodeUnknownSync(tool.output)(raw),
|
||||
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${String(cause)}`),
|
||||
})
|
||||
return checkedCopyIn(result, `Result from tool '${name}'`)
|
||||
}
|
||||
const result = yield* tool(...externalArgs)
|
||||
return checkedCopyIn(result, `Result from tool '${name}'`)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export * as ToolRuntime from "./tool-runtime.js"
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export type Definition<R = never> = {
|
||||
readonly _tag: "RuneTool"
|
||||
readonly description: string
|
||||
readonly input: Schema.Decoder<unknown>
|
||||
readonly output: Schema.Decoder<unknown>
|
||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
|
||||
}
|
||||
|
||||
export type Options<I extends Schema.Decoder<unknown>, O extends Schema.Decoder<unknown>, R = never> = {
|
||||
readonly description: string
|
||||
readonly input: I
|
||||
readonly output: O
|
||||
readonly run: (input: I["Type"]) => Effect.Effect<O["Encoded"], unknown, R>
|
||||
}
|
||||
|
||||
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "RuneTool"
|
||||
|
||||
type JsonSchema = {
|
||||
readonly type?: string | ReadonlyArray<string>
|
||||
readonly enum?: ReadonlyArray<unknown>
|
||||
readonly const?: unknown
|
||||
readonly anyOf?: ReadonlyArray<JsonSchema>
|
||||
readonly oneOf?: ReadonlyArray<JsonSchema>
|
||||
readonly properties?: Readonly<Record<string, JsonSchema>>
|
||||
readonly required?: ReadonlyArray<string>
|
||||
readonly items?: JsonSchema
|
||||
readonly additionalProperties?: boolean | JsonSchema
|
||||
readonly $ref?: string
|
||||
}
|
||||
|
||||
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
|
||||
|
||||
const renderSchema = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): string => {
|
||||
if (schema.$ref) {
|
||||
const name = schema.$ref.split("/").pop()
|
||||
return name && definitions[name] ? renderSchema(definitions[name], definitions) : name ?? "unknown"
|
||||
}
|
||||
if (schema.const !== undefined) return renderLiteral(schema.const)
|
||||
if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
|
||||
const alternatives = schema.anyOf ?? schema.oneOf
|
||||
if (alternatives) {
|
||||
if (alternatives.some((item) => item.type === "number")) return "number"
|
||||
return alternatives.map((item) => renderSchema(item, definitions)).join(" | ")
|
||||
}
|
||||
if (Array.isArray(schema.type)) return schema.type.map((item) => renderSchema({ type: item }, definitions)).join(" | ")
|
||||
if (schema.type === "string") return "string"
|
||||
if (schema.type === "number" || schema.type === "integer") return "number"
|
||||
if (schema.type === "boolean") return "boolean"
|
||||
if (schema.type === "null") return "null"
|
||||
if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, definitions)}>`
|
||||
if (schema.type === "object" || schema.properties) {
|
||||
const required = new Set(schema.required ?? [])
|
||||
const fields = Object.entries(schema.properties ?? {}).map(([name, value]) =>
|
||||
`${name}${required.has(name) ? "" : "?"}: ${renderSchema(value, definitions)}`)
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
fields.push(`[key: string]: ${renderSchema(schema.additionalProperties, definitions)}`)
|
||||
}
|
||||
return `{ ${fields.join("; ")} }`
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export const toTypeScript = (schema: Schema.Top, decoded = false): string => {
|
||||
const visible = decoded ? Schema.toType(schema) : schema
|
||||
const document = Schema.toJsonSchemaDocument(visible) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
}
|
||||
return renderSchema(document.schema, document.definitions ?? {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines one schema-described capability available to a Rune Program through `tools.*`.
|
||||
*
|
||||
* `input` is decoded before `run` is invoked. `run` returns the encoded representation of
|
||||
* `output`, which Rune decodes before returning it to the program. The host capability remains
|
||||
* responsible for authorization and durable side-effect handling.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const lookup = Tool.make({
|
||||
* description: "Look up an order",
|
||||
* input: Schema.Struct({ id: Schema.String }),
|
||||
* output: Schema.Struct({ status: Schema.String }),
|
||||
* run: ({ id }) => Effect.succeed({ status: "open" }),
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const make = <I extends Schema.Decoder<unknown>, O extends Schema.Decoder<unknown>, R>(options: Options<I, O, R>): Definition<R> => ({
|
||||
_tag: "RuneTool",
|
||||
description: options.description,
|
||||
input: options.input,
|
||||
output: options.output,
|
||||
run: (input) => options.run(input as I["Type"]),
|
||||
})
|
||||
|
||||
export * as Tool from "./tool.js"
|
||||
@@ -21,6 +21,9 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import * as CodeModeTool from "./code-mode"
|
||||
|
||||
const MCP_RESOURCE_TOOLS = {
|
||||
list: "list_mcp_resources",
|
||||
@@ -52,6 +55,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
|
||||
sessionID: input.session.id,
|
||||
@@ -86,11 +90,29 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
for (const item of yield* registry.tools({
|
||||
const mcpTools = yield* mcp.tools()
|
||||
// When code mode is enabled and MCP tools are present, expose them through the
|
||||
// single code-mode `execute` tool instead of registering each MCP tool directly
|
||||
// (see the early return below). Code mode is experimental and off by default.
|
||||
// Namespaces are sanitized client names; tool keys are `sanitize(server)_sanitize(tool)`,
|
||||
// so the names match the catalog key prefixes.
|
||||
const codeModeTool =
|
||||
flags.experimentalCodeMode && Object.keys(mcpTools).length > 0
|
||||
? yield* Tool.init(
|
||||
yield* CodeModeTool.define(
|
||||
mcpTools,
|
||||
yield* mcp.defs(),
|
||||
Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const registryTools = yield* registry.tools({
|
||||
modelID: ModelV2.ID.make(input.model.api.id),
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
})
|
||||
|
||||
for (const item of codeModeTool ? [...registryTools, codeModeTool] : registryTools) {
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
@@ -381,7 +403,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
})
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
||||
if (codeModeTool) return tools
|
||||
|
||||
for (const [key, item] of Object.entries(mcpTools)) {
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test"
|
||||
import { define } from "@/session/code-mode"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, type Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { Tool as AITool } from "ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
// A 1x1 transparent PNG, base64-encoded, used to exercise image attachments.
|
||||
const PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
|
||||
const SERVER = "fixtures"
|
||||
|
||||
const ctx: Tool.Context = {
|
||||
sessionID: SessionID.make("ses_code-mode-int"),
|
||||
messageID: MessageID.make("msg_code-mode-int"),
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
callID: "call_code_mode_int",
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
// Truncate echoes its input so assertions read the exact program output.
|
||||
const layer = Layer.mergeAll(
|
||||
Layer.mock(Truncate.Service, {
|
||||
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
|
||||
}),
|
||||
Layer.succeed(Agent.Service, Agent.Service.of({ get: () => Effect.succeed({ name: "build" } as any) } as any)),
|
||||
)
|
||||
|
||||
// A real MCP server, exposed over an in-memory transport, with a representative mix
|
||||
// of tools: plain text, structured data (with an outputSchema), an image, and a
|
||||
// failing tool. Tools are defined with raw JSON Schema so outputSchema is exact.
|
||||
const TOOL_DEFS: MCPToolDef[] = [
|
||||
{
|
||||
name: "get_text",
|
||||
description: "Greet someone and return the greeting as text",
|
||||
inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
|
||||
},
|
||||
{
|
||||
name: "add",
|
||||
description: "Add two numbers and return the structured sum",
|
||||
inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] },
|
||||
outputSchema: { type: "object", properties: { sum: { type: "number" } }, required: ["sum"] },
|
||||
},
|
||||
{
|
||||
name: "screenshot",
|
||||
description: "Capture a screenshot and return it as an image",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "boom",
|
||||
description: "A tool that always fails",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
] as MCPToolDef[]
|
||||
|
||||
function handleCall(name: string, args: Record<string, unknown>) {
|
||||
switch (name) {
|
||||
case "get_text":
|
||||
return { content: [{ type: "text", text: `hello ${args.name}` }] }
|
||||
case "add": {
|
||||
const sum = (args.a as number) + (args.b as number)
|
||||
return { content: [{ type: "text", text: String(sum) }], structuredContent: { sum } }
|
||||
}
|
||||
case "screenshot":
|
||||
return { content: [{ type: "image", data: PNG, mimeType: "image/png" }] }
|
||||
case "boom":
|
||||
return { content: [{ type: "text", text: "kaboom" }], isError: true }
|
||||
default:
|
||||
return { content: [{ type: "text", text: `unknown tool ${name}` }], isError: true }
|
||||
}
|
||||
}
|
||||
|
||||
let tool: Awaited<ReturnType<typeof buildTool>>
|
||||
|
||||
async function buildTool() {
|
||||
const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) =>
|
||||
handleCall(req.params.name, (req.params.arguments ?? {}) as Record<string, unknown>),
|
||||
)
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
await server.connect(serverTransport)
|
||||
const client = new Client({ name: "test-client", version: "1.0.0" })
|
||||
await client.connect(clientTransport)
|
||||
|
||||
const listed = (await client.listTools()).tools as MCPToolDef[]
|
||||
const mcpTools: Record<string, AITool> = {}
|
||||
const mcpDefs: Record<string, MCPToolDef> = {}
|
||||
for (const def of listed) {
|
||||
const key = McpCatalog.toolName(SERVER, def.name)
|
||||
mcpDefs[key] = def
|
||||
mcpTools[key] = McpCatalog.convertTool(def, client)
|
||||
}
|
||||
return Effect.runPromise(define(mcpTools, mcpDefs, [SERVER]).pipe(Effect.flatMap(Tool.init), Effect.provide(layer)))
|
||||
}
|
||||
|
||||
const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx))
|
||||
|
||||
beforeAll(async () => {
|
||||
tool = await buildTool()
|
||||
})
|
||||
|
||||
describe("code mode integration (real MCP server)", () => {
|
||||
test("describe exposes the typed return signature from the tool's outputSchema", async () => {
|
||||
const out = await run("return await tools.$rune.describe('fixtures.add')")
|
||||
const desc = JSON.parse(out.output)
|
||||
expect(desc.path).toBe("fixtures.add")
|
||||
expect(desc.signature).toBe(
|
||||
"tools.fixtures.add(input: { a: number; b: number }): Promise<Result<{ sum: number }>>",
|
||||
)
|
||||
// describe returns TypeScript for the input/output types, not raw JSON Schema.
|
||||
expect(desc.input).toBe("{\n a: number\n b: number\n}")
|
||||
expect(desc.output).toBe("{\n sum: number\n}")
|
||||
expect(desc.outputSchema).toBeUndefined()
|
||||
})
|
||||
|
||||
test("describe falls back to result: unknown when no outputSchema is declared", async () => {
|
||||
const out = await run("return await tools.$rune.describe('fixtures.get_text')")
|
||||
const desc = JSON.parse(out.output)
|
||||
expect(desc.signature).toContain("Promise<Result<unknown>>")
|
||||
})
|
||||
|
||||
test("search finds a tool by keyword", async () => {
|
||||
const out = await run("return await tools.$rune.search('screenshot')")
|
||||
const result = JSON.parse(out.output)
|
||||
expect(result.items.map((i: any) => i.path)).toContain("fixtures.screenshot")
|
||||
expect(out.metadata.toolCalls).toEqual([{ tool: "$rune.search", status: "completed", input: { query: "screenshot" } }])
|
||||
})
|
||||
|
||||
test("calls a text tool and unwraps the result envelope", async () => {
|
||||
const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r.result")
|
||||
expect(out.output).toBe("hello world")
|
||||
expect(out.metadata.toolCalls).toEqual([{ tool: "fixtures.get_text", status: "completed", input: { name: "world" } }])
|
||||
expect(out.attachments).toBeUndefined()
|
||||
})
|
||||
|
||||
test("exposes structured data from a tool with an outputSchema", async () => {
|
||||
const out = await run("const r = await tools.fixtures.add({ a: 2, b: 3 }); return r.result.sum")
|
||||
expect(out.output).toBe("5")
|
||||
})
|
||||
|
||||
test("composes multiple structured calls and returns a plain object", async () => {
|
||||
const out = await run(`
|
||||
const first = await tools.fixtures.add({ a: 1, b: 2 })
|
||||
const second = await tools.fixtures.add({ a: first.result.sum, b: 10 })
|
||||
return { total: second.result.sum }
|
||||
`)
|
||||
expect(JSON.parse(out.output)).toEqual({ total: 13 })
|
||||
expect(out.metadata.toolCalls).toEqual([
|
||||
{ tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } },
|
||||
{ tool: "fixtures.add", status: "completed", input: { a: 3, b: 10 } },
|
||||
])
|
||||
})
|
||||
|
||||
test("forwards an image as an attachment when the whole result is returned", async () => {
|
||||
const out = await run("return await tools.fixtures.screenshot({})")
|
||||
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: `data:image/png;base64,${PNG}` }])
|
||||
})
|
||||
|
||||
test("an attachment is an opaque handle: metadata only, no readable bytes", async () => {
|
||||
// The program sees mime/bytes but NOT the data — a stray return can't leak base64.
|
||||
const out = await run(`
|
||||
const shot = await tools.fixtures.screenshot({})
|
||||
const a = shot.attachments[0]
|
||||
return { result: { mime: a.mime, hasUrl: 'url' in a, hasData: 'data' in a, bytes: a.bytes, keys: Object.keys(a).sort() } }
|
||||
`)
|
||||
expect(JSON.parse(out.output)).toEqual({
|
||||
mime: "image/png",
|
||||
hasUrl: false,
|
||||
hasData: false,
|
||||
bytes: Buffer.from(PNG, "base64").byteLength,
|
||||
keys: ["bytes", "id", "mime", "type"],
|
||||
})
|
||||
// Returning the handle inside `.result` (not as an attachment) surfaces no media
|
||||
// and — crucially — carries no base64, so nothing large re-enters the conversation.
|
||||
expect(out.attachments).toBeUndefined()
|
||||
expect(out.output).not.toContain(PNG)
|
||||
})
|
||||
|
||||
test("drops media when only .result is returned", async () => {
|
||||
const out = await run("const r = await tools.fixtures.screenshot({}); return { result: 'captured' }")
|
||||
expect(out.output).toBe("captured")
|
||||
expect(out.attachments).toBeUndefined()
|
||||
})
|
||||
|
||||
test("runs calls in parallel and forwards multiple attachments the model curates", async () => {
|
||||
const out = await run(`
|
||||
const [a, b] = await Promise.all([tools.fixtures.screenshot({}), tools.fixtures.screenshot({})])
|
||||
return { result: 'two shots', attachments: [...(a.attachments ?? []), ...(b.attachments ?? [])] }
|
||||
`)
|
||||
expect(out.output).toBe("two shots")
|
||||
expect(out.attachments).toHaveLength(2)
|
||||
expect(out.metadata.toolCalls.map((c) => c.tool)).toEqual(["fixtures.screenshot", "fixtures.screenshot"])
|
||||
})
|
||||
|
||||
test("propagates an MCP isError into the program as a catchable error", async () => {
|
||||
const out = await run("try { await tools.fixtures.boom({}) } catch (e) { return 'caught: ' + e.message }")
|
||||
expect(out.output).toBe("caught: kaboom")
|
||||
})
|
||||
|
||||
test("an uncaught MCP error surfaces as a failed execution", async () => {
|
||||
const out = await run("await tools.fixtures.boom({}); return 'unreachable'")
|
||||
expect(out.metadata.error).toBe(true)
|
||||
expect(out.output).toContain("kaboom")
|
||||
})
|
||||
|
||||
test("console output is captured and appended as a Logs section after the result", async () => {
|
||||
const out = await run(`
|
||||
console.log("looking up", { name: "world" })
|
||||
const r = await tools.fixtures.get_text({ name: "world" })
|
||||
console.warn("got", r.result)
|
||||
return r.result
|
||||
`)
|
||||
expect(out.output).toBe('hello world\n\nLogs:\n[log] looking up {"name":"world"}\n[warn] got hello world')
|
||||
expect(out.metadata.error).toBeUndefined()
|
||||
})
|
||||
|
||||
test("console output is preserved on the error path", async () => {
|
||||
const out = await run(`
|
||||
console.log("before the throw")
|
||||
await tools.fixtures.boom({})
|
||||
return "unreachable"
|
||||
`)
|
||||
expect(out.metadata.error).toBe(true)
|
||||
expect(out.output).toContain("kaboom")
|
||||
expect(out.output).toContain("Logs:\n[log] before the throw")
|
||||
})
|
||||
|
||||
test("a program that logs nothing gets no Logs section", async () => {
|
||||
const out = await run("return 'quiet'")
|
||||
expect(out.output).toBe("quiet")
|
||||
expect(out.output).not.toContain("Logs:")
|
||||
})
|
||||
|
||||
test("console does not consume the tool-call metadata (logging is not a tool call)", async () => {
|
||||
const out = await run("console.log('hi'); console.error('bye'); return 'ok'")
|
||||
expect(out.output).toBe("ok\n\nLogs:\n[log] hi\n[error] bye")
|
||||
expect(out.metadata.toolCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("asks permission for each MCP call but not for discovery helpers", async () => {
|
||||
const asked: string[] = []
|
||||
const permCtx: Tool.Context = { ...ctx, ask: (req: any) => Effect.sync(() => void asked.push(req.permission)) }
|
||||
await Effect.runPromise(
|
||||
tool.execute(
|
||||
{
|
||||
code: `
|
||||
await tools.$rune.search('add')
|
||||
await tools.$rune.describe('fixtures.add')
|
||||
await tools.fixtures.add({ a: 1, b: 1 })
|
||||
return 'done'
|
||||
`,
|
||||
},
|
||||
permCtx,
|
||||
),
|
||||
)
|
||||
expect(asked).toEqual(["fixtures_add"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,772 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
Parameters,
|
||||
attachmentTable,
|
||||
define,
|
||||
describe as describeTools,
|
||||
formatValue,
|
||||
groupByServer,
|
||||
rankTools,
|
||||
renderType,
|
||||
toEnvelope,
|
||||
withLogs,
|
||||
type SearchEntry,
|
||||
} from "@/session/code-mode"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import type { Tool as AITool } from "ai"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
|
||||
const ctx: Tool.Context = {
|
||||
sessionID: SessionID.make("ses_code-mode"),
|
||||
messageID: MessageID.make("msg_code-mode"),
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
callID: "call_code_mode",
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
// Build a real MCP-derived AI SDK tool over a fake transport, so the proxy exercises
|
||||
// the same `convertTool` execution path that `mcp.tools()` produces at runtime.
|
||||
function mcpTool(
|
||||
name: string,
|
||||
handler: (args: Record<string, unknown>) => unknown,
|
||||
inputSchema: Record<string, unknown> = { type: "object", properties: {} },
|
||||
): AITool {
|
||||
const client = {
|
||||
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
|
||||
}
|
||||
return McpCatalog.convertTool({ name, description: name, inputSchema } as any, client as any)
|
||||
}
|
||||
|
||||
// Truncate echoes its input so assertions read the exact program output. Agent.get is
|
||||
// only consulted by the shared wrapper during truncation.
|
||||
const layer = Layer.mergeAll(
|
||||
Layer.mock(Truncate.Service, {
|
||||
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
|
||||
}),
|
||||
Layer.succeed(Agent.Service, Agent.Service.of({ get: () => Effect.succeed({ name: "build" } as any) } as any)),
|
||||
)
|
||||
|
||||
// Derive sanitized server namespaces from the catalog keys, mirroring how
|
||||
// session/tools.ts passes `Object.keys(mcp.clients()).map(sanitize)`.
|
||||
function build(mcpTools: Record<string, AITool>, defs: Record<string, MCPToolDef> = {}, servers?: string[]) {
|
||||
const names = servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
|
||||
return Effect.runPromise(define(mcpTools, defs, names).pipe(Effect.flatMap(Tool.init), Effect.provide(layer)))
|
||||
}
|
||||
|
||||
describe("code mode execute", () => {
|
||||
test("defines execute input with an Effect schema", async () => {
|
||||
const decode = Schema.decodeUnknownEffect(Parameters)
|
||||
await expect(Effect.runPromise(decode({ code: "return 1" }))).resolves.toEqual({ code: "return 1" })
|
||||
await expect(Effect.runPromise(decode({}))).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("lists all namespaces, previews tool signatures within budget, and documents discovery", () => {
|
||||
const groups = groupByServer(
|
||||
{
|
||||
github_create_issue: mcpTool("create_issue", () => "", {
|
||||
type: "object",
|
||||
properties: { title: { type: "string" }, body: { type: "string" } },
|
||||
required: ["title"],
|
||||
}),
|
||||
github_list_issues: mcpTool("list_issues", () => ""),
|
||||
linear_search: mcpTool("search", () => ""),
|
||||
},
|
||||
["github", "linear"],
|
||||
)
|
||||
const description = describeTools(groups)
|
||||
|
||||
expect(description).toContain("tools.$rune.search(query")
|
||||
expect(description).toContain("tools.$rune.describe(path)")
|
||||
// Small catalog: the list is comprehensive and says so, with clean counts.
|
||||
expect(description).toContain("This is the COMPLETE list")
|
||||
expect(description).toContain("- github (2 tools)")
|
||||
expect(description).toContain("- linear (1 tool)")
|
||||
// Tools are previewed inline as directly-callable signatures that now include the
|
||||
// awaited return type (Result<T>) so the model sees the result shape up front.
|
||||
expect(description).toContain("tools.github.create_issue(input: { title: string; body?: string }): Result<unknown>")
|
||||
expect(description).toContain("tools.linear.search(input: object): Result<unknown>")
|
||||
// The Result<T> envelope alias is defined once in the prose.
|
||||
expect(description).toContain("type Result<T> = { result: T; attachments?: Attachment[] }")
|
||||
// ...but the preview drops the uniform Promise<…> wrapper — that full form comes from describe.
|
||||
expect(description).not.toContain("): Promise<")
|
||||
})
|
||||
|
||||
test("falls back to namespaces-only when the catalog exceeds the preview budget", () => {
|
||||
const tools: Record<string, AITool> = {}
|
||||
for (let i = 0; i < 60; i++) {
|
||||
tools[`alpha_op_${i}`] = mcpTool(`op_${i}`, () => "", {
|
||||
type: "object",
|
||||
properties: { value: { type: "string" }, count: { type: "number" } },
|
||||
})
|
||||
}
|
||||
tools["zeta_only_tool"] = mcpTool("only_tool", () => "")
|
||||
const groups = groupByServer(tools, ["alpha", "zeta"])
|
||||
const description = describeTools(groups)
|
||||
|
||||
// The list states it is partial, and every namespace is still present with its total.
|
||||
expect(description).toContain("This is a PARTIAL list")
|
||||
expect(description).toContain("- alpha (60 tools")
|
||||
// The later namespace is fully truncated, and says so.
|
||||
expect(description).toContain("- zeta (1 tool, none shown)")
|
||||
expect(description).not.toContain("tools.zeta.only_tool(")
|
||||
// Some early signatures are still previewed.
|
||||
expect(description).toContain("tools.alpha.op_0(")
|
||||
})
|
||||
|
||||
test("tools.$rune.search and tools.$rune.describe expose the catalog on demand", async () => {
|
||||
const tool = await build({
|
||||
github_create_issue: mcpTool("create_issue", () => "", {
|
||||
type: "object",
|
||||
properties: { title: { type: "string" }, body: { type: "string" } },
|
||||
required: ["title"],
|
||||
}),
|
||||
github_list_issues: mcpTool("list_issues", () => ""),
|
||||
linear_search: mcpTool("search", () => ""),
|
||||
})
|
||||
|
||||
const searched = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.$rune.search('issue', { namespace: 'github' })" }, ctx),
|
||||
)
|
||||
const search = JSON.parse(searched.output)
|
||||
expect(search.total).toBe(2)
|
||||
expect(search.items.map((i: any) => i.path).sort()).toEqual(["github.create_issue", "github.list_issues"])
|
||||
expect(searched.metadata.toolCalls).toEqual([
|
||||
{ tool: "$rune.search", status: "completed", input: { query: "issue", namespace: "github" } },
|
||||
])
|
||||
|
||||
const described = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.$rune.describe('github.create_issue')" }, ctx),
|
||||
)
|
||||
const desc = JSON.parse(described.output)
|
||||
expect(desc.path).toBe("github.create_issue")
|
||||
expect(desc.signature).toBe(
|
||||
"tools.github.create_issue(input: { title: string; body?: string }): Promise<Result<unknown>>",
|
||||
)
|
||||
expect(described.metadata.toolCalls).toEqual([
|
||||
{ tool: "$rune.describe", status: "completed", input: { path: "github.create_issue" } },
|
||||
])
|
||||
|
||||
const missing = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.describe('github.nope')" }, ctx))
|
||||
expect(JSON.parse(missing.output).error.code).toBe("tool_not_found")
|
||||
expect(missing.metadata.toolCalls).toEqual([
|
||||
{ tool: "$rune.describe", status: "completed", input: { path: "github.nope" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("describe resolves a tool path regardless of separator (dot, slash, or underscore)", async () => {
|
||||
const tool = await build({ "context7_resolve-library-id": mcpTool("resolve-library-id", () => "") })
|
||||
for (const path of ["context7.resolve-library-id", "context7/resolve-library-id", "context7_resolve-library-id"]) {
|
||||
const described = await Effect.runPromise(tool.execute({ code: `return await tools.$rune.describe(${JSON.stringify(path)})` }, ctx))
|
||||
expect(JSON.parse(described.output).path).toBe("context7.resolve-library-id")
|
||||
}
|
||||
})
|
||||
|
||||
test("describe suggests the real tool for a mistyped path (did-you-mean)", async () => {
|
||||
const tool = await build({ "context7_resolve-library-id": mcpTool("resolve-library-id", () => "") })
|
||||
// Wrong leaf within the right namespace falls back to a namespace-scoped search.
|
||||
const missing = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.$rune.describe('context7/resolve-library')" }, ctx),
|
||||
)
|
||||
const error = JSON.parse(missing.output).error
|
||||
expect(error.code).toBe("tool_not_found")
|
||||
expect(error.suggestions).toContain("context7.resolve-library-id")
|
||||
})
|
||||
|
||||
test("groups multi-underscore server names by longest matching prefix", () => {
|
||||
const groups = groupByServer({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
|
||||
expect([...groups.keys()]).toEqual(["my_server"])
|
||||
expect(groups.get("my_server")![0]).toMatchObject({ local: "do_thing", key: "my_server_do_thing" })
|
||||
})
|
||||
|
||||
test("runs plain JavaScript and returns the value as text", async () => {
|
||||
const tool = await build({})
|
||||
const output = await Effect.runPromise(tool.execute({ code: "return 1 + 2" }, ctx))
|
||||
expect(output.output).toBe("3")
|
||||
expect(output.metadata.toolCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("calls a namespaced MCP tool and flows its text result back into the program", async () => {
|
||||
const seen: Record<string, unknown>[] = []
|
||||
const tool = await build({
|
||||
greeter_hello: mcpTool("hello", (args) => {
|
||||
seen.push(args)
|
||||
return { content: [{ type: "text", text: `hello ${args.name}` }] }
|
||||
}),
|
||||
})
|
||||
|
||||
const output = await Effect.runPromise(
|
||||
tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.result.toUpperCase()" }, ctx),
|
||||
)
|
||||
|
||||
expect(seen).toEqual([{ name: "world" }])
|
||||
expect(output.output).toBe("HELLO WORLD")
|
||||
expect(output.metadata.toolCalls).toEqual([{ tool: "greeter.hello", status: "completed", input: { name: "world" } }])
|
||||
})
|
||||
|
||||
test("exposes structured content as data and composes multiple calls", async () => {
|
||||
const tool = await build({
|
||||
math_add: mcpTool("add", (args) => ({
|
||||
content: [],
|
||||
structuredContent: { sum: (args.a as number) + (args.b as number) },
|
||||
})),
|
||||
})
|
||||
|
||||
const output = await Effect.runPromise(
|
||||
tool.execute(
|
||||
{
|
||||
code: `
|
||||
const first = await tools.math.add({ a: 1, b: 2 })
|
||||
const second = await tools.math.add({ a: first.result.sum, b: 10 })
|
||||
return { total: second.result.sum }
|
||||
`,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(JSON.parse(output.output)).toEqual({ total: 13 })
|
||||
expect(output.metadata.toolCalls).toEqual([
|
||||
{ tool: "math.add", status: "completed", input: { a: 1, b: 2 } },
|
||||
{ tool: "math.add", status: "completed", input: { a: 3, b: 10 } },
|
||||
])
|
||||
})
|
||||
|
||||
test("runs tool calls in parallel with Promise.all", async () => {
|
||||
const tool = await build({
|
||||
echo_one: mcpTool("one", () => ({ content: [{ type: "text", text: "1" }] })),
|
||||
echo_two: mcpTool("two", () => ({ content: [{ type: "text", text: "2" }] })),
|
||||
})
|
||||
|
||||
const output = await Effect.runPromise(
|
||||
tool.execute(
|
||||
{ code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a.result + b.result" },
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.output).toBe("12")
|
||||
expect(output.metadata.toolCalls.map((c) => c.tool).sort()).toEqual(["echo.one", "echo.two"])
|
||||
expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns a readable error when the program throws", async () => {
|
||||
const tool = await build({})
|
||||
const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx))
|
||||
expect(output.output).toBe("Uncaught: boom")
|
||||
expect(output.metadata.error).toBe(true)
|
||||
})
|
||||
|
||||
test("reports an unknown tool and points to discovery", async () => {
|
||||
const tool = await build({ known_tool: mcpTool("tool", () => "ok") })
|
||||
const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
|
||||
expect(output.metadata.error).toBe(true)
|
||||
expect(output.output).toContain("Unknown tool 'known.missing'")
|
||||
expect(output.output).toContain("tools.$rune.search")
|
||||
})
|
||||
|
||||
test("propagates an MCP tool error into the program", async () => {
|
||||
const tool = await build({
|
||||
bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "server exploded" }] })),
|
||||
})
|
||||
const output = await Effect.runPromise(
|
||||
tool.execute(
|
||||
{ code: "try { await tools.bad.tool({}) } catch (e) { return 'caught: ' + e.message }" },
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(output.output).toBe("caught: server exploded")
|
||||
})
|
||||
|
||||
test("asks permission before each child tool call", async () => {
|
||||
const asked: unknown[] = []
|
||||
const permissionCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req)) }
|
||||
const ok = () => ({ content: [{ type: "text", text: "ok" }] })
|
||||
const tool = await build({ a_tool: mcpTool("a", ok), b_tool: mcpTool("b", ok) })
|
||||
|
||||
await Effect.runPromise(
|
||||
tool.execute({ code: "await tools.a.tool({}); await tools.b.tool({}); return 'done'" }, permissionCtx),
|
||||
)
|
||||
|
||||
expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"])
|
||||
})
|
||||
|
||||
test("streams live per-call metadata as a call starts and finishes", async () => {
|
||||
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
|
||||
const recordingCtx: Tool.Context = {
|
||||
...ctx,
|
||||
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
|
||||
}
|
||||
const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) })
|
||||
|
||||
await Effect.runPromise(tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx))
|
||||
|
||||
// The UI sees the call appear as running, then resolve to completed.
|
||||
expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }] })
|
||||
expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "completed", input: { name: "Ada" } }] })
|
||||
})
|
||||
|
||||
test("streams discovery helpers with the same per-call metadata shape", async () => {
|
||||
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
|
||||
const recordingCtx: Tool.Context = {
|
||||
...ctx,
|
||||
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
|
||||
}
|
||||
const tool = await build({ github_create_issue: mcpTool("create_issue", () => "") })
|
||||
|
||||
await Effect.runPromise(
|
||||
tool.execute(
|
||||
{
|
||||
code: `
|
||||
await tools.$rune.search('issue', { namespace: 'github' })
|
||||
await tools.$rune.describe('github.create_issue')
|
||||
return 'done'
|
||||
`,
|
||||
},
|
||||
recordingCtx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(snapshots).toContainEqual({
|
||||
toolCalls: [{ tool: "$rune.search", status: "running", input: { query: "issue", namespace: "github" } }],
|
||||
})
|
||||
expect(snapshots).toContainEqual({
|
||||
toolCalls: [
|
||||
{ tool: "$rune.search", status: "completed", input: { query: "issue", namespace: "github" } },
|
||||
{ tool: "$rune.describe", status: "completed", input: { path: "github.create_issue" } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("marks a failed child call as error in the live metadata", async () => {
|
||||
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
|
||||
const recordingCtx: Tool.Context = {
|
||||
...ctx,
|
||||
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
|
||||
}
|
||||
const tool = await build({
|
||||
bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "boom" }] })),
|
||||
})
|
||||
|
||||
await Effect.runPromise(
|
||||
tool.execute({ code: "try { await tools.bad.tool({ reason: 'test' }) } catch (e) { return 'caught' }" }, recordingCtx),
|
||||
)
|
||||
|
||||
expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error", input: { reason: "test" } }] })
|
||||
})
|
||||
|
||||
test("unit: toEnvelope wraps result and extracts media as opaque attachment handles", () => {
|
||||
const table = attachmentTable()
|
||||
expect(toEnvelope({ structuredContent: { x: 1 }, content: [] }, table.seal)).toEqual({ result: { x: 1 } })
|
||||
expect(toEnvelope({ content: [{ type: "text", text: "hi" }] }, table.seal)).toEqual({ result: "hi" })
|
||||
expect(toEnvelope("raw", table.seal)).toEqual({ result: "raw" })
|
||||
|
||||
// image/audio blocks become OPAQUE handles (mime/bytes, NO url/data); text stays in result
|
||||
const withImage = toEnvelope(
|
||||
{
|
||||
content: [
|
||||
{ type: "text", text: "see image" },
|
||||
{ type: "image", data: "AAAA", mimeType: "image/png" },
|
||||
],
|
||||
},
|
||||
table.seal,
|
||||
)
|
||||
expect(withImage.result).toBe("see image")
|
||||
expect(withImage.attachments).toEqual([{ type: "file", id: "att_1", mime: "image/png", bytes: 3 }])
|
||||
// The handle exposes no bytes, but resolves back to the real attachment host-side.
|
||||
expect((withImage.attachments![0] as any).url).toBeUndefined()
|
||||
expect(table.resolve(withImage.attachments![0])).toEqual({
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
url: "data:image/png;base64,AAAA",
|
||||
})
|
||||
|
||||
// media-only result: undefined result, still surfaces the handle
|
||||
const mediaOnly = toEnvelope({ content: [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }] }, table.seal)
|
||||
expect(mediaOnly.result).toBeUndefined()
|
||||
expect(mediaOnly.attachments).toEqual([{ type: "file", id: "att_2", mime: "image/jpeg", bytes: 3 }])
|
||||
})
|
||||
|
||||
test("unit: attachmentTable resolve drops fabricated or stale handles", () => {
|
||||
const table = attachmentTable()
|
||||
expect(table.resolve({ type: "file", id: "att_999", mime: "image/png" })).toBeUndefined()
|
||||
expect(table.resolve({ type: "file" })).toBeUndefined()
|
||||
expect(table.resolve("nope")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("unit: formatValue", () => {
|
||||
expect(formatValue("text")).toBe("text")
|
||||
expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2))
|
||||
expect(formatValue(undefined)).toBe("undefined")
|
||||
})
|
||||
|
||||
test("unit: withLogs", () => {
|
||||
// No logs: output is returned untouched.
|
||||
expect(withLogs("result", [])).toBe("result")
|
||||
// Logs are appended as a trailing section, one `[level] message` line each.
|
||||
expect(withLogs("result", [{ level: "log", message: "a" }, { level: "warn", message: "b" }])).toBe(
|
||||
"result\n\nLogs:\n[log] a\n[warn] b",
|
||||
)
|
||||
// Empty output still gets the section (no leading blank lines).
|
||||
expect(withLogs("", [{ level: "error", message: "boom" }])).toBe("Logs:\n[error] boom")
|
||||
})
|
||||
|
||||
test("terminates a runaway loop via the operation limit instead of hanging", async () => {
|
||||
const tool = await build({})
|
||||
const output = await Effect.runPromise(tool.execute({ code: "while (true) {}" }, ctx))
|
||||
expect(output.metadata.error).toBe(true)
|
||||
expect(output.output.toLowerCase()).toContain("operation")
|
||||
})
|
||||
|
||||
test("isolates the sandbox from host globals", async () => {
|
||||
const tool = await build({})
|
||||
const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx))
|
||||
expect(output.metadata.error).toBe(true)
|
||||
})
|
||||
|
||||
test("describe shows the structured return type when the tool declares an outputSchema", async () => {
|
||||
const tools = { weather_current: mcpTool("current", () => "", { type: "object", properties: { city: { type: "string" } } }) }
|
||||
const defs: Record<string, MCPToolDef> = {
|
||||
weather_current: {
|
||||
name: "current",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } } },
|
||||
outputSchema: { type: "object", properties: { tempC: { type: "number" }, summary: { type: "string" } }, required: ["tempC"] },
|
||||
} as any,
|
||||
}
|
||||
const tool = await build(tools, defs)
|
||||
const described = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.describe('weather.current')" }, ctx))
|
||||
const desc = JSON.parse(described.output)
|
||||
expect(desc.signature).toBe(
|
||||
"tools.weather.current(input: { city?: string }): Promise<Result<{ tempC: number; summary?: string }>>",
|
||||
)
|
||||
// describe now returns the return shape as pretty TypeScript, not raw JSON Schema.
|
||||
expect(desc.output).toBe("{\n tempC: number\n summary?: string\n}")
|
||||
expect(desc.outputSchema).toBeUndefined()
|
||||
})
|
||||
|
||||
test("describe returns the input type as TypeScript with JSDoc and enum literals", async () => {
|
||||
const tool = await build({
|
||||
docs_resolve: mcpTool("resolve", () => "", {
|
||||
type: "object",
|
||||
properties: {
|
||||
library: { type: "string", description: "The library name to resolve" },
|
||||
kind: { enum: ["react", "vue"] },
|
||||
},
|
||||
required: ["library"],
|
||||
}),
|
||||
})
|
||||
const described = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.describe('docs.resolve')" }, ctx))
|
||||
const desc = JSON.parse(described.output)
|
||||
expect(desc.input).toBe(
|
||||
'{\n /** The library name to resolve */\n library: string\n kind?: "react" | "vue"\n}',
|
||||
)
|
||||
expect(desc.inputSchema).toBeUndefined()
|
||||
})
|
||||
|
||||
test("forwards attachments from a returned tool result and drops them when only .result is returned", async () => {
|
||||
const tool = await build({
|
||||
shot_take: mcpTool("take", () => ({
|
||||
content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }],
|
||||
structuredContent: { name: "shot.png" },
|
||||
})),
|
||||
})
|
||||
|
||||
const forwarded = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx))
|
||||
expect(forwarded.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
|
||||
expect(JSON.parse(forwarded.output)).toEqual({ name: "shot.png" })
|
||||
|
||||
const suppressed = await Effect.runPromise(
|
||||
tool.execute({ code: "const r = await tools.shot.take({}); return { result: r.result }" }, ctx),
|
||||
)
|
||||
expect(suppressed.attachments).toBeUndefined()
|
||||
expect(JSON.parse(suppressed.output)).toEqual({ name: "shot.png" })
|
||||
})
|
||||
|
||||
test("indexes parameter names so tools are searchable by their inputs", async () => {
|
||||
const tool = await build({
|
||||
// The query word appears only as a parameter name, not in path or description.
|
||||
traces_lookup: mcpTool("lookup", () => "", {
|
||||
type: "object",
|
||||
properties: { trace_id: { type: "string", description: "the distributed trace identifier" } },
|
||||
}),
|
||||
other_noop: mcpTool("noop", () => ""),
|
||||
})
|
||||
const out = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.search('trace_id')" }, ctx))
|
||||
const result = JSON.parse(out.output)
|
||||
expect(result.items.map((i: any) => i.path)).toEqual(["traces.lookup"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("rankTools", () => {
|
||||
const E = (path: string, description: string, params = ""): SearchEntry => ({
|
||||
path,
|
||||
server: path.split(".")[0]!,
|
||||
description,
|
||||
searchText: [path, description, params].join("\n").toLowerCase(),
|
||||
})
|
||||
|
||||
test("matches multiple non-contiguous terms (not just a contiguous substring)", () => {
|
||||
const entries = [
|
||||
E("github.create_issue", "Create a new issue on a repository"),
|
||||
E("github.list_pulls", "List pull requests"),
|
||||
]
|
||||
const { items, total } = rankTools(entries, "create issue")
|
||||
expect(total).toBe(1)
|
||||
expect(items[0]!.path).toBe("github.create_issue")
|
||||
})
|
||||
|
||||
test("ranks an exact tool-name match above a substring match", () => {
|
||||
const entries = [E("github.search_issues", "Search issues"), E("github.search", "Full text search")]
|
||||
const { items } = rankTools(entries, "search")
|
||||
expect(items[0]!.path).toBe("github.search")
|
||||
})
|
||||
|
||||
test("ranks a name match above a description-only match", () => {
|
||||
const entries = [
|
||||
E("datadog.list_monitors", "Enumerate alerting definitions"),
|
||||
E("datadog.get_dashboard", "List the monitors on a dashboard"),
|
||||
]
|
||||
const { items } = rankTools(entries, "monitors")
|
||||
expect(items[0]!.path).toBe("datadog.list_monitors")
|
||||
})
|
||||
|
||||
test("matches against indexed parameter text", () => {
|
||||
const entries = [E("traces.lookup", "Fetch a span", "trace_id the distributed trace id"), E("other.noop", "Does nothing")]
|
||||
const { items, total } = rankTools(entries, "trace_id")
|
||||
expect(total).toBe(1)
|
||||
expect(items[0]!.path).toBe("traces.lookup")
|
||||
})
|
||||
|
||||
test("respects the namespace filter", () => {
|
||||
const entries = [E("github.search", "search"), E("linear.search", "search")]
|
||||
const { items, total } = rankTools(entries, "search", "linear")
|
||||
expect(total).toBe(1)
|
||||
expect(items[0]!.path).toBe("linear.search")
|
||||
})
|
||||
|
||||
test("an empty query (or bare wildcard) lists everything alphabetically", () => {
|
||||
const entries = [E("b.two", "second"), E("a.one", "first")]
|
||||
for (const q of ["", "*"]) {
|
||||
const { items, total } = rankTools(entries, q)
|
||||
expect(total).toBe(2)
|
||||
expect(items.map((i) => i.path)).toEqual(["a.one", "b.two"])
|
||||
}
|
||||
})
|
||||
|
||||
test("honors the limit while reporting the full match total", () => {
|
||||
const entries = Array.from({ length: 10 }, (_, i) => E(`s.tool_${i}`, "searchable tool"))
|
||||
const { items, total } = rankTools(entries, "searchable", undefined, 3)
|
||||
expect(total).toBe(10)
|
||||
expect(items).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("returns nothing when no term matches", () => {
|
||||
const entries = [E("github.search", "search")]
|
||||
expect(rankTools(entries, "nonexistent")).toEqual({ items: [], total: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("renderType", () => {
|
||||
test("renders primitives, integers, and arrays", () => {
|
||||
expect(renderType({ type: "string" })).toBe("string")
|
||||
expect(renderType({ type: "integer" })).toBe("number")
|
||||
expect(renderType({ type: "array", items: { type: "string" } })).toBe("string[]")
|
||||
})
|
||||
|
||||
test("renders enums and const as literal types", () => {
|
||||
expect(renderType({ enum: ["a", "b", "c"] })).toBe('"a" | "b" | "c"')
|
||||
expect(renderType({ const: 42 })).toBe("42")
|
||||
})
|
||||
|
||||
test("renders a nullable type array as a union (does not drop null)", () => {
|
||||
expect(renderType({ type: ["string", "null"] })).toBe("string | null")
|
||||
})
|
||||
|
||||
test("parenthesizes a union inside an array", () => {
|
||||
expect(renderType({ type: "array", items: { type: ["string", "null"] } })).toBe("(string | null)[]")
|
||||
})
|
||||
|
||||
test("renders additionalProperties as an index signature", () => {
|
||||
expect(renderType({ type: "object", additionalProperties: { type: "number" } })).toBe("{ [key: string]: number }")
|
||||
expect(renderType({ type: "object", additionalProperties: true })).toBe("{ [key: string]: any }")
|
||||
})
|
||||
|
||||
test("resolves a local $ref against the document root", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/Node" } },
|
||||
required: ["node"],
|
||||
$defs: { Node: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
|
||||
} as any
|
||||
expect(renderType(schema)).toBe("{ node: { id: string } }")
|
||||
})
|
||||
|
||||
test("collapses a self-referential $ref to its name instead of looping", () => {
|
||||
const schema = {
|
||||
$defs: { Node: { type: "object", properties: { next: { $ref: "#/$defs/Node" } } } },
|
||||
$ref: "#/$defs/Node",
|
||||
} as any
|
||||
// Must terminate; the recursive position falls back to the ref name.
|
||||
expect(renderType(schema)).toBe("{ next?: Node }")
|
||||
})
|
||||
|
||||
test("pretty mode emits an indented block with JSDoc for described fields", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "The library name to resolve" },
|
||||
kind: { enum: ["lib", "app"] },
|
||||
},
|
||||
required: ["name"],
|
||||
} as any
|
||||
expect(renderType(schema, { pretty: true })).toBe(
|
||||
'{\n /** The library name to resolve */\n name: string\n kind?: "lib" | "app"\n}',
|
||||
)
|
||||
})
|
||||
|
||||
test("renders anyOf / oneOf as a union", () => {
|
||||
expect(renderType({ anyOf: [{ type: "string" }, { type: "number" }] })).toBe("string | number")
|
||||
expect(renderType({ oneOf: [{ const: "a" }, { const: "b" }] })).toBe('"a" | "b"')
|
||||
})
|
||||
|
||||
test("empty enum / anyOf / type arrays render as never, not an empty string", () => {
|
||||
expect(renderType({ enum: [] })).toBe("never")
|
||||
expect(renderType({ anyOf: [] })).toBe("never")
|
||||
expect(renderType({ type: [] as any })).toBe("never")
|
||||
})
|
||||
|
||||
test("renders a tuple's first item type", () => {
|
||||
expect(renderType({ type: "array", items: [{ type: "string" }, { type: "number" }] as any })).toBe("string[]")
|
||||
})
|
||||
|
||||
test("combines named properties with an additionalProperties index signature", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
additionalProperties: { type: "number" },
|
||||
} as any
|
||||
expect(renderType(schema)).toBe("{ id: string; [key: string]: number }")
|
||||
})
|
||||
|
||||
test("quotes non-identifier property names", () => {
|
||||
const schema = { type: "object", properties: { "content-type": { type: "string" } } } as any
|
||||
expect(renderType(schema)).toBe('{ "content-type"?: string }')
|
||||
})
|
||||
|
||||
test("nests pretty objects with increasing indentation", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { outer: { type: "object", properties: { inner: { type: "string" } }, required: ["inner"] } },
|
||||
required: ["outer"],
|
||||
} as any
|
||||
expect(renderType(schema, { pretty: true })).toBe("{\n outer: {\n inner: string\n }\n}")
|
||||
})
|
||||
|
||||
test("resolves mutually recursive $refs without looping", () => {
|
||||
const schema = {
|
||||
$ref: "#/$defs/A",
|
||||
$defs: {
|
||||
A: { type: "object", properties: { b: { $ref: "#/$defs/B" } } },
|
||||
B: { type: "object", properties: { a: { $ref: "#/$defs/A" } } },
|
||||
},
|
||||
} as any
|
||||
// A -> B -> A: the second A is on the resolution path, so it collapses to its name.
|
||||
expect(renderType(schema)).toBe("{ b?: { a?: A } }")
|
||||
})
|
||||
|
||||
test("preserves a multi-line description as a multi-line JSDoc block", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "The search query.\nSupports globs.\n\nExamples: *.ts" },
|
||||
},
|
||||
required: ["query"],
|
||||
} as any
|
||||
expect(renderType(schema, { pretty: true })).toBe(
|
||||
"{\n /**\n * The search query.\n * Supports globs.\n *\n * Examples: *.ts\n */\n query: string\n}",
|
||||
)
|
||||
})
|
||||
|
||||
test("emits JSDoc tags for schema constraints TypeScript can't express", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
when: { type: "string", format: "date-time", default: "now", description: "start time" },
|
||||
legacy: { type: "boolean", deprecated: true },
|
||||
tags: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 },
|
||||
},
|
||||
required: ["when"],
|
||||
} as any
|
||||
expect(renderType(schema, { pretty: true })).toBe(
|
||||
[
|
||||
"{",
|
||||
" /**",
|
||||
" * start time",
|
||||
' * @default "now"',
|
||||
" * @format date-time",
|
||||
" */",
|
||||
" when: string",
|
||||
" /** @deprecated */",
|
||||
" legacy?: boolean",
|
||||
" /**",
|
||||
" * @minItems 1",
|
||||
" * @maxItems 5",
|
||||
" */",
|
||||
" tags?: string[]",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("neutralizes a comment terminator inside a JSDoc description", () => {
|
||||
const schema = { type: "object", properties: { x: { type: "string", description: "danger */ oops" } } } as any
|
||||
const out = renderType(schema, { pretty: true })
|
||||
expect(out).toContain("/** danger * / oops */")
|
||||
expect(out).not.toContain("*/ oops")
|
||||
})
|
||||
|
||||
test("is total on a self-referential union (never throws)", () => {
|
||||
const a: any = { anyOf: [] }
|
||||
a.anyOf.push(a) // structural cycle with no $ref
|
||||
expect(() => renderType(a)).not.toThrow()
|
||||
})
|
||||
|
||||
test("unwraps the Pydantic allOf: [{ $ref }] shape with sibling description", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
config: { allOf: [{ $ref: "#/$defs/Config" }], description: "the config block" },
|
||||
},
|
||||
required: ["config"],
|
||||
$defs: { Config: { type: "object", properties: { level: { type: "integer" } }, required: ["level"] } },
|
||||
} as any
|
||||
expect(renderType(schema)).toBe("{ config: { level: number } }")
|
||||
})
|
||||
|
||||
test("renders multi-member allOf as an intersection", () => {
|
||||
const schema = {
|
||||
allOf: [
|
||||
{ type: "object", properties: { a: { type: "string" } }, required: ["a"] },
|
||||
{ type: "object", properties: { b: { type: "number" } }, required: ["b"] },
|
||||
],
|
||||
} as any
|
||||
expect(renderType(schema)).toBe("{ a: string } & { b: number }")
|
||||
})
|
||||
|
||||
test("renders a base object's properties even when it also carries a require-one-of anyOf", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { a: { type: "string" }, b: { type: "string" } },
|
||||
anyOf: [{ required: ["a"] }, { required: ["b"] }],
|
||||
} as any
|
||||
expect(renderType(schema)).toBe("{ a?: string; b?: string }")
|
||||
})
|
||||
})
|
||||
@@ -117,6 +117,7 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) {
|
||||
clients: () => Effect.succeed({}),
|
||||
instructions: () => Effect.succeed(instructions),
|
||||
tools: () => Effect.succeed({}),
|
||||
defs: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
resourceTemplates: () => Effect.succeed({}),
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Rune } from "@/session/rune/rune"
|
||||
import { ToolRuntime } from "@/session/rune/tool-runtime"
|
||||
|
||||
// Runs a Rune program with no host tools and returns the ExecuteResult. These tests pin the
|
||||
// JS-parity fixes for the "99% of ordinary defensive JavaScript just works" goal: cases where
|
||||
// Rune used to throw but idiomatic JS yields undefined / succeeds.
|
||||
const run = (code: string) => Effect.runPromise(Rune.execute({ code, tools: {} }))
|
||||
const value = async (code: string) => {
|
||||
const result = await run(code)
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
const error = async (code: string) => {
|
||||
const result = await run(code)
|
||||
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
|
||||
return result.error
|
||||
}
|
||||
|
||||
describe("H2: string property access reads as undefined (not a throw)", () => {
|
||||
test("unknown property on a string is undefined", async () => {
|
||||
expect(await value(`const s = "hi"; return s.login`)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("optional chaining + fallback on a string does not throw", async () => {
|
||||
expect(await value(`const s = "hi"; return s?.login ?? "fallback"`)).toBe("fallback")
|
||||
})
|
||||
|
||||
test("the real MCP pattern: result is a JSON string, defensive read falls through", async () => {
|
||||
// me.result is a string; me.result?.login is undefined, so we fall back to the raw string.
|
||||
expect(await value(`const me = { result: '{"login":"x"}' }; return me.result?.login ?? me.result`)).toBe(
|
||||
'{"login":"x"}',
|
||||
)
|
||||
})
|
||||
|
||||
test("unknown property on a number is undefined", async () => {
|
||||
expect(await value(`return (5).foo ?? "n"`)).toBe("n")
|
||||
})
|
||||
|
||||
test("supported string methods still work", async () => {
|
||||
expect(await value(`return "AB".toLowerCase()`)).toBe("ab")
|
||||
expect(await value(`return "hello".length`)).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("H3: array property access reads as undefined (not a throw)", () => {
|
||||
test("unknown property on an array is undefined", async () => {
|
||||
expect(await value(`return [1,2,3].foo`)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("optional chaining on an array does not throw", async () => {
|
||||
expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb")
|
||||
})
|
||||
|
||||
test("real-but-unsupported array methods still give the rewrite hint", async () => {
|
||||
const err = await error(`return [1,2,3].splice(0,1)`)
|
||||
expect(err.kind).toBe("UnsupportedSyntax")
|
||||
expect(err.message).toContain("splice")
|
||||
})
|
||||
|
||||
test("supported array methods and indexing still work", async () => {
|
||||
expect(await value(`return [1,2,3].map(x => x + 1)`)).toEqual([2, 3, 4])
|
||||
expect(await value(`return [1,2,3][9]`)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("H6: object spread of null/undefined is a no-op", () => {
|
||||
test("spreading null is a no-op", async () => {
|
||||
expect(await value(`const o = null; return { ...o, a: 1 }`)).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("spreading an absent argument merges cleanly", async () => {
|
||||
expect(await value(`function f(opts){ return { ...opts, a: 1 } } return f(undefined)`)).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("spreading a real object still works", async () => {
|
||||
expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
test("spreading an array into an object still errors", async () => {
|
||||
const err = await error(`return { ...[1,2], a: 1 }`)
|
||||
expect(err.kind).toBe("InvalidDataValue")
|
||||
})
|
||||
})
|
||||
|
||||
describe("H4: typeof on an undeclared identifier is 'undefined'", () => {
|
||||
test("feature-detection guard does not throw", async () => {
|
||||
expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe")
|
||||
})
|
||||
|
||||
test("typeof of a declared binding is unaffected", async () => {
|
||||
expect(await value(`const x = 5; return typeof x`)).toBe("number")
|
||||
expect(await value(`const s = "a"; return typeof s`)).toBe("string")
|
||||
})
|
||||
|
||||
test("referencing an undeclared identifier outside typeof still throws", async () => {
|
||||
const err = await error(`return foo + 1`)
|
||||
expect(err.message).toContain("foo")
|
||||
})
|
||||
})
|
||||
|
||||
describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => {
|
||||
test("guards run instead of the program crashing on a transient NaN", async () => {
|
||||
expect(await value(`return parseInt("abc") || 0`)).toBe(0)
|
||||
expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0)
|
||||
expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1)
|
||||
// average of an empty list, guarded — the classic divide-by-zero that used to throw pre-guard
|
||||
expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0)
|
||||
})
|
||||
|
||||
test("a non-finite value becomes null when it leaves the sandbox", async () => {
|
||||
expect(await value(`return 5/0`)).toBeNull()
|
||||
expect(await value(`return 0/0`)).toBeNull()
|
||||
expect(await value(`return Math.max()`)).toBeNull()
|
||||
// nested, too — normalization walks the returned structure
|
||||
expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] })
|
||||
})
|
||||
|
||||
test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => {
|
||||
expect(await value(`return Number.isNaN(NaN)`)).toBe(true)
|
||||
expect(await value(`return Infinity > 1e9`)).toBe(true)
|
||||
expect(await value(`return Number.isFinite(1/0)`)).toBe(false)
|
||||
expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3)
|
||||
// JSON.stringify inside the sandbox matches JS: non-finite serializes to null
|
||||
expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}')
|
||||
})
|
||||
|
||||
test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => {
|
||||
// Tool-call arguments funnel through copyOut too, so this one function pins both boundaries.
|
||||
expect(ToolRuntime.copyOut(NaN)).toBeNull()
|
||||
expect(ToolRuntime.copyOut(Infinity)).toBeNull()
|
||||
expect(ToolRuntime.copyOut(-Infinity)).toBeNull()
|
||||
expect(ToolRuntime.copyOut(42)).toBe(42)
|
||||
expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("H5: builtin coercion functions work as array callbacks", () => {
|
||||
test("filter(Boolean) drops falsy values", async () => {
|
||||
expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("map(String) coerces each element", async () => {
|
||||
expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"])
|
||||
})
|
||||
|
||||
test("arrow callbacks still work (no regression)", async () => {
|
||||
expect(await value(`return [1, 2, 3, 4].filter(x => x % 2 === 0)`)).toEqual([2, 4])
|
||||
expect(await value(`return [1, 2, 3].reduce((a, b) => a + b, 0)`)).toBe(6)
|
||||
})
|
||||
|
||||
test("a non-callable callback is still rejected", async () => {
|
||||
const err = await error(`return [1,2,3].map(42)`)
|
||||
expect(err.message).toContain("callback")
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,7 @@ const mcp = Layer.succeed(
|
||||
clients: () => Effect.succeed({}),
|
||||
instructions: () => Effect.succeed([]),
|
||||
tools: () => Effect.succeed({}),
|
||||
defs: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
resourceTemplates: () => Effect.succeed({}),
|
||||
|
||||
@@ -1758,6 +1758,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
||||
<Match when={display() === "task"}>
|
||||
<Task {...toolprops} />
|
||||
</Match>
|
||||
<Match when={display() === "execute"}>
|
||||
<Execute {...toolprops} />
|
||||
</Match>
|
||||
<Match when={display() === "apply_patch"}>
|
||||
<ApplyPatch {...toolprops} />
|
||||
</Match>
|
||||
@@ -2322,6 +2325,75 @@ export function formatCompletedSubagentDetail(toolcalls: number, duration: strin
|
||||
return `${formatSubagentToolcalls(toolcalls)} · ${duration}`
|
||||
}
|
||||
|
||||
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
|
||||
|
||||
function executeCalls(value: unknown): ExecuteCall[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.flatMap((call) => {
|
||||
const item = recordValue(call)
|
||||
const tool = stringValue(item?.tool)
|
||||
const status = stringValue(item?.status)
|
||||
if (!tool || !status || !["running", "completed", "error"].includes(status)) return []
|
||||
return [{ tool, status: status as ExecuteCall["status"], input: recordValue(item?.input) }]
|
||||
})
|
||||
}
|
||||
|
||||
// The `execute` tool streams child tool calls through metadata, not a child session like Task.
|
||||
function Execute(props: ToolProps) {
|
||||
const ctx = use()
|
||||
const { theme } = useTheme()
|
||||
const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running")
|
||||
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
|
||||
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
|
||||
const hasRuntimeError = createMemo(() => props.metadata.error === true)
|
||||
const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output)
|
||||
const showOutput = createMemo(() => output() && (hasRuntimeError() || calls().length === 0))
|
||||
const detailPadding = 3 + INLINE_TOOL_ICON_WIDTH
|
||||
|
||||
const complete = createMemo(() => (props.part.state.status === "pending" ? false : "execute"))
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineTool
|
||||
icon={props.part.state.status === "completed" && !hasRuntimeError() ? "✓" : props.part.state.status === "error" || hasRuntimeError() ? "✗" : "│"}
|
||||
color={hasRuntimeError() ? theme.error : undefined}
|
||||
spinner={isLoading()}
|
||||
pending="execute"
|
||||
complete={complete()}
|
||||
part={props.part}
|
||||
>
|
||||
execute
|
||||
</InlineTool>
|
||||
<For each={calls()}>
|
||||
{(call) => {
|
||||
const args = input(call.input ?? {})
|
||||
return (
|
||||
<box paddingLeft={detailPadding}>
|
||||
<text fg={call.status === "error" ? theme.error : theme.textMuted}>
|
||||
↳ {call.tool}
|
||||
{args ? ` ${args}` : ""}
|
||||
{call.status === "error" ? " (failed)" : ""}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={showOutput()}>
|
||||
<box paddingLeft={detailPadding}>
|
||||
<For each={outputPreview().split("\n")}>
|
||||
{(line, index) => (
|
||||
<text fg={hasRuntimeError() ? theme.error : theme.textMuted}>
|
||||
{index() === 0 ? "↳ " : " "}
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Edit(props: ToolProps) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
@@ -2578,6 +2650,7 @@ const toolDisplays = new Set([
|
||||
"todowrite",
|
||||
"question",
|
||||
"skill",
|
||||
"execute",
|
||||
])
|
||||
|
||||
export function toolDisplay(tool: string) {
|
||||
|
||||
Reference in New Issue
Block a user