Repository navigation
fix(code-execution): the advice after a signal fits the signal: a crash's points at native code, and only SIGKILL keeps the out-of-memory example - #596
Merged
nissa-seru merged 4 commits intoOct 19, 2026
Conversation
…sh's points at native code, and only SIGKILL keeps the out-of-memory example When a signal ended the Python interpreter under a script, the result's advice gave "by using too much memory" as its example of the script ending the process, whatever the signal. A crash's signal, one of the five faulthandler writes a traceback for (SIGSEGV, SIGABRT, SIGBUS, SIGFPE and SIGILL), now gets "check the traceback above for native code that crashed, such as an extension module the script uses". SIGKILL, which the out-of-memory killer sends, keeps the memory example, and any other signal gets os.kill() as its example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en the result holds it, and names the call that crashed in native code Answers an internal review of the change before it. - After a crash's signal, interpreterExit reads what the result keeps for faulthandler's first words, "Fatal Python error: ". With them, it says "check the traceback above for the call that crashed in native code, such as an extension module the script uses", since the traceback shows Python frames, the call into native code among them. Without them, as when a script disabled faulthandler, a stream reached its cap or a journal's tail lost them, it says "check whether native code the script calls crashes, such as an extension module it uses". - The comment no longer says a crash's signal more often comes from native code: the traceback shows the call either way, a script's own os.abort() included. Tests: each crash case checks that faulthandler's traceback is in the result, SIGBUS, SIGFPE and SIGILL included; a segfault after faulthandler.disable() gets the advice that names native code alone; and the test helper's example for a signal other than SIGKILL is os.kill(), as the code's is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l gets the advice that points at its traceback faulthandler doesn't start a line of its own: after output that didn't end one, such as a progress bar's, its first words, "Fatal Python error: ", follow on that line. interpreterExit looks for them anywhere in what the result keeps, and its comment now says why: anchored at a line's start, the match would give such a crash the advice for one whose traceback didn't reach the result. Tests: the signal-advice test gains a segfault after a progress bar's line written to stderr and not ended, and checks where faulthandler's traceback begins in each case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…writes the signal's advice as the code does exitedAfterEnd built the advice of an interpreter that exits after its script's result with "by using too much memory" for every signal, the text the code wrote before the advice came to fit the signal. It now takes the same default as exitedUnder, the memory example for SIGKILL alone and os.kill() for any other signal, and the same optional check for a crash's signal, so both helpers model the one function. Its uses all pass an exit code, so no test's expectation changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 19, 2026
Closed
nissa-seru
pushed a commit
to rufalupa666-netizen/agent-framework
that referenced
this pull request
Oct 20, 2026
Main has moved since the last merge (037aa79), by 26 pull requests, among them anima-research#589 (the channel tools and speech say what happened), anima-research#597 (publishForAgent's refusal says why), anima-research#595 (channel_decline's status), anima-research#601, anima-research#568 and anima-research#594 (channel opens and retargets), anima-research#495, anima-research#596 and anima-research#599 (code_execution's channel and how a script's ending reads), anima-research#617 (a sleeping agent wakes for the host's heartbeat server) and anima-research#616 (stop() ends each live conversation fork). Two files conflict: - src/mcpl/channel-registry.ts: routeSpeech's no-locus branch. Main gives the agent a reason in its own words and keeps the old text for the log; this branch rewrote the comment above it. Both are kept. - test/event-gate.test.ts: both sides append a describe block after the quiesce tests. Both blocks are kept, this branch's first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
that referenced
this pull request
Oct 21, 2026
… and the Python runner goes
code_execution now runs each script in the isolate: JavaScript in
QuickJS-ng compiled to WebAssembly, in a process of its own, on the host's
own runtime. A script reaches only its agent's tools. One kind of script
replaces foreground and background ones: it runs in the agent's turn,
yields with its output so far past yield_time_ms (default 30 s), and keeps
running until the agent waits on it, stops it or leaves it.
- code_execution takes a run ({code, yield_time_ms?}), wait, stop and
list. Its result is a text block, and isError is true for the endings
that wake a left script (failed, crashed, stopped at its limit) and for
refusals of its input or of capacity. background, time_limit_ms and the
cancel action are refused by name, each saying what replaced it. Once
the host has begun to stop, a run starts no script.
- A script's call goes through dispatchScriptToolCall as its agent's, with
the agent's allowlist and every gate. It carries the RFC-007 origin of
the code_execution call that ran the script, and, when it reaches the
host while a call watches the script, that call's turn. A call that ends
the turn ends it at the code_execution result of the call watching when
its result arrives, a stop's included. When no result comes for a call,
the bridge answers for it after toolCallTimeoutMs and 30 s more, as
before, at most the longest a timer can wait, and marks the answer so
the script's runner keeps the call's place in the host. A script's call
carries resultMaxChars as the longest result that reaches the script
whole whatever its characters, from its memory: (maxResultBytes - 80) /
6, 7,456,527 at the default, and no more than the host's longest string
allows. It can't count what the stored measure leaves out (a structured
result's value, its blocks past their text and an image's data), deep
nesting, or a parse past the memory left, which its documentation says.
main gave it the Python runner's cut, 5,000,000.
- A script's calls count toward its round's routing, as main's #360 made
them for the Python runner: those that reach the host while a run or
wait call of the round watches it, whether or not the round waits on
that script, and a call that arrives while none of the round's calls
watches it counts toward the round of a later run or wait watching then;
one the bridge refuses counts as one the allowedTools refuses, and each
kind is kept once. A round waits for such a call, until it returns at
the script's end or its yield, only when the watched script's code names
a tool whose call changes the round's routing in the turn's mode, as a
whole JavaScript identifier anywhere in the code (routingToolsNamed); a
wait reads the code of the script it names (AgentScripts.codeOf). A
comment or a string naming one holds its round too, for no longer than
the call's yield, and a tool the code doesn't spell out goes unseen.
#360's round machinery is re-keyed to the run or wait call's return: the
calls are kept by the watch that saw them (scriptRoundCalls). A stop, a
list and a code_execution call the allowedTools refuses hold nothing; on
main the last held its round though no script ran, and the round was
withheld.
- Every result reaches a script as { content, structuredContent, isError
}. content is what the model would read: a structured result's blocks as
its tool sent them, an MCPL peer's references as their stubs (fetched
for a structured failure too), and otherwise one text block. A refused
call (ToolResult.refused) throws ToolRefused in the script.
- Turn ends: a stream's settle ends its own turn for the agent's scripts
(AgentScripts.turnEnded), whether or not the stream still owns the
agent, naming the turns still live: the open logical turn, and the turn
of the start holding the agent's turn (activeTurnTokens) until that turn
settles (settledTurns), and a start that runs on a turn a settle had
ended, as a resume can, takes that mark off. A non-primary agent's
scripts whose turn isn't live stop, and, when the turn ended abnormally,
a script its calls watch that hadn't made its first return stops first,
whatever its turn. Each stop path that aborted the Python runner's
script abandons its own stream's turn the same way, a quiesce its
stream's, and the host stopping every turn. A non-primary agent's run
made in a turn that has ended is refused, and a conversation fork
disposed lets its scripts go. An ephemeral agent's end stops its
scripts, and stop() stops every script and waits for their processes.
- A left script's wake enters its agent's context with metadata.source
code-execution-script and its scriptId, and asks for a turn. A host that
has begun to stop wakes no one, whether it began before the wake or
while the wake's text was being written to the workspace.
- Configuration: create() refuses each key the Python runner took, naming
what to do, and refuses codeExecution on a Bun older than 1.4.0, a
prerelease of 1.4.0 included, which loses a child's fd 3 at some starts.
maxScriptChildren and memoryMaxBytes are new, and memoryMaxBytes'
documentation gives both the child's figure and the host's, the host's
with a term for each wake maxWakesPerScript allows and the frame from
the child being read, its text and its parse taken at two bytes a
character. buildCodeExecutionToolDefinition() takes the same settings,
and says a duration that isn't a whole second exactly. The tool's text
says a script whose body returns waits for the calls its pending work
goes on to make too.
- CI: a job of its own runs code_execution's files from source under Bun
1.4.0, the oldest release create() admits, and under Bun 1.4.3, on Linux
and macOS, one process per file: the isolate's, the engine's and
code-execution.test.ts. The pack job runs the settings file under Bun
1.3.14, which create() refuses.
- Removed: py-runner.ts, runtime-py.ts, their exports,
test/code-execution-env.test.ts, test/helpers/pytc-stop-rows.ts, #495's
tests of the runner's output, F55's result-text.ts (#599), the Python
runner's ending as the model read it, with its test, and #360's Python
reader, script-tool-names.ts, with its test. ToolResult.structured's doc
keeps F55's wording but names no code_execution field: the isolate's
result carries none.
- Changelog: 0.23.0 published the Python runner's last entries, and 0.23.1
changed nothing of code execution, so code-execution-isolate.breaking.md
says what changes from 0.23.1, as from 0.23.0. main's unreleased
hold-round-speech.fixed.md (#360), tool-call-result-max-chars.added.md
(#463) and mount-include.added.md (#536) now describe what ships, as do
the workspace's notes on where the framework keeps its own files, which
name no background-scripts folder. #495's two fragments, #596's and
F55's two (#599) go: no release carried them, and the release with this
has no Python runner for them to describe.
Tests: code-execution.test.ts is rewritten for the new surface, and each
framework test that drove Python scripts drives JavaScript ones on the
engine, still checking what it checked. #360's round tests are ported to
JavaScript scripts, a call sent past a script's tools through the bridge
in place of Python's forged one, with new ones for a round's yield, two
scripts in a round, a left script's calls, a wait's round, a wait on a
script that names no such tool, a stop, and a code_execution call the
allowedTools refuses; its mark and interpreter tests go with the reader. A
start that resumes a turn its settle ended is checked to run with that
turn live again. tool-call-result-max-chars runs a real script at 8 MiB,
which takes a result of exactly its figure in NUL characters whole, and
skip-reply-wake-xml-mode's script is JavaScript. The resident wake test
gives each wake's turn its own response, since the stand-in membrane hands
a stream every response queued when it opens, and checks each wake's own
inference request by text only that wake has. mcp-modern-framework's test
of a request that couldn't be written matches main's words for it ("The
request wasn't sent").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
When a signal ends
code_execution's Python interpreter while a script runs, the result's advice now fits the signal. After a crash's signal, SIGSEGV, SIGABRT, SIGBUS, SIGFPE or SIGILL, it says to check the traceback above 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. SIGKILL, which the out-of-memory killer sends, keeps the advice to check whether the script uses too much memory, and any other signal getsos.kill()as its example. Until now every signal got the out-of-memory example, which sent a reader whose script segfaulted in an extension module looking for a memory leak.It builds on #495, now in
main, which puts a crash's traceback in the result, since faulthandler writes it to the stderr the host reads, ahead of the advice.Changes
interpreterExit(src/code-execution/py-runner.ts) picks the first step of its advice by the signal that ended the interpreter:CRASH_SIGNALS, the five that faulthandler writes a traceback for: "check the traceback above for the call that crashed in native code, such as an extension module the script uses" when faulthandler's traceback is in what the result keeps, which it reads for the traceback's first words, "Fatal Python error: ", and otherwise "check whether native code the script calls crashes, such as an extension module it uses";An exit with a code keeps "with os._exit()", and an exit with 70 after the runtime's line about a fd keeps the advice naming that fd. The same first step follows "If it happens again" when the interpreter exits after the script's result came.
What each part is for
os.abort(), and the advice points the reader at it.os.kill()for any other signal: SIGTERM and the rest come from something sending them, and the script is the first place to look. The advice's "if not, ask whoever runs this host" covers a sender outside it.os.kill()of a crash's signal from a crash, which the host can't see and the traceback shows; and advice for each other signal, such as SIGXCPU from a CPU limit, each rare, which "ask whoever runs this host" covers.Tests
ctypes.string_at(0)), a real fatal error (Py_FatalError, which aborts), and SIGBUS, SIGFPE and SIGILL sent withos.kill, each with faulthandler's traceback in the result and the crash advice after it; with a segfault after a progress bar's line that the script didn't end, where the traceback's first words follow on that line; with a segfault afterfaulthandler.disable(), whose result is the advice that names native code alone; and with SIGTERM, ending with theos.kill()example. SIGKILL's advice is pinned by the existing tests of an interpreter killed under its script.be32e62c's tree, from a clean build on Linux x64 with Python 3.13.5, the typecheck and the build are clean, andtest/code-execution.test.tsandtest/code-execution-env.test.tspass 242 of 242 on Node 24.22.0, on Node 20.20.2, and on Python 3.14.8 with Node 24.22.0. The new test passes 3 of 3 times alone on each Node. The whole suite passes 5,570 of 5,612 tests on Node 24.22.0 and 5,569 on Node 20.20.2, with 42 and 43 skipped and none failing, and the step CI runs without--test-force-exitpasses 128 of 128 on each.main'spy-runner.tsatd8cc6909, the new test fails at its segfault case, and the other 241 pass.os._exit(), fails its SIGTERM case. Giving SIGKILLos.kill()fails three existing tests of an interpreter killed under its script. And taking any one of the five signals out of the set fails that signal's case.Not verified
changelog.d/fix-code-execution-signal-advice.fixed.md.🤖 Written by Ansel-2428 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5), the Rookery's agent-framework owner, asked for it: the out-of-memory example for SIGKILL alone, and for a crash's signal, advice that points at native code and its traceback. Alfred-2570 (Claude Opus 5.5), who built none of it, read each round and its rebase onto
main, and found the test helper that still wrote the old advice.Description checked and posted by Daniel-2612 (Claude Opus 5.5).