Skip to content

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 into
anima-research:mainfrom
nissa-seru:fix/code-execution-signal-advice
Oct 19, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/code-execution-signal-advice

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

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 gets os.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:

    • a signal in 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";
    • SIGKILL: "check whether the script itself ends the process, for example by using too much memory", as before;
    • any other signal: the same, with "for example with os.kill()".

    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

  • The crash branch is the change. faulthandler's traceback shows the call a crash's signal came from, whether native code a script calls or the script's own os.abort(), and the advice points the reader at it.
  • Reading what the result keeps for the traceback keeps the advice from pointing at one that isn't there: a script can disable faulthandler or replace the fd it writes to, a foreground stream past its 2,000,000-character cap drops what comes after, and a background script's result carries only its journal's last 4,000 characters, where a long traceback's first words may not be. Without it, the advice names native code the script calls, without "above". It looks for those words anywhere, not only at a line's start, since faulthandler doesn't start a line: after output that didn't end one, such as a progress bar's, they follow on that line. The cost is that output holding those words, from a crash whose own traceback didn't reach the result, gets the advice that points above.
  • The five signals are faulthandler's own list. SIGBUS, SIGFPE and SIGILL are rarer than SIGSEGV and SIGABRT, but they're crashes of the same kind, such as an extension built for a CPU feature the host lacks, which ends with SIGILL.
  • SIGKILL alone keeps the out-of-memory example, since that's the signal the out-of-memory killer sends.
  • 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.
  • Left out: reading the traceback to name the module, which the reader can do; telling a script's own 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

  • New: "a signal's advice fits it: the traceback's call after a crash's signal, native code when no traceback came, the script's own os.kill() after another". It ends the interpreter with a real segfault (ctypes.string_at(0)), a real fatal error (Py_FatalError, which aborts), and SIGBUS, SIGFPE and SIGILL sent with os.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 after faulthandler.disable(), whose result is the advice that names native code alone; and with SIGTERM, ending with the os.kill() example. SIGKILL's advice is pinned by the existing tests of an interpreter killed under its script.
  • At be32e62c's tree, from a clean build on Linux x64 with Python 3.13.5, the typecheck and the build are clean, and test/code-execution.test.ts and test/code-execution-env.test.ts pass 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-exit passes 128 of 128 on each.
  • Red-first: with main's py-runner.ts at d8cc6909, the new test fails at its segfault case, and the other 241 pass.
  • Each piece has a test. Taken out on its own, each piece fails a test. The crash branch, and either of its two texts given whether or not the traceback is there, fail the new test, and so does looking for the traceback's first words only at a line's start, at its case of a line the script didn't end. Widening SIGKILL's example to every signal, or giving other signals os._exit(), fails its SIGTERM case. Giving SIGKILL os.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

  • macOS isn't run here; this pull request's CI runs it.

  • Changelog fragment added: 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).

Ansel-2428 and others added 4 commits October 19, 2026 17:29
…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>
@nissa-seru
nissa-seru merged commit 1b4a428 into anima-research:main Oct 19, 2026
6 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant