diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 382eb9835..c97568f85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,3 +118,51 @@ jobs: - name: Packed release installs on its own run: node scripts/pack-installs-alone.mjs + + # create() refuses codeExecution on a Bun older than 1.4.0, reading the + # Bun it runs under: the settings file checks that here, under the Bun + # connectome-host's CI runs, which create() refuses. + - name: code_execution's settings under Bun 1.3.14, which create() refuses + run: bun test --timeout 60000 ./test/code-execution-isolate-settings.test.ts + + # A Bun host runs a script's process on Bun too, from the package's source, + # so code_execution's files run there as well as under Node, each file in a + # process of its own, on both platforms, since the child's stdio and fd 3 + # are where runtimes and platforms differ. code_execution needs Bun 1.4.0 or + # later (create() refuses it on an older one), so they run under 1.4.0, the + # oldest release it admits, and under 1.4.3. + code-execution-bun: + name: code_execution under Bun (${{ matrix.os }}, bun ${{ matrix.bun }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + bun: [1.4.0, 1.4.3] + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + + - name: Install dependencies + run: npm install --ignore-scripts + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ matrix.bun }} + + - name: code_execution's tests under Bun, from source + run: | + for f in test/code-execution-isolate-*.test.ts test/code-execution-engine.test.ts test/code-execution.test.ts; do + echo "::group::$f" + bun test --timeout 60000 "./$f" || exit 1 + echo "::endgroup::" + done diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml new file mode 100644 index 000000000..1428a674e --- /dev/null +++ b/.github/workflows/engine.yml @@ -0,0 +1,52 @@ +name: Engine + +# code_execution's engine module, src/code-execution/quickjs.wasm, is committed beside what it's +# built from: engine/build.sh, which fetches the pinned quickjs-ng release and applies the patches +# in engine/patches, and engine/interface.c, built with a pinned toolchain. This rebuilds the +# module and fails unless the bytes are the committed ones, so the module can't drift from its +# sources in either direction. It runs only when one of them changes, since the toolchain is a +# 143 MB download. +on: + push: + branches: + - main + paths: + - 'engine/**' + - 'src/code-execution/quickjs.wasm' + - '.github/workflows/engine.yml' + pull_request: + paths: + - 'engine/**' + - 'src/code-execution/quickjs.wasm' + - '.github/workflows/engine.yml' + +permissions: + contents: read + +jobs: + rebuild: + name: The committed engine module is what its sources build + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + # binaryen's wasm-opt comes from npm and runs under Node. + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + + - name: Rebuild the module + run: engine/build.sh "$RUNNER_TEMP/quickjs.wasm" + + - name: Compare it with the committed module + run: | + sha256sum "$RUNNER_TEMP/quickjs.wasm" src/code-execution/quickjs.wasm + if ! cmp -s "$RUNNER_TEMP/quickjs.wasm" src/code-execution/quickjs.wasm; then + echo "::error::src/code-execution/quickjs.wasm isn't what engine/build.sh builds from these sources. Rebuild it with engine/build.sh and commit it with them." + exit 1 + fi diff --git a/changelog.d/code-execution-isolate.breaking.md b/changelog.d/code-execution-isolate.breaking.md new file mode 100644 index 000000000..2c655808e --- /dev/null +++ b/changelog.d/code-execution-isolate.breaking.md @@ -0,0 +1,13 @@ +- **Agents with `code_execution`, and whoever writes their prompts:** scripts are JavaScript now, not Python, and each runs in a process of its own, in QuickJS compiled to WebAssembly, on the host's own runtime (Node or Bun). A script reaches nothing but its agent's tools: no network, files, timers, packages or environment. + - **Calling tools:** by exact name, `await tools["workspace--read"]({ path: "work/notes.md" })`, one argument, an object. The mangled Python names (`workspace__read`) are gone. `ALL_TOOLS` lists each tool's name, description and `inputSchema`. A name that isn't one of the agent's tools throws at once in the script, naming close matches, and nothing is sent. At most 16 of a script's calls are out at once: one past them waits until an earlier one's answer has reached the script, and is never refused, so a `Promise.all` over many calls is paced rather than refused. Calls that timed out count against the 16 the host holds until their tools end, so a call made while 16 of those still run waits, and one that waits past its timeout gets a `TimeoutError` saying it was never sent. A call whose tool gives no result within `toolCallTimeoutMs` and 30 s more, which the host stops waiting for then, keeps its place until the script ends, since its tool may still be running. A call's arguments may be up to 4 MiB of JSON holding at most 100,000 values: past either, the call throws `ArgumentsTooLarge`, and nothing is sent. + - **Results:** every tool answers one shape, `{ content, structuredContent, isError }`: `content` is what the model would read (a structured result's blocks, otherwise one text block), `structuredContent` the tool's own structured content or `null`, and `isError` true when the tool failed. A structured result's blocks are as its tool sent them, so images survive. A call refused before its tool ran (`ToolResult.refused`) throws an `Error` instead, `ToolRefused`, and nothing was done. A result that can't be converted to JSON, such as a cyclic object from an in-process module, comes as a failure in the same shape, its text naming the tool and saying the call completed. Before, a script got a string, the tool's text or `Error: …` for a failure, carrying `.is_error` and `.structured`, and a result with structured content got `{ content, structuredContent, isError, error? }`, with a failure's text in `error`. + - **`code_execution`'s own result** is an error (`isError`) when its script failed, crashed or was stopped at its limit, or the call was refused, with the text the model reads as its `error`; otherwise that text is its `data`, as a list of one text block. It carries no `structured`. Before, a script's result was a success whatever its `return_code`, and its `data` an object of `stdout`, `stderr`, `return_code` and, when the call's limit was capped, `time_limit_note`. + - **One kind of script:** a script runs in its agent's turn; past `yield_time_ms` (default 30 s, at most `maxYieldMs`, 10 min by default) it yields with its output so far and keeps running. Then `{"action": "wait", "script_id": …}` waits again, `{"action": "stop", …}` stops it and `{"action": "list"}` lists the agent's scripts, each with the wakes the host delivered for it. Each return shows the output since the previous one, its first and last 8,000 characters, cut alike to fit the agent's inline cap for tool results where the rest of the return leaves them room. The primary agent may leave a script running past its turn: `wake_agent()` is open to any running script, and a left script that fails, crashes or reaches its limit (24 h) wakes its agent; any other agent's scripts stop when its turn ends. The background log file is gone, its place taken by each return's output. + - **Refused inputs, each naming its replacement:** `background` (every script can yield and keep running), `time_limit_ms` (`yield_time_ms`) and the `cancel` action (`stop`). + - **Nothing is kept between scripts:** each starts fresh, so globals no longer persist until an idle reclaim. A script writes what must last with a tool. + - **Large results:** a result too large for the script's memory (past a third of it, or one that doesn't fit when it's parsed), or nested more than 2,000 deep, isn't passed to the script: the call's `ResultTooLarge` error says the tool ran, and a refusal the script can't take stays a refusal, with its start. Any other result reaches the script whole. Before, a text result was cut at 5,000,000 characters, and a structured one past that length was saved to the workspace, the script getting `{ isError, oversized: { chars, savedTo } }` in its place, or, when it couldn't be saved, a failure saying the tool may have completed. + - **Unchanged:** a script's calls go through the ordinary dispatcher as its agent's, with its allowlist and every gate; intermediate results stay out of the model's context; a call's `endTurn` ends the turn at the `code_execution` result watching the script; `wake_agent()` keeps its spacing and cap; RFC-007 observers see a script's calls as before. + - **Wakes:** a `wake_agent()` still waiting for its spacing when its script ends is dropped, however the script ended. Before, only a cancel dropped it, and a script that finished or died still woke its agent after the wait, saying it was still running. New: a wake's payload may be up to 64 KiB of JSON, and past it `wake_agent()` throws `WakeRefused`, with nothing sent. +- **Operators with `codeExecution` enabled:** the host no longer needs a Python interpreter. On Bun it needs Bun 1.4.0 or later: `create()` refuses `codeExecution` on an older Bun, since Bun 1.3.14 loses, at some starts, the pipe a script's process answers on, which crashes the script. `create()` refuses each setting the Python runner took, naming what to do: `pythonPath`, `idleReclaimMs`, `env` and `inheritEnv` are gone, since a script has no interpreter, nothing to reclaim and no environment, and `scriptTimeoutMs`, `maxScriptTimeoutMs`, `backgroundMaxLifetimeMs` and `maxBackgroundScripts` become `yieldMs`, `maxYieldMs`, `scriptLifetimeMs` and `maxRunningScripts`. New: `maxScriptChildren` (default 16), the script processes a host runs at once, and `memoryMaxBytes` (default 128 MiB, 4 MiB to 2 GiB in 64 KiB pages), each script's budget. A script's process stays within about its runtime's base plus 2.7 times `memoryMaxBytes`, plus what its calls take as they go out, up to about 200 MiB under Node and 410 MiB under Bun, and the host holds at most about 6 times `memoryMaxBytes`, plus 653 MiB, plus 128 KiB for each wake `maxWakesPerScript` allows, for a script, about 1.4 GiB at the defaults, for its 16 calls out at once, timed-out ones whose tools still run included, up to 16 more waiting for those, their answers, the frame from its process being read and its waiting wakes: size a host by both. `toolCallTimeoutMs`, `wakeMinIntervalMs` and `maxWakesPerScript` are unchanged. A connectome-host recipe that sets `codeExecution.pythonPath`, `env`, `scriptTimeoutMs` or `idleReclaimMs` is refused the same way. + - **What it promises:** `code_execution` adds nothing to what an agent's tools allow, provided the host runtime's WebAssembly engine, Bun's or Node's, and our glue are right. It claims no containment beyond that. +- **Library users:** `PyRunner`, `buildInjectedTools`, `PYTHON_RUNTIME_SOURCE` and the types `InjectedTool`, `ExecResult`, `PyRunnerOptions`, `ScriptToolCallHandler` and `ScriptToolResult` are no longer exported. `CodeExecutionConfig` has the new keys, and `buildCodeExecutionToolDefinition()` takes them, as `create()` does, where it took the Python runner's options. `HostModeStatus.backgroundScripts` counts scripts left running after a yield. A script's wake enters the agent's context with `metadata.source` `code-execution-script` (was `background-script`), tagged `script:wake` as before. diff --git a/changelog.d/f55-code-execution-text.breaking.md b/changelog.d/f55-code-execution-text.breaking.md deleted file mode 100644 index db183989a..000000000 --- a/changelog.d/f55-code-execution-text.breaking.md +++ /dev/null @@ -1 +0,0 @@ -- **Hosts:** a foreground `code_execution` result's `data` is now the text the model reads, as a list of one text block, and its fields, `stdout`, `stderr`, `return_code` and `time_limit_note`, are in `structured`. A host that reads those fields from `data` reads them from `structured`. We found no host that reads them. A background script's results are unchanged. diff --git a/changelog.d/f55-code-execution-text.changed.md b/changelog.d/f55-code-execution-text.changed.md deleted file mode 100644 index 7a4f1d7c5..000000000 --- a/changelog.d/f55-code-execution-text.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `code_execution`'s result now reaches the model as text, where it was one line of JSON with the script's output escaped inside it. How the script ended comes first, as `exit code: N` and, when the call's `time_limit_ms` was capped, the time-limit note; then the script's stderr, then its stdout, each under a label. A result past the agent's inline cap (`tool_result_inline_max_chars`, 24,000 characters by default) is cut a stream at a time to each one's head and tail, around a count of what was cut, at a line's end where one is near: stderr keeps up to a third of the room, or more when stdout needs less, and its tail keeps its last line whole when that line fits. So at any cap that holds a cut of each stream, however long stdout runs, it no longer pushes the exit code and the end of stderr, which holds a traceback's last line and the reason a stopped script gives, past the cut. With a writable workspace mount that has no include list, the whole output goes to a file of its own in the folder a long result spills to, and the result's last line says where; without one, or when writing it fails, that line says what was cut wasn't kept. A cap too small to hold a cut of each stream beside that line, a few hundred characters, leaves the whole text to the inline cap's usual spill, which keeps its head. diff --git a/changelog.d/fix-code-execution-output-to-host.breaking.md b/changelog.d/fix-code-execution-output-to-host.breaking.md deleted file mode 100644 index 9939a5827..000000000 --- a/changelog.d/fix-code-execution-output-to-host.breaking.md +++ /dev/null @@ -1 +0,0 @@ -- **Hosts:** the `code_execution` interpreter now takes its channel to the host on its file descriptors 3 and 4, and `PYTHON_RUNTIME_SOURCE`, which the package exports, speaks a new protocol there. **Who needs to act:** a host whose `pythonPath` starts Python through a wrapper that doesn't pass fds 3 and 4 on, such as one that runs it in a container, under sudo with its default settings, or from a Python script through `subprocess`, whose `close_fds` is on by default, and a host that runs `PYTHON_RUNTIME_SOURCE` itself rather than through `PyRunner`. Until it acts, every script fails to start, and the result says the host's channel didn't reach the interpreter: the runtime says so on stderr and exits with 78 (EX_CONFIG). **Migration:** have the wrapper `exec` Python, or pass fds 3 and 4 on. A host that runs the runtime itself opens fd 3 for its messages to the runtime and fd 4 for the runtime's, gives it `/dev/null` as fd 0, reads its fds 1 and 2 itself, puts a `mark` in each `exec`, and takes the script's output on each stream up to that mark, which the runtime writes once the script returns: `exec_result` now carries only the return code. It stops the runtime with the `exit` op, since closing fd 3 while a script runs now ends the runtime with exit code 70 and a line saying a script closed, shut down or replaced fd 3. `init` no longer takes `log_path` or `tail_chars`, so the runtime writes no journal for a background script and keeps no tail: the host writes both from fds 1 and 2, where a background script's stderr joins its stdout. **Unchanged by this change:** `PyRunner`'s interface, `ExecResult` included, and a `pythonPath` that names an interpreter or a wrapper that `exec`s one, of which it needs nothing more. The interpreter's new working directory, where a wrapper's own relative paths now resolve, comes from #193, whose entry says what it changes. No sibling package needs a new version. diff --git a/changelog.d/fix-code-execution-output-to-host.fixed.md b/changelog.d/fix-code-execution-output-to-host.fixed.md deleted file mode 100644 index 7e7ce60ff..000000000 --- a/changelog.d/fix-code-execution-output-to-host.fixed.md +++ /dev/null @@ -1,8 +0,0 @@ -- What the commands a `code_execution` script runs write to stdout and stderr now comes back in the script's result, beside the script's own prints, line by line in the order written on each stream: `subprocess` without `capture_output`, `os.system`, and C code writing to file descriptors 1 and 2. It used to reach only the host's log, and a line a command wrote without a newline ran into the runtime's own reply, so the script waited out its time limit. A command with a lot of output now fills the result as the script's own prints do: each stream keeps up to 2,000,000 characters, counted as UTF-16 code units, so a character outside the Basic Multilingual Plane, such as an emoji, counts as two, and a result past the agent's inline cap is cut to fit it, with a note naming the workspace file that holds the whole of it or saying that it wasn't kept. Output belongs to the script running when it arrives: a command a script left running writes into the result of a later script running then, and into the host's log between scripts or once its interpreter has been replaced. The tool's description now says that what the script and the commands it runs print comes back, and that a background script's output streams to a log file when the agent has a writable workspace. That changes the tool definitions every request carries, and by [Anthropic's prompt-caching documentation](https://platform.claude.com/docs/en/build-with-claude/prompt-caching#what-invalidates-the-cache), a prompt cache written before the upgrade isn't reused. -- A command a `code_execution` script runs now reads the end of its input at once: its standard input is `/dev/null`. It used to read the runtime's channel from the host, so a command that reads its input, such as `cat`, or `ssh` without `-n`, took the host's messages, and the script failed or waited out its time limit. -- A script that closes or redirects its file descriptors 0, 1 or 2 leaves the next script its output: the runtime's channel to the host has file descriptors of its own, 3 and 4, which no command a script runs is given, and each script starts with 0, 1 and 2 as the host set them, blocking even after a command set one non-blocking. A script that points 1 or 2 at the runtime's channel gets it put back at its end, so a partial line it left there reaches its result; what a script or its commands write onto that channel, or read from it, can still garble the runtime's messages: the script's own, and, from a command started holding the channel, a later script's for as long as that command runs. A script whose result is garbled that way waits out its time limit and the grace, and the interpreter restarts, so the next script's variables are gone. A script's `os.close(1)` used to leave it waiting out its time limit, and its `os.close(0)` sometimes left the next script doing so. A script that closes or replaces one of the runtime's own file descriptors, 3, 4, or its copies of 0 to 2, now ends the interpreter by the end of that script at the latest, and the result says which and what to check, as does one that shuts down the socket under file descriptor 1, 3 or 4. That line is lost when the script also closed or replaced both the runtime's copy of file descriptor 2 and file descriptor 2 itself, or shut down the socket under them, and the result leaves it out once the script's stderr has passed its 2,000,000-character cap. -- What a script and its commands wrote before it was stopped, as far as it had reached the host, now comes back ahead of the message saying why, when it's killed at its time limit, aborted, or its interpreter crashes; it used to be lost. A partial line still in the script's writer when the interpreter is killed or crashes is lost with it. A crash's own text comes back too, and goes to the host's log: a fatal error's, which goes to the script's file descriptor 2 wherever the script pointed it, and now a traceback for a segfault or an abort, which faulthandler writes where CPython alone wrote nothing. -- A background script's journal now holds its commands' output too, each line as it arrives, and ends saying why the host stopped the script, when it did, a time limit's note included, unless the journal reached its 50 MB cap, now counted in bytes, as its cap line says, or a write to it failed. A background script that crashes leaves the last of what it wrote, its crash's text included and after it, in the message that wakes its agent, where that held only the message about the exit. A journal that can't be opened or written says so once in the host's log. A background script started where no workspace is writable is told that `wake_agent()` reaches its agent and, if the script dies, the last of its output reaches it too, where it was told its output wasn't retrievable. -- A script that prints a lone surrogate, such as `"\udcff"`, now gets its output back with it written as `\udcff`, rather than as U+FFFD. -- A script's prints now reach the host as each line is complete, so a partial line waits for its newline, a flush or the end of the script, as Python's stdout does on a terminal, and what a wrapper of the script's own in `sys.stdout` or `sys.stderr` holds is flushed at the script's end, as Python's exit would. Printing costs more than it did: 100,000 short `print` calls took about 0.36 s, against about 0.15 s before, on Linux with Node 24.22.0 and Python 3.13.5. -- An interpreter that exits as it starts now says in the result what it wrote to stderr, up to its last 2,000 characters, with "…" where they're cut, and one ended by a signal as it starts names the signal. Output that arrives while its interpreter runs no script, such as from a command a script left running, even once that interpreter has ended, reaches the host's log as `stdout while its interpreter runs no script: …` or `stderr while its interpreter runs no script: …`, each piece cut at 2,000 characters, where stdout's used to read `non-protocol stdout line: …`: up to 65,536 characters of such lines, counted as UTF-16 code units, on each pipe from the start of its interpreter's last script, then a line saying the host drops the rest while the interpreter runs no script. While the interpreter lives, the host reads such output as it's written; once the host has torn the interpreter down, as at an idle reclaim, it reads it at 1 MiB a second at most, so a command left running that writes without end slows to that rather than costing the host a core, and keeps running. A host that turns the idle reclaim off reads such a command at full speed for as long as its interpreter lives. When the host's messages stop reaching an interpreter, the host now gives it a moment to exit on its own before killing it, so one that died, as by running out of memory, reads as its own exit rather than as one that stopped reading; and the stopped-reading message no longer says a script closed its standard input: it says the interpreter stopped reading the host's messages, and to check whether the script closes, shuts down or replaces file descriptor 3. diff --git a/changelog.d/fix-code-execution-signal-advice.fixed.md b/changelog.d/fix-code-execution-signal-advice.fixed.md deleted file mode 100644 index 779fa9ac0..000000000 --- a/changelog.d/fix-code-execution-signal-advice.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- When a crash's signal (SIGSEGV, SIGABRT, SIGBUS, SIGFPE or SIGILL) ends `code_execution`'s Python interpreter while a script runs, the result now says to check the traceback above it for the call that crashed in native code, such as an extension module the script uses, or, when no traceback reached the result, to check whether native code the script calls crashes. It used to say to check whether the script uses too much memory, which now comes only after SIGKILL, the signal the out-of-memory killer sends. Any other signal gets `os.kill()` as its example. diff --git a/changelog.d/hold-round-speech.fixed.md b/changelog.d/hold-round-speech.fixed.md index 8e6d1cfc7..5bc872cf2 100644 --- a/changelog.d/hold-round-speech.fixed.md +++ b/changelog.d/hold-round-speech.fixed.md @@ -25,67 +25,66 @@ out are never streamed, and neither is text held as a draft. A `{{unsent}}` resend streams the draft's words it sends, rather than the token. -- With native tool calls (`roundContent`), a call made inside a foreground - `code_execution` script now counts toward its round's routing, as the - same call made directly does (#306). In explicit mode an inner - `skip_reply` keeps the round's envelopes in the context. In locus and - hybrid mode an inner `skip_reply` keeps the round's prose private, and an - inner silencing send, a `drafts` resend among them, holds it and the - turn's later prose as drafts, as a direct one does. An inner - `think` under the private same-round policy holds the round back in - every mode. An inner `skip_reply` that's refused, because the agent's - allowedTools doesn't name it or because this host can't set the - `wake_in_seconds` it asks for, counts as a refused direct one: it keeps - only its own round private, and the turn goes on. An inner send the - allowedTools refuses, like a refused direct one, silences nothing. On - the fallback without `roundContent`, as in XML tool mode, an inner - call counts only as a direct one does there. Nothing is routed until the - turn ends, so a silencing send holds the whole turn's prose as drafts, - the rounds before it included. The private `think` hold and an explicit - round's `skip_reply` don't apply there (#307). -- A round whose script can change its routing waits for the script, and its - prose goes out when the script finishes. A script can change it when its - code names a tool whose call changes the round's routing in the turn's - mode: in locus and hybrid mode `skip_reply`, a silencing send, `drafts` - (whose resend is one), `say` or `whisper`; in explicit mode only - `skip_reply`; and in either, `think` under the private same-round - policy. Disabled mode holds nothing. A name - counts in code wherever it stands as a whole word of ASCII letters, digits - and underscores, or after `__` as in `mcpl__world__say`, as a function is - called or a `tools[...]` key is written: `say_hello` doesn't name `say`. A - name in a comment doesn't count, since Python never runs one, while a name - anywhere in a string does, since a string can be a key or code to run. A - script can also change its round's routing when the interpreter it runs in - has run a script that left such a tool reachable, by naming the tool - inside a definition (a function or lambda, a class's methods among them) - or other than in a direct call, or by keeping a string that is the tool's - whole name, optionally namespaced, as a key kept for later is. That holds - until the interpreter is reclaimed or restarted. A direct call outside a - definition, a class's own body included, or a string that only mentions a - tool, as a printed sentence does, leaves nothing reachable. Without code - execution, a `code_execution` call holds nothing. Any other round is - routed at its tool-calls event, as before, so narration beside a script - isn't held for the script's runtime. What goes unseen is routed as it was - before scripts counted, such as a tool name the code doesn't spell out - (built at runtime, written with escapes, or in non-ASCII spellings), code - loaded from a file or module, an enumeration of the tools, a kept key - whose prefix, as the script writes it, contains whitespace, a call written - in a string's text (code run with `exec` or `eval`, or an f-string's - field), inside a definition too, a definition or alias made inside such a - string, a call that outlives its script (one it awaits only in a later - script, schedules as a task, or leaves in a generator expression), and - code a later script runs from a string an earlier one kept. The turn's +- With native tool calls (`roundContent`), a call of a `code_execution` + script that reaches the host while a run or wait call of the round + watches the script now counts toward that round's routing, as the same + call made directly does (#306), whether or not the round waits on that + call's script. In explicit mode an inner `skip_reply` keeps the round's + envelopes in the context. In locus and hybrid mode an inner `skip_reply` + keeps the round's prose private, and an inner silencing send, a `drafts` + resend among them, holds it and the turn's later prose as drafts, as a + direct one does. An inner `think` under the private same-round policy + holds the round back in every mode but disabled. An inner `skip_reply` + that's refused at dispatch, because this host can't set the + `wake_in_seconds` it asks for or because the agent's allowedTools doesn't + name it, or that the host refuses before dispatch, as for arguments that + aren't an object, counts as a refused direct one: it keeps only its own + round private, and the turn goes on. An inner send refused so, like a + refused direct one, silences nothing. A script has only the tools its + agent's allowedTools names, so a call of + any other tool fails in the script and reaches no one. On the fallback + without `roundContent`, as in XML tool mode, an inner call counts only as + a direct one does there. Nothing is routed until the turn ends, so a + silencing send holds the whole turn's prose as drafts, the rounds before + it included. The private `think` hold and an explicit round's + `skip_reply` don't apply there (#307). +- A round whose run or wait call watches a script that can change its + routing waits for that call to return, at the script's end or at the + call's yield, and its prose goes out then. A script can change it when + its code names a tool whose call changes the round's routing in the + turn's mode: in locus and hybrid mode `skip_reply`, a silencing send, + `drafts` (whose resend is one, so its name holds whatever action its + call asks), `say` or `whisper`; in explicit mode only + `skip_reply`; and in either, `think` under the private same-round policy. + Disabled mode holds nothing. A wait reads the code of the script it + names. A name counts wherever it stands in the code as a whole + JavaScript identifier, comments and strings included: `tools.say(...)`, + `tools["say"]` and `tools["world--say"]` name `say`, and `say_hello` + doesn't. So a comment or a string that mentions such a tool holds its + round too, for no longer than the call's yield: 30 seconds by default, + and up to `maxYieldMs`, 600 seconds by default, when the call asks. A + tool name the code doesn't spell out, built at runtime or written with + escapes, goes unseen, and its round is routed as it was before scripts + counted. A stop or a list holds nothing, and neither does a + `code_execution` call without code execution or one the agent's + allowedTools refuses. Any other round is routed at its tool-calls event, + as before, so narration beside a script isn't held for it. The turn's locus and an explicit round's `>>` targets are taken at the round's tool-calls event, when the model has finished writing it, not when its - scripts return; hybrid's `>>>` targets apply as its delivery runs, as for - any queued hybrid delivery. A background script's call returns as soon as - the script has started, and its later calls aren't the round's. A script's - result settles only the round that dispatched it, even when a newer round - reuses its call id. A round whose turn ends or stops waiting on it, or - that a newer round of the same agent replaces, before its scripts return, - as when the turn is stopped, is withheld: it is neither sent nor held as - a draft, a log line names it, and in locus and hybrid mode the turn's - delivery receipt counts it as suppressed. + calls return; hybrid's `>>>` targets apply as its delivery runs, as for + any queued hybrid delivery. What counts is the host's receipt of a call, + not the script's making it: a call that reaches the host while no call of + the round watches the script isn't the round's, as one made once its run + has yielded, one made while it was watched that waited, with 16 of the + script's calls already out, until the watching call had returned, and one + still on its way when the call yielded. Such a call's effect can come + after the round's prose. A call's result settles only the round that + dispatched it, even when a + newer round reuses its call id. A round whose turn ends or stops waiting + on it, or that a newer round of the same agent replaces, before its calls + return, as when the turn is stopped, is withheld: it is neither sent nor + held as a draft, a log line names it, and in locus and hybrid mode the + turn's delivery receipt counts it as suppressed. - Every stream now sends `channels/outgoing/complete` for each channel it streamed to, while its server registers it and takes the stream, on every exit path, as MCPL Spec 14.3 asks: once its deliveries have run, or, when diff --git a/changelog.d/mount-include.added.md b/changelog.d/mount-include.added.md index 910507d7e..4df4dfffb 100644 --- a/changelog.d/mount-include.added.md +++ b/changelog.d/mount-include.added.md @@ -7,4 +7,4 @@ - **Where a nested mount is found:** its root is placed where disk has it at each start, and its directory is matched by spelling, so four cases fall outside the guarantee, as for any nested mount: on a disk that folds names, a mount configured through a link that spells a directory otherwise than disk does isn't found nested, and the outer mount holds its files as its own; on such a disk, a path through a link of the outer mount that spells a nested mount's directory otherwise than that mount's configured path does, whether the link or the configured path is the one spelled otherwise than disk, isn't found to lead there, and the outer mount reads, lists, searches and writes there as its own, creating files too, though a walk from its root passes over the directory; on such a disk, a place in a nested mount's directory that a link does lead to is judged by that mount's include, and named, in the spelling the link's target gives, so a refusal can name a file disk spells outside that include, or leave a covered one unnamed; and a nested mount whose root moves while running, as `acceptRoot` takes, stays placed where it was until the next start. - **Registered text:** text a host registers for a path with `registerGeneratedTextFile` is served there whatever the include says: every spelling that resolves to its path reads it, and none writes it. On a disk that folds names, a spelling in another case isn't its path, and reaches whatever file disk has there. - History from before the include stays as it was: no pass decides an entry outside it, a `materialize` of the whole mount leaves a deletion or conflict held outside it alone (one that names a directory inside the mount counts those beneath it under `passedOver`), the mount's `status` counts only what's inside, and a listing shows no directory that only such history holds. - - The framework keeps its own files (`tool-results/`, `refs/`, `background-scripts/`) in the first read-write mount without an include, and a setup whose only writable mount has one is treated as having none. The over-cap notice, the background-script log's text and the host's log line for a fetched reference it can't store say there is no writable mount without an include. What it kept in a mount before the mount had an include is outside the mount once it has one, unless the include covers it, so a `read` of a path an earlier spill note or reference gave is refused. + - The framework keeps its own files (`tool-results/`, `refs/`) in the first read-write mount without an include, and a setup whose only writable mount has one is treated as having none. The over-cap notice and the host's log line for a fetched reference it can't store say there is no writable mount without an include. What it kept in a mount before the mount had an include is outside the mount once it has one, unless the include covers it, so a `read` of a path an earlier spill note or reference gave is refused. diff --git a/changelog.d/tool-call-result-max-chars.added.md b/changelog.d/tool-call-result-max-chars.added.md index 7b3016fe7..3911b192d 100644 --- a/changelog.d/tool-call-result-max-chars.added.md +++ b/changelog.d/tool-call-result-max-chars.added.md @@ -1,5 +1,5 @@ - **Module authors:** a tool call now carries `resultMaxChars`: how long its result may be and still reach its caller whole, measured as the framework stores it (an error's text, or a success's `data` as JSON, with a content array taken as its text). A tool that pages or budgets its answer can stay under it. - A model's call carries the agent's effective inline cap: its `tool_result_inline_max_chars` setting, else the residence's `toolResultInlineMaxChars`, else the default 24,000, lowered to the strategy's per-message bound. Past it, the framework writes the result to a workspace file and stores its start, or only its start where no file can be written. A utility run through `utils` carries the same, and so does an operator's puppet call, a utility run through it included. - - A call from a running `code_execution` script carries the script's limit, 5,000,000 characters, past which a successful result the script receives is cut. A result that carries `structured` reaches the script instead as one JSON object of its content, structured content and error, measured whole; one past the limit is saved to a workspace file in its place, or, where it can't be, isn't delivered. A utility the script runs through `utils` carries the same. + - A call from a running `code_execution` script carries the longest result that reaches the script whole whatever its characters, from the script's memory. The script takes an answer of up to a third of its `memoryMaxBytes`, and a character of a result takes at most six bytes there, a control character escaped as `\u0000`. So the figure is that third, less the 80 bytes of a text answer's envelope, over six: 7,456,527 characters at the default 128 MiB, moving with the setting. On Node, past about 1.5 GiB, the host's longest string bounds it instead. A result with a `structured` value reaches the script as its content's blocks as they are, beside that value as JSON, and the figure counts neither the value nor what the blocks carry past their text, such as each further block's keys or an image's data, so a tool that returns one leaves room for them. A result nested deeper than 2,000 levels, or whose parse needs more of the script's memory than is free, doesn't reach the script whatever its length. A result that doesn't reach it is answered `ResultTooLarge`, saying the call took effect. Nothing is cut or saved to a file. A utility the script runs through `utils` carries the same figure. - A call made in process (`ModuleContext.callTool`, `executeToolCall`) carries what its caller put on it, and a utility it runs through `utils` carries what that call carried. Without it, a module holds its answer to a bound of its own, if any. - The module registry now passes every field of a call on to the module, `resultMaxChars` included. It used to pass `id`, `name`, `input`, `callerAgentName` and `callerTurnId` alone. diff --git a/changelog.d/tool-result-refused.added.md b/changelog.d/tool-result-refused.added.md new file mode 100644 index 000000000..dc6da6eac --- /dev/null +++ b/changelog.d/tool-result-refused.added.md @@ -0,0 +1 @@ +- **`ToolResult.refused`:** `true` when the dispatcher refused the call before it opened, as RFC-007's lifecycle refuses one, so nothing ran and the call certainly had no effect: the agent may not use the tool (its `allowedTools`), a `skip_reply` wake was refused, the call's MCPL server is absent or still connecting, the host's policy for that server denies the tool, or a fork reached outside its channel. The lifecycle refuses the call from the same place, so observers see no events for it. A check made after the call opened is a failure whose text says nothing was done, and a module's result can't claim `refused`. A script's call refused so reaches the script as an `Error`, any other failure as a result with `isError`. diff --git a/engine/build.sh b/engine/build.sh new file mode 100755 index 000000000..1a7c57fb0 --- /dev/null +++ b/engine/build.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Builds src/code-execution/quickjs.wasm, code_execution's engine, from quickjs-ng's release pinned +# below, with the patches in engine/patches applied, and engine/interface.c, with a pinned +# toolchain: wasi-sdk 32, checked against the sha256 below, and binaryen 130.0.0's wasm-opt from +# npm, pinned by version (npm checks it against the registry's integrity hash), which runs once. +# The toolchain is fetched into ENGINE_TOOLCHAIN_DIR (default ~/.cache/agent-framework-engine), and +# quickjs-ng's source into this build's own scratch directory, at every build. +# +# The engine changes through the pin or a patch. To bump, change QUICKJS_TAG and QUICKJS_COMMIT to +# the new release, and its line and notice in src/code-execution/quickjs.wasm.LICENSES, and delete +# each patch the release has taken in. To patch, add a `git diff` made +# in a clone of quickjs-ng at QUICKJS_COMMIT to engine/patches/, with a .patch name: the patches +# are applied in the byte order of their names, and there are none today. Either way, run this and +# commit src/code-execution/quickjs.wasm with the change. +# +# On x86_64 Linux, the same sources and toolchain have given the same bytes on two machines, and CI +# rebuilds there and compares the result with the committed module. The pinned wasi-sdk is the +# x86_64 Linux build; elsewhere, set WASI_SDK to an install of wasi-sdk 32, whose output may then +# differ from the committed module. +# +# Usage: engine/build.sh [OUT] (OUT defaults to src/code-execution/quickjs.wasm) +set -euo pipefail +# Byte order for the patches' names, whatever this machine's locale. +export LC_ALL=C + +QUICKJS_TAG=v0.17.0 +QUICKJS_COMMIT=6d46d07d04041b40f4f49eaa7fdebe44c314c699 +WASI_SDK_URL=https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-32/wasi-sdk-32.0-x86_64-linux.tar.gz +WASI_SDK_SHA256=55fc523ebfbc98f69d1034fcfcb83d1ff5610cd9ab7eceef6cd097a30ba4ef93 +BINARYEN_VERSION=130.0.0 + +here=$(cd "$(dirname "$0")" && pwd) +root=$(dirname "$here") +# Paths given relative to where this was run from, since it changes directory before it uses them. +absolute() { case $1 in /*) printf '%s\n' "$1" ;; *) printf '%s\n' "$PWD/$1" ;; esac; } +out=$(absolute "${1:-$root/src/code-execution/quickjs.wasm}") +cache=$(absolute "${ENGINE_TOOLCHAIN_DIR:-$HOME/.cache/agent-framework-engine}") +if [ -n "${WASI_SDK:-}" ]; then WASI_SDK=$(absolute "$WASI_SDK"); fi +mkdir -p "$cache" + +if [ -z "${WASI_SDK:-}" ]; then + if [ "$(uname -s)-$(uname -m)" != "Linux-x86_64" ]; then + echo "build.sh: the pinned wasi-sdk is the x86_64 Linux build; set WASI_SDK to an install of wasi-sdk 32" >&2 + exit 1 + fi + WASI_SDK=$cache/wasi-sdk-32 + if [ ! -x "$WASI_SDK/bin/clang" ]; then + tarball=$cache/wasi-sdk-32.tar.gz + curl -fsSL -o "$tarball" "$WASI_SDK_URL" + echo "$WASI_SDK_SHA256 $tarball" | sha256sum -c --quiet - + rm -rf "$WASI_SDK.tmp" + mkdir -p "$WASI_SDK.tmp" + tar -xzf "$tarball" -C "$WASI_SDK.tmp" --strip-components=1 + rm "$tarball" + mv "$WASI_SDK.tmp" "$WASI_SDK" + fi +fi +WASM_OPT=$cache/binaryen-$BINARYEN_VERSION/node_modules/.bin/wasm-opt +if [ ! -x "$WASM_OPT" ]; then + # npm checks the package against the registry's integrity hash. The directory gets a + # package.json of its own first: without one, npm installs into the nearest directory above + # that has one. + rm -rf "$cache/binaryen-$BINARYEN_VERSION" + mkdir -p "$cache/binaryen-$BINARYEN_VERSION" + echo '{ "private": true }' > "$cache/binaryen-$BINARYEN_VERSION/package.json" + (cd "$cache/binaryen-$BINARYEN_VERSION" && npm install --silent --no-audit --no-fund --no-save "binaryen@$BINARYEN_VERSION" >/dev/null) +fi + +CC=$WASI_SDK/bin/clang +SYSROOT=$WASI_SDK/share/wasi-sysroot +build=$(mktemp -d) +trap 'rm -rf "$build"' EXIT + +# git with none of this machine's configuration: no system or global config or attributes file, no +# hooks, no templates (it gets an empty directory of them), and no GIT_ variable from the +# environment but the network's (GIT_SSL_*, GIT_HTTP_*, GIT_PROXY_*). Any of the rest can change +# what a checkout or a patch writes, or where, while HEAD still names the pin; the network's carry +# only the fetch, whose objects the commit check after the clone stands for. So a proxy or a CA +# reaches the clone from the environment alone (https_proxy, HTTPS_PROXY, all_proxy, +# GIT_SSL_CAINFO), and a mirror a builder's git configuration names isn't taken. +nogit_home=$build/home +mkdir -p "$nogit_home/templates" +pinned_git() ( + for v in $(compgen -e); do + case $v in + GIT_SSL_* | GIT_HTTP_* | GIT_PROXY_*) ;; + GIT_*) unset "$v" ;; + esac + done + export HOME="$nogit_home" XDG_CONFIG_HOME="$nogit_home" GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \ + GIT_ATTR_NOSYSTEM=1 GIT_TEMPLATE_DIR="$nogit_home/templates" + git -c core.attributesFile=/dev/null -c core.hooksPath=/dev/null -c core.autocrlf=false "$@" +) + +# quickjs-ng at the pin, checked by its commit, whose hash names every byte of its files. The +# clone writes them as they're committed: upstream's .gitattributes only marks files for diffs. +src=$build/src +mkdir -p "$src" +pinned_git -c advice.detachedHead=false \ + clone --quiet --depth 1 --branch "$QUICKJS_TAG" https://github.com/quickjs-ng/quickjs.git "$src/quickjs-ng" +got=$(pinned_git -C "$src/quickjs-ng" rev-parse HEAD) +if [ "$got" != "$QUICKJS_COMMIT" ]; then + echo "build.sh: quickjs-ng $QUICKJS_TAG is $got upstream, not the pinned $QUICKJS_COMMIT" >&2 + exit 1 +fi +# The patches, each a regular file named *.patch, in an engine/patches that's a directory. +if { [ -e "$here/patches" ] || [ -L "$here/patches" ]; } && { [ ! -d "$here/patches" ] || [ -L "$here/patches" ]; }; then + echo "build.sh: engine/patches isn't a directory (a symbolic link, even to one, isn't taken); make it one, or remove it" >&2 + exit 1 +fi +shopt -s nullglob dotglob +patches=("$here"/patches/*) +shopt -u nullglob dotglob +# Guarded, since bash before 4.4 calls an empty array's expansion unbound under set -u. +for patch in ${patches[@]+"${patches[@]}"}; do + if [ "${patch%.patch}" = "$patch" ] || [ -L "$patch" ] || [ ! -f "$patch" ]; then + echo "build.sh: engine/patches/${patch##*/} isn't a regular file named *.patch (a symbolic link, even to one, isn't taken); make it one, or remove it" >&2 + exit 1 + fi + if ! pinned_git -C "$src/quickjs-ng" apply "$patch"; then + echo "build.sh: engine/patches/${patch##*/} doesn't apply to quickjs-ng $QUICKJS_TAG" >&2 + exit 1 + fi +done +cp "$here/interface.c" "$src/interface.c" + +# Relative paths, so nothing of this machine's layout, nor the scratch directory's, reaches the +# module: the sources are compiled as quickjs-ng/*.c and interface.c, as they always have been. +cd "$src" +CFLAGS=( + --target=wasm32-wasip1 --sysroot="$SYSROOT" -Oz -flto + -Iquickjs-ng -D_GNU_SOURCE -DQJS_BUILD_LIBC=0 + -D_WASI_EMULATED_PROCESS_CLOCKS -D_WASI_EMULATED_SIGNAL + -fvisibility=hidden + -Wall -Wno-implicit-fallthrough -Wno-sign-compare -Wno-missing-field-initializers + -Wno-unused-parameter -Wno-unused-but-set-variable +) +for f in quickjs dtoa libregexp libunicode; do + "$CC" "${CFLAGS[@]}" -c "quickjs-ng/$f.c" -o "$build/$f.o" +done +"$CC" "${CFLAGS[@]}" -Werror -c interface.c -o "$build/interface.o" + +# A reactor whose memory the host makes, so the host sets its maximum, the child's budget. +# The stack sits first in memory, at [0, 1 MiB), growing down from its top. QuickJS's limit is +# set below the top as engine_init finds it, 192 KiB at most, and recursion its checks don't see +# that runs past the stack's bottom wraps below address 0, past the memory's end, and traps +# rather than writing over data. +"$CC" --target=wasm32-wasip1 --sysroot="$SYSROOT" -Oz -flto -mexec-model=reactor \ + -lwasi-emulated-process-clocks -lwasi-emulated-signal \ + -Wl,--import-memory -Wl,--stack-first -Wl,-z,stack-size=1048576 -Wl,--strip-debug \ + "$build"/*.o -o "$build/linked.wasm" + +"$WASM_OPT" -Oz --strip-debug --strip-producers "$build/linked.wasm" -o "$build/quickjs.wasm" +mkdir -p "$(dirname "$out")" +cp "$build/quickjs.wasm" "$out" +# macOS has shasum, not sha256sum. +if command -v sha256sum >/dev/null; then sum=$(sha256sum "$out"); else sum=$(shasum -a 256 "$out"); fi +size=$(wc -c < "$out") +echo "${sum%% *} ${size// /} bytes $out" diff --git a/engine/interface.c b/engine/interface.c new file mode 100644 index 000000000..64e8059e4 --- /dev/null +++ b/engine/interface.c @@ -0,0 +1,780 @@ +/* + * code_execution's engine: agent-framework's interface to QuickJS-ng, compiled with the release + * engine/build.sh pins into one WebAssembly module, src/code-execution/quickjs.wasm. + * + * It is held to what code_execution's bridge needs: limits, evaluating the prelude and a script, + * one host function whose calls the host settles by id, running jobs, the interrupt, and output. + * The guest reaches nothing outside JavaScript except three of the imports below, a host call, + * the interrupt and output, which only JSON text and output text cross, and the clocks: Date.now + * and performance.now read WASI's clock_time_get. The fourth import, host_supervise, only the + * interrupt handler calls, and nothing crosses it. libc's other WASI calls, four random bytes as + * the module loads and stdio for the engine's own diagnostics, aren't reachable from guest code. + * The host's side is src/code-execution/engine.ts. + * + * One runtime and one context per module instance, made by engine_init. A child process holds + * one instance for one script, so nothing here is reentrant or thread-safe. + */ +#include +#include +#include +#include +#include + +#include "quickjs.h" + +#define IMPORT(name) __attribute__((import_module("env"), import_name(name))) +#define EXPORT(name) __attribute__((export_name(name))) + +/* The guest called __host(name, args): the host takes call `id`, to settle later with + engine_settle. Returns 0 when the host took it, nonzero when the host failed (the binding + keeps why). */ +IMPORT("host_call") +extern int host_call(uint32_t id, const char *name, uint32_t name_len, + const char *args, uint32_t args_len); + +/* QuickJS's interrupt handler, called while the guest runs. Nonzero stops the guest for good. */ +IMPORT("host_interrupt") +extern int host_interrupt(void); + +/* Called at every interrupt check, before host_interrupt is asked and after it has said stop, a + failed import's stop included: the host's watch over its own process, such as its parent + going, which no stop or failure ends. It answers nothing itself: a stop it calls for, as an + orphan with no way to end its process does, comes through host_interrupt. */ +IMPORT("host_supervise") +extern void host_supervise(void); + +/* The guest called __output(stream, text), stream 1 for out and 2 for err. Returns 0, or + nonzero when the host failed. */ +IMPORT("host_output") +extern int host_output(uint32_t stream, const char *text, uint32_t len); + +/* How a call into the guest left it; engine.ts mirrors these. */ +enum { + ENGINE_PENDING = 0, /* the queue is empty and the script hasn't settled */ + ENGINE_FULFILLED = 1, /* the script settled with a value: engine_result has it */ + ENGINE_REJECTED = 2, /* the script, or the prelude, threw: engine_result has why */ + ENGINE_INTERRUPTED = 3, /* host_interrupt returned nonzero while it ran */ + ENGINE_HOST_FAILED = 4, /* an import reported failure: the run is over, and the host refuses + the guest's later calls and output and stops it at its next + interrupt check */ + ENGINE_FAILED = 5, /* once the script was running, an exception escaped its promises (a + callback it gave queueMicrotask or a FinalizationRegistry threw, + or a job failed), or the engine failed to settle a call. The run + is over, every later call answers this, and engine_result has the + exception */ + ENGINE_MISUSE = -1, /* called out of order, or with a malformed buffer */ + ENGINE_NO_MEMORY = -2, /* the engine couldn't allocate its own state */ + ENGINE_UNKNOWN_CALL = -3 /* engine_settle named no pending call */ +}; +/* Or'd into engine_settle's state when the result didn't fit and the call took the host's + rejection instead. */ +#define ENGINE_UNFIT 16 +/* Or'd into engine_settle's state when the call's resolve or reject function never ran with the + result, nor with the host's rejection: the run had been stopped or had failed before this + settle, or the call of that function was stopped or failed before it ran. Never with + ENGINE_UNFIT. */ +#define ENGINE_DROPPED 32 + +static JSRuntime *rt; +static JSContext *ctx; +/* Call id -> [resolve, reject]. Held only here, so guest code can't reach it. */ +static JSValue pending; +/* The script's completion promise from JS_EVAL_FLAG_ASYNC, which settles to { value }. */ +static JSValue completion; +/* What ended the run when it wasn't the completion promise: the prelude's or the script's + synchronous throw, or an exception that escaped a job or settling a call; and the state it + gives, ENGINE_REJECTED or ENGINE_FAILED. */ +static JSValue failure; +static int failure_state; +static bool started; +static bool failed; +static bool interrupted; +static bool host_failed; +/* engine_result is serializing the script's value or error, which can run the script's own code + (a toJSON, a getter) after its ending is fixed. */ +static bool reading; +static uint32_t next_id = 1; + +/* + * QuickJS's allocations, held to the memory limit here rather than by JS_SetMemoryLimit. + * Adapted from quickjs-wasi 3.6.2's c/interface.c (MIT, Copyright (c) 2026 Vercel, Inc.; the + * notice is in src/code-execution/quickjs.wasm.LICENSES). + * + * QuickJS checks its own limit before it allocates, and refuses at exactly the limit. When a + * script reaches it with many small allocations, the "out of memory" InternalError QuickJS then + * builds is refused too, and the script's catch gets a bare null (the engine test's small-objects + * case shows it). So QuickJS's own limit stays off, and these functions refuse ordinary + * allocations at a soft limit OOM_HEADROOM under the limit. After a refusal, allocations may use + * the headroom up to the limit, so that QuickJS can build the error and the script's handler can + * run, and the next allocation that fits under the soft limit closes it again. So a script that + * reaches the limit while the headroom is closed gets a real out-of-memory error, as long as + * building that error doesn't reach this allocator twice with the first fitting under the soft + * limit, which would close the headroom before the second. Most of what builds an error comes from + * blocks QuickJS's arenas already hold, which don't reach this allocator, so that's rare. The + * host's buffers (engine_alloc) are held to the same limits, so a refused copy opens the headroom + * too, and the child's unfit error can still be copied in. Whatever runs while it's open can spend + * it: a script that catches the error and goes on allocating without freeing, or a result settled + * while the guest sits at the soft limit, whose parse goes in once a copy's refusal has opened it. + * An out-of-memory error after that can be a bare null: keeping the headroom for the error alone + * would take QuickJS's own in_out_of_memory flag, which its API doesn't expose. And a promise + * reaction whose job QuickJS can't allocate is dropped (fulfill_or_reject_promise ignores + * JS_EnqueueJob's failure), so the await it would have resumed never settles, and the engine + * answers pending with no call in flight. + */ +#define OOM_HEADROOM (64 * 1024) + +static size_t mem_limit; /* 0: no limit */ +static size_t mem_used; /* the usable size of every live allocation */ +static bool mem_in_oom; /* a refusal has opened the headroom */ + +static size_t soft_limit(void) { + return mem_limit > 2 * OOM_HEADROOM ? mem_limit - OOM_HEADROOM : mem_limit / 2; +} + +/* Whether mem_used + size would pass cap, without overflowing. */ +static bool exceeds(size_t size, size_t cap) { + return size > cap || mem_used > cap - size; +} + +static bool refuse(size_t size) { + if (mem_limit == 0) + return false; + if (!exceeds(size, soft_limit())) { + mem_in_oom = false; + return false; + } + if (mem_in_oom && !exceeds(size, mem_limit)) + return false; + mem_in_oom = true; + return true; +} + +static void *limited_calloc(void *opaque, size_t count, size_t size) { + (void)opaque; + /* QuickJS has checked count * size for overflow. */ + if (refuse(count * size)) + return NULL; + void *ptr = calloc(count, size); + if (ptr) + mem_used += malloc_usable_size(ptr); + return ptr; +} + +static void *limited_malloc(void *opaque, size_t size) { + (void)opaque; + if (refuse(size)) + return NULL; + void *ptr = malloc(size); + if (ptr) + mem_used += malloc_usable_size(ptr); + return ptr; +} + +static void limited_free(void *opaque, void *ptr) { + (void)opaque; + if (!ptr) + return; + size_t usable = malloc_usable_size(ptr); + mem_used -= usable <= mem_used ? usable : mem_used; + free(ptr); +} + +static void *limited_realloc(void *opaque, void *ptr, size_t size) { + (void)opaque; + size_t old_usable = ptr ? malloc_usable_size(ptr) : 0; + /* Only growth is held to the limit: a shrinking realloc releases memory. */ + size_t growth = size > old_usable ? size - old_usable : 0; + if (growth > 0 && refuse(growth)) + return NULL; + void *new_ptr = realloc(ptr, size); + if (new_ptr) { + mem_used -= old_usable <= mem_used ? old_usable : mem_used; + mem_used += malloc_usable_size(new_ptr); + } + return new_ptr; +} + +static size_t limited_usable_size(const void *ptr) { + return malloc_usable_size((void *)ptr); +} + +static const JSMallocFunctions limited_malloc_funcs = { + limited_calloc, limited_malloc, limited_free, limited_realloc, limited_usable_size, +}; + +/* Once the host has stopped the guest, every later check stops it too, without asking again, so + a stop holds for good even if the host would say otherwise later. Its supervision runs at every + check all the same: a stop the script keeps from landing, as below, leaves the guest running, + and the host still has to see its own process's life, such as its parent going. The engine + runs none of the script's code after a stop: every entry point checks the flag first, and + error_json ends its read at one. QuickJS does: building an error's backtrace converts + Error.stackTraceLimit, which can call a valueOf, and calls Error.prepareStackTrace, for the + error a stop throws as for any other. A stop that lands in that code is lost when QuickJS puts + back the error it was building, and the script goes on from there. This ends such code at its + next check, and __host and __output refuse it, so none of it reaches the host. QuickJS checks + at its first poll and once in 10,000 after, so a check ends the code running then, not at + once. */ +static int interrupt_handler(JSRuntime *r, void *opaque) { + (void)r; + (void)opaque; + host_supervise(); + if (interrupted || host_interrupt()) { + interrupted = true; + return 1; + } + return 0; +} + +/* __host(name, args): name and args are strings (the prelude stringifies the arguments). + Returns a promise the host settles with engine_settle. Once the script has ended, while its + ending is read, or once it's stopped, the call is refused. */ +static JSValue js_host(JSContext *c, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; + if (reading || interrupted) + return JS_ThrowInternalError(c, "the script's calls no longer reach the host"); + if (argc < 2 || !JS_IsString(argv[0]) || !JS_IsString(argv[1])) + return JS_ThrowTypeError(c, "__host takes a tool name and its arguments as JSON text"); + if (next_id == UINT32_MAX) + return JS_ThrowRangeError(c, "__host: no call ids left"); + + JSValue funcs[2]; + JSValue promise = JS_NewPromiseCapability(c, funcs); + if (JS_IsException(promise)) + return promise; + /* JS_NewArrayFrom takes both functions, and frees them if it fails. */ + JSValue entry = JS_NewArrayFrom(c, 2, funcs); + if (JS_IsException(entry)) { + JS_FreeValue(c, promise); + return JS_EXCEPTION; + } + + size_t name_len, args_len; + const char *name = JS_ToCStringLen(c, &name_len, argv[0]); + if (!name) { + JS_FreeValue(c, entry); + JS_FreeValue(c, promise); + return JS_EXCEPTION; + } + const char *args = JS_ToCStringLen(c, &args_len, argv[1]); + if (!args) { + JS_FreeCString(c, name); + JS_FreeValue(c, entry); + JS_FreeValue(c, promise); + return JS_EXCEPTION; + } + + uint32_t id = next_id; + if (JS_SetPropertyUint32(c, pending, id, entry) < 0) { + JS_FreeCString(c, name); + JS_FreeCString(c, args); + JS_FreeValue(c, promise); + return JS_EXCEPTION; + } + next_id++; + int refused = host_call(id, name, (uint32_t)name_len, args, (uint32_t)args_len); + JS_FreeCString(c, name); + JS_FreeCString(c, args); + if (refused) { + JSAtom atom = JS_NewAtomUInt32(c, id); + if (atom != JS_ATOM_NULL) { + JS_DeleteProperty(c, pending, atom, 0); + JS_FreeAtom(c, atom); + } + JS_FreeValue(c, promise); + host_failed = true; + return JS_ThrowInternalError(c, "the host failed while taking a call"); + } + return promise; +} + +/* __output(stream, text): stream 1 is out and 2 is err. Refused as __host is. */ +static JSValue js_output(JSContext *c, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; + if (reading || interrupted) + return JS_ThrowInternalError(c, "the script's output no longer reaches the host"); + int32_t stream; + if (argc < 2 || !JS_IsNumber(argv[0]) || !JS_IsString(argv[1])) + return JS_ThrowTypeError(c, "__output takes a stream, 1 or 2, and a string"); + if (JS_ToInt32(c, &stream, argv[0]) < 0) + return JS_EXCEPTION; + if (stream != 1 && stream != 2) + return JS_ThrowRangeError(c, "__output's stream is 1 or 2"); + size_t len; + const char *text = JS_ToCStringLen(c, &len, argv[1]); + if (!text) + return JS_EXCEPTION; + int refused = host_output((uint32_t)stream, text, (uint32_t)len); + JS_FreeCString(c, text); + if (refused) { + host_failed = true; + return JS_ThrowInternalError(c, "the host failed while taking output"); + } + return JS_UNDEFINED; +} + +/* Keeps `exc` as what ended the run, unless something already did, with the state it gives: + ENGINE_REJECTED for the prelude's or the script's own throw as it starts, ENGINE_FAILED for an + exception that escapes once it runs. Takes `exc`. */ +static void fail_with(JSValue exc, int st) { + if (failed) { + JS_FreeValue(ctx, exc); + return; + } + failure = exc; + failure_state = st; + failed = true; +} + +/* Runs pending jobs one at a time until none is left or the run has ended. */ +static void run_jobs(void) { + for (;;) { + if (interrupted || host_failed || failed) + return; + JSContext *job_ctx; + int r = JS_ExecutePendingJob(rt, &job_ctx); + if (r == 0) + return; + if (r < 0) { + /* A promise job catches what its handler throws, but the job of a queueMicrotask or + FinalizationRegistry callback passes its throw on, and so does a failure of the + job machinery itself, or an uncatchable error. */ + JSValue exc = JS_GetException(job_ctx); + if (interrupted || host_failed) + JS_FreeValue(ctx, exc); + else + fail_with(exc, ENGINE_FAILED); + return; + } + } +} + +static int state(void) { + /* An exception that escaped once the script ran comes first: nothing records one once the + guest is stopped or an import has failed, so it came before either, and stays the answer + after one. A throw as the run starts is the script's own ending, which a stop that comes + while it's read turns into a stop, as it does a rejection. */ + if (failed && failure_state == ENGINE_FAILED) + return ENGINE_FAILED; + if (interrupted) + return ENGINE_INTERRUPTED; + if (host_failed) + return ENGINE_HOST_FAILED; + if (failed) + return failure_state; + switch (JS_PromiseState(ctx, completion)) { + case JS_PROMISE_FULFILLED: + return ENGINE_FULFILLED; + case JS_PROMISE_REJECTED: + return ENGINE_REJECTED; + case JS_PROMISE_PENDING: + return ENGINE_PENDING; + default: + return ENGINE_MISUSE; + } +} + +/* Makes the runtime and context. memory_limit is the limit on QuickJS's allocations, which the + binding sets a margin under the linear memory's maximum (0 means none). stack_size is the + stack QuickJS allows below this call, which the binding keeps under the link-time stack. */ +EXPORT("engine_init") +int engine_init(uint32_t memory_limit, uint32_t stack_size) { + if (rt) + return ENGINE_MISUSE; + mem_limit = memory_limit; + rt = JS_NewRuntime2(&limited_malloc_funcs, NULL); + if (!rt) + return ENGINE_NO_MEMORY; + JS_SetMaxStackSize(rt, stack_size); + JS_SetInterruptHandler(rt, interrupt_handler, NULL); + ctx = JS_NewContext(rt); + if (!ctx) { + JS_FreeRuntime(rt); + rt = NULL; + return ENGINE_NO_MEMORY; + } + completion = JS_UNDEFINED; + failure = JS_UNDEFINED; + pending = JS_NewObjectProto(ctx, JS_NULL); + if (JS_IsException(pending)) + return ENGINE_NO_MEMORY; + JSValue global = JS_GetGlobalObject(ctx); + int bad = JS_SetPropertyStr(ctx, global, "__host", JS_NewCFunction(ctx, js_host, "__host", 2)) < 0 || + JS_SetPropertyStr(ctx, global, "__output", JS_NewCFunction(ctx, js_output, "__output", 2)) < 0; + JS_FreeValue(ctx, global); + return bad ? ENGINE_NO_MEMORY : 0; +} + +/* Evaluates the prelude as a global script, then the script as global code with top-level await, + then runs jobs. Each buffer must end in a NUL at [len], as JS_Eval requires; filename is a + NUL-terminated string. Called once. */ +EXPORT("engine_start") +int engine_start(const char *prelude, uint32_t prelude_len, const char *script, uint32_t script_len, + const char *filename) { + if (!ctx || started || !prelude || !script || !filename || prelude[prelude_len] != '\0' || + script[script_len] != '\0') + return ENGINE_MISUSE; + started = true; + JSValue r = JS_Eval(ctx, prelude, prelude_len, "", JS_EVAL_TYPE_GLOBAL); + if (JS_IsException(r)) { + JSValue exc = JS_GetException(ctx); + if (interrupted || host_failed) + JS_FreeValue(ctx, exc); + else + fail_with(exc, ENGINE_REJECTED); + return state(); + } + JS_FreeValue(ctx, r); + r = JS_Eval(ctx, script, script_len, filename, JS_EVAL_TYPE_GLOBAL | JS_EVAL_FLAG_ASYNC); + if (JS_IsException(r)) { + /* A syntax error, or a failure before the async function started. */ + JSValue exc = JS_GetException(ctx); + if (interrupted || host_failed) + JS_FreeValue(ctx, exc); + else + fail_with(exc, ENGINE_REJECTED); + return state(); + } + completion = r; + run_jobs(); + return state(); +} + +/* Frees one of the host's buffers, and forgets it. */ +static void release(char **buf) { + limited_free(NULL, *buf); + *buf = NULL; +} + +/* engine_settle's work, which frees each buffer as soon as it's done with it. */ +static int settle(uint32_t id, int ok, char **json, uint32_t json_len, char **unfit, + uint32_t unfit_len) { + if (!started || (*json && (*json)[json_len] != '\0') || (*unfit && (*unfit)[unfit_len] != '\0')) + return ENGINE_MISUSE; + JSValue entry = JS_GetPropertyUint32(ctx, pending, id); + if (JS_IsException(entry)) { + JS_FreeValue(ctx, JS_GetException(ctx)); + return ENGINE_NO_MEMORY; + } + if (JS_IsUndefined(entry)) + return ENGINE_UNKNOWN_CALL; + JSAtom atom = JS_NewAtomUInt32(ctx, id); + if (atom == JS_ATOM_NULL) { + JS_FreeValue(ctx, entry); + JS_FreeValue(ctx, JS_GetException(ctx)); + return ENGINE_NO_MEMORY; + } + JS_DeleteProperty(ctx, pending, atom, 0); + JS_FreeAtom(ctx, atom); + if (interrupted || host_failed || failed) { + JS_FreeValue(ctx, entry); + return state() | ENGINE_DROPPED; + } + + JSValue value = JS_UNDEFINED; + bool resolve = ok != 0; + bool too_large = !*json; + if (*json) { + value = JS_ParseJSON(ctx, *json, json_len, ""); + release(json); + if (JS_IsException(value)) { + JS_FreeValue(ctx, JS_GetException(ctx)); + too_large = true; + } + } + if (too_large) { + resolve = false; + if (*unfit) { + value = JS_ParseJSON(ctx, *unfit, unfit_len, ""); + } else { + JS_ThrowOutOfMemory(ctx); + value = JS_EXCEPTION; + } + if (JS_IsException(value)) + value = JS_GetException(ctx); + } + release(unfit); + JSValue fn = JS_GetPropertyUint32(ctx, entry, resolve ? 0 : 1); + JS_FreeValue(ctx, entry); + if (JS_IsException(fn)) { + JS_FreeValue(ctx, value); + fail_with(JS_GetException(ctx), ENGINE_FAILED); + return state() | ENGINE_DROPPED; + } + JSValue r = JS_Call(ctx, fn, JS_UNDEFINED, 1, (JSValueConst *)&value); + JS_FreeValue(ctx, fn); + JS_FreeValue(ctx, value); + if (JS_IsException(r)) { + /* A promise's resolve and reject functions don't throw once they run (what reading a + result's `then` throws rejects the promise), so the call never ran: the interrupt check + on its way in stopped the guest, or there was no stack left to make it. */ + JSValue exc = JS_GetException(ctx); + if (interrupted || host_failed) + JS_FreeValue(ctx, exc); + else + fail_with(exc, ENGINE_FAILED); + return state() | ENGINE_DROPPED; + } + JS_FreeValue(ctx, r); + run_jobs(); + int st = state(); + return too_large ? st | ENGINE_UNFIT : st; +} + +/* Settles call `id`, then runs jobs. With ok, the call's promise resolves to the parsed JSON; + otherwise it rejects with it. A result the guest can't take rejects it with the parse of unfit + instead, the host's own error, and the state answered has ENGINE_UNFIT set: a null json, which + the host couldn't copy in, or a parse that fails, for want of memory or stack (or because it + isn't JSON, which a host's result never is). Any of these took effect without reaching the + script, which an error at the call's await would hide. A result nested deeper than the + binding's MAX_RESULT_DEPTH never gets here: under Node, a parse nested about 8,000 deep (5,000 + with V8's optimizing tier alone) overflows the host's stack before QuickJS's own check sees the + recursion, and the binding refuses results well short of that. When the run had been stopped + or had failed before this settle, or the call of the promise's resolve or reject function is + stopped or fails before it runs, the state answered has ENGINE_DROPPED set: that function never + ran with the result. Once it has, the result is handed over, and the script's own code runs + with it: a `then` getter the script put on Object.prototype, which can reject the promise + instead, and its continuations. A stop or failure in that code leaves the result handed over. + + json and unfit are the host's buffers from engine_alloc, each null or ending in a NUL at [len] + as JS_ParseJSON requires, and this frees both, whatever it returns: the result's copy as soon + as it's parsed, so it's gone before the unfit error is parsed, and the unfit error's once it's + parsed or isn't needed, before the script's code runs. A parse that fails frees what it built + too. If even the unfit error can't be copied in (a null unfit) or parsed, the call rejects with + what that failure gives. */ +EXPORT("engine_settle") +int engine_settle(uint32_t id, int ok, char *json, uint32_t json_len, char *unfit, + uint32_t unfit_len) { + int st = settle(id, ok, &json, json_len, &unfit, unfit_len); + release(&json); + release(&unfit); + return st; +} + +/* What a read or a conversion that ran the script's code gave, once a stop has come: dropped, + with its exception, if it threw. A stop that lands in a stack hook or an iterator's return, which + QuickJS drops, lets that code go on and give a part after the stop, which isn't kept. */ +static JSValue drop_late(JSValue p) { + if (JS_IsException(p)) + JS_FreeValue(ctx, JS_GetException(ctx)); + else + JS_FreeValue(ctx, p); + return JS_UNDEFINED; +} + +/* One part of an object, for error_json: read, and made a string at once, so a stop in a later + part's read can't take it. Nothing is read once a stop has come, and a part whose read or + string form ends after a stop is left out. A part that isn't there, or whose read throws, is + undefined; one with no string form is null, which error_json leaves out with no stand-in, as it + does a stack that isn't a string. */ +static JSValue error_part(JSValue v, const char *key, bool any) { + if (interrupted) + return JS_UNDEFINED; + JSValue p = JS_GetPropertyStr(ctx, v, key); + if (interrupted) + return drop_late(p); + if (any && !JS_IsException(p) && !JS_IsUndefined(p) && !JS_IsString(p)) { + JSValue s = JS_ToString(ctx, p); + JS_FreeValue(ctx, p); + if (interrupted) + return drop_late(s); + if (JS_IsException(s)) { + JS_FreeValue(ctx, JS_GetException(ctx)); + return JS_NULL; + } + p = s; + } + if (JS_IsException(p)) { + JS_FreeValue(ctx, JS_GetException(ctx)); + return JS_UNDEFINED; + } + if (!JS_IsString(p)) { + JS_FreeValue(ctx, p); + return JS_UNDEFINED; + } + return p; +} + +/* The text of a value or an error, for engine_result. Takes v. Returns a JS string, undefined, + or an exception. Reading an object's parts can run the script's own code (a getter, a toJSON, a + toString), and a stop ends the read: what was read before it stays, and nothing more is read, + neither a part nor a stand-in for a missing message, and what such code gives after a stop + that QuickJS dropped is left out. The message is read first, as the part most worth keeping, + and each part is a string before the next is read. */ +static JSValue error_json(JSValue v) { + /* No prototype, so a toJSON the script put on Object.prototype doesn't serialize it. */ + JSValue o = JS_NewObjectProto(ctx, JS_NULL); + if (JS_IsException(o)) { + JS_FreeValue(ctx, v); + return o; + } + JSValue name = JS_UNDEFINED, message = JS_UNDEFINED, stack = JS_UNDEFINED; + if (JS_IsObject(v)) { + message = error_part(v, "message", true); + name = error_part(v, "name", true); + stack = error_part(v, "stack", false); + /* An object with no message of its own, such as a plain object thrown, or one whose + message can't be read, is told by its JSON, or by its string form when it has none, + rather than left blank. */ + if (JS_IsUndefined(message) && !interrupted) { + message = JS_JSONStringify(ctx, v, JS_UNDEFINED, JS_UNDEFINED); + if (interrupted) { + message = drop_late(message); + } else if (JS_IsException(message)) { + JS_FreeValue(ctx, JS_GetException(ctx)); + message = JS_UNDEFINED; + } + if (JS_IsUndefined(message) && !interrupted) { + message = JS_ToString(ctx, v); + if (interrupted) + message = drop_late(message); + } + } + } else { + /* A primitive's string form runs no script code, but a Symbol has none: its conversion + throws a TypeError, whose backtrace can run the script's stack hook, and the error is left + out with nothing the hook gave. */ + message = JS_ToString(ctx, v); + } + /* A stand-in that throws, or a primitive with no string form, such as a Symbol, is left out. */ + JSValue parts[3] = {name, message, stack}; + const char *keys[3] = {"name", "message", "stack"}; + for (int i = 0; i < 3; i++) { + JSValue p = parts[i]; + if (JS_IsException(p)) + JS_FreeValue(ctx, JS_GetException(ctx)); + else if (!JS_IsString(p)) + JS_FreeValue(ctx, p); + else if (JS_SetPropertyStr(ctx, o, keys[i], p) < 0) + JS_FreeValue(ctx, JS_GetException(ctx)); + } + JS_FreeValue(ctx, v); + JSValue text = JS_JSONStringify(ctx, o, JS_UNDEFINED, JS_UNDEFINED); + JS_FreeValue(ctx, o); + return text; +} + +/* Copies a JS string into a buffer for the host: [u32 kind][u32 length][UTF-8 bytes]. */ +static uint8_t *pack(uint32_t kind, JSValue text) { + size_t len = 0; + const char *s = NULL; + if (JS_IsString(text)) { + s = JS_ToCStringLen(ctx, &len, text); + if (!s) { + JS_FreeValue(ctx, JS_GetException(ctx)); + JS_FreeValue(ctx, text); + return NULL; + } + } + uint8_t *buf = limited_malloc(NULL, 8 + len); + if (buf) { + memcpy(buf, &kind, 4); + uint32_t len32 = (uint32_t)len; + memcpy(buf + 4, &len32, 4); + if (len) + memcpy(buf + 8, s, len); + } + if (s) + JS_FreeCString(ctx, s); + JS_FreeValue(ctx, text); + return buf; +} + +/* The ending as engine_result gives it: the value's JSON (kind 0 or 1), or an error's (kind 2). */ +static uint8_t *read_ending(int st) { + JSValue text; + uint32_t kind = 2; + if (st != ENGINE_FULFILLED) { + text = error_json(failed ? JS_DupValue(ctx, failure) : JS_PromiseResult(ctx, completion)); + } else { + JSValue settled = JS_PromiseResult(ctx, completion); + JSValue value = JS_GetPropertyStr(ctx, settled, "value"); + JS_FreeValue(ctx, settled); + if (JS_IsException(value)) { + text = error_json(JS_GetException(ctx)); + } else { + text = JS_JSONStringify(ctx, value, JS_UNDEFINED, JS_UNDEFINED); + JS_FreeValue(ctx, value); + if (JS_IsException(text)) + text = error_json(JS_GetException(ctx)); + else + kind = JS_IsUndefined(text) ? 0 : 1; + } + } + if (JS_IsException(text)) { + JS_FreeValue(ctx, JS_GetException(ctx)); + return NULL; + } + return pack(kind, text); +} + +/* After ENGINE_FULFILLED, ENGINE_REJECTED or ENGINE_FAILED: a buffer the host frees with + engine_free, [u32 kind][u32 length][UTF-8]. Kind 0: fulfilled with undefined (or a value + JSON.stringify leaves out), no text. Kind 1: fulfilled, the value's JSON. Kind 2: an error, as + JSON {name?, message?, stack?}: what the script threw, why its value couldn't be stringified, + or the exception that ended the run. + Returns 0 in any other state, when it can't allocate, or when a stop lands while the script's own + ending is read, which leaves none to give. The ending is fixed before it's read. The script's own + code that serializing runs (a toJSON, a getter), and the jobs it queues, which run before this + returns until the run fails or is stopped, can't reach the host; a reaction it attaches to a + promise still pending runs when that settles, as the script's other continuations do after its + ending. A stop in those jobs comes after the ending was read, so this returns the ending; a + failure there ends the run after its ending, which engine_state then answers. An exception that + ended the run is what ended it however its read goes, so a stop while it's read leaves out the + parts the stop cut off (see error_json), and the rest is returned. Data nested deeper than the + host's stack allows can overflow it in JS_JSONStringify, which throws out of the module: the + binding reports that as a crash. */ +EXPORT("engine_result") +uint8_t *engine_result(void) { + int st = state(); + if (st != ENGINE_FULFILLED && st != ENGINE_REJECTED && st != ENGINE_FAILED) + return NULL; + reading = true; + uint8_t *buf = read_ending(st); + if (interrupted && st != ENGINE_FAILED) { + /* What was read is the stop's error, not the ending. */ + limited_free(NULL, buf); + buf = NULL; + } else { + run_jobs(); + } + reading = false; + return buf; +} + +/* The run's state as engine_start and engine_settle answer it, for the host to read again after + engine_result: the jobs reading an ending queues run before that returns, and an exception + escaping them ends the run after its ending. */ +EXPORT("engine_state") +int engine_state(void) { + return started ? state() : ENGINE_MISUSE; +} + +/* Buffers the host fills for engine_start and engine_settle. The host frees engine_start's after + that call, and engine_result's once it has read it; engine_settle frees its own. They're held + to the same limit as QuickJS's allocations: a result's copy and the value parsed from it share + the guest's memory, and with the copy outside the limit, the two together could exhaust linear + memory before the limit refused anything, so the parse's failure would look like no refusal + and an out-of-memory error couldn't be built. */ +EXPORT("engine_alloc") +void *engine_alloc(uint32_t size) { + return limited_malloc(NULL, size ? size : 1); +} + +EXPORT("engine_free") +void engine_free(void *ptr) { + limited_free(NULL, ptr); +} + +/* Frees the context and runtime. Nothing may be called after it. */ +EXPORT("engine_dispose") +void engine_dispose(void) { + if (!rt) + return; + JS_FreeValue(ctx, completion); + JS_FreeValue(ctx, failure); + JS_FreeValue(ctx, pending); + JS_FreeContext(ctx); + JS_FreeRuntime(rt); + ctx = NULL; + rt = NULL; +} diff --git a/package.json b/package.json index b7572055c..53d017a4a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ }, "scripts": { "build": "tsc", - "postbuild": "node -e \"const fs=require('node:fs'); for (const p of ['dist/src/api/mcp-server.js','dist/src/recovery/recover-cli.js']) fs.chmodSync(p, 0o755)\"", + "postbuild": "node -e \"const fs=require('node:fs'); for (const p of ['dist/src/api/mcp-server.js','dist/src/recovery/recover-cli.js']) fs.chmodSync(p, 0o755); fs.copyFileSync('src/code-execution/quickjs.wasm', 'dist/src/code-execution/quickjs.wasm')\"", + "predev": "node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/src/code-execution', { recursive: true }); fs.copyFileSync('src/code-execution/quickjs.wasm', 'dist/src/code-execution/quickjs.wasm')\"", "dev": "tsc --watch", "clean": "rm -rf dist", "typecheck": "tsc --noEmit", diff --git a/src/code-execution/engine.ts b/src/code-execution/engine.ts new file mode 100644 index 000000000..6533e0162 --- /dev/null +++ b/src/code-execution/engine.ts @@ -0,0 +1,595 @@ +/** + * code_execution's engine binding: QuickJS-ng compiled to WebAssembly (`quickjs.wasm`, built by + * `engine/build.sh` from the quickjs-ng release it pins and `engine/interface.c`), loaded into the child + * process that runs one script. + * + * The guest reaches nothing outside JavaScript except three of the slots the child fills: a host + * call, the interrupt check and output. The fourth, supervision, the guest can't reach: the engine + * calls it at each interrupt check. Only text crosses them: a tool's name and its arguments as + * JSON, output, and each result's JSON on the way back in. The module's memory is made here, with + * the maximum the child is given, so that maximum bounds everything the guest holds. + */ +import { readFile } from 'node:fs/promises'; +import { randomFillSync } from 'node:crypto'; + +/** What the child sets once, before any script code runs. */ +export interface EngineLimits { + /** + * The linear memory's maximum, the guest's budget: a whole number of 64 KiB pages, from + * `MIN_MEMORY_BYTES` to `MAX_MEMORY_BYTES`. The engine's own stack and data take its first ~1.1 MiB, and + * QuickJS's allocation limit sits a margin below the rest, so that a script reaching it gets a + * catchable out-of-memory error rather than a failed allocation inside the engine. + */ + memoryMaxBytes: number; + /** QuickJS's stack limit, at most `MAX_STACK_BYTES`; `DEFAULT_STACK_BYTES` when left out. */ + maxStackBytes?: number; +} + +/** + * The four imports, as slots the child fills. Each is called synchronously from inside the + * engine, so none may await, and none may call back into the engine: a call into it from a slot + * throws an `EngineError`. A slot that throws ends the run: the call in progress throws that + * error, wrapped as an `EngineError`, and no slot is called again but `supervise()`. + */ +export interface EngineSlots { + /** + * The guest called `__host(name, argsJson)`, which returned a pending promise to the guest. + * `callId` names that promise for `settle`. Ids are the engine's, 1, 2, 3 and on. + */ + hostCall(callId: number, name: string, argsJson: string): void; + /** + * Called while the guest runs, every few thousand of its operations. Return true to stop it for + * good: the engine doesn't ask again, the call in progress answers `interrupted`, and so does + * every call after, with two exceptions. A stop in the jobs that reading the script's ending + * queued, after the ending was read whole, lets that call answer the ending, and the calls after + * it answer `interrupted`. And a run answered `failed` stays so, a stop while its exception is + * read included; a throw as the run starts is the script's own ending, answered `rejected`, which + * a stop while it's read turns into `interrupted`, as for any rejection. + */ + interrupt(): boolean; + /** + * Called at every interrupt check, before `interrupt()` is asked, and after it has said stop or a + * slot has failed, when it's asked no more: the child's watch over its own process, such as its + * parent going, which no stop or failure ends, since a script can keep a stop from landing. It + * returns nothing: a stop it calls for comes through `interrupt()`. One that throws ends the run + * as any slot's failure, and is still called at the checks after. + */ + supervise(): void; + /** The guest called `__output(stream, text)`: stream 1 is out, 2 is err. */ + output(stream: 1 | 2, text: string): void; +} + +/** How a call into the guest left it. */ +export type RunState = + /** The queue is empty and the script hasn't settled: it awaits a call, or a promise nothing settles. */ + | { kind: 'pending' } + /** + * The script settled. `json` is JSON.stringify of its value, undefined when that gives none. It's + * taken once: every later answer of this state carries the same text. + */ + | { kind: 'fulfilled'; json: string | undefined } + /** + * The prelude or the script threw, or its value couldn't be stringified (a BigInt, a cycle). + * The fields are what was thrown: an Error's name, message and stack. A thrown value with no + * message of its own has name `''`, and as its message an object's JSON, or else its string + * form; a Symbol, which has none here, leaves the message `''`. + */ + | { kind: 'rejected'; name: string; message: string; stack?: string } + /** + * Once the script was running, an exception escaped its promises and ended the run, even after + * its own ending: a callback it gave `queueMicrotask` or a `FinalizationRegistry` threw, or a job + * failed in the engine itself; or the engine couldn't settle a call. The fields are the + * exception's, read as the engine reads a `rejected` one (an Error's name, message and stack; a + * value with no message of its own, its JSON or string form), less any part a stop cut off while + * it was read: a stop ends the read, and nothing after it is read. Every later call answers + * the same, a stop after it included, and no later result reaches the script. + */ + | { kind: 'failed'; name: string; message: string; stack?: string } + /** `interrupt()` returned true while it ran. */ + | { kind: 'interrupted' }; + +/** + * What `settle` answers: the run's state; `unfit` when the guest couldn't take the result, for + * want of memory to copy it in or of memory or stack to parse it, and the call took the child's + * own error instead; and `dropped` when neither was handed over, because the run had been stopped + * or had failed before this settle (`interrupted` or `failed`), or the call of the promise's + * resolve or reject function was stopped or failed before it ran. Without `dropped`, that function + * ran with the result or the child's error, and the script's own code runs with it: a `then` + * getter it put on Object.prototype, which can reject the promise instead, and its continuations. + * A stop or failure in that code, or in the jobs after, leaves it handed over. The two are never + * both set. + */ +export type SettleState = RunState & { unfit?: true; dropped?: true }; + +export interface Engine { + /** + * Evaluates the prelude as a global script, then the script as global code with top-level + * await, then runs pending jobs until none is left, the guest is interrupted or the run fails. + * The script's value is that of its last expression statement. Called once. + */ + start(prelude: string, script: string, filename: string): RunState; + /** + * Settles call `callId`, then runs pending jobs as `start` does. With `ok`, the call's promise + * resolves to the parsed JSON; otherwise it rejects with it, and the prelude makes an Error of + * it. A result the guest can't take, for want of memory to copy it in or of memory or stack to + * parse it, nested deeper than `MAX_RESULT_DEPTH`, or not JSON, rejects it with `unfitJson` + * parsed, the child's own error (`ResultTooLarge`), and the answer has `unfit: true`: the call + * took effect, and an error at its await would read as a failed call. Allowed after `fulfilled`, + * for calls still in flight when a script returned. Throws for an id that isn't pending. + * + * Once a call has answered the script's ending, later calls answer it again, unless something + * came after it: a stop answers `interrupted`, a slot's failure throws, and an exception that + * escapes the script's promises answers `failed`. One that escapes the jobs reading the ending + * queued is answered by the call that read it, in the ending's place. + */ + settle(callId: number, ok: boolean, json: string | Uint8Array, unfitJson: string): SettleState; + /** Frees the engine's runtime, unless the module trapped, which leaves it as it is. Nothing may be called after it. */ + dispose(): void; +} + +/** The engine failed, rather than the script: a slot threw, the engine crashed, or it was misused. */ +export class EngineError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'EngineError'; + } +} + +const PAGE = 65536; +/** The link-time stack, at the start of linear memory (engine/build.sh). */ +const LINK_STACK_BYTES = 1024 * 1024; +export const MIN_MEMORY_BYTES = 4 * 1024 * 1024; +/** + * 2 GiB, so that every address in linear memory fits the signed 32-bit numbers the module's + * pointers arrive as, and no conversion is needed where the host reads them. + */ +export const MAX_MEMORY_BYTES = 2048 * 1024 * 1024; +/** + * The most QuickJS's stack limit may be, and its default. QuickJS measures its stack in the + * module's linear memory, but each level of a script's recursion through calls also takes the + * host's own stack, two to three times as much in the 31 ways to recurse that were measured, and + * under Node that stack is about 984 KiB. Recursing through a getter, a proxy trap or a built-in + * that calls back (map, toString, for...of), the host's stack ran out first, crashing the engine, + * once QuickJS's limit passed about 320 KiB. At this limit, QuickJS's catchable RangeError comes + * first in each of the 31, with about two fifths of the host's stack to spare. Bun's stack is + * larger, and the same limit holds there, so recursion through calls ends the same way on either. + * + * Recursion in C that the check barely sees isn't held to it, and under Node crashes the run, the + * host's stack running out: a script's text nesting function expressions 300 deep or brackets 700 + * deep (the parser), a chain of 3,000 iterator helpers, and the script's own data nested about + * 5,000 deep in JSON.stringify (4,000 with V8's optimizing tier alone), as a call's arguments + * are, or about 8,000 deep in JSON.parse (5,000; see MAX_RESULT_DEPTH). Under Bun, whose stack + * is larger, QuickJS's check ends JSON's recursion first, with a RangeError the script can catch, + * from about 15,000 deep, though a parse nested 22,000 deep still runs Bun's stack out. Each + * iterator helper's next calls the one before directly, past QuickJS's checks, so under Bun a + * chain of 30,000 runs the module's own 1 MiB stack out instead, and the module traps: the stack + * sits first in its memory, so an overflow wraps below address 0 to past the memory's end. + */ +export const MAX_STACK_BYTES = 192 * 1024; +export const DEFAULT_STACK_BYTES = MAX_STACK_BYTES; +/** + * The deepest nesting of arrays and objects a result may have. QuickJS's stack check measures the + * module's own stack, which the C recursion of JSON's parse and stringify barely uses, so under + * Node a parse nested about 8,000 deep (5,000 with V8's optimizing tier alone) overflows the + * host's stack first, and the engine crashes (under Bun, QuickJS's check comes first). A deeper + * result is unfit, since the call took effect. A script's own data isn't held to this, and nested + * thousands deep, it can crash its own run (see MAX_STACK_BYTES). + */ +export const MAX_RESULT_DEPTH = 2000; +/** The most of the engine's own stdio a crash's message keeps. */ +const DIAGNOSTICS_CHARS = 4096; +/** + * The longest ending text the binding decodes, in UTF-8 bytes: V8's longest string, 2^29 - 24 + * characters (Node's `buffer.constants.MAX_STRING_LENGTH`), which a text of this many bytes can't + * exceed. Node can't decode a longer one. Bun's longest string is 2^31 - 1, and the same bound + * holds there, so a script ends the same way on either. A longer text is answered with a + * RangeError that gives its length: `rejected` for the script's own ending, its value's or what it + * threw, and `failed` for an exception that ended the run. + */ +const MAX_ENDING_BYTES = 2 ** 29 - 24; + +// interface.c's states and failures. +const PENDING = 0, FULFILLED = 1, REJECTED = 2, INTERRUPTED = 3, HOST_FAILED = 4, FAILED = 5; +const UNFIT = 16, DROPPED = 32; +const MISUSE = -1, NO_MEMORY = -2, UNKNOWN_CALL = -3; + +interface Exports { + _initialize(): void; + engine_init(memoryLimit: number, stackSize: number): number; + engine_start(prelude: number, preludeLen: number, script: number, scriptLen: number, filename: number): number; + engine_settle(id: number, ok: number, json: number, jsonLen: number, unfit: number, unfitLen: number): number; + engine_result(): number; + engine_state(): number; + engine_alloc(size: number): number; + engine_free(ptr: number): void; + engine_dispose(): void; +} + +/** + * The part of the WebAssembly API this file uses. The repository's `lib` has no DOM, which is + * where TypeScript declares WebAssembly, and Node and Bun both provide it at runtime. + */ +interface Wasm { + Memory: new (descriptor: { initial: number; maximum: number }) => { readonly buffer: ArrayBuffer }; + compile(bytes: Uint8Array): Promise; + instantiate(module: object, imports: Record>): Promise<{ exports: Record }>; +} +const Wasm = (globalThis as unknown as { WebAssembly: Wasm }).WebAssembly; + +let compiled: Promise | undefined; + +/** The committed module, compiled once per process. */ +function engineModule(): Promise { + compiled ??= readFile(new URL('./quickjs.wasm', import.meta.url)).then((bytes) => Wasm.compile(bytes)); + return compiled; +} + +/** Whether JSON text nests arrays and objects deeper than `limit`. */ +function nestsDeeper(json: string | Uint8Array, limit: number): boolean { + // The characters that count are ASCII, so UTF-8 bytes and UTF-16 code units read alike. + const at = typeof json === 'string' ? (i: number) => json.charCodeAt(i) : (i: number) => json[i]; + let depth = 0, inString = false; + for (let i = 0; i < json.length; i++) { + const c = at(i); + if (inString) { + if (c === 0x5c) i++; // a backslash escapes the next character + else if (c === 0x22) inString = false; + } else if (c === 0x22) { + inString = true; + } else if (c === 0x5b || c === 0x7b) { + if (++depth > limit) return true; + } else if (c === 0x5d || c === 0x7d) { + depth--; + } + } + return false; +} + +/** QuickJS's allocation limit for a given linear maximum: the stack and data, then a margin. */ +function quickjsMemoryLimit(memoryMaxBytes: number): number { + return memoryMaxBytes - LINK_STACK_BYTES - 1024 * 1024 - Math.floor(memoryMaxBytes / 32); +} + +/** Instantiates the committed module with these limits and slots. One instance per child. */ +export async function loadEngine(limits: EngineLimits, slots: EngineSlots): Promise { + const { memoryMaxBytes } = limits; + const stackBytes = limits.maxStackBytes ?? DEFAULT_STACK_BYTES; + if (!Number.isSafeInteger(memoryMaxBytes) || memoryMaxBytes % PAGE !== 0 || + memoryMaxBytes < MIN_MEMORY_BYTES || memoryMaxBytes > MAX_MEMORY_BYTES) { + throw new RangeError(`memoryMaxBytes must be a whole number of 64 KiB pages from ${MIN_MEMORY_BYTES} to ${MAX_MEMORY_BYTES}, not ${memoryMaxBytes}`); + } + if (!Number.isSafeInteger(stackBytes) || stackBytes < 16 * 1024 || stackBytes > MAX_STACK_BYTES) { + throw new RangeError(`maxStackBytes must be from 16384 to ${MAX_STACK_BYTES}, not ${stackBytes}`); + } + + const memory = new Wasm.Memory({ initial: Math.ceil((LINK_STACK_BYTES + 2 * PAGE) / PAGE), maximum: memoryMaxBytes / PAGE }); + // ignoreBOM, so text that starts with U+FEFF keeps it. + const decoder = new TextDecoder('utf-8', { ignoreBOM: true }); + const encoder = new TextEncoder(); + /** Whether a slot threw, and what: a slot may throw anything, undefined included. */ + let slotFailed = false; + let slotError: unknown; + let broken: EngineError | undefined; + /** What the engine wrote before it crashed. */ + let diagnostics = ''; + /** A call into the module is in progress, so a slot is the only thing that can call again. */ + let busy = false; + /** interrupt() has returned true: the guest is stopped for good. */ + let stopped = false; + /** The module trapped, so its state may be anything, and it isn't called again. */ + let crashed = false; + + /** + * A view of the module's memory. A corrupted guest could pass any pointer, and the view's own + * bounds check throws a RangeError for one outside the memory, which ends the run as a slot's + * failure. Made at each read, since a growing memory replaces its buffer. + */ + const bytes = (ptr: number, len: number): Uint8Array => new Uint8Array(memory.buffer, ptr, len); + const text = (ptr: number, len: number) => decoder.decode(bytes(ptr, len)); + /** Runs a slot. A throw is kept, and the engine told the slot failed. */ + const guard = (fn: () => void): number => { + if (slotFailed) return 1; + try { + fn(); + return 0; + } catch (err) { + slotFailed = true; + slotError = err; + return 1; + } + }; + + const EBADF = 8, EINVAL = 28, ESPIPE = 70; + const env = { + memory, + host_call: (id: number, name: number, nameLen: number, args: number, argsLen: number) => + guard(() => slots.hostCall(id >>> 0, text(name, nameLen), text(args, argsLen))), + // Once a slot has failed, this stops the guest at its next check. + host_interrupt: () => { + if (slotFailed) return 1; + try { + if (!slots.interrupt()) return 0; + stopped = true; + return 1; + } catch (err) { + slotFailed = true; + slotError = err; + return 1; + } + }, + host_output: (stream: number, ptr: number, len: number) => + guard(() => slots.output(stream === 2 ? 2 : 1, text(ptr, len))), + // Not an ordinary slot: a slot's failure doesn't end it, so a guest that keeps its stop from + // landing is still watched. + host_supervise: () => { + try { + slots.supervise(); + } catch (err) { + if (!slotFailed) { + slotFailed = true; + slotError = err; + } + } + }, + }; + // The few WASI calls libc makes: the clocks (the real-time one for Date.now and the seed QuickJS + // gives Math.random, the monotonic one for performance.now), four random bytes libc asks for + // once as the module loads, and stdio, which only the engine's own diagnostics reach (an + // assertion's message before it traps). + const wasi = { + clock_time_get: (id: number, _precision: bigint, out: number) => { + let ns: bigint; + if (id === 0) ns = BigInt(Date.now()) * 1_000_000n; + else if (id >= 1 && id <= 3) ns = process.hrtime.bigint(); + else return EINVAL; + new DataView(memory.buffer).setBigUint64(bytes(out, 8).byteOffset, ns, true); + return 0; + }, + random_get: (ptr: number, len: number) => { + randomFillSync(bytes(ptr, len)); + return 0; + }, + fd_write: (fd: number, iovs: number, iovsLen: number, written: number) => { + if (fd !== 1 && fd !== 2) return EBADF; + const view = new DataView(memory.buffer); + let total = 0; + for (let i = 0; i < iovsLen; i++) { + const base = view.getUint32(bytes(iovs + i * 8, 8).byteOffset, true); + const len = view.getUint32(iovs + i * 8 + 4, true); + // Kept to DIAGNOSTICS_CHARS in all, clipped before decoding: each byte is at most a char. + const room = DIAGNOSTICS_CHARS - diagnostics.length; + if (room > 0) diagnostics += text(base, Math.min(len, room)); + total += len; + } + view.setUint32(bytes(written, 4).byteOffset, total, true); + return 0; + }, + fd_fdstat_get: (fd: number, out: number) => { + if (fd !== 1 && fd !== 2) return EBADF; + const b = bytes(out, 24); + b.fill(0); + b[0] = 2; // a character device, so stdio writes unbuffered lines + return 0; + }, + fd_seek: (fd: number) => (fd === 1 || fd === 2 ? ESPIPE : EBADF), + fd_close: () => EBADF, + }; + + const instance = await Wasm.instantiate(await engineModule(), { env, wasi_snapshot_preview1: wasi }); + const ex = instance.exports as unknown as Exports; + + /** + * Calls into the module. A trap leaves the engine broken, and every later call throws its error. + * One made from inside a slot, while another is in progress, is refused: the two would share the + * module's state. + */ + const call = (what: string, fn: () => T): T => { + if (broken) throw broken; + if (busy) throw new EngineError(`${what} was called from inside one of the engine's slots, which may not call back into it`); + busy = true; + let out: T; + try { + out = fn(); + } catch (err) { + if (err instanceof EngineError) throw err; + // A trap, or an exception in reading what the module returned, which it only throws when the + // module returned something it couldn't have made whole: either way the module can't be + // taken as whole, so dispose() leaves it as it is. + crashed = true; + // A slot that failed first is why: the guest ran on, refused, until the module trapped. + if (slotFailed) return slotFailure(what); + const why = diagnostics.trim(); + broken = new EngineError(`the engine crashed in ${what}: ${describe(err)}${why ? ` (${why})` : ''}`, { cause: err }); + throw broken; + } finally { + busy = false; + } + return out; + }; + /** A thrown value as text, for a message: it may have no string form, or one that throws. */ + const describe = (err: unknown): string => { + try { + return String(err instanceof Error ? err.message : err); + } catch { + return 'a value with no string form'; + } + }; + /** Copies text, or UTF-8 bytes, into the module with the NUL its C expects. The caller frees it, or hands it to engine_settle, which does. */ + const put = (value: string | Uint8Array): { ptr: number; len: number } => { + const len = typeof value === 'string' ? Buffer.byteLength(value, 'utf8') : value.length; + const ptr = ex.engine_alloc(len + 1); + if (ptr === 0) return { ptr: 0, len }; + const dst = new Uint8Array(memory.buffer, ptr, len + 1); + if (typeof value === 'string') encoder.encodeInto(value, dst); + else dst.set(value); + dst[len] = 0; + return { ptr, len }; + }; + /** The ending's text, or its length alone when it's too long to decode, or nothing when the module couldn't make it. */ + const result = (): { kind: number; text: string } | { kind: number; tooLong: number } | undefined => { + const ptr = ex.engine_result(); + if (ptr === 0) return undefined; + const view = new DataView(memory.buffer); + const kind = view.getUint32(ptr, true); + const len = view.getUint32(ptr + 4, true); + if (len > MAX_ENDING_BYTES) { + ex.engine_free(ptr); + return { kind, tooLong: len }; + } + const out = decoder.decode(new Uint8Array(memory.buffer, ptr + 8, len)); + ex.engine_free(ptr); + return { kind, text: out }; + }; + const errorState = (json: string, kind: 'rejected' | 'failed'): RunState => { + const e = JSON.parse(json) as { name?: string; message?: string; stack?: string }; + const state: { kind: 'rejected' | 'failed'; name: string; message: string; stack?: string } = + { kind, name: e.name ?? '', message: e.message ?? '' }; + if (typeof e.stack === 'string') state.stack = e.stack; + return state; + }; + /** What an ending that can't be read out is answered with, by what it is. */ + const unread = { + [FULFILLED]: { + memory: 'out of memory while serializing the script\'s return value; return less, or let go of data the script no longer needs', + long: (n: number) => `the script's return value comes to ${n} bytes as text, more than the ${MAX_ENDING_BYTES} the engine reads back; return less`, + }, + [REJECTED]: { + memory: 'out of memory while reading what the script threw; throw something smaller, such as an Error with a shorter message, or let go of data the script no longer needs', + long: (n: number) => `what the script threw comes to ${n} bytes as text, more than the ${MAX_ENDING_BYTES} the engine reads back; throw something smaller`, + }, + [FAILED]: { + memory: 'out of memory while reading the exception that ended the run; let go of data the script no longer needs, and if a callback given to queueMicrotask or a FinalizationRegistry threw it, throw something smaller or catch it there', + long: (n: number) => `the exception that ended the run comes to ${n} bytes as text, more than the ${MAX_ENDING_BYTES} the engine reads back; if a callback given to queueMicrotask or a FinalizationRegistry threw it, throw something smaller or catch it there`, + }, + } as const; + /** + * Each ending as it was first read. Reading one serializes the script's value or error in the + * guest, which can run the script's own code (a toJSON, a getter), and every settle after the + * script has ended answers that ending again: read once, that code runs once. + */ + const endings = new Map(); + /** The run ended because a slot threw. */ + const slotFailure = (what: string): never => { + broken = new EngineError(`a slot threw during ${what}: ${describe(slotError)}`, { cause: slotError }); + throw broken; + }; + /** Reads a state code into a RunState, or throws for the engine's own failures. */ + const toState = (code: number, what: string): RunState => { + switch (code) { + case PENDING: + return { kind: 'pending' }; + case INTERRUPTED: + // A slot's failure stops the guest through the interrupt, so this can be that failure. + if (slotFailed) return slotFailure(what); + return { kind: 'interrupted' }; + case FULFILLED: + case REJECTED: + case FAILED: { + // Each kept under its own code, so a failure after the script's own ending is read anew. + const known = endings.get(code); + if (known) return { ...known }; + const r = result(); + // Reading runs the interrupt slot, which can throw, or stop the guest. A stop that lands + // while the script's own ending is read leaves none to give (the module returns nothing), + // so this call answers as any call does in which that happens. One that lands in the jobs + // the read queued comes after a whole ending, which this call answers. An exception + // escaping those jobs ends the run after its ending, and is what this call answers. An + // exception that ended the run is read with the parts a stop cut off left out. + if (slotFailed) return slotFailure(what); + if (code !== FAILED) { + if (ex.engine_state() === FAILED) return toState(FAILED, what); + if (!r && stopped) return { kind: 'interrupted' }; + } + const kind = code === FAILED ? 'failed' : 'rejected'; + const state: RunState = !r ? { kind, name: 'InternalError', message: unread[code].memory } + : 'tooLong' in r ? { kind, name: 'RangeError', message: unread[code].long(r.tooLong) } + : r.kind === 0 ? { kind: 'fulfilled', json: undefined } + : r.kind === 1 ? { kind: 'fulfilled', json: r.text } + : errorState(r.text, kind); + endings.set(code, state); + return { ...state }; + } + case HOST_FAILED: + return slotFailure(what); + case UNKNOWN_CALL: + throw new EngineError(`${what}: no pending call with that id`); + case NO_MEMORY: + broken = new EngineError(`${what}: the engine ran out of memory for its own state`); + throw broken; + case MISUSE: + default: + throw new EngineError(`${what}: the engine refused the call (${code})`); + } + }; + + call('initialization', () => ex._initialize()); + const init = call('initialization', () => ex.engine_init(quickjsMemoryLimit(memoryMaxBytes), stackBytes)); + if (init !== 0) throw new EngineError(`the engine couldn't initialize (${init})`); + + let started = false; + let disposed = false; + const live = (what: string) => { + if (disposed) throw new EngineError(`${what} after dispose()`); + }; + + return { + start(prelude, script, filename) { + live('start()'); + if (broken) throw broken; + if (started) throw new EngineError('start() was already called'); + started = true; + return call('start()', () => { + const p = put(prelude), s = put(script), f = put(filename); + try { + if (!p.ptr || !s.ptr || !f.ptr) return { kind: 'rejected', name: 'InternalError', message: 'out of memory while loading the script' } as RunState; + return toState(ex.engine_start(p.ptr, p.len, s.ptr, s.len, f.ptr), 'start()'); + } finally { + for (const b of [p, s, f]) if (b.ptr) ex.engine_free(b.ptr); + } + }); + }, + settle(callId, ok, json, unfitJson) { + live('settle()'); + if (broken) throw broken; + if (!started) throw new EngineError('settle() before start()'); + if (callId >>> 0 !== callId) throw new EngineError(`settle(): ${callId} isn't a call id`); + return call('settle()', () => { + // The small error first, so a result that takes the last of the memory leaves it room, + // and once more if refused: the refusal opens the allocator's headroom, which it fits in. + // A copy the module can't make room for goes as a null pointer, and so does a result + // nested too deeply to parse. The module frees both copies, each as soon as it's parsed. + let u = put(unfitJson); + if (!u.ptr) u = put(unfitJson); + const j = nestsDeeper(json, MAX_RESULT_DEPTH) ? { ptr: 0, len: 0 } : put(json); + const code = ex.engine_settle(callId, ok ? 1 : 0, j.ptr, j.len, u.ptr, u.len); + if (code < 0) return toState(code, 'settle()'); + const state: SettleState = toState(code & ~(UNFIT | DROPPED), 'settle()'); + if (code & UNFIT) state.unfit = true; + if (code & DROPPED) state.dropped = true; + return state; + }); + }, + dispose() { + if (disposed) return; + // Refused from inside a slot, it isn't marked done, so it can be made again. + if (busy) throw new EngineError('dispose() was called from inside one of the engine\'s slots, which may not call back into it'); + // A module whose call ended in an exception, a trap or otherwise, may have stopped anywhere, + // so it's left as it is. One that ended for a slot's failure, or for want of memory for its + // own state, is whole, and frees its runtime. + if (!crashed) { + busy = true; + try { + ex.engine_dispose(); + } catch (err) { + crashed = true; + disposed = true; + broken = new EngineError(`the engine crashed in dispose(): ${describe(err)}`, { cause: err }); + throw broken; + } finally { + busy = false; + } + } + disposed = true; + }, + }; +} diff --git a/src/code-execution/index.ts b/src/code-execution/index.ts index 062fd1f38..d213c54f2 100644 --- a/src/code-execution/index.ts +++ b/src/code-execution/index.ts @@ -1,21 +1,9 @@ /** - * Client-side programmatic tool calling (PTC) for the connectome native stack. - * - * Mirrors the surface Anthropic's server-side PTC was trained on - * (code_execution tool + tools exposed as async python functions), but runs - * the interpreter on the host and dispatches inner tool calls in-process - * through the framework's own tool routing — intermediate results never - * enter the model's context; the full trace stays on-box. + * Client-side programmatic tool calling (`code_execution`): an agent's + * scripts call its other tools in-process through the framework's own tool + * routing, so intermediate results never enter the model's context. Each + * script runs in a process of its own, in QuickJS compiled to WebAssembly + * (engine.ts), and reaches nothing but the agent's tools. */ -export { PyRunner, buildInjectedTools } from './py-runner.js'; -export type { - InjectedTool, - ExecResult, - PyRunnerOptions, - ScriptToolCallHandler, - ScriptToolResult, - BackgroundExecOptions, -} from './py-runner.js'; -export { PYTHON_RUNTIME_SOURCE } from './runtime-py.js'; export { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME } from './tool-definition.js'; diff --git a/src/code-execution/isolate/calls.ts b/src/code-execution/isolate/calls.ts new file mode 100644 index 000000000..6bfa72dbd --- /dev/null +++ b/src/code-execution/isolate/calls.ts @@ -0,0 +1,199 @@ +/** + * A script's call record, kept in the parent, outside the child, so losing + * the child loses none of it. A call goes in at + * dispatch, as running until it's answered. Answered calls are counted; those + * whose answer the script didn't see are named, with their arguments, so an + * agent can tell which effects may have taken place. A script can live a day, + * so the record is bounded: it names at most CALLS_NAMED and counts the rest + * by tool, and it holds a counted call only while it runs. A named call stays + * until the script ends, and its state can still change after the call ends, + * as when a timed-out call's answer comes. + */ + +/** A call's arguments, or a later answer's start, as a return names it: at most this many characters. */ +export const CALL_BRIEF_CHARS = 120; +/** How many calls whose answer the script didn't see a return names; it counts the rest by tool. */ +export const CALLS_NAMED = 10; + +/** How a return names a tool, as the agent writes it in a script. */ +export const toolRef = (tool: string): string => (tool === 'wake_agent' ? 'wake_agent()' : `tools[${JSON.stringify(tool)}]`); + +/** + * A copy of `text` that shares no storage with it. A slice of a string can + * keep the whole string it was cut from alive, in V8 and in JavaScriptCore, so + * a short piece kept from a long text is copied first. + */ +export function fresh(text: string): string { + return JSON.parse(JSON.stringify(text)) as string; +} + +/** `text` cut to CALL_BRIEF_CHARS with "…", never between a surrogate pair's halves, as a copy of its own. */ +export function callBrief(text: string): string { + if (text.length <= CALL_BRIEF_CHARS) return fresh(text); + let cut = CALL_BRIEF_CHARS - 1; + const before = text.charCodeAt(cut - 1); + if (before >= 0xd800 && before <= 0xdbff) cut -= 1; + return fresh(`${text.slice(0, cut)}…`); +} + +/** A call whose answer the script didn't see, as a return names it. */ +export interface NamedCall { + tool: string; + /** Its arguments as the script sent them, cut to CALL_BRIEF_CHARS. */ + args: string; + /** + * - `uncertain`: still running when the script ended, so it may or may not take effect. + * - `timed-out`: no answer came within the call's timeout, so it may still take effect. The script was given a TimeoutError, unless it ended before reading it. + * - `timed-out-answered`: no answer came within the call's timeout, and one came later; `answer` is its start. + * - `too-large`: answered, with a result the script couldn't receive (too large for its memory, or nested too deep), so it got ResultTooLarge in its place. + * - `unseen`: answered, and the script was stopped or crashed before it read the answer. + */ + state: 'uncertain' | 'timed-out' | 'timed-out-answered' | 'too-large' | 'unseen'; + /** For `timed-out-answered`: the answer's start, cut to CALL_BRIEF_CHARS. */ + answer?: string; +} + +/** The record as a return reports it. */ +export interface CallSummary { + /** Calls the script made. */ + made: number; + /** Answered while the script waited, with a result that wasn't an error. */ + answered: number; + /** Answered while the script waited, with a result marked as an error. */ + failed: number; + /** Refused before they ran: nothing was done. */ + refused: number; + /** Never sent, so nothing was done: they timed out while they waited for room in the host, the run ended while they waited, or the host read them once the script's process had exited. */ + unsent: number; + /** Still in flight, the script waiting on them. */ + running: number; + /** Waiting their turn in the host, not sent yet: calls waiting for room, and wakes waiting for theirs. */ + waiting: number; + /** Those whose answer the script didn't see, the first CALLS_NAMED of them, in the order each became so. */ + named: NamedCall[]; + /** The rest of those, counted by tool. */ + more: Record; +} + +/** How a call that the script waited on was answered. */ +export type CallOutcome = 'answered' | 'failed' | 'refused' | 'too-large'; + +/** One call the record still follows, held by the caller between its start and its end. */ +export interface CallEntry { + readonly tool: string; + readonly args: string; + /** Set once the call has a place among the named, so a later answer can say what it was. */ + named: NamedCall | null; + /** True once the call's timeout passed with no answer: the script is given a TimeoutError, unless it ended before reading it. */ + timedOut: boolean; + /** True once the record holds the call's last word. */ + done: boolean; +} + +export class CallLog { + private made = 0; + private readonly counts = { answered: 0, failed: 0, refused: 0, unsent: 0 }; + private readonly running = new Set(); + /** Timed-out calls among the named whose answer hasn't come: it can still be named, until the script ends. */ + private readonly awaiting = new Set(); + private readonly named: NamedCall[] = []; + private readonly more = new Map(); + + /** A call went out: it's running until it's answered. */ + start(tool: string, argsJson: string): CallEntry { + this.made += 1; + const entry: CallEntry = { tool, args: callBrief(argsJson), named: null, timedOut: false, done: false }; + this.running.add(entry); + return entry; + } + + /** + * A call or wake the script made was never sent, so nothing was done: a + * call that timed out while it waited for room in the host, or that the run + * ended, or its process exited, before it went out, and one read after the + * process exited; a wake still waiting for its turn when the run ended, or + * once a stop was asked or the process exited. + */ + unsent(): void { + this.made += 1; + this.counts.unsent += 1; + } + + /** The call's timeout passed with no answer, and the script is given a TimeoutError. Its answer may still come. */ + timedOut(entry: CallEntry): void { + if (entry.done || entry.timedOut) return; + entry.timedOut = true; + this.running.delete(entry); + entry.named = this.name(entry, 'timed-out'); + if (entry.named) this.awaiting.add(entry); + else entry.done = true; + } + + /** The call's answer came. `brief` is the start of what the tool answered, for a call the script stopped waiting for. */ + answered(entry: CallEntry, outcome: CallOutcome, brief: string): void { + if (entry.done) return; + entry.done = true; + this.running.delete(entry); + this.awaiting.delete(entry); + if (entry.timedOut) { + if (entry.named) { + entry.named.state = 'timed-out-answered'; + entry.named.answer = callBrief(brief); + } + return; + } + if (outcome === 'too-large') this.name(entry, 'too-large'); + else this.counts[outcome] += 1; + } + + /** + * The call was answered, and the script ended before it read the answer: a + * refusal did nothing, and is counted; any other answer is named. + */ + unseen(entry: CallEntry, outcome: CallOutcome): void { + if (entry.done) return; + entry.done = true; + this.running.delete(entry); + this.awaiting.delete(entry); + if (outcome === 'refused') this.counts.refused += 1; + else this.name(entry, 'unseen'); + } + + /** + * The script ended: every call still running may or may not take effect, + * and the record stays as it stands, so it names the calls as the host saw + * them when the script ended. + */ + ended(): void { + for (const entry of this.running) { + entry.done = true; + this.name(entry, 'uncertain'); + } + this.running.clear(); + for (const entry of this.awaiting) entry.done = true; + this.awaiting.clear(); + } + + /** The record, with `waiting` calls the host holds back, for room or a wake's turn, which the script has made too. */ + summary(waiting = 0): CallSummary { + return { + made: this.made + waiting, + ...this.counts, + running: this.running.size, + waiting, + named: this.named.map((c) => ({ ...c })), + more: Object.fromEntries(this.more), + }; + } + + /** A place among the named, or a count by tool past CALLS_NAMED. */ + private name(entry: CallEntry, state: NamedCall['state']): NamedCall | null { + if (this.named.length < CALLS_NAMED) { + const named: NamedCall = { tool: entry.tool, args: entry.args, state }; + this.named.push(named); + return named; + } + this.more.set(entry.tool, (this.more.get(entry.tool) ?? 0) + 1); + return null; + } +} diff --git a/src/code-execution/isolate/child-entry.ts b/src/code-execution/isolate/child-entry.ts new file mode 100644 index 000000000..8c7ba27d8 --- /dev/null +++ b/src/code-execution/isolate/child-entry.ts @@ -0,0 +1,5 @@ +/** The script the parent spawns for each script: the child, on the engine. */ +import { loadEngine } from '../engine.js'; +import { childMain } from './child-main.js'; + +childMain(loadEngine); diff --git a/src/code-execution/isolate/child-main.ts b/src/code-execution/isolate/child-main.ts new file mode 100644 index 000000000..258b6e769 --- /dev/null +++ b/src/code-execution/isolate/child-main.ts @@ -0,0 +1,132 @@ +/** + * The child's process: reads the parent's frames on stdin, writes its own on + * fd 3, runs the script with the engine, and exits. Spawned by the parent with + * the parent's own runtime (`process.execPath`), Node's or Bun's. + * + * The channel is the one way that works the same under both runtimes, as + * probes under Bun 1.4.3, Node 24.19.0 and Node 20.20.2 showed: a socket on + * another fd reads nothing under Bun, and a file stream on one leaves Node's + * exit waiting on a blocked read. The child writes with `writeSync`, since its slots run while + * the engine is mid-call, and output has to reach the parent while a guest + * computes without queuing in memory. + * + * `runChildProcess` takes the engine loader so a test can spawn a child whose + * engine is a stand-in; the entry the parent spawns in production passes the + * engine's binding. + */ + +import { writeSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { EngineError, MAX_MEMORY_BYTES } from '../engine.js'; +import { runChild, type ChildIo, type LoadEngine } from './child.js'; +import { encodeFrame, FrameDecoder, RESULT_FRAME_OVERHEAD } from './frames.js'; +import { MAX_CHILD_FRAME_BYTES, type ParentMessage, type StartMessage } from './protocol.js'; + +const TO_PARENT_FD = 3; + +/** + * The largest frame the child takes from its parent: a result as large as the + * largest memory can be given (a third of it, as the parent bounds a result), + * with its header. The parent is trusted; this only bounds a broken one. + */ +const MAX_PARENT_FRAME_BYTES = Math.floor(MAX_MEMORY_BYTES / 3) + RESULT_FRAME_OVERHEAD; + +/** A shared buffer to wait on, so a full pipe is retried after a short sleep without the event loop. */ +const sleeper = new Int32Array(new SharedArrayBuffer(4)); + +/** Write all of `buf` to `fd`, synchronously, waiting a moment when the pipe is full. */ +function writeAll(fd: number, buf: Buffer): void { + let offset = 0; + while (offset < buf.length) { + try { + offset += writeSync(fd, buf, offset, buf.length - offset); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'EAGAIN' && code !== 'EWOULDBLOCK') throw err; + Atomics.wait(sleeper, 0, 0, 1); + } + } +} + +/** The parent's frames, as messages, from the child's stdin. The parent's frames are trusted: a bad one ends the child. */ +function parentFrames(): () => Promise { + const decoder = new FrameDecoder(MAX_PARENT_FRAME_BYTES, true); + const queue: ParentMessage[] = []; + let ended = false; + let failure: Error | null = null; + let wake: (() => void) | null = null; + const stream = process.stdin; + stream.on('data', (chunk) => { + try { + for (const value of decoder.push(chunk as Buffer)) queue.push(value as ParentMessage); + } catch (err) { + failure = err as Error; + } + wake?.(); + }); + const finish = () => { + ended = true; + wake?.(); + }; + stream.on('end', finish); + stream.on('error', (err) => { + failure = err; + finish(); + }); + return async () => { + for (;;) { + if (failure) throw failure; + const next = queue.shift(); + if (next) return next; + if (ended) return null; + await new Promise((resolve) => { + wake = () => { + wake = null; + resolve(); + }; + }); + } + }; +} + +export async function runChildProcess(loadEngine: LoadEngine): Promise { + const next = parentFrames(); + const first = await next(); + if (first === null) return 0; + if (first.t !== 'start') throw new Error('the first frame from the parent was not a start'); + const start = first as StartMessage; + const io: ChildIo = { + next, + // Against the parent's cap, so the two can't drift apart. + send: (message) => writeAll(TO_PARENT_FD, encodeFrame(message, MAX_CHILD_FRAME_BYTES)), + now: () => performance.now(), + // On Linux and macOS an orphan is reparented, so its parent's pid isn't + // the one the start named once its parent has gone, even if that went + // before this ran. Windows keeps the pid, so an orphan isn't seen there. + parentGone: () => process.ppid !== start.parentPid, + orphaned: () => process.exit(0), + }; + await runChild(io, start, loadEngine); + return 0; +} + +/** + * The child's process as the parent spawns it: run the script, and exit. An + * engine that fails, rather than the script, ends the child with its reason + * on one line of stderr, which the parent puts in the crash it reports; any + * other throw is a fault here, and its stack goes there instead. + */ +export function childMain(loadEngine: LoadEngine): void { + runChildProcess(loadEngine).then( + (code) => process.exit(code), + (err: unknown) => { + // Written at once: a write to a pipe is asynchronous on macOS, and the exit would drop it. + try { + writeSync(2, err instanceof EngineError ? `${err.message}\n` : `${err instanceof Error ? err.stack : String(err)}\n`); + } catch { + // The parent learns of the crash from the exit either way. + } + process.exit(1); + }, + ); +} diff --git a/src/code-execution/isolate/child.ts b/src/code-execution/isolate/child.ts new file mode 100644 index 000000000..c46c17f0a --- /dev/null +++ b/src/code-execution/isolate/child.ts @@ -0,0 +1,327 @@ +/** + * A script's child: one process per script, which runs it in the engine and + * answers the engine's four imports. It takes its start from the parent's + * first frame, sends each tool call to the parent and settles it with the + * parent's answer, flushes the output in bounded batches, and reports how the + * script ended. A crash it can't report: the parent sees that the child ended + * without saying how. + * + * Stopping: the parent creates the stop file and sends a stop frame. A + * computing guest's interrupt import sees the file, checked at most every few + * milliseconds, so a stop reaches a spinning guest without its event loop; an + * idle guest's child reads the frame. Why the stop was asked is the parent's + * to say, so the child reports only that the script stopped. + */ + +import { existsSync } from 'node:fs'; +import { MAX_RESULT_DEPTH, type Engine, type EngineLimits, type EngineSlots, type RunState, type SettleState } from '../engine.js'; +import { argumentsRefusal, MAX_CALLS_IN_FLIGHT, RESULT_TOO_LARGE, wakeLimitRefusal, wakeRefusal } from './protocol.js'; +import { OutputBatch } from './output.js'; +import { bounded, glimpse, NAME_CHARS, VALUE_CHARS } from './render.js'; +import { preludeSource, scriptCall, scriptFrames, WAKE_CALL_NAME } from './prelude.js'; +import type { ParentMessage, StartMessage } from './protocol.js'; + +/** How often, at most, the interrupt import looks for the stop file. */ +export const STOP_CHECK_MS = 3; + +/** + * The filename of the one line that starts the script, `await __run(...)`. + * The script's own frames carry ``, the source name QuickJS gives the + * async function it's compiled into (SCRIPT_LINE_OFFSET). + */ +const SCRIPT_FILENAME = 'script.js'; + +export type LoadEngine = (limits: EngineLimits, slots: EngineSlots) => Promise; + +export interface ChildIo { + /** The parent's frames, in order, each already decoded; done when the parent closes its end. */ + next(): Promise; + /** + * Send one message now, synchronously: the engine may be mid-call. A message + * too long for a frame throws its FrameError, and nothing is sent. + */ + send(message: unknown): void; + /** Milliseconds from some fixed point. */ + now(): number; + /** Whether the parent has gone, so no one is left to stop the script or read its ending. */ + parentGone?(): boolean; + /** + * End the child's process at once, its parent having gone: nothing it does + * reaches anyone, its unsent output included, and nothing is left to kill + * it if its guest keeps a stop from ending it. The child looks for its + * parent at the engine's interrupt checks, at most once a stop check + * (`STOP_CHECK_MS`), after a stop as before one. Without this, the child + * stops its guest instead. + */ + orphaned?(): never; +} + +/** The value's JSON from the engine's JSON of the prelude's text: a string, so one parse gives it, and anything else is given as it came. */ +function valueJson(json: string): string { + try { + const text: unknown = JSON.parse(json); + return typeof text === 'string' ? text : json; + } catch { + return json; + } +} + +/** Run the script `start` names to its end. Resolves once its ending is sent, or when the parent closes the channel. */ +export async function runChild(io: ChildIo, start: StartMessage, loadEngine: LoadEngine): Promise { + const batch = new OutputBatch(); + /** The calls the guest waits on, by id: their tools' names. */ + const outstanding = new Map(); + /** Calls answered here, without going out: their arguments were too large to send. */ + const local: { id: number; json: string }[] = []; + /** + * While a result settles, the calls the guest's jobs make are held, and go + * out after that result's `seen`, which says whether the result fit: the + * parent then frees the answered call's place before it counts the next. + * A result the engine drops reaches no script code, and what code QuickJS + * still runs as the stop lands, a stack hook building the stop's error, + * can't make a call, since the engine refuses `__host` once stopped: its + * settle holds none. + */ + let held: unknown[] | null = null; + /** Wakes sent, which the script's cap counts. */ + let wakesSent = 0; + let stopped = false; + let lastFlush = io.now(); + let lastStopCheck = -Infinity; + let lastParentCheck = -Infinity; + /** Its parent has been seen gone, so the child has ended its process, or stopped its guest. */ + let orphan = false; + + const flush = (): void => { + lastFlush = io.now(); + for (const text of batch.flush()) io.send({ t: 'out', text }); + }; + + const slots: EngineSlots = { + hostCall(callId, name, argsJson) { + outstanding.set(callId, name); + // What the host won't take is refused here, before a frame is built, + // and by the parent again, since the child is untrusted. A call that + // passes always fits a frame, which carries its arguments escaped, at + // most six bytes for each of theirs. + const wake = name === WAKE_CALL_NAME; + // A wake past the script's cap is refused too, after its size, so one + // refused for its size uses none of the script's wakes. + const refusal = wake + ? (wakeRefusal(argsJson) ?? (wakesSent >= start.maxWakes ? wakeLimitRefusal(start.maxWakes) : null)) + : argumentsRefusal(name, argsJson); + if (refusal) { + // The slot can't settle the call from inside the engine: it's answered + // as soon as the engine returns. + local.push({ id: callId, json: JSON.stringify(refusal) }); + return; + } + if (wake) wakesSent += 1; + // What the script wrote before the call reaches the host before it does. + if (batch.unflushed > 0) flush(); + const call = { t: 'call', id: callId, name, args: argsJson }; + // A wake takes no place among the calls in flight, so it needn't wait for a seen. + if (held === null || wake) io.send(call); + else if (held.length < MAX_CALLS_IN_FLIGHT) held.push(call); + else { + // A guest held to the prelude has at most that many calls out. One + // that makes more, its promises replaced, would have them held + // without bound: the rest go out at once, which the parent takes as + // the protocol break it is, and the guest is stopped. + stopped = true; + io.send(call); + } + }, + interrupt() { + const t = io.now(); + if (!stopped && t - lastStopCheck >= STOP_CHECK_MS) { + lastStopCheck = t; + stopped = existsSync(start.stopFile); + } + if (batch.unflushed > 0 && t - lastFlush >= start.flushMs) flush(); + return stopped; + }, + supervise() { + // A guest that computes doesn't let the child see its parent go, so + // that's checked at the engine's interrupt checks, at most once a stop + // check, a stop's included: a guest can keep a stop from ending it, + // since QuickJS drops a stop it takes inside a stack hook or an + // iterator's return, and only the parent kills a child that runs on. + // The engine calls this first, so an orphan ends its process at once + // rather than stop its guest. Output waiting to be sent goes with the + // process: the parent that would read it is gone, and a write to it + // could fail or block. + const t = io.now(); + if (orphan || t - lastParentCheck < STOP_CHECK_MS) return; + lastParentCheck = t; + if (!io.parentGone?.()) return; + orphan = true; + io.orphaned?.(); + stopped = true; + }, + output(_stream, text) { + if (batch.write(text) || io.now() - lastFlush >= start.flushMs) flush(); + }, + }; + + const engine: Engine = await loadEngine( + { memoryMaxBytes: start.memoryMaxBytes, ...(start.maxStackBytes === undefined ? {} : { maxStackBytes: start.maxStackBytes }) }, + slots, + ); + // The loop's own failure, if one is leaving it: dispose()'s, after it, is the lesser news. + let failing = false; + try { + // The script's own ending: the first terminal state. Calls still in flight + // when it ends are waited for, so each outcome reaches the record, and their + // later states don't change how the script ended: a stop then ends the + // wait, and the script's own ending is what's reported. A failure once the + // script ran (`failed`: an exception escaped its promises) ends the run at + // once, past an ending it kept: the engine takes nothing more. + const terminal = (s: RunState) => s.kind === 'fulfilled' || s.kind === 'rejected'; + let ending = { kind: 'pending' } as RunState; + let interrupted = false; + const after = (next: RunState): void => { + if (next.kind === 'interrupted') interrupted = true; + if (next.kind === 'failed' || !terminal(ending)) ending = next; + }; + const over = (): boolean => interrupted || ending.kind === 'failed'; + const settleLocal = (): void => { + while (local.length > 0 && !over()) { + const { id, json } = local.shift()!; + outstanding.delete(id); + after(engine.settle(id, false, json, json)); + } + }; + after(engine.start(preludeSource(start.tools), scriptCall(start.script), SCRIPT_FILENAME)); + settleLocal(); + for (;;) { + if (over() || stopped || outstanding.size === 0) { + flush(); + // The value, or a failure's message and stack, cut as a return shows + // them, so the ending's frame always fits whatever the script made. + if (ending.kind === 'fulfilled') { + // The prelude's run resolves to its value's JSON text, which the engine + // gives as a JSON string: one layer off is the value's JSON. + io.send(ending.json === undefined ? { t: 'end', how: 'finished' } : { t: 'end', how: 'finished', value: bounded(valueJson(ending.json), VALUE_CHARS) }); + } else if (ending.kind === 'rejected' || ending.kind === 'failed') { + // The script's frames go apart from the name and message, each cut + // on its own, so a cut never breaks which is which. Every failure's + // stack is QuickJS's own, or one the script set, and is put as the + // agent reads it here (scriptFrames), whether the script threw it or + // it escaped the script's promises, and cut as it's read: the frames + // go as scriptFrames gives them, cut once. + const frames = ending.stack ? scriptFrames(ending.stack, VALUE_CHARS) : ''; + io.send({ + t: 'end', + how: 'failed', + error: { + name: bounded(ending.name, NAME_CHARS), + message: bounded(ending.message, VALUE_CHARS), + ...(frames ? { stack: frames } : {}), + }, + }); + } else if (interrupted || stopped) { + io.send({ t: 'end', how: 'stopped' }); + } else { + io.send({ + t: 'end', + how: 'failed', + stalled: true, + error: { + name: 'Error', + message: 'The script is waiting on a promise that nothing can settle: no tool call is pending, and there are no timers here.', + }, + }); + } + return; + } + // The guest is idle until a call is answered: send its output so far. + flush(); + const message = await io.next(); + if (message === null) return; // the parent is gone, and with it anyone to tell + if (message.t === 'stop') { + stopped = true; + continue; + } + if (message.t !== 'result' || !outstanding.has(message.id)) continue; + const name = outstanding.get(message.id)!; + outstanding.delete(message.id); + // A result the guest can't take, too large for its memory, nested too + // deep or not parsed, reaches the script as ResultTooLarge, made here, + // and the parent hears it wasn't seen. An answer that isn't ok is an + // error the host gave in the result's place, such as a refusal. + // The call as the script wrote it: a wake is wake_agent. + const called = name === WAKE_CALL_NAME ? 'wake_agent' : name; + // An error the host gave in the result's place keeps its name, such as + // a refusal's, and its message's start: whether the call ran is in + // them, read here, outside the guest. + const unfit = message.ok + ? JSON.stringify({ + name: RESULT_TOO_LARGE, + message: `${called}'s result, ${message.json.length} bytes, couldn't be received by this script: it was ${unfitWhy(true)}; ` + + 'the call took effect, and none of its result reached the script. Calling it again would do the call again, its effect included, and likely bring the same result, so if the call only reads, ask the tool for less if it can', + bytes: message.json.length, + }) + : unfitAnswer(called, message.json); + held = []; + let state: SettleState; + let calls: unknown[]; + try { + state = engine.settle(message.id, message.ok, message.json, unfit); + } finally { + calls = held; + held = null; + } + // A result that reached the script freed its call's place, as the + // prelude has it, and its seen says so. One that never reached it + // (`dropped`) has no seen: the script never had it, so the prelude + // never freed its place, and what code QuickJS still runs as the stop + // lands can't reach the host, since the engine refuses `__host` once + // stopped. The run's end counts the answer as one the script didn't + // read (Gladys-2592, room-203 #159239, #162129). + if (!state.dropped) io.send(state.unfit ? { t: 'seen', id: message.id, unfit: true } : { t: 'seen', id: message.id }); + for (const call of calls) io.send(call); + after(state); + settleLocal(); + } + } catch (err) { + failing = true; + throw err; + } finally { + try { + engine.dispose(); + } catch (err) { + if (!failing) throw err; + } + } +} + +/** An error the host gave in a result's place, which the guest couldn't take: its name and its message's start, read in the child. */ +function unfitAnswer(tool: string, json: Uint8Array): string { + let name = RESULT_TOO_LARGE; + let message = ''; + let parsed = false; + try { + const answer = JSON.parse(new TextDecoder().decode(json)) as { name?: unknown; message?: unknown }; + parsed = true; + if (typeof answer.name === 'string' && answer.name) name = bounded(answer.name, NAME_CHARS); + if (typeof answer.message === 'string') message = glimpse(answer.message, UNFIT_GLIMPSE_CHARS); + } catch { + // Not readable here either: only its size and the tool are known. + } + return JSON.stringify({ + name, + message: `${message ? `${message} ` : `${tool}'s answer `}(the host's answer, ${json.length} bytes, couldn't be received by this script whole: it was ${unfitWhy(!parsed)})`, + bytes: json.length, + }); +} + +/** Why the guest couldn't take an answer: what the engine refuses, less what the child has ruled out. */ +function unfitWhy(couldBeJson: boolean): string { + return couldBeJson + ? `too large for its memory, nested deeper than ${MAX_RESULT_DEPTH} levels, or not valid JSON` + : `too large for its memory, or nested deeper than ${MAX_RESULT_DEPTH} levels`; +} + +/** How much of an unfit answer's message reaches the script. */ +const UNFIT_GLIMPSE_CHARS = 2000; diff --git a/src/code-execution/isolate/frames.ts b/src/code-execution/isolate/frames.ts new file mode 100644 index 000000000..27f6a6900 --- /dev/null +++ b/src/code-execution/isolate/frames.ts @@ -0,0 +1,209 @@ +/** + * Length-prefixed frames between a script's parent and its child: four bytes + * of big-endian length, then that many bytes of body. The parent writes to the + * child's stdin and reads the child's fd 3. + * + * A body's first byte says what it holds: + * - `JSON_KIND`: a message as UTF-8 JSON. Every frame from a child is one. + * - `RESULT_KIND`: a call's result for the guest, from the parent: whether it + * resolves or rejects, the call's id, and the result's JSON as UTF-8 bytes, + * which the child hands the engine as they are, with no second parse and no + * string copy (the path the memory budget was measured on). + * + * The decoder is the only way bytes from a child become values in the parent, + * so it bounds what a child can make the parent hold: a frame longer than its + * limit, of a kind it doesn't take, not JSON, or holding more values than its + * limit (counted before it's parsed), is a protocol error, and the parent ends + * the script as crashed rather than reading on. + */ + +/** The largest frame from a child: a call's arguments or a batch of output. */ +export const MAX_FRAME_BYTES = 64 * 1024 * 1024; + +const HEADER_BYTES = 4; +export const JSON_KIND = 0; +export const RESULT_KIND = 1; +/** A result body's own header: its kind, `ok`, and the call's id. */ +const RESULT_HEADER_BYTES = 1 + 1 + 4; + +/** A frame that breaks the protocol: too long, of a kind not taken, not UTF-8, not JSON, holding too many values, or cut off when its stream ended. */ +export class FrameError extends Error { + /** For a frame too long to encode: how long its body came to. */ + readonly bytes: number | undefined; + + constructor(message: string, bytes?: number) { + super(message); + this.name = 'FrameError'; + this.bytes = bytes; + } +} + +/** + * The values in a JSON text, counted outside its strings: the top value, and + * each value inside an object or array. Counting stops once past `stopAfter`. + */ +export function countJsonValues(text: string, stopAfter = Infinity): number { + let values = 1; + let inString = false; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (inString) { + if (c === 0x5c) i++; + else if (c === 0x22) inString = false; + } else if (c === 0x22) { + inString = true; + } else if (c === 0x7b || c === 0x5b) { + // An object or array holds a first value unless it closes at once. + let j = i + 1; + while (j < text.length && (text.charCodeAt(j) === 0x20 || text.charCodeAt(j) === 0x0a || text.charCodeAt(j) === 0x0d || text.charCodeAt(j) === 0x09)) j++; + const next = text.charCodeAt(j); + if (next !== 0x7d && next !== 0x5d && ++values > stopAfter) return values; + } else if (c === 0x2c) { + if (++values > stopAfter) return values; + } + } + return values; +} + +/** A call's result as the child decodes it: the JSON's bytes, untouched. */ +export interface ResultFrame { + t: 'result'; + id: number; + ok: boolean; + json: Uint8Array; +} + +function frame(body: Buffer, maxFrameBytes: number): Buffer { + if (body.length > maxFrameBytes) { + throw new FrameError(`a frame of ${body.length} bytes is longer than the ${maxFrameBytes} the protocol allows`, body.length); + } + const out = Buffer.allocUnsafe(HEADER_BYTES + body.length); + out.writeUInt32BE(body.length, 0); + body.copy(out, HEADER_BYTES); + return out; +} + +/** One message as a JSON frame. One longer than `maxFrameBytes` throws a FrameError, and nothing is written. */ +export function encodeFrame(message: unknown, maxFrameBytes: number = MAX_FRAME_BYTES): Buffer { + const json = JSON.stringify(message); + if (json === undefined) throw new FrameError('a frame must hold a JSON value'); + const body = Buffer.allocUnsafe(1 + Buffer.byteLength(json, 'utf8')); + body[0] = JSON_KIND; + body.write(json, 1, 'utf8'); + return frame(body, maxFrameBytes); +} + +/** + * A call's result as a result frame: the JSON's bytes follow a header of `ok` + * and the id. Written straight into the frame, so a result's bytes are held + * once, which the host's memory figure counts. + */ +export function encodeResultFrame(id: number, ok: boolean, json: string, maxFrameBytes: number): Buffer { + const bodyLength = RESULT_HEADER_BYTES + Buffer.byteLength(json, 'utf8'); + if (bodyLength > maxFrameBytes) { + throw new FrameError(`a frame of ${bodyLength} bytes is longer than the ${maxFrameBytes} the protocol allows`, bodyLength); + } + const out = Buffer.allocUnsafe(HEADER_BYTES + bodyLength); + out.writeUInt32BE(bodyLength, 0); + out[HEADER_BYTES] = RESULT_KIND; + out[HEADER_BYTES + 1] = ok ? 1 : 0; + out.writeUInt32BE(id >>> 0, HEADER_BYTES + 2); + out.write(json, HEADER_BYTES + RESULT_HEADER_BYTES, 'utf8'); + return out; +} + +/** How much a result frame's body holds beyond its JSON. */ +export const RESULT_FRAME_OVERHEAD = RESULT_HEADER_BYTES; + +/** + * Bytes in, whole frames' values out. Holds at most one frame's bytes plus its + * header: a header naming more than `maxFrameBytes` throws before any of that + * frame's body is kept, and once a header names its body's length, the body + * is one buffer of that length, which each chunk is copied into. So however + * the bytes arrive, one at a time included, the decoder keeps no chunk of + * them. Takes result frames only if asked to: a parent's decoder takes JSON + * alone. + */ +export class FrameDecoder { + private readonly header = Buffer.alloc(HEADER_BYTES); + private headerFilled = 0; + private body: Buffer | null = null; + private bodyFilled = 0; + private broken: FrameError | null = null; + + constructor( + private readonly maxFrameBytes: number = MAX_FRAME_BYTES, + private readonly takesResults = false, + /** The most values a JSON frame may hold, counted before it's parsed. */ + private readonly maxValues = Infinity, + ) {} + + /** The values of every frame `chunk` completes, in order. Throws the same FrameError from then on, once one frame was bad. */ + push(chunk: Buffer): unknown[] { + if (this.broken) throw this.broken; + const values: unknown[] = []; + let at = 0; + while (at < chunk.length) { + if (this.body === null) { + const n = Math.min(HEADER_BYTES - this.headerFilled, chunk.length - at); + chunk.copy(this.header, this.headerFilled, at, at + n); + this.headerFilled += n; + at += n; + if (this.headerFilled < HEADER_BYTES) break; + const length = this.header.readUInt32BE(0); + if (length > this.maxFrameBytes) { + return this.fail(`a frame announced ${length} bytes, more than the ${this.maxFrameBytes} the protocol allows`); + } + if (length === 0) return this.fail('a frame was empty'); + this.body = Buffer.allocUnsafe(length); + this.bodyFilled = 0; + } + const n = Math.min(this.body.length - this.bodyFilled, chunk.length - at); + chunk.copy(this.body, this.bodyFilled, at, at + n); + this.bodyFilled += n; + at += n; + if (this.bodyFilled < this.body.length) break; + const body = this.body; + this.body = null; + this.headerFilled = 0; + values.push(this.decode(body)); + } + return values; + } + + /** At the stream's end: throws if a frame was begun and not finished. */ + end(): void { + if (this.broken) throw this.broken; + if (this.headerFilled > 0 || this.body !== null) { + this.fail(`the stream ended inside a frame, with ${this.body === null ? this.headerFilled : this.bodyFilled} of its bytes read`); + } + } + + private decode(body: Buffer): unknown { + if (body[0] === RESULT_KIND && this.takesResults) { + if (body.length < RESULT_HEADER_BYTES) return this.fail('a result frame was shorter than its header'); + const result: ResultFrame = { t: 'result', id: body.readUInt32BE(2), ok: body[1] === 1, json: body.subarray(RESULT_HEADER_BYTES) }; + return result; + } + if (body[0] !== JSON_KIND) return this.fail(`a frame was of kind ${body[0]}, which this side doesn't take`); + let text: string; + try { + // A byte-order mark is kept, so it fails JSON.parse as the text it is. + text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(body.subarray(1)); + } catch { + return this.fail('a frame was not UTF-8'); + } + if (countJsonValues(text, this.maxValues) > this.maxValues) return this.fail(`a frame held more than ${this.maxValues} values`); + try { + return JSON.parse(text); + } catch { + return this.fail('a frame was not JSON'); + } + } + + private fail(message: string): never { + this.broken = new FrameError(message); + this.body = null; + throw this.broken; + } +} diff --git a/src/code-execution/isolate/output.ts b/src/code-execution/isolate/output.ts new file mode 100644 index 000000000..d8bfce9a0 --- /dev/null +++ b/src/code-execution/isolate/output.ts @@ -0,0 +1,161 @@ +/** + * A script's output, bounded once, in the parent: every return, whether a + * yield, a wait or the ending's, gives what the script wrote since the + * previous one, its first and last characters within the bound and a count of + * what was dropped between. A long stretch keeps its start, since that's + * often where its trouble began, such as the first error before a cascade. + * + * The child can't know when the parent returns, since its frames reach it only + * while its guest is idle, so the bound sits in the parent: the child holds + * one bounded batch of what the guest writes and flushes it, and the parent + * keeps the head and tail of each return's stretch, starting both afresh at + * each return. Memory is bounded by construction on both sides; the price is + * that a printing loop's whole output crosses the pipe, in batches. + */ + +export interface OutputBounds { + /** Characters kept from the start of each return's stretch. */ + headChars: number; + /** Characters kept from its end. */ + tailChars: number; +} + +export const DEFAULT_OUTPUT_BOUNDS: OutputBounds = { headChars: 8_000, tailChars: 8_000 }; + +/** The most one output frame carries, so a single large write is sent in pieces. */ +export const MAX_BATCH_CHARS = 64 * 1024; + +/** Where to cut `text` near `at` without parting a surrogate pair: one earlier if `at` falls inside one. */ +function safeCut(text: string, at: number): number { + if (at <= 0 || at >= text.length) return Math.max(0, Math.min(at, text.length)); + const before = text.charCodeAt(at - 1); + return before >= 0xd800 && before <= 0xdbff ? at - 1 : at; +} + +/** The child's side: what the guest wrote since the last flush, at most a batch of it at a time. */ +export class OutputBatch { + private pending: string[] = []; + private size = 0; + + /** Characters waiting to be flushed. */ + get unflushed(): number { + return this.size; + } + + /** True once the batch is full, so the caller flushes now. */ + write(text: string): boolean { + if (text.length === 0) return this.size >= MAX_BATCH_CHARS; + this.pending.push(text); + this.size += text.length; + return this.size >= MAX_BATCH_CHARS; + } + + /** The waiting text in pieces of at most a batch each, emptying it; empty when nothing waits. */ + flush(): string[] { + if (this.size === 0) return []; + let text = this.pending.join(''); + this.pending = []; + this.size = 0; + const pieces: string[] = []; + while (text.length > MAX_BATCH_CHARS) { + const cut = safeCut(text, MAX_BATCH_CHARS); + pieces.push(text.slice(0, cut)); + text = text.slice(cut); + } + if (text.length > 0) pieces.push(text); + return pieces; + } +} + +/** A return's stretch of output: its head and tail, and the count dropped between them. */ +export interface OutputWindow { + head: string; + tail: string; + dropped: number; +} + +/** The line naming what was dropped, at its longest, as renderOutput puts it between the head and the tail. */ +const DROPPED_LINE_CHARS = `\n[… ${Number.MAX_SAFE_INTEGER} characters of output dropped here …]\n`.length; + +/** + * The stretch as the model sees it: the head, a line naming what was dropped + * if anything was, and the tail. Past `max`, its start and its end are cut + * alike to fit it, each taking the room the other doesn't need, and the count + * takes in what they gave up, so a return held to its agent's inline cap + * still shows both ends. A `max` that leaves no room for that line gets the + * line alone, which passes it. + */ +export function renderOutput(w: OutputWindow, max = Infinity): string { + const whole = w.dropped === 0 ? w.head + w.tail : `${w.head}\n[… ${w.dropped} characters of output dropped here …]\n${w.tail}`; + if (whole.length <= max) return whole; + const room = Math.max(0, Math.floor(max) - DROPPED_LINE_CHARS); + // With nothing dropped yet, the head and the tail are one stretch, whose end + // lies in the head while the tail is short or empty: it's cut as one. + const [first, last] = w.dropped === 0 ? [whole, whole] : [w.head, w.tail]; + const lastKeep = Math.min(last.length, Math.max(Math.floor(room / 2), room - first.length)); + const head = first.slice(0, safeCut(first, Math.min(first.length, room - lastKeep))); + // Kept from the end, never starting on a pair's second half. + let from = last.length - lastKeep; + const at = last.charCodeAt(from); + if (from > 0 && at >= 0xdc00 && at <= 0xdfff) from += 1; + const tail = last.slice(from); + const dropped = w.dropped === 0 ? whole.length - head.length - tail.length : w.dropped + (first.length - head.length) + from; + return `${head}\n[… ${dropped} characters of output dropped here …]\n${tail}`; +} + +/** The parent's side: the head and tail of what arrived since the last return, and the count dropped between. */ +export class WindowOutput { + private head = ''; + private tail = ''; + private dropped = 0; + + constructor(private readonly bounds: OutputBounds) {} + + /** Add text the child flushed. */ + apply(text: string): void { + let rest = text; + if (this.tail.length === 0 && this.dropped === 0) { + const room = this.bounds.headChars - this.head.length; + if (room > 0) { + const cut = safeCut(rest, room); + this.head += rest.slice(0, cut); + rest = rest.slice(cut); + if (rest.length === 0) return; + } + } + this.tail += rest; + const over = this.tail.length - this.bounds.tailChars; + if (over > 0) { + // Drop from the front, never splitting a pair: the cut moves later by one if it would. + let cut = over; + const at = this.tail.charCodeAt(cut - 1); + if (at >= 0xd800 && at <= 0xdbff) cut += 1; + this.dropped += cut; + this.tail = this.tail.slice(cut); + } + } + + /** Characters dropped from the stretch so far. */ + get droppedChars(): number { + return this.dropped; + } + + /** The stretch as it stands. */ + window(): OutputWindow { + return { head: this.head, tail: this.tail, dropped: this.dropped }; + } + + /** The stretch as the model sees it, whole (renderOutput). */ + render(): string { + return renderOutput(this.window()); + } + + /** The stretch, and a fresh one from here. */ + take(): OutputWindow { + const window = this.window(); + this.head = ''; + this.tail = ''; + this.dropped = 0; + return window; + } +} diff --git a/src/code-execution/isolate/prelude.ts b/src/code-execution/isolate/prelude.ts new file mode 100644 index 000000000..6c59f4575 --- /dev/null +++ b/src/code-execution/isolate/prelude.ts @@ -0,0 +1,409 @@ +/** + * The guest's prelude: JavaScript the engine evaluates before a script, which + * builds what a script meets from the binding's two globals, `__host(name, + * argsJson)` and `__output(stream, text)`: + * + * - `tools[name](args)`, each tool by its exact name, answering one result + * shape, `{ content, structuredContent, isError }`. A refusal before + * dispatch rejects with an Error carrying the refusal's fields. Reading a + * name that isn't a tool throws at once, naming the close matches. + * At most MAX_CALLS_IN_FLIGHT are out at once, and one past them waits + * here, in the script's own memory, until one is answered. + * - `ALL_TOOLS`, each tool's name, description and input schema. + * - `text(value)` and `console.log/info/warn/error/debug(...)`, which write + * the script's output: a string as it is, anything else as JSON. + * - `wake_agent(payload)`, which asks the host to wake the agent. + * - `__run(src)`, which runs a script as the body of an async function, so it + * may `await` at its top level and `return` a value. + * + * Nothing here guards a boundary: the guest's whole world is this instance, + * and every call it makes is checked by the host. Built-ins aren't frozen, so a + * script that patches one breaks only itself: its calls and its ending are + * recorded outside the guest. + * + * Adapted from pi-codemode's guest prelude (`@earendil-works/pi-codemode` + * 1.3.0, `dist/runtime/prelude-source.js`): the tools proxy and its + * close-match error, the output helpers and the error formatting. Its licence, + * the LICENSE at the root of github.com/earendil-works/pi, which the package's + * `license: "MIT"` refers to: + * + * MIT License. Copyright (c) 2025 Mario Zechner. Permission is hereby granted, + * free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software + * without restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: The above copyright notice and this + * permission notice shall be included in all copies or substantial portions + * of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY + * KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO + * EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE + * USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { MAX_CALLS_IN_FLIGHT } from './protocol.js'; +import { cutOf } from './render.js'; + +/** The host call name `wake_agent()` uses. Parentheses can't be in a tool's name, so it never names a tool. */ +export const WAKE_CALL_NAME = '(wake_agent)'; + +/** + * How many lines QuickJS's async function constructor puts before a script's + * first line, and the filename it gives the script's code: a script's frames + * read `:N:C`, with its own line N less this. + */ +export const SCRIPT_LINE_OFFSET = 2; + +/** + * A frame of the script's own code, as QuickJS writes one in a stack: " at + * NAME (PLACE)", or " at PLACE" for a syntax error, its PLACE at the end + * and the script's, `:LINE:COLUMN`, its LINE of at most ten digits, as + * QuickJS writes a line (a 32-bit integer). A NAME may hold any character but + * a line feed, which the stack's lines are split at, so `[^]` rather than + * `.`, which stops at a carriage return and U+2028 and U+2029 too. The + * guest's copy of the rule (errorText) is given these two patterns as they + * are. + */ +export const SCRIPT_FRAME = /^\s+at\s[^]*:\d{1,10}:\d+\)?$/; +/** A script frame's place, at its end: its line is renumbered as the agent wrote it. */ +export const SCRIPT_PLACE = /:(\d{1,10})(:\d+\)?)$/; +/** + * SCRIPT_FRAME as it reads a line within a whole stack, from the line's + * start (sticky) to the line feed or the stack's end that closes it, so that + * no string is made for the line: a line holds no line feed, so there `\s` + * is `[^\S\n]` and `[^]` is `[^\n]`. A test holds the two to one reading. + */ +const SCRIPT_FRAME_IN_STACK = /[^\S\n]+at[^\S\n][^\n]*:\d{1,10}:\d+\)?(?=\n|$)/y; + +/** + * A failure's frames as the agent reads them, one per line: the script's own, + * numbered as the agent wrote its lines, leaving out the prelude's frames and + * the engine's native ones. QuickJS gives an Error's stack as its frames + * alone, and nothing here adds the failure's name and message to it, so the + * script's frames are the lines shaped as its frames are (SCRIPT_FRAME): a + * line of a stack the script or its Error.prepareStackTrace set is one if it + * is shaped so, and isn't otherwise. A function's name holding a line feed + * splits its frame in two, and each half is read on its own, so the frame is + * lost unless a half is itself shaped as a frame. Code the script compiles at + * run time is named `` as the script is, so the rule takes its frames + * for the script's: an eval's read at its string's lines less two, and a + * Function constructor's at its body's own lines, plus one for each line + * break in its parameters, since the constructor puts its parameters and two + * lines before the body, as the prelude's AsyncFunction does. The prelude's + * errorText applies this rule in the guest, to an Error the script logs, + * leading with the name and message, while a string the script logs, an + * Error's `stack` among them, shows as QuickJS or the script wrote it; the + * child applies the rule here to every failure's stack. The two are one rule: + * the guest is given the pair of patterns as they are, and the walk here + * reads SCRIPT_FRAME as SCRIPT_FRAME_IN_STACK and renumbers a place itself, + * as SCRIPT_PLACE says, so a change to one has to be made to all three, and + * tests hold them together. + */ +export function scriptFrames(stack: string, max = Infinity): string { + // Two walks of the stack, a line at a time, making no string for a line + // that isn't a frame, or for a frame the cut doesn't keep: a stack the + // script set can be any length, as many lines as characters or one line as + // long as the stack, and beside it the child holds only what the cut keeps. + // The first walk measures the frames' text; the second takes what the cut + // keeps of it, all of it if it fits `max`, or else its first and last + // halves (bounded's cut, cutOf). + let length = -1; + eachFrame(stack, (start, place, line, rest, end) => { + length += 1 + (place - start) + LABEL_CHARS + digits(line) + (end - rest); + }); + if (length < 0) return ''; + const fits = length <= max; + const half = fits ? length : Math.floor(max / 2); + const tailFrom = length - (fits ? 0 : half); + const head: string[] = []; + const tail: string[] = []; + let at = 0; + // The part of the frames' text from `at` that `text` gives from `from` to + // `to`: what of it falls in the head or the tail goes there. + const put = (text: string, from: number, to: number): void => { + const n = to - from; + if (at < half) head.push(text.slice(from, from + Math.min(n, half - at))); + if (at + n > tailFrom) tail.push(text.slice(from + Math.max(0, tailFrom - at), to)); + at += n; + }; + eachFrame(stack, (start, place, line, rest, end) => { + if (at > 0) put('\n', 0, 1); + put(stack, start, place); + const n = LABEL_CHARS + digits(line); + if (at < half || at + n > tailFrom) put(`script:${line}`, 0, n); + else at += n; + put(stack, rest, end); + }); + return fits ? head.join('') : cutOf(head.join(''), tail.join(''), length); +} + +/** `script:`, which a frame's place is renumbered with. */ +const LABEL_CHARS = 7; + +/** How many digits a whole number from 1 to 9,999,999,999 is written with. */ +function digits(n: number): number { + let count = 1; + for (let rest = n; rest >= 10; rest = Math.floor(rest / 10)) count += 1; + return count; +} + +/** + * Each of the script's frames in `stack`, in order, as scriptFrames reads + * them, with no string made for a line: where its line starts, where its + * place starts, its place's line as the agent wrote it, where the rest of its + * place starts (`:COLUMN`, and a closing parenthesis), and where its line + * ends. + */ +function eachFrame(stack: string, frame: (start: number, place: number, line: number, rest: number, end: number) => void): void { + for (let start = 0; start <= stack.length; ) { + let end = stack.indexOf('\n', start); + if (end === -1) end = stack.length; + SCRIPT_FRAME_IN_STACK.lastIndex = start; + if (SCRIPT_FRAME_IN_STACK.test(stack)) { + // A frame ends in its place, and no later `:` fits in what + // follows it there. Its line has at most ten digits, so the number is + // exact and digits() ends, as it wouldn't on the Infinity that a few + // hundred digits parse to. + const place = stack.lastIndexOf(':', end); + let line = 0; + let rest = place + 8; + for (let c = stack.charCodeAt(rest); c >= 48 && c <= 57; c = stack.charCodeAt(++rest)) line = line * 10 + (c - 48); + frame(start, place, Math.max(1, line - SCRIPT_LINE_OFFSET), rest, end); + } + start = end + 1; + } +} + +/** One tool as the prelude lists it in ALL_TOOLS. */ +export interface PreludeTool { + name: string; + description: string; + /** The tool's input schema, as the agent's own tool list gives it. */ + inputSchema?: unknown; +} + +/** + * The prelude, for these tools. The child, not the prelude, refuses a wake + * past the script's cap (child.ts). The tool list goes in as + * a JSON string the prelude parses, so a schema's `__proto__` key stays a key, + * as it would not in an object literal, and the prelude is one string the + * engine evaluates as global code. + */ +export function preludeSource(tools: readonly PreludeTool[]): string { + const toolsJson = JSON.stringify(tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema ?? null }))); + return `(function (host, out, toolList) { + "use strict"; + const stringify = JSON.stringify; + const ErrorCtor = Error; + const TypeErrorCtor = TypeError; + const scriptOffset = ${SCRIPT_LINE_OFFSET}; + + // QuickJS stacks list frames only: lead with "Name: message", as V8 does, and + // keep only the script's own frames, leaving out this prelude's, the + // engine's native ones and the line that started the script, each numbered + // as the agent wrote its lines. The child applies the same rule to every + // failure's stack (scriptFrames), reading each line in place. + const scriptFrame = ${SCRIPT_FRAME}; + const scriptPlace = ${SCRIPT_PLACE}; + function errorText(error) { + const head = error.message ? String(error.name) + ": " + String(error.message) : String(error.name); + const stack = typeof error.stack === "string" ? error.stack : ""; + const frames = stack.split("\\n") + .filter((line) => scriptFrame.test(line)) + .map((line) => line.replace(scriptPlace, (m, n, rest) => "script:" + Math.max(1, Number(n) - scriptOffset) + rest)); + return [head, ...frames].join("\\n"); + } + + function format(value) { + if (typeof value === "string") return value; + if (value instanceof ErrorCtor) return errorText(value); + try { + const json = stringify(value); + return json === undefined ? String(value) : json; + } catch { + return String(value); + } + } + + function write(stream, value) { + let rendered; + try { + rendered = format(value); + } catch (error) { + throw new TypeErrorCtor(error instanceof ErrorCtor ? error.message : String(error)); + } + out(stream, rendered + "\\n"); + } + + // A refusal before dispatch arrives as its JSON, { name, message, ...fields }. + function asError(reason) { + if (reason instanceof ErrorCtor) return reason; + const fields = reason !== null && typeof reason === "object" ? reason : { message: String(reason) }; + const error = new ErrorCtor(typeof fields.message === "string" ? fields.message : "the call was refused"); + for (const key of Object.keys(fields)) { + if (key === "name" || key === "message" || key === "stack") continue; + Object.defineProperty(error, key, { value: fields[key], writable: true, enumerable: true, configurable: true }); + } + if (typeof fields.name === "string" && fields.name) error.name = fields.name; + return error; + } + + function toJson(name, args) { + let json; + try { + json = stringify(args); + } catch (error) { + throw new TypeErrorCtor(name + "'s arguments aren't JSON (" + (error instanceof ErrorCtor ? error.message : String(error)) + "); nothing was sent"); + } + if (json === undefined) throw new TypeErrorCtor(name + "'s arguments aren't JSON; nothing was sent"); + return json; + } + + // At most maxInFlight of the script's tool calls are out at once. One past + // them waits here, in the script's own memory, until one is answered, and + // its place passes straight to it: never refused, only paced. + const maxInFlight = ${MAX_CALLS_IN_FLIGHT}; + let inFlight = 0; + const waiting = []; + function release() { + const next = waiting.shift(); + if (next) next(); + else inFlight -= 1; + } + function call(name, args) { + let json; + try { + json = args === undefined ? "{}" : toJson(name, args); + } catch (error) { + return Promise.reject(error); + } + return new Promise((resolve, reject) => { + const go = () => { + let pending; + try { + pending = host(name, json); + } catch (error) { + release(); + reject(asError(error)); + return; + } + pending.then( + (value) => { release(); resolve(value); }, + (reason) => { release(); reject(asError(reason)); }, + ); + }; + if (inFlight < maxInFlight) { + inFlight += 1; + go(); + } else { + waiting.push(go); + } + }); + } + + // wake_agent() takes no place among the calls in flight. The child refuses + // a payload too large, and a wake past the script's cap, before any goes out. + function wake(payload) { + let json; + try { + json = toJson("wake_agent()", payload === undefined ? null : payload); + } catch (error) { + return Promise.reject(error); + } + return host(${JSON.stringify(WAKE_CALL_NAME)}, json).then((value) => value, (reason) => { throw asError(reason); }); + } + + const tools = Object.create(null); + const allTools = []; + for (const { name, description, inputSchema } of toolList) { + if (name in tools) continue; + tools[name] = (args) => call(name, args); + allTools.push(Object.freeze({ name, description, inputSchema })); + } + Object.freeze(tools); + Object.freeze(allTools); + + // A name that isn't a tool throws at once, naming the close matches, rather + // than failing later as "not a function". \`in\` still answers false. + const comparable = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, ""); + const names = allTools.map((tool) => tool.name); + const toolsProxy = new Proxy(tools, { + get(object, property, receiver) { + if (typeof property !== "string" || property in object || property in Object.prototype || property === "then" || property === "toJSON") { + return Reflect.get(object, property, receiver); + } + const wanted = comparable(property); + const exact = names.filter((name) => comparable(name) === wanted); + const close = exact.length > 0 ? exact : names.filter((name) => wanted && (comparable(name).includes(wanted) || wanted.includes(comparable(name)))); + let message = "tools[" + stringify(property) + "] is not one of your tools."; + if (close.length > 0) message += " Did you mean " + close.slice(0, 5).map((name) => "tools[" + stringify(name) + "]").join(", ") + "?"; + else if (names.length === 0) message += " You have no tools."; + else if (names.length <= 20) message += " Your tools: " + names.join(", ") + "."; + else message += " ALL_TOOLS lists them."; + message += " Nothing was sent."; + throw new TypeErrorCtor(message); + }, + }); + + const consoleObject = {}; + for (const level of ["log", "info", "debug"]) consoleObject[level] = (...args) => out(1, args.map(format).join(" ") + "\\n"); + for (const level of ["warn", "error"]) consoleObject[level] = (...args) => out(2, args.map(format).join(" ") + "\\n"); + Object.freeze(consoleObject); + + const AsyncFunction = (async function () {}).constructor; + // The script's value, as the JSON text of what it returned, serialized here + // once: its toJSON and getters run once, and the engine serializes this text + // as a string, which runs no script code however often it's read. A value + // JSON can't hold, such as a function, a cycle or a BigInt, is its string form. + async function run(src) { + let value; + try { + const fn = new AsyncFunction(src); + value = await fn.call(undefined); + } catch (error) { + // An Error goes as it is, a syntax error's included: the child puts its + // frames as the agent reads them (scriptFrames). Anything else thrown, + // such as a plain object or a string, fails the script with its JSON or + // its string form as the message, so the agent reads what was thrown; + // its stack, made here, holds no frame of the script's. + if (error instanceof ErrorCtor) throw error; + const failure = new ErrorCtor(format(error)); + failure.name = ""; + throw failure; + } + if (value === undefined) return undefined; + let json; + try { + json = stringify(value); + } catch { + json = undefined; + } + if (json !== undefined) return json; + // Said as a string, so the agent reads that the value itself didn't come back. + let text; + try { + text = "JSON can't hold the returned value, so this is its string form: " + String(value); + } catch { + text = "JSON can't hold the returned value, and it has no string form"; + } + return stringify(text); + } + + Object.defineProperty(globalThis, "tools", { value: toolsProxy, enumerable: true }); + Object.defineProperty(globalThis, "ALL_TOOLS", { value: allTools, enumerable: true }); + Object.defineProperty(globalThis, "console", { value: consoleObject, enumerable: true, configurable: true }); + Object.defineProperty(globalThis, "text", { value: (value) => write(1, value), enumerable: true }); + Object.defineProperty(globalThis, "wake_agent", { value: wake, enumerable: true }); + Object.defineProperty(globalThis, "__run", { value: run }); + try { delete globalThis.__host; delete globalThis.__output; } catch {} +})(globalThis.__host, globalThis.__output, JSON.parse(${JSON.stringify(toolsJson)})); +`; +} + +/** The one line `start` evaluates after the prelude: the script, as a string literal, handed to `__run`. */ +export function scriptCall(script: string): string { + return `await __run(${JSON.stringify(script)});`; +} diff --git a/src/code-execution/isolate/protocol.ts b/src/code-execution/isolate/protocol.ts new file mode 100644 index 000000000..6d3526995 --- /dev/null +++ b/src/code-execution/isolate/protocol.ts @@ -0,0 +1,240 @@ +/** + * The messages between a script's parent and its child, and the checks that + * turn a decoded frame from the child into one of them. Every frame from a + * child is untrusted: the parent acts only on a + * message `decodeChildMessage` accepts, and any other ends the script as + * crashed. + */ + +import { countJsonValues } from './frames.js'; +import type { PreludeTool } from './prelude.js'; + +export { countJsonValues }; + +/** + * The most tool calls a script has out at once, from the call until the + * script has its answer. One past them waits in the script, in its own memory, + * until one is answered, and is never refused, so a `Promise.all` over many + * calls still works, only paced, and what waits is the script's to hold. A + * child that sends more crashes its run. `wake_agent()` takes no place: its + * answer holds nothing, and its own cap bounds it. + */ +export const MAX_CALLS_IN_FLIGHT = 16; + +/** + * The most JSON a tool call's arguments may be, in UTF-8 bytes: room for a + * generated file of a few MB of ordinary text as one string (escaping takes + * more room for quotes, backslashes and control characters). With MAX_ARGUMENT_VALUES it bounds + * what the host holds for a call while it runs: its arguments' text and their + * parsed value, at most about twice these bytes each (a string the runtime + * keeps in two bytes a character) and about 92 bytes a value, the most a + * value cost in a parse under Node 24 and Bun 1.4. + */ +export const MAX_ARGUMENT_BYTES = 4 * 1024 * 1024; + +/** The most values a tool call's arguments may hold: each object, array and element counts. */ +export const MAX_ARGUMENT_VALUES = 100_000; + +/** The most JSON a `wake_agent()` payload may be, in UTF-8 bytes: a wake says what happened, and a longer result is written with a tool. */ +export const MAX_WAKE_PAYLOAD_BYTES = 64 * 1024; + +/** A cap as the tool's text says it, such as 4 MiB. */ +export const capText = (bytes: number): string => (bytes % (1024 * 1024) === 0 ? `${bytes / (1024 * 1024)} MiB` : `${bytes / 1024} KiB`); + +/** A `wake_agent()` payload the host won't take, said to the script: nothing was sent. Null when it may go out. */ +export function wakeRefusal(payloadJson: string): { name: 'WakeRefused'; message: string; bytes: number } | null { + const bytes = Buffer.byteLength(payloadJson, 'utf8'); + if (bytes <= MAX_WAKE_PAYLOAD_BYTES) return null; + return { + name: 'WakeRefused', + message: `wake_agent()'s payload is ${bytes} bytes of JSON, past the ${capText(MAX_WAKE_PAYLOAD_BYTES)} a wake can carry; nothing was sent. Write a longer result with a tool, and wake with where to find it.`, + bytes, + }; +} + +/** + * The longest frame a child sends: a call frame carries its arguments escaped, + * at most six bytes for each of the 4 MiB the script may send, with room for + * its name and the rest. The child encodes its frames against it, and the + * parent refuses a longer one before its body is kept, so one frame being + * read costs the host at most this much as bytes, and its text and its parse + * up to twice as much again each. + */ +export const MAX_CHILD_FRAME_BYTES = 6 * MAX_ARGUMENT_BYTES + 1024 * 1024; + +/** A wake past the script's cap, said to the script: nothing was sent. */ +export function wakeLimitRefusal(maxWakes: number): { name: 'WakeRefused'; message: string } { + // A cap of 0 turns wakes off for every script, so a new one can't either. + if (maxWakes === 0) { + return { + name: 'WakeRefused', + message: "wake_agent() is off on this host: its operator set maxWakesPerScript to 0, so nothing was sent, and no script here can wake its agent.", + }; + } + return { + name: 'WakeRefused', + message: + `wake limit reached: ${maxWakes} per script, set by the host's maxWakesPerScript; nothing was sent, and every later ` + + 'wake_agent() in this script is refused the same way. A new script can wake its agent again.', + }; +} + +/** A call's arguments the host won't take, said to the script: nothing was sent. Null when they may go out. */ +export function argumentsRefusal(tool: string, argsJson: string): { name: 'ArgumentsTooLarge'; message: string; bytes: number } | null { + const bytes = Buffer.byteLength(argsJson, 'utf8'); + if (bytes > MAX_ARGUMENT_BYTES) { + return { + name: 'ArgumentsTooLarge', + message: `${tool}'s arguments are ${bytes} bytes of JSON, past the ${capText(MAX_ARGUMENT_BYTES)} a call can take; nothing was sent. Send the data in parts, over several calls.`, + bytes, + }; + } + if (countJsonValues(argsJson, MAX_ARGUMENT_VALUES) > MAX_ARGUMENT_VALUES) { + return { + name: 'ArgumentsTooLarge', + message: `${tool}'s arguments hold more than ${MAX_ARGUMENT_VALUES.toLocaleString('en-US')} values, the most a call can take; nothing was sent. Send the data in parts, or as one string.`, + bytes, + }; + } + return null; +} + +/** The error a script gets for a call whose result was too large for its memory: the call took effect. */ +export const RESULT_TOO_LARGE = 'ResultTooLarge'; + +/** Why a stop was asked for: by the agent, or at the script's limit. Only the parent knows it. */ +export type StopReason = 'agent' | 'limit'; + +/** The first frame to the child: the script and everything it runs with. */ +export interface StartMessage { + t: 'start'; + script: string; + tools: PreludeTool[]; + /** The WebAssembly memory's maximum: the child's budget. */ + memoryMaxBytes: number; + maxStackBytes?: number; + /** The file whose appearance stops the script: its content says nothing. */ + stopFile: string; + /** How often, at most, waiting output is flushed while the guest runs. */ + flushMs: number; + /** The most wakes the script may send: the child refuses one past them itself. */ + maxWakes: number; + /** The parent's pid: the child ends its process once its parent is another, its parent having gone. */ + parentPid: number; +} + +/** + * A tool call's answer, as the child decodes a result frame: `ok` resolves the + * guest's promise with the parsed `json`, otherwise rejects with it. The JSON + * stays bytes, for the engine to parse in the guest. + */ +export type { ResultFrame as ResultMessage } from './frames.js'; +import type { ResultFrame } from './frames.js'; + +/** A stop for a child whose guest is idle, waiting on calls; a computing guest sees the stop file instead. */ +export interface StopMessage { + t: 'stop'; +} + +export type ParentMessage = StartMessage | ResultFrame | StopMessage; + +/** The guest called a tool, or `wake_agent()`, by the prelude's call name. */ +export interface CallMessage { + t: 'call'; + id: number; + name: string; + /** The arguments as the guest's JSON text. */ + args: string; +} + +/** Output the guest wrote since the last such frame. */ +export interface OutMessage { + t: 'out'; + text: string; +} + +/** An error as the guest describes it. */ +export interface GuestError { + name: string; + message: string; + /** The script's frames, one per line, as the agent reads them (scriptFrames): the name and message aren't in it. */ + stack?: string; +} + +/** + * The engine settled a call's result, so its place is free. `unfit` when the + * result didn't fit the guest's memory, so the script got ResultTooLarge in + * its place. The parent's record counts a call as the script saw it. + */ +export interface SeenMessage { + t: 'seen'; + id: number; + unfit?: true; +} + +/** + * How the script ended, as the child saw it. A crash is the parent's to see: + * the child can't report it. A stop's reason is the parent's too, since it + * asked for the stop. + */ +export type EndMessage = + | { t: 'end'; how: 'finished'; value?: string } + | { t: 'end'; how: 'failed'; error: GuestError; stalled?: boolean } + | { t: 'end'; how: 'stopped' }; + +export type ChildMessage = CallMessage | OutMessage | SeenMessage | EndMessage; + +const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); +/** A call's id: a result frame carries it as an unsigned 32-bit number, so a larger one couldn't name its call. */ +const isCallId = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 0xffffffff; + +/** The longest tool name the parent takes from a child, in UTF-16 code units. Providers allow 64; this leaves room and bounds the error text. */ +const MAX_NAME_CHARS = 256; + +/** + * A frame from a child, as a message the parent may act on, or the reason it + * can't be one. Fields beyond those a message has are ignored. + */ +export function decodeChildMessage(value: unknown): ChildMessage | { invalid: string } { + if (!isRecord(value)) return { invalid: 'a frame from the child was not an object' }; + switch (value.t) { + case 'call': { + if (!isCallId(value.id)) return { invalid: "a call's id was not a whole number from 0 to 4294967295" }; + if (typeof value.name !== 'string' || value.name.length === 0 || value.name.length > MAX_NAME_CHARS) { + return { invalid: "a call's name was not a string of 1 to 256 UTF-16 code units" }; + } + if (typeof value.args !== 'string') return { invalid: "a call's arguments were not text" }; + return { t: 'call', id: value.id, name: value.name, args: value.args }; + } + case 'seen': { + if (!isCallId(value.id)) return { invalid: "a seen frame's id was not a whole number from 0 to 4294967295" }; + return value.unfit === true ? { t: 'seen', id: value.id, unfit: true } : { t: 'seen', id: value.id }; + } + case 'out': { + if (typeof value.text !== 'string') return { invalid: 'an output frame did not hold text' }; + return { t: 'out', text: value.text }; + } + case 'end': { + if (value.how === 'finished') { + if (value.value !== undefined && typeof value.value !== 'string') return { invalid: "a finished script's value was not JSON text" }; + return value.value === undefined ? { t: 'end', how: 'finished' } : { t: 'end', how: 'finished', value: value.value }; + } + if (value.how === 'failed') { + const error = value.error; + if (!isRecord(error) || typeof error.name !== 'string' || typeof error.message !== 'string' || (error.stack !== undefined && typeof error.stack !== 'string')) { + return { invalid: "a failed script's error did not hold a name and a message" }; + } + return { + t: 'end', + how: 'failed', + error: { name: error.name, message: error.message, ...(error.stack === undefined ? {} : { stack: error.stack }) }, + ...(value.stalled === true ? { stalled: true } : {}), + }; + } + if (value.how === 'stopped') return { t: 'end', how: 'stopped' }; + return { invalid: 'an end frame named no ending' }; + } + default: + return { invalid: 'a frame from the child named no message' }; + } +} diff --git a/src/code-execution/isolate/render.ts b/src/code-execution/isolate/render.ts new file mode 100644 index 000000000..c6b8b5790 --- /dev/null +++ b/src/code-execution/isolate/render.ts @@ -0,0 +1,230 @@ +/** + * What the model reads for a return of `code_execution`: how the script ended + * or that it's still running, first, so no cut can drop it; then what became + * of its calls, naming each whose effect is uncertain with a count of the + * rest; then what it returned and what it wrote since the last return. + */ + +import { fresh, toolRef, type CallSummary, type NamedCall } from './calls.js'; +import type { Ending } from './runner.js'; +import type { ScriptListing, ScriptReturn } from './scripts.js'; +import { renderOutput } from './output.js'; + +/** + * The most of a script's return value, or of a failure's message or stack, + * that a return shows, as a head and a tail of half this each. The run keeps + * them cut to this, so an ended run kept for a later wait holds no more. + */ +export const VALUE_CHARS = 8_000; + +/** The most of a failure's name a return shows: a name is a word, so the message and the stack keep the room. */ +export const NAME_CHARS = 200; + +/** + * The most a cut's marker adds to its text (bounded's, with a count of up to + * 16 digits). A text bounded again with this much room past its first bound + * keeps its first cut, and the count that cut gives, as they are. + */ +export const CUT_MARK_CHARS = 64; + +/** + * `text`, or its head and tail of about `max / 2` each with the count dropped + * between, as copies of their own. Neither cut parts a surrogate pair. + */ +export function bounded(text: string, max: number): string { + if (text.length <= max) return text; + const half = Math.floor(max / 2); + return cutOf(text.slice(0, half), text.slice(text.length - half), text.length); +} + +/** + * bounded's cut of a text of `length` characters, past its bound, from its + * first and its last `max / 2` characters, rounded down: `head` and `tail`, + * of that one length. So a text that's never held whole, such as a stack's + * frames as the agent reads them (scriptFrames), is cut as bounded cuts it. + */ +export function cutOf(head: string, tail: string, length: number): string { + const half = head.length; + let kept = half; + const last = head.charCodeAt(kept - 1); + if (last >= 0xd800 && last <= 0xdbff) kept -= 1; + const first = tail.charCodeAt(0); + const skipped = first >= 0xdc00 && first <= 0xdfff ? 1 : 0; + return `${fresh(head.slice(0, kept))}\n[… ${length - half + skipped - kept} characters dropped here …]\n${fresh(tail.slice(skipped))}`; +} + +/** A duration as the settings give it: exactly, a fraction of a second included. */ +export function formatMs(ms: number): string { + if (ms < 1000) return `${ms} ms`; + if (ms % 3_600_000 === 0) return `${ms / 3_600_000} h`; + if (ms % 60_000 === 0) return `${ms / 60_000} min`; + return `${ms / 1000} s`; +} + +function endingLine(id: string, ending: Ending, lifetimeMs: number): string { + switch (ending.how) { + case 'finished': + return `Script ${id} finished.`; + case 'failed': + // The run keeps the message cut to VALUE_CHARS, with room for the cut's marker. + return `Script ${id} failed: ${ending.error.name ? `${ending.error.name}: ` : ''}${ending.error.message}`; + case 'stopped': { + const why = ending.reason === 'agent' + ? `Script ${id} was stopped, as you asked.` + : ending.why + ? `Script ${id} was stopped: ${ending.why}.` + : `Script ${id} was stopped at its limit of ${formatMs(lifetimeMs)}.`; + return ending.short ? `${why} Its process didn't stop by itself and was ended, so its output may be short.` : why; + } + case 'crashed': + // The detail is the host's account; what it means for the agent follows. + return `Script ${id} crashed: ${ending.detail}. A crash is the script's process failing, not an error its code threw: a fault in the host or its runtime, or something the script's own data asked of the runtime that it couldn't do, such as reading values nested thousands deep, or code nested hundreds deep. Running it again may work; if the same script crashes again, change what it does with its data or its code.`; + } +} + +/** + * The start of `text`, up to `max` characters and "…" when it goes on, never + * parting a surrogate pair: a glimpse of a text that may already have been cut, + * whose count a second cut would misstate. + */ +export function glimpse(text: string, max: number): string { + if (text.length <= max) return text; + let cut = max; + const last = text.charCodeAt(cut - 1); + if (last >= 0xd800 && last <= 0xdbff) cut -= 1; + return `${fresh(text.slice(0, cut))}…`; +} + +/** A named call as the agent wrote it, with its arguments. */ +function namedRef(call: NamedCall): string { + return call.tool === 'wake_agent' ? `wake_agent(${call.args})` : `${toolRef(call.tool)}(${call.args})`; +} + +function namedLine(call: NamedCall): string { + switch (call.state) { + case 'uncertain': + return `- still running when the script ended, so it may or may not take effect: ${namedRef(call)}`; + case 'timed-out': + return `- timed out in the script, and still running when the script ended, so it may still take effect: ${namedRef(call)}`; + case 'timed-out-answered': + return `- timed out in the script, and its answer came later: ${namedRef(call)}, answer ${JSON.stringify(call.answer ?? '')}`; + case 'too-large': + return `- answered with a result the script couldn't receive, too large for its memory or nested too deep, so it got an error instead: ${namedRef(call)}`; + case 'unseen': + return `- answered, but the script ended before it read the answer: ${namedRef(call)}`; + } +} + +/** + * What became of the calls. At a yield, counts. At the end, counts, and each + * call whose answer the script didn't see, named with its arguments, past + * CALLS_NAMED counted by tool. + */ +function callsLines(calls: CallSummary, running: boolean): string { + if (calls.made === 0) return running ? 'It has made no tool calls yet.' : 'It made no tool calls.'; + const unseen = calls.named.length + Object.values(calls.more).reduce((sum, n) => sum + n, 0); + const parts: string[] = []; + const add = (n: number, words: string) => { + if (n > 0) parts.push(`${n} ${words}`); + }; + add(calls.answered, 'answered'); + add(calls.failed, 'answered with an error'); + add(calls.refused, calls.refused === 1 ? 'refused before it ran, so nothing was done' : 'refused before they ran, so nothing was done'); + add(calls.unsent, 'never sent, so nothing was done'); + add(calls.running, 'still running'); + add(calls.waiting, 'waiting their turn in the host'); + // Timed out, answered with a result too large for the script's memory, + // still running when the script ended (uncertain), or answered after the + // script stopped reading (unseen): either way the script went on without + // the answer. + add(unseen, unseen === 1 ? "whose answer it didn't see" : "whose answers it didn't see"); + const head = `${running ? 'Its calls so far' : 'Its calls'}: ${calls.made}${parts.length ? ` (${parts.join('; ')})` : ''}`; + if (running || unseen === 0) return `${head}.`; + const lines = [`${head}:`, ...calls.named.map(namedLine)]; + const more = Object.entries(calls.more); + if (more.length > 0) { + lines.push(`- and ${unseen - calls.named.length} more: ${more.map(([tool, n]) => `${toolRef(tool)} ×${n}`).join(', ')}`); + } + return lines.join('\n'); +} + +export interface RenderOptions { + /** The bound the return was made at, for a yield's line. */ + boundMs: number; + lifetimeMs: number; + /** + * The agent's inline cap for tool results, which the return is stored + * under: the output is cut to the room the rest of the return leaves in + * it. None when left out. + */ + maxChars?: number; +} + +/** The text a return gives the model. */ +export function renderReturn(r: ScriptReturn, options: RenderOptions): string { + const lines: string[] = []; + if (r.ending === null) { + lines.push(`Script ${r.scriptId} is still running after ${formatMs(options.boundMs)}.`); + lines.push( + r.stopsAtTurnEnd + ? `It will be stopped when your turn ends if it's still running, since only the primary agent's scripts outlive a turn. ` + + `Wait for it with {"action": "wait", "script_id": "${r.scriptId}"} to see it finish, or stop it with {"action": "stop", "script_id": "${r.scriptId}"}.` + : `Wait for it with {"action": "wait", "script_id": "${r.scriptId}"}, stop it with {"action": "stop", "script_id": "${r.scriptId}"}, ` + + 'or leave it running: it can wake you with wake_agent(), and you are woken if it fails, crashes or reaches its limit.', + ); + } else { + lines.push(endingLine(r.scriptId, r.ending, options.lifetimeMs)); + } + // The ending's line gave the name and message; the script's frames follow (GuestError.stack). + if (r.ending?.how === 'failed' && r.ending.error.stack) lines.push(r.ending.error.stack); + lines.push(callsLines(r.calls, r.ending === null)); + // The run keeps the value cut to VALUE_CHARS, with room for the cut's marker. + if (r.ending?.how === 'finished' && r.ending.value !== undefined) lines.push(`It returned: ${r.ending.value}`); + const { head, tail, dropped } = r.output; + if (head.length + tail.length + dropped === 0) { + lines.push(r.first ? 'No output.' : 'No output since the last return.'); + return lines.join('\n'); + } + // The output comes last, and past the agent's inline cap the framework's + // cut would take its tail first: it gets the room the rest leaves, its head + // and tail cut alike. + const label = `${r.first ? 'Its output' : 'Its output since the last return'}:\n`; + const rest = lines.join('\n').length + 1 + label.length; + lines.push(label + renderOutput(r.output, (options.maxChars ?? Infinity) - rest)); + return lines.join('\n'); +} + +function minutesAgo(ms: number): string { + const minutes = Math.round(ms / 60_000); + return minutes < 1 ? 'under a minute' : `${minutes} min`; +} + +/** How a listed script stands, in a few words. */ +function listingState(s: ScriptListing, now: number): string { + if (s.running) return `running for ${minutesAgo(now - s.startedAt)}`; + const when = s.endedAt === null ? '' : ` ${minutesAgo(now - s.endedAt)} ago`; + const e = s.ending; + switch (e?.how) { + case 'finished': + return `finished${when}`; + case 'failed': + return `failed${when}: ${e.error.name ? `${e.error.name}: ` : ''}${glimpse(e.error.message, 300)}`; + case 'crashed': + return `crashed${when}`; + case 'stopped': + return e.reason === 'agent' ? `stopped by you${when}` : e.why ? `stopped${when}: ${e.why}` : `stopped at its limit${when}`; + default: + return 'ended'; + } +} + +/** A `list` of an agent's scripts: each one's id and how it stands, with how to see more. */ +export function renderList(scripts: ScriptListing[], now: number): string { + if (scripts.length === 0) return 'You have no scripts.'; + const lines = scripts.map((s) => `- ${s.scriptId}: ${listingState(s, now)}${s.wakes > 0 ? ` (${s.wakes} wake${s.wakes === 1 ? '' : 's'} delivered)` : ''}`); + return [ + 'Your scripts:', + ...lines, + '{"action": "wait", "script_id": ...} gives a script\'s output since its last return, or its result once it has ended.', + ].join('\n'); +} diff --git a/src/code-execution/isolate/runner.ts b/src/code-execution/isolate/runner.ts new file mode 100644 index 000000000..7feef2f6a --- /dev/null +++ b/src/code-execution/isolate/runner.ts @@ -0,0 +1,911 @@ +/** + * The parent's side of one script: its child process, the calls it makes, its + * output, and how it ends. One `ScriptRun` per script, and one child per run, + * which ends with it, so nothing a script held stays after it. + * + * Every frame from the child is decoded and checked before the parent acts on + * it, since the child is untrusted. A frame that breaks the protocol, a child + * that exits without saying how the script ended, or one that doesn't stop + * when asked, ends the run as crashed. A tool call goes to `onToolCall`, the + * bridge to the framework's dispatcher, and its outcome goes into the run's + * call record at dispatch, as running until it's answered. + * + * What the host holds for a script is bounded: at most MAX_CALLS_IN_FLIGHT of + * its calls wait for answers at once, and at most MAX_CALLS_IN_FLIGHT are out + * in the host, timed-out ones whose tools still run included, with any past + * those waiting here as their text. Each call's arguments are within + * MAX_ARGUMENT_BYTES and MAX_ARGUMENT_VALUES, and each answer is at most + * `maxResultBytes`. Answers wait for the child as their frames, written while + * its stdin takes them and after its 'drain' once it hasn't, so each is held + * once, in bytes. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { extname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Readable, Writable } from 'node:stream'; +import { encodeFrame, encodeResultFrame, FrameDecoder, FrameError, RESULT_FRAME_OVERHEAD } from './frames.js'; +import { MAX_BATCH_CHARS, WindowOutput, type OutputBounds, type OutputWindow } from './output.js'; +import { WAKE_CALL_NAME, type PreludeTool } from './prelude.js'; +import { CallLog, callBrief, type CallEntry, type CallOutcome, type CallSummary } from './calls.js'; +import { + argumentsRefusal, + capText, + MAX_CHILD_FRAME_BYTES, + decodeChildMessage, + MAX_ARGUMENT_BYTES, + MAX_ARGUMENT_VALUES, + MAX_CALLS_IN_FLIGHT, + MAX_WAKE_PAYLOAD_BYTES, + RESULT_TOO_LARGE, + wakeRefusal, + type ChildMessage, + type GuestError, + type StopReason, +} from './protocol.js'; +import { bounded, CUT_MARK_CHARS, formatMs, glimpse, NAME_CHARS, VALUE_CHARS } from './render.js'; + +/** A call's answer for the guest: `ok` resolves its promise with the parsed JSON, otherwise rejects with it. */ +export interface CallAnswer { + ok: boolean; + json: string; +} + +/** A tool call's answer from the bridge. */ +export interface ToolAnswer extends CallAnswer { + /** The result was marked as an error. */ + isError?: boolean; + /** The start of what the tool answered, as an agent would read it, for naming a call the script stopped waiting for. */ + brief?: string; + /** + * No result came from the host in time, so the bridge answered for it: the + * tool may still be running, holding its work, or its result was lost, and + * the host can't tell which. Its place stays taken until the run ends. + */ + held?: boolean; +} + +/** How a script ended. */ +export type Ending = + | { how: 'finished'; value?: string } + | { how: 'failed'; error: GuestError; stalled?: boolean } + /** `short` when the child had to be ended, so its output so far may be short. */ + | { how: 'stopped'; reason: StopReason; short?: boolean; why?: string } + | { how: 'crashed'; detail: string }; + +/** A run as it stands: its output since the last return, its calls, and its ending once it has one. */ +export interface RunSnapshot { + output: string; + calls: CallSummary; + ending: Ending | null; +} + +/** How many script children a host runs at once, counted until each child's process has exited. */ +export class ChildCapacity { + private live = 0; + constructor(readonly max: number) {} + get running(): number { + return this.live; + } + /** Take a place, or false when the host is full. */ + take(): boolean { + if (this.live >= this.max) return false; + this.live += 1; + return true; + } + release(): void { + this.live = Math.max(0, this.live - 1); + } +} + +/** + * The child's entry, beside this module and with its extension: `.ts` where + * the host runs this package's source, as a Bun host does through the + * package's `bun` export, and `.js` where it runs the build. So the runtime + * is given a file that exists: given a `.js` path that it maps to the `.ts` + * beside it, Bun runs it as `bun run` would, with npm_* variables and a PATH + * of its own in the child's environment. + */ +export const CHILD_ENTRY = fileURLToPath(new URL(`./child-entry${extname(fileURLToPath(import.meta.url))}`, import.meta.url)); + +export interface ScriptRunOptions { + /** The child's entry: a script the runtime runs as the child process (CHILD_ENTRY, but for tests). */ + childEntry: string; + /** Arguments after the entry, if any. */ + childArgs?: readonly string[]; + /** The runtime to run it with: by default the parent's own. */ + execPath?: string; + memoryMaxBytes: number; + maxStackBytes?: number; + output: OutputBounds; + /** How often, at most, the child flushes output while its guest computes. */ + flushMs: number; + /** The script's limit: at this age it's stopped, as "stopped at its limit". */ + lifetimeMs: number; + /** How long a call may go unanswered before the script is told it timed out. */ + toolCallTimeoutMs: number; + /** How long a stopped child has to report its ending before it's ended. */ + stopGraceMs: number; + /** The least time between two of the script's wakes: an early one waits for its turn. */ + wakeMinIntervalMs: number; + /** The most wakes one script may send; past it, a wake is refused. */ + maxWakes: number; + /** The bridge: dispatch one tool call. Never rejects; a refusal is `ok: false`. */ + onToolCall(name: string, argsJson: string): Promise; + /** Send `wake_agent(payload)`, the script's `n`th wake, once its turn has come: answers the guest's promise. */ + onWake(payloadJson: string, n: number): Promise; + /** Called once, when the run has an ending. */ + onEnd?(ending: Ending): void; +} + +/** The most of a child's stderr kept, for a crash's detail. */ +const STDERR_KEEP_CHARS = 4_000; +/** + * The most values a frame from the child may hold. Its largest, a failure's + * ending, holds eight, since arguments, output and values travel as strings. + */ +const MAX_CHILD_FRAME_VALUES = 32; +/** How much of a refusal too long for the script's memory reaches it. */ +const REFUSAL_GLIMPSE_CHARS = 2000; + +/** Remove a run's directory. A failure leaves only an empty directory behind, which mustn't stop the run's ending. */ +function removeDir(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // Nothing else to do: the directory is left. + } +} + +export { MAX_CALLS_IN_FLIGHT }; + +/** + * The largest result the parent sends a script: a third of its linear memory, + * since parsing one takes about three times its size there (a heap run under + * Bun 1.4.3 and Node 24.19.0). A larger one would fail in the guest anyway, + * after growing the child past its budget, so it's refused before it's sent, + * and the script's ResultTooLarge says the call took effect. + */ +export function maxResultBytes(memoryMaxBytes: number): number { + return Math.max(0, Math.floor(memoryMaxBytes / 3)); +} + +/** A refusal's own text, from its JSON, for a refusal that came with no brief. */ +function refusalText(json: string): string { + try { + const message = (JSON.parse(json) as { message?: unknown }).message; + if (typeof message === 'string') return message; + } catch { + // Not a refusal's JSON: its text is what there is. + } + return json; +} + +export class ScriptRun { + private child: ChildProcess | null = null; + private dir: string | null = null; + private readonly output: WindowOutput; + private readonly log = new CallLog(); + /** The ids of the guest's calls the child named, reserved until it says the script has their answer. */ + private readonly pendingIds = new Set(); + /** The calls answered and not yet seen: only these may the child say the script has. */ + private readonly answeredIds = new Set(); + /** The script's tools, the only names its calls may have. */ + private toolNames = new Set(); + /** Results sent to the guest and not yet seen: each is counted once the child says the script has it, and whether it fit. */ + private readonly delivered = new Map(); + private ending: Ending | null = null; + private readonly waiters = new Set<() => void>(); + private stderr = ''; + private lifetimeTimer: ReturnType | null = null; + private killTimer: ReturnType | null = null; + private processExited = false; + private stopAsked: { reason: StopReason; why?: string } | null = null; + /** Each unanswered call's timeout, cleared when it's answered or the run ends. */ + private readonly callTimers = new Set>(); + /** The calls holding a place among those in flight, by id, until the child says the script has their answer. */ + private readonly inFlight = new Set(); + /** + * How many calls the bridge holds, until it answers each: a call that timed + * out in the script, and so left its place there, may still hold its + * arguments in the host. Counted, not keyed by id, since the child may name + * a timed-out call's id again once the script has seen its TimeoutError. + * At most MAX_CALLS_IN_FLIGHT; a call past them waits in `dispatchWaiting`, + * as its text, until one is answered. One the bridge answered for (`held`) + * keeps its place until the run ends. + */ + private hostCalls = 0; + /** Calls waiting for the bridge to hold fewer: each resolved with true when its turn comes, false when it won't be sent. */ + private readonly dispatchWaiting = new Map void>(); + /** Wakes the child has sent, which the cap counts: those refused once a stop was asked included, so a child that ignores the stop is still bounded. */ + private wakeFrames = 0; + /** Wakes handed to the host to deliver, whatever it answered: a wake's number, which the host is given. */ + private wakes = 0; + /** Wakes the host delivered, its answer neither a refusal nor a failure. */ + private delivered_wakes = 0; + /** Wakes waiting for their turn, which the run's end counts as never sent. */ + private wakesWaiting = 0; + /** When the next wake may go out, on the monotonic clock: the spacing counts from when the last wake's delivery ended. */ + private nextWakeAt = 0; + /** Settles once the latest wake has gone out, or won't: wakes go out in the order they were made. */ + private wakeChain: Promise = Promise.resolve(); + /** Wakes waiting out the spacing, each released at its time or when the run ends. */ + private readonly wakeWaiters = new Set<() => void>(); + /** + * Results waiting for the child's stdin to drain, as their frames: each holds + * its JSON's bytes, which the result bound counts. A result's id becomes one + * the child may say it has seen once its frame is written. + */ + private readonly resultQueue: Array<{ id: number; frame: Buffer }> = []; + private draining = false; + private exitResolve!: () => void; + private endedResolve!: () => void; + /** Resolves when the run ends: a wake waiting behind another's delivery stops waiting then. */ + private readonly endedSignal: Promise = new Promise((resolve) => (this.endedResolve = resolve)); + /** Resolves when the child's process has exited, or at once if it never started. */ + readonly exited: Promise = new Promise((resolve) => (this.exitResolve = resolve)); + + constructor(private readonly options: ScriptRunOptions) { + this.output = new WindowOutput(options.output); + } + + /** + * Spawn the child and send it the script. A start frame too long for a frame + * throws before anything is spawned, with `exited` resolved, so nothing is + * left running and the place it took frees at once. A child that can't be + * spawned crashes the run. + */ + start(script: string, tools: readonly PreludeTool[]): void { + let dir: string | null = null; + let startFrame: Buffer; + try { + this.toolNames = new Set(tools.map((t) => t.name)); + // Absolute, since the child starts in it: a relative TMPDIR would name + // the stop file from two different places. + dir = resolve(mkdtempSync(join(tmpdir(), 'af-script-'))); + startFrame = encodeFrame({ + t: 'start', + script, + tools: tools.map((t) => ({ name: t.name, description: t.description, ...(t.inputSchema === undefined ? {} : { inputSchema: t.inputSchema }) })), + memoryMaxBytes: this.options.memoryMaxBytes, + ...(this.options.maxStackBytes === undefined ? {} : { maxStackBytes: this.options.maxStackBytes }), + stopFile: join(dir, 'stop'), + parentPid: process.pid, + flushMs: this.options.flushMs, + maxWakes: this.options.maxWakes, + }); + } catch (err) { + if (dir) removeDir(dir); + this.exitResolve(); + throw err; + } + this.dir = dir; + const runDir = dir; + // A process that never started never exits: the run crashes, and its place + // is free now. It counts as exited, so the ending arms no kill and holds no child. + const neverStarted = (why: unknown): void => { + this.processExited = true; + this.crash(`its process could not run: ${why instanceof Error ? why.message : String(why)}`); + removeDir(runDir); + this.exitResolve(); + }; + let child: ChildProcess; + try { + child = spawn(this.options.execPath ?? process.execPath, [this.options.childEntry, ...(this.options.childArgs ?? [])], { + // The parent's frames go in on stdin, and the child's come back on fd 3 (child-main.ts says why). + stdio: ['pipe', 'ignore', 'pipe', 'pipe'], + // The child is ours and reads nothing from its environment; the guest has none at all. + env: {}, + // Its own directory, which holds nothing but the stop file: Bun reads a + // .env file, and its bunfig.toml, from the directory it starts in, so + // in the host's it would load the host's .env into the child. + cwd: runDir, + }); + } catch (err) { + // Node throws here for some arguments it refuses at once. + neverStarted(err); + return; + } + // Node emits a spawn's 'error' on a later tick, which this takes. Bun's can + // come while spawn runs, before this is attached: spawn then throws it into + // the catch above, or returns with its pipes null, which the check below + // takes. + child.on('error', (err) => { + if (child.pid === undefined) neverStarted(err); + else this.crash(`its process could not run: ${err.message}`); + }); + this.child = child; + const toChild = child.stdin as Writable | null; + const fromChild = child.stdio[3] as Readable | null; + if (!toChild || !fromChild) { + // Bun can return with no pipes and emit the spawn's 'error', which names + // the cause, just after: a turn of the loop lets it come first. + setImmediate(() => neverStarted(new Error('its pipes were never opened'))); + return; + } + // A child's frames are small flat objects, its arguments and output + // carried as strings, so one holding more values isn't following the + // protocol, and is refused before it's parsed. + const decoder = new FrameDecoder(MAX_CHILD_FRAME_BYTES, false, MAX_CHILD_FRAME_VALUES); + fromChild.on('data', (chunk: Buffer) => { + let values: unknown[]; + try { + values = decoder.push(chunk); + } catch (err) { + this.crash(`its process sent the host a message it couldn't read (${(err as FrameError).message})`); + return; + } + for (const value of values) { + const message = decodeChildMessage(value); + if ('invalid' in message) { + this.crash(`its process sent the host a message it couldn't read (${message.invalid})`); + return; + } + this.handle(message); + } + }); + // Whether the child said how the script ended is known once its process has + // exited and fd 3 has ended, so every frame it wrote has been read: Node can + // emit 'exit' before it has read a child's last frames (1 in 2,700 runs of a + // probe under Node 20.20.2), and Bun 1.4.3 emits 'close' before an extra + // pipe like fd 3 has ended. A crash's detail waits for stderr's end too, for + // the same reason. + let exit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + let channelEnded = false; + let stderrEnded = child.stderr === null; + const decide = (): void => { + if (!exit || !channelEnded || !stderrEnded || this.ending !== null) return; + const how = exit.signal ? `was ended by ${exit.signal}` : `exited with code ${exit.code}`; + if (this.stopAsked) this.end({ how: 'stopped', reason: this.stopAsked.reason, short: true, ...(this.stopAsked.why ? { why: this.stopAsked.why } : {}) }); + else this.crash(`its process ${how} before the script ended${this.stderr ? `: ${this.stderr.trim()}` : ''}`); + }; + const channelEnd = (): void => { + if (channelEnded) return; + channelEnded = true; + // A frame cut off at the channel's end can't be read: the child broke off + // mid-message. Once a stop was asked that's the stop's doing, since the + // grace's kill can cut a frame, and the run ends as stopped. + try { + decoder.end(); + } catch (err) { + if (!this.stopAsked) this.crash(`its process sent the host a message it couldn't read (${(err as FrameError).message})`); + } + decide(); + }; + fromChild.on('end', channelEnd); + fromChild.on('close', channelEnd); + fromChild.on('error', () => {}); + toChild.on('error', () => {}); + const stderrEnd = (): void => { + stderrEnded = true; + decide(); + }; + child.stderr?.on('data', (chunk: Buffer) => { + this.stderr = (this.stderr + chunk.toString('utf8')).slice(-STDERR_KEEP_CHARS); + }); + child.stderr?.on('end', stderrEnd); + child.stderr?.on('close', stderrEnd); + child.stderr?.on('error', () => {}); + // The host's place for the child frees when its process exits. fd 3 and + // stderr have the grace to end after it, which only a process the child + // left holding them could stretch. + child.on('exit', (code, signal) => { + exit = { code, signal }; + this.processExited = true; + if (this.killTimer) clearTimeout(this.killTimer); + removeDir(runDir); + this.exitResolve(); + if (!channelEnded || !stderrEnded) { + setTimeout(() => { + // After the next poll, so what the child wrote before it exited is + // read first, even when this loop was too busy to read it in the grace. + setImmediate(() => { + fromChild.destroy(); + child.stderr?.destroy(); + channelEnd(); + stderrEnd(); + }); + }, this.options.stopGraceMs).unref(); + } + decide(); + if (this.ending !== null) this.release(); + }); + this.write(startFrame); + this.lifetimeTimer = setTimeout(() => this.stop('limit'), this.options.lifetimeMs); + } + + /** The run as it stands, its output since the last return included. */ + snapshot(): RunSnapshot { + // Calls waiting for room in the host, and wakes waiting for their turn, are the script's too. + return { output: this.output.render(), calls: this.log.summary(this.dispatchWaiting.size + this.wakesWaiting), ending: this.ending }; + } + + /** The output since the last return, as it stands. */ + outputWindow(): OutputWindow { + return this.output.window(); + } + + /** The output since the last return, starting a fresh stretch from here. */ + takeOutput(): OutputWindow { + return this.output.take(); + } + + get ended(): boolean { + return this.ending !== null; + } + + /** End the child's process now if it's still alive, whatever the run's state. Resolves once it has exited. */ + kill(): Promise { + if (!this.processExited) this.child?.kill('SIGKILL'); + return this.exited; + } + + /** The wakes the host delivered to the script's agent: those it answered ok and not as an error. A refused wake isn't one, nor a failed one, though a failure may have taken effect. */ + get wakesDelivered(): number { + return this.delivered_wakes; + } + + /** Resolves when the script has ended, or after `ms`, whichever comes first. */ + wait(ms: number): Promise { + if (this.ending) return Promise.resolve(this.snapshot()); + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + this.waiters.delete(done); + resolve(this.snapshot()); + }; + const timer = setTimeout(done, ms); + this.waiters.add(done); + }); + } + + /** + * Ask the script to stop: the stop file for a computing guest, a frame for an + * idle one. If the child hasn't said how it ended within the grace, it's + * ended, and the ending says its output so far may be short. The reason is + * the one asked here, whatever the child says. + */ + stop(reason: StopReason, why?: string): Promise { + if (this.ending || !this.child) return Promise.resolve(this.snapshot()); + // A process that has exited has no stop to take: its run ends as its last + // frames say, or as a crash, which a stop asked now mustn't take for its own. + if (!this.stopAsked && !this.processExited) { + this.stopAsked = { reason, ...(why ? { why } : {}) }; + try { + if (this.dir) writeFileSync(join(this.dir, 'stop'), ''); + } catch { + // The frame below still reaches an idle child, and the grace ends a computing one. + } + this.send({ t: 'stop' }); + this.killTimer = setTimeout(() => { + this.child?.kill('SIGKILL'); + }, this.options.stopGraceMs); + this.killTimer.unref?.(); + } + return new Promise((resolve) => { + const done = () => { + this.waiters.delete(done); + resolve(this.snapshot()); + }; + if (this.ending) done(); + else this.waiters.add(done); + }); + } + + private send(message: unknown): void { + this.write(encodeFrame(message)); + } + + /** A call's result, as a result frame once its turn comes: the JSON's bytes go to the engine as they are. */ + private sendResult(id: number, ok: boolean, json: string): void { + this.resultQueue.push({ id, frame: encodeResultFrame(id, ok, json, maxResultBytes(this.options.memoryMaxBytes) + RESULT_FRAME_OVERHEAD) }); + this.pump(); + } + + /** Write waiting results, one frame at a time: after a write the pipe didn't take whole, the next waits for 'drain'. */ + private pump(): void { + const pipe = this.child?.stdin ?? undefined; + if (!pipe || pipe.destroyed) { + this.resultQueue.length = 0; + return; + } + while (!this.draining && this.resultQueue.length > 0) { + const { id, frame } = this.resultQueue.shift()!; + this.answeredIds.add(id); + if (!pipe.write(frame)) { + this.draining = true; + pipe.once('drain', () => { + this.draining = false; + this.pump(); + }); + } + } + } + + private write(frame: Buffer): void { + const pipe = this.child?.stdin ?? undefined; + if (!pipe || pipe.destroyed) return; + pipe.write(frame); + } + + + private handle(message: ChildMessage): void { + if (this.ending && message.t !== 'out') return; + switch (message.t) { + case 'out': + // The child flushes output in pieces of at most a batch. + if (message.text.length > MAX_BATCH_CHARS) { + this.crash(`its process sent the host ${message.text.length.toLocaleString('en-US')} characters of output in one frame, past the ${MAX_BATCH_CHARS.toLocaleString('en-US')} it sends at most`); + return; + } + this.output.apply(message.text); + return; + case 'call': + void this.call(message.id, message.name, message.args); + return; + case 'seen': { + // The script has the answer the parent sent, or got ResultTooLarge in + // its place: the call's id and its place are free. A seen for a call + // whose answer the parent hasn't written isn't following the protocol. + if (!this.answeredIds.delete(message.id)) { + this.crash(`its process said the script had the answer to call ${message.id}, which the host never sent`); + return; + } + this.pendingIds.delete(message.id); + this.inFlight.delete(message.id); + const sent = this.delivered.get(message.id); + if (!sent) return; + this.delivered.delete(message.id); + // A refusal stays one though the script couldn't receive it: nothing was done either way. + this.log.answered(sent.entry, message.unfit && sent.outcome !== 'refused' ? 'too-large' : sent.outcome, sent.brief); + return; + } + case 'end': { + const { t: _t, ...said } = message; + // The child cuts the value and a failure's parts as a return shows them. + // The parent bounds them too, since an ended run is kept for a later + // wait, with room for the child's cut and its marker, so an honest + // child's cut, and the count it gives, pass as they are. + if (said.how === 'finished') { + this.end(said.value === undefined ? { how: 'finished' } : { how: 'finished', value: bounded(said.value, VALUE_CHARS + CUT_MARK_CHARS) }); + return; + } + if (said.how === 'failed') { + const error = { + name: bounded(said.error.name, NAME_CHARS + CUT_MARK_CHARS), + message: bounded(said.error.message, VALUE_CHARS + CUT_MARK_CHARS), + ...(said.error.stack === undefined ? {} : { stack: bounded(said.error.stack, VALUE_CHARS + CUT_MARK_CHARS) }), + }; + this.end({ how: 'failed', error, ...(said.stalled ? { stalled: true } : {}) }); + return; + } + if (!this.stopAsked) { + this.crash("its process sent the host a message it couldn't read (the script said it was stopped, and no stop was asked)"); + return; + } + this.end({ how: 'stopped', reason: this.stopAsked.reason, ...(this.stopAsked.why ? { why: this.stopAsked.why } : {}) }); + return; + } + } + } + + private async call(id: number, name: string, args: string): Promise { + const wake = name === WAKE_CALL_NAME; + // The prelude offers only the script's tools, so a call to another name + // isn't following it. The name is the child's, bounded by the protocol. + if (!wake && !this.toolNames.has(name)) { + this.crash(`its process sent the host a call to ${JSON.stringify(name)}, which isn't one of the script's tools`); + return; + } + if (this.pendingIds.has(id)) { + this.crash(`its process sent the host a message it couldn't read (call ${id} was named twice)`); + return; + } + this.pendingIds.add(id); + const tool = wake ? 'wake_agent' : name; + // Refused here, before anything is sent: the record counts it, and nothing was done. + const refuse = (refusal: { name: string; message: string }): void => { + this.delivered.set(id, { entry: this.log.start(tool, args), outcome: 'refused', brief: callBrief(refusal.message) }); + this.answer(id, false, JSON.stringify(refusal)); + }; + let n = 0; + let wakeSent = (): void => {}; + if (wake) { + // The child refuses a wake past the cap itself, and counts every wake it + // sends, one refused here included, so a child that sends more isn't + // following it. Counted before a stop's refusal, so wakes sent after a + // stop are bounded too. + if (this.wakeFrames >= this.options.maxWakes) { + this.crash(`its process sent the host more than ${this.options.maxWakes} wakes, the most a script may send`); + return; + } + // The child refuses a payload past its cap before a frame is built. + const tooLarge = wakeRefusal(args); + if (tooLarge) { + this.crash(`its process sent the host a wake of ${tooLarge.bytes.toLocaleString('en-US')} bytes, past the ${capText(MAX_WAKE_PAYLOAD_BYTES)} the script refuses itself`); + return; + } + this.wakeFrames += 1; + const stopping = this.stopRefusal('WakeRefused', tool); + if (stopping) { + refuse(stopping); + return; + } + // An early wake waits for its turn rather than being dropped: once the + // one before it has gone out, and the spacing since that one's delivery + // has passed. The run's end stops both waits. + const previous = this.wakeChain; + this.wakeChain = new Promise((resolve) => (wakeSent = resolve)); + this.wakesWaiting += 1; + await Promise.race([previous, this.endedSignal]); + await this.wakeTurn(); + // A wake still waiting for its turn when the script ended, or once a + // stop was asked or its process exited, is never sent: the ending is the + // script's last word, and the endings that should wake its agent already + // do. The record counts it as never sent: the run's end counts those + // still waiting when it comes, and one dropped before it is counted here. + // (A call waiting in the script for a place is likewise never sent when a + // stop or a crash ends it; one whose script returns goes out, since the + // child waits for every call it made.) + if (this.ending) { + wakeSent(); + return; + } + this.wakesWaiting -= 1; + if (this.stopAsked || this.processExited) { + wakeSent(); + this.log.unsent(); + return; + } + this.wakes += 1; + n = this.wakes; + } else { + // The prelude sends at most MAX_CALLS_IN_FLIGHT at once and keeps the + // rest waiting in the script, so a child that sends more isn't following it. + if (this.inFlight.size >= MAX_CALLS_IN_FLIGHT) { + this.crash(`its process sent the host more than ${MAX_CALLS_IN_FLIGHT} calls at once, the most a script may have out`); + return; + } + this.inFlight.add(id); + // The child refuses arguments past their caps before a frame is built. + const tooLarge = argumentsRefusal(name, args); + if (tooLarge) { + this.crash( + `its process sent the host a call to ${name} whose arguments pass their caps of ${capText(MAX_ARGUMENT_BYTES)} and ` + + `${MAX_ARGUMENT_VALUES.toLocaleString('en-US')} values, which the script refuses itself`, + ); + return; + } + const refusal = this.stopRefusal('ToolRefused', tool); + if (refusal) { + refuse(refusal); + return; + } + } + // The guest has its answer: a result, or a TimeoutError in its place. + let settled = false; + const settle = (ok: boolean, json: string): void => { + settled = true; + this.answer(id, ok, json); + }; + let entry: CallEntry | null = null; + // A call's time runs from when the script made it, a wait for room in the host included. + const timer = wake ? null : setTimeout(() => { + if (timer) this.callTimers.delete(timer); + if (settled || this.ending) return; + const waiting = this.dispatchWaiting.get(id); + if (waiting) { + // It never went out: no place in the host came free for it in time. + this.dispatchWaiting.delete(id); + waiting(false); + this.log.unsent(); + settle(false, JSON.stringify({ + name: 'TimeoutError', + message: `${name} was never sent: ${MAX_CALLS_IN_FLIGHT} of this script's earlier calls were still running in the host, timed-out ones ` + + `included, and its turn didn't come within ${formatMs(this.options.toolCallTimeoutMs)}, so nothing was done; calling it again may go through once one of them gets its result, ` + + 'though a call whose tool the host stopped waiting for stays counted until this script ends, and a new script starts with none', + })); + return; + } + if (!entry) return; + this.log.timedOut(entry); + settle(false, JSON.stringify({ + name: 'TimeoutError', + message: `${name} was sent, and no answer came within ${formatMs(this.options.toolCallTimeoutMs)}; it may still take effect`, + })); + }, this.options.toolCallTimeoutMs); + if (timer) this.callTimers.add(timer); + const clearTimer = (): void => { + if (!timer) return; + clearTimeout(timer); + this.callTimers.delete(timer); + }; + if (!wake) { + // Past the calls the host holds, it waits as its text, and its place is + // taken for it when its turn comes (releasePlace). + if (this.hostCalls < MAX_CALLS_IN_FLIGHT) this.hostCalls += 1; + else if (!(await new Promise((resolve) => this.dispatchWaiting.set(id, resolve)))) return; + // Once the run has ended or its process has exited, nothing it asks goes + // out: a frame the child wrote before it exited can be read after the + // exit, and a call waiting for room can be given its place after it. The + // record counts such a call as never sent: the script made it, and + // nothing was done. A stop asked while it waited refuses it, as it would + // have refused it then. + if (this.ending || this.processExited) { + clearTimer(); + this.releasePlace(); + this.log.unsent(); + return; + } + const stopping = this.stopRefusal('ToolRefused', tool); + if (stopping) { + clearTimer(); + this.releasePlace(); + refuse(stopping); + return; + } + } + entry = this.log.start(tool, args); + let answer: ToolAnswer; + try { + answer = wake ? await this.options.onWake(args, n) : await this.options.onToolCall(name, args); + } catch (err) { + // The bridge never rejects. One that does is answered as a failure, + // which may have taken effect, never as an unhandled rejection. + const text = `${tool} failed in the host: ${err instanceof Error ? err.message : String(err)}; it may have taken effect`; + answer = { ok: true, isError: true, brief: text, json: JSON.stringify({ content: [{ type: 'text', text }], structuredContent: null, isError: true }) }; + } + if (wake) { + this.nextWakeAt = performance.now() + this.options.wakeMinIntervalMs; + if (answer.ok && !answer.isError) this.delivered_wakes += 1; + wakeSent(); + } else if (!answer.held) this.releasePlace(); + // No result came, and the bridge answered for it, after the script's own + // timeout (the bridge waits longer): the call stays as the record had it + // (timed out, or uncertain at an ending), and its place stays taken. Had + // the script's timeout not come yet, its timer would still give it one. + if (answer.held) return; + clearTimer(); + const bytes = Buffer.byteLength(answer.json, 'utf8'); + const limit = maxResultBytes(this.options.memoryMaxBytes); + const tooLarge = bytes > limit; + // A refusal stays one however long its text: nothing was done. + const outcome: CallOutcome = !answer.ok ? 'refused' : tooLarge ? 'too-large' : answer.isError ? 'failed' : 'answered'; + // Only its start is kept, as a copy: the record names a call by that much. + const brief = callBrief(answer.brief ?? answer.json); + if (settled || this.ending) { + // The script stopped waiting for it, or ended: the record takes the answer as it comes. + this.log.answered(entry, outcome, brief); + return; + } + this.delivered.set(id, { entry, outcome, brief }); + if (tooLarge) { + settle(false, JSON.stringify(outcome === 'refused' + ? { + name: wake ? 'WakeRefused' : 'ToolRefused', + message: `${glimpse(answer.brief ?? refusalText(answer.json), REFUSAL_GLIMPSE_CHARS)} (the refusal's text, ${bytes} bytes, is too large for this script's memory, so only its start is here; nothing was done)`, + } + : { + name: RESULT_TOO_LARGE, + message: `${name}'s result, ${bytes} bytes, is too large for this script's memory; the call took effect, and none of its result reached the script`, + bytes, + })); + return; + } + settle(answer.ok, answer.json); + } + + /** + * A call no longer holds a place in the host: the bridge answered it, or it + * won't be sent. The first call waiting for room takes the place, in the + * same step, so no later call can take it first. Whatever lets a waiting + * call go takes it off the queue. One given its place once the run has + * ended or its process has exited isn't sent (the call's own check), and + * the end lets the calls still waiting go. + */ + private releasePlace(): void { + this.hostCalls -= 1; + const next = this.dispatchWaiting.entries().next(); + if (next.done) return; + const [nextId, go] = next.value; + this.dispatchWaiting.delete(nextId); + this.hostCalls += 1; + go(true); + } + + /** An answer to the call the child named `id`, sent once its turn comes. The id stays reserved until the child says the script has it. */ + private answer(id: number, ok: boolean, json: string): void { + this.sendResult(id, ok, json); + } + + /** A call or wake that comes once a stop has been asked isn't sent: the script is ending. */ + private stopRefusal(name: 'ToolRefused' | 'WakeRefused', tool: string): { name: string; message: string } | null { + return this.stopAsked ? { name, message: `${tool}: the script is being stopped, so nothing was sent` } : null; + } + + /** Resolves once the spacing since the last wake has passed, or at once when the run has ended. */ + private wakeTurn(): Promise { + const wait = this.nextWakeAt - performance.now(); + if (wait <= 0 || this.ending) return Promise.resolve(); + return new Promise((resolve) => { + const release = (): void => { + clearTimeout(timer); + this.wakeWaiters.delete(release); + resolve(); + }; + const timer = setTimeout(release, wait); + this.wakeWaiters.add(release); + }); + } + + /** + * Once the process has exited and the run has ended, nothing more goes to + * the child: an ended run kept for a later wait holds none of it. Whichever + * of the two comes second calls this. + */ + private release(): void { + this.child?.stdin?.destroy(); + this.child = null; + } + + private crash(detail: string): void { + if (this.ending) return; + this.end({ how: 'crashed', detail }); + this.child?.kill('SIGKILL'); + } + + private end(ending: Ending): void { + if (this.ending) return; + // Results sent and not yet seen were answered, and the script ended + // before it read them: each is named, a refusal counted. Calls still + // running may or may not take effect. A stop or a crash can leave either, + // whatever the ending: a script that had returned or thrown when a stop + // came ends as it did, with the calls its stop cut the wait for. So can a + // failure that escaped the script's promises, which ends the run at once, + // and a result the engine dropped as a stop came, which gets no seen. A + // TimeoutError the engine dropped so leaves its call named timed-out, as + // the host's timer had it, with no answer to name. Otherwise the child + // reports a finish or a failure only once every call it made is settled + // and seen. The host's count of the calls the bridge holds stays as it + // is, since those tools may still run: each frees its place as it + // answers, and one the bridge answered for with no result (held) never + // does. + for (const sent of this.delivered.values()) this.log.unseen(sent.entry, sent.outcome); + this.delivered.clear(); + this.pendingIds.clear(); + this.answeredIds.clear(); + this.inFlight.clear(); + this.log.ended(); + this.ending = ending; + if (this.lifetimeTimer) clearTimeout(this.lifetimeTimer); + // A child exits on its own once it has said how its script ended. One that + // doesn't, such as one stuck in its cleanup, is ended after the stop's + // grace, so it can't hold its place or outlive its run. + if (this.child && !this.processExited && !this.killTimer) { + this.killTimer = setTimeout(() => this.child?.kill('SIGKILL'), this.options.stopGraceMs); + this.killTimer.unref?.(); + } + for (const timer of this.callTimers) clearTimeout(timer); + this.callTimers.clear(); + // Nothing more goes to the child: waiting results are dropped, and calls + // waiting for a place go nowhere, so an ended run that's kept for a later + // wait holds neither. + this.resultQueue.length = 0; + for (const release of [...this.wakeWaiters]) release(); + this.endedResolve(); + // Calls waiting for room in the host are never sent, and the record counts + // them so: the script made them, and nothing was done. + for (const [waitingId, go] of [...this.dispatchWaiting]) { + this.dispatchWaiting.delete(waitingId); + this.log.unsent(); + go(false); + } + // So are wakes waiting for their turn: each sees the ending and goes. + for (; this.wakesWaiting > 0; this.wakesWaiting -= 1) this.log.unsent(); + for (const waiter of [...this.waiters]) waiter(); + if (this.processExited) this.release(); + this.options.onEnd?.(ending); + } +} diff --git a/src/code-execution/isolate/scripts.ts b/src/code-execution/isolate/scripts.ts new file mode 100644 index 000000000..467743885 --- /dev/null +++ b/src/code-execution/isolate/scripts.ts @@ -0,0 +1,479 @@ +/** + * Every agent's scripts: one kind of script, which runs in its agent's turn + * and, past its bound, yields with its output so far and keeps running. The + * agent then waits on it, stops it, or leaves it. + * + * - A left script that finishes cleanly, or that its agent stopped, says + * nothing; its result waits for a `wait` or a `list`, kept for the agent's + * newest ended scripts. One that fails, crashes or is stopped at its limit + * wakes its agent with its ending. + * - Only the primary agent's scripts outlive a turn (any agent's, when no + * primary agent is named). Another agent's yield says so, and at its turn's + * end its running scripts are stopped, as "stopped at its limit". That stop + * wakes no one, since the agent was told at the yield. + * - A turn that ends abnormally (a stream cancelled, an error, a quiesce) + * while a call still watches its script stops the script quietly if it + * hasn't returned yet; a wait it abandons leaves its script running, so a + * later failure still wakes the agent. A return made just before such an + * end counts as made, though its result is lost with the turn's. + * - An agent has at most `maxRunning` scripts running, and the host at most + * its capacity of children. + * - `wake_agent()` keeps today's spacing and cap: an early wake waits for its + * turn rather than being dropped, and past the cap it's refused. A wake + * still waiting for its turn when its script ends is never sent. + * + * The framework supplies what this can't know: the agent's tools now, the + * dispatcher a script's calls go through, and how to wake an agent. + */ + +import { ChildCapacity, ScriptRun, type CallAnswer, type Ending, type ScriptRunOptions, type ToolAnswer } from './runner.js'; +import type { CallSummary } from './calls.js'; +import type { OutputBounds, OutputWindow } from './output.js'; +import type { PreludeTool } from './prelude.js'; +import { glimpse } from './render.js'; + +/** The run or wait call watching a script: its turn, which the script's calls carry, and whether one of them ended that turn. */ +export interface ScriptWatch { + turnId?: number; + endTurn: boolean; +} + +/** What the framework keeps with a script, handed back with each of its calls. */ +export interface ScriptCallContext { + /** RFC-007: what the script's calls are attributed to, its run call's origin. */ + origin?: unknown; + /** The call watching the script when this call reached the host (`host.dispatch`), if one was: the call carries its turn. One the script made while watched that waited, with 16 of its calls already out, takes the watch as it stood when it went out, not when the script made it. */ + watch?: ScriptWatch; + /** The call watching the script now, if one is: a result that ends the turn ends it at that call's return. Without it, `watch`. */ + watching?(): ScriptWatch | undefined; +} + +export interface ScriptsHost { + /** The tools a script of this agent may call now, without code_execution itself. */ + tools(agentName: string): PreludeTool[]; + /** The bridge: dispatch one of the script's calls as its agent's, through the ordinary dispatcher. Never rejects. */ + dispatch(agentName: string, toolName: string, argsJson: string, context: ScriptCallContext): Promise; + /** Wake the agent with this text: a script's wake_agent() payload, or a left script's ending. `label` names this wake, for a spilled copy. */ + wake(agentName: string, scriptId: string, text: string, label: string): Promise; + /** Whether this agent's scripts may outlive its turn. */ + outlivesTurn(agentName: string): boolean; +} + +export interface ScriptsConfig { + childEntry: string; + execPath?: string; + memoryMaxBytes: number; + maxStackBytes?: number; + output: OutputBounds; + flushMs: number; + /** The bound a run or wait yields at by default, and the most an agent may ask. */ + yieldMs: number; + maxYieldMs: number; + /** A script's limit. */ + lifetimeMs: number; + toolCallTimeoutMs: number; + stopGraceMs: number; + /** Scripts one agent may have running at once, yielded ones included. */ + maxRunning: number; + /** Ended scripts kept per agent for a later wait or list. */ + keepEnded: number; + wakeMinIntervalMs: number; + maxWakesPerScript: number; +} + +/** What one run, wait or stop returns. */ +export interface ScriptReturn { + scriptId: string; + /** Still running past the bound, or ended. */ + running: boolean; + /** The output since this script's previous return. */ + output: OutputWindow; + /** True for the script's first return. */ + first: boolean; + calls: CallSummary; + ending: Ending | null; + /** True when this agent's script will be stopped at the end of its turn unless it waits for it. */ + stopsAtTurnEnd: boolean; +} + +/** One script in a listing. */ +export interface ScriptListing { + scriptId: string; + running: boolean; + ending: Ending | null; + startedAt: number; + endedAt: number | null; + /** The wakes the host delivered for it. */ + wakes: number; +} + +interface Script { + id: string; + agentName: string; + /** The code it runs. */ + code: string; + run: ScriptRun; + context: { origin?: unknown }; + /** The turn its run call was made in, when one was: its agent's turns end it by that. */ + turnId: number | undefined; + startedAt: number; + endedAt: number | null; + /** The order its ending came in among this host's: the newest ended are those kept. */ + endedSeq: number; + /** The delivery of a wake it sent that may not have reached its agent yet: its ending's notice follows it. */ + delivering: Promise | null; + /** The call watching it now, if one is: its ending goes there, and its calls carry its turn. */ + watch: ScriptWatch | null; + /** Its ending wakes no one: it was stopped where its agent was told it would be, or never saw it. */ + quietEnd: boolean; + /** Its agent has gone: nothing can wait on it or list it, so it leaves the map once it has ended. */ + gone: boolean; + /** Returns made so far. */ + returns: number; + /** A call watching it has returned its ending, so a notice of the ending would only repeat it. */ + endingReturned: boolean; +} + +/** Why a run, wait or stop couldn't be done: nothing was run or changed. */ +export class ScriptRefusal extends Error { + constructor(message: string) { + super(message); + this.name = 'ScriptRefusal'; + } +} + +/** The endings that wake a left script's agent: it didn't do its work, and not by its agent's choice. */ +export function endingWakes(ending: Ending): boolean { + return ending.how === 'failed' || ending.how === 'crashed' || (ending.how === 'stopped' && ending.reason === 'limit'); +} + +export class AgentScripts { + private readonly scripts = new Map(); + /** + * Marks this host's script ids, so an id from before a restart, still in an + * agent's history, almost surely names no script of this one: four base-36 + * characters, so about one restart in 1.7 million draws the tag of the host + * before it, and an old id could then name a new script of the same agent. + */ + private readonly idTag = Math.random().toString(36).slice(2, 6).padEnd(4, '0'); + /** Every run whose child process hasn't exited, kept or not: dispose() ends any still alive. */ + private readonly live = new Set(); + private counter = 0; + private endedCounter = 0; + + constructor( + private readonly host: ScriptsHost, + private readonly config: ScriptsConfig, + private readonly capacity: ChildCapacity, + ) {} + + /** The bound for this call: the agent's ask, capped, or the default. */ + bound(askedMs: number | undefined): number { + return askedMs === undefined ? this.config.yieldMs : Math.min(askedMs, this.config.maxYieldMs); + } + + async run( + agentName: string, + code: string, + askedMs?: number, + options: { origin?: unknown; watch?: ScriptWatch } = {}, + ): Promise { + // Listed first, so a module whose tool list throws refuses the run with nothing taken. + let tools: readonly PreludeTool[]; + try { + tools = this.host.tools(agentName); + } catch (err) { + throw new ScriptRefusal(`The script's tools couldn't be listed: ${err instanceof Error ? err.message : String(err)}. Nothing was run.`); + } + const running = this.running(agentName); + if (running >= this.config.maxRunning) { + throw new ScriptRefusal( + `You have ${running} scripts running, your most. Wait for one, stop one, or let one end before you start another. Nothing was run.`, + ); + } + if (!this.capacity.take()) { + throw new ScriptRefusal( + `This host is running ${this.capacity.running} scripts, its most. Try again once one has ended. Nothing was run.`, + ); + } + const id = `s${++this.counter}-${this.idTag}`; + const script: Script = { + id, + agentName, + code, + run: null as unknown as ScriptRun, + context: options.origin === undefined ? {} : { origin: options.origin }, + turnId: options.watch?.turnId, + startedAt: Date.now(), + endedAt: null, + endedSeq: 0, + delivering: null, + watch: options.watch ?? { endTurn: false }, + quietEnd: false, + gone: false, + returns: 0, + endingReturned: false, + }; + const runOptions: ScriptRunOptions = { + childEntry: this.config.childEntry, + ...(this.config.execPath ? { execPath: this.config.execPath } : {}), + memoryMaxBytes: this.config.memoryMaxBytes, + ...(this.config.maxStackBytes === undefined ? {} : { maxStackBytes: this.config.maxStackBytes }), + output: this.config.output, + flushMs: this.config.flushMs, + lifetimeMs: this.config.lifetimeMs, + toolCallTimeoutMs: this.config.toolCallTimeoutMs, + stopGraceMs: this.config.stopGraceMs, + wakeMinIntervalMs: this.config.wakeMinIntervalMs, + maxWakes: this.config.maxWakesPerScript, + onToolCall: (name, args) => + this.host.dispatch(agentName, name, args, { + ...script.context, + ...(script.watch ? { watch: script.watch } : {}), + watching: () => script.watch ?? undefined, + }), + onWake: (payload, n) => this.wake(script, payload, n), + onEnd: (ending) => this.ended(script, ending), + }; + script.run = new ScriptRun(runOptions); + const run = script.run; + this.live.add(run); + void run.exited.then(() => { + this.live.delete(run); + this.capacity.release(); + }); + try { + script.run.start(code, tools); + } catch (err) { + // Nothing was spawned, and `exited` has resolved, which frees the place. + throw new ScriptRefusal(`The script's process could not start: ${err instanceof Error ? err.message : String(err)}. Nothing was run.`); + } + this.scripts.set(id, script); + // A script that ended at its start, before it was in the map, trimmed + // without itself: the trim is taken again with it there. + this.trimEnded(agentName); + return this.observe(script, this.bound(askedMs), script.watch!); + } + + async wait(agentName: string, scriptId: string, askedMs?: number, watch: ScriptWatch = { endTurn: false }): Promise { + const script = this.unwatched(this.own(agentName, scriptId)); + script.watch = watch; + return this.observe(script, this.bound(askedMs), watch); + } + + async stop(agentName: string, scriptId: string, watch: ScriptWatch = { endTurn: false }): Promise { + const script = this.unwatched(this.own(agentName, scriptId)); + script.watch = watch; + // A run that has ended returns its ending to this stop: marked before the + // stop yields, as observe marks a wait's, so a notice whose turn comes + // first doesn't repeat it. + if (script.run.ended) script.endingReturned = true; + await script.run.stop('agent'); + return this.observe(script, 0, watch); + } + + /** The code of this agent's script by that id, as a wait or a stop would find the script; undefined when the agent has none by it. */ + codeOf(agentName: string, scriptId: string): string | undefined { + const script = this.scripts.get(scriptId); + return script && script.agentName === agentName && !script.gone ? script.code : undefined; + } + + list(agentName: string): ScriptListing[] { + return [...this.scripts.values()] + .filter((s) => s.agentName === agentName && !s.gone) + .map((s) => ({ + scriptId: s.id, + running: !s.run.ended, + ending: s.run.snapshot().ending, + startedAt: s.startedAt, + endedAt: s.endedAt, + wakes: s.run.wakesDelivered, + })); + } + + /** + * An agent's turn ended: `turn.id`, with `turn.live` the agent's turns still + * live (its open turn, and the turn of the stream that holds the agent), or + * `'every'` when all its turns end at once (the host stopping, or a quiesce + * that names no turn of its own). + * Turns can overlap, a silent one beside an open one, so each script goes by + * its own turn, the one its run call was made in. A turn that ended without + * its calls' answers (`abandoned`) leaves its calls watching scripts with no + * one to answer, whatever the agent: a script that hasn't returned yet is + * stopped, and one that has is let go. An agent whose scripts don't outlive + * a turn has stopped every script whose turn isn't live, and every one + * started in no turn: a script lives while the turn that started it does. + */ + turnEnded(agentName: string, turn: { id: number; live: readonly number[] } | 'every', abandoned: boolean, reason?: string): void { + const outlives = this.host.outlivesTurn(agentName); + for (const script of this.scripts.values()) { + if (script.agentName !== agentName || script.gone || script.run.ended) continue; + if (abandoned && script.watch && (turn === 'every' || script.watch.turnId === turn.id)) { + if (script.returns === 0) { + this.stopQuietly(script, `its turn ended before the script returned${reason ? ` (${reason})` : ''}`); + continue; + } + script.watch = null; + } + if (outlives) continue; + if (turn !== 'every' && script.turnId !== undefined && turn.live.includes(script.turnId)) continue; + this.stopQuietly(script, "its agent's turn ended, and only the primary agent's scripts outlive a turn"); + } + } + + /** + * An agent that has gone, such as an ephemeral one whose run ended: its + * scripts are stopped, and each leaves the map once it has ended, since no + * later run of the agent will trim them. + */ + agentGone(agentName: string): void { + for (const script of [...this.scripts.values()]) { + if (script.agentName !== agentName || script.gone) continue; + if (script.run.ended) { + this.scripts.delete(script.id); + continue; + } + script.gone = true; + this.stopQuietly(script, 'its agent ended'); + } + } + + /** Stop every running script, as the framework stops, and end any child process still alive after its script's ending. */ + async dispose(): Promise { + const running = [...this.scripts.values()].filter((s) => !s.run.ended); + for (const script of running) script.quietEnd = true; + await Promise.all(running.map((s) => s.run.stop('limit', 'the host stopped'))); + await Promise.all([...this.live].map((run) => run.kill())); + } + + /** Running scripts of this agent, yielded ones included; not those of an earlier agent of its name that has gone, which the host's count still holds. */ + running(agentName: string): number { + return [...this.scripts.values()].filter((s) => s.agentName === agentName && !s.gone && !s.run.ended).length; + } + + /** Running scripts that no call is watching: those acting outside any turn. */ + leftRunning(): number { + return [...this.scripts.values()].filter((s) => !s.run.ended && s.watch === null).length; + } + + private stopQuietly(script: Script, why: string): void { + script.quietEnd = true; + script.watch = null; + void script.run.stop('limit', why); + } + + private own(agentName: string, scriptId: string): Script { + const script = this.scripts.get(scriptId); + if (!script || script.agentName !== agentName || script.gone) { + throw new ScriptRefusal( + `You have no script ${JSON.stringify(scriptId)}: it isn't one of yours, it ran before this host restarted, ` + + `or it ended and was dropped: only your ${this.config.keepEnded} newest ended scripts are kept, and none of an earlier agent of your name that has gone. Nothing was changed.`, + ); + } + return script; + } + + /** A script no other call is watching now: one watching it gets its output and its ending. */ + private unwatched(script: Script): Script { + if (script.watch !== null) { + throw new ScriptRefusal(`Another of your calls is waiting on ${script.id} now, and its result will show how the script stands. Nothing was changed.`); + } + return script; + } + + private async observe(script: Script, ms: number, watch: ScriptWatch): Promise { + // A run that has ended returns its ending at once: marked now, so a notice + // whose turn comes before this return does doesn't repeat it. One that ends + // while this call watches it gives no notice at all (ended), so this and a + // stop's own, made before its stop yields, are the only marks a returned + // ending needs. + if (script.run.ended && script.watch === watch) script.endingReturned = true; + const snap = await script.run.wait(ms); + // A call whose turn was abandoned no longer watches the script: its + // return reaches no one, so it takes none of the output from the next. + const current = script.watch === watch; + if (current) script.watch = null; + if (current) script.returns += 1; + return { + scriptId: script.id, + running: snap.ending === null, + output: current ? script.run.takeOutput() : script.run.outputWindow(), + first: script.returns <= 1, + calls: snap.calls, + ending: snap.ending, + stopsAtTurnEnd: snap.ending === null && !this.host.outlivesTurn(script.agentName), + }; + } + + private ended(script: Script, ending: Ending): void { + script.endedAt = Date.now(); + script.endedSeq = ++this.endedCounter; + if (script.gone) this.scripts.delete(script.id); + else this.trimEnded(script.agentName); + // A watched script's ending goes to the call watching it. A left one says + // nothing when it finished or its agent stopped it, and wakes its agent + // otherwise, after any wake it sent that is still being delivered: the + // ending is the script's last word. + if (script.watch || script.quietEnd || !endingWakes(ending)) return; + const send = (): void => { + // A wait or stop that took the ending while the notice waited has already given it. + if (script.endingReturned) return; + void this.host.wake(script.agentName, script.id, leftEndingText(script.id, ending), `${script.id}-ended`).catch(() => {}); + }; + if (script.delivering) void script.delivering.then(send); + else send(); + } + + /** Send the script's `n`th wake, whose turn the run has waited for. */ + private async wake(script: Script, payloadJson: string, n: number): Promise { + let delivery: Promise; + try { + delivery = Promise.resolve(this.host.wake( + script.agentName, + script.id, + wakeText(script.id, payloadJson, n, this.config.maxWakesPerScript, Date.now() - script.startedAt), + `${script.id}-wake${n}`, + )); + } catch (err) { + delivery = Promise.reject(err); + } + const done = delivery.then(() => {}, () => {}); + script.delivering = done; + void done.then(() => { + if (script.delivering === done) script.delivering = null; + }); + try { + await delivery; + return { ok: true, json: 'null' }; + } catch (err) { + return { + ok: false, + json: JSON.stringify({ name: 'WakeRefused', message: `the wake couldn't be delivered: ${err instanceof Error ? err.message : String(err)}` }), + }; + } + } + + /** Keep only the agent's newest ended scripts, newest by the order their endings came in. */ + private trimEnded(agentName: string): void { + const ended = [...this.scripts.values()] + .filter((s) => s.agentName === agentName && s.run.ended) + .sort((a, b) => a.endedSeq - b.endedSeq); + for (const s of ended.slice(0, Math.max(0, ended.length - this.config.keepEnded))) this.scripts.delete(s.id); + } +} + +function leftEndingText(scriptId: string, ending: Ending): string { + const how = ending.how === 'failed' + ? `failed: ${ending.error.name ? `${ending.error.name}: ` : ''}${glimpse(ending.error.message, 300)}` + : ending.how === 'crashed' + ? `crashed: ${ending.detail}` + : ending.how === 'stopped' + ? ending.why ? `was stopped: ${ending.why}` : 'was stopped at its limit' + : 'ended'; + return `[script ${scriptId}] Your script ${how}. {"action": "wait", "script_id": "${scriptId}"} gives its result.`; +} + +function wakeText(scriptId: string, payloadJson: string, n: number, max: number, ageMs: number): string { + const minutes = Math.round(ageMs / 60_000); + return `[script ${scriptId}] Woke you with wake_agent(), ${minutes} min after it started (wake ${n} of ${max}): ${payloadJson}`; +} diff --git a/src/code-execution/py-runner.ts b/src/code-execution/py-runner.ts deleted file mode 100644 index 3f112ec3e..000000000 --- a/src/code-execution/py-runner.ts +++ /dev/null @@ -1,1392 +0,0 @@ -/** - * PyRunner — subprocess lifecycle + protocol driver for the client-side - * programmatic tool calling (`code_execution`) runtime. - * - * One runner per agent. The interpreter is spawned lazily on first exec, - * persists between execs (script globals survive — container-reuse - * semantics), and is reclaimed after an idle period, mirroring the managed - * runtime's ~5-minute container reclaim. - * - * This is a ROBUSTNESS boundary, not a security sandbox — same doctrine as - * GateScript: the agent already has broader host access through its tools. - * What this class guarantees is liveness: a wedged or runaway script cannot - * hang the agent turn (cancel + SIGINT -> grace -> SIGKILL -> respawn) and a crashed - * interpreter surfaces as a tool result, never as an unhandled rejection. - * - * The protocol runs on the interpreter's fds 3 (host -> py) and 4 (py -> host); - * its fd 0 is /dev/null, and the host reads its fds 1 and 2 itself (see - * OutputStream), so what a script and the commands it runs write to them - * reaches the host whatever then happens to the interpreter. - */ - -import { spawn, type ChildProcess } from 'node:child_process'; -import { buildChildEnv } from '../mcpl/transport.js'; -import { randomBytes } from 'node:crypto'; -import { closeSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync, writeSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { createInterface, type Interface } from 'node:readline'; -import type { Readable, Writable } from 'node:stream'; -import { StringDecoder } from 'node:string_decoder'; -import { boundedErrorMessage } from '../error-message.js'; -import { PYTHON_RUNTIME_SOURCE } from './runtime-py.js'; - -export interface InjectedTool { - /** Python identifier the tool is bound to (sanitized, `--` -> `__`). */ - pyName: string; - /** Exact framework tool name, sent back on tool_call ops. */ - toolName: string; -} - -export interface ExecResult { - stdout: string; - stderr: string; - returnCode: number; - /** Set when the host killed the script (deadline / abort / crash). */ - aborted?: boolean; - /** Background execs: rolling tail of interleaved stdout+stderr (for the - * crash/finish envelope; the full journal is in the log file). */ - tail?: string; -} - -/** Per-exec options for background (daemon) scripts. */ -export interface BackgroundExecOptions { - /** Absolute path for the stdout+stderr journal, which the runner appends to as the output arrives (null: tail only). */ - logPath: string | null; - /** - * Called on each wake_agent() from the script. Resolve with null to ack - * (deliver), or an error string to refuse (raises RuntimeError in-script). - * May resolve late — rate limiting backpressures inside wake_agent(). - */ - onWake: (line: number, payload: unknown) => Promise; - /** Overrides the runner's scriptTimeoutMs (background lifetime). */ - lifetimeMs: number; -} - -/** - * An inner tool call's result as the script gets it. `text` is the string - * its await returns, and the runner sets `.is_error` on that string from - * `isError`. With `structuredJson`, `text` is the JSON object a structured - * result reaches a script as, in any of its forms, and `.structured` reads - * its `structuredContent`, None for a form that holds none, such as one past - * the size cap; without it, `.structured` is None. - */ -export interface ScriptToolResult { - text: string; - isError: boolean; - structuredJson?: boolean; -} - -/** - * Resolves inner tool calls. Must never reject — map errors to results; a - * rejection reaches the script as an `Error: …` result. A plain string is - * the result's text with nothing said about it, so the script's - * `.is_error` and `.structured` are None. - */ -export type ScriptToolCallHandler = ( - toolName: string, - args: Record, -) => Promise; - -export interface PyRunnerOptions { - /** Python to spawn (default 'python3'). A wrapper must pass fds 3 and 4 on, where the protocol runs (see CodeExecutionConfig). */ - pythonPath?: string; - /** Per-tool-call timeout surfaced as TimeoutError inside the script. */ - toolCallTimeoutMs?: number; - /** Whole-script deadline; exceeded -> cancel, grace, kill. */ - scriptTimeoutMs?: number; - /** How long a script gets to stop after its deadline before the interpreter is killed (default 10s). */ - cancelGraceMs?: number; - /** Idle interpreter reclaim (state lost), mirroring container reclaim. */ - idleReclaimMs?: number; - onToolCall: ScriptToolCallHandler; - /** Log prefix, typically the agent name. */ - label?: string; - /** Variables for the interpreter beyond what it inherits, such as - * PYTHONPATH or a venv's. It inherits only what stdio MCPL children do - * (CHILD_ENV_ALLOWLIST and LC_*, CA bundles and proxy variables among - * them, and a proxy URL can carry credentials), then these, then - * PYTHONUNBUFFERED and PYTHONIOENCODING, which the runner sets last. - * Like the allowlist, this limits what the model's code inherits, not - * what a process running as the same user can read. */ - env?: Record; - /** Pass the host's whole environment instead of the allowlist, as before - * `env` existed. That hands every host variable, provider keys included, - * to the model's code and to every subprocess a script starts; listing - * what scripts need in `env` is the safer route. */ - inheritEnv?: boolean; -} - -const DEFAULT_TOOL_CALL_TIMEOUT_MS = 270_000; -const DEFAULT_SCRIPT_TIMEOUT_MS = 600_000; -const DEFAULT_IDLE_RECLAIM_MS = 300_000; -const CANCEL_GRACE_MS = 10_000; -/** After the deadline, SIGINT is repeated at this interval until the script reports or is killed. */ -const INTERRUPT_REPEAT_MS = 200; -/** Each of a script's stdout and stderr is kept up to this many characters in its result. */ -const OUTPUT_CAP_CHARS = 2_000_000; -/** A background script's journal stops growing past this many bytes. */ -const LOG_FILE_CAP_BYTES = 50_000_000; -/** How much of a background script's output its result keeps, for the envelope that tells the agent how it ended. */ -const TAIL_CHARS = 4000; -/** - * How long an interpreter that exited unexpectedly gets for the last of what it - * wrote, its output and its messages, to arrive when something still holds its - * pipes open, such as a command its script left running; otherwise their end - * says it has all arrived. - */ -const EXIT_OUTPUT_GRACE_MS = 200; -/** How long a message failing to reach an interpreter waits for the interpreter's own exit before the host ends it as one that stopped reading. */ -const UNREACHABLE_GRACE_MS = 200; -/** How much of what an interpreter wrote before it failed to start, or crashed, goes into the message and the host's log, and how much of each piece of output with no script running goes into the log. */ -const LAST_WORDS_CHARS = 2000; -/** - * How much of the host's log a pipe's output takes while no script of its - * interpreter runs, from the start of its last exec, or its own start: lines - * of up to 65,536 characters in all, counted as UTF-16 code units, each piece - * cut at LAST_WORDS_CHARS, enough to see what a command a script left running - * is doing. The rest is dropped. - */ -const IDLE_LOG_CHARS = 65_536; -/** - * How fast the host reads the pipes of an interpreter it has torn down, which - * a command a script left running may still hold. It goes on reading, so such - * a command isn't ended by a broken pipe, and one writing far under this, as a - * dev server's log does, never waits; one that writes without end slows to - * this, rather than costing the host a core for as long as it runs. A live - * interpreter's pipes are read at full speed, so what such a command writes - * between scripts is read as it's written, and the idle reclaim bounds how - * long that lasts, unless a host turns the reclaim off. - */ -const IDLE_READ_BYTES_PER_SECOND = 1_048_576; -/** The runtime's exit when the host's channel, fds 3 and 4, didn't reach it: the host's configuration, which calling again doesn't change. 78 is sysexits' EX_CONFIG, which Python itself never exits with. */ -const HOST_CHANNEL_MISSING_EXIT = 78; -/** - * The runtime's exit when it ends itself (runtime-py.ts _fatal), after a line - * that, when a script took one of its fds, says which. Read with that line, so - * a script that prints such a line and ends any other way isn't taken for it. - */ -const RUNTIME_FATAL_EXIT = 70; - -/** The longest delay Node's timers honour (~24.8 days); a longer one fires after ~1 ms. */ -export const MAX_TIMER_MS = 2_147_483_647; - -/** A time limit for a message: "45s", "10 min", "2.5 h". */ -export function formatLimit(ms: number): string { - if (ms < 120_000) return `${Math.round(ms / 1000)}s`; - if (ms < 7_200_000) return `${Math.round(ms / 6_000) / 10} min`; - return `${Math.round(ms / 360_000) / 10} h`; -} - -/** `line` after `text`, on a line of its own: a text that doesn't end with a newline gets one first. */ -function onItsOwnLine(text: string, line: string): string { - return (text === '' || text.endsWith('\n') ? text : `${text}\n`) + line; -} - -/** Add a line of the runner's own to a background script's journal, on a line of its own. */ -function writeLine(journal: Journal, line: string): void { - journal.write(`${journal.tail === '' || journal.tail.endsWith('\n') ? '' : '\n'}${line}\n`); -} - -/** Whether the child is still running: not exited, not ended by a signal, and not killed by us. */ -function alive(child: ChildProcess): boolean { - return child.exitCode === null && child.signalCode === null && !child.killed; -} - -/** - * All of `text` at the end of the file: a write the kernel cuts short, as at a - * file-size limit, goes on from where it stopped, and one that fails throws. - */ -function writeAll(fd: number, text: string): void { - const data = Buffer.from(text); - for (let at = 0; at < data.length; ) { - const n = writeSync(fd, data, at); - // POSIX has a regular file take at least a byte of a write or fail it; a - // write that took none would otherwise hold the host's event loop here. - if (n === 0) throw new Error('the file took none of a write'); - at += n; - } -} - -/** - * A background script's journal: the log file the agent can read with its own - * tools while the script runs, and the tail its result carries. It's written - * synchronously, from the host's event loop, so a workspace whose writes block - * holds the host while they do. - */ -class Journal { - private fd: number | null = null; - private written = 0; - private capped = false; - tail = ''; - - constructor( - path: string | null, - private readonly log: (text: string) => void, - ) { - if (!path) return; - try { - mkdirSync(dirname(path), { recursive: true }); - this.fd = openSync(path, 'a'); - } catch (err) { - log(`cannot open log file: ${err instanceof Error ? err.message : String(err)}`); - } - } - - write(text: string): void { - if (text === '') return; - this.tail = (this.tail + text).slice(-TAIL_CHARS); - if (this.fd === null || this.capped) return; - const bytes = Buffer.byteLength(text); - try { - if (this.written + bytes > LOG_FILE_CAP_BYTES) { - this.capped = true; - writeAll(this.fd, `\n[log capped at ${LOG_FILE_CAP_BYTES} bytes]\n`); - } else { - writeAll(this.fd, text); - this.written += bytes; - } - } catch (err) { - // A journal that can't be written to still keeps its tail. - this.log(`cannot write the log file any more: ${err instanceof Error ? err.message : String(err)}`); - this.close(); - } - } - - close(): void { - if (this.fd === null) return; - try { - closeSync(this.fd); - } catch { - // already gone - } - this.fd = null; - } -} - -const NOTHING = Buffer.alloc(0); - -/** The first n UTF-16 code units of s, short of a surrogate pair the cut would split. */ -function cutWhole(s: string, n: number): string { - if (s.length <= n) return s; - const cut = s.slice(0, n); - return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut; -} - -/** - * One of the interpreter's output pipes, its fd 1 or fd 2, as the host reads it. - * While an exec runs, what arrives before that exec's end mark is the exec's: - * kept for its result, up to OUTPUT_CAP_CHARS, or written to a background - * script's journal. The mark itself is cut out. What arrives while no exec of - * its interpreter runs, such as what a command a script left running writes, - * goes to the host's log as it arrives, both while the interpreter lives and - * after it has ended, up to IDLE_LOG_CHARS between scripts; once the runner - * has torn the interpreter down, it's read at IDLE_READ_BYTES_PER_SECOND at - * most (retire). - */ -export class OutputStream { - /** Every process holding the pipe has closed it, so nothing more will come. */ - ended = false; - /** The running exec's mark has arrived, so all of its output on this pipe has. */ - marked = false; - /** The end of what arrived while no exec ran: an interpreter that failed to start says why here. */ - lastIdle = ''; - /** Whether lastIdle lost its beginning to the LAST_WORDS_CHARS cut. */ - lastIdleCut = false; - private mark: Buffer | null = null; - /** The end of a chunk that could be the start of a mark the next chunk completes. */ - private carry: Buffer = NOTHING; - private readonly decoder = new StringDecoder('utf8'); - private text = ''; - private truncated = false; - private journal: Journal | null = null; - private onEnd: (() => void)[] = []; - /** How many characters of log lines output with no script running has taken since this pipe's last exec began: IDLE_LOG_CHARS at most. */ - private idleLogged = 0; - /** Set once the runner has torn its interpreter down: nothing reads this pipe for an exec again. */ - private retired = false; - /** The wait that holds a retired pipe back (slow). */ - private resumer: ReturnType | null = null; - /** Bytes read since the last wait was set, which the next one charges (slow). */ - private owed = 0; - - constructor( - private readonly name: 'stdout' | 'stderr', - private readonly pipe: Readable, - private readonly log: (text: string) => void, - private readonly onMarked: () => void, - ) { - pipe.on('data', (chunk: Buffer) => this.push(chunk)); - const ended = () => { - if (this.ended) return; - this.ended = true; - for (const fn of this.onEnd.splice(0)) fn(); - }; - pipe.on('end', ended); - pipe.on('close', ended); - pipe.on('error', (err) => { - log(`reading the python runtime's ${name} failed: ${err.message}`); - ended(); - }); - } - - /** Begin an exec's share of the pipe, which its mark ends. */ - begin(mark: Buffer, journal: Journal | null): void { - // A character that output with no script running left half-written is - // that output's, not this exec's. - this.logIdle(this.decoder.end()); - // The stretch with no script running that follows this exec logs a share - // of its own. - this.idleLogged = 0; - this.mark = mark; - this.marked = false; - this.journal = journal; - this.text = ''; - this.truncated = false; - } - - /** The exec's share as it stands, with a note if it was cut at the cap; what arrives next goes to the log. */ - take(): string { - if (this.mark !== null) { - // Settled before its mark came (a kill, an abort or a crash): what arrived counts. - this.keep(this.carry); - this.carry = NOTHING; - this.keep(this.decoder.end()); - } - const text = this.text + (this.truncated ? `\n[${this.name} truncated at ${OUTPUT_CAP_CHARS} chars]` : ''); - this.mark = null; - this.journal = null; - this.text = ''; - this.truncated = false; - return text; - } - - /** Run fn once the pipe has ended (at once if it has). */ - whenEnded(fn: () => void): void { - if (this.ended) fn(); - else this.onEnd.push(fn); - } - - private push(chunk: Buffer): void { - let bytes = this.carry.length > 0 ? Buffer.concat([this.carry, chunk]) : chunk; - this.carry = NOTHING; - if (this.mark !== null) { - const at = bytes.indexOf(this.mark); - if (at < 0) { - // Hold back only an end that could be the mark's start, so a - // background script's journal keeps up with its output. - const hold = markStartAtEnd(bytes, this.mark); - this.keep(bytes.subarray(0, bytes.length - hold)); - this.carry = bytes.subarray(bytes.length - hold); - return; - } - this.keep(bytes.subarray(0, at)); - this.keep(this.decoder.end()); - bytes = bytes.subarray(at + this.mark.length); - this.mark = null; - this.marked = true; - this.onMarked(); - } - if (bytes.length > 0) { - const text = this.decoder.write(bytes); - const idle = this.lastIdle + text; - if (idle.length > LAST_WORDS_CHARS) { - this.lastIdleCut = true; - // A cut between a surrogate pair's halves would leave its low half, which reads as U+FFFD. - const kept = idle.slice(-LAST_WORDS_CHARS); - this.lastIdle = /^[\uDC00-\uDFFF]/.test(kept) ? kept.slice(1) : kept; - } else { - this.lastIdle = idle; - } - this.logIdle(text); - if (this.retired) this.slow(bytes.length); - } - } - - /** The runner has torn its interpreter down: read what still comes at IDLE_READ_BYTES_PER_SECOND at most. */ - retire(): void { - this.retired = true; - } - - /** - * Log output that arrived while no exec of this pipe's interpreter ran, each - * piece up to LAST_WORDS_CHARS, in lines of IDLE_LOG_CHARS in all since the - * pipe's last exec began, the last cut to fit. Each line is charged its - * whole length, so many small writes can't take more of the log. - */ - private logIdle(text: string): void { - const shown = text.trimEnd(); - if (this.idleLogged >= IDLE_LOG_CHARS || shown.trim() === '') return; - const head = `${this.name} while its interpreter runs no script: `; - const kept = cutWhole(shown, LAST_WORDS_CHARS); - const piece = kept.length < shown.length ? `${kept} [… ${shown.length - kept.length} more characters]` : shown; - const fits = IDLE_LOG_CHARS - this.idleLogged - head.length; - if (fits > 0) { - const line = head + cutWhole(piece, fits); - this.idleLogged += line.length; - this.log(line); - } - if (fits <= piece.length) { - this.idleLogged = IDLE_LOG_CHARS; - this.log(`${head}past ${IDLE_LOG_CHARS} characters of the log, so the host drops what follows while its interpreter runs no script`); - } - } - - /** - * Hold the pipe back for as long as these bytes take at - * IDLE_READ_BYTES_PER_SECOND, then read on. Something else can resume the - * pipe during a wait, as Node does with a child's stdio when the child - * exits, so each piece holds it back again, and what came during a wait - * waits its own time once that wait ends. - */ - private slow(bytes: number): void { - this.pipe.pause(); - this.owed += bytes; - if (this.resumer === null) this.wait(); - } - - private wait(): void { - const ms = Math.ceil((this.owed * 1000) / IDLE_READ_BYTES_PER_SECOND); - this.owed = 0; - this.resumer = setTimeout(() => { - this.resumer = null; - if (this.owed > 0) this.wait(); - else this.pipe.resume(); - }, ms); - // A command left running doesn't keep the host's process alive. - this.resumer.unref(); - } - - /** Keep what arrived for the running exec. */ - private keep(part: Buffer | string): void { - const text = typeof part === 'string' ? part : this.decoder.write(part); - if (text === '') return; - if (this.journal) { - this.journal.write(text); - return; - } - if (this.truncated) return; - const room = OUTPUT_CAP_CHARS - this.text.length; - if (text.length <= room) { - this.text += text; - return; - } - this.text += text.slice(0, room); - this.truncated = true; - } -} - -/** How many bytes at the end of `bytes` could be the start of `mark`. */ -function markStartAtEnd(bytes: Buffer, mark: Buffer): number { - for (let n = Math.min(bytes.length, mark.length - 1); n > 0; n--) { - if (bytes.subarray(bytes.length - n).equals(mark.subarray(0, n))) return n; - } - return 0; -} - -/** Run fn once the readable has ended (at once if it has). */ -function whenReadableEnded(stream: Readable, fn: () => void): void { - if (stream.readableEnded || stream.destroyed) { - fn(); - return; - } - let called = false; - const once = () => { - if (called) return; - called = true; - fn(); - }; - stream.once('end', once); - stream.once('close', once); -} - -/** - * Run fn once an exited interpreter's output pipes and its messages' pipe have - * all ended, which says the last of what it wrote has arrived, or else after - * EXIT_OUTPUT_GRACE_MS, if something, such as a command its script left - * running, still holds them, and one more round of the event loop's reads, so - * what had already arrived is read first even when the host's loop was busy - * past the grace. - */ -function afterOutputEnds(stdout: OutputStream, stderr: OutputStream, messages: Readable | null, fn: () => void): void { - let done = false; - const finish = () => { - if (done) return; - done = true; - clearTimeout(timer); - fn(); - }; - const timer = setTimeout(() => setImmediate(finish), EXIT_OUTPUT_GRACE_MS); - stdout.whenEnded(() => stderr.whenEnded(() => (messages ? whenReadableEnded(messages, finish) : finish()))); -} - -/** - * What a script stopped at its time limit is told after why: that the tool - * calls it made keep their effects, and whether a longer limit can help, - * which only the deployment's ceiling for its kind of script can say. A - * foreground script at its ceiling is offered background mode too, when its - * caller passes `backgroundCeilingMs` (the framework does for an agent that - * could start a background script as the script starts) and a background - * script may run longer. - */ -function limitRemedy(limitMs: number, ceilingMs: number | undefined, background: boolean, backgroundCeilingMs?: number): string { - const effects = 'Tool calls it had already made keep their effects, so check them before running it again.'; - if (ceilingMs === undefined) return effects; - if (limitMs < ceilingMs) return `${effects} To give it longer, ${background ? 'start it with' : 'pass'} time_limit_ms up to ${ceilingMs} (${formatLimit(ceilingMs)}).`; - if (background) return `${effects} ${formatLimit(limitMs)} is the longest a background script runs here; starting it again runs it from the top.`; - const inBackground = backgroundCeilingMs !== undefined && backgroundCeilingMs > limitMs - ? `, or run it with background: true, where a script can run up to ${formatLimit(backgroundCeilingMs)}` : ''; - return `${effects} ${formatLimit(limitMs)} is the longest a script can run here, so split the work across shorter scripts${inBackground}.`; -} - -/** - * Why a script didn't run when its interpreter didn't start. A spawn that - * found nothing it could run (ENOENT, EACCES) fails the same way until the - * host changes, so that one says code_execution can't run here rather than - * to try again. A failure before the spawn, while creating or writing the - * runtime's files, is about those files rather than the interpreter, and - * keeps Node's own text, which names the path for an open or a mkdtemp. An - * interpreter that exits as it starts passes on up to the last LAST_WORDS_CHARS - * of what it wrote to stderr, from the end, where a traceback says what failed, - * and with "…" where they're cut; when the runtime itself exits so because - * the host's channel didn't reach it (HOST_CHANNEL_MISSING_EXIT), its own - * words say who must act and that calling again fails the same way, so they - * end the text. - */ -function startFailure(pythonPath: string, err: unknown): string { - const { code, syscall, exitCode, said } = (err ?? {}) as { code?: unknown; syscall?: unknown; exitCode?: unknown; said?: unknown }; - const spawning = typeof syscall === 'string' && syscall.startsWith('spawn'); - if (spawning && (code === 'ENOENT' || code === 'EACCES')) { - const until = code === 'ENOENT' ? 'installs it or points this host at one' : 'makes it executable or points this host at another'; - return `script didn't run: this host can't start its Python interpreter, ${pythonPath} (${code}), so code_execution can't run scripts here until whoever runs this host ${until}. Calling it again before then fails the same way.`; - } - if (typeof syscall === 'string' && !spawning) { - return `script didn't run: this host couldn't create or write the files its Python runtime starts from (${boundedErrorMessage(err)}), so none of the script ran. Run it again; if it fails the same way, ask whoever runs this host.`; - } - const wrote = typeof said === 'string' ? said : ''; - // The runtime's own words say who must act, and that calling again fails the same way. - if (exitCode === HOST_CHANNEL_MISSING_EXIT && wrote !== '') { - return `script didn't run: the Python interpreter, ${pythonPath}, exited as it started, saying: ${wrote}`; - } - const why = typeof code === 'string' ? `couldn't be started (${code})` : boundedErrorMessage(err); - return `script didn't run: the Python interpreter, ${pythonPath}, ${why}, so none of the script ran. Run it again; if it fails the same way, ask whoever runs this host.` - + (wrote === '' ? '' : ` It wrote to stderr: ${wrote}`); -} - -/** Why a script stopped when its interpreter stopped reading the host's messages, so that the host ended it. */ -function stoppedReading(background: boolean): string { - return "script stopped: the Python interpreter stopped reading the host's messages, so the host ended it and the rest of the script didn't run" - + `${background ? '' : ', and variables from earlier scripts are gone'}. Tool calls it had already made keep their effects, ` - + 'so check them before you run it again in a new interpreter. If it stops the same way, check whether the script closes, ' - + "shuts down or replaces file descriptor 3, where the host's messages arrive; if not, ask whoever runs this host."; -} - -/** The signals faulthandler writes a traceback for (runtime-py.ts), which a crash ends a process with. */ -const CRASH_SIGNALS: ReadonlySet = new Set(['SIGSEGV', 'SIGABRT', 'SIGBUS', 'SIGFPE', 'SIGILL']); - -/** How faulthandler's traceback for a crash's signal begins. */ -const CRASH_TRACEBACK = 'Fatal Python error: '; - -/** - * Why a script stopped when its interpreter exited under it, and what running it - * again does; or, when the script's result came before the exit (ended), that - * the script ran to its end and what of its output the exit may have cost. - */ -function interpreterExit(exitCode: number | null, signal: NodeJS.Signals | null, background: boolean, said = '', ended = false): string { - const how = signal ? `was ended by ${signal}` : `exited with code ${exitCode}`; - // The runtime ends itself (_fatal) with code 70 after a line saying which of its fds a script took, - // or shut down the socket under. The line reaches the host unless the script took both the runtime's - // copy of fd 2 and fd 2 itself, or shut down the socket under them, and it's read from what the result - // keeps, which past a stream's cap or a journal's tail lacks it. - const taken = exitCode === RUNTIME_FATAL_EXIT ? fdTakenFromRuntime(said) : null; - // After a crash's signal, faulthandler's traceback shows the call that crashed, in native code or the - // script's own os.abort(), when it reached what the result keeps: a script can disable faulthandler, - // and a stream's cap or a journal's tail can lose it. Its first words are looked for anywhere, not - // only at a line's start: faulthandler doesn't start a line, so after output that didn't end one, - // such as a progress bar's, they follow on that line. SIGKILL is the out-of-memory killer's; any - // other signal, the script can send itself. - const check = taken - ? `check whether the script closes, shuts down or replaces ${taken}, as the runtime's line above says` - : signal && CRASH_SIGNALS.has(signal) - ? said.includes(CRASH_TRACEBACK) - ? 'check the traceback above for the call that crashed in native code, such as an extension module the script uses' - : 'check whether native code the script calls crashes, such as an extension module it uses' - : `check whether the script itself ends the process, for example ${ - signal === 'SIGKILL' ? 'by using too much memory' : signal ? 'with os.kill()' : 'with os._exit()'}`; - const gone = background ? '' : ', and variables from earlier scripts are gone'; - if (ended) { - return `script ended, but the Python interpreter ${how} before all of its output reached the host, so some of it may be missing${gone}. ` - + `If it happens again, ${check}; if not, ask whoever runs this host.`; - } - return `script stopped: the Python interpreter ${how} while it ran, so the rest of the script didn't run${gone}. ` - + 'Tool calls it had already made keep their effects, so check them before you run it again in a new interpreter. ' - + `If it stops the same way, ${check}; if not, ask whoever runs this host.`; -} - -/** The file descriptor the runtime's last line of its own says a script took from it (runtime-py.ts _lost), if it says so. */ -function fdTakenFromRuntime(said: string): string | null { - let taken: string | null = null; - for (const m of said.matchAll(/\[pytc-runtime\] [^\n]*: a script closed, shut down or replaced ((?:the runtime's copy of )?fd \d)[^\n]*; exiting/g)) { - taken = m[1]; - } - return taken; -} - -interface PendingExec { - id: string; - resolve: (result: ExecResult) => void; - deadlineTimer: ReturnType | null; - killTimer: ReturnType | null; - /** Repeats the deadline SIGINT (see interruptChild). */ - interruptTimer?: ReturnType; - settled: boolean; - /** Set once the deadline fired: the result then says the script ran out of time. */ - deadlineMs: number | null; - /** The longest limit this exec could have had (exec's opts): whether a longer one would help. */ - ceilingMs: number | undefined; - /** For a foreground exec, the longest a background script may run (exec's opts): whether background mode would help. */ - backgroundCeilingMs: number | undefined; - /** A background exec has its interpreter to itself, so no earlier script's state is lost with it. */ - background: boolean; - /** Set once the script went to the interpreter. Before that, the exec is - * waiting for the interpreter to start, and an interpreter that exits then - * failed to start (see exec). */ - sent: boolean; - /** A background exec's journal, which its output goes to instead of its result. */ - journal: Journal | null; - /** The script's return code, once the interpreter has reported it; the exec - * settles when its output's end marks have arrived too. */ - returnCode: number | null; - /** Set when the host settled the exec itself (stopPending), rather than its result and marks. */ - stopped?: true; -} - -/** Interpreters started in this process, by every runner: each one's number. */ -let interpretersStarted = 0; - -/** - * The interpreter to spawn. A bare name is looked up on PATH. A path with a - * separator is resolved against the host's working directory, where it - * resolved before the interpreter got a working directory of its own: spawn - * would resolve it against the runtime directory instead. - */ -function spawnablePath(pythonPath: string): string { - const hasSeparator = pythonPath.includes('/') || (process.platform === 'win32' && pythonPath.includes('\\')); - return hasSeparator ? resolve(pythonPath) : pythonPath; -} - -export class PyRunner { - private readonly pythonPath: string; - private readonly toolCallTimeoutMs: number; - private readonly scriptTimeoutMs: number; - private readonly cancelGraceMs: number; - private readonly idleReclaimMs: number; - private readonly onToolCall: ScriptToolCallHandler; - private readonly label: string; - private readonly envOptions: { env?: Record; inheritEnv?: boolean }; - - private onWake: ((line: number, payload: unknown) => Promise) | null = null; - private child: ChildProcess | null = null; - private childReady: Promise | null = null; - /** The interpreter's fd 3, where the host's messages go. */ - private protoIn: Writable | null = null; - /** Ends the interpreter whose fd 3 a message couldn't reach (see ensureChild). */ - private unreachable: ((why: string) => void) | null = null; - /** Reads the interpreter's messages from its fd 4. */ - private reader: Interface | null = null; - /** The interpreter's stdout and stderr, as the host reads them. */ - private out: OutputStream | null = null; - private err: OutputStream | null = null; - private runtimeDir: string | null = null; - private pending: PendingExec | null = null; - private idleTimer: ReturnType | null = null; - private execCounter = 0; - /** The number of this runner's latest interpreter (interpretersStarted). */ - private interpreterNumber = 0; - private disposed = false; - - constructor(options: PyRunnerOptions) { - this.pythonPath = options.pythonPath ?? 'python3'; - this.toolCallTimeoutMs = options.toolCallTimeoutMs ?? DEFAULT_TOOL_CALL_TIMEOUT_MS; - this.scriptTimeoutMs = options.scriptTimeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS; - this.cancelGraceMs = options.cancelGraceMs ?? CANCEL_GRACE_MS; - this.idleReclaimMs = options.idleReclaimMs ?? DEFAULT_IDLE_RECLAIM_MS; - this.onToolCall = options.onToolCall; - this.label = options.label ?? 'pytc'; - // The interpreter runs model-authored code: the same env discipline as an - // MCPL connector child (#175), for a child that deserves it more. Stored - // as options, not a snapshot — the env is rebuilt at every spawn so a - // respawn after idle reclaim sees current host values, as the old - // per-spawn `{ ...process.env }` did. - this.envOptions = { env: options.env, inheritEnv: options.inheritEnv }; - } - - get busy(): boolean { - return this.pending !== null; - } - - /** - * The live interpreter, whose state (variables, imports, functions) the - * scripts run in it share: a number no other interpreter in this process - * has had, from any runner, or null while none is running, before the - * first script or once it was reclaimed, killed or has exited. - */ - get interpreter(): number | null { - return this.child && this.child.exitCode === null && !this.child.killed ? this.interpreterNumber : null; - } - - /** - * Run one script. Tools are (re-)injected before every exec so the - * interpreter always reflects the current tool surface (list_changed etc.). - * Never rejects: every failure mode resolves to an ExecResult. - * - * With `background` options the exec is a daemon: output journals to the - * log file, wake_agent() is available in-script, and the deadline is the - * background lifetime. A background runner should be DEDICATED to that one - * script (the framework creates one per background script). - * - * `opts.deadlineMs` replaces the runner's scriptTimeoutMs for this exec (a - * per-call time limit); a background exec uses its `lifetimeMs` instead. - * `opts.ceilingMs` is the longest limit the deployment allows this kind of - * script: a script stopped at its limit is offered a longer one only below - * it. Without it, the stop doesn't say whether a longer limit would help. - * `opts.backgroundCeilingMs`, for a foreground exec, is the longest a - * background script may run: one stopped at its ceiling is offered - * background mode when that is longer. - */ - async exec( - code: string, - tools: InjectedTool[], - background?: BackgroundExecOptions, - opts?: { deadlineMs?: number; ceilingMs?: number; backgroundCeilingMs?: number }, - ): Promise { - if (this.disposed) { - return { stdout: '', stderr: 'code_execution runner disposed', returnCode: 1, aborted: true }; - } - if (this.busy) { - return { - stdout: '', - stderr: "script didn't run: another code_execution script of yours is already running, and they run one at a time. " - + 'Run this one again once that one has finished, or combine the two into one script.', - returnCode: 1, - aborted: true, - }; - } - this.clearIdleTimer(); - - // The exec claims the runner before the await: a same-tick second exec - // sees it busy, and abort() or dispose() while the interpreter starts - // settles this exec, whose script then never runs. - let resolveResult!: (result: ExecResult) => void; - const settled = new Promise((resolve) => { - resolveResult = resolve; - }); - const pending: PendingExec = { - id: `e${++this.execCounter}`, - resolve: resolveResult, - deadlineTimer: null, - killTimer: null, - settled: false, - deadlineMs: null, - ceilingMs: opts?.ceilingMs, - backgroundCeilingMs: opts?.backgroundCeilingMs, - background: background !== undefined, - sent: false, - journal: null, - returnCode: null, - }; - this.pending = pending; - try { - await this.ensureChild(); - } catch (err) { - // Settled already when an abort or dispose reclaimed the interpreter - // as it started; another exec may have claimed the runner since. - if (!pending.settled) { - this.settlePending({ - stdout: '', - stderr: startFailure(this.pythonPath, err), - returnCode: 1, - aborted: true, - }); - this.reclaim('spawn-failed'); - } - return settled; - } - if (pending.settled) return settled; - - const execId = pending.id; - // Clamped: a deadline past Node's timer range would fire at once instead of never. - const deadlineMs = Math.min(MAX_TIMER_MS, background?.lifetimeMs ?? opts?.deadlineMs ?? this.scriptTimeoutMs); - this.onWake = background?.onWake ?? null; - pending.deadlineTimer = setTimeout(() => { - pending.deadlineMs = deadlineMs; - // Deadline: ask politely first (script sees CancelledError and its - // exec_result still flows back), then kill on unresponsiveness. - this.send({ op: 'cancel', id: execId, reason: 'deadline' }); - // The cancel lands only when the script awaits. Blocking code (time.sleep, a - // busy loop, a blocking read) never does, so interrupt it as well: the runtime - // raises KeyboardInterrupt in the script's own code, or schedules the - // cancellation of a script that is waiting. Repeated, because a waiting script - // can resume into blocking code before that cancellation runs; the runtime - // ignores the repeats once the script was interrupted or cancelled. - this.interruptChild(); - pending.interruptTimer = setInterval(() => this.interruptChild(), INTERRUPT_REPEAT_MS); - pending.killTimer = setTimeout(() => { - pending.deadlineMs = null; // this message already says why - this.stopPending( - `script stopped: it reached its ${formatLimit(deadlineMs)} time limit and did not respond, so it was killed` + - (pending.background ? '. ' : ' and the interpreter restarted (variables from earlier scripts are gone). ') + - limitRemedy(deadlineMs, pending.ceilingMs, pending.background, pending.backgroundCeilingMs), - ); - this.reclaim('deadline-kill'); - }, this.cancelGraceMs); - }, deadlineMs); - // A day-scale background deadline must not hold the process open. - if (background) pending.deadlineTimer.unref?.(); - - // What the script writes, up to its mark, is this exec's: a background - // script's goes to its journal. - const mark = Buffer.from(`\x1epytc-end-${randomBytes(16).toString('hex')}\x1e`); - pending.journal = background ? new Journal(background.logPath, (text) => this.log(text)) : null; - this.out?.begin(mark, pending.journal); - this.err?.begin(mark, pending.journal); - pending.sent = true; - this.send({ - op: 'init', - tools: tools.map((t) => ({ py_name: t.pyName, tool_name: t.toolName })), - call_timeout_s: Math.round(this.toolCallTimeoutMs / 1000), - ...(background ? { background: true } : {}), - }); - this.send({ op: 'exec', id: execId, code, mark: mark.toString('utf8') }); - - const result = await settled; - - this.onWake = null; - if (!background) this.armIdleTimer(); - return result; - } - - /** - * Abort the runner's script (its turn ended or is reset to run again, or the - * agent was reset or stopped), whether it's running or still waiting for its - * interpreter to start; a waiting script then never runs. The exec settles - * at once, and the interpreter is reclaimed, a starting one too: after an - * abort its state is suspect (a script died midway), and the next exec gets - * a fresh one. - */ - abort(reason: string): void { - if (!this.pending) return; - this.send({ op: 'cancel', id: this.pending.id, reason }); - this.stopPending(`script aborted by host: ${reason}`); - this.reclaim(`abort: ${reason}`); - } - - dispose(): void { - if (this.disposed) return; - this.disposed = true; - this.stopPending('code_execution runner disposed'); - this.reclaim('dispose'); - } - - // ------------------------------------------------------------------------- - - private async ensureChild(): Promise { - if (this.child && alive(this.child)) { - return this.childReady ?? Promise.resolve(); - } - this.teardownChild(); - - // Absolute, since the interpreter runs in it: from a relative TMPDIR, - // runtime.py's path would otherwise resolve against the directory itself. - this.runtimeDir = resolve(mkdtempSync(join(tmpdir(), 'af-pytc-'))); - const runtimePath = join(this.runtimeDir, 'runtime.py'); - writeFileSync(runtimePath, PYTHON_RUNTIME_SOURCE, 'utf8'); - - const child = spawn(spawnablePath(this.pythonPath), [runtimePath], { - // fd 0 is /dev/null, the host reads fds 1 and 2, and the protocol has 3 and 4. - stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], - // The runtime directory, not the host's working directory: a script's - // relative paths land here, and go at teardown, rather than beside the - // host's .env and data. An absolute path still reaches the host's files. - cwd: this.runtimeDir, - // Set last, over a declared env. The host reads fds 1 and 2 as UTF-8. - // The runtime's channel and a script's writers say UTF-8 themselves - // (runtime-py.ts), but other Python writing there takes its encoding and - // buffering from these: the interpreter's sys.stdout and sys.stderr - // between scripts, which a thread a script left running prints through, - // and any Python a script starts. So what it writes reaches the host as - // it's written, and in UTF-8, where Python before 3.15 on Windows writes - // a pipe in the ANSI code page unless told otherwise; a PYTHONUTF8 in - // the host process's own environment reaches the interpreter only with - // inheritEnv: true or declared in env. - env: { ...buildChildEnv(this.envOptions), PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' }, - }); - this.child = child; - this.interpreterNumber = ++interpretersStarted; - const protoIn = child.stdio[3] as Writable; - const protoOut = child.stdio[4] as Readable; - this.protoIn = protoIn; - const stdout = new OutputStream('stdout', child.stdout as Readable, (text) => this.log(text), () => this.settleIfComplete(child)); - const stderr = new OutputStream('stderr', child.stderr as Readable, (text) => this.log(text), () => this.settleIfComplete(child)); - this.out = stdout; - this.err = stderr; - protoOut.on('error', (e) => this.log(`reading the python runtime's messages failed: ${e.message}`)); - - // An interpreter that no longer reads the host's messages, as after a - // script closes file descriptor 3, could go on running, and every later - // message to it is lost: its exec would wait out its time limit. So when - // a message can't reach it, it's ended, and its exit settles the exec. Such - // a write fails after write() returns: the pipe refuses it, or has already - // ended, its far end closed. Its error, which with no listener would be - // thrown as an uncaught exception, is heard here too. An interpreter that - // died on its own breaks the pipe the same way, as by dying with a message - // unread, and its exit can be seen after the break: so the kill waits - // UNREACHABLE_GRACE_MS and one more round of reads, and an interpreter that - // exited by then is left to its own exit, which says what happened. - let inputFailed = false; - let killing = false; - const unreachable = (why: string) => { - // One the host has torn down already ended its pipe on purpose. - if (child !== this.child || killing) return; - this.log(`messages to the python runtime failed: ${why}`); - killing = true; - setTimeout( - () => - setImmediate(() => { - if (child === this.child && alive(child) && child.pid !== undefined) { - inputFailed = true; - child.kill('SIGKILL'); - } - }), - UNREACHABLE_GRACE_MS, - ); - }; - this.unreachable = unreachable; - protoIn.on('error', (e) => unreachable(e.message)); - - child.on('error', (err) => { - // Spawn failure lands here (e.g. python3 missing); readiness rejects. - console.error(`[pytc:${this.label}] python process error: ${err.message}`); - }); - - child.on('exit', (exitCode, signal) => { - // An exec still waiting for the interpreter is settled by exec, as a - // runtime that failed to start. - const pending = this.pending; - if (!pending?.sent) { - // One still starting is reported by its exec, as a failed start. - if (!pending && this.child === child) { - this.log(`python runtime exited while no script ran (code=${exitCode}, signal=${signal ?? 'none'}); the next script starts a new one`); - } - if (this.child === child) this.teardownChild(); - return; - } - const killedDeaf = inputFailed && signal === 'SIGKILL'; - // Why, from what arrived: the runtime's own exit names the fd it lost in its last line. - const why = (said: string) => - killedDeaf - ? stoppedReading(pending.background) - : interpreterExit(exitCode, signal, pending.background, said, pending.returnCode !== null); - // The host's log keeps the short form, with the exit's own code and signal. - const logged = killedDeaf - ? "python runtime stopped reading the host's messages, so it was killed" - : `python runtime exited unexpectedly (code=${exitCode}, signal=${signal ?? 'none'})`; - // What it wrote last, such as a fatal error's own text or an exec's - // result, is already on its way through its pipes: settle once they've - // ended, or after a short grace while a command it started still holds - // them. - afterOutputEnds(stdout, stderr, protoOut, () => { - if (this.pending === pending) { - const last = this.stopPending(why).trim(); - // A background script's last output is its journal's, both streams. - const what = pending.journal ? 'the last it wrote' : 'the last it wrote to stderr'; - this.log(last ? `${logged}; ${what}: ${last.slice(-LAST_WORDS_CHARS)}` : logged); - } else if (!pending.stopped) { - // Its script's result and marks, already on their way, settled the - // exec while the host waited: it exited as its script ended, whether - // or not a next exec has replaced it since. - this.log(`python runtime exited as its script ended (code=${exitCode}, signal=${signal ?? 'none'}); the next script starts a new one`); - } - if (this.child === child) this.teardownChild(); - }); - }); - - this.reader = createInterface({ input: protoOut }); - this.reader.on('line', (line) => this.handleLine(line, child)); - // readline passes its input's error on to the interface, where with no - // listener it would be thrown as an uncaught exception and end the host. - // protoOut's own listener above logs it. - this.reader.on('error', () => {}); - - this.childReady = new Promise((resolve, reject) => { - let over = false; - const finish = (outcome: () => void) => { - if (over) return; - over = true; - clearTimeout(timeout); - // Another interpreter may have started since this one exited. - if (this.readyResolver === onReady) this.readyResolver = null; - if (this.readyFailer === onError) this.readyFailer = null; - child.off('exit', onExit); - child.off('error', onError); - outcome(); - }; - const onReady = () => finish(resolve); - const onExit = (exitCode: number | null, signal: NodeJS.Signals | null) => { - // Why it ended, such as a traceback, is on its way through its stderr. - // Its messages' pipe doesn't matter here, and the exit's teardown has - // stopped reading it. A tail that lost its beginning says so. - afterOutputEnds(stdout, stderr, null, () => { - const tail = stderr.lastIdle.trim(); - const said = tail !== '' && stderr.lastIdleCut ? `…${tail}` : tail; - const how = signal ? `was ended by ${signal} as it started` : 'exited as it started'; - finish(() => reject(Object.assign(new Error(how), { exitCode, said }))); - }); - }; - const onError = (e: Error) => finish(() => reject(e)); - const timeout = setTimeout(() => finish(() => reject(new Error("didn't start within 15 seconds"))), 15_000); - this.readyResolver = onReady; - this.readyFailer = onError; - child.on('exit', onExit); - child.on('error', onError); - }); - return this.childReady; - } - - private log(text: string): void { - console.error(`[pytc:${this.label}] ${text}`); - } - - private readyResolver: (() => void) | null = null; - /** Fails the readiness of an interpreter that is still starting (see reclaim). */ - private readyFailer: ((err: Error) => void) | null = null; - - private handleLine(line: string, child: ChildProcess): void { - let msg: { op?: string; id?: string; name?: string; args?: unknown; return_code?: number }; - try { - msg = JSON.parse(line); - } catch { - this.log(`non-protocol line from the python runtime: ${line.slice(0, 200)}`); - return; - } - - switch (msg.op) { - case 'ready': - this.readyResolver?.(); - return; - - case 'tool_call': { - const callId = msg.id; - const toolName = msg.name; - if (!callId || !toolName) return; - const args = - msg.args && typeof msg.args === 'object' && !Array.isArray(msg.args) - ? (msg.args as Record) - : {}; - this.onToolCall(toolName, args) - .catch((err): ScriptToolResult => ({ - text: `Error: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - })) - .then((result) => { - // Only an object is a ScriptToolResult. Anything else a handler - // resolves with, a string or a value its type rules out, is sent - // as it is, so the call is still answered. - const reply = typeof result === 'object' && result !== null - ? { - op: 'tool_result', - id: callId, - result: result.text, - is_error: result.isError, - ...(result.structuredJson ? { structured_json: true } : {}), - } - : { op: 'tool_result', id: callId, result }; - this.reply(child, reply, `result of ${toolName} call ${callId}`); - }); - return; - } - - case 'wake': { - const wakeId = msg.id; - if (!wakeId) return; - const line = typeof (msg as { line?: unknown }).line === 'number' - ? (msg as { line: number }).line - : -1; - const payload = (msg as { payload?: unknown }).payload; - const handler = this.onWake; - const refuse = handler - ? handler(line, payload).catch((err: unknown) => - `wake handler failed: ${err instanceof Error ? err.message : String(err)}`) - : Promise.resolve('this script is not allowed to wake the agent'); - void refuse.then((error) => { - this.reply(child, { op: 'wake_ack', id: wakeId, ...(error ? { error } : {}) }, `ack of wake ${wakeId}`); - }); - return; - } - - case 'exec_result': { - const pending = this.pending; - if (pending && msg.id === pending.id && pending.returnCode === null) { - pending.returnCode = typeof msg.return_code === 'number' ? msg.return_code : 1; - this.settleIfComplete(child); - } - return; - } - - default: - this.log(`unknown protocol op: ${String(msg.op)}`); - } - } - - /** - * Settle the running exec once its script has reported and the end marks - * of both its output pipes have arrived, which says all of its output has. - */ - private settleIfComplete(child: ChildProcess): void { - const pending = this.pending; - if (child !== this.child || !pending || pending.returnCode === null) return; - if (!this.out?.marked || !this.err?.marked) return; - const stdout = this.out.take(); - const stderr = this.err.take(); - const journal = pending.journal; - if (journal) { - this.settlePending({ stdout: '', stderr: '', returnCode: pending.returnCode, tail: journal.tail }); - } else { - this.settlePending({ stdout, stderr, returnCode: pending.returnCode }); - } - } - - /** - * Settle the running exec now: what its script wrote so far, as far as it - * has reached the host, then `why` it stopped, on a line of its own. Output - * still in the pipes goes to the host's log. A script that reported before - * its interpreter ended keeps its own return code. Returns what the script - * wrote to stderr (a background script's tail), for the host's log. - */ - private stopPending(reason: string | ((said: string) => string)): string { - const pending = this.pending; - if (!pending || pending.settled) return ''; - pending.stopped = true; - const stdout = this.out?.take() ?? ''; - const stderr = this.err?.take() ?? ''; - const returnCode = pending.returnCode ?? 1; - const journal = pending.journal; - // The runtime's own lines are in a background script's journal. A foreground - // script's are in its stderr, unless it pointed fd 2 at stdout's pipe and took - // the runtime's copy of fd 2 (runtime-py.ts _diag): stderr goes last, as the - // one the runtime writes to first, since the last line that names a fd counts. - const why = typeof reason === 'string' ? reason : reason(journal ? journal.tail : `${stdout}\n${stderr}`); - if (journal) { - const tail = journal.tail; - // The journal says why too: in its file, which the agent reads with its - // own tools, when it has one that hasn't reached its cap or failed a - // write, and in its tail, which the result carries. - writeLine(journal, why); - this.settlePending({ stdout: '', stderr: why, returnCode, aborted: true, tail: journal.tail }); - return tail; - } - this.settlePending({ stdout, stderr: onItsOwnLine(stderr, why), returnCode, aborted: true }); - return stderr; - } - - private settlePending(result: ExecResult): void { - const pending = this.pending; - if (!pending || pending.settled) return; - pending.settled = true; - // Say why the script stopped: the in-script cancellation only reads "cancelled by host". - // Only a script that ended unsuccessfully was stopped: one that caught the cancellation - // and finished its work returns 0, and must not read as stopped. - if (pending.deadlineMs !== null && result.returnCode !== 0) { - const note = `script stopped: it reached its ${formatLimit(pending.deadlineMs)} time limit. ` - + `${limitRemedy(pending.deadlineMs, pending.ceilingMs, pending.background, pending.backgroundCeilingMs)}\n`; - // On a line of its own: a script's output, and the runner's own texts, such as an - // interpreter's exit, can end without a newline. - result = { ...result, stderr: onItsOwnLine(result.stderr, note) }; - // A background script's journal says so too: in its file, when it has one, - // and in its tail, which is the result's. - if (pending.journal) { - writeLine(pending.journal, note.trimEnd()); - result = { ...result, tail: pending.journal.tail }; - } - } - // Only now: what the stop wrote above is the journal's last. - pending.journal?.close(); - if (pending.deadlineTimer) clearTimeout(pending.deadlineTimer); - if (pending.killTimer) clearTimeout(pending.killTimer); - if (pending.interruptTimer) clearInterval(pending.interruptTimer); - this.pending = null; - pending.resolve(result); - } - - /** - * Answer the interpreter that asked. Call and wake ids restart in every - * interpreter, so an answer that outlives its interpreter (the script was - * aborted, killed or crashed, or the interpreter reclaimed) would resolve - * the same id in the one that replaced it, inside another script: drop it. - */ - private reply(child: ChildProcess, msg: unknown, what: string): void { - if (child !== this.child) { - this.log(`dropped late ${what}: the interpreter that asked for it is gone`); - return; - } - this.send(msg); - } - - private send(obj: unknown): void { - const child = this.child; - const input = this.protoIn; - const unreachable = this.unreachable; - if (!child || !input || !alive(child)) return; - try { - input.write(JSON.stringify(obj) + '\n', (err) => { - if (err) unreachable?.(err.message); - }); - } catch (err) { - this.log(`protocol write failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - /** - * SIGINT the interpreter: blocking code in the running script raises - * KeyboardInterrupt. Not on Windows, where Node's kill() ends the process - * outright; there the cancel op and the kill grace remain. - * process.kill rather than child.kill, which would mark the child `killed`: - * a killed child gets no more protocol messages and is respawned on the - * next exec, losing the interpreter state this keeps. - */ - private interruptChild(): void { - const child = this.child; - if (process.platform === 'win32' || !child || !alive(child)) return; - if (child.pid === undefined) return; - try { - process.kill(child.pid, 'SIGINT'); - } catch { - // already gone: the exit handler settles the exec - } - } - - /** Kill interpreter + clean temp dir. State is lost (by design). */ - private reclaim(reason: string): void { - if (this.child) { - this.log(`reclaiming python interpreter (${reason})`); - } - // An interpreter killed as it starts never says it's ready: fail its - // readiness now, so an exec waiting on it returns at once rather than at - // the 15 s timeout. Here rather than in teardownChild, which an - // interpreter's own exit also runs, so that one still reads "exited as - // it started". - this.readyFailer?.(new Error(`python runtime reclaimed (${reason}) before it was ready`)); - this.teardownChild(); - this.clearIdleTimer(); - } - - private teardownChild(): void { - if (this.reader) { - this.reader.close(); - this.reader = null; - } - const child = this.child; - if (child) { - const input = this.protoIn; - this.child = null; - this.childReady = null; - this.readyResolver = null; - this.readyFailer = null; - this.protoIn = null; - this.unreachable = null; - // Its output pipes stay read, into the host's log, until whatever still - // holds them, such as a command its script left running, closes them: - // breaking them under such a command could end it. They're read slower - // from now on, since nothing reads them for an exec again. - this.out?.retire(); - this.err?.retire(); - this.out = null; - this.err = null; - child.removeAllListeners('exit'); - try { - input?.end(); - } catch { - // stream may already be destroyed - } - // A child whose spawn failed has no pid, and its exitCode stays null - // until Node's 'error' for it, a tick later. A kill() in that tick, as - // from an abort or dispose on the tick exec was called, signals no - // child of ours but whatever pid Node's handle held: the host's own - // process group on Node 20.20.2, another process's pid on 24.19. - if (alive(child) && child.pid !== undefined) { - child.kill('SIGKILL'); - } - } - const dir = this.runtimeDir; - this.runtimeDir = null; - if (dir) { - const remove = (): void => { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - // temp cleanup is best-effort - } - }; - // The directory is the interpreter's working directory, which Windows - // won't remove while the process lives, so there it goes once the - // process has. Elsewhere it goes now, so a host that exits right after - // this doesn't leave it behind. A child that never started has no exit - // to wait for. - const running = child !== null && child.pid !== undefined && child.exitCode === null && child.signalCode === null; - if (running && process.platform === 'win32') child.once('exit', remove); - else remove(); - } - } - - private armIdleTimer(): void { - this.clearIdleTimer(); - if (this.idleReclaimMs <= 0 || !this.child) return; - this.idleTimer = setTimeout(() => { - if (!this.pending) this.reclaim('idle'); - }, this.idleReclaimMs); - // Do not hold the process open just to reclaim an idle interpreter. - this.idleTimer.unref?.(); - } - - private clearIdleTimer(): void { - if (this.idleTimer) { - clearTimeout(this.idleTimer); - this.idleTimer = null; - } - } -} - -/** - * Map framework tool names to python identifiers: `--` (the framework's - * prefix separator) becomes `__`, and every remaining character that is not - * valid in a python identifier becomes `_` (fleet reality: module and server - * ids contain single hyphens — `mcpl-admin`, `dog-events` — found live on - * the first canary run, 2026-07-26). A leading digit gets a `_` prefix. - * - * Names that sanitize to nothing, or that collide after sanitization, are - * skipped loudly — a skipped tool is unreachable from scripts (the - * exact-name tools[...] dict is built from this same list). - */ -export function buildInjectedTools( - toolNames: string[], - log: (message: string) => void = (m) => console.error(m), -): InjectedTool[] { - const seen = new Map(); - const injected: InjectedTool[] = []; - for (const toolName of toolNames) { - let pyName = toolName.replace(/--/g, '__').replace(/[^A-Za-z0-9_]/g, '_'); - if (/^[0-9]/.test(pyName)) pyName = '_' + pyName; - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(pyName)) { - log(`[pytc] tool '${toolName}' skipped: cannot derive a python identifier`); - continue; - } - const existing = seen.get(pyName); - if (existing) { - log(`[pytc] tool '${toolName}' skipped: python name '${pyName}' collides with '${existing}'`); - continue; - } - seen.set(pyName, toolName); - injected.push({ pyName, toolName }); - } - return injected; -} diff --git a/src/code-execution/quickjs.wasm b/src/code-execution/quickjs.wasm new file mode 100644 index 000000000..176904c10 Binary files /dev/null and b/src/code-execution/quickjs.wasm differ diff --git a/src/code-execution/quickjs.wasm.LICENSES b/src/code-execution/quickjs.wasm.LICENSES new file mode 100644 index 000000000..219cc90b7 --- /dev/null +++ b/src/code-execution/quickjs.wasm.LICENSES @@ -0,0 +1,149 @@ +quickjs.wasm, code_execution's engine, is built by engine/build.sh from agent-framework's own +engine/interface.c and the works below, whose notices it carries. + +===================================================================================================================================================== +QuickJS-ng v0.17.0 (commit 6d46d07d04041b40f4f49eaa7fdebe44c314c699, with any patch in engine/patches applied), https://github.com/quickjs-ng/quickjs +===================================================================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2017-2026 Fabrice Bellard +Copyright (c) 2017-2024 Charlie Gordon +Copyright (c) 2023-2026 Ben Noordhuis +Copyright (c) 2023-2026 Saúl Ibarra Corretgé + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +======================================================================================== +quickjs-wasi 3.6.2, https://github.com/vercel-labs/quickjs-wasi: the memory-limiting +allocator in engine/interface.c is adapted from its c/interface.c +======================================================================================== + +MIT License + +Copyright (c) 2026 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +======================================================================================== +wasi-libc, as wasi-sdk 32 ships it (wasi-libc 2fc32bc81b9f07f8d9525edea59bfbaf760c06d6), +https://github.com/WebAssembly/wasi-libc +======================================================================================== + +wasi-libc as a whole is multi-licensed under the Apache License v2.0 with LLVM Exceptions, +the Apache License v2.0, and the MIT License: + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +Portions of wasi-libc are derived from third-party works covered by their own licenses: + +--- musl (libc-top-half/musl) --- + +Copyright © 2005-2020 Rich Felker, et al. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- cloudlibc (libc-bottom-half/cloudlibc) --- + +Copyright (c) 2015-2017 Nuxi (https://nuxi.nl/) and contributors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + +--- dlmalloc (dlmalloc) --- + +Written by Doug Lea and released to the public domain (CC0). + +======================================================================================== +LLVM compiler-rt builtins, as wasi-sdk 32 ships them: Apache License v2.0 with LLVM +Exceptions, whose exception lets compiled code like this module carry no notice. +======================================================================================== diff --git a/src/code-execution/result-text.ts b/src/code-execution/result-text.ts deleted file mode 100644 index 231899070..000000000 --- a/src/code-execution/result-text.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * What the model reads when a foreground `code_execution` script ends. - * - * How the script ended comes first: its exit code, and the time-limit note - * when the call's limit was capped. stderr comes next, ahead of stdout: its - * end holds a traceback, and for a script the runner stopped, why it stopped, - * which the runner writes on a line of its own after the script's own stderr. - * Within a budget, each stream is cut to its head and its tail around a count - * of what was cut, and stderr keeps a share of the room of its own, so in any - * budget that holds a cut of each stream, however long stdout runs, it can't - * push the exit code or stderr's end out of view. - */ - -export interface ScriptEnd { - stdout: string; - stderr: string; - returnCode: number; - /** Set when the call's time_limit_ms was capped at the deployment's maximum. */ - timeLimitNote?: string; -} - -/** The whole text, nothing cut. */ -export function scriptEndText(end: ScriptEnd): string { - return heading(end) + section('stderr', end.stderr) + section('stdout', end.stdout); -} - -/** - * The text within `budget` characters. When the whole text is longer, stderr - * keeps up to a third of the room the streams have, or more when stdout needs - * less, and stdout takes the rest, each cut to its head and tail. stderr's - * tail keeps its last line whole when the line fits its room. Returns null - * when the streams' room is three cut markers or less, so that stderr's share, - * when stderr is cut, keeps some of its own text beside its count. - */ -export function scriptEndTextWithin(end: ScriptEnd, budget: number): string | null { - const whole = scriptEndText(end); - if (whole.length <= budget) return whole; - const { stderr, stdout } = end; - const marker = cutMarker(Math.max(stderr.length, stdout.length)).length; - const room = budget - heading(end).length - labelLength('stderr', stderr) - labelLength('stdout', stdout); - if (room <= 3 * marker) return null; - const stderrRoom = Math.min(stderr.length, Math.max(Math.ceil(room / 3), room - stdout.length)); - return heading(end) - + section('stderr', cutMiddle(stderr, stderrRoom, true)) - + section('stdout', cutMiddle(stdout, room - stderrRoom)); -} - -function heading(end: ScriptEnd): string { - return `exit code: ${end.returnCode}\n` + (end.timeLimitNote ? `time limit: ${end.timeLimitNote}\n` : ''); -} - -/** A stream under its label, ending in a newline; nothing for an empty stream. */ -function section(name: string, body: string): string { - if (body === '') return ''; - return `--- ${name} ---\n${body}${body.endsWith('\n') ? '' : '\n'}`; -} - -/** What a stream's section adds besides the stream itself, at most: its label and a closing newline. */ -function labelLength(name: string, body: string): number { - return body === '' ? 0 : `--- ${name} ---\n`.length + 1; -} - -/** The count of what was cut, on a line of its own. */ -function cutMarker(cut: number, afterLineEnd = false): string { - return `${afterLineEnd ? '' : '\n'}[… ${cut} characters cut …]\n`; -} - -/** - * `text` in at most `keep` characters: its head and its tail around a count of - * what was cut. Each cut falls at a line's end when one is near, within a - * quarter of the part it ends, so a part line doesn't read as a whole one; - * otherwise it falls mid-line, never inside a surrogate pair. With - * `keepLastLine`, the tail is at least the last line when that line fits. - * `keep` must hold the marker for a cut of `text.length`. - */ -function cutMiddle(text: string, keep: number, keepLastLine = false): string { - if (text.length <= keep) return text; - const left = keep - cutMarker(text.length).length; - let headLength = Math.floor(left / 2); - let tailLength = left - headLength; - if (keepLastLine) { - // The tail keeps the last line whole, taking from the head, when the - // line fits; a longer one keeps its end. Blank lines at the end, LF or - // CRLF, go with the line before them. - let contentEnd = text.length; - while (contentEnd > 0 && (text[contentEnd - 1] === '\n' || text[contentEnd - 1] === '\r')) contentEnd--; - const lastLine = text.length - (text.lastIndexOf('\n', contentEnd - 1) + 1); - if (lastLine > tailLength && lastLine <= left) { - tailLength = lastLine; - headLength = left - lastLine; - } - } - let headEnd = headLength; - const lineEnd = headEnd > 0 ? text.lastIndexOf('\n', headEnd - 1) : -1; - if (lineEnd >= 0 && headEnd - (lineEnd + 1) <= headLength / 4) headEnd = lineEnd + 1; - else if (headEnd > 0 && isHighSurrogate(text.charCodeAt(headEnd - 1))) headEnd--; - let tailStart = text.length - tailLength; - const lineStart = text.indexOf('\n', tailStart - 1) + 1; - if (lineStart > 0 && lineStart - tailStart <= tailLength / 4) tailStart = lineStart; - else if (tailStart < text.length && isLowSurrogate(text.charCodeAt(tailStart))) tailStart++; - const head = text.slice(0, headEnd); - return head + cutMarker(tailStart - headEnd, head === '' || head.endsWith('\n')) + text.slice(tailStart); -} - -function isHighSurrogate(code: number): boolean { - return code >= 0xd800 && code <= 0xdbff; -} - -function isLowSurrogate(code: number): boolean { - return code >= 0xdc00 && code <= 0xdfff; -} diff --git a/src/code-execution/runtime-py.ts b/src/code-execution/runtime-py.ts deleted file mode 100644 index 967fc619a..000000000 --- a/src/code-execution/runtime-py.ts +++ /dev/null @@ -1,1070 +0,0 @@ -/** - * Python runtime for client-side programmatic tool calling (PTC). - * - * This is the interpreter-side half of the `code_execution` tool: a small - * asyncio REPL that mimics the environment Anthropic's server-side PTC was - * trained on (see docs/en/agents-and-tools/tool-use/programmatic-tool-calling): - * - * - scripts run with top-level `await` (PyCF_ALLOW_TOP_LEVEL_AWAIT) - * - host tools are injected as async functions taking a single dict and - * returning the tool_result text as a str (ToolResult), which also - * carries .is_error and .structured - * - a pending tool call that gets no response raises TimeoutError with the - * same message shape the managed runtime produces - * - interpreter globals persist across execs (container-reuse semantics) - * - * BACKGROUND MODE (model-authored daemons): when init carries - * `background: true`, the exec additionally gets: - * - `wake_agent(payload)` — async; reports the caller's line number (which - * indexes into the agent-authored script — self-correlation) and delivers - * the payload to the host, which injects it into the agent's context and - * triggers inference. Awaits a host ack so rate limiting backpressures - * inside the script instead of dropping wakes. - * - its stderr joined to its stdout (fd 2 onto fd 1's pipe), so the host's - * journal of its output (a log file the agent can read, and a tail for the - * crash envelope) holds their lines in the order they were written. The - * runtime's own lines and faulthandler's traceback go to that pipe too, so - * a crash's text comes after the lines the script wrote before it; a - * partial line still in a script's writer is lost with the process. - * - * OUTPUT. The protocol has file descriptors of its own: the host opens the - * runtime's fds 3 (host -> py) and 4 (py -> host), which the runtime makes - * non-inheritable as it starts, so no command a script runs is given them. fd 0 - * is /dev/null, and fds 1 and 2 are pipes the host reads itself: a script's - * prints, line by line, and the output of every command it runs go there in - * the order written, and what reaches them reaches the host whatever then - * happens to the interpreter. A partial line waits in the script's writer for - * its newline, a flush or the exec's end, as Python's stdout does on a - * terminal, and a command writing at the same moment as a long write can land - * between its parts. The host gives each exec what arrived on each stream - * before that exec's end mark (the exec op's `mark`), which the runtime writes - * into both streams once the script has returned; each stream is first in, - * first out, so what the script and the commands it waited for wrote is ahead - * of it. Nothing a script or a command does with fds 0, 1 or 2 reaches the - * protocol, short of pointing them at the runtime's channel, fds 3 and 4, - * which can garble its messages while the script runs and, through a command - * started holding the channel, for as long as that command runs. A garbled - * exec_result leaves its script waiting out its time limit and the grace, and - * the interpreter is restarted, so the next script's variables are gone. The - * runtime puts fds 0, 1 and 2 back as the host set them, blocking, at each - * exec's start, and puts back fd 1 or 2 pointed at the channel at the script's - * end too, before anything is flushed (_off_channel). Its own fds (3 and 4, and its copies of 0, 1 and 2) it - * checks at each exec's start and end, and fd 4 before each message: one a - * script closed or replaced ends the interpreter, saying which - * (_check_own_fds). So does a message or an end mark that can't be written, as - * after a script shuts down the socket under fd 4 or an output pipe, which - * leaves the fd as it was (send, _write_marks). - * - * Embedded as a TS string because tsc does not copy non-TS assets to dist/; - * PyRunner materializes it to a temp file at spawn. - * - * Protocol (JSON lines, on fds 3 and 4): - * host -> py : {op:"init", tools:[{py_name,tool_name}], call_timeout_s, - * background?} - * {op:"exec", id, code, mark} - * {op:"tool_result", id, result, is_error?, structured_json?} - * {op:"wake_ack", id, error?} - * {op:"cancel", id, reason?} - * {op:"exit"} stops the runtime: a running script is - * cancelled at its next await, one blocked in - * synchronous code runs on until then (a host - * that can't wait sends SIGINT, as at a time - * limit), and it exits 0. It goes alone on a - * line under 100 characters, as {"op":"exit"} - * does: the reader thread looks for it only in - * such lines. A host that closes fd 3 instead - * while a script runs gets exit code 70 and the - * line "cannot read the host's messages any - * more: a script closed, shut down or replaced - * fd 3, where they arrive", since the runtime - * can't tell that from a script's own shutdown - * of the socket under fd 3. - * py -> host : {op:"ready"} - * {op:"tool_call", id, exec_id, name, args} - * {op:"wake", id, exec_id, line, payload} - * {op:"exec_result", id, return_code} - * - * At a deadline the host sends cancel and then SIGINT, repeated until the - * script reports (not on Windows): the cancel stops a script waiting on an - * await, the signal also one stuck in blocking code (see _on_sigint). - * - * IMPORTANT: the python source below must not contain backticks or the - * sequence dollar+brace (TS template literal syntax). String.raw preserves - * backslashes, so \n inside python string literals is fine. - */ - -export const PYTHON_RUNTIME_SOURCE: string = String.raw` -import ast -import asyncio -import faulthandler -import functools -import inspect -import io -import json -import os -import re -import select -import signal -import stat -import sys -import threading -import traceback -import weakref - -CALL_TIMEOUT_S = 270.0 -BACKGROUND = False - -# A process a script forks has the runtime's state too; at its script's end -# it leaves, since the exec and its result are the runtime's (_run_script). -_RUNTIME_PID = os.getpid() - -# The protocol's own channel, fds 3 and 4 as the host opened them (_start). -PROTO_IN = None -PROTO_OUT = None - -# Private copies of fds 0, 1 and 2 as the host set them up: /dev/null, and the -# two output pipes the host reads (_take_std_fds). Each exec puts fds 0, 1 and 2 -# back from them, whatever an earlier script did with its own, and writes its -# end marks through them. os.dup's copies are non-inheritable, so no command a -# script runs holds them. -_STD_IN = None -_STD_OUT = None -_STD_ERR = None - -# The runtime's own fds, as it found them: fd -> (what losing it stops, which -# fd it is, what it was). A script can close or replace any of them (os.close, -# os.dup2), so each exec's start and end checks them (_check_own_fds). -_OWN_FDS = {} - -# Every writer the runtime gave a script, which a script can keep past its -# exec (a logging handler keeps sys.stderr): each exec's end flushes them all, -# so a partial line goes out in the exec that wrote it. -_script_writers = weakref.WeakSet() - -_send_lock = threading.Lock() - -# A str can hold a lone surrogate, which UTF-8 can't encode: on POSIX, -# os.fsdecode makes one of each byte in a filename that doesn't decode as -# UTF-8, and json.loads makes one of a "\ud800" escape. The protocol's channel -# is strict UTF-8 (_start), so writing one would fail the whole message, and a -# tool call or a wake carrying one would never reach the host. Each goes as -# U+FFFD. What a script prints shows it escaped, as backslashreplace writes -# it (_script_writer). -_LONE_SURROGATE = re.compile(r"[\ud800-\udfff]") - - -def send(obj): - line = _LONE_SURROGATE.sub("\ufffd", json.dumps(obj, ensure_ascii=False, default=str)) - with _send_lock: - # A script can close or replace fd 4 before a tool call, as well as - # before its end, so each message checks it first. - _check_own_fd(4) - try: - PROTO_OUT.write(line + "\n") - PROTO_OUT.flush() - except OSError as exc: - # fd 4 is still the host's, so the socket under it broke: a script - # shut it down, or the host's end is gone. Nothing more reaches it. - _fatal(_lost(4) + " (" + str(exc) + ")") - - -def _start(): - global PROTO_IN, PROTO_OUT - # A wrapper between the host and this interpreter can close fds 3 and 4 - # (a container, or sudo with its default settings, does): say so where the - # host reads, for a failed start that names what to fix, and stop with - # EX_CONFIG (78), which tells the host that calling again won't help - # (HOST_CHANNEL_MISSING_EXIT in py-runner.ts). - missing = [] - for fd in (3, 4): - try: - os.fstat(fd) - except OSError: - missing.append(fd) - if missing: - which = ("its file descriptors 3 and 4 aren't open" if len(missing) == 2 - else "its file descriptor " + str(missing[0]) + " isn't open") - try: - os.write(2, ( - "The host's channel didn't reach this Python interpreter: " + which + ", as when the " - "host's pythonPath starts Python through a wrapper that doesn't pass both file descriptors " - "3 and 4 on, such as a container, or sudo with its default settings. code_execution can't " - "run scripts here until whoever runs this host changes that, so calling it again fails " - "the same way.\n" - ).encode()) - except OSError: - pass - sys.exit(78) - # fds 3 and 4 came from the host inheritable; no command a script runs may - # hold them, or read the host's messages. - os.set_inheritable(3, False) - os.set_inheritable(4, False) - _own(3, "cannot read the host's messages any more", "fd 3, where they arrive") - _own(4, "cannot send the host a message any more", "fd 4, where they go") - PROTO_IN = open(3, "r", encoding="utf-8", newline="\n") - PROTO_OUT = open(4, "w", encoding="utf-8", newline="\n") - # Between scripts, a thread a script left running prints through these: - # a lone surrogate is escaped there, as in a script's own writers, rather - # than raising in the thread (PYTHONIOENCODING names no handler). - for stream in (sys.stdout, sys.stderr): - try: - stream.reconfigure(errors="backslashreplace") - except Exception: - pass - _take_std_fds() - - -def _take_std_fds(): - global _STD_IN, _STD_OUT, _STD_ERR - _STD_IN, _STD_OUT, _STD_ERR = os.dup(0), os.dup(1), os.dup(2) - _own(_STD_IN, "cannot give scripts their stdin any more", "the runtime's copy of fd 0") - _own(_STD_OUT, "cannot reach the output pipes any more", "the runtime's copy of fd 1") - _own(_STD_ERR, "cannot reach the output pipes any more", "the runtime's copy of fd 2") - _enable_faulthandler(_STD_ERR) - - -def _enable_faulthandler(fd): - # A crash's own text goes where the host reads it, a pipe that outlives the - # process: faulthandler adds a traceback for a segfault or an abort, where - # CPython alone writes nothing. Python 3.14 adds the C stack after it, - # native frames a script can't act on, which would push the error's own - # line out of the end of it that the host logs, so it's left out. - faulthandler.enable(file=fd, all_threads=False, - **({"c_stack": False} if sys.version_info >= (3, 14) else {})) - - -def _identity(fd): - # Which file the fd is: its type, device and inode. Not its whole mode: - # macOS's fstat sets a socket's permission bits from its state, so they - # change when its write side is shut down or its peer ends it, as when a - # host ends fd 3 after the exit op, while the fd is still the runtime's own. - st = os.fstat(fd) - return (stat.S_IFMT(st.st_mode), st.st_dev, st.st_ino) - - -def _own(fd, stops, which): - _OWN_FDS[fd] = (stops, which, _identity(fd)) - - -def _lost(fd): - # The runtime's line for one of its own fds that it can't use any more, in - # the words the host's advice reads the fd from (fdTakenFromRuntime in - # py-runner.ts): a script closed or replaced it, or shut down the socket - # under it, which only a failed write shows. - stops, which, _ = _OWN_FDS[fd] - return stops + ": a script closed, shut down or replaced " + which - - -def _check_own_fd(fd): - # A script closed or replaced one of the runtime's own fds: say which and - # end, before the runtime writes to a file that isn't its own any more. - own = _OWN_FDS.get(fd) - if own is None: - return - try: - same = _identity(fd) == own[2] - except OSError: - same = False - if not same: - _fatal(_lost(fd)) - - -def _check_own_fds(): - for fd in list(_OWN_FDS): - _check_own_fd(fd) - - -def _diag(text): - # A note of the runtime's own for the host's log, through fd 2's copy or, - # if a script closed or replaced that, fd 2 itself, and only while it's - # one of the two outputs the host gave the runtime, as the runtime found - # them at its start: a note never goes into a file a script put in its - # place. A background script's journal is one stream, stdout's pipe, so - # there the note goes through fd 1's copy first, after the lines the - # script wrote before it. It waits for room on a pipe a command left non-blocking, - # as the marks do (_write_some). - data = ("[pytc-runtime] " + text + "\n").encode("utf-8", "backslashreplace") - pipes = [_OWN_FDS[fd][2] for fd in (_STD_OUT, _STD_ERR) if fd in _OWN_FDS] - for fd in ((_STD_OUT, 2, _STD_ERR) if BACKGROUND else (_STD_ERR, 2)): - try: - if fd is not None and _identity(fd) in pipes: - _write_all(fd, data) - return - except OSError: - pass - - -def _write_some(fd, data): - # A command can leave the output pipes non-blocking: they're one open file - # with the runtime's fds 1 and 2 and its copies. A full pipe then waits for - # room, where a bare write would raise BlockingIOError. - while True: - try: - return os.write(fd, data) - except BlockingIOError: - select.select([], [fd], []) - - -def _write_all(fd, data): - while data: - data = data[_write_some(fd, data):] - - -class _FdWriter(io.RawIOBase): - # A script's fd 1 or 2 by number (_write_some), which closing never closes. - def __init__(self, fd): - super().__init__() - self._fd = fd - # Set while _drop_held empties the writer above it. - self.dropping = False - - def writable(self): - return True - - def fileno(self): - return self._fd - - def write(self, data): - if self.dropping: - return len(data) - return _write_some(self._fd, data) - - -def _script_writer(fd): - # Line-buffered, as Python's own stdout is on a terminal: each line reaches - # the host as it's printed, in order with what the commands a script runs - # write, for one write per line. A partial line waits for its newline, a - # flush or the end of the exec. It writes to the fd by number, so one an - # earlier script kept, such as a logging handler's, writes into the exec - # that's running. - writer = io.TextIOWrapper(io.BufferedWriter(_FdWriter(fd)), encoding="utf-8", - errors="backslashreplace", newline="\n", line_buffering=True) - _script_writers.add(writer) - return writer - - -def _open_streams(): - # fds 0, 1 and 2 as the host set them, whatever the last script did. - os.dup2(_STD_IN, 0) - os.dup2(_STD_OUT, 1) - # A background script's journal is one stream: its stderr joins its stdout's - # pipe, so their lines keep the order they were written in. - os.dup2(_STD_OUT if BACKGROUND else _STD_ERR, 2) - # And blocking, as the host set them: O_NONBLOCK belongs to a pipe's open - # file, which dup2 doesn't touch, so one a command set would reach every - # later script and command. - os.set_blocking(_STD_OUT, True) - os.set_blocking(_STD_ERR, True) - return _script_writer(1), _script_writer(2) - - -def _off_channel(): - # A script can point its fd 1 or 2 at the runtime's own channel, as with - # os.dup2(4, 1). What its writers still hold would then go into the channel - # ahead of the script's result, which the host couldn't read, and the exec - # would wait out its time limit. So at the script's end, before anything is - # flushed, one that is fd 3 or fd 4 goes back to its pipe, as each exec's - # start puts it (_open_streams). What a script or its commands write onto - # the channel, or read from it, can still garble the runtime's messages: the - # script's own while it runs, and, from a command it started holding the - # channel, a later script's for as long as that command runs. A script - # whose exec_result is garbled waits out its time limit and the grace, and - # the interpreter is restarted. Only a channel per script would end that. - # A copy a script closed or replaced is left alone, so nothing goes into - # a file of the script's: the check at the exec's end ends the runtime. - channel = [_OWN_FDS[fd][2] for fd in (3, 4) if fd in _OWN_FDS] - for fd, pipe in ((1, _STD_OUT), (2, _STD_OUT if BACKGROUND else _STD_ERR)): - try: - if _identity(fd) in channel and _identity(pipe) == _OWN_FDS[pipe][2]: - os.dup2(pipe, fd) - except OSError: - pass - - -def _note(stream, text): - # The runtime's own line in a script's stream, which may have broken under - # it, or whose write the script may have replaced with its own, which may - # raise anything, SystemExit included: the line then goes through the - # runtime's own note (_diag), and the exec's end goes on. - try: - stream.write(text) - except BaseException: - _diag(text.strip("\n")) - - -def _flush_writers(): - # Send what the script left in its writers, the ones it kept from earlier - # execs too, at the exec's end and before a fork. A script can put its own - # code in one, as by replacing its flush, which may raise anything, - # SystemExit included: what it raises is said, but for the ValueError - # _flush takes as a closed writer, and the exec's end or the fork goes on, - # as for a script's own object (_flush_script_streams). - for writer in list(_script_writers): - try: - _flush(writer) - except BaseException as exc: - _diag("some of what the script wrote couldn't be sent (" + _said(exc) + ")") - - -def _flush_script_streams(): - # What a script left in a wrapper of its own that it put in sys.stdout or - # sys.stderr goes out in its exec, as Python flushes them at exit. The - # runtime's own writers are _flush_writers', and a wrapper the script - # keeps anywhere else is its own. Compared by identity: the weak set's - # membership test would hash the object, which a script's may refuse. - writers = list(_script_writers) - for stream in (sys.stdout, sys.stderr): - # A script's own object runs its own code here, which may raise - # anything, SystemExit included: it's said, and the exec's end goes on. - # A closed one's ValueError is passed over, as _flush passes over the - # runtime's own: closing it flushed what it held. - try: - if any(stream is w for w in writers) or not callable(getattr(stream, "flush", None)): - continue - _flush(stream) - except BaseException as exc: - _diag("some of what the script wrote couldn't be sent (" + _said(exc) + ")") - - -def _said(exc): - # An exception's text, or its type's name when it has none, as SystemExit() - # hasn't, or when a script's own exception class refuses to give it. None - # of the script's code runs past the try: the text is taken as an exact str, - # since a str subclass's own methods run on truth and concatenation, and the - # name comes through type's own descriptor, as _brief's does, so a - # metaclass's doesn't run, and as an exact str too, since a script can set - # a class's __name__ to a str subclass. - try: - text = str.__str__(str(exc)) - except BaseException: - text = "" - return text or str.__str__(type.__dict__["__name__"].__get__(type(exc))) - - -def _flush(writer): - try: - writer.flush() - except ValueError: - # A script closed or detached it, or the stream under it: nothing more - # goes through it. A script that closed or detached the writer itself - # flushed it then, and saw any failure of that flush; one that closed - # the stream under it dropped what the writer held, which can't be told - # from nothing here. A ValueError from a flush the script put in its - # place is taken the same way. - return - except BaseException as exc: - # Its fd closed or broke under it, or the script's own flush in its place - # raised, so that much can't be sent. Dropped, - # it can't go out later, once the fd is back, in another exec's output, - # where a writer the script kept would flush it: a writer over the - # runtime's raw writer is (_drop_held), and what a file of the script's - # own holds stays its own, as with any file a script keeps. - _drop_held(writer) - _diag("some of what the script wrote couldn't be sent (" + _said(exc) + ")") - - -def _drop_held(writer): - # Empty a writer whose flush failed, and leave it usable: one of the - # runtime's, or a script's own wrapper over the runtime's raw writer, - # through the runtime's buffer, as TextIOWrapper(sys.stdout.detach()) - # makes, or through a buffer of its own. Emptying one writer leaves - # another wrapper's own buffer as it is; one that shares this writer's - # buffer, as TextIOWrapper(sys.stdout.buffer) does, loses with it what it - # put there, under the same note. - raw = getattr(getattr(writer, "buffer", None), "raw", None) - if not isinstance(raw, _FdWriter): - return - # Through the type's own flush, since a script may have put a flush of - # its own on the writer, which is what failed. - raw.dropping = True - try: - type(writer).flush(writer) - finally: - raw.dropping = False - - -def _before_fork(): - _flush_script_streams() - _flush_writers() - - -if hasattr(os, "register_at_fork"): - # A fork copies what the script's writers hold, and what its own wrappers - # in sys.stdout and sys.stderr hold. Sent first, it reaches the host once, - # and a forked child's writers hold only what the child writes. A wrapper - # the script keeps anywhere else is its own, at a fork as at its end, as - # any buffered file a Python program forks with is. - os.register_at_fork(before=_before_fork) - - -def _write_marks(mark): - # End this exec's share of each stream with its mark: everything the - # script wrote, and all of every command it waited for, is ahead of it. - # A mark that can't be written, on a fd the check before it passed, as - # after a script shut down the socket under fd 1 or 2, ends the - # interpreter, which settles the exec rather than leave it waiting out - # its time limit for the mark. - # The stream the runtime's notes go to gets its mark last, so the line - # _fatal writes when the other's can't be written is still in this exec's - # share: stderr's, or a background script's journal, stdout's (_diag). - data = mark.encode("utf-8") - for fd in ((_STD_ERR, _STD_OUT) if BACKGROUND else (_STD_OUT, _STD_ERR)): - try: - _write_all(fd, data) - except OSError as exc: - _fatal(_lost(fd) + " (" + str(exc) + ")") - - -def _fatal(why): - # The runtime can't go on, as when a script closed one of the runtime's own - # fds under it: say why through its copy of fd 2, which the host reads - # unless a script replaced that too, or shut down the socket under it - # (_diag), and end, so the host settles - # the exec on the exit with the output that arrived, rather than waiting - # out its time limit for a result that can't come. - _diag(why + "; exiting") - os._exit(70) - - -# Persistent interpreter state (mirrors container reuse: variables survive -# between exec calls until the host reclaims the interpreter). -SCRIPT_GLOBALS = {"__name__": "__main__", "__builtins__": __builtins__} -_injected_py_names = set() - -_pending_tool_futures = {} -_pending_wake_futures = {} -_next_tool_call_id = 0 -_next_wake_id = 0 -_current_exec_id = None -_current_exec_task = None - -# How the host stops a script (its time limit, or an abort): ONE per-script -# state, so the script is stopped exactly once, by whichever path gets there -# first. Every other path then does nothing to that script. -# PENDING -> nothing asked yet. -# REQUESTED -> asked while the script was outside its own code (waiting on -# an await, writing a protocol line, or not started yet). A -# cancellation is queued on the loop, or delivered as the -# script starts. -# DELIVERED -> KeyboardInterrupt raised in its code, or its task cancelled. -# Final: whatever the script does next (except/finally, awaits -# in its cleanup) runs to completion or to the host's kill. -# Transitions happen only in the main thread: in the SIGINT handler (between -# bytecodes, so it never cancels anything itself -- see _on_sigint) and in -# loop callbacks. Neither can interleave a transition of the other halfway -# in a way that matters: the handler only moves PENDING to REQUESTED or, with -# the script's own frame on the stack, to DELIVERED; a loop callback never -# runs while the script's frame is on the stack. -# -# Where the signal (or the cancel op) can land, and what happens: -# 1. script's own code, running or blocking -> KeyboardInterrupt there -# 2. script waiting on an await (loop idle) -> cancellation queued; it wakes -# the loop and lands at that await -# 3. main() mid-dispatch, between the done() check of a future the script -# awaits and its set_result (reply behind the cancellation) -> queued; -# set_result completes; the script gets CancelledError instead of it -# 4. the script writing a protocol line (send) -> queued; the line stays -# whole; the cancellation lands at the script's next await -# 5. reply ahead of the queued cancellation, script then awaits -> the -# cancellation lands at that await -# 6. reply ahead, script then blocks -> the next signal (the host repeats -# it) raises KeyboardInterrupt there; the queued cancellation then does -# nothing, so cleanup that awaits is not cut short -# 7. reply ahead, script finishes before the queued cancellation runs -> -# it does nothing, and never reaches the next script -# 8. script not started yet (signal, or the cancel op) -> delivered as -# CancelledError when it starts, so it still reports -# 9. cancel op before the signal -> delivered; the signal and its repeats -# do nothing, so cleanup after a caught CancelledError runs on -# 10. cancel op or repeated signal after delivery -> nothing -# 11. script finished, reporting its result -> nothing -# 12. between scripts -> nothing -# 13. a previous script installed an eager task factory -> no effect: the -# runtime builds its script task directly, and the state is in place -# before the script's first step -# 14. the flush before a fork the script makes -> queued, as in 4; the fork -# goes on, and the cancellation lands at the script's next await, or the -# next signal stops a script that blocks, as in 6 -_PENDING = "pending" -_REQUESTED = "requested" -_DELIVERED = "delivered" - - -class _ScriptStop: - __slots__ = ("task", "running", "state", "interrupt") - - def __init__(self): - self.task = None - self.running = False # the script's own code may be on the stack - self.state = _PENDING - self.interrupt = None # the KeyboardInterrupt raised in its code - - -_stop = None # the running script's _ScriptStop; None between scripts - - -def _deliver_cancel(st): - # Loop context only: the cancel op in main()'s dispatch, or queued by the - # SIGINT handler. Never from inside the handler, where a cancellation - # could land between main()'s done() check of a future the script awaits - # and its set_result. - if st is None or st is not _stop or st.state == _DELIVERED: - return - if not st.running: - st.state = _REQUESTED # not started yet: delivered as it starts - return - st.state = _DELIVERED - st.task.cancel() - - -class ToolResult(str): - """A tool's result: the string the tool gave, with two attributes. - - is_error: True if the result is an error, False if not, None if the - host didn't say. - structured: the parsed structuredContent of a structured result's JSON - object, or None. - """ - - # Defaults, since pickle and copy rebuild a str subclass from its text - # alone and then restore the attributes. - def __new__(cls, text="", is_error=None, structured_json=False): - self = str.__new__(cls, text) - self.is_error = is_error - self._structured_json = structured_json - return self - - # Read from the string itself, the one copy sent, on first use only. - @functools.cached_property - def structured(self): - if not self._structured_json: - return None - return json.loads(self).get("structuredContent") - - -def _resolve(fut, value): - # Defense in depth: the script may have been cancelled since the lookup. - if fut is not None and not fut.done(): - try: - fut.set_result(value) - except asyncio.InvalidStateError: - pass - - -def _brief(value): - # A value as a refusal shows it: by its type, such as , which fails - # loudly if copied rather than sending something else. The agent has its - # values in the call it just wrote. No value's own code runs here: its - # __repr__ isn't called, and its type is named through type's own - # descriptor, so a metaclass's __name__ doesn't run either. - return "<" + type.__dict__["__name__"].__get__(type(value)) + ">" - - -def _make_tool_fn(tool_name, py_name): - async def tool_fn(*positional, **kwargs): - global _next_tool_call_id - if len(positional) > 1: - raise TypeError( - py_name + " wasn't called: a tool function takes one dict of " - "arguments, such as await " + py_name + "({...}), and this call " - "gave it " + str(len(positional)) + " positional arguments" - ) - # args= on its own is the dict, as it has always been. Beside other - # keywords it's one of them, since a tool such as utils has a field - # of that name, so the suggested dict keeps it. - if positional: - args = positional[0] - elif list(kwargs) == ["args"]: - args = kwargs.pop("args") - else: - args = None - if kwargs: - if args is not None and not isinstance(args, dict): - raise TypeError( - py_name + " wasn't called: a tool function takes one dict of " - "arguments, not keyword arguments, and this call also gave it " - + _brief(args) + ", so put every argument in one dict: await " - + py_name + "({...})" - ) - # The likely intent is plain: the same arguments in one dict. - meant = dict(args) if isinstance(args, dict) else {} - meant.update(kwargs) - # A key is what the agent has to write, so a str key is shown - # whole up to 60 characters, as names are elsewhere; a longer one, - # or any other key, is shown as a value is. A tool's result counts - # as a str: ToolResult keeps str's repr, so no script code runs. - shown = ", ".join((repr(k) if (type(k) is str or type(k) is ToolResult) and len(k) <= 60 else _brief(k)) + ": " + _brief(v) - for k, v in meant.items()) - raise TypeError( - py_name + " wasn't called: a tool function takes one dict of " - "arguments, not keyword arguments, so write await " - + py_name + "({" + shown + "})" - ) - if args is None: - args = {} - if not isinstance(args, dict): - raise TypeError( - py_name + " wasn't called: a tool function takes one dict of " - "arguments, such as await " + py_name + "({...}), and this call " - "gave it " + _brief(args) - ) - _next_tool_call_id += 1 - call_id = "t" + str(_next_tool_call_id) - fut = asyncio.get_running_loop().create_future() - _pending_tool_futures[call_id] = fut - send({ - "op": "tool_call", - "id": call_id, - "exec_id": _current_exec_id, - "name": tool_name, - "args": args, - }) - try: - return await asyncio.wait_for(fut, timeout=CALL_TIMEOUT_S) - except asyncio.TimeoutError: - raise TimeoutError( - "Calling tool ['" + tool_name + "'] timed out (no response after " - + str(int(CALL_TIMEOUT_S)) + "s)." - ) - finally: - _pending_tool_futures.pop(call_id, None) - tool_fn.__name__ = py_name - tool_fn.__qualname__ = py_name - tool_fn.__doc__ = ( - "Host tool '" + tool_name + "'. Takes a single dict of arguments and " - "returns the tool result as a string, with .is_error and .structured " - "beside it." - ) - return tool_fn - - -async def wake_agent(payload=None): - """Wake the agent: deliver payload into their context and trigger a turn. - - Reports the caller's line number (indexes into the script the agent - wrote). Awaits the host ack — rate limiting backpressures here rather - than dropping wakes. Raises RuntimeError when the host refuses (wake cap - exceeded, script cancelled). - """ - global _next_wake_id - frame = inspect.currentframe() - line = frame.f_back.f_lineno if frame is not None and frame.f_back is not None else -1 - _next_wake_id += 1 - wake_id = "w" + str(_next_wake_id) - fut = asyncio.get_running_loop().create_future() - _pending_wake_futures[wake_id] = fut - send({ - "op": "wake", - "id": wake_id, - "exec_id": _current_exec_id, - "line": line, - "payload": payload, - }) - try: - error = await fut - if error: - raise RuntimeError("wake_agent refused by host: " + str(error)) - return None - finally: - _pending_wake_futures.pop(wake_id, None) - - -def handle_init(msg): - global CALL_TIMEOUT_S, BACKGROUND - timeout = msg.get("call_timeout_s") - if isinstance(timeout, (int, float)) and timeout > 0: - CALL_TIMEOUT_S = float(timeout) - BACKGROUND = bool(msg.get("background")) - # A background script's stderr joins its stdout's pipe (_open_streams), so - # a crash's traceback goes there too, after the lines the script wrote first. - _enable_faulthandler(_STD_OUT if BACKGROUND else _STD_ERR) - - # Remove functions injected by a previous init that are no longer present - # (tool list changed) without touching user-defined globals. - new_tools = msg.get("tools") or [] - new_py_names = set() - tools_dict = {} - for entry in new_tools: - py_name = entry.get("py_name") - tool_name = entry.get("tool_name") - if not py_name or not tool_name: - continue - fn = _make_tool_fn(tool_name, py_name) - SCRIPT_GLOBALS[py_name] = fn - tools_dict[tool_name] = fn - new_py_names.add(py_name) - for stale in _injected_py_names - new_py_names: - SCRIPT_GLOBALS.pop(stale, None) - _injected_py_names.clear() - _injected_py_names.update(new_py_names) - # Exact-name escape hatch: tools["mcpl--server--tool"]({...}) - SCRIPT_GLOBALS["tools"] = tools_dict - # The doorbell exists only in background mode — a foreground script's - # agent is already awake (mid-turn). - if BACKGROUND: - SCRIPT_GLOBALS["wake_agent"] = wake_agent - else: - SCRIPT_GLOBALS.pop("wake_agent", None) - - -def _leave_forked(code, err): - # A process the script forked ends on sys.exit() as Python ends: with its - # code, 0 for none, or 1 after writing a code that isn't a number, and with - # what it wrote sent. The atexit handlers are the parent's, so it skips - # them, as multiprocessing's children do. - if code is None: - status = 0 - elif isinstance(code, int): - status = code & 0xFF - else: - status = 1 - try: - text = str(code) + "\n" - except BaseException: - text = "" - _note(err, text) - _flush_script_streams() - _flush_writers() - os._exit(status) - - -async def _run_script(exec_id, code, mark, st): - global _current_exec_id, _current_exec_task, _stop - _check_own_fds() - out, err = _open_streams() - old_out, old_err, old_in = sys.stdout, sys.stderr, sys.stdin - sys.stdout, sys.stderr = out, err - sys.stdin = io.StringIO("") # input() ends at once, as fd 0's /dev/null does for a command - return_code = 0 - try: - compiled = compile(code, "