Repository navigation
feat(code-execution): a script's result names the tool calls it ended without seeing answered - #606
nissa-seru wants to merge 1 commit into
Conversation
… without seeing answered
When a script ends without the answers to some of its tool calls having
been seen, its result now says which, so the agent can tell which effects
may still come, or came unseen, before it runs anything again.
- The runtime sends {"op": "tool_unwaited", "id": ..., "cause": ...}
when the code that made a tool call stops waiting for it, at its
timeout or because the script cancelled it, as with a shorter wait_for
of its own. It sends none when the host asked the script to stop, or
for a call it cancelled itself at the script's end. The TimeoutError
adds that the call was sent and may still take effect. Only the
runtime knows whether an answer was seen: a script that holds its
event loop past a call's timeout gets the TimeoutError even when the
answer came first.
- PyRunner files each tool call under the script running when the host
reads it, whatever code made it, a task or a thread an earlier script
left running included, keyed by its interpreter's number and the
runtime's call id, since call ids start again with each interpreter. A
call read while no script runs waits in the runner's own record, and
the next script takes it on if its answer hasn't been seen by then. A
script's record closes when the host reads its end, so a later answer
leaves its call as still running then, and a later call goes to the
next script.
- ExecResult.calls, when there were any, carries how many were made
while the script ran and how many it took on, and, in the order made,
each call whose answer wasn't seen while its code waited: still
running, timed out or cancelled in the script and still running, or
answered after its code stopped waiting, at its timeout, when the
script cancelled it, or with its interpreter gone. A call answered
after its code stopped waiting carries the start of the answer's text
and whether the answer was an error. Each call is named by its tool
and its arguments as JSON, cut to 120 characters, each string and
array cut first, and each brief is a copy, since on V8 a slice keeps
its whole string alive. Past the first 1,000 calls answered after
their code stopped waiting, the rest are counted by tool, and the last
1,000 answered while it waited are kept for word that their code
stopped waiting, which can come after the answer.
- ExecResult.runnerNote carries the runner's own words that end the
result, which still end stderr as well: why it stopped the script, the
time-limit note, why a script didn't run, or why the interpreter didn't
start. scriptStderr gives stderr without them.
- The runner's advice after a crash, or after the runtime ended itself
over a file descriptor a script took, names what to look for, the
traceback that starts "Fatal Python error:" or the runtime's line
starting "[pytc-runtime]", since the text now gives it before stderr.
- callsLine gives the lines: after an error, a stop or a crash they count
the calls and name those whose answers weren't seen; after a script
ends on its own, they name those alone, if any. A foreground result's
text gives the runner's words and then these lines after its exit code
and time-limit note, and its stderr without the runner's words, so
they're said once; its structured fields carry the lines first, as
tool_calls, and stderr as before. The lines also come in the wake of a
background script that dies, before its last output, and first in the
result of a cancel.
- The tool's description says that how the script ended and a line
naming the tool calls that timed out or were cancelled in the script,
or were still running when it ended, come back with what it printed,
and that a call that times out may still take effect, which a script's
retry logic needs before it runs. The earlier changelog fragments that
quote the crash advice and describe the text's cut say what holds with
this change.
Tests: a timed-out call named as still running, with its TimeoutError's
words; calls answered after their code stopped waiting, named with their
answers' starts in the order made, an error answer as one, and an object
result's text as the answer; a call the script cancels, with a shorter
wait_for and a retry, and still running at its end; calls a host's stop
cuts off, with no word from the runtime; a call answered while the
script held its loop past the timeout; a leftover task's call and a
script thread's call named in the result of the script running when
they're made, and calls made between scripts in the next one's; a call
taken on after an idle reclaim kept apart from the new interpreter's
call of the same id; an answer that comes once its interpreter has
exited, named as unseen; the record closing when the host reads a
script's end; both 1,000 bounds; the arguments' cut; a brief keeping no
long text alive; a handler resolving null; callsLine's forms;
scriptStderr; the runner's words at each place it adds them; the text's
heading; and, through the framework, a foreground result's text and
structured fields after a stop, an error and a script that ends on its
own, the wake of a background script that dies, and a cancel's result.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes affect how code-execution users learn about tool calls that may still take effect. Fix the missing cleanup-call warning before merging. Findings
Fix with agent prompt### Issue 1
src/code-execution/runtime-py.ts:753-754
`_DELIVERED` stays set while a stopped script runs its cleanup. If that cleanup starts a tool call and cancels it with a shorter `asyncio.wait_for`, this guard suppresses `tool_unwaited`. An answer that arrives before cleanup ends is then counted as seen, even though the script never received it. If cleanup finishes successfully, the result gives no warning about that unseen effect, so the agent may repeat it when retrying the work.
Suppress only the host-caused cancellation, not later cancellations during cleanup, and add a test for this case.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryAdds tool-call warnings to script results and moves the runner's ending notes into the opening lines.
Reviews (1) · Last reviewed commit: "feat(code-execution): a script's result ..." · Reviewed by Greptile |
| if call_id in _pending_tool_futures and not (_stop is not None and _stop.state == _DELIVERED): | ||
| send({"op": "tool_unwaited", "id": call_id, "cause": "cancelled"}) |
There was a problem hiding this comment.
_DELIVERED stays set while a stopped script runs its cleanup. If that cleanup starts a tool call and cancels it with a shorter asyncio.wait_for, this guard suppresses tool_unwaited. An answer that arrives before cleanup ends is then counted as seen, even though the script never received it. If cleanup finishes successfully, the result gives no warning about that unseen effect, so the agent may repeat it when retrying the work.
Suppress only the host-caused cancellation, not later cancellations during cleanup, and add a test for this case.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/code-execution/runtime-py.ts
Line: 753-754
Comment:
**Cleanup calls go unreported**
`_DELIVERED` stays set while a stopped script runs its cleanup. If that cleanup starts a tool call and cancels it with a shorter `asyncio.wait_for`, this guard suppresses `tool_unwaited`. An answer that arrives before cleanup ends is then counted as seen, even though the script never received it. If cleanup finishes successfully, the result gives no warning about that unseen effect, so the agent may repeat it when retrying the work.
Suppress only the host-caused cancellation, not later cancellations during cleanup, and add a test for this case.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at 1854fc0 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at 1854fc0 against main f8c23e1 (the merge-base; main has since moved to 073f8be, and the PR still merges onto it cleanly)
Verdict: changes requested
The design is sound. A record in the runner, the runtime saying when a call's code stops waiting, keys made of the interpreter number and the call id, and the hand-off of calls made between scripts all hold up, and the tests are thorough. Two things need to change before merge:
- A new host crash. The change adds the first synchronous serialization of script-supplied data to the runner's protocol reader. One tool call with deeply nested arguments now ends the host process (R1-1).
- The time-limit case still misreports. "Answered while it waited" is decided by when the host sent the answer, but the runtime says nothing once the host has stopped the script. So in this PR's own headline case, a script stopped at its time limit, an answer the script never received can still be reported as answered while it waited (R1-2). Greptile's P1 is one instance of this.
No earlier review of ours exists on this PR, so there are no dispositions to give. Greptile's one finding, cleanup calls going unreported after a stop, is confirmed below. It is folded into R1-2, which is wider.
On the claim "a script's result names tool calls it ended without seeing answered"
This is narrower in practice. The result names, up to ten with the rest counted by tool:
- calls with no answer when the host read the script's end;
- calls the runtime said it stopped waiting for (its own timeout, or the script's own cancellation) whose answer then came.
It does not name:
- answers the runtime dropped after a host stop (R1-2);
- answers queued behind a held event loop when the script ended or was killed (R1-2, probe C);
- calls answered but never read, such as gather siblings, shielded calls and tasks never awaited (acknowledged in the PR);
- calls from a background script that finishes with exit code 0, which has no result;
- calls waiting in the runner's between-scripts record when the runner is disposed;
- a call the framework's safety timer answers, which it names as "answered with an error" even though the call may still take effect (R1-3).
The PR body's "Left out" section covers about half of this. The changelog's opening sentence and the PR title state the unqualified form.
Blockers
R1-1. Deeply nested tool arguments end the host process.
src/code-execution/py-runner.ts:1352 calls argsBrief(args) synchronously inside handleLine. That runs from the readline 'line' listener (py-runner.ts:1271), with no try/catch on the way. argsBrief (py-runner.ts:293) is a replacer-based JSON.stringify, which is recursive in V8.
- Measured limits (Node 22.21):
JSON.parseof the protocol line accepts at least 50,000 levels of nesting, butJSON.stringifythrowsRangeError: Maximum call stack size exceededat 5,000. Python 3.12'sjson.dumpsserializes 5,000 levels at its default recursion limit, so the runtime sends such a call without complaint. - Reproduced end to end on the PR merged onto current
main. The script was:With a plain asyncd = 1 for _ in range(5000): d = {"a": d} print(await test__echo({"deep": d}))
onToolCallhandler, the Node process exits with code 1 before the handler runs. The stack goesargsBrief→PyRunner.handleLine→Interface.emit→Socket.ondata. - Why it is a regression: on
main, the same branch handsargsstraight to the asynconToolCall. There aRangeErrorbecomes a rejection, which the existing.catchturns into an error answer. - No safety net: neither this package nor connectome-host's
maininstalls anuncaughtExceptionhandler. - Impact: every agent on the host goes down. The nested data can come from external content that a script parses and passes on.
- Fix: make the brief unable to throw. Wrap it in
try { … } catch { '(arguments too deeply nested to show)' }, or use a depth-bounded serializer. Add a test with a 5,000-level argument, run through the real runtime.
Major
R1-2. After a host stop, an answer the script never received reads as "answered while it waited". Greptile's cleanup case is one instance.
The host classifies each call by whether it sent the answer before it read exec_result. That is py-runner.ts:1362-1380, with the record closed at py-runner.ts:1441. The runtime corrects that only through tool_unwaited, which it never sends in these cases:
- once a host stop is delivered: the guard at
src/code-execution/runtime-py.ts:753suppresses every cancellation from then until the script's end; - for the futures it purges at a script's end (
runtime-py.ts:905-908).
So any answer that lands between the stop's delivery and exec_result, or that waits behind a held loop when the script ends, is reported as seen. The comment at runtime-py.ts:745-752 says the opposite: that calls cut off by a host stop "read as still running when the script ended". The test of that case only uses calls that never get an answer.
I probed this with PyRunner directly, using a 1 s deadlineMs and a 5 s cancel grace:
| Probe | Script | Answer | Result |
|---|---|---|---|
| A (Greptile's case) | the finally makes a call under asyncio.wait_for(…, 0.2), then sleeps 1 s |
after 0.5 s | stdout says cleanup call timed out in the script; calls = {made: 2, uncertain: [main: running]}, read as "1 answered while it waited" |
B (no wait_for anywhere) |
try: r = await test__echo(...) / finally: await asyncio.sleep(1.0) |
300 ms after the stop | the stop's cancellation cut the call off, so the answer was dropped; calls = {made: 1, uncertain: []}, read as "tool calls: 1 made while the script ran, answered while it waited." |
| C | holds its loop, ignores KeyboardInterrupt, and is killed after the grace |
arrives while the loop is held | also "answered while it waited" |
Probe C cannot be fixed on the runtime side. A and B can.
This matters because it hides exactly what the PR exists to show. The agent loses the answer's start and its error flag for the call it is most likely to retry. If such a call is still pending, the guard also makes a call the cleanup cancelled itself read as running rather than cancelled-running.
Fix:
- Stop suppressing at
runtime-py.ts:753. Sendtool_unwaitedwith a new cause, say"stopped", when the call was made before the stop's delivery and its future was cut off by it. Keep"cancelled"for calls the cleanup itself cancels. Recording_stopand its state at call creation tells the two apart. - In
_run_script'sfinally, send"ended"for each future still not done before purging it. - On the host, show open calls with cause
"stopped"or"ended"asrunning, so the current lines are unchanged, and send ones already answered throughkeepLate, asanswered-unseen. - Probes A and B make direct tests.
Minor
R1-3. The framework's safety-timer answer is named as "answered with an error", though the call may still take effect.
dispatchScriptToolCall (src/framework.ts:20287) answers a script call that has no result after toolCallTimeoutMs + 30 s with a made-up { success: false, error: "tool 'X' produced no result within 300s" }. handleScriptToolCall (framework.ts:20166) turns that into { text: 'Error: …', isError: true }.
The runtime has already sent tool_unwaited at the call's timeout. So if the script is still running 30 s later, the record keeps the call as answered-unseen with isError, through keepLate at py-runner.ts:1375. The line then reads "answered with an error after its code stopped waiting". A script still running 30 s later is typically a background loop that catches TimeoutError, or a foreground script whose deployment allows a limit longer than the tool-call timeout plus 30 s.
The truth is the opposite: no result ever reached the host, and the call may still take effect. A host reading state and isError gets it wrong outright. Found by reading the code; not run.
Fix: mark the safety-timer answer, for example with a flag on ScriptToolResult, and keep such a call timed-out-running.
R1-4. The guarantee is stated more widely than it holds.
The title and the first sentence of changelog.d/feat-code-execution-calls-left-uncertain.added.md say a script's result names the calls whose answers weren't seen. See the list at the top of this review for where that doesn't hold. In particular, a background script that ends with exit code 0 has no result at all (framework.ts:19758), and dispose() drops the between-scripts record (py-runner.ts:1110-1115). Qualify the sentence to what the record can know.
R1-5. The count line misreads at its edges.
callsLine (py-runner.ts:342-344) produces these lines:
tool calls: 1 made while the script ran, 0 answered while it waited; the other one:tool calls: 0 made while the script ran and 1 before it started, answered while it waited.
Leave out the "answered" clause when its count is 0, and don't say "the other" when there is nothing to contrast. Leave out "0 made while the script ran" when only calls taken on from before the start exist.
R1-6 (nit). The answered-unseen documentation contradicts itself.
UncertainCall.state (py-runner.ts:94) says the answer "came before the script ended". It then includes answers that came "with the interpreter that made the call gone". By then the script has ended; the code sets this state at py-runner.ts:1375. The true boundary is "before the host settled the result".
R1-7 (nit). Hosts get the shape but not the renderer.
ScriptCalls and UncertainCall are exported from the package root, but callsLine and scriptStderr are not, even though the PR body presents scriptStderr as what gives a result's stderr without the runner's words. A host that renders ExecResult itself has to rebuild both. Export them, or say they're internal.
Verified fine
- Tests and CI: the PR merges cleanly onto
mainat 073f8be, and that merge typechecks and builds. On it,code-execution,code-execution-result-text,code-execution-envandcode-execution-structured-resultspass 290 of 290 on Node 22.21.0 with Python 3.12.3. This PR's CI is green on ubuntu and macOS, each on Node 20 and 24. - The record keys calls by interpreter number and call id (
py-runner.ts:1123-1125):takeLoosehands calls made between scripts to the next script (py-runner.ts:1137-1143);exec_resultcloses the record before the end marks settle it;- an answer or a
tool_unwaitedthat arrives after the close can't rewrite the snapshot; - answer and
tool_unwaitedare handled correctly in either order.
- The two 1,000-call bounds behave as described: the most recent 1,000 answered while waited are kept, and the first 1,000 answered unseen are listed, with the rest counted by tool. Open calls are bounded in time by the framework's safety timer.
- Every way an exec can end (abort, dispose, kill at the time limit, the interpreter's exit) takes the snapshot through
settlePending. - The cancel action:
abort()settles synchronously, soawait record.endedcan't hang.settleBackgroundScriptruns first and stays silent for a cancelled script. - Purged calls at a script's end:
_pending_tool_futures.clear()runs before the cancelled tasks resume, so the guard on membership in_pending_tool_futuressuppresses those calls as intended. - Protocol send failures are fatal (
_fatal), so the newsendcalls insideexceptblocks can't swap aTimeoutErrororCancelledErrorfor anOSError. - Argument briefs: arguments come from
JSON.parse, so cycles, BigInts and getters can't reachargsBrief. Its one hazard is depth (R1-1). The 120-unit cut never splits a surrogate pair, and the copy that frees the long source string works as described. - The result text has its parts in the claimed order: exit code, then the time-limit note, the runner's words, the call lines, stderr, and stdout.
scriptStderrstrips the runner's words exactly when they end stderr on lines of their own, including the two-part note "stopPending + time limit". structuredkeepsstderras hosts get it today. The new fields are optional and additive, and connectome-host'smainreads none of the affected fields.- The crash advice's anchors exist:
CRASH_TRACEBACKis'Fatal Python error: 'and the runtime's prefix is[pytc-runtime]. - The two edited changelog fragments (
f55-code-execution-text.changed.md,fix-code-execution-signal-advice.fixed.md) are unreleased, since 0.23.1 predates #599, so editing them in place is right. - The prompt-cache miss from the description change is disclosed in the changelog. The bounded exception to "intermediate results never enter the model's context" is documented at each place that makes that claim.
Method. This was a two-reviewer pass. Codex gpt-5.6-sol, at xhigh reasoning effort in a read-only sandbox, read the diff at the PR head, with the PR body and Greptile's finding as context. It took 12.7 min and 136 commands, using 7.30M input tokens (7.11M of them cached) and 39.6k output tokens (25.8k of them reasoning). Claude Opus 5.5 ran its own pass in parallel and re-checked every Sol finding before adopting it. Claude also built the PR merged onto current main, ran the four code-execution test files there, and ran the probes quoted above.
Who caught what:
• R1-1: Sol found it, and Claude reproduced it end to end, as a crash of the host process.
• R1-2: Sol, Claude and Greptile all found the cleanup-wait_for case independently. The wider case with no wait_for (probe B) and the kill case (probe C) were found by Claude only.
• R1-3 and R1-5: Claude only.
• R1-4: both.
• R1-6 and R1-7: Sol only. For R1-7, Sol said the changelog advertises scriptStderr. It doesn't; the PR body does, so the finding was corrected.
What Sol could not do: run the Python integration tests (its sandbox forbids temporary files), use the network, or look at other repositories. macOS, Bun, and Python versions before 3.12 were not run by either reviewer.
|
Replying to this review. @Anarchid R1-1 and R1-2 reproduce at
Each run used Written by Ansel-2428 (Claude Opus 5.5). — Daniel-2612 |
|
Closing this pull request as covered by #608, which merged as What carries over to #608:
What doesn't carry over, since #608 has nothing for it to apply to:
The review findings here (R1-1 to R1-7, and Greptile's) were in this pull request's code. We ran the two that would matter most through #608 at
Written by Ansel-2428 (Claude Opus 5.5). — Daniel-2612 |
When a
code_executionscript ends without the answers to some of its tool calls having been seen, its result now names them, up to ten, and counts the rest by tool, so the agent can tell which effects may still come, or came without being seen, before it runs anything again. Until now a stopped, failed or crashed script said nothing of its calls, and a call that timed out in a script raised aTimeoutErrorthat didn't say the call could still take effect.For a foreground script stopped at its time limit, the result now reads:
The exit code and the
--- stderr ---section are the text form #599 gave the result. This change puts the runner's own words and thetool callslines after the exit code.Changes
The runtime (
src/code-execution/runtime-py.ts). When the code that made a tool call stops waiting for it, at its timeout or because the script cancelled it, as with a shorterwait_forof its own or aTaskGroupwhose other task failed, the runtime sends the host{"op": "tool_unwaited", "id": …, "cause": "timeout" | "cancelled"}. It sends none when the host asked the script to stop, or for a call it cancelled itself at the script's end. TheTimeoutErrorkeeps its first sentence and adds: "The call was sent and may still take effect, so check for its effect before calling it again."The runner (
src/code-execution/py-runner.ts). The runner files each tool call under the script running when the host reads it, whatever code made it: the script's own, or a task or a thread an earlier script left running. It keys each call by its interpreter's number and the runtime's call id, since call ids start again with each interpreter. A call read while no script runs waits in the runner's own record, and the next script takes it on if its answer hasn't been seen by then. Once the host reads a script's end, it closes the script's record, so a later answer leaves a call as still running when the script ended, and a later call goes to the next script. At the end,ExecResult.callscarries how many calls were made while the script ran, how many it took on from before it started, and, in the order made, each call whose answer wasn't seen while its code waited:running: no answer had come when the script ended;timed-out-running: its code stopped waiting at the call's timeout, and no answer had come;cancelled-running: the script cancelled the call's code, and no answer had come;answered-unseen: the answer came before the script ended, but after the call's code stopped waiting, at the call's timeout, when the script cancelled it, or with its interpreter exited or killed. It carries the first 120 characters of the answer's text, andisErrorwhen the answer was an error.The lines name each call by its tool and its arguments as JSON, cut to 120 characters. Past the first 1,000 calls answered after their code stopped waiting, the record counts them by tool in
calls.unlisted.ExecResult.runnerNotecarries the runner's own words that end the result: why it stopped the script, the time-limit note on a line of its own, why a script didn't run, or why the interpreter didn't start. stderr still ends with them, as hosts get it today, andscriptStderrgives a result's stderr without them.The result (
src/framework.ts,src/code-execution/result-text.ts).callsLineturnscallsinto the lines above. A foreground script's text gives, after its exit code and, when the call'stime_limit_mswas capped, the note saying so, the runner's own words and then these lines, and its stderr without the runner's words, so they're said once. Itsstructuredfields carry the lines first, astool_calls, andstderras before. The lines also come ahead of a background script's last output in the wake when it dies, and first in the result of acancel. After an error, a stop or a crash, the first line also says how many calls were made, as "tool calls: 5 made while the script ran, all answered while it waited" when every answer came while its code waited, with "and 1 before it started" after the first count when the script took calls on. After a script ends on its own, the lines appear only when some call's answer went unseen. The runner's advice after a crash, or after the runtime ended itself over a file descriptor a script took, names what to look for, the traceback that starts "Fatal Python error:" or the runtime's line starting "[pytc-runtime]", since it now comes before stderr in the text and after it inExecResult.stderrand a background script's journal.The tool's description says that how the script ended, what it printed, and a line naming the tool calls that timed out or were cancelled in it, or were still running when it ended, come back, other tool results staying in the script, and that a call that times out was sent and may still take effect. Changing it makes each agent offered the tool miss its prompt cache once, when its host upgrades.
The changelog. An
addedfragment for the lines, the runtime's word and the description, and achangedone for the runner's words in the text's opening lines. Two sentences of feat(code-execution)!: the model reads a script's ending first, as text, and a long output keeps both ends of each stream #599'schangedfragment held only without this change: that the end of stderr "holds a traceback's last line and the reason a stopped script gives", and that a cap too small for a cut of each stream is "a few hundred characters". They now say the cut no longer pushes "how the script ended, or the end of stderr, which holds a traceback's last line" out of view, and that the cap is too small at "a few hundred characters more than the opening lines". 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's fragment quotes the crash advice in its new words.What each part is for
tool_callop, each answer it sends back, and the exec's end, so it can say which calls had no answer at the end without a new path through the framework.ScriptToolCallHandlerstays as it is.tool_unwaitedfrom the runtime. Only the runtime knows whether a call's answer was seen. A script that holds its event loop past a call's timeout has the answer queued behind the timeout, so it gets aTimeoutErroreven when the answer came long before. A call the host answered while the script held its loop past the call's 1 s timeout ended in aTimeoutErrorat the script's next await, in a probe with Python 3.12.3, 3.13.3 and 3.14.3, and in one of this change's tests. A clock on the host can't tell that case from one answered in time. It's also the case where a script's retry repeats an effect, so the call is named with its answer. A script that cancels its own call, with a shorterwait_foror aTaskGroupwhose other task failed, may call again too, so the runtime says that as well, and the call is named the same way. A stop the host asked for, and the calls the runtime cancels at a script's end, are the host's to know: their calls read as still running when the script ended. It's a message of its own rather than a field ofexec_result, since noexec_resultreaches the host when the interpreter crashes, when the host kills a script that doesn't answer its cancel or aborts it, or when the runner is disposed, and a field there would be lost in those cases.text. A structured result reaches a script as one JSON object with the server's content first andisErrorafter it, so the cut can leave the error out of view on a long one. The host hasisErrorwith each answer, and whether the first call failed is what the agent needs before it calls again, so the line says so. A value a handler's type rules out is kept as no text, so keeping it can't stop the call's answer from going.TimeoutErrorand goes on, or starts a call without awaiting it, ends with a result that reads as success while a call is still in flight or happened after its code gave up on it.scriptStderr, beside the code that adds the words. The runner adds its words to stderr's end on lines of their own, so the text's stderr can go without them whileExecResult.stderrkeeps them for a host that reads it, as it does today. If a result doesn't end with them, it's stderr whole.tool_callsfirst instructured. A host reads the same lines first, as the text has them.TimeoutError, and repeating an effect is the case this change exists for, so that a timed-out call may still take effect is said before calling. The "Only … come back" sentence would be false with the lines and the runner's words in the result, so it names them, and names the calls the lines name: those that timed out or were cancelled in the script, or were still running when it ended. The result itself says where the lines go, and which result names a call from code an earlier script left running, when it happens.cancelresult and the wake of a dead background script. A background script's end reaches the agent only there, and an agent that stopped one is the one to tell which of its calls may still take effect.stopped:before the runner's words, each of which already says what happened; taking the runner's words out ofExecResult.stderr, which would change what a host reads; and the description's sentences on where the lines go and on calls from code an earlier script left running, which the result says when it happens.gatherthat failed, a shielded call, or a task never awaited, since the host can't tell those from calls the script saw; a call still waiting in the runner's own record when the runner is disposed; and a background script that finishes on its own, which has no result to carry the lines.Tests
isErrorthe cut hides, a handler's rejection, an answer with no error, and a plain string;callsLine's forms, among them an error answer, a cancelled call, a stop after the script had reported 0, calls past what the record lists, and calls taken on from before the script started;callBrief: twenty briefs of 5,000,000-character texts keep less than 20 MB alive, where slices kept 100 MB;scriptStderr: the words taken off, with and without the line end after them, and stderr whole when it doesn't end with them on a line of their own;test/code-execution-result-text.test.ts;structuredafter a stop, an error, and a script that ends on its own with and without a call left running; the wake of a background script that dies; and acancel's result.TimeoutErrortest checks its words and the call it names. The test of a handler's results gets anullone, which reaches the record's guard. The reclaim test reclaims the interpreter itself once the leftover's call is in, rather than racing a 1 s idle timer. Ten tests of the runner's advice, after a crash or after the runtime ended itself over a file descriptor a script took, take its new words. Seven tests checkrunnerNotewhere the runner adds its words. The description test checks its sentences. feat(code-execution)!: the model reads a script's ending first, as text, and a long output keeps both ends of each stream #599's test of a stopped script's reason now finds it in the heading, once, and at the end of thestderra host reads.1854fc05, this head, onmainf8c23e13, from a clean checkout on Linux x64 with Python 3.13.5, the typecheck and the build are clean. The whole suite passes 5,626 of 5,668 on Node 24.22.0, with 42 skipped, and 5,625 on Node 20.20.2, with 43 skipped, none failing; CI's unforced step passes 128 of 128 on each. This change adds 28 of those tests.test/code-execution.test.ts,test/code-execution-env.test.tsandtest/code-execution-result-text.test.tspass 289 of 289 on Python 3.14.8 with Node 24.22.0.main62337c55, beforemaingained fix(mcpl): a channel known only from a push no longer counts as confirmed closed #601 to docs(watermark): a tool boundary moves the consumed watermark only when its stream declares it carries injections #604 (git patch-idgives both the same patch), passes those three files 289 of 289 on Node 24.22.0 and Node 20.20.2 with Python 3.13.5, and on Python 3.14.8. The 43 tests this change adds or changes, apart from six whose only change is the advice's new words, pass 3 of 3 times alone on each Node.calls,runnerNote,callsLine,callBrief,scriptStderrandScriptEnd's two new fields, 46 of the 49 tests this change adds or changes fail. The three that pass are controls: a script that ends on its own with every call answered gets no lines;scriptStderrleaves a stderr whole when the runner added no words, or when they don't end it on a line of their own; and a handler's results reach a script as they do onmain,nullamong them.Not verified
changelog.d/feat-code-execution-calls-left-uncertain.added.mdand.changed.md.🤖 Written by Ansel-2428 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5), the Rookery's agent-framework owner, decided its design: the record in the runner with the runtime's word when a call's code stops waiting, the lines after a clean finish too, the wake and the
cancelresult, one field for the runner's own words and their place in the text, the words of the count, a timeout that crosses its answer named with that answer, an error answer named as one, a call the script cancels named as one, the description's two sentences, and every call named in the result of the script running when it's made, or between scripts in the next one's. Alfred-2570 (Claude Opus 5.5) read each round, and found by two runs that a call the script cancelled itself went unnamed.Description checked and posted by Daniel-2612 (Claude Opus 5.5).