Compare commits

...

25 Commits

Author SHA1 Message Date
Aiden Cline e06a099a88 fix(opencode): let NaN/Infinity flow in Rune, normalize to null at the boundary
Rune previously threw the instant any non-finite number materialized, which
killed the program before the model's own guard could run — so idiomatic code
like parseInt(x) || 0, Number(x) then Number.isNaN(x), averages, and counters
crashed mid-expression. Real JS (and a real-engine sandbox) let these values
flow and rely on JSON serialization to turn them into null at the edge.

- copyIn no longer rejects non-finite numbers, so NaN/Infinity exist as ordinary
  in-sandbox intermediates and defensive guards can run.
- copyOut normalizes non-finite numbers to null as a value leaves the sandbox;
  final return and tool-call arguments both funnel through it, so one check pins
  both boundaries (matching JSON.stringify, which produces null anyway).
- NaN/Infinity are now bindable identifiers (e.g. reduce(max, -Infinity)).

Extends rune-parity.test.ts (guards run, non-finite -> null out incl. nested, a
direct copyOut unit test) and updates rune.md.
2026-07-01 12:26:55 -05:00
Aiden Cline cc437b9e9a fix(opencode): make Rune tolerate idiomatic defensive JavaScript
Bring the Rune interpreter closer to JS semantics so ordinary defensive code
stops crashing where real JS would quietly yield undefined or succeed:

- Unknown property reads on strings, numbers, and arrays now yield undefined
  instead of throwing (including under optional chaining, which only guards
  null/undefined receivers). MCP results are frequently JSON strings, so
  `result?.field ?? result` reads the field when present and falls back to the
  raw string otherwise. The method allowlist still errors (e.g. arr.splice keeps
  its rewrite hint).
- `typeof undeclaredIdentifier` is now "undefined" rather than a reference
  error, so `typeof x !== "undefined"` feature-detection guards are safe.
- Object spread of null/undefined is a no-op, so `{ ...maybeOpts, override }`
  merges work when the operand is absent.
- Builtin coercions (Boolean/String/Number) are accepted as array callbacks, so
  filter(Boolean) / map(String) / map(Number) work.

Adds rune-parity.test.ts covering each fix plus its guardrails, and documents
the parity behavior in rune.md.
2026-07-01 12:20:54 -05:00
Aiden Cline 064c34b25a feat(opencode): capture console output and surface it to the model
- console.log/warn/error/info/debug are now a real Rune interpreter builtin:
  a seeded global that formats its args (strings verbatim, objects/arrays as
  JSON, space-joined) and appends a line to a per-run LogCollector. It is not a
  tool call (spends no tool-call budget), returns undefined, and any other
  member throws. Formatting is charged to maxOperations and total captured
  output is bounded by maxAuditBytes so a logging loop can't exhaust memory.
- The collector is shared by reference with parallel interpreter forks (like the
  operation budget) and lives in execute()'s outer scope, so logs are surfaced
  on every ExecuteResult path — success, thrown error, and timeout.
- ExecuteResult (and its schema) gain a logs field; code mode appends captured
  logs to the model-facing output as a trailing '[level] message' section on
  both the success and error paths (withLogs). Logs go to the model only.
- Documents console in rune.md; drops the now-satisfied 'not done yet' entry.
2026-07-01 11:36:37 -05:00
Aiden Cline 2d9015c30d fix(tui): simplify execute running state 2026-07-01 11:02:01 -05:00
Aiden Cline c16bba8bc0 feat(opencode): JSDoc tags, Result<T> return hints, and opaque attachments
- describe/preview now surface each tool's return type as Result<T> (alias
  defined once in the prompt); the inline preview shows it too, so the model
  sees a tool's result shape without a describe round-trip. T is the declared
  outputSchema else unknown, with prose telling the model to inspect an unknown
  result before assuming fields.
- renderType pretty mode emits JSDoc tags a TS type can't express: @default,
  @format, @deprecated, @minItems/@maxItems (multi-line descriptions preserved).
- Attachments are now opaque handles: a tool's media becomes
  { type:'file', id, mime, filename?, bytes } with no inline bytes. Real bytes
  stay host-side in a per-execution attachmentTable; the program propagates or
  drops a handle (return it to surface the image to the model+user) but cannot
  read or leak the base64. Documents the divergence from prior art in rune.md.
- Records the throw/catch (not errors-as-values) decision in rune.md.
2026-07-01 10:54:42 -05:00
Aiden Cline 5085a13b0e feat(opencode): render code-mode types as TypeScript, not JSON Schema
tools.$rune.describe now returns { path, description, signature, input,
output? } with input/output as TypeScript instead of raw JSON Schema.
Rewrite renderType into a total, cycle-safe JSON-Schema -> TS renderer:
resolve local $refs (collapsing recursive refs to their name), render
enums/const as literals, anyOf/oneOf and nullable type arrays as unions,
allOf as an intersection (unwrapping the Pydantic allOf:[{$ref}] shape),
tuples, and additionalProperties as an index signature. Pretty mode emits
JSDoc (multi-line preserved) for described fields, with */ neutralized.
Falls back to any/object and never throws; depth-capped.

Docs updated; code-mode/renderType test coverage expanded.
2026-06-30 22:34:23 -05:00
Aiden Cline 05b934616b fix(tui): surface execute child call details 2026-06-30 19:23:49 -05:00
Aiden Cline 2726a74203 fix(tui): normalize execute tool styling 2026-06-30 18:03:23 -05:00
Aiden Cline bc427b11b7 feat: live code-mode execute UI in the TUI
Backend: code-mode execute now streams per-call progress. Metadata.toolCalls
becomes CallEntry[] ({ tool: dotted-path, status: running|completed|error })
and is published via ctx.metadata on every status change — when a child MCP
call starts and when it resolves/fails — so the tool part updates live.

TUI: add an Execute component (dispatched for the "execute" tool). Condensed
view shows a run header (spinner while running, ✓ when done) plus a live `↳`
line per child tool call, colored on failure; clicking toggles a syntax-
highlighted view of the program source. Unlike Task, there is no child session,
so the call list is sourced entirely from the streamed metadata.
2026-06-30 17:13:16 -05:00
Aiden Cline dc75ea0cc0 feat(opencode): state whether the code mode tool list is complete or partial
The preview now reports its own comprehensiveness so the model knows when it
has the whole catalog vs. when it must search:

- Overall: "This is the COMPLETE list ..." when every tool fits the budget,
  else "This is a PARTIAL list — X of Y tools are shown ...".
- Per namespace: a fully-shown server reads `- github (2 tools)`; a truncated
  one is annotated `- alpha (70 tools, 31 shown)` or `- zeta (1 tool, none shown)`.

When complete, the model isn't pushed toward needless $rune.search calls; when
partial, exactly what's missing is unambiguous.
2026-06-30 16:56:37 -05:00
Aiden Cline 55aa8cce44 feat(opencode): inline budgeted call signatures in code mode preview
The preview previously showed name + prose per tool but no parameters, so
the model couldn't call a tool correctly without a $rune.describe round-trip
(or a failed guess — e.g. calling context7 resolve-library-id with only
libraryName, missing the required query). Replace the prose preview with a
compact, directly-callable input signature:

  tools.context7["resolve-library-id"](input: { query: string; libraryName: string })

