Repository navigation
feat(code-execution): per-call time limit (time_limit_ms) - #203
Conversation
code_execution takes an optional timeout_ms: the time limit for that script, like Claude Code's per-call Bash timeout. It defaults to scriptTimeoutMs and may go up to the new codeExecution.maxScriptTimeoutMs, which defaults to the same value, so agents can only shorten the limit until a deployment raises the ceiling. Longer requests are capped and the result says so. For a background script it shortens the lifetime, capped at backgroundMaxLifetimeMs. The tool description now states the limit, and a script stopped at its limit says "script stopped: it reached its N time limit" instead of only "cancelled by host". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UCEhG5Sap3gQCiaNzWEXqb
|
|
|
||
| const execId = `e${++this.execCounter}`; | ||
| const deadlineMs = background?.lifetimeMs ?? this.scriptTimeoutMs; | ||
| const deadlineMs = background?.lifetimeMs ?? opts?.deadlineMs ?? this.scriptTimeoutMs; |
There was a problem hiding this comment.
Long deadlines cancel immediately If an operator allows a foreground deadline longer than 2,147,483,647 ms—for example, by setting
maxScriptTimeoutMs to 30 days—the requested value reaches Node’s setTimeout. Node reduces that delay to approximately 1 ms, so the script is cancelled almost immediately instead of receiving its requested time limit. Bound supported deadlines or schedule longer ones in intervals.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/code-execution/py-runner.ts
Line: 174
Comment:
**Long deadlines cancel immediately** If an operator allows a foreground deadline longer than 2,147,483,647 ms—for example, by setting `maxScriptTimeoutMs` to 30 days—the requested value reaches Node’s `setTimeout`. Node reduces that delay to approximately 1 ms, so the script is cancelled almost immediately instead of receiving its requested time limit. Bound supported deadlines or schedule longer ones in intervals.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| if (pending.deadlineMs !== null) { | ||
| const note = `script stopped: it reached its ${formatLimit(pending.deadlineMs)} time limit\n`; | ||
| // A background script reports its output tail, so the note goes there too. | ||
| result = { ...result, stderr: result.stderr + note, ...(result.tail !== undefined ? { tail: result.tail + note } : {}) }; |
There was a problem hiding this comment.
Completed scripts appear stopped If a script catches
asyncio.CancelledError, finishes cleanup, and returns successfully during the grace period, this note still says it was stopped even though its return code is 0. The contradictory result could prompt an agent to retry work that completed.
Knowledge Base Used: Code execution tools
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/code-execution/py-runner.ts
Line: 404-407
Comment:
**Completed scripts appear stopped** If a script catches `asyncio.CancelledError`, finishes cleanup, and returns successfully during the grace period, this note still says it was stopped even though its return code is 0. The contradictory result could prompt an agent to retry work that completed.
**Knowledge Base Used:** [Code execution tools](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/code-execution-tools.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| `A script is stopped after ${formatLimit(limits.defaultMs)}; pass timeout_ms to set this call's limit ` + | ||
| `(at most ${formatLimit(limits.maxMs)}). ` + |
There was a problem hiding this comment.
Background lifetime is misstated This unqualified sentence says a script stops after the foreground default and cannot request more than the foreground maximum. With default settings, a background script instead has a 24-hour lifetime, not a 10-minute one. Qualify these figures as foreground limits so agents can plan watchers around the correct lifetime.
| `A script is stopped after ${formatLimit(limits.defaultMs)}; pass timeout_ms to set this call's limit ` + | |
| `(at most ${formatLimit(limits.maxMs)}). ` + | |
| `A foreground script is stopped after ${formatLimit(limits.defaultMs)}; pass timeout_ms to set ` + | |
| `its limit (at most ${formatLimit(limits.maxMs)}). ` + |
Knowledge Base Used: Code execution tools
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/code-execution/tool-definition.ts
Line: 44-45
Comment:
**Background lifetime is misstated** This unqualified sentence says a script stops after the foreground default and cannot request more than the foreground maximum. With default settings, a background script instead has a 24-hour lifetime, not a 10-minute one. Qualify these figures as foreground limits so agents can plan watchers around the correct lifetime.
```suggestion
`A foreground script is stopped after ${formatLimit(limits.defaultMs)}; pass timeout_ms to set ` +
`its limit (at most ${formatLimit(limits.maxMs)}). ` +
```
**Knowledge Base Used:** [Code execution tools](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/code-execution-tools.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| return { | ||
| defaultMs, | ||
| // Never below the default: asking for the default must always be allowed. | ||
| maxMs: Math.max(defaultMs, opts?.maxScriptTimeoutMs ?? defaultMs), |
There was a problem hiding this comment.
Configured ceiling is ignored If an operator sets
maxScriptTimeoutMs below scriptTimeoutMs, Math.max silently replaces that ceiling with the default. For example, a one-minute maximum alongside a ten-minute default still permits ten-minute requests. Reject or clearly diagnose this conflicting configuration so operators know their ceiling is not enforced.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/code-execution/tool-definition.ts
Line: 108
Comment:
**Configured ceiling is ignored** If an operator sets `maxScriptTimeoutMs` below `scriptTimeoutMs`, `Math.max` silently replaces that ceiling with the default. For example, a one-minute maximum alongside a ten-minute default still permits ten-minute requests. Reject or clearly diagnose this conflicting configuration so operators know their ceiling is not enforced.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const scriptId = `bg-${++this.backgroundScriptCounter}`; | ||
| const lifetimeMs = cfg?.backgroundMaxLifetimeMs ?? 86_400_000; | ||
| // The agent's timeout_ms (already capped) shortens the lifetime; it never extends it. | ||
| const lifetimeMs = timeLimitMs ?? cfg?.backgroundMaxLifetimeMs ?? 86_400_000; |
There was a problem hiding this comment.
Short lifetimes display as zero A background call can now request a lifetime as short as one second, but the start result rounds
lifetime_hours to one decimal place. Any lifetime under three minutes is reported as 0, leaving the agent without an accurate effective lifetime in that result. Preserve finer precision or report milliseconds for short limits.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 10874
Comment:
**Short lifetimes display as zero** A background call can now request a lifetime as short as one second, but the start result rounds `lifetime_hours` to one decimal place. Any lifetime under three minutes is reported as `0`, leaving the agent without an accurate effective lifetime in that result. Preserve finer precision or report milliseconds for short limits.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.anima-research#155 separates how long the agent watches a script (wait_ms, on_timeout) from how long the script may run. "timeout" there names the watching side, so a per-call `timeout_ms` that stops the script would read as the same thing. `time_limit_ms` pairs with `wait_ms` instead. The description also says that stopping a script does not stop tools it already called. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UCEhG5Sap3gQCiaNzWEXqb
- Clamp every deadline to Node's timer range (~24.8 days): a longer one
fired after ~1 ms and stopped the script at once.
- Say "script stopped: it reached its time limit" only when the script
ended unsuccessfully; one that caught the cancellation and finished
returns 0 and is not reported as stopped.
- The description qualifies the limit as the foreground one and states
the background lifetime.
- A maxScriptTimeoutMs below scriptTimeoutMs is refused when the framework
is created, before anything opens, instead of being silently ignored.
- A background start reports `lifetime` exactly ("30s"), since
lifetime_hours rounds short lifetimes to 0.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCEhG5Sap3gQCiaNzWEXqb
|
Thanks — all five were valid; fixed in e6ae0ce:
Each has a test; the runner fixes' two tests fail without them. |
… and the integration fixes Brings the branch onto current main (0.19.0 plus anima-research#203's per-call time_limit_ms). Conflicts in PyRunner.exec, the tool description and runCodeExecution are resolved as on the local execution integration branch: time_limit_ms bounds the Python run and wait_ms bounds the observation, the time-limit cap note survives a yield and a later wait. Also carries the fixes found while integrating (without anima-research#181's structured results, which stay in their own PR): - a script notification accepted into a live tool continuation retires its queued wake; a turn that ends keeps it armed - shutdown resolves pending inner-tool waiters; late tool results after the queue closed are dropped instead of raising - host status counts running background interpreters only, fixing the typecheck against anima-research#122's getHostModeStatus Co-Authored-By: Codex (GPT-6) <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The deadline's cancel op is an asyncio cancellation, so it reached a script only at an await. Blocking code (time.sleep, a busy loop, a blocking read) ran past the limit: it either returned and read as a success, or was killed after the 10s grace with its output and the interpreter's variables lost. At the deadline the host now also sends the interpreter SIGINT (not on Windows, where Node's kill() ends the process). The runtime installs its own handler, which raises KeyboardInterrupt only while the running script's frames are on the stack and at most once per script; anywhere else (between scripts, a suspended script, the protocol loop, a protocol write) it does nothing. _run_script catches it, so the result keeps the output so far, shows the line the script was stopped at, and gets the time-limit note, and the interpreter and its globals survive. The reader thread blocks SIGINT so the signal reaches the main thread. The kill grace stays as the backstop, and its message now names the time limit and the lost variables. Refs anima-research#235 (F3), anima-research#203. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er that asked Inner tool-call ids (t1, t2, ...) and wake ids restart in every interpreter, and a late result was sent to whichever interpreter was current when it arrived. After a script was aborted, killed at its time limit, or crashed while a call was in flight, and the next script started in a fresh interpreter, the old call's result resolved the new script's t1: the new script went on with the wrong data, and its own result was dropped. Each tool_call / wake is now answered on the child process that sent it; an answer whose interpreter is gone is dropped and logged. Refs anima-research#235, anima-research#203. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
An agent using
code_executioncan't choose how long its script may run. The whole-script limit is set once per deployment (codeExecution.scriptTimeoutMs, 10 min by default), and the tool description doesn't mention it. Claude Code's Bash tool, by contrast, takes a time limit on each call. This adds the same control here.When a script hits the limit, its result said only
KeyboardInterrupt: script cancelled by host, which doesn't tell the agent it ran out of time.Changes
code_executiontakes an optionaltime_limit_ms: this call's time limit.scriptTimeoutMs; may go up to the newcodeExecution.maxScriptTimeoutMs.background: true): shortens the lifetime; capped atbackgroundMaxLifetimeMs.maxScriptTimeoutMsdefaults toscriptTimeoutMs, so nothing changes for existing deployments: agents can only shorten the limit until an operator raises the ceiling.time_limit_notesaying so.script stopped: it reached its N time limit(in stderr, and in a background script's output tail, which is what its crash wake shows).Relationship to #155 and #181
wait_ms,on_timeout) from how long the script runs. This PR is the per-call control for the running side, so the two compose:wait_mssays when the tool returns,time_limit_mssays when the script is stopped. The name avoids "timeout", which fix(code-execution): separate script execution from bounded observation #155 uses for the watching side. Under fix(code-execution): separate script execution from bounded observation #155 a script outlives its observation, so a per-call limit still matters there (a long build watched briefly), and the ceiling defaulting to today's limit keeps a long script from holding the shared interpreter unless the deployment allows it.PyRunner.exec,runCodeExecutionand the tool description. If fix(code-execution): separate script execution from bounded observation #155 lands first, the per-exec deadline moves into its reserved-before-startupexec, and the description sentence joins fix(code-execution): separate script execution from bounded observation #155's compact description; I'll do that rebase.Tests
npm run build, thennpm test: 1108 tests, 1104 pass, 0 fail, 4 skipped.dist/test/code-execution.test.js: 33 pass, 7 of them new (real python3): per-exec deadline inPyRunner; description and schema; shortening one call while the next keeps the default; capping with the note;maxScriptTimeoutMsallowing longer; invalid values refused; background lifetime capped and the script stopped.runCodeExecution, 2 of the new tests fail (the other new tests cover the cap and note, which that mutation leaves in place).next/acceptance/agent-framework.mjsagainst this branch: 7 checks passed.Not verified
No live model or provider; the framework tests drive
executeToolCalldirectly. Not exercised through Connectome's recipes.changelog.d/code-execution-timeout.added.md🤖 Generated with Claude Code
https://claude.ai/code/session_01UCEhG5Sap3gQCiaNzWEXqb