diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 5ae401c5f0..2d6988eee5 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -8,11 +8,11 @@ The package is currently private to this workspace. Its API is designed around t ```ts // One execution -yield* CodeMode.execute({ tools, code }) +yield * CodeMode.execute({ tools, code }) // A reusable runtime const runtime = CodeMode.make({ tools, limits }) -yield* runtime.execute(code) +yield * runtime.execute(code) // One agent-facing code tool const codeTool = runtime.agentTool() @@ -55,7 +55,9 @@ const runtime = CodeMode.make({ }, }) -const result = yield* runtime.execute(` +const result = + yield * + runtime.execute(` const order = await tools.orders.lookup({ id: "order_42" }) return { id: order.id, needsAttention: order.status !== "complete" } `) @@ -72,8 +74,8 @@ Successful result values are JSON-safe data. A program that returns `undefined`, ```ts const tool = Tool.make({ description, - input, // Effect Schema (validating) or JSON Schema (render-only) - output, // optional; same choice + input, // Effect Schema (validating) or JSON Schema (render-only) + output, // optional; same choice run, }) ``` @@ -89,13 +91,15 @@ The description and schemas are part of the model-visible tool contract. Keep de Use `CodeMode.execute` for a single execution: ```ts -const result = yield* CodeMode.execute({ - tools: { orders: { lookup: lookupOrder } }, - code: `return await tools.orders.lookup({ id: "order_42" })`, - limits: { maxToolCalls: 10 }, - onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call), - onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call), -}) +const result = + yield * + CodeMode.execute({ + tools: { orders: { lookup: lookupOrder } }, + code: `return await tools.orders.lookup({ id: "order_42" })`, + limits: { maxToolCalls: 10 }, + onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call), + onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call), + }) ``` The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations. @@ -110,10 +114,10 @@ const runtime = CodeMode.make({ limits: { timeoutMs: 30_000 }, }) -runtime.catalog() // structured tool descriptions -runtime.instructions() // model-facing syntax and tool guide +runtime.catalog() // structured tool descriptions +runtime.instructions() // model-facing syntax and tool guide runtime.execute(source) // ExecuteResult -runtime.agentTool() // { name, description, input, output, execute } +runtime.agentTool() // { name, description, input, output, execute } ``` `catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`. @@ -222,10 +226,10 @@ CodeMode is an orchestration language, not a general JavaScript runtime. The limits are exactly three knobs: -| Limit | Default | Bounds | -| --- | ---: | --- | -| `timeoutMs` | none — no timeout | Wall-clock execution time. | -| `maxToolCalls` | none — unlimited | Tool calls admitted during the execution. | +| Limit | Default | Bounds | +| ---------------- | -------------------: | -------------------------------------------------------------------- | +| `timeoutMs` | none — no timeout | Wall-clock execution time. | +| `maxToolCalls` | none — unlimited | Tool calls admitted during the execution. | | `maxOutputBytes` | none — no truncation | Model-facing output: the serialized result value plus captured logs. | No limit has a default, on purpose: execution budgets are host policy, not library policy — a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context. @@ -254,28 +258,25 @@ Two interpreter internals are fixed constants rather than knobs: at most 8 tool Failures are data: -| Kind | Meaning | -| --- | --- | -| `ParseError` | Source is empty or cannot be parsed. | -| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. | -| `UnknownTool` | A program referenced a tool the host did not provide. | -| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. | -| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. | -| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). | -| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. | -| `TimeoutExceeded` | Execution exceeded `timeoutMs`. | -| `ToolFailure` | A tool refused or failed. | -| `ExecutionFailure` | The program threw or another execution error occurred. | +| Kind | Meaning | +| ----------------------- | -------------------------------------------------------------------------------------------------------- | +| `ParseError` | Source is empty or cannot be parsed. | +| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. | +| `UnknownTool` | A program referenced a tool the host did not provide. | +| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. | +| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. | +| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). | +| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. | +| `TimeoutExceeded` | Execution exceeded `timeoutMs`. | +| `ToolFailure` | A tool refused or failed. | +| `ExecutionFailure` | The program threw or another execution error occurred. | Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`: ```ts import { toolError } from "@opencode-ai/codemode" -run: ({ id }) => - authorized(id) - ? loadOrder(id) - : Effect.fail(toolError("Order is unavailable")) +run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable"))) ``` Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index d824681f92..857983e3a8 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -39,6 +39,7 @@ package and was **deleted** in Wave 3 (done, see below). From issue #34787 and design discussion. Do not relitigate these casually. ### Core direction + - Generic CodeMode lives in its own package: `@opencode-ai/codemode` (repo scope convention; the issue's `@opencode/codemode` name was normalized to the `@opencode-ai/*` convention). - **Keep the hand-rolled interpreter.** No QuickJS/V8/sandbox-engine dependency. We own and @@ -52,6 +53,7 @@ From issue #34787 and design discussion. Do not relitigate these casually. products/blog posts) in code, comments, commit messages, or docs in this repo. ### MCP / tools + - The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary `Tool.make(...)` definitions and hands CodeMode a plain tool tree. - Permissions stay in the OpenCode adapter (each tool's `run` wraps the permission ask). @@ -61,8 +63,9 @@ From issue #34787 and design discussion. Do not relitigate these casually. `tools..` namespaces before handing them over. ### Discovery / search + - **Search only — no separate `describe`.** `tools.$codemode.search({ query?, namespace?, - limit? })` over the final tool tree, owned by this package. +limit? })` over the final tool tree, owned by this package. - Search result item shape: `{ path, description, signature }` in an `{ items, total }` wrapper. The `signature` string embeds the full input/output TypeScript types — in search results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schema @@ -82,6 +85,7 @@ From issue #34787 and design discussion. Do not relitigate these casually. - Tools without an output schema render `unknown` as their return type. ### Schemas / Tool.make + - `Tool.make` carries rich metadata so search can render real signatures. - Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially render-only — used for TypeScript rendering; the adapter may validate on its own). Leave @@ -90,6 +94,7 @@ From issue #34787 and design discussion. Do not relitigate these casually. normalization for plugin authors can come later. ### Attachments / output + - **No `output.text/file/image` API in v1.** (Deleted in Wave 2.) - Tool calls return native structured payloads into the sandbox. Files/images emitted by child tools **never enter the sandbox** — the OpenCode adapter strips and accumulates them @@ -100,6 +105,7 @@ From issue #34787 and design discussion. Do not relitigate these casually. image bytes into context or drop attachments. ### Runtime behavior + - Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` — matching the original locked spec exactly. NO limit has a default (user direction, Fix 6 for the first two; extended to `maxOutputBytes` in the truncation-layering fix below): @@ -129,7 +135,7 @@ From issue #34787 and design discussion. Do not relitigate these casually. - `console.*` is captured into `logs` on the result; the host appends them to model-facing output. Not a tool call; costs no tool budget. - Simple tool-call **start/end hooks** for nested progress: `onToolCallStart({ index, name, - input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`. +input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`. Interrupted calls fire no end event. No `CurrentToolCall` context service (removed in Wave 2). @@ -148,8 +154,9 @@ and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and `test/tool/registry.test.ts`). ### Wave 0 — scaffold (done) + - `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool, - tool-error,tool-runtime}.ts`, README, AGENTS.md, tests. +tool-error,tool-runtime}.ts`, README, AGENTS.md, tests. - `package.json`: name `@opencode-ai/codemode`, deps `acorn@8.15.0`, `typescript: catalog:`, `effect: catalog:` (both repos pin effect `4.0.0-beta.83`; opencode's effect patch only touches `unstable/httpapi`, which this package doesn't use). @@ -157,6 +164,7 @@ and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`. ### Wave 1a — forgiving JS semantics (done) + Ported from the old opencode rune work; `test/parity.test.ts` (24 tests) is the acceptance spec. The seeded interpreter was deliberately strict; these behaviors replaced that: @@ -173,6 +181,7 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t null/undefined still throws (real JS throws too). ### Wave 1b-i — stdlib value types: Date, RegExp, Map, Set (done) + `src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both `codemode.ts` and `tool-runtime.ts` import without a cycle). Design: @@ -202,19 +211,20 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t interpolation renders `/regex/` and ISO dates directly. ### Wave 2 — API layer (done) + The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this wave; both packages typecheck clean. - **`Tool.make` schema flexibility** (`src/tool.ts`): `input`/`output` each accept an Effect Schema (validating, decoded both directions as before) OR a raw JSON Schema document (render-only — no validation, values pass through; rendering handles `$defs`/`definitions` - + `$ref`). `output` is **optional** → signature renders `Promise` and the host - result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from - `tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/ - `jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there - anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty - `Schema.Struct({})` renders as `{ } | Array` (effect's JSON Schema emission) — - cosmetic, fixed in Wave 4. + - `$ref`). `output` is **optional** → signature renders `Promise` and the host + result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from + `tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/ + `jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there + anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty + `Schema.Struct({})` renders as `{ } | Array` (effect's JSON Schema emission) — + cosmetic, fixed in Wave 4. - **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output` global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields, instructions line, README section, seeded tests. AGENTS.md keeps a rephrased @@ -228,22 +238,23 @@ wave; both packages typecheck clean. (`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls fire no end event (timeout kills the whole execution anyway). - **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?, - maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as +maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5 later deleted that type and the internal limit system entirely. - **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte - output limit; return a smaller value]`; logs keep leading lines within the remaining budget - + `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to - `ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox - `maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 — - truncation is now the only result-size mechanism.) +output limit; return a smaller value]`; logs keep leading lines within the remaining budget + - `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to + `ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox + `maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 — + truncation is now the only result-size mechanism.) - **Search polish**: default limit 12 → **10** (`defaultSearchLimit`); exact-path lookup — a trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone (`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged. ### Wave 3 — OpenCode MCP adapter (done) + `packages/opencode/src/session/code-mode.ts` rewritten as a thin adapter over this package; the vendored rune interpreter is gone. Same `define(mcpTools, mcpDefs, servers)` signature, so `tools.ts` gating (flag on + MCP tools exist → single `execute` tool, early-return suppresses @@ -257,7 +268,7 @@ per-MCP registration; MCP resource tools unaffected) is unchanged. invoked) — so signature rendering, the inline-vs-search switch, and `$codemode.search` availability all come from this package and stay consistent with execution. - **`run` path**: per-child permission ask first (`ctx.ask({ permission: entry.key, patterns: - ["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child). +["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child). Denials and host failures are mapped to `toolError(message)` so they surface as safe, catchable in-program failures (MCP `isError` text propagates as `e.message`; without this they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper from @@ -270,10 +281,10 @@ per-MCP registration; MCP resource tools unaffected) is unchanged. No handles, no `Result` envelope, no base64 in the sandbox, no data-size tuning (the `maxDataBytes` budget that existed at the time was deleted in Fix 5). - **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND - error — logs are plain pre-formatted lines now), attachments: accumulated }` through the +error — logs are plain pre-formatted lines now), attachments: accumulated }` through the existing `Tool.ExecuteResult.attachments` → `message-v2.ts` vision plumbing; attachments ride on both success and error results. Diagnostic `suggestions` not already contained in - the message are appended to error output. Native outer truncation stays on (adapter never + the message are appended to error output. Native outer truncation stays on (adapter never sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default at the time) cut first — since the truncation-layering fix, native truncation is the only layer. Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout); @@ -296,6 +307,7 @@ per-MCP registration; MCP resource tools unaffected) is unchanged. describe/`renderType`/`rankTools` tests died with the old design (58+17+24 → 34+16). ### Wave 4 — instructions/prompting + polish (done) + Instructions are now the budgeted-catalog + prompting-guidance form; verified e2e against a real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both packages typecheck clean. @@ -321,7 +333,7 @@ packages typecheck clean. exported); `CodeMode.execute` (one-shot) passes it too, preserving the `execute`≡`make().execute` law. A speculative `tools.$codemode.search` call on a small catalog now succeeds instead of `UnknownTool`, and unknown-tool suggestions always point at - search. Search is *advertised* in the instructions only when the inlined list is PARTIAL, + search. Search is _advertised_ in the instructions only when the inlined list is PARTIAL, keeping small-catalog instructions tight. - **Prompting content** in `instructions()`, mapping 1:1 to the §5 transcript failures: parse-string-results-as-JSON, return-small, console-for-intermediates, and @@ -340,7 +352,7 @@ packages typecheck clean. - **E2E (verified, headless)**: from the repo root with `OPENCODE_EXPERIMENTAL_CODE_MODE=1`, the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory, sequential-thinking; left uncommitted/as-is), and `bun packages/opencode/src/index.ts run - --dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single +--dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single `execute` tool registered alongside core tools (per-MCP registration suppressed; MCP resource tools unaffected); the live description read back as "Available tools (PARTIAL — 56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace @@ -352,6 +364,7 @@ packages typecheck clean. images, output truncation. ### Wave 5 — Promise generalization (done) + First-class promise values in the interpreter; the direct-tool-call-only `Promise.all` restriction (and its bespoke AST checks) is gone. Package suite is 136 tests / 0 fail (35 new in `test/promise.test.ts`); adapter suites and both typechecks unchanged/green; the opencode @@ -495,7 +508,7 @@ adapter needed **no changes**. `tools.*`." (the second line drops the tools clause when the tree is empty). - **`## Workflow`**: numbered steps — find a tool via `tools.$codemode.search` → read the `{ path, description, signature }` matches → call by path → `typeof res === - "string" ? JSON.parse(res) : res` → return only the needed fields. When the catalog is +"string" ? JSON.parse(res) : res` → return only the needed fields. When the catalog is COMPLETE the search/read steps collapse into "Pick a tool from the list under `## Available tools`" and the steps renumber (4 instead of 5). - **`## Rules`**: call-by-exact-path; TEXT-is-JSON → JSON.parse; return small (never raw @@ -526,70 +539,72 @@ adapter needed **no changes**. **Fix 4 — token-budgeted catalog (was bytes)** (user direction: signatures need a token budget; namespaces must always be present): - - `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so - the package stays dependency-free; keep in sync if the core heuristic changes. - - `DiscoveryOptions.maxInlineCatalogBytes` → `maxInlineCatalogTokens` (default 4,000 - estimated tokens ≈ the old 16,000 bytes at 4 chars/token — behavior parity, not a size - reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first - + stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in + +- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so + the package stays dependency-free; keep in sync if the core heuristic changes. +- `DiscoveryOptions.maxInlineCatalogBytes` → `maxInlineCatalogTokens` (default 4,000 + estimated tokens ≈ the old 16,000 bytes at 4 chars/token — behavior parity, not a size + reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first + - stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in Fix 8). Namespace stub lines were and remain unbudgeted — every namespace always appears with its tool count, even at budget 0 (asserted in package and adapter tests). - - Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall - to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the - lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements - (2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ≈ 1,100 tokens fixed; - worst-case net description ≈ fixed + 4,000 ≈ 5,100 estimated tokens. +- Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall + to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the + lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements + (2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ≈ 1,100 tokens fixed; + worst-case net description ≈ fixed + 4,000 ≈ 5,100 estimated tokens. **Fix 5 — internal limits removed** (user direction: only the three PUBLIC limits survive as configurable knobs; the internal limit system dies): - - `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at - the time; Fix 6 later removed the first two defaults. Same validation: safe integers, - timeoutMs >= 1, others >= 0, RangeError otherwise) is now - the ENTIRE limit surface — exactly the shape §2's original locked spec named. - `ResolvedExecutionLimits` shrank to those three fields; the `@internal` - `InternalExecutionLimits` type is deleted. - - **Deleted outright**: `maxOperations` and the whole operation-budget machinery - (`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/ - `cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check); - `maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`, - the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes` - fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte - checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and - audit-trail byte accounting — `toolCalls` records and the start/end hooks are unchanged); - `maxCollectionLength` (every array-length/object-field-count check — this knob was - actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded` - and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and - `ExecuteResultSchema` (fine — the package is unreleased). - - **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork - semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check — kept - only because it produces a clearer error than a native stack-overflow RangeError; still - `InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone — - `copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just - `(tools, maxToolCalls, hooks?, searchIndex?)`. - - **Verified fact**: timeout interruption does NOT depend on the operation budget — the - Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts - even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at - ~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression - test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` → - `TimeoutExceeded`, elapsed well under a few seconds). - - **Kept (correctness, not budgets)**: circular detection (`copyIn` walks + - `rejectCircularInsertion` on mutations), plain-objects-only, blocked properties - (`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit - behaviors unchanged. - - Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels - now fail at the data boundary (`copyIn`) instead of at construction; array index - assignment allows any non-negative integer index (holes permitted, message now "must be - a non-negative integer"); interpreter-produced deep/hostile structures that overflow the - native stack during a walk still normalize to the existing "Execution exceeded the - maximum nesting depth." data diagnostic — failures remain data everywhere. - - Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth ×2, - enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/ - maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit - test — superseded by the package timeout regression test); rewrote the helpers that used - `InternalExecutionLimits` as a convenience to plain `ExecutionLimits` - (promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter - suites: 34 + 16. + +- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at + the time; Fix 6 later removed the first two defaults. Same validation: safe integers, + timeoutMs >= 1, others >= 0, RangeError otherwise) is now + the ENTIRE limit surface — exactly the shape §2's original locked spec named. + `ResolvedExecutionLimits` shrank to those three fields; the `@internal` + `InternalExecutionLimits` type is deleted. +- **Deleted outright**: `maxOperations` and the whole operation-budget machinery + (`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/ + `cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check); + `maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`, + the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes` + fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte + checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and + audit-trail byte accounting — `toolCalls` records and the start/end hooks are unchanged); + `maxCollectionLength` (every array-length/object-field-count check — this knob was + actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded` + and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and + `ExecuteResultSchema` (fine — the package is unreleased). +- **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork + semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check — kept + only because it produces a clearer error than a native stack-overflow RangeError; still + `InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone — + `copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just + `(tools, maxToolCalls, hooks?, searchIndex?)`. +- **Verified fact**: timeout interruption does NOT depend on the operation budget — the + Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts + even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at + ~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression + test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` → + `TimeoutExceeded`, elapsed well under a few seconds). +- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks + + `rejectCircularInsertion` on mutations), plain-objects-only, blocked properties + (`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit + behaviors unchanged. +- Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels + now fail at the data boundary (`copyIn`) instead of at construction; array index + assignment allows any non-negative integer index (holes permitted, message now "must be + a non-negative integer"); interpreter-produced deep/hostile structures that overflow the + native stack during a walk still normalize to the existing "Execution exceeded the + maximum nesting depth." data diagnostic — failures remain data everywhere. +- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth ×2, + enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/ + maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit + test — superseded by the package timeout regression test); rewrote the helpers that used + `InternalExecutionLimits` as a convenience to plain `ExecutionLimits` + (promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter + suites: 34 + 16. **Fix 6 — no default timeout / tool-call cap** (user direction): `timeoutMs` and `maxToolCalls` lost their defaults (were 10_000 / 100) — absent now means no timeout / @@ -639,196 +654,200 @@ adapter suites: 34 + 16. **Fix 8 — condensed instructions + round-robin catalog fairness + plural-aware search** (user direction: the fixed instruction prose was too verbose; two discovery fixes ride along). All in `tool-runtime.ts`; no interpreter changes. - - **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens) - are replaced by four short lines (~188) built on "models already know JavaScript; name - only what is unusual or missing": (1) standard modern JS works — functions/closures, - destructuring, template literals, loops, try/catch, spread, optional chaining, the - usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and - Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are - stripped before execution, decorators are not supported; (3) NOT supported (each fails - with a message naming the alternative): classes, generators, for await...of, - .then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors - are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates → - ISO strings; Map/Set/RegExp → `{}`). Every claim was verified against the interpreter - before writing: probed empirically — classes/generators/for-await/.then/.catch/ - .finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged - templates/object getters all fail with clear diagnostics; TS annotations/`as`/ - interfaces/type aliases are stripped and TS **enums actually work** (transpileModule - compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned. - `supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched. - - **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and - return-small content now lives ONLY in the numbered Workflow steps (with their - compliance-driving justifications inline: "most tools return JSON as a string", "raw - payloads get truncated and waste context"); Rules keeps only bullets adding new - content — filter/aggregate collections in code, console.* intermediates (logs ride - back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace - (PARTIAL only), and the media rule compressed to one line. The no-.then/.catch - guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search - step gained query-style guidance (`— short phrases like "list issues" work best`; a - clearly-a-query-string example, not a tool name), and the exact-path guidance is now - "call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it - as-is rather than guessing segments". - - **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0, - bytes/3.7 — same method as Fix 4; chars/4 in parentheses): - preamble 44 → 44 (41 → 41), Workflow 146 → 187 (135 → 171), Rules 362 → 191 - (332 → 176), Syntax 453 → 188 (419 → 174); fixed prose total 1,005 → 610 (927 → 562), - ≈ 40% reduction with no behavioral content dropped. Workflow grew slightly because it - absorbed the deduped parse/return-small justifications. - - **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss - behavior (alphabetically-late namespaces starved to "none shown" while an early - namespace inlines everything) is replaced by round-robin fairness — in each round - (namespaces alphabetical), every namespace still holding un-inlined tools attempts to - place its next-cheapest line against the shared token budget; a namespace whose next - line does not fit is done while the others keep going; stop when all are done. Every - namespace gets some representation before any namespace gets everything. Kept: - `estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace - `(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL - header, alphabetical namespace order in the output, cheapest-first within each - namespace's shown set. - - **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must - be substring of indexed text), so query "issues" missed a tool whose text only says - "issue". Now each term expands to `termForms` — the term plus naive singular variants - (trailing "es" stripped when length > 3, trailing "s" when length > 2) — and each of - the four field checks passes when ANY form matches. Weights, exact-path lookup, and - namespace scoping untouched. A true plural path match still outranks a singular-only - description match (path substring 8 + searchable 2 > description 4 + searchable 2). - - **Tests**: package instruction/structure assertions updated to the new text; new - syntax-section test (leads with "Standard modern JavaScript works", names the - verified not-supported list, keeps the data-boundary note); the budget-exhaustion - test rewritten to assert the new fairness (alpha.expensive not fitting must NOT - prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new - plural/singular test (query "issues" finds a singular-only tool; ranking still - prefers the true "issues" path match). Adapter: description assertions updated; the - large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` + - its inlined line) — it was "none shown" under starvation. README updated (budgeted - catalog paragraph → round-robin; search paragraph → singular variants; - instructions-structure paragraph → new section contents). Package suite: 169 pass / - 0 fail; adapter suites: 34 + 16. + +- **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens) + are replaced by four short lines (~188) built on "models already know JavaScript; name + only what is unusual or missing": (1) standard modern JS works — functions/closures, + destructuring, template literals, loops, try/catch, spread, optional chaining, the + usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and + Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are + stripped before execution, decorators are not supported; (3) NOT supported (each fails + with a message naming the alternative): classes, generators, for await...of, + .then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors + are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates → + ISO strings; Map/Set/RegExp → `{}`). Every claim was verified against the interpreter + before writing: probed empirically — classes/generators/for-await/.then/.catch/ + .finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged + templates/object getters all fail with clear diagnostics; TS annotations/`as`/ + interfaces/type aliases are stripped and TS **enums actually work** (transpileModule + compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned. + `supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched. +- **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and + return-small content now lives ONLY in the numbered Workflow steps (with their + compliance-driving justifications inline: "most tools return JSON as a string", "raw + payloads get truncated and waste context"); Rules keeps only bullets adding new + content — filter/aggregate collections in code, console.\* intermediates (logs ride + back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace + (PARTIAL only), and the media rule compressed to one line. The no-.then/.catch + guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search + step gained query-style guidance (`— short phrases like "list issues" work best`; a + clearly-a-query-string example, not a tool name), and the exact-path guidance is now + "call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it + as-is rather than guessing segments". +- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0, + bytes/3.7 — same method as Fix 4; chars/4 in parentheses): + preamble 44 → 44 (41 → 41), Workflow 146 → 187 (135 → 171), Rules 362 → 191 + (332 → 176), Syntax 453 → 188 (419 → 174); fixed prose total 1,005 → 610 (927 → 562), + ≈ 40% reduction with no behavioral content dropped. Workflow grew slightly because it + absorbed the deduped parse/return-small justifications. +- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss + behavior (alphabetically-late namespaces starved to "none shown" while an early + namespace inlines everything) is replaced by round-robin fairness — in each round + (namespaces alphabetical), every namespace still holding un-inlined tools attempts to + place its next-cheapest line against the shared token budget; a namespace whose next + line does not fit is done while the others keep going; stop when all are done. Every + namespace gets some representation before any namespace gets everything. Kept: + `estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace + `(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL + header, alphabetical namespace order in the output, cheapest-first within each + namespace's shown set. +- **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must + be substring of indexed text), so query "issues" missed a tool whose text only says + "issue". Now each term expands to `termForms` — the term plus naive singular variants + (trailing "es" stripped when length > 3, trailing "s" when length > 2) — and each of + the four field checks passes when ANY form matches. Weights, exact-path lookup, and + namespace scoping untouched. A true plural path match still outranks a singular-only + description match (path substring 8 + searchable 2 > description 4 + searchable 2). +- **Tests**: package instruction/structure assertions updated to the new text; new + syntax-section test (leads with "Standard modern JavaScript works", names the + verified not-supported list, keeps the data-boundary note); the budget-exhaustion + test rewritten to assert the new fairness (alpha.expensive not fitting must NOT + prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new + plural/singular test (query "issues" finds a singular-only tool; ranking still + prefers the true "issues" path match). Adapter: description assertions updated; the + large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` + + its inlined line) — it was "none shown" under starvation. README updated (budgeted + catalog paragraph → round-robin; search paragraph → singular variants; + instructions-structure paragraph → new section contents). Package suite: 169 pass / + 0 fail; adapter suites: 34 + 16. **Fix 9 — prompting trims per user review of Fix 8** (user reviewed the condensed instructions and directed further cuts): - - Default `maxInlineCatalogTokens` 4,000 → **2,000** (user wants ~2k tokens of signatures - auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces). - - Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single - `unknown`-treatment warning: "A result typed `Promise` has no guaranteed - shape — verify what actually came back before relying on its fields." (Deliberately - does NOT suggest console.log — user review: naming it there nudges models to log AND - return the same data; the prompt stays console-neutral, neither for nor against.) - The media-stripping MECHANISM is unchanged and still tested; only the prose about it - is gone — the `[N images attached]` marker is self-explanatory in context. - - Kept as-is per user: the JSON.parse workflow step (maps to the original motivating - transcript failure; NOT copied from prior art — see §5 note), the browse-namespace rule - (undecided), no no-fetch/ambient-authority rule added (proposed, not approved). - - Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter - boundary ("could get weird" — type flips, program-sees vs tool-sent divergence). Logged - as a next-iteration follow-up below. + +- Default `maxInlineCatalogTokens` 4,000 → **2,000** (user wants ~2k tokens of signatures + auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces). +- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single + `unknown`-treatment warning: "A result typed `Promise` has no guaranteed + shape — verify what actually came back before relying on its fields." (Deliberately + does NOT suggest console.log — user review: naming it there nudges models to log AND + return the same data; the prompt stays console-neutral, neither for nor against.) + The media-stripping MECHANISM is unchanged and still tested; only the prose about it + is gone — the `[N images attached]` marker is self-explanatory in context. +- Kept as-is per user: the JSON.parse workflow step (maps to the original motivating + transcript failure; NOT copied from prior art — see §5 note), the browse-namespace rule + (undecided), no no-fetch/ambient-authority rule added (proposed, not approved). +- Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter + boundary ("could get weird" — type flips, program-sees vs tool-sent divergence). Logged + as a next-iteration follow-up below. **DSL-expansion pass — interpreter-surface batch from §4** (the deferred medium-tier JS parity items, done as one focused pass; no public API or limit changes): - - **`instanceof` + real Error values**: the `errorConstructors` names (`Error`, - `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are - bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` → - `"function"`). Error values stay the same plain `{ name, message }` null-prototype - objects as before — the constructor name additionally rides on a NON-ENUMERABLE symbol - key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread, - JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the - brand is lost on spread/boundary copies exactly like JS loses the prototype. - `caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so - caught interpreter AND tool failures are `instanceof Error` and carry the `name` the - equivalent real-JS failure would have (follow-up fix, user-directed — "closest to real - JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set - fluently at throw sites via `.as(name)` — `JSON.parse` failures are `"SyntaxError"` (and - now include the engine's position detail in the message; safe — derived from the - program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown - identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`, - a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly - keeps its own name when it is one of the standard seven. Tool failures and everything - without a specific analogue stay `"Error"` — internal class names never leak. Specific - names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS. - The operator is handled in `evaluateBinaryExpression` - BEFORE the data-only operand check (like `typeof`, it observes any lhs — promises and - functions included); recognized rhs: the error constructors (a specific type matches its - own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes), - `Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and - `Number`/`String`/`Boolean` (always false — no boxed values exist); anything else is a - catchable error naming the recognized constructors. - - **Array methods**: `splice` (mutating, returns the removed elements; insertions run - `rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined - delete count removes nothing), `fill` (circular-checked value) and `copyWithin` - (host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set - convention — for...of and spread work either way). The `retryableArrayMethods` - "rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown - array properties still read `undefined`. - - **String methods**: `localeCompare(that)` (locale/options arguments ignored — host - default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form - → catchable error naming the four valid forms), `trimLeft`/`trimRight` as - trimStart/trimEnd aliases. - - **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the - offending pattern (or flags) plus the engine reason (deduped "Invalid regular - expression:" prefix via `regexFailureReason`) and a shared escaping hint - (`escapeRegexHint`); flags failures list the valid flag letters; the - replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and - the single-match alternative. - - **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues = - false)` — recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox - checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template - interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`, - which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by - reference as leaves** (contents not walked — Map/Set members are validated at their - mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity, - plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep - the await-hinting rejection in BOTH modes (deliberate — JS-parity pass-through was - considered and skipped to preserve the nudge). The HOST boundary (final result, - tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and - still serializes JSON forms (Date → ISO, RegExp/Map/Set → `{}`); host instances met on - the preserving path are defensively wrapped into sandbox equivalents. Ripple: the - `Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` → `[]`, - assign sources contribute nothing, hasOwn → false — JS has no own enumerable props - there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the - template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread - already preserved instances (reference copies, no checkpoint) — now tested. - - **Console formatting**: `formatConsoleArgument` is total and deep - (`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity` - literally — never the JSON `null`; finite numbers match their JSON form), nested - strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO - date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become - in-place `[CodeMode reference]` markers instead of collapsing the whole argument, - cycles render `[Circular]` (reachable via Map/Set members, which mutation never - checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob) - degrades to `…` — console can no longer fail a program. `console.table` guards with - `containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell - walkers treat sandbox values as scalar cells. - - **Prose**: the instructions Syntax not-supported line dropped its `instanceof - Error`/splice mentions (nothing else reworded); README updated (checkpoint - preservation vs boundary serialization, error values/`instanceof`, new array/string - methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists - supported syntax, was already non-exhaustive, and stays accurate). - - **Tests**: package suite 169 → 209 (parity: Error/instanceof + real-JS error-name - coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias - describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib - `instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console - rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged - (34 + 16, green); both packages `tsgo --noEmit` clean. + +- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`, + `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are + bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` → + `"function"`). Error values stay the same plain `{ name, message }` null-prototype + objects as before — the constructor name additionally rides on a NON-ENUMERABLE symbol + key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread, + JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the + brand is lost on spread/boundary copies exactly like JS loses the prototype. + `caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so + caught interpreter AND tool failures are `instanceof Error` and carry the `name` the + equivalent real-JS failure would have (follow-up fix, user-directed — "closest to real + JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set + fluently at throw sites via `.as(name)` — `JSON.parse` failures are `"SyntaxError"` (and + now include the engine's position detail in the message; safe — derived from the + program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown + identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`, + a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly + keeps its own name when it is one of the standard seven. Tool failures and everything + without a specific analogue stay `"Error"` — internal class names never leak. Specific + names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS. + The operator is handled in `evaluateBinaryExpression` + BEFORE the data-only operand check (like `typeof`, it observes any lhs — promises and + functions included); recognized rhs: the error constructors (a specific type matches its + own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes), + `Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and + `Number`/`String`/`Boolean` (always false — no boxed values exist); anything else is a + catchable error naming the recognized constructors. +- **Array methods**: `splice` (mutating, returns the removed elements; insertions run + `rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined + delete count removes nothing), `fill` (circular-checked value) and `copyWithin` + (host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set + convention — for...of and spread work either way). The `retryableArrayMethods` + "rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown + array properties still read `undefined`. +- **String methods**: `localeCompare(that)` (locale/options arguments ignored — host + default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form + → catchable error naming the four valid forms), `trimLeft`/`trimRight` as + trimStart/trimEnd aliases. +- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the + offending pattern (or flags) plus the engine reason (deduped "Invalid regular + expression:" prefix via `regexFailureReason`) and a shared escaping hint + (`escapeRegexHint`); flags failures list the valid flag letters; the + replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and + the single-match alternative. +- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues = +false)` — recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox + checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template + interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`, + which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by + reference as leaves** (contents not walked — Map/Set members are validated at their + mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity, + plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep + the await-hinting rejection in BOTH modes (deliberate — JS-parity pass-through was + considered and skipped to preserve the nudge). The HOST boundary (final result, + tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and + still serializes JSON forms (Date → ISO, RegExp/Map/Set → `{}`); host instances met on + the preserving path are defensively wrapped into sandbox equivalents. Ripple: the + `Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` → `[]`, + assign sources contribute nothing, hasOwn → false — JS has no own enumerable props + there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the + template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread + already preserved instances (reference copies, no checkpoint) — now tested. +- **Console formatting**: `formatConsoleArgument` is total and deep + (`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity` + literally — never the JSON `null`; finite numbers match their JSON form), nested + strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO + date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become + in-place `[CodeMode reference]` markers instead of collapsing the whole argument, + cycles render `[Circular]` (reachable via Map/Set members, which mutation never + checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob) + degrades to `…` — console can no longer fail a program. `console.table` guards with + `containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell + walkers treat sandbox values as scalar cells. +- **Prose**: the instructions Syntax not-supported line dropped its `instanceof +Error`/splice mentions (nothing else reworded); README updated (checkpoint + preservation vs boundary serialization, error values/`instanceof`, new array/string + methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists + supported syntax, was already non-exhaustive, and stays accurate). +- **Tests**: package suite 169 → 209 (parity: Error/instanceof + real-JS error-name + coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias + describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib + `instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console + rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged + (34 + 16, green); both packages `tsgo --noEmit` clean. **Truncation layering — CodeMode truncation off in OpenCode** (user direction; resolves the §4 outer-truncation item the OPPOSITE way from "kill the outer one"): - - `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two - limits: absent = no truncation. All three limits are uniformly no-default — budgets are - host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`; - `boundOutput` only runs when the host set the limit. Explicit values validate as before - (safe integer ≥ 0). - - OpenCode continues to pass NO limits, which now also means no CodeMode truncation. - `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation - applies with no special-casing — verified by tracing `wrap()` (`tool.ts:130-144`, - 50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under - `tool-output/`): the `metadata.truncated` self-truncation exemption never fires for - `execute` (its metadata never sets that key). One truncation layer, the host's — and it - is the richer one (file dump + explore/grep hint vs an inline marker). - - Hosts without their own output bounding set `maxOutputBytes` explicitly; README table - and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit → 100KB - value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test - that relied on the old default now asserts the oversized result reaches the shared - wrapper un-truncated. Suites: 210 + 50, tsgo clean both. + +- `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two + limits: absent = no truncation. All three limits are uniformly no-default — budgets are + host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`; + `boundOutput` only runs when the host set the limit. Explicit values validate as before + (safe integer ≥ 0). +- OpenCode continues to pass NO limits, which now also means no CodeMode truncation. + `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation + applies with no special-casing — verified by tracing `wrap()` (`tool.ts:130-144`, + 50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under + `tool-output/`): the `metadata.truncated` self-truncation exemption never fires for + `execute` (its metadata never sets that key). One truncation layer, the host's — and it + is the richer one (file dump + explore/grep hint vs an inline marker). +- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table + and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit → 100KB + value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test + that relied on the old default now asserts the oversized result reaches the shared + wrapper un-truncated. Suites: 210 + 50, tsgo clean both. **Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default 4,000 and alphabetical cheapest-first — now 2,000 and round-robin, matching Fix 8/9 reality) @@ -837,132 +856,137 @@ regular dependency; hosts depend on it themselves because the API surface is Eff **Registry promotion + permission-aware catalog** (the "promote to a proper tool service" restructure; fixes the §4 permission-advertising bug): - - **The adapter moved** `src/session/code-mode.ts` → `src/tool/code-mode.ts` and is now a - registry-resident tool service on the TaskTool precedent: `CodeModeTool = - Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`, - and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by - `flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the - registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The - session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` + - append) is deleted; the early return that suppresses raw per-MCP registration when the - flag is on stays session-side, keyed on the same flag+tool-count condition. - - **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP - tool count is consulted once (an Effect) before the synchronous filter, and code mode - passes the predicate iff `flags.experimentalCodeMode` && count > 0. - - **Description split on the `describeTask` precedent**: the tool's static base - description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()` - appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog, - `catalogInstructions` in the adapter) at the same composition point as task — so - `plugin.trigger("tool.definition")` sees the base description first. - - **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from - `llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)` - (a record filter over `Permission.disabled` — only a hard `deny` with pattern `"*"` - hides a tool; ask-level rules stay fully visible and prompt at call time) and - `resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters - with the merged agent+session ruleset that `SessionTools.resolve` passes into the - registry before building the catalog/search index; `execute` rebuilds the runtime per - execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset - (`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge - `SessionTools.context` wires into `ctx.ask`) — a denied tool is not dispatchable - even if the model guesses its name and yields the normal unknown-tool diagnostic. - Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives - at request-prep after descriptions are built and has no child-call equivalent. - - **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult` - unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution - limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired - through `Tool.Context` exactly like every registry tool). - - **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation, - permission ruleset) was considered and deliberately skipped — the per-turn rebuild is - cheap (grouping + string rendering); revisit only if profiling shows it matters. - - **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration} - .test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct - `define(...)` construction; description assertions target `catalogInstructions`, the - registry's composition input) and gained permission coverage: deny excluded from - catalog/search, ask-level stays visible and callable, denied tool undispatchable - (unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/ - registry.test.ts` gained four registry-level tests: registered with flag+MCP tools, - excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering - through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green. + +- **The adapter moved** `src/session/code-mode.ts` → `src/tool/code-mode.ts` and is now a + registry-resident tool service on the TaskTool precedent: `CodeModeTool = +Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`, + and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by + `flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the + registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The + session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` + + append) is deleted; the early return that suppresses raw per-MCP registration when the + flag is on stays session-side, keyed on the same flag+tool-count condition. +- **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP + tool count is consulted once (an Effect) before the synchronous filter, and code mode + passes the predicate iff `flags.experimentalCodeMode` && count > 0. +- **Description split on the `describeTask` precedent**: the tool's static base + description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()` + appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog, + `catalogInstructions` in the adapter) at the same composition point as task — so + `plugin.trigger("tool.definition")` sees the base description first. +- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from + `llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)` + (a record filter over `Permission.disabled` — only a hard `deny` with pattern `"*"` + hides a tool; ask-level rules stay fully visible and prompt at call time) and + `resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters + with the merged agent+session ruleset that `SessionTools.resolve` passes into the + registry before building the catalog/search index; `execute` rebuilds the runtime per + execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset + (`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge + `SessionTools.context` wires into `ctx.ask`) — a denied tool is not dispatchable + even if the model guesses its name and yields the normal unknown-tool diagnostic. + Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives + at request-prep after descriptions are built and has no child-call equivalent. +- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult` + unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution + limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired + through `Tool.Context` exactly like every registry tool). +- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation, + permission ruleset) was considered and deliberately skipped — the per-turn rebuild is + cheap (grouping + string rendering); revisit only if profiling shows it matters. +- **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration} +.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct + `define(...)` construction; description assertions target `catalogInstructions`, the + registry's composition input) and gained permission coverage: deny excluded from + catalog/search, ask-level stays visible and callable, denied tool undispatchable + (unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/ +registry.test.ts` gained four registry-level tests: registered with flag+MCP tools, + excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering + through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green. **Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the §4 "plugin hooks skip child calls" gap): - - `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool" - middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook → - permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's - `ctx.ask`) → dispatch through the ai-sdk tool's execute inside the `Tool.execute` - tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) → - plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute - resolved with; each caller keeps its own shaping edge — the legacy per-MCP loop in - `SessionTools.resolve` applies its existing model-facing shaping/truncation, code - mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers - already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool, - not about sessions or code mode. - - **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result — - which is exactly what the legacy loop always passed (the raw `CallToolResult`, not - the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit - and the hook payload cannot drift between callers. No callback/edge-firing design - was needed. - - **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the - hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to - the entry key; `n` = per-execution counter starting at 1, shared across all child - calls in one program). callID is an opaque string — nothing parses it. The ai-sdk - `toolCallId` (`options.toolCallId`) stays each caller's existing value - (`ctx.callID ?? entry.key` for code mode). - - **Child-scoped hook failures**: `CodeModeTool` (which now also yields - `Plugin.Service`) wraps the whole child call — hooks, ask, dispatch — in - `toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin - hook failure fails ONLY that child call as a catchable in-program `toolError`; other - calls in the same program keep running and interruption still propagates as - interruption. Legacy semantics unchanged: a hook failure fails the tool call. - - **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the - MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a - failing before hook is caught in-program, gates dispatch, and leaves the outer - execute ok) — both code-mode harnesses gained a `Plugin.Service` mock (pass-through - trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins - `SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer): - flag on + MCP tools → `execute` present, raw MCP keys suppressed; flag off → raw - keys present, `execute` absent; and the legacy raw-MCP execute fires before/after - hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter - 45 + 16, session/tool/permission all green; this package untouched (211 pass). + +- `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool" + middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook → + permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's + `ctx.ask`) → dispatch through the ai-sdk tool's execute inside the `Tool.execute` + tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) → + plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute + resolved with; each caller keeps its own shaping edge — the legacy per-MCP loop in + `SessionTools.resolve` applies its existing model-facing shaping/truncation, code + mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers + already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool, + not about sessions or code mode. +- **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result — + which is exactly what the legacy loop always passed (the raw `CallToolResult`, not + the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit + and the hook payload cannot drift between callers. No callback/edge-firing design + was needed. +- **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the + hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to + the entry key; `n` = per-execution counter starting at 1, shared across all child + calls in one program). callID is an opaque string — nothing parses it. The ai-sdk + `toolCallId` (`options.toolCallId`) stays each caller's existing value + (`ctx.callID ?? entry.key` for code mode). +- **Child-scoped hook failures**: `CodeModeTool` (which now also yields + `Plugin.Service`) wraps the whole child call — hooks, ask, dispatch — in + `toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin + hook failure fails ONLY that child call as a catchable in-program `toolError`; other + calls in the same program keep running and interruption still propagates as + interruption. Legacy semantics unchanged: a hook failure fails the tool call. +- **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the + MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a + failing before hook is caught in-program, gates dispatch, and leaves the outer + execute ok) — both code-mode harnesses gained a `Plugin.Service` mock (pass-through + trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins + `SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer): + flag on + MCP tools → `execute` present, raw MCP keys suppressed; flag off → raw + keys present, `execute` absent; and the legacy raw-MCP execute fires before/after + hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter + 45 + 16, session/tool/permission all green; this package untouched (211 pass). **Signature rendering + compound-assignment parity fixes** (externally reported, both verified real with failing tests before fixing): - - **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema` - emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123` - rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper — - bare identifiers stay bare, everything else is `JSON.stringify`-quoted — applied in the - single `field` closure both the compact and pretty renderings share. The - `identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s - bracket-notation `toolExpression` imports it: one source of truth for "is this a bare - identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact, - pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct). - - **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old - `anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just - `number`, dropping real JSON Schema alternatives (`string | number`, `number | null`, - etc.). The collapse is now restricted to Effect's number-schema artifact - (`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums), - while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3. - - **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`): - `applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y` - diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`; - `d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved - verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`; - compound assignment validates against a `compoundOperators` set (`+=` … `>>>=`) and - dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`) - keep their separate short-circuit path (`evaluateLogicalAssignment`), and both - assignment call sites still wrap results in `boundedData`. Deliberate side effect: - compound assignment now rejects opaque references, consistent with binary operators. - Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity, - string `+=` object/array, member-target compound, 13-case operator sweep vs real JS). - Package suite: 220 pass. + +- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema` + emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123` + rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper — + bare identifiers stay bare, everything else is `JSON.stringify`-quoted — applied in the + single `field` closure both the compact and pretty renderings share. The + `identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s + bracket-notation `toolExpression` imports it: one source of truth for "is this a bare + identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact, + pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct). +- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old + `anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just + `number`, dropping real JSON Schema alternatives (`string | number`, `number | null`, + etc.). The collapse is now restricted to Effect's number-schema artifact + (`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums), + while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3. +- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`): + `applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y` + diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`; + `d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved + verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`; + compound assignment validates against a `compoundOperators` set (`+=` … `>>>=`) and + dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`) + keep their separate short-circuit path (`evaluateLogicalAssignment`), and both + assignment call sites still wrap results in `boundedData`. Deliberate side effect: + compound assignment now rejects opaque references, consistent with binary operators. + Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity, + string `+=` object/array, member-target compound, 13-case operator sweep vs real JS). + Package suite: 220 pass. --- ## 4. Remaining work (detailed TODO) ### Next DSL-expansion pass (done — see the DSL-expansion pass entry in §3) + Batch these together — per user direction: important, but deliberately deferred to one focused interpreter-surface pass rather than picked off piecemeal. + - [x] Medium-tier JS parity items deferred from the original audit: caught errors are plain `{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value — `x instanceof Error` is unsupported syntax); `splice` (still a @@ -981,6 +1005,7 @@ focused interpreter-surface pass rather than picked off piecemeal. (`console.log({ m: map })`) — could deep-format instead. ### Next iteration: text-result handling (deliberate follow-up, user-directed) + - [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the server sends it, else joined text as a plain string (the program JSON.parses it, guided by a workflow step). Considered and deferred: (a) conservative boundary @@ -992,6 +1017,7 @@ focused interpreter-surface pass rather than picked off piecemeal. revisit once real usage shows which failure modes matter. ### Next iteration: stdlib surface (prioritized) + Current instructions say "usual Array/String/Object/Math/JSON methods," but the interpreter is intentionally a subset. Keep CodeMode focused on orchestration and data shaping, not a full host runtime, but close the high-friction gaps models are likely to reach for. @@ -1028,7 +1054,9 @@ Explicit non-goals for now: `structuredClone`, `WeakMap`/`WeakSet`, and timers orchestration use case. ### Wiring-review findings (subagent code review of the OpenCode integration, triaged) + Pre-PR fixes (user-approved cut): + - [x] **Cancellation does not interrupt the interpreter** — the no-limits rationale claimed "user cancel interrupts the execution fiber," but `tools.ts` runs tools via `run.promise` → `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring; @@ -1073,6 +1101,7 @@ Pre-PR fixes (user-approved cut): `title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`. Post-MVP (logged, not blocking an experimental flag): + - [x] **Plugin `tool.execute.before/after` hooks skip child calls** — legacy MCP registration fires them per tool (`tools.ts:419-441`); under code mode only the outer `execute` fires them, so auditing/intercepting plugins silently lose MCP @@ -1105,6 +1134,7 @@ Post-MVP (logged, not blocking an experimental flag): names that are no longer directly callable under code mode. ### Backlog / loose ends (non-blocking, any order) + - [ ] `evaluateUpdateExpression` (`++`/`--`) still uses raw `Number(current)`, so `d++` on a sandbox Date yields `NaN` where `d += 1` now uses epoch semantics (and real JS `d++` would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment @@ -1141,7 +1171,7 @@ Post-MVP (logged, not blocking an experimental flag): ## 5. Context and gotchas for whoever picks this up - **Motivating failure (why forgiving semantics + prompting matter):** in a real transcript, - the model wrote `me.result?.login ?? me.result` where the tool result was a JSON *string* — + the model wrote `me.result?.login ?? me.result` where the tool result was a JSON _string_ — the old strict interpreter threw (`String property 'login' is not available`); then the model returned a raw 105KB payload, which native truncation dumped to a file, costing a subagent round-trip to extract one number. Interpreter forgiveness stops the crashes; diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 5f879a294f..b8e5ac06be 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -118,9 +118,16 @@ export type CodeModeOptions = {}> = Omit(["all", "allSettled", "race", "resolve", "reject"]) -const errorConstructors = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "EvalError", "URIError"]) +const errorConstructors = new Set([ + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "ReferenceError", + "EvalError", + "URIError", +]) const valueConstructors = new Set(["Date", "RegExp", "Map", "Set"]) const dateMethods = new Set([ - "getTime", "valueOf", "toISOString", "toJSON", "toString", - "getFullYear", "getMonth", "getDate", "getDay", "getHours", "getMinutes", "getSeconds", "getMilliseconds", - "getUTCFullYear", "getUTCMonth", "getUTCDate", "getUTCDay", "getUTCHours", "getUTCMinutes", "getUTCSeconds", "getUTCMilliseconds", + "getTime", + "valueOf", + "toISOString", + "toJSON", + "toString", + "getFullYear", + "getMonth", + "getDate", + "getDay", + "getHours", + "getMinutes", + "getSeconds", + "getMilliseconds", + "getUTCFullYear", + "getUTCMonth", + "getUTCDate", + "getUTCDay", + "getUTCHours", + "getUTCMinutes", + "getUTCSeconds", + "getUTCMilliseconds", "getTimezoneOffset", ]) const dateStatics = new Set(["now", "parse", "UTC"]) const regexpMethods = new Set(["test", "exec", "toString"]) // Read-only host regex fields surfaced as plain values. -const regexpProperties = new Set(["source", "flags", "lastIndex", "global", "ignoreCase", "multiline", "sticky", "unicode", "dotAll"]) +const regexpProperties = new Set([ + "source", + "flags", + "lastIndex", + "global", + "ignoreCase", + "multiline", + "sticky", + "unicode", + "dotAll", +]) const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) @@ -350,7 +455,12 @@ const supportedSyntaxMessage = "Supported orchestration syntax: tools.* calls (they return promises — resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), 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 (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported — use await with try/catch)." const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError => - new InterpreterRuntimeError(`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`, node, "UnsupportedSyntax", [supportedSyntaxMessage]) + new InterpreterRuntimeError( + `Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`, + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) /** How many eagerly forked tool calls may run at once. Fixed; not a configurable knob. */ const TOOL_CALL_CONCURRENCY = 8 @@ -358,7 +468,11 @@ const TOOL_CALL_CONCURRENCY = 8 /** Console formatting recursion ceiling; deeper values render as "…". Fixed; not a knob. */ const MAX_CONSOLE_DEPTH = 32 -const validateLimit = (name: keyof ExecutionLimits, value: Value, minimum: number): Value => { +const validateLimit = ( + name: keyof ExecutionLimits, + value: Value, + minimum: number, +): Value => { if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) { throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`) } @@ -385,7 +499,12 @@ class InterpreterRuntimeError extends Error { */ errorName: string = "Error" - constructor(message: string, node?: AstNode, readonly kind: DiagnosticKind = "ExecutionFailure", readonly suggestions?: ReadonlyArray) { + constructor( + message: string, + node?: AstNode, + readonly kind: DiagnosticKind = "ExecutionFailure", + readonly suggestions?: ReadonlyArray, + ) { super(message) this.name = "InterpreterRuntimeError" @@ -400,8 +519,7 @@ class InterpreterRuntimeError extends Error { } } -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null const asNode = (value: unknown, context: string): AstNode => { if (!isRecord(value) || typeof value.type !== "string") { @@ -535,7 +653,11 @@ const normalizeError = (error: unknown): Diagnostic => { 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") { + } else if ( + value !== null && + typeof value === "object" && + typeof (value as { message?: unknown }).message === "string" + ) { message = (value as { message: string }).message } else { try { @@ -605,10 +727,17 @@ const caughtErrorValue = (thrown: unknown): unknown => { const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true) const isRuntimeReference = (value: unknown): boolean => - value instanceof CodeModeFunction || value instanceof ToolReference || value instanceof IntrinsicReference || - value instanceof GlobalNamespace || value instanceof GlobalMethodReference || value instanceof PromiseNamespace || - value instanceof PromiseMethodReference || value instanceof SandboxPromise || value instanceof CoercionFunction || - value instanceof ErrorConstructorReference || isSandboxValue(value) + value instanceof CodeModeFunction || + value instanceof ToolReference || + value instanceof IntrinsicReference || + value instanceof GlobalNamespace || + value instanceof GlobalMethodReference || + value instanceof PromiseNamespace || + value instanceof PromiseMethodReference || + value instanceof SandboxPromise || + value instanceof CoercionFunction || + value instanceof ErrorConstructorReference || + isSandboxValue(value) const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { if (isRuntimeReference(value)) return true @@ -643,10 +772,15 @@ const containsOpaqueReference = (value: unknown, seen = new Set()): bool // like a real JS promise. const typeofValue = (value: unknown): string => { if ( - value instanceof CodeModeFunction || value instanceof CoercionFunction || value instanceof IntrinsicReference || - value instanceof GlobalMethodReference || value instanceof PromiseMethodReference || value instanceof PromiseNamespace || + value instanceof CodeModeFunction || + value instanceof CoercionFunction || + value instanceof IntrinsicReference || + value instanceof GlobalMethodReference || + value instanceof PromiseMethodReference || + value instanceof PromiseNamespace || value instanceof ErrorConstructorReference - ) return "function" + ) + return "function" if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" if (value instanceof GlobalNamespace) { return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" @@ -665,12 +799,18 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => } if (rhs instanceof GlobalNamespace) { switch (rhs.name) { - case "Date": return lhs instanceof SandboxDate - case "RegExp": return lhs instanceof SandboxRegExp - case "Map": return lhs instanceof SandboxMap - case "Set": return lhs instanceof SandboxSet - case "Array": return Array.isArray(lhs) - case "Object": return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function") + case "Date": + return lhs instanceof SandboxDate + case "RegExp": + return lhs instanceof SandboxRegExp + case "Map": + return lhs instanceof SandboxMap + case "Set": + return lhs instanceof SandboxSet + case "Array": + return Array.isArray(lhs) + case "Object": + return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function") } } if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise @@ -736,12 +876,14 @@ const matchToValue = (match: RegExpMatchArray): Array => { const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode): 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) + 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) + 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)) @@ -749,15 +891,29 @@ const invokeStringMethod = (value: string, name: string, args: Array, n let result: unknown switch (name) { - case "toLowerCase": result = value.toLowerCase(); break - case "toUpperCase": result = value.toUpperCase(); break - case "trim": result = value.trim(); break + case "toLowerCase": + result = value.toLowerCase() + break + case "toUpperCase": + result = value.toUpperCase() + break + case "trim": + result = value.trim() + break // trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them. - case "trimStart": case "trimLeft": result = value.trimStart(); break - case "trimEnd": case "trimRight": result = value.trimEnd(); break + case "trimStart": + case "trimLeft": + result = value.trimStart() + break + case "trimEnd": + case "trimRight": + result = value.trimEnd() + break // Locale/options arguments are ignored: comparison runs with the host default locale, and // the common use is a sort comparator where any consistent order works. - case "localeCompare": result = value.localeCompare(str(0)); break + case "localeCompare": + result = value.localeCompare(str(0)) + break case "normalize": { const form = optStr(0) try { @@ -783,16 +939,33 @@ const invokeStringMethod = (value: string, name: string, args: Array, n result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0) 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 "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": case "replaceAll": { if (args[0] instanceof CodeModeFunction || args[1] instanceof CodeModeFunction) { - throw new InterpreterRuntimeError(`String.${name} does not support function replacers in CodeMode; use match/matchAll and rebuild the string instead.`, node, "UnsupportedSyntax", [supportedSyntaxMessage]) + throw new InterpreterRuntimeError( + `String.${name} does not support function replacers in CodeMode; use match/matchAll and rebuild the string instead.`, + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) } if (args[0] instanceof SandboxRegExp) { const pattern = (args[0] as SandboxRegExp).regex @@ -840,26 +1013,46 @@ const invokeStringMethod = (value: string, name: string, args: Array, n } case "repeat": { const count = num(0) - if (!Number.isFinite(count) || count < 0) throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) + if (!Number.isFinite(count) || count < 0) + throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) result = value.repeat(count) break } - case "padStart": result = value.padStart(num(0), optStr(1)); break - case "padEnd": result = value.padEnd(num(0), 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 + case "padStart": + result = value.padStart(num(0), optStr(1)) + break + case "padEnd": + result = value.padEnd(num(0), 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; NaN flows as an ordinary in-sandbox value // (normalized to null only at the data boundary — see copyOut), so return it as-is. - case "charCodeAt": result = value.charCodeAt(optNum(0) ?? 0); break - case "codePointAt": result = value.codePointAt(optNum(0) ?? 0); break - case "toString": result = value; break + case "charCodeAt": + result = value.charCodeAt(optNum(0) ?? 0) + break + case "codePointAt": + result = value.codePointAt(optNum(0) ?? 0) + break + case "toString": + result = value + break case "concat": { result = value.concat(...args.map((_, index) => str(index))) break } - default: throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) + default: + throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) } return boundedData(result, `String.${name} result`) } @@ -873,8 +1066,12 @@ const invokeNumberMethod = (value: number, name: string, args: Array, n } let result: unknown switch (name) { - case "toFixed": result = value.toFixed(optNum(0)); break - case "toExponential": result = value.toExponential(optNum(0)); break + 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) @@ -888,7 +1085,8 @@ const invokeNumberMethod = (value: number, name: string, args: Array, n result = value.toString(radix) break } - default: throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node) + default: + throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node) } return boundedData(result, `Number.${name} result`) } @@ -899,7 +1097,8 @@ const coerceToString = (value: unknown): string => { if (value === undefined) return "undefined" // Sandbox values stringify deterministically: Date as ISO (not the host's locale/timezone // toString), RegExp as its literal form, Map/Set with their JS Object.prototype tags. - if (value instanceof SandboxDate) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date" + if (value instanceof SandboxDate) + return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date" if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}` if (value instanceof SandboxMap) return "[object Map]" if (value instanceof SandboxSet) return "[object Set]" @@ -936,7 +1135,8 @@ const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNo 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) + 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)) @@ -971,9 +1171,12 @@ const invokeObjectMethod = (name: string, args: Array, node: AstNode): } return Object.keys(value) } - 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 "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 = Object.create(null) for (const source of args) { @@ -981,7 +1184,8 @@ const invokeObjectMethod = (name: string, args: Array, node: AstNode): const value = boundedData(source, "Object.assign input") // A sandbox value source contributes nothing (no own enumerable properties in JS). if (isSandboxValue(value)) continue - if (value === null || typeof value !== "object" || Array.isArray(value)) throw new InterpreterRuntimeError("Object.assign expects data objects.", node) + 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 @@ -995,15 +1199,18 @@ const invokeObjectMethod = (name: string, args: Array, node: AstNode): return out } const pairs = boundedData(args[0], "Object.fromEntries input") - if (!Array.isArray(pairs)) throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) + if (!Array.isArray(pairs)) + throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) const out: Record = Object.create(null) for (const pair of pairs) { - if (!Array.isArray(pair)) throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) + 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 CodeMode.`, node) + default: + throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) } } @@ -1014,23 +1221,40 @@ const invokeMathMethod = (name: string, args: Array, node: AstNode): nu }) 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 CodeMode.`, node) + 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 CodeMode.`, node) } } @@ -1039,7 +1263,12 @@ const invokeJsonMethod = (name: string, args: Array, node: AstNode): un case "stringify": { const replacer = args[1] if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) { - throw new InterpreterRuntimeError("JSON.stringify replacers are not supported in CodeMode.", node, "UnsupportedSyntax", [supportedSyntaxMessage]) + throw new InterpreterRuntimeError( + "JSON.stringify replacers are not supported in CodeMode.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) } const space = args[2] const indent = typeof space === "number" || typeof space === "string" ? space : undefined @@ -1062,7 +1291,8 @@ const invokeJsonMethod = (name: string, args: Array, node: AstNode): un } return copyIn(parsed, "JSON.parse result") } - default: throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) + default: + throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) } } @@ -1082,12 +1312,17 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u ) } // Map/Set materialize directly (the data checkpoint would serialize them to {}). - if (args[0] instanceof SandboxMap) return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) + if (args[0] instanceof SandboxMap) + return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values()) const source = boundedData(args[0], "Array.from input") 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") { + if ( + source !== null && + typeof source === "object" && + typeof (source as { length?: unknown }).length === "number" + ) { return Array.from(source as ArrayLike) } throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node) @@ -1100,17 +1335,24 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u const invokeNumberStatic = (name: string, args: Array, 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 "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) + 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 CodeMode.`, node) + case "parseFloat": + return parseFloat(coerceToString(value)) + default: + throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node) } } @@ -1120,54 +1362,82 @@ const invokeStringStatic = (name: string, args: Array, node: AstNode): 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 CodeMode.`, node) + case "fromCharCode": + return String.fromCharCode(...codes) + case "fromCodePoint": + return String.fromCodePoint(...codes) + default: + throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node) } } const invokeDateStatic = (name: string, args: Array, node: AstNode): number => { switch (name) { - case "now": return Date.now() - case "parse": return Date.parse(coerceToString(args[0])) + case "now": + return Date.now() + case "parse": + return Date.parse(coerceToString(args[0])) case "UTC": { const parts = args.map((arg) => coerceToNumber(arg)) return Date.UTC(...(parts as Parameters)) } - default: throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node) + default: + throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node) } } const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => { const hosted = new Date(value.time) switch (name) { - case "getTime": case "valueOf": return value.time + case "getTime": + case "valueOf": + return value.time case "toISOString": { if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node) return hosted.toISOString() } // toJSON of an invalid date is null in JS (never a throw); toString stays ISO for // determinism across host timezones/locales. - case "toJSON": return Number.isFinite(value.time) ? hosted.toISOString() : null - case "toString": return coerceToString(value) - case "getFullYear": return hosted.getFullYear() - case "getMonth": return hosted.getMonth() - case "getDate": return hosted.getDate() - case "getDay": return hosted.getDay() - case "getHours": return hosted.getHours() - case "getMinutes": return hosted.getMinutes() - case "getSeconds": return hosted.getSeconds() - case "getMilliseconds": return hosted.getMilliseconds() - case "getUTCFullYear": return hosted.getUTCFullYear() - case "getUTCMonth": return hosted.getUTCMonth() - case "getUTCDate": return hosted.getUTCDate() - case "getUTCDay": return hosted.getUTCDay() - case "getUTCHours": return hosted.getUTCHours() - case "getUTCMinutes": return hosted.getUTCMinutes() - case "getUTCSeconds": return hosted.getUTCSeconds() - case "getUTCMilliseconds": return hosted.getUTCMilliseconds() - case "getTimezoneOffset": return hosted.getTimezoneOffset() - default: throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node) + case "toJSON": + return Number.isFinite(value.time) ? hosted.toISOString() : null + case "toString": + return coerceToString(value) + case "getFullYear": + return hosted.getFullYear() + case "getMonth": + return hosted.getMonth() + case "getDate": + return hosted.getDate() + case "getDay": + return hosted.getDay() + case "getHours": + return hosted.getHours() + case "getMinutes": + return hosted.getMinutes() + case "getSeconds": + return hosted.getSeconds() + case "getMilliseconds": + return hosted.getMilliseconds() + case "getUTCFullYear": + return hosted.getUTCFullYear() + case "getUTCMonth": + return hosted.getUTCMonth() + case "getUTCDate": + return hosted.getUTCDate() + case "getUTCDay": + return hosted.getUTCDay() + case "getUTCHours": + return hosted.getUTCHours() + case "getUTCMinutes": + return hosted.getUTCMinutes() + case "getUTCSeconds": + return hosted.getUTCSeconds() + case "getUTCMilliseconds": + return hosted.getUTCMilliseconds() + case "getTimezoneOffset": + return hosted.getTimezoneOffset() + default: + throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node) } } @@ -1175,26 +1445,31 @@ const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array, node: AstNode): unknown => { - if (ref.namespace === "console") throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) + if (ref.namespace === "console") + throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node) if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node) if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) if (ref.namespace === "Date") { - if (!dateStatics.has(ref.name)) throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node) + if (!dateStatics.has(ref.name)) + throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node) return invokeDateStatic(ref.name, args, node) } if (ref.namespace === "RegExp" || ref.namespace === "Map" || ref.namespace === "Set") { @@ -1207,7 +1482,8 @@ const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, no const spreadItems = (spread: unknown): Array | undefined => { if (Array.isArray(spread)) return spread if (typeof spread === "string") return Array.from(spread) - if (spread instanceof SandboxMap) return Array.from(spread.map.entries(), ([key, item]): Array => [key, item]) + if (spread instanceof SandboxMap) + return Array.from(spread.map.entries(), ([key, item]): Array => [key, item]) if (spread instanceof SandboxSet) return Array.from(spread.set.values()) return undefined } @@ -1300,7 +1576,7 @@ class Interpreter { // 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*() { + return Effect.gen(function* () { self.hoistFunctions(program.body) let value: unknown = undefined let returned = false @@ -1338,7 +1614,7 @@ class Interpreter { // diagnostic (interrupted calls, e.g. Promise.race losers, are ignored). private drainPendingSettlements(): Effect.Effect { const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { for (const promise of [...self.pendingSettlements]) { const exit = yield* self.observePromise(promise) if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue @@ -1357,13 +1633,15 @@ class Interpreter { // scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a // first-class promise value. `startImmediately` makes the runtime admit the call — charging // the tool-call budget and firing onToolCallStart — at the call site, before any await. - private createToolCallPromise(path: ReadonlyArray, args: Array): Effect.Effect { + private createToolCallPromise( + path: ReadonlyArray, + args: Array, + ): Effect.Effect { const self = this return Effect.map( - Effect.forkChild( - this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), - { startImmediately: true }, - ), + Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), { + startImmediately: true, + }), (fiber) => { const promise = new SandboxPromise(fiber) self.pendingSettlements.add(promise) @@ -1387,13 +1665,22 @@ class Interpreter { return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node)) } - private unwrapPromiseExit(promise: SandboxPromise | undefined, exit: Exit.Exit, node?: AstNode): Effect.Effect { + private unwrapPromiseExit( + promise: SandboxPromise | undefined, + exit: Exit.Exit, + node?: AstNode, + ): Effect.Effect { if (Exit.isSuccess(exit)) return Effect.succeed(exit.value) // A call Promise.race interrupted after losing settles as a catchable program failure; // any other interruption is execution teardown (timeout/host) and must keep propagating // as interruption rather than becoming program-visible data. if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) { - return Effect.fail(new InterpreterRuntimeError("This tool call was interrupted because another value settled a Promise.race first.", node)) + return Effect.fail( + new InterpreterRuntimeError( + "This tool call was interrupted because another value settled a Promise.race first.", + node, + ), + ) } return Effect.failCause(exit.cause) } @@ -1446,7 +1733,7 @@ class Interpreter { private evaluateBlock(node: AstNode): Effect.Effect { this.pushScope() const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { const body = getArray(node, "body") self.hoistFunctions(body) @@ -1470,7 +1757,12 @@ class Interpreter { private createFunction(node: AstNode): CodeModeFunction { if (node.generator === true) { - throw new InterpreterRuntimeError("Generator functions are not supported in CodeMode.", node, "UnsupportedSyntax", [supportedSyntaxMessage]) + throw new InterpreterRuntimeError( + "Generator functions are not supported in CodeMode.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) } return new CodeModeFunction( getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), @@ -1495,16 +1787,25 @@ class Interpreter { const alternateNode = getOptionalNode(node, "alternate") return Effect.flatMap(this.evaluateExpression(testNode), (test) => - test ? this.evaluateStatement(consequentNode) : alternateNode ? this.evaluateStatement(alternateNode) : Effect.succeed({ kind: "none" })) + test + ? this.evaluateStatement(consequentNode) + : alternateNode + ? this.evaluateStatement(alternateNode) + : Effect.succeed({ kind: "none" }), + ) } private evaluateSwitchStatement(node: AstNode): Effect.Effect { const self = this this.pushScope() - return Effect.gen(function*() { + return Effect.gen(function* () { const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) if (containsOpaqueReference(discriminant)) { - throw new InterpreterRuntimeError("Switch discriminants must be data values in CodeMode.", node, "InvalidDataValue") + throw new InterpreterRuntimeError( + "Switch discriminants must be data values in CodeMode.", + node, + "InvalidDataValue", + ) } const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`)) let defaultIndex: number | undefined @@ -1517,7 +1818,11 @@ class Interpreter { } const candidate = yield* self.evaluateExpression(test) if (containsOpaqueReference(candidate)) { - throw new InterpreterRuntimeError("Switch case values must be data values in CodeMode.", test, "InvalidDataValue") + throw new InterpreterRuntimeError( + "Switch case values must be data values in CodeMode.", + test, + "InvalidDataValue", + ) } if (candidate === discriminant) { selected = index @@ -1543,7 +1848,7 @@ class Interpreter { const bodyNode = getNode(node, "body") const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { while (yield* self.evaluateExpression(testNode)) { const result = yield* self.evaluateStatement(bodyNode) @@ -1552,7 +1857,7 @@ class Interpreter { } if (result.kind === "break") { - return { kind: "none" } satisfies StatementResult + return { kind: "none" } satisfies StatementResult } if (result.kind === "return") { @@ -1573,7 +1878,7 @@ class Interpreter { const testNode = getNode(node, "test") const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { do { const result = yield* self.evaluateStatement(bodyNode) @@ -1601,7 +1906,7 @@ class Interpreter { private evaluateForStatement(node: AstNode): Effect.Effect { this.pushScope() const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { const initNode = getOptionalNode(node, "init") const testNode = getOptionalNode(node, "test") const updateNode = getOptionalNode(node, "update") @@ -1615,23 +1920,28 @@ class Interpreter { } } - const perIterationBindings = initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" - ? Array.from(self.currentScope().keys()) - : [] + const perIterationBindings = + initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" + ? Array.from(self.currentScope().keys()) + : [] while (testNode ? yield* self.evaluateExpression(testNode) : true) { let iterationScope: Map | undefined if (perIterationBindings.length > 0) { - iterationScope = new Map(perIterationBindings.map((name) => { - const binding = self.currentScope().get(name)! - return [name, { ...binding }] - })) + 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() - })), + Effect.ensuring( + Effect.sync(() => { + if (iterationScope) self.popScope() + }), + ), ) if (result.kind === "return") { @@ -1672,7 +1982,7 @@ class Interpreter { } const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { const left = getNode(node, "left") const right = yield* self.evaluateExpression(getNode(node, "right")) const body = getNode(node, "body") @@ -1710,9 +2020,11 @@ class Interpreter { } const result = yield* self.evaluateStatement(body).pipe( - Effect.ensuring(Effect.sync(() => { - if (declaration) self.popScope() - })), + Effect.ensuring( + Effect.sync(() => { + if (declaration) self.popScope() + }), + ), ) if (result.kind === "return") { @@ -1756,7 +2068,7 @@ class Interpreter { private evaluateForInStatement(node: AstNode): Effect.Effect { const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { const left = getNode(node, "left") const right = yield* self.evaluateExpression(getNode(node, "right")) const body = getNode(node, "body") @@ -1801,9 +2113,11 @@ class Interpreter { } const result = yield* self.evaluateStatement(body).pipe( - Effect.ensuring(Effect.sync(() => { - if (declaration) self.popScope() - })), + Effect.ensuring( + Effect.sync(() => { + if (declaration) self.popScope() + }), + ), ) if (result.kind === "return") { @@ -1869,12 +2183,10 @@ class Interpreter { const caught = caughtErrorValue(Cause.squash(cause)) const parameter = getOptionalNode(handler, "param") self.pushScope() - return Effect.gen(function*() { + 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())), - ) + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) }, onSuccess: Effect.succeed, }) @@ -1889,10 +2201,12 @@ class Interpreter { cause.reasons.some(Cause.isInterruptReason) ? Effect.failCause(cause) : Effect.flatMap(this.evaluateStatement(finalizer), (final) => - isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause)), + isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause), + ), onSuccess: (result) => Effect.flatMap(this.evaluateStatement(finalizer), (final) => - isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result)), + isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result), + ), }) } @@ -1900,7 +2214,7 @@ class Interpreter { const kind = getString(node, "kind") const declarations = getArray(node, "declarations") const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { for (const declarationValue of declarations) { const declaration = asNode(declarationValue, "declarations") @@ -1915,9 +2229,14 @@ class Interpreter { }) } - private declarePattern(pattern: AstNode, value: unknown, mutable: boolean, node: AstNode): Effect.Effect { + private declarePattern( + pattern: AstNode, + value: unknown, + mutable: boolean, + node: AstNode, + ): Effect.Effect { const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { if (pattern.type === "Identifier") { self.declare(getString(pattern, "name"), value, mutable, node) return @@ -1932,7 +2251,11 @@ class Interpreter { 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") + throw new InterpreterRuntimeError( + "Object destructuring requires a data object value.", + pattern, + "InvalidDataValue", + ) } const consumed = new Set() @@ -1949,7 +2272,11 @@ class Interpreter { continue } - if (property.type !== "Property" || getBoolean(property, "computed") || getString(property, "kind") !== "init") { + if ( + property.type !== "Property" || + getBoolean(property, "computed") || + getString(property, "kind") !== "init" + ) { throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) } @@ -1993,7 +2320,9 @@ class Interpreter { // sandbox regex from those (the host `value` instance is never exposed). const regex = node.regex if (isRecord(regex) && typeof regex.pattern === "string") { - return Effect.sync(() => this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node)) + return Effect.sync(() => + this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node), + ) } return Effect.sync(() => boundedData(node.value, "Literal")) } @@ -2016,7 +2345,8 @@ class Interpreter { return this.readMember(node) case "ChainExpression": return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => - value === OptionalShortCircuit ? undefined : value) + value === OptionalShortCircuit ? undefined : value, + ) case "ObjectExpression": return this.evaluateObjectExpression(node) case "ArrayExpression": @@ -2032,7 +2362,8 @@ class Interpreter { // matching real JS semantics for non-thenables. const self = this return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => - value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value)) + value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value), + ) } case "NewExpression": return this.evaluateNewExpression(node) @@ -2058,19 +2389,24 @@ class Interpreter { ) } if (errorConstructors.has(name)) { - return Effect.gen(function*() { - const arg = argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined + return Effect.gen(function* () { + const arg = + argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined return createErrorValue(name, arg === undefined ? "" : coerceToString(arg)) }) } if (valueConstructors.has(name)) { - return Effect.gen(function*() { + return Effect.gen(function* () { const args = yield* self.evaluateCallArguments(argNodes) switch (name) { - case "Date": return self.constructDate(args) - case "RegExp": return self.constructRegExp(args, node) - case "Map": return self.constructMap(args[0], node) - default: return self.constructSet(args[0], node) + case "Date": + return self.constructDate(args) + case "RegExp": + return self.constructRegExp(args, node) + case "Map": + return self.constructMap(args[0], node) + default: + return self.constructSet(args[0], node) } }) } @@ -2093,7 +2429,8 @@ class Interpreter { private constructRegExp(args: Array, node: AstNode): SandboxRegExp { const first = args[0] - const pattern = first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first) + const pattern = + first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first) const flagsArg = args[1] if (flagsArg !== undefined && typeof flagsArg !== "string") { throw new InterpreterRuntimeError( @@ -2127,7 +2464,10 @@ class Interpreter { ? Array.from(init.map.entries(), ([key, item]): Array => [key, item]) : undefined if (entries === undefined) { - throw new InterpreterRuntimeError("new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", node) + throw new InterpreterRuntimeError( + "new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", + node, + ) } for (const pair of entries) { if (!Array.isArray(pair)) { @@ -2155,12 +2495,10 @@ class Interpreter { return target } - - private evaluateBinaryExpression(node: AstNode): Effect.Effect { const operator = getString(node, "operator") const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { const lhs = (yield* self.evaluateExpression(getNode(node, "left"))) as any const rhs = (yield* self.evaluateExpression(getNode(node, "right"))) as any // Like `typeof`, `instanceof` observes any value without coercing it (a promise or @@ -2194,34 +2532,55 @@ class Interpreter { const l = coerceOperand(lhs) as any const r = coerceOperand(rhs) as any switch (operator) { - case "+": return l + r - case "-": return l - r - case "*": return l * r - case "/": return l / r - case "%": return l % r - case "**": return l ** r + case "+": + return l + r + case "-": + return l - r + case "*": + return l * r + case "/": + return l / r + case "%": + return l % r + case "**": + return l ** r // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. - case "==": return bothObjects ? lhs === rhs : l == r - case "===": return lhs === rhs - case "!=": return bothObjects ? lhs !== rhs : l != r - case "!==": return lhs !== rhs - case "<": return l < r - case "<=": return l <= r - case ">": return l > r - case ">=": return l >= r - case "&": return l & r - case "|": return l | r - case "^": return l ^ r - case "<<": return l << r - case ">>": return l >> r - case ">>>": return l >>> r + case "==": + return bothObjects ? lhs === rhs : l == r + case "===": + return lhs === rhs + case "!=": + return bothObjects ? lhs !== rhs : l != r + case "!==": + return lhs !== rhs + case "<": + return l < r + case "<=": + return l <= r + case ">": + return l > r + case ">=": + return l >= r + case "&": + return l & r + case "|": + return l | r + case "^": + return l ^ r + case "<<": + return l << r + case ">>": + return l >> r + case ">>>": + return l >>> r 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/...). return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey) - default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) + default: + throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) } } @@ -2230,7 +2589,10 @@ class Interpreter { 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")) + if (operator === "??") + return left !== null && left !== undefined + ? Effect.succeed(left) + : this.evaluateExpression(getNode(node, "right")) throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node) }) } @@ -2256,17 +2618,25 @@ class Interpreter { // Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value // (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to // their JS string form first (see evaluateBinaryExpression). - const operand = rhs instanceof SandboxDate - ? (rhs.time as any) - : rhs !== null && typeof rhs === "object" - ? (coerceToString(rhs) as any) - : rhs + const operand = + rhs instanceof SandboxDate + ? (rhs.time as any) + : 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 = ~operand; break - default: throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) + case "+": + result = +operand + break + case "-": + result = -operand + break + case "~": + result = ~operand + break + default: + throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) } return boundedData(result, "Unary expression result") }) @@ -2276,7 +2646,7 @@ class Interpreter { const left = getNode(node, "left") const operator = getString(node, "operator") const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { if (operator === "??=" || operator === "||=" || operator === "&&=") { return yield* self.evaluateLogicalAssignment(node, left, operator) } @@ -2284,13 +2654,19 @@ class Interpreter { 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") + const next = boundedData( + self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), + "Assignment result", + ) 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") + const next = boundedData( + self.applyCompoundAssignment(operator, current, rightValue, node), + "Assignment result", + ) return Effect.succeed({ write: true, next, result: next }) }) } @@ -2298,13 +2674,17 @@ class Interpreter { }) } - private evaluateLogicalAssignment(node: AstNode, left: AstNode, operator: string): Effect.Effect { + private evaluateLogicalAssignment( + node: AstNode, + left: AstNode, + operator: string, + ): Effect.Effect { 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*() { + return Effect.gen(function* () { const current = self.getIdentifierValue(name, left) if (!shouldAssign(current)) return current const rightValue = yield* self.evaluateExpression(getNode(node, "right")) @@ -2315,8 +2695,13 @@ class Interpreter { // 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 })) + ? 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) } @@ -2358,7 +2743,7 @@ class Interpreter { const argNodes = getArray(node, "arguments") const self = this - return Effect.gen(function*() { + 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 @@ -2393,7 +2778,7 @@ class Interpreter { if (callable instanceof ErrorConstructorReference) { return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0])) } - throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) + throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) }) } @@ -2413,7 +2798,8 @@ class Interpreter { } private invokeConsole(name: string, args: Array, node: AstNode): undefined { - if (!consoleMethods.has(name)) throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node) + if (!consoleMethods.has(name)) + throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node) this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node))) return undefined } @@ -2473,7 +2859,9 @@ class Interpreter { if (Array.isArray(value)) { return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]` } - return `{${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`).join(",")}}` + return `{${Object.entries(value) + .map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`) + .join(",")}}` } finally { seen.delete(value) } @@ -2489,7 +2877,10 @@ class Interpreter { const rows = this.consoleTableRows(data, columns) const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) const header = ["(index)", ...keys].join("\t") - return [header, ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t"))].join("\n") + return [ + header, + ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")), + ].join("\n") } private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray | undefined { @@ -2499,7 +2890,10 @@ class Interpreter { return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined } - private consoleTableRows(data: unknown, columns: ReadonlyArray | undefined): Array<{ readonly index: string; readonly values: Record }> { + private consoleTableRows( + data: unknown, + columns: ReadonlyArray | undefined, + ): Array<{ readonly index: string; readonly values: Record }> { if (Array.isArray(data)) { return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) })) } @@ -2526,14 +2920,18 @@ class Interpreter { private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { const args: Array = [] 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 = spreadItems(spread) - if (items === undefined) throw new InterpreterRuntimeError("Spread arguments require an array, string, Map, or Set in CodeMode.", argNode) + if (items === undefined) + throw new InterpreterRuntimeError( + "Spread arguments require an array, string, Map, or Set in CodeMode.", + argNode, + ) args.push(...items) } else { args.push(yield* self.evaluateExpression(argNode)) @@ -2548,13 +2946,19 @@ class Interpreter { // whatever — because tool calls already run eagerly on their own fibers; the combinators // only observe settlements. Joining is therefore sequential (no extra fibers) without // costing parallelism, and the concurrency cap stays where the work is: the fork semaphore. - private invokePromiseMethod(ref: PromiseMethodReference, args: Array, node: AstNode): Effect.Effect { + private invokePromiseMethod( + ref: PromiseMethodReference, + args: Array, + node: AstNode, + ): Effect.Effect { const self = this if (ref.name === "resolve") { // Promise.resolve of a promise is that promise (JS flattens); anything else is a // promise already fulfilled with the value. const value = args[0] - return Effect.succeed(value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value))) + return Effect.succeed( + value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)), + ) } if (ref.name === "reject") { return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0])))) @@ -2573,8 +2977,10 @@ class Interpreter { // Mark every promise element observed up-front (Promise.all handles all of its // members' failures, as in JS), then join in index order; the first failure rejects // the whole call while unrelated in-flight members keep running. - const settles = items.map((item) => (item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item))) - return Effect.gen(function*() { + const settles = items.map((item) => + item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item), + ) + return Effect.gen(function* () { const values: Array = [] for (const settle of settles) values.push(yield* settle) return values @@ -2584,13 +2990,16 @@ class Interpreter { const observations = items.map((item) => item instanceof SandboxPromise ? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit })) - : Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) })) - return Effect.gen(function*() { + : Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }), + ) + return Effect.gen(function* () { const outcomes: Array = [] for (const observation of observations) { const { exit, promise } = yield* observation if (Exit.isSuccess(exit)) { - outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value })) + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), + ) continue } const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause) @@ -2599,22 +3008,34 @@ class Interpreter { return yield* Effect.failCause(exit.cause) } const thrown = raceInterrupted - ? new InterpreterRuntimeError("This tool call was interrupted because another value settled a Promise.race first.", node) + ? new InterpreterRuntimeError( + "This tool call was interrupted because another value settled a Promise.race first.", + node, + ) : Cause.squash(exit.cause) - outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "rejected", reason: caughtErrorValue(thrown) })) + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { + status: "rejected", + reason: caughtErrorValue(thrown), + }), + ) } return outcomes }) } case "race": { if (items.length === 0) { - throw new InterpreterRuntimeError("Promise.race([]) would never settle; provide at least one promise or value.", node) + throw new InterpreterRuntimeError( + "Promise.race([]) would never settle; provide at least one promise or value.", + node, + ) } const observations = items.map((item, index) => item instanceof SandboxPromise ? Effect.map(this.observePromise(item), (exit) => ({ index, exit })) - : Effect.succeed({ index, exit: Exit.succeed(item as unknown) })) - return Effect.gen(function*() { + : Effect.succeed({ index, exit: Exit.succeed(item as unknown) }), + ) + return Effect.gen(function* () { // First settlement (fulfilled OR rejected) wins; the observations never fail, so // racing them yields exactly that. Losing in-flight calls are then interrupted. const winner = yield* Effect.raceAll(observations) @@ -2624,7 +3045,11 @@ class Interpreter { yield* Fiber.interrupt(item.fiber) } const winningItem = items[winner.index] - return yield* self.unwrapPromiseExit(winningItem instanceof SandboxPromise ? winningItem : undefined, winner.exit, node) + return yield* self.unwrapPromiseExit( + winningItem instanceof SandboxPromise ? winningItem : undefined, + winner.exit, + node, + ) }) } } @@ -2635,7 +3060,7 @@ class Interpreter { return Effect.suspend(() => { const savedScopes = self.scopes self.scopes = [...fn.capturedScopes, new Map()] - const run = Effect.gen(function*() { + 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. @@ -2660,11 +3085,21 @@ class Interpreter { return yield* self.evaluateExpression(fn.body) }) - return run.pipe(Effect.ensuring(Effect.sync(() => { self.scopes = savedScopes }))) + return run.pipe( + Effect.ensuring( + Effect.sync(() => { + self.scopes = savedScopes + }), + ), + ) }) } - private invokeIntrinsic(ref: IntrinsicReference, args: Array, node: AstNode): Effect.Effect { + private invokeIntrinsic( + ref: IntrinsicReference, + args: Array, + node: AstNode, + ): Effect.Effect { if (typeof ref.receiver === "string") { return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) } @@ -2691,7 +3126,11 @@ class Interpreter { // Runs a Map/Set callback (forEach) accepting a user function or a builtin coercion, // mirroring the array-method callback contract. - private applyCollectionCallback(callback: unknown, name: string, node: AstNode): (args: Array) => Effect.Effect { + private applyCollectionCallback( + callback: unknown, + name: string, + node: AstNode, + ): (args: Array) => Effect.Effect { if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) { throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) } @@ -2701,64 +3140,96 @@ class Interpreter { : this.invokeFunction(callback, callbackArgs) } - private invokeMapMethod(target: SandboxMap, name: string, args: Array, node: AstNode): Effect.Effect { + private invokeMapMethod( + target: SandboxMap, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { switch (name) { - case "get": return Effect.succeed(target.map.get(args[0])) - case "has": return Effect.succeed(target.map.has(args[0])) - case "set": return Effect.sync(() => { - target.map.set(args[0], args[1]) - return target - }) - case "delete": return Effect.sync(() => target.map.delete(args[0])) - case "clear": return Effect.sync(() => { - target.map.clear() - return undefined - }) - case "keys": return Effect.sync(() => Array.from(target.map.keys())) - case "values": return Effect.sync(() => Array.from(target.map.values())) - case "entries": return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) + case "get": + return Effect.succeed(target.map.get(args[0])) + case "has": + return Effect.succeed(target.map.has(args[0])) + case "set": + return Effect.sync(() => { + target.map.set(args[0], args[1]) + return target + }) + case "delete": + return Effect.sync(() => target.map.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.map.clear() + return undefined + }) + case "keys": + return Effect.sync(() => Array.from(target.map.keys())) + case "values": + return Effect.sync(() => Array.from(target.map.values())) + case "entries": + return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) case "forEach": { const apply = this.applyCollectionCallback(args[0], "Map.forEach", node) - return Effect.gen(function*() { + return Effect.gen(function* () { // Snapshot iteration, matching the array-method callback contract. for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target]) return undefined }) } - default: throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) + default: + throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) } } - private invokeSetMethod(target: SandboxSet, name: string, args: Array, node: AstNode): Effect.Effect { + private invokeSetMethod( + target: SandboxSet, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { switch (name) { - case "has": return Effect.succeed(target.set.has(args[0])) - case "add": return Effect.sync(() => { - target.set.add(args[0]) - return target - }) - case "delete": return Effect.sync(() => target.set.delete(args[0])) - case "clear": return Effect.sync(() => { - target.set.clear() - return undefined - }) + case "has": + return Effect.succeed(target.set.has(args[0])) + case "add": + return Effect.sync(() => { + target.set.add(args[0]) + return target + }) + case "delete": + return Effect.sync(() => target.set.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.set.clear() + return undefined + }) case "keys": - case "values": return Effect.sync(() => Array.from(target.set.values())) - case "entries": return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) + case "values": + return Effect.sync(() => Array.from(target.set.values())) + case "entries": + return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) case "forEach": { const apply = this.applyCollectionCallback(args[0], "Set.forEach", node) - return Effect.gen(function*() { + return Effect.gen(function* () { for (const item of Array.from(target.set.values())) yield* apply([item, item, target]) return undefined }) } - default: throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) + default: + throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) } } - private invokeArrayMethod(target: Array, name: string, args: Array, node: AstNode): Effect.Effect { + private invokeArrayMethod( + target: Array, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { 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) + if (typeof value !== "number") + throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) return value } switch (name) { @@ -2767,15 +3238,22 @@ class Interpreter { throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) } const input = boundedData(target, "Array.join input") as Array - return Effect.succeed(input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : args[0] as string)) + return Effect.succeed( + input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), + ) } case "includes": - if (args.length === 0 || args.length > 2) throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) + 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"))) + 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": @@ -2833,7 +3311,13 @@ class Interpreter { return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) } case "copyWithin": - return Effect.succeed(target.copyWithin(optNumber(args[0], "target index") ?? 0, optNumber(args[1], "start") ?? 0, optNumber(args[2], "end"))) + return Effect.succeed( + target.copyWithin( + optNumber(args[0], "target index") ?? 0, + optNumber(args[1], "start") ?? 0, + optNumber(args[2], "end"), + ), + ) // keys/values/entries return arrays (not iterators), matching the Map/Set convention; // they work with for...of and spread either way. case "keys": @@ -2856,7 +3340,7 @@ class Interpreter { callback instanceof CoercionFunction ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) : self.invokeFunction(callback, callbackArgs) - return Effect.gen(function*() { + 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() @@ -2912,7 +3396,8 @@ class Interpreter { accumulator = args[1] start = 0 } else { - if (items.length === 0) throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) + if (items.length === 0) + throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) accumulator = items[0] start = 1 } @@ -2928,7 +3413,8 @@ class Interpreter { 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) + 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 } @@ -2952,7 +3438,11 @@ class Interpreter { }) } - private sortArray(target: Array, comparator: unknown, node: AstNode): Effect.Effect, unknown, R> { + private sortArray( + target: Array, + comparator: unknown, + node: AstNode, + ): Effect.Effect, unknown, R> { if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) { throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) } @@ -2962,13 +3452,14 @@ class Interpreter { const left = coerceToString(a) const right = coerceToString(b) return left < right ? -1 : left > right ? 1 : 0 - })) + }), + ) } const self = this const mergeSort = (items: Array): Effect.Effect, unknown, R> => { if (items.length <= 1) return Effect.succeed(items) const midpoint = Math.floor(items.length / 2) - return Effect.gen(function*() { + return Effect.gen(function* () { const left = yield* mergeSort(items.slice(0, midpoint)) const right = yield* mergeSort(items.slice(midpoint)) const merged: Array = [] @@ -2994,7 +3485,7 @@ class Interpreter { const objectValue: Record = Object.create(null) as Record const properties = getArray(node, "properties") const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { for (const propertyValue of properties) { const property = asNode(propertyValue, "properties") @@ -3005,10 +3496,15 @@ class Interpreter { // (Date/RegExp/Map/Set) have no own enumerable properties in JS, so they are no-ops too. if (spread === null || spread === undefined || isSandboxValue(spread)) continue if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { - throw new InterpreterRuntimeError("Object spread requires a data object in CodeMode.", property, "InvalidDataValue") + throw new InterpreterRuntimeError( + "Object spread requires a data object in CodeMode.", + property, + "InvalidDataValue", + ) } for (const [key, value] of Object.entries(spread)) { - if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property) + if (isBlockedMember(key)) + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property) objectValue[key] = value } continue @@ -3053,7 +3549,7 @@ class Interpreter { const values: Array = [] const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { for (const elementValue of elements) { if (elementValue === null) { values.push(undefined) @@ -3063,7 +3559,11 @@ class Interpreter { if (element.type === "SpreadElement") { const spread = yield* self.evaluateExpression(getNode(element, "argument")) const items = spreadItems(spread) - if (items === undefined) throw new InterpreterRuntimeError("Array spread requires an array, string, Map, or Set in CodeMode.", element) + if (items === undefined) + throw new InterpreterRuntimeError( + "Array spread requires an array, string, Map, or Set in CodeMode.", + element, + ) values.push(...items) } else { values.push(yield* self.evaluateExpression(element)) @@ -3080,7 +3580,7 @@ class Interpreter { let output = "" const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { for (let index = 0; index < quasis.length; index += 1) { const quasi = asNode(quasis[index], "quasis") const rawValue = quasi.value @@ -3105,15 +3605,11 @@ class Interpreter { private evaluateConditionalExpression(node: AstNode): Effect.Effect { return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) => - this.evaluateExpression(getNode(node, test ? "consequent" : "alternate"))) + this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")), + ) } - private applyCompoundAssignment( - operator: string, - current: unknown, - incoming: unknown, - node: AstNode, - ): unknown { + private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown { // `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation // so compound assignment inherits the same coercion semantics (Dates, data objects, ...). // Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=) @@ -3124,13 +3620,26 @@ class Interpreter { return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node) } - private getMemberReference(node: AstNode): Effect.Effect { + private getMemberReference( + node: AstNode, + ): Effect.Effect< + | MemberReference + | ToolReference + | PromiseMethodReference + | IntrinsicReference + | GlobalMethodReference + | 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*() { + return Effect.gen(function* () { const objectValue = yield* self.evaluateExpression(objectNode) if (objectValue === OptionalShortCircuit) return OptionalShortCircuit if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit @@ -3160,7 +3669,10 @@ class Interpreter { if (objectValue instanceof GlobalNamespace) { if (typeof key !== "string" || isBlockedMember(key)) { - throw new InterpreterRuntimeError(`${objectValue.name}.${String(key)} is not available in CodeMode.`, propertyNode) + throw new InterpreterRuntimeError( + `${objectValue.name}.${String(key)} is not available in CodeMode.`, + propertyNode, + ) } if (objectValue.name === "Math" && mathConstants.has(key)) { return new ComputedValue((Math as unknown as Record)[key]) @@ -3239,7 +3751,11 @@ class Interpreter { } if (isRuntimeReference(objectValue)) { - throw new InterpreterRuntimeError("CodeMode runtime references are opaque and do not expose properties.", objectNode, "InvalidDataValue") + throw new InterpreterRuntimeError( + "CodeMode runtime references are opaque and do not expose properties.", + objectNode, + "InvalidDataValue", + ) } if (typeof objectValue !== "object" || objectValue === null) { @@ -3251,7 +3767,12 @@ class Interpreter { } if (Array.isArray(objectValue)) { - if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && (typeof key !== "number" && !/^\d+$/.test(key))) { + if ( + key !== "length" && + !(typeof key === "string" && arrayMethods.has(key)) && + typeof key !== "number" && + !/^\d+$/.test(key) + ) { // Own non-index properties read through (match results carry index/groups); like JS, // they are readable in place and dropped by JSON at data boundaries. if (typeof key === "string" && Object.hasOwn(objectValue, key)) { @@ -3278,7 +3799,8 @@ class Interpreter { reference instanceof PromiseMethodReference || reference instanceof IntrinsicReference || reference instanceof GlobalMethodReference - ) return reference + ) + return reference if (Array.isArray(reference.target)) { if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { return new IntrinsicReference(reference.target, reference.key) @@ -3301,7 +3823,7 @@ class Interpreter { compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, ): Effect.Effect { const self = this - return Effect.gen(function*() { + return Effect.gen(function* () { const reference = yield* self.getMemberReference(node) if ( reference === OptionalShortCircuit || @@ -3315,7 +3837,8 @@ class Interpreter { throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node) } if (Array.isArray(reference.target)) { - if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node) + if (reference.key === "length") + throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node) if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node) } @@ -3330,8 +3853,15 @@ class Interpreter { // Rejects inserting a value that (transitively) contains the container it is being inserted // into — the mutation that would create a circular structure no later walk could survive. - private rejectCircularInsertion(container: object, value: unknown, label: string, node: AstNode, seen = new Set()): void { - if (value === container) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + private rejectCircularInsertion( + container: object, + value: unknown, + label: string, + node: AstNode, + seen = new Set(), + ): 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) @@ -3344,7 +3874,11 @@ class Interpreter { const target = reference.target const index = key as number if (!Number.isInteger(index) || index < 0) { - throw new InterpreterRuntimeError("Array assignment index must be a non-negative integer.", node, "InvalidDataValue") + throw new InterpreterRuntimeError( + "Array assignment index must be a non-negative integer.", + node, + "InvalidDataValue", + ) } this.rejectCircularInsertion(target, next, "Array assignment result", node) target[index] = next @@ -3437,7 +3971,6 @@ class Interpreter { private popScope(): void { this.scopes.pop() } - } /** @@ -3460,9 +3993,14 @@ const executeWithLimits = >( ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), } - const tools = ToolRuntime.make((options.tools ?? {}) as HostTools>, limits.maxToolCalls, hooks, searchIndex) + const tools = ToolRuntime.make( + (options.tools ?? {}) as HostTools>, + limits.maxToolCalls, + hooks, + searchIndex, + ) const logs: Array = [] - const logged = () => logs.length > 0 ? { logs: [...logs] } : {} + const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) if (options.code.trim().length === 0) { return Effect.succeed({ @@ -3472,7 +4010,7 @@ const executeWithLimits = >( }) } - const operation = Effect.gen(function*() { + const operation = Effect.gen(function* () { const program = parseProgram(options.code) const interpreter = new Interpreter>(tools.invoke, tools.keys, logs) const value = yield* interpreter.run(program) @@ -3483,23 +4021,22 @@ const executeWithLimits = >( ...logged(), toolCalls: tools.calls, } satisfies ExecuteResult - }).pipe( - (program) => { - const timeoutMs = limits.timeoutMs - if (timeoutMs === undefined) return program - return program.pipe( - Effect.timeoutOrElse({ - duration: timeoutMs, - orElse: () => Effect.succeed({ + }).pipe((program) => { + const timeoutMs = limits.timeoutMs + if (timeoutMs === undefined) return program + return program.pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.succeed({ ok: false, error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, ...logged(), toolCalls: tools.calls, } satisfies ExecuteResult), - }), - ) - }, - ) + }), + ) + }) return operation.pipe( Effect.catchCause((cause) => @@ -3512,7 +4049,7 @@ const executeWithLimits = >( toolCalls: tools.calls, } satisfies ExecuteResult), ), - Effect.map((result) => limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes)), + Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))), ) } @@ -3574,7 +4111,9 @@ const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResu : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls } } -export const execute = >(options: ExecuteOptions): Effect.Effect> => { +export const execute = >( + options: ExecuteOptions, +): Effect.Effect> => { const tools = (options.tools ?? {}) as HostTools> ToolRuntime.assertValidTools(tools) return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools)) @@ -3592,7 +4131,9 @@ export const execute = >(options: Ex * const code = runtime.agentTool() * ``` */ -export const make = = {}>(options: CodeModeOptions = {} as CodeModeOptions): CodeModeRuntime> => { +export const make = = {}>( + options: CodeModeOptions = {} as CodeModeOptions, +): CodeModeRuntime> => { const tools = (options.tools ?? {}) as HostTools> ToolRuntime.assertValidTools(tools) const limits = resolveExecutionLimits(options.limits) diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts index ce33d603ec..96629f486e 100644 --- a/packages/codemode/src/index.ts +++ b/packages/codemode/src/index.ts @@ -1,10 +1,4 @@ -export { - ToolError, - CodeMode, - ExecuteInputSchema, - ExecuteResultSchema, - toolError, -} from "./codemode.js" +export { ToolError, CodeMode, ExecuteInputSchema, ExecuteResultSchema, toolError } from "./codemode.js" export { Tool } from "./tool.js" export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js" export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js" diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 10e54dcb2c..3311707c6e 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -21,11 +21,16 @@ export type HostTools = { export type Services = Tools extends (...args: Array) => Effect.Effect ? R - : Tools extends { readonly _tag: "CodeModeTool"; readonly run: (input: unknown) => Effect.Effect } + : Tools extends { + readonly _tag: "CodeModeTool" + readonly run: (input: unknown) => Effect.Effect + } ? R - : Tools extends object - ? string extends keyof Tools ? never : Services - : never + : Tools extends object + ? string extends keyof Tools + ? never + : Services + : never /** Minimal audit record retained for each admitted tool call. */ export type ToolCall = { @@ -68,11 +73,13 @@ export type SafeObject = Record const reservedNamespace = "$codemode" const defaultMaxInlineCatalogTokens = 2_000 const defaultSearchLimit = 10 -const searchSignature = "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>" +const searchSignature = + "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>" const toolExpression = (path: string) => - "tools" + path + "tools" + + path .split(".") - .map((segment) => identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`) + .map((segment) => (identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)) .join("") export class ToolReference { @@ -88,7 +95,12 @@ const MAX_VALUE_DEPTH = 32 export class ToolRuntimeError extends Error { constructor( - readonly kind: "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded", + readonly kind: + | "UnknownTool" + | "InvalidToolInput" + | "InvalidToolOutput" + | "InvalidDataValue" + | "ToolCallLimitExceeded", message: string, readonly suggestions: ReadonlyArray = [], ) { @@ -131,7 +143,13 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown => copyBounded(value, label, 0, new Set(), preserveSandboxValues) -const copyBounded = (value: unknown, label: string, depth: number, seen: Set, preserveSandboxValues: boolean): unknown => { +const copyBounded = ( + value: unknown, + label: string, + depth: number, + seen: Set, + preserveSandboxValues: boolean, +): unknown => { if (depth > MAX_VALUE_DEPTH) { throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) } @@ -166,7 +184,12 @@ const copyBounded = (value: unknown, label: string, depth: number, seen: Set { return value } -const definitions = (tools: HostTools, path: ReadonlyArray = []): Array<{ path: string; definition: Definition }> => { +const definitions = ( + tools: HostTools, + path: ReadonlyArray = [], +): Array<{ path: string; definition: Definition }> => { const entries: Array<{ path: string; definition: Definition }> = [] for (const [name, value] of Object.entries(tools)) { const next = [...path, name] @@ -342,8 +372,11 @@ const toSearchEntry = (path: string, definition: Definition, description: path, definition.description, ...inputProperties(definition).flatMap(({ name, description: property }) => - property === undefined ? [name] : [name, property]), - ].join("\n").toLowerCase(), + property === undefined ? [name] : [name, property], + ), + ] + .join("\n") + .toLowerCase(), }) /** The runtime search index over every described tool. Search is always registered. */ @@ -396,7 +429,8 @@ export const discoveryPlan = ( namespace, picked: new Set(), queue: [...group].sort( - (left, right) => estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path), + (left, right) => + estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path), ), })) let used = 0 @@ -472,7 +506,9 @@ export const discoveryPlan = ( "- A result typed `Promise` has no guaranteed shape — verify what actually came back before relying on its fields.", "- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`.", "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", - ...(complete ? [] : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.']), + ...(complete + ? [] + : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.']), ] const syntax = [ @@ -500,26 +536,21 @@ export const discoveryPlan = ( const count = `${group.length} tool${group.length === 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.size === group.length ? count : picked.size === 0 ? `${count}, none shown` : `${count}, ${picked.size} shown` + const label = + picked.size === group.length + ? count + : picked.size === 0 + ? `${count}, none shown` + : `${count}, ${picked.size} shown` toolSection.push(`- ${namespace} (${label})`) for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool)) } if (!complete) { - toolSection.push( - "", - "Search returns complete callable signatures:", - `- ${searchSignature}`, - ) + toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`) } } - const lines = [ - ...intro, - ...workflow, - ...rules, - ...syntax, - ...toolSection, - ] + const lines = [...intro, ...workflow, ...rules, ...syntax, ...toolSection] return { catalog: described, instructions: lines.join("\n"), @@ -534,18 +565,29 @@ export const discoveryPlan = ( * function in JS). An unknown path is an `UnknownTool` error pointing at the working * discovery idioms, mirroring how calling an unknown tool fails. */ -const namespaceKeys = (tools: HostTools, path: ReadonlyArray, searchEnabled: boolean): ReadonlyArray => { +const namespaceKeys = ( + tools: HostTools, + path: ReadonlyArray, + searchEnabled: boolean, +): ReadonlyArray => { // The reserved discovery namespace is virtual (never present in the host tree); enumerate // it explicitly so `Object.keys(tools.$codemode)` matches the callable surface. if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"] let value: HostTool | Definition | HostTools = tools for (const segment of path) { - if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) { + if ( + isBlockedMember(segment) || + typeof value === "function" || + isDefinition(value) || + !Object.hasOwn(value, segment) + ) { throw new ToolRuntimeError( "UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, searchEnabled - ? ["Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools."] + ? [ + "Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.", + ] : ["Object.keys(tools) lists the available namespaces."], ) } @@ -555,12 +597,25 @@ const namespaceKeys = (tools: HostTools, path: ReadonlyArray, sear return Object.keys(value) } -const resolve = (tools: HostTools, path: ReadonlyArray, searchEnabled: boolean): HostTool | Definition => { +const resolve = ( + tools: HostTools, + path: ReadonlyArray, + searchEnabled: boolean, +): HostTool | Definition => { let value: HostTool | Definition | HostTools = tools for (const segment of path) { - if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) { - throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : []) + if ( + isBlockedMember(segment) || + typeof value === "function" || + isDefinition(value) || + !Object.hasOwn(value, segment) + ) { + throw new ToolRuntimeError( + "UnknownTool", + `Unknown tool '${path.join(".")}'.`, + searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [], + ) } value = value[segment] as HostTool | Definition | HostTools } @@ -602,7 +657,8 @@ export const make = ( return effect.pipe( Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })), Effect.tapError((error) => - onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) })), + onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) }), + ), ) } @@ -624,7 +680,7 @@ export const make = ( calls, keys: (path) => namespaceKeys(tools, path, searchEnabled), invoke: (path, args) => - Effect.gen(function*() { + Effect.gen(function* () { const name = path.join(".") const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`))) const call = { name } @@ -637,17 +693,32 @@ export const make = ( if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`) const input = externalArgs[0] if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) { - throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.") + throw new ToolRuntimeError( + "InvalidToolInput", + "tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.", + ) } const request = input as { query?: unknown; namespace?: unknown; limit?: unknown } if (request.query !== undefined && typeof request.query !== "string") { - throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search query must be a string when provided.") + throw new ToolRuntimeError( + "InvalidToolInput", + "tools.$codemode.search query must be a string when provided.", + ) } if (request.namespace !== undefined && typeof request.namespace !== "string") { - throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search namespace must be a string when provided.") + throw new ToolRuntimeError( + "InvalidToolInput", + "tools.$codemode.search namespace must be a string when provided.", + ) } - if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)) { - throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search limit must be a positive safe integer when provided.") + if ( + request.limit !== undefined && + (typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0) + ) { + throw new ToolRuntimeError( + "InvalidToolInput", + "tools.$codemode.search limit must be a positive safe integer when provided.", + ) } const query = typeof request.query === "string" ? request.query : "" const namespace = typeof request.namespace === "string" ? request.namespace : undefined @@ -656,40 +727,50 @@ export const make = ( Effect.try({ try: () => { const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit - const scoped = namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace) + const scoped = + namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace) // A query that names one tool path exactly (canonical path or rendered // JavaScript expression) is a lookup, not a search: return that tool alone. const trimmed = query.trim() const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed - const exact = pathQuery === "" ? undefined : scoped.find((entry) => - entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed) + const exact = + pathQuery === "" + ? undefined + : scoped.find( + (entry) => + entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, + ) const terms = tokenize(query).map(termForms) // Additive field-weighted scoring, summed across terms: exact path or path // segment (20) > path substring (8) > description substring (4) > any // searchable text, incl. input parameter names/descriptions (2). Each term // matches a field when any of its forms (the term or a singular variant) // does. An empty query browses everything, alphabetical by path. - const ranked = exact !== undefined - ? [exact] - : scoped - .map((entry) => { - const path = entry.description.path.toLowerCase() - const description = entry.description.description.toLowerCase() - const score = terms.reduce( - (total, forms) => - total + - (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + - (forms.some((form) => path.includes(form)) ? 8 : 0) + - (forms.some((form) => description.includes(form)) ? 4 : 0) + - (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), - 0, + const ranked = + exact !== undefined + ? [exact] + : scoped + .map((entry) => { + const path = entry.description.path.toLowerCase() + const description = entry.description.description.toLowerCase() + const score = terms.reduce( + (total, forms) => + total + + (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + + (forms.some((form) => path.includes(form)) ? 8 : 0) + + (forms.some((form) => description.includes(form)) ? 4 : 0) + + (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), + 0, + ) + return { entry, score } + }) + .filter(({ score }) => terms.length === 0 || score > 0) + .sort( + (left, right) => + right.score - left.score || + left.entry.description.path.localeCompare(right.entry.description.path), ) - return { entry, score } - }) - .filter(({ score }) => terms.length === 0 || score > 0) - .sort((left, right) => - right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path)) - .map(({ entry }) => entry) + .map(({ entry }) => entry) // Result paths are rendered as JavaScript expressions so each `path` is // directly usable as the call site (`await tools.github.list({ ... })` or // `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty, @@ -711,10 +792,12 @@ export const make = ( const tool = resolve(tools, path, searchEnabled) let describedInput: unknown if (isDefinition(tool)) { - if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) + if (externalArgs.length !== 1) + throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) describedInput = yield* Effect.try({ try: () => decodeToolInput(tool, externalArgs[0]), - catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), + catch: (cause) => + new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), }) } const input = isDefinition(tool) ? describedInput : externalArgs @@ -722,7 +805,7 @@ export const make = ( const currentCall = { index, name, input } if (isDefinition(tool)) { return yield* observeEnd( - Effect.gen(function*() { + Effect.gen(function* () { const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput))) const result = yield* Effect.try({ try: () => decodeToolOutput(tool, raw), @@ -734,7 +817,7 @@ export const make = ( ) } return yield* observeEnd( - Effect.gen(function*() { + Effect.gen(function* () { return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name) }), currentCall, diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index 95d9f75c8b..98b8499a53 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -57,8 +57,7 @@ export type Options(value: unknown): value is Definition => typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool" -const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder & Schema.Top => - Schema.isSchema(schema) +const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder & Schema.Top => Schema.isSchema(schema) const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" @@ -69,10 +68,12 @@ const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unkn export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ /** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ -const renderKey = (name: string): string => identifierSegment.test(name) ? name : JSON.stringify(name) +const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name)) const effectNumberSentinel = (schema: JsonSchema) => - schema.type === "string" && Array.isArray(schema.enum) && schema.enum.length === 1 && + schema.type === "string" && + Array.isArray(schema.enum) && + schema.enum.length === 1 && (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity") /** @@ -118,7 +119,8 @@ const docTags = (schema: JsonSchema): Array => { */ const jsdoc = (description: string | undefined, tags: ReadonlyArray, pad: string): string => { const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) => - line.replaceAll("*/", "* /").replace(/\s+$/, "")) + 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 "" @@ -127,7 +129,12 @@ const jsdoc = (description: string | undefined, tags: ReadonlyArray, pad return `${pad}/**\n${body}\n${pad} */\n` } -const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: ReadonlySet = new Set()): string => { +const renderSchema = ( + schema: JsonSchema, + ctx: RenderContext, + depth = 0, + seen: ReadonlySet = new Set(), +): string => { if (depth > MAX_RENDER_DEPTH) return "unknown" if (schema.$ref) { const name = schema.$ref.split("/").pop() @@ -146,13 +153,16 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R if ( alternatives.some((item) => item.type === "number") && alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) - ) return "number" + ) + return "number" // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` // (no properties/items); render the bare shape as {} instead of `{} | Array`. if ( alternatives.length === 2 && - alternatives[0]?.type === "object" && alternatives[0].properties === undefined && - alternatives[1]?.type === "array" && alternatives[1].items === undefined + alternatives[0]?.type === "object" && + alternatives[0].properties === undefined && + alternatives[1]?.type === "array" && + alternatives[1].items === undefined ) { return "{}" } @@ -170,7 +180,8 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R const required = new Set(schema.required ?? []) const properties = Object.entries(schema.properties ?? {}) const additional = schema.additionalProperties - const indexType = additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined + const indexType = + additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined const field = ([name, value]: readonly [string, JsonSchema]) => `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}` @@ -183,7 +194,9 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R // Pretty: an indented block, each described field preceded by its JSDoc comment. if (properties.length === 0 && indexType === undefined) return "{}" const pad = " ".repeat(depth + 1) - const lines = properties.map((entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`) + const lines = properties.map( + (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`, + ) if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`) return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` } @@ -262,7 +275,9 @@ export const inputProperties = (definition: Definition): Array(definition: Definition, pretty = false): string => - isEffectSchema(definition.input) ? toTypeScript(definition.input, false, pretty) : jsonSchemaToTypeScript(definition.input, pretty) + isEffectSchema(definition.input) + ? toTypeScript(definition.input, false, pretty) + : jsonSchemaToTypeScript(definition.input, pretty) /** * The model-visible TypeScript type of a tool's result; tools without an output schema diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts index e7ea42634a..699f0c489e 100644 --- a/packages/codemode/src/values.ts +++ b/packages/codemode/src/values.ts @@ -49,4 +49,7 @@ export class SandboxSet { } export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet => - value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet + value instanceof SandboxDate || + value instanceof SandboxRegExp || + value instanceof SandboxMap || + value instanceof SandboxSet diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index ac04e9208a..e7a8c35ecd 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test" import { Cause, Effect, Schema } from "effect" -import { CodeMode, ExecuteInputSchema, ExecuteResultSchema, Tool, toolError, type ExecutionLimits } from "../src/index.js" +import { + CodeMode, + ExecuteInputSchema, + ExecuteResultSchema, + Tool, + toolError, + type ExecutionLimits, +} from "../src/index.js" import type { Definition } from "../src/tool.js" const run = (tool: Definition) => @@ -75,11 +82,14 @@ describe("CodeMode host failure boundary", () => { output: Schema.Unknown, run: () => Effect.succeed( - new Proxy({}, { - ownKeys: () => { - throw new Error("host-output-secret") + new Proxy( + {}, + { + ownKeys: () => { + throw new Error("host-output-secret") + }, }, - }), + ), ), }), ) @@ -162,9 +172,7 @@ describe("CodeMode tool-call observation", () => { ) expect(result.ok).toBe(true) - expect(calls).toStrictEqual([ - { index: 0, name: "context.lookup", input: { query: "deployment failure" } }, - ]) + expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }]) }) test("observes settled calls with outcome and duration", async () => { @@ -173,25 +181,26 @@ describe("CodeMode tool-call observation", () => { description: "Look up a value", input: Schema.Struct({ query: Schema.String }), output: Schema.String, - run: ({ query }) => - query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query), + run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)), }) const runtime = CodeMode.make({ tools: { context: { lookup } }, - onToolCallStart: (call) => Effect.sync(() => { - events.push({ phase: "start", index: call.index, name: call.name }) - }), - onToolCallEnd: (call) => Effect.sync(() => { - expect(call.durationMs).toBeGreaterThanOrEqual(0) - events.push({ - phase: "end", - index: call.index, - name: call.name, - outcome: call.outcome, - ...(call.message === undefined ? {} : { message: call.message }), - }) - }), + onToolCallStart: (call) => + Effect.sync(() => { + events.push({ phase: "start", index: call.index, name: call.name }) + }), + onToolCallEnd: (call) => + Effect.sync(() => { + expect(call.durationMs).toBeGreaterThanOrEqual(0) + events.push({ + phase: "end", + index: call.index, + name: call.name, + outcome: call.outcome, + ...(call.message === undefined ? {} : { message: call.message }), + }) + }), }) const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`)) @@ -210,13 +219,15 @@ describe("CodeMode tool-call observation", () => { describe("CodeMode console capture", () => { test("captures console output as bounded result logs", async () => { - const result = await Effect.runPromise(CodeMode.execute({ - code: ` + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` const returned = console.log("Thread info:", { name: "Demo", count: 2 }) console.warn("careful") return returned `, - })) + }), + ) expect(result).toStrictEqual({ ok: true, @@ -228,39 +239,45 @@ describe("CodeMode console capture", () => { }) test("keeps logs captured before failures", async () => { - const result = await Effect.runPromise(CodeMode.execute({ - code: ` + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` console.log("before failure") throw new Error("boom") `, - })) + }), + ) expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"]) expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom") }) test("prints NaN and Infinity literally instead of the JSON null", async () => { - const result = await Effect.runPromise(CodeMode.execute({ - code: ` + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` console.log(NaN) console.log(Infinity, -Infinity) console.log({ ratio: NaN, bounds: [Infinity] }) return null `, - })) + }), + ) expect(result.ok).toBe(true) expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}']) }) test("renders sandbox values nested inside logged containers", async () => { - const result = await Effect.runPromise(CodeMode.execute({ - code: ` + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` console.log({ m: new Map([["a", 1]]), when: new Date(0), r: /ab/g, s: new Set([1, 2]) }) console.log([new Date(0)]) return null `, - })) + }), + ) expect(result.ok).toBe(true) expect(result.logs).toStrictEqual([ @@ -270,38 +287,40 @@ describe("CodeMode console capture", () => { }) test("console formatting is total: cycles and opaque references render as markers", async () => { - const result = await Effect.runPromise(CodeMode.execute({ - code: ` + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` const m = new Map() m.set("self", m) console.log({ box: m }) console.log({ fn: (x) => x, ok: 1 }) return null `, - })) + }), + ) expect(result.ok).toBe(true) - expect(result.logs).toStrictEqual([ - '{"box":Map(1) [["self",[Circular]]]}', - '{"fn":[CodeMode reference],"ok":1}', - ]) + expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}']) }) test("console.table renders sandbox value cells", async () => { - const result = await Effect.runPromise(CodeMode.execute({ - code: ` + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` console.table([{ when: new Date(0), n: NaN }]) return null `, - })) + }), + ) expect(result.ok).toBe(true) expect(result.logs).toStrictEqual(["(index)\twhen\tn\n0\t1970-01-01T00:00:00.000Z\tNaN"]) }) test("captures console.dir and console.table output", async () => { - const result = await Effect.runPromise(CodeMode.execute({ - code: ` + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` console.dir({ nested: { ok: true } }) console.table([ { name: "Kit", count: 1, hidden: "x" }, @@ -309,15 +328,13 @@ describe("CodeMode console capture", () => { ], ["name", "count"]) return "done" `, - })) + }), + ) expect(result).toStrictEqual({ ok: true, value: "done", - logs: [ - '{"nested":{"ok":true}}', - "(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2", - ], + logs: ['{"nested":{"ok":true}}', "(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2"], toolCalls: [], }) }) @@ -325,9 +342,11 @@ describe("CodeMode console capture", () => { describe("CodeMode output budget", () => { test("absent maxOutputBytes means no truncation at all", async () => { - const result = await Effect.runPromise(CodeMode.execute({ - code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`, - })) + const result = await Effect.runPromise( + CodeMode.execute({ + code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`, + }), + ) expect(result.ok).toBe(true) if (!result.ok) return @@ -338,29 +357,35 @@ describe("CodeMode output budget", () => { test("truncates an oversized result value with a marker instead of failing", async () => { const limits: ExecutionLimits = { maxOutputBytes: 40 } - const result = await Effect.runPromise(CodeMode.execute({ - code: `return { data: "${"x".repeat(200)}" }`, - limits, - })) + const result = await Effect.runPromise( + CodeMode.execute({ + code: `return { data: "${"x".repeat(200)}" }`, + limits, + }), + ) expect(result.ok).toBe(true) if (!result.ok) return expect(result.truncated).toBe(true) expect(typeof result.value).toBe("string") - expect(result.value).toMatch(/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/) + expect(result.value).toMatch( + /^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/, + ) expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) }) test("keeps leading logs within the remaining budget and marks the cut", async () => { const limits: ExecutionLimits = { maxOutputBytes: 40 } - const result = await Effect.runPromise(CodeMode.execute({ - code: ` + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` console.log("first line") console.log("${"y".repeat(200)}") return "ok" `, - limits, - })) + limits, + }), + ) expect(result.ok).toBe(true) if (!result.ok) return @@ -370,12 +395,14 @@ describe("CodeMode output budget", () => { }) test("does not mark results within the budget", async () => { - const result = await Effect.runPromise(CodeMode.execute({ - code: ` + const result = await Effect.runPromise( + CodeMode.execute({ + code: ` console.log("fits") return { fits: true } `, - })) + }), + ) expect(result).toStrictEqual({ ok: true, value: { fits: true }, @@ -395,18 +422,21 @@ describe("CodeMode schema flexibility", () => { properties: { id: { type: "string" }, count: { type: "number" } }, required: ["id"], }, - run: (input) => Effect.sync(() => { - observed.push(input) - return { echoed: input } - }), + run: (input) => + Effect.sync(() => { + observed.push(input) + return { echoed: input } + }), }) const runtime = CodeMode.make({ tools: { adapter: { call } } }) - expect(runtime.catalog()).toStrictEqual([{ - path: "adapter.call", - description: "Call an adapter-described tool", - signature: "tools.adapter.call(input: { id: string; count?: number }): Promise", - }]) + expect(runtime.catalog()).toStrictEqual([ + { + path: "adapter.call", + description: "Call an adapter-described tool", + signature: "tools.adapter.call(input: { id: string; count?: number }): Promise", + }, + ]) // JSON Schema is render-only: mistyped input passes through unvalidated. const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`)) @@ -421,17 +451,25 @@ describe("CodeMode schema flexibility", () => { input: { type: "object", properties: { login: { type: "string" } }, required: ["login"] }, output: { $ref: "#/$defs/User", - $defs: { User: { type: "object", properties: { login: { type: "string" }, id: { type: "number" } }, required: ["login", "id"] } }, + $defs: { + User: { + type: "object", + properties: { login: { type: "string" }, id: { type: "number" } }, + required: ["login", "id"], + }, + }, }, run: () => Effect.succeed({ login: "kit", id: 7 }), }) const runtime = CodeMode.make({ tools: { users: { lookup } } }) - expect(runtime.catalog()).toStrictEqual([{ - path: "users.lookup", - description: "Look up a user", - signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>", - }]) + expect(runtime.catalog()).toStrictEqual([ + { + path: "users.lookup", + description: "Look up a user", + signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>", + }, + ]) const result = await Effect.runPromise(runtime.execute(`return await tools.users.lookup({ login: "kit" })`)) expect(result.ok).toBe(true) @@ -478,16 +516,20 @@ describe("CodeMode public contract", () => { expect(agentTool.input).toBe(ExecuteInputSchema) expect(agentTool.output).toBe(ExecuteResultSchema) expect(agentTool.description).toBe(runtime.instructions()) - expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(projected) + expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual( + projected, + ) }) test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => { const runtime = CodeMode.make({ tools }) - expect(runtime.catalog()).toStrictEqual([{ - path: "orders.lookup", - description: "Look up an order by ID", - signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>", - }]) + expect(runtime.catalog()).toStrictEqual([ + { + path: "orders.lookup", + description: "Look up an order by ID", + signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>", + }, + ]) expect(runtime.instructions()).toContain("Available tools (COMPLETE list") expect(runtime.instructions()).toContain("- orders (1 tool)") expect(runtime.instructions()).toContain( @@ -502,11 +544,13 @@ describe("CodeMode public contract", () => { expect(result.ok).toBe(true) if (result.ok) { expect(result.value).toStrictEqual({ - items: [{ - path: "tools.orders.lookup", - description: "Look up an order by ID", - signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>", - }], + items: [ + { + path: "tools.orders.lookup", + description: "Look up an order by ID", + signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>", + }, + ], total: 1, }) } @@ -521,31 +565,43 @@ describe("CodeMode public contract", () => { }) const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) - expect(runtime.catalog()).toStrictEqual([{ - path: "context7.resolve-library-id", - description: "Resolve a library ID", - signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise', - }]) - expect(runtime.instructions()).toContain('tools.context7["resolve-library-id"](input: { libraryName: string }): Promise') + expect(runtime.catalog()).toStrictEqual([ + { + path: "context7.resolve-library-id", + description: "Resolve a library ID", + signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise', + }, + ]) + expect(runtime.instructions()).toContain( + 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise', + ) - const search = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`)) + const search = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`), + ) expect(search.ok).toBe(true) if (search.ok) { expect(search.value).toStrictEqual({ - items: [{ - path: 'tools.context7["resolve-library-id"]', - description: "Resolve a library ID", - signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise', - }], + items: [ + { + path: 'tools.context7["resolve-library-id"]', + description: "Resolve a library ID", + signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise', + }, + ], total: 1, }) } - const call = await Effect.runPromise(runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`)) + const call = await Effect.runPromise( + runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`), + ) expect(call.ok).toBe(true) if (call.ok) expect(call.value).toBe("/resolved/TypeScript") - const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`)) + const exact = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`), + ) expect(exact.ok).toBe(true) if (exact.ok) expect((exact.value as { total: number }).total).toBe(1) }) @@ -561,7 +617,9 @@ describe("CodeMode public contract", () => { expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax")) expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list")) // The workflow carries the result-shape guidance; Rules only add content beyond it. - expect(instructions).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string') + expect(instructions).toContain( + '`const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string', + ) expect(instructions).toContain("Return only the fields you need") expect(instructions).toContain("raw payloads get truncated and waste context") expect(instructions).toContain("`const res = await tools..(input)`") @@ -584,8 +642,12 @@ describe("CodeMode public contract", () => { expect(partial).toContain( '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` — short phrases like "list issues" work best.', ) - expect(partial).toContain("Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`") - expect(partial).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') + expect(partial).toContain( + "Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`", + ) + expect(partial).toContain( + '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.', + ) expect(partial).not.toContain("total_count") expect(partial).not.toContain("tools.orders.lookup({") }) @@ -604,7 +666,9 @@ describe("CodeMode public contract", () => { expect(instructions).not.toContain("instanceof Error") expect(instructions).not.toContain("splice") // The data-boundary note survives. - expect(instructions).toContain("Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.") + expect(instructions).toContain( + "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", + ) }) test("zero tools keep minimal sections and the no-tools notice", () => { @@ -635,18 +699,22 @@ describe("CodeMode public contract", () => { tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, discovery: { maxInlineCatalogTokens: 0 }, }) - expect(runtime.instructions()).toContain("Available tools (PARTIAL — 0 of 3 shown; find the rest with tools.$codemode.search)") + expect(runtime.instructions()).toContain( + "Available tools (PARTIAL — 0 of 3 shown; find the rest with tools.$codemode.search)", + ) expect(runtime.instructions()).toContain("- thread (2 tools, none shown)") expect(runtime.instructions()).toContain("- orders (1 tool, none shown)") expect(runtime.instructions()).toMatch(/\$codemode\.search/) expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/) - const result = await Effect.runPromise(runtime.execute(` + const result = await Effect.runPromise( + runtime.execute(` return await tools.$codemode.search({ query: "send message attachment upload file to current Discord thread", limit: 2 }) - `)) + `), + ) expect(result.ok).toBe(true) if (!result.ok) return expect(result.value).toStrictEqual({ @@ -666,19 +734,27 @@ describe("CodeMode public contract", () => { }) expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }]) - const variants = await Effect.runPromise(runtime.execute(` + const variants = await Effect.runPromise( + runtime.execute(` return await Promise.all([ tools.$codemode.search({ query: "file" }), tools.$codemode.search({ query: "image" }) ]) - `)) + `), + ) expect(variants.ok).toBe(true) if (variants.ok) { - expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe("tools.thread.uploadFile") - expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe("tools.thread.generateImage") + expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe( + "tools.thread.uploadFile", + ) + expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe( + "tools.thread.generateImage", + ) } - const removed = await Effect.runPromise(runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`)) + const removed = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`), + ) expect(removed.ok).toBe(false) if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool") }) @@ -706,15 +782,19 @@ describe("CodeMode public contract", () => { } for (const query of ["many.tool13", "tools.many.tool13"]) { - const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`)) + const exact = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`), + ) expect(exact.ok).toBe(true) if (exact.ok) { expect(exact.value).toStrictEqual({ - items: [{ - path: "tools.many.tool13", - description: "Numbered tool 13", - signature: "tools.many.tool13(input: {\n id: string\n}): Promise", - }], + items: [ + { + path: "tools.many.tool13", + description: "Numbered tool 13", + signature: "tools.many.tool13(input: {\n id: string\n}): Promise", + }, + ], total: 1, }) } @@ -737,20 +817,23 @@ describe("CodeMode public contract", () => { }) // Empty query + namespace browses just that namespace, alphabetical by path. - const browse = await Effect.runPromise(runtime.execute( - `return await tools.$codemode.search({ query: "", namespace: "github" })`, - )) + const browse = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`), + ) expect(browse.ok).toBe(true) if (browse.ok) { const value = browse.value as { items: Array<{ path: string }>; total: number } expect(value.total).toBe(2) - expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.create_issue", "tools.github.list_issues"]) + expect(value.items.map((item) => item.path)).toStrictEqual([ + "tools.github.create_issue", + "tools.github.list_issues", + ]) } // A query + namespace ranks within that namespace only. - const scoped = await Effect.runPromise(runtime.execute( - `return await tools.$codemode.search({ query: "issues", namespace: "linear" })`, - )) + const scoped = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`), + ) expect(scoped.ok).toBe(true) if (scoped.ok) { const value = scoped.value as { items: Array<{ path: string }>; total: number } @@ -758,9 +841,9 @@ describe("CodeMode public contract", () => { expect(value.items[0]?.path).toBe("tools.linear.list_issues") } - const invalid = await Effect.runPromise(runtime.execute( - `return await tools.$codemode.search({ query: "issues", namespace: 7 })`, - )) + const invalid = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`), + ) expect(invalid.ok).toBe(false) if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput") }) @@ -785,9 +868,9 @@ describe("CodeMode public contract", () => { // "attachment" appears in neither path nor description — only in the input schema's // property names, which the searchable text includes. - const byParameter = await Effect.runPromise(runtime.execute( - `return await tools.$codemode.search({ query: "attachment" })`, - )) + const byParameter = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`), + ) expect(byParameter.ok).toBe(true) if (byParameter.ok) { const value = byParameter.value as { items: Array<{ path: string }>; total: number } @@ -796,9 +879,9 @@ describe("CodeMode public contract", () => { } // Substring matching: a partial word ("docum") still hits the description. - const bySubstring = await Effect.runPromise(runtime.execute( - `return await tools.$codemode.search({ query: "docum" })`, - )) + const bySubstring = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "docum" })`), + ) expect(bySubstring.ok).toBe(true) if (bySubstring.ok) { const value = bySubstring.value as { items: Array<{ path: string }>; total: number } @@ -825,9 +908,9 @@ describe("CodeMode public contract", () => { }) // "issues" still finds the singular-only tool (term OR singular(term) per field)... - const plural = await Effect.runPromise(runtime.execute( - `return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`, - )) + const plural = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`), + ) expect(plural.ok).toBe(true) if (plural.ok) { const value = plural.value as { items: Array<{ path: string }>; total: number } @@ -836,14 +919,15 @@ describe("CodeMode public contract", () => { } // ...while a true "issues" path match still outranks the singular-only description match. - const ranked = await Effect.runPromise(runtime.execute( - `return await tools.$codemode.search({ query: "issues" })`, - )) + const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`)) expect(ranked.ok).toBe(true) if (ranked.ok) { const value = ranked.value as { items: Array<{ path: string }>; total: number } expect(value.total).toBe(2) - expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.list_issues", "tools.tracker.fetch_all"]) + expect(value.items.map((item) => item.path)).toStrictEqual([ + "tools.github.list_issues", + "tools.tracker.fetch_all", + ]) } }) @@ -882,8 +966,12 @@ describe("CodeMode public contract", () => { run: () => Effect.succeed("ok"), }) const expensive = Tool.make({ - description: "An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime", - input: Schema.Struct({ someRatherLongParameterName: Schema.String, anotherEvenLongerParameterName: Schema.Number }), + description: + "An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime", + input: Schema.Struct({ + someRatherLongParameterName: Schema.String, + anotherEvenLongerParameterName: Schema.Number, + }), output: Schema.String, run: () => Effect.succeed("ok"), }) @@ -896,7 +984,9 @@ describe("CodeMode public contract", () => { }) const instructions = runtime.instructions() - expect(instructions).toContain("Available tools (PARTIAL — 2 of 3 shown; find the rest with tools.$codemode.search)") + expect(instructions).toContain( + "Available tools (PARTIAL — 2 of 3 shown; find the rest with tools.$codemode.search)", + ) expect(instructions).toContain("- alpha (2 tools, 1 shown)") expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise // Cheap") expect(instructions).not.toContain("tools.alpha.expensive(") @@ -912,10 +1002,11 @@ describe("CodeMode public contract", () => { description: "Double a number", input: Schema.Struct({ value: Schema.NumberFromString }), output: Schema.NumberFromString, - run: ({ value }) => Effect.sync(() => { - observed.push(value) - return String(value * 2) - }), + run: ({ value }) => + Effect.sync(() => { + observed.push(value) + return String(value * 2) + }), }) const runtime = CodeMode.make({ tools: { math: { double: transformed } }, @@ -934,9 +1025,11 @@ describe("CodeMode public contract", () => { }) test("returns JSON-safe data and normalizes undefined to null", async () => { - const result = await Effect.runPromise(CodeMode.execute({ - code: `return { top: undefined, nested: [1, undefined] }`, - })) + const result = await Effect.runPromise( + CodeMode.execute({ + code: `return { top: undefined, nested: [1, undefined] }`, + }), + ) expect(result).toStrictEqual({ ok: true, value: { top: null, nested: [1, null] }, @@ -947,18 +1040,20 @@ describe("CodeMode public contract", () => { test("rejects invalid configuration and discovery limits", async () => { expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) - expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(RangeError) + expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow( + RangeError, + ) expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError) expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: -1 } })).toThrow(RangeError) - const result = await Effect.runPromise(CodeMode.make({ - tools, - discovery: { maxInlineCatalogTokens: 0 }, - }).execute( - `return await tools.$codemode.search({ query: "order", limit: 0.5 })`, - )) + const result = await Effect.runPromise( + CodeMode.make({ + tools, + discovery: { maxInlineCatalogTokens: 0 }, + }).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`), + ) expect(result.ok).toBe(false) if (result.ok) return expect(result.error.kind).toBe("InvalidToolInput") @@ -979,14 +1074,16 @@ describe("CodeMode public contract", () => { output: Schema.Number, run: () => Effect.succeed(1), }) - const result = await Effect.runPromise(CodeMode.execute({ - tools: { host: { count: counter } }, - code: ` + const result = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { count: counter } }, + code: ` let total = 0 for (let i = 0; i < 150; i += 1) total += await tools.host.count({}) return total `, - })) + }), + ) expect(result).toMatchObject({ ok: true, value: 150 }) if (result.ok) expect(result.toolCalls.length).toBe(150) }) @@ -1008,8 +1105,6 @@ describe("CodeMode public contract", () => { }) test("reserves the discovery namespace", () => { - expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow( - /reserved for CodeMode discovery tools/, - ) + expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(/reserved for CodeMode discovery tools/) }) }) diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index 981316fd4d..87c075a792 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -36,10 +36,12 @@ const error = async (code: string) => { describe("Object.keys over tool references", () => { test("enumerates top-level namespaces (the transcript program)", async () => { - expect(await value(` + expect( + await value(` const namespaces = Object.keys(tools) return { namespaces, count: namespaces.length } - `)).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 }) + `), + ).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 }) }) test("enumerates tool names at a nested namespace", async () => { @@ -92,7 +94,8 @@ describe("Object.keys over arrays", () => { describe("for...in", () => { test("iterates own enumerable keys of a plain object with break/continue", async () => { - expect(await value(` + expect( + await value(` const seen = [] for (const key in { a: 1, b: 2, c: 3, d: 4 }) { if (key === "b") continue @@ -100,41 +103,50 @@ describe("for...in", () => { seen.push(key) } return seen - `)).toEqual(["a", "c"]) + `), + ).toEqual(["a", "c"]) }) test("iterates index strings over arrays", async () => { - expect(await value(` + expect( + await value(` const indexes = [] for (const i in ["x", "y", "z"]) { if (i === "2") break indexes.push(i) } return indexes - `)).toEqual(["0", "1"]) + `), + ).toEqual(["0", "1"]) }) test("supports let declarations and bare identifiers", async () => { - expect(await value(` + expect( + await value(` let last = "" for (let key in { a: 1, b: 2 }) last = key return last - `)).toBe("b") - expect(await value(` + `), + ).toBe("b") + expect( + await value(` let key = "before" for (key in { only: 1 }) {} return key - `)).toBe("only") + `), + ).toBe("only") }) test("enumerates namespaces and tools from the host tool tree", async () => { - expect(await value(` + expect( + await value(` const names = [] for (const ns in tools) { for (const name in tools[ns]) names.push(ns + "." + name) } return names - `)).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"]) + `), + ).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"]) }) test("unsupported values fail with a hint at for...of and Object.keys", async () => { diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 36b6883cd0..14c58fa004 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -143,21 +143,40 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo describe("Error values and instanceof", () => { test("new Error carries name/message and is instanceof Error", async () => { - expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "boom"]) + expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([ + true, + "Error", + "boom", + ]) }) test("Error without new behaves like new Error", async () => { - expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "plain"]) - expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual(["Error", "", true]) + expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([ + true, + "Error", + "plain", + ]) + expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual([ + "Error", + "", + true, + ]) }) test("specific error types are instanceof themselves and Error, not each other", async () => { - expect(await value(`const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`)).toEqual([true, true, false]) + expect( + await value( + `const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`, + ), + ).toEqual([true, true, false]) expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false) }) test("thrown errors keep instanceof through try/catch", async () => { - expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([true, "x"]) + expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([ + true, + "x", + ]) }) test("interpreter runtime failures are caught as Error values", async () => { @@ -168,33 +187,48 @@ describe("Error values and instanceof", () => { test("caught failures carry the constructor name the real-JS failure would have", async () => { // JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the // message keeps the engine's position detail. - expect(await value(` + expect( + await value(` try { JSON.parse("{oops") } catch (e) { return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")] } - `)).toEqual(["SyntaxError", true, true, false, true]) - expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)) - .toEqual(["ReferenceError", true]) - expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)) - .toEqual(["TypeError", true]) - expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)) - .toEqual(["RangeError", true]) - expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)) - .toEqual(["SyntaxError", true]) - expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)) - .toEqual(["SyntaxError", true]) + `), + ).toEqual(["SyntaxError", true, true, false, true]) + expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)).toEqual([ + "ReferenceError", + true, + ]) + expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)).toEqual([ + "TypeError", + true, + ]) + expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)).toEqual( + ["RangeError", true], + ) + expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([ + "SyntaxError", + true, + ]) + expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([ + "SyntaxError", + true, + ]) }) test("diagnostics without a specific real-JS analogue are named plain Error", async () => { - expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)) - .toEqual(["Error", true]) + expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)).toEqual([ + "Error", + true, + ]) }) test("Promise.allSettled rejection reasons are Error values", async () => { - expect(await value(` + expect( + await value(` const settled = await Promise.allSettled([Promise.reject(new Error("b"))]) return [settled[0].reason instanceof Error, settled[0].reason.message] - `)).toEqual([true, "b"]) + `), + ).toEqual([true, "b"]) }) test("non-error thrown values are not instanceof Error", async () => { @@ -203,7 +237,11 @@ describe("Error values and instanceof", () => { }) test("plain data is never instanceof Error", async () => { - expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([false, false, false]) + expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([ + false, + false, + false, + ]) }) test("error values still serialize as plain { name, message } data", async () => { @@ -227,17 +265,29 @@ describe("Error values and instanceof", () => { describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { test("splice removes in place and returns the removed elements", async () => { - expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ removed: [2, 3], a: [1, 4] }) + expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ + removed: [2, 3], + a: [1, 4], + }) }) test("splice inserts new elements at the cut", async () => { expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"]) - expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({ removed: [2], a: [1, "x", 3] }) + expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({ + removed: [2], + a: [1, "x", 3], + }) }) test("splice with one argument removes to the end; negative start counts back", async () => { - expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ removed: [2, 3], a: [1] }) - expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({ removed: [3], a: [1, 2] }) + expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ + removed: [2, 3], + a: [1], + }) + expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({ + removed: [3], + a: [1, 2], + }) }) test("splice rejects inserting a container into itself", async () => { @@ -258,11 +308,13 @@ describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { test("keys/values/entries return arrays usable with for...of and spread", async () => { expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2]) expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"]) - expect(await value(` + expect( + await value(` const out = [] for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item) return out - `)).toEqual(["0:a", "1:b"]) + `), + ).toEqual(["0:a", "1:b"]) expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]]) }) }) @@ -300,40 +352,33 @@ describe("compound assignment matches its binary operator", () => { } test("sandbox Date += concatenates its string form, like d = d + 1", async () => { - const result = await pair( - `let d = new Date(1000); d += 1; return d`, - `let d = new Date(1000); d = d + 1; return d`, - ) + const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`) expect(result).toBe("1970-01-01T00:00:01.000Z1") }) test("sandbox Date numeric compound ops use its time value", async () => { - expect(await pair( - `let d = new Date(1000); d -= 400; return d`, - `let d = new Date(1000); d = d - 400; return d`, - )).toBe(600) - expect(await pair( - `let d = new Date(1000); d /= 4; return d`, - `let d = new Date(1000); d = d / 4; return d`, - )).toBe(250) + expect( + await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`), + ).toBe(600) + expect(await pair(`let d = new Date(1000); d /= 4; return d`, `let d = new Date(1000); d = d / 4; return d`)).toBe( + 250, + ) }) test("string += object/array matches x = x + obj", async () => { - expect(await pair( - `let x = "a"; x += { b: 1 }; return x`, - `let x = "a"; x = x + { b: 1 }; return x`, - )).toBe("a[object Object]") - expect(await pair( - `let x = "a"; x += [1, 2]; return x`, - `let x = "a"; x = x + [1, 2]; return x`, - )).toBe("a1,2") + expect(await pair(`let x = "a"; x += { b: 1 }; return x`, `let x = "a"; x = x + { b: 1 }; return x`)).toBe( + "a[object Object]", + ) + expect(await pair(`let x = "a"; x += [1, 2]; return x`, `let x = "a"; x = x + [1, 2]; return x`)).toBe("a1,2") }) test("compound assignment through a member target coerces the same way", async () => { - expect(await pair( - `const o = { s: "t" }; o.s += new Date(0); return o.s`, - `const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`, - )).toBe("t1970-01-01T00:00:00.000Z") + expect( + await pair( + `const o = { s: "t" }; o.s += new Date(0); return o.s`, + `const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`, + ), + ).toBe("t1970-01-01T00:00:00.000Z") }) test("numeric and string compound operators sweep identically to their expansions", async () => { diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index ed6b3bb8c8..6283fcd7cd 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -23,7 +23,7 @@ const sleepyTool = (trace: Trace) => input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }), output: Schema.Number, run: ({ id, ms }) => - Effect.gen(function*() { + Effect.gen(function* () { trace.starts.push(id) trace.active += 1 trace.maxActive = Math.max(trace.maxActive, trace.active) @@ -31,10 +31,14 @@ const sleepyTool = (trace: Trace) => trace.active -= 1 trace.completed += 1 return id - }).pipe(Effect.onInterrupt(() => Effect.sync(() => { - trace.active -= 1 - trace.interrupted += 1 - }))), + }).pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + trace.active -= 1 + trace.interrupted += 1 + }), + ), + ), }) const failingTool = Tool.make({ @@ -46,11 +50,13 @@ const failingTool = Tool.make({ const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}): Promise => { const trace = options.trace ?? makeTrace() - return Effect.runPromise(CodeMode.execute({ - tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } }, - code, - ...(options.limits ? { limits: options.limits } : {}), - })) + return Effect.runPromise( + CodeMode.execute({ + tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } }, + code, + ...(options.limits ? { limits: options.limits } : {}), + }), + ) } const value = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => { @@ -121,7 +127,8 @@ describe("first-class promise values", () => { }) test("an awaited failure is catchable exactly like a synchronous throw", async () => { - expect(await value(` + expect( + await value(` const p = tools.host.fail({}) try { await p @@ -129,7 +136,8 @@ describe("first-class promise values", () => { } catch (e) { return e.message } - `)).toBe("Lookup refused") + `), + ).toBe("Lookup refused") }) test("a fire-and-forget call completes before the execution ends", async () => { @@ -186,20 +194,24 @@ describe("promises at data boundaries", () => { describe("Promise.all over arbitrary arrays", () => { test("mixes promises and plain values, preserving order", async () => { - expect(await value(` + expect( + await value(` return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42]) - `)).toEqual([1, "plain", 2, 42]) + `), + ).toEqual([1, "plain", 2, 42]) }) test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => { - expect(await value(` + expect( + await value(` const calls = [] calls.push(tools.host.sleepy({ id: 1 })) calls.push(7) const more = [tools.host.sleepy({ id: 2 })] const batch = [...calls, ...more, "x"] return await Promise.all(batch) - `)).toEqual([1, 7, 2, "x"]) + `), + ).toEqual([1, 7, 2, "x"]) }) test("runs items.map tool calls in parallel", async () => { @@ -238,14 +250,16 @@ describe("Promise.all over arbitrary arrays", () => { }) test("rejects with the first failure, catchable in-program", async () => { - expect(await value(` + expect( + await value(` try { await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})]) return "no" } catch (e) { return e.message } - `)).toBe("Lookup refused") + `), + ).toBe("Lookup refused") }) test("a non-collection argument is a clear error", async () => { @@ -264,14 +278,16 @@ describe("Promise.all over arbitrary arrays", () => { describe("Promise.allSettled", () => { test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => { - expect(await value(` + expect( + await value(` return await Promise.allSettled([ tools.host.sleepy({ id: 5 }), tools.host.fail({}), "plain", Promise.reject(new Error("boom")), ]) - `)).toEqual([ + `), + ).toEqual([ { status: "fulfilled", value: 5 }, { status: "rejected", reason: { name: "Error", message: "Lookup refused" } }, { status: "fulfilled", value: "plain" }, @@ -306,7 +322,8 @@ describe("Promise.race", () => { }) test("awaiting an interrupted loser afterwards is a catchable program failure", async () => { - expect(await value(` + expect( + await value(` const fast = tools.host.sleepy({ id: 1, ms: 10 }) const slow = tools.host.sleepy({ id: 2, ms: 5000 }) const winner = await Promise.race([fast, slow]) @@ -316,23 +333,31 @@ describe("Promise.race", () => { } catch (e) { return { winner, caught: e.message } } - `)).toEqual({ winner: 1, caught: "This tool call was interrupted because another value settled a Promise.race first." }) + `), + ).toEqual({ + winner: 1, + caught: "This tool call was interrupted because another value settled a Promise.race first.", + }) }) test("a rejection can win the race", async () => { - expect(await value(` + expect( + await value(` try { await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })]) return "no" } catch (e) { return e.message } - `)).toBe("Lookup refused") + `), + ).toBe("Lookup refused") }) test("a plain value wins over pending promises", async () => { const trace = makeTrace() - expect(await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace })).toBe("immediate") + expect( + await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }), + ).toBe("immediate") expect(trace.interrupted).toBe(1) }) @@ -350,14 +375,16 @@ describe("Promise.resolve / Promise.reject", () => { }) test("reject produces a promise whose await throws the reason", async () => { - expect(await value(` + expect( + await value(` try { await Promise.reject("nope") return "no" } catch (e) { return e } - `)).toBe("nope") + `), + ).toBe("nope") }) }) diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 252070a069..9c45371d93 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -83,15 +83,9 @@ describe("pretty signature rendering", () => { true, ) expect(pretty).toBe( - [ - "{", - " /** Search filter */", - " filter?: {", - " /** Issue state */", - " state?: string", - " }", - "}", - ].join("\n"), + ["{", " /** Search filter */", " filter?: {", " /** Issue state */", " state?: string", " }", "}"].join( + "\n", + ), ) }) @@ -119,7 +113,14 @@ describe("pretty signature rendering", () => { expect(pretty).toContain(" /** @deprecated */\n legacy?: string") expect(pretty).toContain(" /** @format uri */\n homepage?: string") expect(pretty).toContain( - [" /**", ' * @default ["a","b"]', " * @minItems 2", " * @maxItems 5", " */", " tags?: Array"].join("\n"), + [ + " /**", + ' * @default ["a","b"]', + " * @minItems 2", + " * @maxItems 5", + " */", + " tags?: Array", + ].join("\n"), ) }) @@ -212,7 +213,11 @@ describe("non-identifier property names render as quoted keys", () => { const tool = Tool.make({ description: "Adapter tool with awkward field names", input: rawSchema, - output: { type: "object", properties: { "content-type": { type: "string" } }, required: ["content-type"] } as const, + output: { + type: "object", + properties: { "content-type": { type: "string" } }, + required: ["content-type"], + } as const, run: () => Effect.succeed({ "content-type": "text/plain" }), }) expect(inputTypeScript(tool)).toContain('"foo-bar"?: string') @@ -269,9 +274,9 @@ describe("pretty signatures in search results", () => { const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } }) const search = async (query: string) => { - const result = await Effect.runPromise(runtime.execute( - `return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`, - )) + const result = await Effect.runPromise( + runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`), + ) expect(result.ok).toBe(true) if (!result.ok) throw new Error("search failed") return result.value as { items: Array<{ path: string; signature: string }>; total: number } diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 1dedd1a1e9..ac0e8e2e79 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -40,7 +40,11 @@ describe("Date", () => { }) test("UTC getters read calendar components", async () => { - expect(await value(`const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`)).toEqual([2024, 2, 5, 6, 7, 8, 9]) + expect( + await value( + `const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`, + ), + ).toEqual([2024, 2, 5, 6, 7, 8, 9]) }) test("invalid dates yield NaN times, guardable in-sandbox", async () => { @@ -49,7 +53,9 @@ describe("Date", () => { }) test("toISOString on an invalid date is a catchable error", async () => { - expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe("caught") + expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe( + "caught", + ) }) test("template interpolation renders the ISO form", async () => { @@ -72,14 +78,18 @@ describe("Date", () => { }) test("sorting dates with a numeric comparator", async () => { - expect(await value(` + expect( + await value(` const dates = [new Date(3000), new Date(1000), new Date(2000)] return dates.sort((a, b) => a - b).map((d) => d.getTime()) - `)).toEqual([1000, 2000, 3000]) + `), + ).toEqual([1000, 2000, 3000]) }) test("new Date(year, month, day) accepts component form", async () => { - expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([2024, 0, 2]) + expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([ + 2024, 0, 2, + ]) }) test("typeof and unknown properties are forgiving", async () => { @@ -95,25 +105,31 @@ describe("RegExp", () => { }) test("exec exposes captures and index", async () => { - expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual({ - full: "abb", - group: "bb", - index: 2, - }) + expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual( + { + full: "abb", + group: "bb", + index: 2, + }, + ) expect(await value(`return /a/.exec("zzz")`)).toBeNull() }) test("named groups read through", async () => { - expect(await value(`const m = /(?[a-z]+)-(?\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`)).toBe("ab42") + expect( + await value(`const m = /(?[a-z]+)-(?\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`), + ).toBe("ab42") }) test("global exec advances lastIndex across calls", async () => { - expect(await value(` + expect( + await value(` const r = /\\d+/g const first = r.exec("a1b22c") const second = r.exec("a1b22c") return [first[0], second[0]] - `)).toEqual(["1", "22"]) + `), + ).toEqual(["1", "22"]) }) test("string match: non-global carries index, global lists all matches", async () => { @@ -194,34 +210,51 @@ describe("RegExp", () => { describe("Map", () => { test("get/set/has/size with chaining", async () => { - expect(await value(` + expect( + await value(` const m = new Map() m.set("a", 1).set("b", 2) return { a: m.get("a"), b: m.get("b"), has: m.has("a"), miss: m.get("zz") === undefined, size: m.size } - `)).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 }) + `), + ).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 }) }) test("object keys use identity", async () => { - expect(await value(` + expect( + await value(` const key = { id: 1 } const m = new Map() m.set(key, "hit") return [m.get(key), m.get({ id: 1 }) === undefined] - `)).toEqual(["hit", true]) + `), + ).toEqual(["hit", true]) }) test("construction from entry pairs and another Map", async () => { expect(await value(`const m = new Map([["a", 1], ["b", 2]]); return m.get("b")`)).toBe(2) - expect(await value(`const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`)).toEqual([1, 2, false]) + expect( + await value( + `const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`, + ), + ).toEqual([1, 2, false]) expect((await error(`return new Map("nope")`)).message).toMatch(/\[key, value\] pairs/) expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/) }) test("keys/values/entries return arrays", async () => { - expect(await value(` + expect( + await value(` const m = new Map([["a", 1], ["b", 2]]) return { keys: m.keys(), values: m.values(), entries: m.entries() } - `)).toEqual({ keys: ["a", "b"], values: [1, 2], entries: [["a", 1], ["b", 2]] }) + `), + ).toEqual({ + keys: ["a", "b"], + values: [1, 2], + entries: [ + ["a", 1], + ["b", 2], + ], + }) }) test("Object.fromEntries(map) and Array.from(map)", async () => { @@ -230,13 +263,15 @@ describe("Map", () => { }) test("for...of iterates [key, value] pairs with destructuring", async () => { - expect(await value(` + expect( + await value(` const m = new Map([["a", 1], ["b", 2]]) let total = 0 let names = "" for (const [key, count] of m) { names += key; total += count } return names + total - `)).toBe("ab3") + `), + ).toBe("ab3") }) test("spread produces entry pairs", async () => { @@ -244,32 +279,38 @@ describe("Map", () => { }) test("forEach passes (value, key)", async () => { - expect(await value(` + expect( + await value(` const m = new Map([["a", 1], ["b", 2]]) const seen = [] m.forEach((count, key) => seen.push(key + count)) return seen - `)).toEqual(["a1", "b2"]) + `), + ).toEqual(["a1", "b2"]) }) test("delete and clear", async () => { - expect(await value(` + expect( + await value(` const m = new Map([["a", 1], ["b", 2]]) const removed = m.delete("a") const missed = m.delete("zz") const sizeAfterDelete = m.size m.clear() return [removed, missed, sizeAfterDelete, m.size] - `)).toEqual([true, false, 1, 0]) + `), + ).toEqual([true, false, 1, 0]) }) test("counting idiom: grouped tallies", async () => { - expect(await value(` + expect( + await value(` const words = ["a", "b", "a", "c", "a"] const counts = new Map() for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1) return Object.fromEntries(counts) - `)).toEqual({ a: 3, b: 1, c: 1 }) + `), + ).toEqual({ a: 3, b: 1, c: 1 }) }) test("maps serialize to {} at the boundary, like JSON", async () => { @@ -286,12 +327,14 @@ describe("Map", () => { describe("Set", () => { test("add/has/delete/size with chaining", async () => { - expect(await value(` + expect( + await value(` const s = new Set() s.add(1).add(2).add(1) const removed = s.delete(2) return [s.size, s.has(1), s.has(2), removed] - `)).toEqual([1, true, false, true]) + `), + ).toEqual([1, true, false, true]) }) test("dedupe idiom: [...new Set(items)]", async () => { @@ -308,11 +351,13 @@ describe("Set", () => { }) test("for...of iterates values", async () => { - expect(await value(` + expect( + await value(` let total = 0 for (const n of new Set([1, 2, 3])) total += n return total - `)).toBe(6) + `), + ).toBe(6) }) test("sets serialize to {} at the boundary, like JSON", async () => { @@ -338,21 +383,32 @@ describe("stdlib integration", () => { }) test("dates inside Map values survive in-sandbox reads", async () => { - expect(await value(` + expect( + await value(` const m = new Map([["start", new Date(1000)]]) return m.get("start").getTime() - `)).toBe(1000) + `), + ).toBe(1000) }) test("instanceof recognizes the stdlib value types", async () => { - expect(await value(`return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`)).toEqual([true, true, true, true]) - expect(await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`)).toEqual([true, true, true, false]) + expect( + await value( + `return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`, + ), + ).toEqual([true, true, true, true]) + expect( + await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`), + ).toEqual([true, true, true, false]) expect(await value(`return [new Map() instanceof Set, "s" instanceof Date]`)).toEqual([false, false]) - expect(await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`)).toBe(true) + expect( + await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`), + ).toBe(true) }) test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => { - expect(await value(` + expect( + await value(` const raw = '[{"at":"2024-01-01T05:00:00Z","tag":"a b"},{"at":"2024-01-01T09:00:00Z","tag":"b c"},{"at":"2024-01-02T01:00:00Z","tag":"a"}]' const rows = JSON.parse(raw) const tags = new Set() @@ -363,27 +419,34 @@ describe("stdlib integration", () => { byDay.set(day, (byDay.get(day) ?? 0) + 1) } return { tags: [...tags].sort((a, b) => (a < b ? -1 : 1)), byDay: Object.fromEntries(byDay) } - `)).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } }) + `), + ).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } }) }) }) describe("sandbox values at intra-sandbox checkpoints", () => { test("Object.values/entries keep Dates usable", async () => { expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0) - expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe("d:0") + expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe( + "d:0", + ) }) test("Object.assign keeps Maps usable", async () => { - expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe(1) + expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe( + 1, + ) }) test("object and array spread keep sandbox values usable", async () => { - expect(await value(` + expect( + await value(` const src = { m: new Map([["a", 1]]) } const copy = { ...src } copy.m.set("b", 2) return [copy.m.get("a"), src.m.get("b")] - `)).toEqual([1, 2]) + `), + ).toEqual([1, 2]) expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000) }) @@ -404,7 +467,10 @@ describe("sandbox values at intra-sandbox checkpoints", () => { }) test("the host boundary still serializes JSON forms: results, JSON.stringify, and tool arguments", async () => { - expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({ d: "1970-01-01T00:00:00.000Z", m: {} }) + expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({ + d: "1970-01-01T00:00:00.000Z", + m: {}, + }) expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}') const observed: Array = [] @@ -417,10 +483,12 @@ describe("sandbox values at intra-sandbox checkpoints", () => { return "ok" }), }) - const result = await Effect.runPromise(CodeMode.execute({ - tools: { host: { capture } }, - code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`, - })) + const result = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { capture } }, + code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`, + }), + ) expect(result.ok).toBe(true) expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }]) }) diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 3508ee981c..0529c70217 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -206,10 +206,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre }) function resolveTools(input: Pick) { - const visible = Permission.visibleTools( - input.tools, - Permission.merge(input.agent.permission, input.permission ?? []), - ) + const visible = Permission.visibleTools(input.tools, Permission.merge(input.agent.permission, input.permission ?? [])) return Record.filter(visible, (_, k) => input.user.tools?.[k] !== false) } diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index bef135b03a..c7e328c7da 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -104,7 +104,8 @@ export function groupByServer( const byLongest = [...servers].sort((a, b) => b.length - a.length) const groups = new Map() for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) { - const server = byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key) + const server = + byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key) const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key const def = mcpDefs[key] const entry: CatalogEntry = { @@ -129,7 +130,9 @@ export function buildCatalog( mcpDefs: Record, servers: readonly string[], ): CatalogEntry[] { - return [...groupByServer(mcpTools, servers, mcpDefs).values()].flat().filter((entry) => entry.tool.execute !== undefined) + return [...groupByServer(mcpTools, servers, mcpDefs).values()] + .flat() + .filter((entry) => entry.tool.execute !== undefined) } /** @@ -334,7 +337,8 @@ export const CodeModeTool = Tool.define( const collect = (attachment: Attachment) => void attachments.push(attachment) // 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 = () => ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) + const publish = () => + ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) // One CodeMode tool per MCP tool, running the same shared middle as legacy // per-tool registration (McpInvoke.invoke: plugin before hook → permission diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 98224a6d1a..a7456200b4 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -276,7 +276,10 @@ const layer = Layer.effect( // fresh per turn so it tracks live tool-list changes. Hard-denied tools (the shared // Permission.visibleTools predicate over the agent's ruleset) never enter the // catalog, its inlined signatures, or the in-program search index. - const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) { + const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* ( + agent: Agent.Info, + permission?: PermissionV1.Ruleset, + ) { const visible = Permission.visibleTools(yield* mcp.tools(), Permission.merge(agent.permission, permission ?? [])) const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize) return catalogInstructions(visible, yield* mcp.defs(), servers) @@ -341,7 +344,6 @@ const layer = Layer.effect( }), ) - function isZodType(value: unknown): value is z.ZodType { return typeof value === "object" && value !== null && "_zod" in value } diff --git a/packages/opencode/test/session/tools.test.ts b/packages/opencode/test/session/tools.test.ts index 8c8e439a52..d4b31c8843 100644 --- a/packages/opencode/test/session/tools.test.ts +++ b/packages/opencode/test/session/tools.test.ts @@ -96,8 +96,7 @@ function resolveTools(trigger?: Plugin.Interface["trigger"]) { Layer.mergeAll( Layer.mock(Permission.Service, { ask: () => Effect.void }), Layer.mock(Plugin.Service, { - trigger: - trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]), + trigger: trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]), }), Layer.mock(Truncate.Service, { output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index c2cec6c668..5a1a625204 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -21,8 +21,7 @@ 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 PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" const SERVER = "fixtures" @@ -163,8 +162,8 @@ async function buildTool() { // this real in-memory server listed — the same snapshot shape the live service returns. const layer = Layer.mergeAll( Layer.mock(Plugin.Service, { - trigger: (((_name: unknown, _input: unknown, output: unknown) => - Effect.succeed(output)) as Plugin.Interface["trigger"]), + trigger: ((_name: unknown, _input: unknown, output: unknown) => + Effect.succeed(output)) as Plugin.Interface["trigger"], }), Layer.mock(Truncate.Service, { output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), @@ -196,9 +195,7 @@ describe("code mode integration (real MCP server)", () => { test("the appended catalog inlines full signatures with real MCP schemas", () => { expect(description).toContain("Available tools (COMPLETE list") expect(description).toContain("- fixtures (4 tools)") - expect(description).toContain( - "tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>", - ) + expect(description).toContain("tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>") expect(description).toContain("tools.fixtures.get_text(input: { name: string }): Promise") expect(description).toContain("// Add two numbers and return the structured sum") // Small catalog: everything is inline, so no discovery tool is advertised. diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index c4ded36904..a954beb598 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -193,7 +193,9 @@ describe("code mode execute", () => { // never cherry-picks a catalog tool or fabricates result fields. expect(description).toContain("## Workflow") expect(description).toContain("1. Pick a tool from the list under `## Available tools`") - expect(description).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string') + expect(description).toContain( + '`const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string', + ) expect(description).toContain("Return only the fields you need") expect(description).not.toContain("total_count") }) @@ -249,7 +251,9 @@ describe("code mode execute", () => { expect(description).toContain("tools.$codemode.search(") // PARTIAL catalogs put search first in the workflow and advertise namespace browsing. expect(description).toContain("1. Find a tool (skip when it is already listed below)") - expect(description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') + expect(description).toContain( + '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.', + ) expect(description).not.toContain("total_count") // All op lines cost the same estimated tokens (chars/4 rounds away the 1- vs 3-digit // name difference), so the path tiebreak decides: the lexicographically-first ops made @@ -290,7 +294,10 @@ describe("code mode execute", () => { linear_search: mcpTool("search", () => ""), }) const output = await Effect.runPromise( - tool.execute({ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" }, ctx), + tool.execute( + { code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" }, + ctx, + ), ) expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear"], count: 2 }) }) @@ -565,9 +572,7 @@ describe("code mode execute", () => { const tool = await build({ shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })), }) - const out = await Effect.runPromise( - tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx), - ) + const out = await Effect.runPromise(tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx)) expect(out.output).toBe("captured") expect(out.attachments).toHaveLength(1) }) @@ -690,9 +695,7 @@ describe("code mode permission visibility", () => { expect(called).toEqual([]) // The rest of the namespace still works. - const allowed = await Effect.runPromise( - tool.execute({ code: "return await tools.github.list_issues({})" }, ctx), - ) + const allowed = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, ctx)) expect(allowed.metadata.error).toBeUndefined() expect(allowed.output).toBe("ok") }) @@ -706,9 +709,7 @@ describe("code mode permission visibility", () => { ["github"], [askRule("github_list_issues")], ) - const out = await Effect.runPromise( - tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx), - ) + const out = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx)) expect(out.output).toBe("ok") expect(asked).toEqual(["github_list_issues"]) }) @@ -733,16 +734,21 @@ describe("toSandboxResult", () => { test("prefers structuredContent over text", () => { const { collect } = collector() - expect(toSandboxResult({ structuredContent: { x: 1 }, content: [{ type: "text", text: "hi" }] }, collect)).toEqual( - { x: 1 }, - ) + expect(toSandboxResult({ structuredContent: { x: 1 }, content: [{ type: "text", text: "hi" }] }, collect)).toEqual({ + x: 1, + }) }) test("joins text content when no structured content is present", () => { const { collect } = collector() expect( toSandboxResult( - { content: [{ type: "text", text: "one" }, { type: "text", text: "two" }] }, + { + content: [ + { type: "text", text: "one" }, + { type: "text", text: "two" }, + ], + }, collect, ), ).toBe("one\ntwo") diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 13167e6d6a..7462fcc2d7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2355,7 +2355,13 @@ function Execute(props: ToolProps) { return ( <>