All namespaces are still always listed with counts; the budget now caps
inlined signatures, and the description states explicitly that any tool not
shown must be found via $rune.search/$rune.describe first. The full typed
signature (with the Promise return) and schemas remain $rune.describe-only.
2026-06-30 15:46:30 -05:00
Aiden Cline 394c37084b docs(opencode): add rune.md (how it works, what's missing) 2026-06-30 15:03:45 -05:00
Aiden Cline 79275e60fb fix(opencode): describe attachments as routable values, not opaque
The previous wording claimed attachment bytes 'aren't available in code',
but a tool result's { result, attachments } envelope is copied straight
into the sandbox (tool-runtime invoke -> checkedCopyIn), so attachment.url
is a real data: URL string the program can read and route — e.g. feed one
tool's media into another tool's input, which is exactly what code mode is
uniquely good at. Reword the description to say so. Still not the emit
pattern: only `result` becomes conversation text; returned attachments
lower to FileParts and nothing else in the sandbox re-enters the chat.

Add an integration test asserting the data URL is readable in-program.
2026-06-30 14:10:06 -05:00
Aiden Cline cbcc67b1e2 refactor(opencode): single source of discovery docs, clearer attachment wording
- Reword the attachment line in the execute description: attachments are
  opaque media handles you forward to the user, not byte-readable in code.
- Strip the hardcoded tools.$rune.search/describe block from the runtime's
  instructions(): discovery is not a runtime feature (the embedder registers
  and documents it), and the baked-in object-arg form contradicted code
  mode's positional API. Removes the only prompt discrepancy.
- Make the runtime's unknown-tool suggestion generic instead of naming $rune.

Discovery is now documented in exactly one place (code-mode describe()).
2026-06-30 13:59:33 -05:00
Aiden Cline a3bfba809f feat(opencode): unify code mode discovery under tools.$rune
Remove Rune's vestigial built-in $rune.search/$rune.describe (which only
saw effect-Schema Definitions, never our dynamic MCP tools) and the
reserved-namespace guard. Move our ranked search and typed describe under
tools.$rune.* — the runtime's own namespace, separate from MCP server
namespaces and collision-proof since $ never appears in a sanitized
server name. One discovery implementation, no dead code.
2026-06-30 11:11:55 -05:00
Aiden Cline ba12049ca5 feat(opencode): tokenized, ranked tool search in code mode
Replace the contiguous-substring search with tokenized, field-weighted
scoring adapted from the deferred-tool-search bridge (#34368): exact tool
name > path > description > indexed parameter text, summed across terms
and ranked by relevance. Index each tool's parameter names/descriptions
so tools are findable by their inputs. Keep the namespace filter and
{ items, total } shape. Add unit + end-to-end search tests.
2026-06-30 10:08:02 -05:00
Aiden Cline acc1742859 test(opencode): end-to-end code mode test over a real MCP server
Stand up an in-memory MCP server with text, structured (outputSchema),
image, and failing tools, wire it through convertTool + define, and
exercise search/describe, the result+attachments envelope, structured
composition, image forwarding/suppression, parallel calls, error
propagation, and per-call permission gating.
2026-06-30 09:55:25 -05:00
Aiden Cline 1448f248fd feat(opencode): budgeted tool preview in code mode description
Always list every MCP namespace, then inline a preview of individual
tools (path + brief) until a character budget is hit; remaining
namespaces show counts only. Front-loads a useful slice of the catalog
to cut discovery round-trips without dumping the full tool list.
2026-06-30 09:49:52 -05:00
Aiden Cline 71ffc73272 feat(opencode): run code mode on the vendored rune interpreter
Replace the in-process AsyncFunction engine with Rune.execute. MCP tools
are exposed as a host-tool tree (tools.<server>.<tool>) plus top-level
search/describe; each call is permission-gated and coerced to the
{ result, attachments? } envelope. Raise data limits for base64 media.

This adds real sandboxing: host globals are isolated and runaway loops
terminate via the operation limit instead of hanging the event loop.
2026-06-30 09:36:59 -05:00
Aiden Cline 14527d2047 feat(opencode): code mode result+attachments envelope and typed describe
Expose mcp.defs() so code mode can read MCP outputSchema. Tool calls and
the final return now use one { result, attachments? } envelope; media
blocks become FilePart attachments. describe renders typed signatures
with the structured return type when an outputSchema is present.
2026-06-30 09:27:39 -05:00
Aiden Cline 3a6621c5fc chore(opencode): vendor rune interpreter for code mode
Copy the Rune TS AST interpreter into the session package as the
foundation for code-mode execution. Add acorn as a dependency and
promote typescript to a runtime dependency (both required by Rune's
parser/transpile path). Fix effect beta.83 API drift (Schema.Defect
is now a function). Interpreter logic is unchanged; nothing imports
it yet.
2026-06-30 00:05:58 -05:00
Aiden Cline caa5f28cc9 refactor(opencode): simplify code mode namespace listing
Drop the MCP-instructions blending from the execute tool description; just
list namespace names and tool counts on the tool definition. The full server
instructions already live in the system prompt's <mcp_instructions>, and
per-tool detail is fetched on demand via tools.search/tools.describe.
2026-06-29 18:46:28 -05:00
Aiden Cline cad83ab53e feat(opencode): progressive tool discovery for code mode
Shrink the execute tool description to namespaces only (server name, tool
count, and a brief note reused from the server's MCP instructions) instead of
inlining every tool signature, so the prompt stays small for large catalogs.

Add in-sandbox discovery: `tools.search(query, { namespace?, limit? })` returns
ranked tool paths + descriptions, and `tools.describe(path)` returns the full
signature and input schema on demand (with tool_not_found suggestions). The
proxy is now recursive so both `tools.<server>.<tool>(args)` and the dotted
`tools[path](args)` returned by search resolve to the catalog key.
2026-06-29 18:39:55 -05:00
Aiden Cline b0aa6bfb61 feat(opencode): namespace code mode tools by MCP server
Expose connected MCP tools to code mode as per-server namespaces
(`tools.<server>.<tool>(args)`) and generate the execute tool description
from the catalog: one namespace block per server, each tool rendered with a
TypeScript-style input signature derived from its JSON schema plus its
description, and the calling convention stated up front.

The server/tool split is cosmetic; child-call routing re-joins the segments
into the existing flat catalog key, so it stays exact regardless of
underscores in server or tool names.
2026-06-29 18:33:08 -05:00
Aiden Cline 49f20b6a30 feat(opencode): add experimental code mode execute tool
Add an experimental, off-by-default `execute` tool that runs LLM-authored
JavaScript with a `tools.<name>(args)` proxy over connected MCP tools. When
OPENCODE_EXPERIMENTAL_CODE_MODE is enabled and MCP tools are present, the
session exposes the single code-mode tool instead of registering each MCP
tool directly; child calls route through the native permission path.

Code mode is defined via the standard Tool.define/Tool.init machinery so it
inherits arg decoding and output truncation from the shared wrapper. Tool
results are reduced to structured content or text, and the program's return
value is coerced to text without failing on shape.

Note: execution currently uses an in-process AsyncFunction with no isolation
or timeout; sandboxing is tracked as follow-up work.
2026-06-29 18:18:25 -05:00
18 changed files with 5694 additions and 6 deletions
+21 -2
View File
@@ -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=="],
+2 -1
View File
@@ -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"),
+19
View File
@@ -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,
+832
View File
@@ -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>
}),
}),
)
}
+2
View File
@@ -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 }) })
+243
View File
@@ -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.)
+2888
View File
@@ -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"
+100
View File
@@ -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"
+27 -3
View File
@@ -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({}),
+73
View File
@@ -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) {