diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b419e99640..06bc2b8d6f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,10 +12,12 @@ updates: update-types: - minor - patch - # packages/code-mode depends on the typescript JS API (transpileModule, - # ScriptTarget, …) at runtime; the 6.0+ native compiler removed those - # exports, so the package stays pinned to 5.9. The workspace root already - # compiles on 7.x, so ignoring >=6 only affects that runtime pin. + # Retained from when an in-house Code Mode transpiler used the typescript + # JS API (transpileModule, ScriptTarget, …) at runtime and had to stay on + # 5.9, since the 6.0+ native compiler removed those exports. That + # transpiler is gone — @ai-sdk/code-mode owns transpilation now, and no + # workspace pins typescript — so this ignore no longer has a subject. + # Dropping it is a dependency-policy call, not this refactor's to make. ignore: - dependency-name: 'typescript' versions: diff --git a/.github/workflows/windows-sandbox-w0.yml b/.github/workflows/windows-sandbox-w0.yml index 605da92129..79fee8bb56 100644 --- a/.github/workflows/windows-sandbox-w0.yml +++ b/.github/workflows/windows-sandbox-w0.yml @@ -77,8 +77,6 @@ jobs: run: npm run build --workspace @maka/core - name: Build storage workspace run: npm run build --workspace @maka/storage - - name: Build code-mode workspace - run: npm run build --workspace @maka/code-mode - name: Build runtime workspace run: npm run build --workspace @maka/runtime - name: Run real filesystem-worker operations through the broker diff --git a/apps/desktop/package.json b/apps/desktop/package.json index dd472b0d88..f630fbd09b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,7 +25,7 @@ "build:preload": "esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --outfile=dist/preload/preload.cjs --external:electron", "build:overlay": "node ../../scripts/build-cursor-overlay.mjs", "build:renderer": "vite build && node ../../scripts/check-third-party-notices.mjs", - "build:workspace-deps": "npm --workspace @maka/code-mode run build && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/ui run build", + "build:workspace-deps": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/ui run build", "package:macos-arm64": "electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never", "package:windows-x64": "electron-builder --config electron-builder.config.mjs --win --x64 --publish never", "typecheck": "tsc -p tsconfig.preload.json --noEmit && tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.renderer.json --noEmit && tsc -p tsconfig.storybook.json --noEmit", diff --git a/package-lock.json b/package-lock.json index 6a1515294d..9e1f4ff1d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ - "packages/code-mode", "packages/core", "packages/storage", "packages/mcp", @@ -139,6 +138,21 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/code-mode": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/code-mode/-/code-mode-1.0.23.tgz", + "integrity": "sha512-MUxwboIp8+Zf/aY7P5pUg3ytGFzjCX2FSTMvvNCqFtpqprx+afLzEljKhSrZjc/tBgoXv7I3dE/h9vDeM9HMBA==", + "license": "Apache-2.0", + "dependencies": { + "run": "^2.0.0" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "ai": "7.0.66" + } + }, "node_modules/@ai-sdk/cohere": { "version": "4.0.27", "resolved": "https://registry.npmjs.org/@ai-sdk/cohere/-/cohere-4.0.27.tgz", @@ -155,6 +169,23 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.52", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.52.tgz", + "integrity": "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/google": { "version": "4.0.44", "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-4.0.44.tgz", @@ -2230,10 +2261,6 @@ "ws": "^8.19.0" } }, - "node_modules/@maka/code-mode": { - "resolved": "packages/code-mode", - "link": true - }, "node_modules/@maka/computer-use": { "resolved": "packages/computer-use", "link": true @@ -4951,6 +4978,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ai": { + "version": "7.0.66", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.66.tgz", + "integrity": "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.52", + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -13590,64 +13634,6 @@ "maka-agent": "dist/cli.js" } }, - "packages/code-mode": { - "name": "@maka/code-mode", - "version": "0.1.0", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/code-mode": "1.0.23", - "ai": "7.0.66" - } - }, - "packages/code-mode/node_modules/@ai-sdk/code-mode": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/@ai-sdk/code-mode/-/code-mode-1.0.23.tgz", - "integrity": "sha512-MUxwboIp8+Zf/aY7P5pUg3ytGFzjCX2FSTMvvNCqFtpqprx+afLzEljKhSrZjc/tBgoXv7I3dE/h9vDeM9HMBA==", - "license": "Apache-2.0", - "dependencies": { - "run": "^2.0.0" - }, - "engines": { - "node": ">=22.13.0" - }, - "peerDependencies": { - "ai": "7.0.66" - } - }, - "packages/code-mode/node_modules/@ai-sdk/gateway": { - "version": "4.0.52", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.52.tgz", - "integrity": "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "4.0.7", - "@ai-sdk/provider-utils": "5.0.27", - "@vercel/oidc": "3.2.0" - }, - "engines": { - "node": ">=22" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4.1.8" - } - }, - "packages/code-mode/node_modules/ai": { - "version": "7.0.66", - "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.66.tgz", - "integrity": "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/gateway": "4.0.52", - "@ai-sdk/provider": "4.0.7", - "@ai-sdk/provider-utils": "5.0.27" - }, - "engines": { - "node": ">=22" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4.1.8" - } - }, "packages/computer-use": { "name": "@maka/computer-use", "version": "0.1.0", @@ -13702,13 +13688,13 @@ "license": "Apache-2.0", "dependencies": { "@ai-sdk/anthropic": "4.0.39", + "@ai-sdk/code-mode": "1.0.23", "@ai-sdk/cohere": "4.0.27", "@ai-sdk/google": "4.0.44", "@ai-sdk/open-responses": "2.0.28", "@ai-sdk/openai": "4.0.42", "@ai-sdk/openai-compatible": "3.0.30", "@larksuiteoapi/node-sdk": "1.72.0", - "@maka/code-mode": "0.1.0", "@maka/core": "0.1.0", "@modelcontextprotocol/sdk": "1.30.0", "@mozilla/readability": "^0.6.0", @@ -13756,23 +13742,6 @@ "electron": "^43.2.0" } }, - "packages/runtime/node_modules/@ai-sdk/gateway": { - "version": "4.0.52", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.52.tgz", - "integrity": "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "4.0.7", - "@ai-sdk/provider-utils": "5.0.27", - "@vercel/oidc": "3.2.0" - }, - "engines": { - "node": ">=22" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4.1.8" - } - }, "packages/runtime/node_modules/@slack/logger": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-5.0.0.tgz", @@ -13854,23 +13823,6 @@ "node": ">= 20" } }, - "packages/runtime/node_modules/ai": { - "version": "7.0.66", - "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.66.tgz", - "integrity": "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/gateway": "4.0.52", - "@ai-sdk/provider": "4.0.7", - "@ai-sdk/provider-utils": "5.0.27" - }, - "engines": { - "node": ">=22" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4.1.8" - } - }, "packages/runtime/node_modules/https-proxy-agent": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", diff --git a/package.json b/package.json index bd50c44656..4d7e641777 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "packageManager": "npm@11.19.0", "type": "module", "workspaces": [ - "packages/code-mode", "packages/core", "packages/storage", "packages/mcp", @@ -33,8 +32,8 @@ "dev": "npm --workspace @maka/desktop run dev:hmr --", "dev:full": "npm run build && npm --workspace @maka/desktop run start", "cli:dev": "node packages/cli/dist/dev-cli.js", - "build": "npm --workspace @maka/code-mode run build && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build", - "build:test": "npm run clean && npm --workspace @maka/code-mode run build && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build:test", + "build": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build", + "build:test": "npm run clean && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build:test", "clean": "node scripts/clean-build.mjs", "rebuild": "npm run clean && npm run build", "check:stale": "node scripts/check-stale-dist.mjs", diff --git a/packages/cli/package.json b/packages/cli/package.json index ca31453ef4..682b2234d9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,7 +13,7 @@ "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", "build": "tsc -p tsconfig.json && node scripts/chmod-bin.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", - "pretest": "npm --workspace @maka/code-mode run build && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/eval run build", + "pretest": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/eval run build", "test": "npm run clean && npm run build && npm run test:dist", "test:dist": "node --test \"dist/**/*.test.js\"" }, diff --git a/packages/code-mode/package.json b/packages/code-mode/package.json deleted file mode 100644 index c259f777d8..0000000000 --- a/packages/code-mode/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@maka/code-mode", - "version": "0.1.0", - "license": "Apache-2.0", - "description": "QuickJS sandbox for bounded agent tool orchestration.", - "type": "module", - "private": true, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": "./dist/index.js" - }, - "scripts": { - "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test:dist": "node --test \"dist/**/*.test.js\"", - "test": "npm run clean && npm run build && npm run test:dist" - }, - "dependencies": { - "@ai-sdk/code-mode": "1.0.23", - "ai": "7.0.66" - } -} diff --git a/packages/code-mode/tsconfig.json b/packages/code-mode/tsconfig.json deleted file mode 100644 index b9ebcd2dca..0000000000 --- a/packages/code-mode/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "composite": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/core/package.json b/packages/core/package.json index 9906819742..aa51d1cdde 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,6 +42,7 @@ "./permission-profile": "./dist/permission-profile.js", "./windows-path": "./dist/windows-path.js", "./sandbox-boundary": "./dist/sandbox-boundary.js", + "./serialized-byte-length": "./dist/serialized-byte-length.js", "./permission-profile-compiler": "./dist/permission-profile-compiler.js", "./user-question": "./dist/user-question.js", "./connections": "./dist/connections.js", diff --git a/packages/core/src/__tests__/serialized-byte-length.test.ts b/packages/core/src/__tests__/serialized-byte-length.test.ts new file mode 100644 index 0000000000..5eced4a5c3 --- /dev/null +++ b/packages/core/src/__tests__/serialized-byte-length.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { serializedByteLength } from '../serialized-byte-length.js'; + +test('counts the bounded JSON representation used at the tool boundary', () => { + let inspectedPastLimit = false; + const trailing = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { + inspectedPastLimit = true; + throw new Error('must not inspect values after the byte limit'); + }, + }); + + assert.equal(serializedByteLength('\0'.repeat(10)), 62); + assert.equal(serializedByteLength(['x'.repeat(128), trailing], 32), 33); + assert.equal(inspectedPastLimit, false); +}); + +test('agrees with JSON.stringify on the payloads validation bounds', () => { + const payloads: unknown[] = [ + { fileSystem: { entries: [{ path: '/tmp/工作区', scope: 'subtree', access: 'write' }] } }, + { network: { enabled: true } }, + { kind: 'managed', profile: { roots: [] }, revision: 3 }, + // Every two-byte escape, so dropping one from the escape set and falling + // back to \\uXXXX shows up as a byte-count disagreement. + { text: 'tab\tnewline\nreturn\rbackspace\bformfeed\fquote"backslash\\ emoji😀 lone\ud800' }, + [], + {}, + ]; + + for (const payload of payloads) { + assert.equal( + serializedByteLength(payload), + new TextEncoder().encode(JSON.stringify(payload)).byteLength, + JSON.stringify(payload), + ); + } +}); + +test('pins the deliberate departure from JSON.stringify at the top level', () => { + // `JSON.stringify(undefined)` is unrepresentable, but an absent result is not + // an oversized one: four bytes conservatively bounds what callers publish in + // its place. Reporting infinity here would read downstream as "result too + // large". + assert.equal(serializedByteLength(undefined), 4); + assert.equal(serializedByteLength(null), 4); +}); + +test('reports values JSON cannot represent as unrepresentable', () => { + const circular: Record = {}; + circular.self = circular; + + for (const value of [ + () => 'x', + Symbol('unserializable'), + 1n, + circular, + { toJSON: () => 'x' }, + new Date(0), + new (class Instance {})(), + ]) { + assert.equal(serializedByteLength(value), Number.POSITIVE_INFINITY, String(value?.toString())); + } +}); diff --git a/packages/core/src/additional-permissions.ts b/packages/core/src/additional-permissions.ts index 2765c0a428..4eaa8797f9 100644 --- a/packages/core/src/additional-permissions.ts +++ b/packages/core/src/additional-permissions.ts @@ -192,12 +192,6 @@ function trimTrailingSlashes(value: string): string { return trimTrailingPathSeparators(value); } -function serializedByteLength(value: unknown): number { - const json = JSON.stringify(value); - if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(json).byteLength; - return json.length; -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -218,3 +212,4 @@ import { samePath, trimTrailingPathSeparators, } from './absolute-path.js'; +import { serializedByteLength } from './serialized-byte-length.js'; diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index 85b4aed3e1..80944d359d 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -18,6 +18,7 @@ import { type PermissionProfileManaged, type PermissionProfileMatchContext, } from './permission-profile.js'; +import { serializedByteLength } from './serialized-byte-length.js'; export const SANDBOX_BOUNDARY_ACCESS_MODES = ['read', 'write'] as const; export type SandboxBoundaryAccess = (typeof SANDBOX_BOUNDARY_ACCESS_MODES)[number]; @@ -752,12 +753,6 @@ function trimTrailingSlashes(value: string): string { return trimTrailingPathSeparators(value); } -function serializedByteLength(value: unknown): number { - const json = JSON.stringify(value); - if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(json).byteLength; - return json.length; -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/packages/code-mode/src/index.ts b/packages/core/src/serialized-byte-length.ts similarity index 54% rename from packages/code-mode/src/index.ts rename to packages/core/src/serialized-byte-length.ts index e436bda4a0..0e94903c5b 100644 --- a/packages/code-mode/src/index.ts +++ b/packages/core/src/serialized-byte-length.ts @@ -1,11 +1,21 @@ -export interface CodeModeToolDefinition { - name: string; -} - /** - * The byte definition shared by Code Mode and Runtime's nested-result - * publication boundary. JSON is the representation that is persisted and - * returned to the cell, including quotes and escapes for strings. + * The single serialized-byte fact for the repository: how many UTF-8 bytes the + * JSON representation of a value occupies, including quotes and escapes. Used + * wherever a payload is bounded before it is persisted or published. + * + * Counting stops as soon as `maxBytes` is exceeded, so callers can bound + * untrusted input without materializing it. Values JSON cannot faithfully + * represent (a circular reference, a `toJSON` hook, a non-plain prototype) + * report `Number.POSITIVE_INFINITY` rather than a count. + * + * One deliberate departure from `JSON.stringify`, which returns `undefined` + * for a top-level `undefined`: that case is counted as four bytes rather than + * reported as unrepresentable. An absent result is not an oversized one, and + * four bytes is a conservative bound on what callers publish in its place — a + * Code Mode cell substitutes `null` through `value ?? null`, and a tool result + * becomes empty text through result-content coercion. Reporting infinity would + * make a tool that simply returned nothing fail its result-byte bound as though + * the result were too large. */ export function serializedByteLength(value: unknown, maxBytes = Number.POSITIVE_INFINITY): number { const limit = @@ -18,7 +28,6 @@ export function serializedByteLength(value: unknown, maxBytes = Number.POSITIVE_ } return budget.bytes; } - interface SerializedByteBudget { bytes: number; limit: number; @@ -145,127 +154,3 @@ function addSerializedBytes(budget: SerializedByteBudget, bytes: number): boolea budget.bytes += bytes; return true; } - -export interface CodeModeLimits { - maxSourceBytes: number; - /** Sandbox invocation deadline; aborted host operations still drain before settlement. */ - maxSandboxTimeMs: number; - maxMemoryBytes: number; - maxStackBytes: number; - maxToolCalls: number; - maxToolConcurrency: number; - maxToolInputBytes: number; - maxToolOutputBytes: number; - maxOutputBytes: number; -} - -export const DEFAULT_CODE_MODE_LIMITS: Readonly = Object.freeze({ - maxSourceBytes: 64 * 1024, - maxSandboxTimeMs: 30_000, - maxMemoryBytes: 64 * 1024 * 1024, - maxStackBytes: 2 * 1024 * 1024, - maxToolCalls: 32, - maxToolConcurrency: 8, - maxToolInputBytes: 1024 * 1024, - maxToolOutputBytes: 1024 * 1024, - maxOutputBytes: 1024 * 1024, -}); - -export type CodeModeDiagnosticKind = - | 'parse_error' - | 'execution_error' - | 'unknown_tool' - | 'limit_exceeded' - | 'tool_failure'; - -export interface CodeModeDiagnostic { - kind: CodeModeDiagnosticKind; - message: string; -} - -export interface CodeModeToolCall { - index: number; - name: string; -} - -export interface CodeModeExecutionSuccess { - ok: true; - value: unknown; - toolCalls: CodeModeToolCall[]; -} - -export interface CodeModeExecutionFailure { - ok: false; - error: CodeModeDiagnostic; - toolCalls: CodeModeToolCall[]; -} - -export type CodeModeExecutionResult = CodeModeExecutionSuccess | CodeModeExecutionFailure; - -export interface ExecuteCodeCellInput { - code: string; - tools: readonly CodeModeToolDefinition[]; - callTool(name: string, input: unknown, signal: AbortSignal): Promise; - isFatalToolError?: (error: unknown) => boolean; - signal?: AbortSignal; - limits?: Partial; -} - -interface QueuedCodeCell { - input: ExecuteCodeCellInput; - resolve: (result: CodeModeExecutionResult) => void; - reject: (error: unknown) => void; - onAbort?: () => void; -} - -let queuedCodeCell: QueuedCodeCell | undefined; -let codeCellActive = false; - -export async function executeCodeCell( - input: ExecuteCodeCellInput, -): Promise { - if (input.signal?.aborted) throw input.signal.reason ?? abortError(); - return new Promise((resolve, reject) => { - const entry: QueuedCodeCell = { input, resolve, reject }; - if (codeCellActive) { - if (queuedCodeCell) { - resolve({ - ok: false, - error: { kind: 'limit_exceeded', message: 'Code Mode execution queue is full' }, - toolCalls: [], - }); - return; - } - const onAbort = () => { - if (queuedCodeCell !== entry) return; - queuedCodeCell = undefined; - reject(input.signal?.reason ?? abortError()); - }; - entry.onAbort = onAbort; - input.signal?.addEventListener('abort', onAbort, { once: true }); - queuedCodeCell = entry; - return; - } - codeCellActive = true; - runCodeCell(entry); - }); -} - -function runCodeCell(entry: QueuedCodeCell): void { - if (entry.onAbort) entry.input.signal?.removeEventListener('abort', entry.onAbort); - void executeCodeCellImpl(entry.input) - .then(entry.resolve, entry.reject) - .finally(() => { - const next = queuedCodeCell; - queuedCodeCell = undefined; - if (next) runCodeCell(next); - else codeCellActive = false; - }); -} - -function abortError(): Error { - const error = new Error('Code Mode cell aborted'); - error.name = 'AbortError'; - return error; -} -import { executeCodeCellImpl } from './quickjs.js'; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 82ef18114a..242efdaee2 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -125,13 +125,13 @@ }, "dependencies": { "@ai-sdk/anthropic": "4.0.39", + "@ai-sdk/code-mode": "1.0.23", "@ai-sdk/cohere": "4.0.27", "@ai-sdk/google": "4.0.44", "@ai-sdk/open-responses": "2.0.28", "@ai-sdk/openai": "4.0.42", "@ai-sdk/openai-compatible": "3.0.30", "@larksuiteoapi/node-sdk": "1.72.0", - "@maka/code-mode": "0.1.0", "@maka/core": "0.1.0", "@modelcontextprotocol/sdk": "1.30.0", "@mozilla/readability": "^0.6.0", diff --git a/packages/runtime/src/__tests__/child-agent-run-limiter.test.ts b/packages/runtime/src/__tests__/admission-limiter.test.ts similarity index 97% rename from packages/runtime/src/__tests__/child-agent-run-limiter.test.ts rename to packages/runtime/src/__tests__/admission-limiter.test.ts index 82c83002f2..b93fa77bdb 100644 --- a/packages/runtime/src/__tests__/child-agent-run-limiter.test.ts +++ b/packages/runtime/src/__tests__/admission-limiter.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; import type { SessionHeader } from '@maka/core/session'; -import { ChildAgentRunLimiter } from '../child-agent-run-limiter.js'; +import { AdmissionLimiter } from '../admission-limiter.js'; import { MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN, ToolRuntime, @@ -11,9 +11,9 @@ import { type MakaToolContext, } from '../tool-runtime.js'; -describe('ChildAgentRunLimiter', () => { +describe('AdmissionLimiter', () => { test('grants waiting permits in FIFO order and makes release idempotent', async () => { - const limiter = new ChildAgentRunLimiter(1); + const limiter = new AdmissionLimiter(1); const first = await limiter.acquire(new AbortController().signal); const grants: string[] = []; const secondPending = limiter.acquire(new AbortController().signal).then((permit) => { @@ -44,7 +44,7 @@ describe('ChildAgentRunLimiter', () => { }); test('removes an aborted waiter without consuming capacity', async () => { - const limiter = new ChildAgentRunLimiter(1); + const limiter = new AdmissionLimiter(1); const first = await limiter.acquire(new AbortController().signal); const waitingController = new AbortController(); const waiting = limiter.acquire(waitingController.signal); @@ -59,7 +59,7 @@ describe('ChildAgentRunLimiter', () => { }); test('closes the turn scope for queued and future permits', async () => { - const limiter = new ChildAgentRunLimiter(1); + const limiter = new AdmissionLimiter(1); const first = await limiter.acquire(new AbortController().signal); const second = limiter.acquire(new AbortController().signal); const third = limiter.acquire(new AbortController().signal); diff --git a/packages/runtime/src/__tests__/code-mode-backend.test.ts b/packages/runtime/src/__tests__/code-mode-backend.test.ts index 7f9a40175f..eca833c772 100644 --- a/packages/runtime/src/__tests__/code-mode-backend.test.ts +++ b/packages/runtime/src/__tests__/code-mode-backend.test.ts @@ -186,6 +186,72 @@ test('denies a nested MCP call before invoking its provider', async () => { assert.equal(providerCalls, 0); }); +test('bounds cells outstanding on one backend, across the host drain', async () => { + // The guarantee under test is the wiring, not the primitive: the permit is + // taken before the cell and released only once `executeCodeCell` settles, + // which is after its host operations have drained. Replacing the admission + // with a no-op must fail this test. + let firstToolStarted!: () => void; + let releaseFirstTool!: () => void; + const firstToolRunning = new Promise((resolve) => { + firstToolStarted = resolve; + }); + const firstToolCanFinish = new Promise((resolve) => { + releaseFirstTool = resolve; + }); + let toolCalls = 0; + + const instance = backend( + execEveryTurnModel('return await tools.lookup({ id: "nested" })'), + [], + undefined, + { + tools: [ + { + name: 'lookup', + description: 'Look up a node', + parameters: z.object({ id: z.string() }), + impl: async (input: { id: string }) => { + toolCalls += 1; + if (toolCalls === 1) { + firstToolStarted(); + await firstToolCanFinish; + } + return input; + }, + }, + ], + }, + ); + + const cell = (turnId: string) => + collect(instance.send({ turnId, text: 'look it up', context: [], toolMode: 'code_mode' })); + + const first = cell('turn-1'); + await firstToolRunning; + + const second = cell('turn-2'); + const third = cell('turn-3'); + + // The third cell finds a cell active and one already queued, so it is turned + // away without ever reaching the sandbox. + const thirdEvents = await third; + const turnedAway = thirdEvents.find( + (event): event is Extract => + event.type === 'tool_result' && event.toolUseId === 'exec-3', + ); + assert.ok(turnedAway, 'the third cell should settle its exec call'); + assert.match(JSON.stringify(turnedAway.content), /limit_exceeded/); + + // The second cell is queued behind the first, which is still holding a host + // operation, so it has not started one of its own. + assert.equal(toolCalls, 1, 'a queued cell must not start host work'); + + releaseFirstTool(); + await Promise.all([first, second]); + assert.equal(toolCalls, 2, 'the queued cell runs once the first releases'); +}); + test('routes a nested cell call back through ToolRuntime', async () => { const implementationCalls: unknown[] = []; const events = await collect( @@ -939,6 +1005,31 @@ function execThenStopModel( }); } +function execEveryTurnModel(code: string): MockLanguageModelV4 { + let call = 0; + return new MockLanguageModelV4({ + doStream: async () => { + call += 1; + return { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: `exec-${call}`, + toolName: 'exec', + input: JSON.stringify({ code }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: ZERO_USAGE, + }, + ]), + }; + }, + }); +} + function capturingModel(captured: string[][]): MockLanguageModelV4 { return new MockLanguageModelV4({ doStream: async ({ tools }) => { diff --git a/packages/code-mode/src/__tests__/code-mode.test.ts b/packages/runtime/src/__tests__/code-mode.test.ts similarity index 74% rename from packages/code-mode/src/__tests__/code-mode.test.ts rename to packages/runtime/src/__tests__/code-mode.test.ts index 96669475e3..149267be9c 100644 --- a/packages/code-mode/src/__tests__/code-mode.test.ts +++ b/packages/runtime/src/__tests__/code-mode.test.ts @@ -1,7 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; import { jsonSchema, tool } from 'ai'; -import { type ExecuteCodeCellInput, executeCodeCell, serializedByteLength } from '../index.js'; +import { + DEFAULT_CODE_MODE_EXECUTION_POLICY, + type ExecuteCodeCellInput, + executeCodeCell, +} from '../code-mode.js'; function execute(code: string, input: Partial> = {}) { return executeCodeCell({ @@ -12,21 +18,6 @@ function execute(code: string, input: Partial }); } -test('counts the bounded JSON representation used at the tool boundary', () => { - let inspectedPastLimit = false; - const trailing = Object.defineProperty({}, 'value', { - enumerable: true, - get: () => { - inspectedPastLimit = true; - throw new Error('must not inspect values after the byte limit'); - }, - }); - - assert.equal(serializedByteLength('\0'.repeat(10)), 62); - assert.equal(serializedByteLength(['x'.repeat(128), trailing], 32), 33); - assert.equal(inspectedPastLimit, false); -}); - test('executes standard JavaScript without an interpreter subset', async () => { const result = await execute(` const key = 'answer'; @@ -150,6 +141,40 @@ test('does not expose Node capabilities to cell code', async () => { }); }); +test('keeps sandbox console output out of the host process stdout', async () => { + // The product policy's `maxConsoleOutputBytes: 1` is the only thing standing + // between cell code and the host's stdout, which the CLI writes its TUI and + // command output to. The sandbox writes from a worker thread that Node pipes + // into the parent's stdout, so this has to be observed from outside the + // process rather than by patching `process.stdout.write` here. + const moduleUrl = new URL('../code-mode.js', import.meta.url).href; + const child = spawn( + process.execPath, + [ + '--input-type=module', + '-e', + `const { executeCodeCell } = await import(${JSON.stringify(moduleUrl)}); + const result = await executeCodeCell({ + code: "console.log('sandbox-console-marker'); return 1;", + tools: [], + callTool: async () => null, + }); + if (!result.ok || result.value !== 1) process.exit(2);`, + ], + { stdio: ['ignore', 'pipe', 'ignore'] }, + ); + let stdout = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + + const [code] = (await once(child, 'exit')) as [number | null]; + + assert.equal(code, 0, 'the probe cell did not run to completion'); + assert.equal(stdout, ''); +}); + test('starts each cell in a fresh global context', async () => { const first = await execute('globalThis.transient = 42; return globalThis.transient;'); const second = await execute('return globalThis.transient ?? null;'); @@ -268,14 +293,16 @@ test('reports uncaught runtime and tool failures', async (t) => { test('enforces byte and bridge limits', async (t) => { await t.test('source', async () => { - const result = await execute('return null;', { limits: { maxSourceBytes: 1 } }); + const result = await execute('return null;', { + executionPolicy: { ...DEFAULT_CODE_MODE_EXECUTION_POLICY, maxSourceBytes: 1 }, + }); assert.equal(result.ok ? undefined : result.error.kind, 'limit_exceeded'); }); await t.test('tool input', async () => { const result = await execute("return await tools.echo({ value: '12345' });", { tools: [{ name: 'echo' }], - limits: { maxToolInputBytes: 4 }, + executionPolicy: { ...DEFAULT_CODE_MODE_EXECUTION_POLICY, maxToolInputBytes: 4 }, callTool: async () => null, }); assert.equal(result.ok ? undefined : result.error.kind, 'limit_exceeded'); @@ -284,46 +311,37 @@ test('enforces byte and bridge limits', async (t) => { await t.test('tool output', async () => { const result = await execute('return await tools.echo({});', { tools: [{ name: 'echo' }], - limits: { maxToolOutputBytes: 4 }, + executionPolicy: { ...DEFAULT_CODE_MODE_EXECUTION_POLICY, maxToolOutputBytes: 4 }, callTool: async () => '12345', }); assert.equal(result.ok ? undefined : result.error.kind, 'limit_exceeded'); }); await t.test('cell output', async () => { - const result = await execute("return '12345';", { limits: { maxOutputBytes: 4 } }); + const result = await execute("return '12345';", { + executionPolicy: { ...DEFAULT_CODE_MODE_EXECUTION_POLICY, maxResultBytes: 4 }, + }); assert.equal(result.ok ? undefined : result.error.kind, 'limit_exceeded'); }); await t.test('tool calls', async () => { const result = await execute('await tools.echo({}); return await tools.echo({});', { tools: [{ name: 'echo' }], - limits: { maxToolCalls: 1 }, + executionPolicy: { ...DEFAULT_CODE_MODE_EXECUTION_POLICY, maxBridgeRequests: 1 }, callTool: async () => null, }); assert.equal(result.ok ? undefined : result.error.kind, 'limit_exceeded'); }); - await t.test('undefined override keeps the product default', async () => { - const result = await execute( - ` - for (let index = 0; index < 33; index += 1) await tools.echo({ index }); - return null; - `, - { - tools: [{ name: 'echo' }], - limits: { maxToolCalls: undefined } as unknown as ExecuteCodeCellInput['limits'], - callTool: async () => null, - }, - ); - assert.equal(result.ok ? undefined : result.error.kind, 'limit_exceeded'); - }); - await t.test('tool concurrency', async () => { let started = 0; const result = await execute('return await Promise.all([tools.echo({}), tools.echo({})]);', { tools: [{ name: 'echo' }], - limits: { maxToolConcurrency: 1, maxSandboxTimeMs: 500 }, + executionPolicy: { + ...DEFAULT_CODE_MODE_EXECUTION_POLICY, + maxInFlightBridgeRequests: 1, + timeoutMs: 500, + }, callTool: async (_name, _input, signal) => { started += 1; await new Promise((resolve) => { @@ -340,7 +358,7 @@ test('enforces byte and bridge limits', async (t) => { test('enforces the configured VM stack limit', async () => { const result = await execute('function recurse() { return recurse(); } return recurse();', { - limits: { maxStackBytes: 64 * 1024 }, + executionPolicy: { ...DEFAULT_CODE_MODE_EXECUTION_POLICY, maxStackSizeBytes: 64 * 1024 }, }); assert.equal(result.ok, false); @@ -349,7 +367,11 @@ test('enforces the configured VM stack limit', async () => { test('enforces the configured VM memory limit', async () => { const result = await execute('return new ArrayBuffer(16 * 1024 * 1024).byteLength;', { - limits: { maxMemoryBytes: 8 * 1024 * 1024, maxSandboxTimeMs: 5_000 }, + executionPolicy: { + ...DEFAULT_CODE_MODE_EXECUTION_POLICY, + memoryLimitBytes: 8 * 1024 * 1024, + timeoutMs: 5_000, + }, }); assert.equal(result.ok, false); @@ -357,7 +379,9 @@ test('enforces the configured VM memory limit', async () => { }); test('preempts a pure compute loop at the sandbox-time limit', async () => { - const result = await execute('while (true) {}', { limits: { maxSandboxTimeMs: 20 } }); + const result = await execute('while (true) {}', { + executionPolicy: { ...DEFAULT_CODE_MODE_EXECUTION_POLICY, timeoutMs: 20 }, + }); assert.equal(result.ok, false); if (!result.ok) assert.equal(result.error.kind, 'limit_exceeded'); @@ -478,105 +502,3 @@ test('aborts and drains concurrent tools while preserving the first fatal failur await Promise.allSettled([execution]); } }); - -test('bounds serial execution at one pending cell', async () => { - const started: string[] = []; - let firstStarted!: () => void; - let releaseFirst!: () => void; - const firstHasStarted = new Promise((resolve) => { - firstStarted = resolve; - }); - const firstCanFinish = new Promise((resolve) => { - releaseFirst = resolve; - }); - const callTool = async (_name: string, input: unknown) => { - const id = (input as { id: string }).id; - started.push(id); - if (id === 'first') { - firstStarted(); - await firstCanFinish; - } - return id; - }; - const run = (id: string) => - execute(`return await tools.hold({ id: '${id}' });`, { - tools: [{ name: 'hold' }], - callTool, - }); - - const first = run('first'); - await firstHasStarted; - const second = run('second'); - const third = run('third'); - - try { - const excess = await Promise.race([ - third, - new Promise<'still-pending'>((resolve) => setImmediate(() => resolve('still-pending'))), - ]); - assert.notEqual(excess, 'still-pending'); - if (excess !== 'still-pending') { - assert.equal(excess.ok ? undefined : excess.error.kind, 'limit_exceeded'); - assert.deepEqual(excess.toolCalls, []); - } - assert.deepEqual(started, ['first']); - releaseFirst(); - const results = await Promise.all([first, second]); - assert.deepEqual( - results.map((result) => (result.ok ? result.value : undefined)), - ['first', 'second'], - ); - } finally { - releaseFirst(); - await Promise.allSettled([first, second, third]); - } -}); - -test('aborts a queued cell without waiting for the active cell', async () => { - let firstStarted!: () => void; - let releaseFirst!: () => void; - const firstHasStarted = new Promise((resolve) => { - firstStarted = resolve; - }); - const firstCanFinish = new Promise((resolve) => { - releaseFirst = resolve; - }); - const first = execute('return await tools.hold({});', { - tools: [{ name: 'hold' }], - callTool: async () => { - firstStarted(); - await firstCanFinish; - return null; - }, - }); - await firstHasStarted; - - const controller = new AbortController(); - const reason = new Error('queued cell cancelled'); - let queuedToolCalls = 0; - const queued = execute('return await tools.never({});', { - tools: [{ name: 'never' }], - signal: controller.signal, - callTool: async () => { - queuedToolCalls += 1; - return null; - }, - }); - controller.abort(reason); - - const outcome = await Promise.race([ - queued.then( - () => 'resolved' as const, - (error) => error, - ), - new Promise<'still-pending'>((resolve) => setImmediate(() => resolve('still-pending'))), - ]); - - try { - assert.equal(outcome, reason); - assert.equal(queuedToolCalls, 0); - } finally { - releaseFirst(); - await Promise.allSettled([first, queued]); - } -}); diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index b4d432f90b..156ac7e652 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -278,6 +278,27 @@ describe('ToolRuntime durable boundary', () => { assert.equal(JSON.stringify(harness.events).includes('NON_JSON_RESULT'), false); }); + it('admits a nested tool that returned nothing under the result limit', async () => { + const outcomes: ToolOutcomeCommit[] = []; + const harness = makeHarness({ + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async (input) => { + outcomes.push(input); + return { created: true, runtimeEventSeq: 2 }; + }, + }); + + const result = await harness.executeNested( + tool(() => undefined), + 32, + ); + + // An absent result is published as empty text, so it must not be rejected + // as though the result were too large. + assert.equal(result, undefined); + assert.equal(JSON.stringify(outcomes).includes('byte limit exceeded'), false); + }); + it('persists nested CodeMode identity across durable and legacy tool activity', async () => { const prepared: ToolPreparedCommit[] = []; const outcomes: ToolOutcomeCommit[] = []; diff --git a/packages/runtime/src/child-agent-run-limiter.ts b/packages/runtime/src/admission-limiter.ts similarity index 65% rename from packages/runtime/src/child-agent-run-limiter.ts rename to packages/runtime/src/admission-limiter.ts index 90be5e79b2..d347b588be 100644 --- a/packages/runtime/src/child-agent-run-limiter.ts +++ b/packages/runtime/src/admission-limiter.ts @@ -1,29 +1,36 @@ -export interface ChildAgentRunPermit { +export interface AdmissionPermit { release(): void; } -interface ChildAgentRunWaiter { +interface AdmissionWaiter { signal: AbortSignal; - resolve: (permit: ChildAgentRunPermit) => void; + resolve: (permit: AdmissionPermit) => void; reject: (error: unknown) => void; onAbort: () => void; } /** - * Abort-aware FIFO permits for real child-agent executions. + * Abort-aware FIFO permits, bounded by capacity. * - * Tool-call admission and child-run capacity are separate boundaries: one - * admitted tool may eventually spawn multiple children. This limiter belongs at - * the narrow spawn capability so every caller shares the same real-run budget. + * Two boundaries use it, at different lifetimes. Child-agent runs take one + * instance per turn: tool-call admission and child-run capacity are separate + * boundaries, since one admitted tool may eventually spawn multiple children, + * so the limiter belongs at the narrow spawn capability where every caller + * shares the same real-run budget. Code Mode cells take one per backend, which + * has to outlive a turn — see `executeCodeModeCell`. + * + * A caller that must turn work away rather than queue it reads `waitingCount` + * before calling `acquire`. Nothing awaits between that read and the enqueue + * inside `acquire`, so the pair is atomic. */ -export class ChildAgentRunLimiter { +export class AdmissionLimiter { private active = 0; - private readonly waiters: ChildAgentRunWaiter[] = []; + private readonly waiters: AdmissionWaiter[] = []; private closedError: Error | undefined; constructor(readonly capacity: number) { if (!Number.isSafeInteger(capacity) || capacity < 1) { - throw new Error('Child agent run capacity must be a positive safe integer'); + throw new Error('Admission capacity must be a positive safe integer'); } } @@ -35,15 +42,15 @@ export class ChildAgentRunLimiter { return this.waiters.length; } - acquire(signal: AbortSignal): Promise { + acquire(signal: AbortSignal): Promise { if (this.closedError) return Promise.reject(this.closedError); if (signal.aborted) return Promise.reject(abortReason(signal)); if (this.active < this.capacity && this.waiters.length === 0) { this.active += 1; return Promise.resolve(this.createPermit()); } - return new Promise((resolve, reject) => { - const waiter: ChildAgentRunWaiter = { + return new Promise((resolve, reject) => { + const waiter: AdmissionWaiter = { signal, resolve, reject, @@ -69,7 +76,7 @@ export class ChildAgentRunLimiter { } } - private createPermit(): ChildAgentRunPermit { + private createPermit(): AdmissionPermit { let released = false; return { release: () => { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 316329dbd5..33fbba7737 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -89,7 +89,6 @@ import type { ToolInvocationRecord, } from '@maka/core/usage-stats/types'; import type { ContextBudgetDiagnostic, PromptSegmentEstimate } from '@maka/core/usage-stats/types'; -import { DEFAULT_CODE_MODE_LIMITS, executeCodeCell } from '@maka/code-mode'; import type { JSONValue, ModelFinishReason, @@ -111,6 +110,12 @@ import Ajv2020 from 'ajv/dist/2020.js'; import { z } from 'zod'; import { AsyncEventQueue } from './async-queue.js'; +import { AdmissionLimiter } from './admission-limiter.js'; +import { + type CodeModeExecutionResult, + DEFAULT_CODE_MODE_EXECUTION_POLICY, + executeCodeCell, +} from './code-mode.js'; import { StreamWatchdog, formatStreamWatchdogError, @@ -901,6 +906,15 @@ function nativeApplyPatchFailureOutput(output: ToolResultOutput): ToolResultOutp }; } +/** + * One Code Mode cell runs at a time on a backend, with one allowed to wait. + * Widening either needs evidence that concurrent cells are wanted; none exists + * today, and this is the bound the Code Mode adapter enforced before execution + * admission moved to the side that owns it. + */ +const MAX_ACTIVE_CODE_MODE_CELLS = 1; +const MAX_WAITING_CODE_MODE_CELLS = 1; + const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10; const MAX_IDLE_WATCHDOG_RETRIES_PER_STEP = 1; const MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP = 1; @@ -1043,6 +1057,9 @@ export class AiSdkBackend implements AgentBackend { private readonly toolAvailabilityRuntime: ToolAvailabilityRuntime; private readonly applyPatchProfile: ApplyPatchProfile | null; + /** Bounds outstanding Code Mode cells on this backend. */ + private readonly codeCellAdmission = new AdmissionLimiter(MAX_ACTIVE_CODE_MODE_CELLS); + /** * Every `send()` currently in flight on this backend. * @@ -3079,7 +3096,7 @@ export class AiSdkBackend implements AgentBackend { const nextBytes = new TextEncoder().encode(event.chunk).byteLength; if ( nestedOutputLimitExceeded || - nestedOutputBytes + nextBytes > DEFAULT_CODE_MODE_LIMITS.maxToolOutputBytes + nestedOutputBytes + nextBytes > DEFAULT_CODE_MODE_EXECUTION_POLICY.maxToolOutputBytes ) { nestedOutputLimitExceeded = true; return; @@ -3090,38 +3107,64 @@ export class AiSdkBackend implements AgentBackend { }, pushAndWaitUntilConsumed: (event) => eventSink.pushAndWaitUntilConsumed(event), }; - return executeCodeCell({ - code, - signal: context.abortSignal, - tools: [...snapshot.values()].map((tool) => ({ - name: tool.name, - })), - isFatalToolError: isRuntimeCommitBoundaryError, - callTool: async (name, input, signal) => { - const tool = snapshot.get(name); - if (!tool) throw new Error(`Tool "${name}" is not active or nestable in this cell`); - const parsedInput = await validateCodeModeToolInput(tool, input); - const settlement = await scope.toolRuntime.settleToolCallRaw({ - tool, - turnId: context.turnId, - toolCallId: `${context.toolCallId}:nested:${this.newId()}`, - input: parsedInput, - abortSignal: signal, - eventSink: nestedEventSink, - origin: 'code_mode', - parentToolCallId: context.toolCallId, - ...(context.operationId ? { parentOperationId: context.operationId } : {}), - maxResultBytes: DEFAULT_CODE_MODE_LIMITS.maxToolOutputBytes, - }); - if (settlement.providerError !== undefined) { - throw new Error(settlement.providerError); - } - if (nestedOutputLimitExceeded) { - throw new Error('Code Mode nested output byte limit exceeded'); - } - return settlement.result; - }, - }); + // A permit is held across the cell's complete lifecycle, not just its + // sandbox run: `executeCodeCell` settles only once the cell's host + // operations have drained, so releasing on settlement covers the drain. + // The sandbox worker cap cannot serve this purpose — on cancellation + // `runCodeMode` releases its worker and rejects at once, by design, while + // host operations started by the cell may still be running with durable + // side effects. Only the Runtime waits for those, so only the Runtime can + // bound them; releasing when the worker is released would let repeated + // cancellation accumulate host work without bound. + // + // One cell may wait; the next is turned away rather than queued, which is + // what the Code Mode adapter did before this moved to the side that owns + // execution. Nothing awaits between reading `waitingCount` and the enqueue + // inside `acquire`, so the pair is atomic. + if (this.codeCellAdmission.waitingCount >= MAX_WAITING_CODE_MODE_CELLS) { + return { + ok: false, + error: { kind: 'limit_exceeded', message: 'Code Mode execution queue is full' }, + toolCalls: [], + } satisfies CodeModeExecutionResult; + } + const permit = await this.codeCellAdmission.acquire(context.abortSignal); + try { + return await executeCodeCell({ + code, + signal: context.abortSignal, + tools: [...snapshot.values()].map((tool) => ({ + name: tool.name, + })), + isFatalToolError: isRuntimeCommitBoundaryError, + callTool: async (name, input, signal) => { + const tool = snapshot.get(name); + if (!tool) throw new Error(`Tool "${name}" is not active or nestable in this cell`); + const parsedInput = await validateCodeModeToolInput(tool, input); + const settlement = await scope.toolRuntime.settleToolCallRaw({ + tool, + turnId: context.turnId, + toolCallId: `${context.toolCallId}:nested:${this.newId()}`, + input: parsedInput, + abortSignal: signal, + eventSink: nestedEventSink, + origin: 'code_mode', + parentToolCallId: context.toolCallId, + ...(context.operationId ? { parentOperationId: context.operationId } : {}), + maxResultBytes: DEFAULT_CODE_MODE_EXECUTION_POLICY.maxToolOutputBytes, + }); + if (settlement.providerError !== undefined) { + throw new Error(settlement.providerError); + } + if (nestedOutputLimitExceeded) { + throw new Error('Code Mode nested output byte limit exceeded'); + } + return settlement.result; + }, + }); + } finally { + permit.release(); + } } private handlePlanToolResult( diff --git a/packages/code-mode/src/quickjs.ts b/packages/runtime/src/code-mode.ts similarity index 57% rename from packages/code-mode/src/quickjs.ts rename to packages/runtime/src/code-mode.ts index ebef116a18..effc4eab78 100644 --- a/packages/code-mode/src/quickjs.ts +++ b/packages/runtime/src/code-mode.ts @@ -1,34 +1,105 @@ +// packages/runtime/src/code-mode.ts +// Integration glue over `@ai-sdk/code-mode`: the product execution policy, the +// result shapes the backend publishes, and the adapter that bridges sandbox +// tool calls onto host tools and waits for them to drain. + import { CodeModeError, + type CodeModeExecutionPolicy, CodeModeToolError, experimental_runCodeMode as runCodeMode, } from '@ai-sdk/code-mode'; import { jsonSchema, tool, type ToolSet } from 'ai'; -import type { - CodeModeDiagnostic, - CodeModeExecutionResult, - CodeModeToolCall, - ExecuteCodeCellInput, -} from './index.js'; -import { DEFAULT_CODE_MODE_LIMITS } from './index.js'; -export async function executeCodeCellImpl( +export interface CodeModeToolDefinition { + name: string; +} + +/** + * Product limits for a Code Mode cell, expressed in the SDK's own policy shape. + * The SDK applies looser defaults; these are the values Maka ships. + */ +export const DEFAULT_CODE_MODE_EXECUTION_POLICY: Readonly> = + Object.freeze({ + /** Sandbox invocation deadline; aborted host operations still drain before settlement. */ + timeoutMs: 30_000, + memoryLimitBytes: 64 * 1024 * 1024, + maxStackSizeBytes: 2 * 1024 * 1024, + maxResultBytes: 1024 * 1024, + maxConsoleOutputBytes: 1, + maxSourceBytes: 64 * 1024, + maxToolInputBytes: 1024 * 1024, + maxToolOutputBytes: 1024 * 1024, + maxBridgeRequests: 32, + maxInFlightBridgeRequests: 8, + }); + +export type CodeModeDiagnosticKind = + | 'parse_error' + | 'execution_error' + | 'unknown_tool' + | 'limit_exceeded' + | 'tool_failure'; + +export interface CodeModeDiagnostic { + kind: CodeModeDiagnosticKind; + message: string; +} + +export interface CodeModeToolCall { + index: number; + name: string; +} + +export interface CodeModeExecutionSuccess { + ok: true; + value: unknown; + toolCalls: CodeModeToolCall[]; +} + +export interface CodeModeExecutionFailure { + ok: false; + error: CodeModeDiagnostic; + toolCalls: CodeModeToolCall[]; +} + +export type CodeModeExecutionResult = CodeModeExecutionSuccess | CodeModeExecutionFailure; + +export interface ExecuteCodeCellInput { + code: string; + tools: readonly CodeModeToolDefinition[]; + callTool(name: string, input: unknown, signal: AbortSignal): Promise; + isFatalToolError?: (error: unknown) => boolean; + signal?: AbortSignal; + /** + * The complete policy for this cell. Production omits it, so the frozen + * product default is the only policy Maka ships; tests pass a whole policy to + * reach a limit they cannot practically hit at its default, such as the 30s + * deadline or a megabyte-scale byte cap. + * + * There is deliberately no per-field merge. The SDK resolves its own policy + * with `??` and skips a check outright for an integer above its + * `2_147_483_647` ceiling, so a partial override is a way to widen a product + * limit while appearing to tighten one. + */ + executionPolicy?: Readonly>; +} + +/** + * Runs one Code Mode cell to quiescence. + * + * The returned promise settles only after every host operation the cell started + * has settled, on both the success and the failure path. That is the contract + * the backend's `codeCellAdmission` limiter depends on; the comment at its call + * site explains why the sandbox worker cap cannot stand in for it. + * + * This module holds no cross-cell state. Bounding how many cells run at once + * belongs to whoever owns execution, not to this adapter. + */ +export async function executeCodeCell( input: ExecuteCodeCellInput, ): Promise { - const limits = { - maxSourceBytes: input.limits?.maxSourceBytes ?? DEFAULT_CODE_MODE_LIMITS.maxSourceBytes, - maxSandboxTimeMs: input.limits?.maxSandboxTimeMs ?? DEFAULT_CODE_MODE_LIMITS.maxSandboxTimeMs, - maxMemoryBytes: input.limits?.maxMemoryBytes ?? DEFAULT_CODE_MODE_LIMITS.maxMemoryBytes, - maxStackBytes: input.limits?.maxStackBytes ?? DEFAULT_CODE_MODE_LIMITS.maxStackBytes, - maxToolCalls: input.limits?.maxToolCalls ?? DEFAULT_CODE_MODE_LIMITS.maxToolCalls, - maxToolConcurrency: - input.limits?.maxToolConcurrency ?? DEFAULT_CODE_MODE_LIMITS.maxToolConcurrency, - maxToolInputBytes: - input.limits?.maxToolInputBytes ?? DEFAULT_CODE_MODE_LIMITS.maxToolInputBytes, - maxToolOutputBytes: - input.limits?.maxToolOutputBytes ?? DEFAULT_CODE_MODE_LIMITS.maxToolOutputBytes, - maxOutputBytes: input.limits?.maxOutputBytes ?? DEFAULT_CODE_MODE_LIMITS.maxOutputBytes, - }; + const executionPolicy = input.executionPolicy ?? DEFAULT_CODE_MODE_EXECUTION_POLICY; const toolCalls: CodeModeToolCall[] = []; const hostToolOperations = new Set>(); const fatalAbortController = new AbortController(); @@ -75,20 +146,7 @@ export async function executeCodeCellImpl( js: input.code, tools, toolExecutionOptions: { abortSignal: invocationSignal }, - options: { - executionPolicy: { - timeoutMs: limits.maxSandboxTimeMs, - memoryLimitBytes: limits.maxMemoryBytes, - maxStackSizeBytes: limits.maxStackBytes, - maxResultBytes: limits.maxOutputBytes, - maxConsoleOutputBytes: 1, - maxSourceBytes: limits.maxSourceBytes, - maxToolInputBytes: limits.maxToolInputBytes, - maxToolOutputBytes: limits.maxToolOutputBytes, - maxBridgeRequests: limits.maxToolCalls, - maxInFlightBridgeRequests: limits.maxToolConcurrency, - }, - }, + options: { executionPolicy }, }); await drainHostToolOperations(hostToolOperations); if (fatalToolFailure) throw fatalToolFailure.reason; diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index d74b18aa15..ee1a0530b8 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -10,6 +10,7 @@ import { type SandboxBoundarySettlement, type SettleSandboxBoundaryRequest, } from '@maka/core/sandbox-boundary'; +import { serializedByteLength } from '@maka/core/serialized-byte-length'; import { ToolOutcomeUnknownError } from '@maka/core/events'; import type { SandboxBoundaryDecisionAckEvent, @@ -46,7 +47,6 @@ import type { SessionHeader } from '@maka/core/session'; import type { ToolInvocationRecord } from '@maka/core/usage-stats/types'; import { redactSecrets } from '@maka/core/redaction'; import { TOOL_BOUNDARY_PROTOCOL_V1, type RuntimeEvent } from '@maka/core/runtime-event'; -import { serializedByteLength } from '@maka/code-mode'; import { recordToolArtifactsSafely, type ToolArtifactRecorder } from './tool-artifacts.js'; import { computerActionFields, describeComputerUseArgsViolation } from './computer-use-codec.js'; @@ -64,7 +64,7 @@ import { type RuntimeCommitSink, type ToolRecoveryMode, } from './runtime-commit-sink.js'; -import { ChildAgentRunLimiter } from './child-agent-run-limiter.js'; +import { AdmissionLimiter } from './admission-limiter.js'; import type { AgentProfile } from './agent-catalog.js'; import type { SubagentExecutionRef } from './subagent-execution.js'; import { sandboxErrorMetadata, serializeSandboxError } from './sandbox/errors.js'; @@ -483,7 +483,7 @@ export class ToolRuntime { private sandboxBoundaryClosureDeferred = false; private questionClosureDeferred = false; private activeSubagentToolCount = 0; - private childAgentRunLimiter = new ChildAgentRunLimiter(MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN); + private childAgentRunLimiter = new AdmissionLimiter(MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN); /** * Tool-availability gating for the execute boundary. Set by the backend each * turn from `ToolAvailabilityRuntime`. Undefined when gating is off (economy @@ -765,7 +765,7 @@ export class ToolRuntime { resetTurnState(): void { const priorChildAgentRunLimiter = this.childAgentRunLimiter; - this.childAgentRunLimiter = new ChildAgentRunLimiter(MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN); + this.childAgentRunLimiter = new AdmissionLimiter(MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN); priorChildAgentRunLimiter.close( new Error('Child agent run permit scope ended before capacity became available'), ); diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json index d195ec5fa1..708eb074f0 100644 --- a/packages/runtime/tsconfig.json +++ b/packages/runtime/tsconfig.json @@ -7,5 +7,5 @@ }, "include": ["src/**/*.ts"], "exclude": [], - "references": [{ "path": "../code-mode" }, { "path": "../core" }, { "path": "../storage" }] + "references": [{ "path": "../core" }, { "path": "../storage" }] } diff --git a/scripts/check-stale-dist.mjs b/scripts/check-stale-dist.mjs index 93ccd2e346..7fb29e7923 100644 --- a/scripts/check-stale-dist.mjs +++ b/scripts/check-stale-dist.mjs @@ -27,7 +27,6 @@ const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, '..'); const pairs = [ - { pkg: '@maka/code-mode', src: 'packages/code-mode/src', dist: 'packages/code-mode/dist' }, { pkg: '@maka/core', src: 'packages/core/src', dist: 'packages/core/dist' }, { pkg: '@maka/storage', src: 'packages/storage/src', dist: 'packages/storage/dist' }, { pkg: '@maka/runtime', src: 'packages/runtime/src', dist: 'packages/runtime/dist' }, diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index a16e405046..31f22a9247 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -42,7 +42,6 @@ const CLI_PACKAGE_FILES = new Set([ const CLI_PACKAGE_WORKSPACES = [ 'packages/cli', - 'packages/code-mode', 'packages/core', 'packages/eval', 'packages/mcp', diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index fe8d2e2e6f..96bcfc93fa 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -37,7 +37,6 @@ if (unsupportedArguments.length > 0) { throw new Error(`Unsupported release argument: ${unsupportedArguments.join(', ')}`); } const internalPackageNames = [ - '@maka/code-mode', '@maka/core', '@maka/eval', '@maka/mcp', @@ -47,7 +46,6 @@ const internalPackageNames = [ ]; const internalPackageSet = new Set(internalPackageNames); const buildOrder = [ - '@maka/code-mode', '@maka/core', '@maka/storage', '@maka/mcp',