Skip to content

feat(code-execution): per-call time limit (time_limit_ms) - #203

Merged
antra-tess merged 3 commits into
anima-research:mainfrom
Meganeuridae:feat/code-execution-timeout
Oct 2, 2026
Merged

antra-tess merged 3 commits into
anima-research:mainfrom
Meganeuridae:feat/code-execution-timeout

Conversation

@Meganeuridae

@Meganeuridae Meganeuridae commented Oct 2, 2026 •

Copy link
Copy Markdown

Problem

An agent using code_execution can'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_execution takes an optional time_limit_ms: this call's time limit.
    • Foreground: defaults to scriptTimeoutMs; may go up to the new codeExecution.maxScriptTimeoutMs.
    • Background (background: true): shortens the lifetime; capped at backgroundMaxLifetimeMs.
  • maxScriptTimeoutMs defaults to scriptTimeoutMs, so nothing changes for existing deployments: agents can only shorten the limit until an operator raises the ceiling.
  • A request above the ceiling is capped, not refused. The result carries time_limit_note saying so.
  • A non-number or a value under 1000 ms is refused before anything runs.
  • The tool description now states the limit and its maximum. A stopped script says 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

Tests

  • npm run build, then npm 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 in PyRunner; description and schema; shortening one call while the next keeps the default; capping with the note; maxScriptTimeoutMs allowing longer; invalid values refused; background lifetime capped and the script stopped.
  • With the per-call limit disabled in runCodeExecution, 2 of the new tests fail (the other new tests cover the cap and note, which that mutation leaves in place).
  • terminal-sessions-mcp 2.0's next/acceptance/agent-framework.mjs against this branch: 7 checks passed.

Not verified

No live model or provider; the framework tests drive executeToolCall directly. Not exercised through Connectome's recipes.


  • Changelog fragment added — changelog.d/code-execution-timeout.added.md

🤖 Generated with Claude Code

https://claude.ai/code/session_01UCEhG5Sap3gQCiaNzWEXqb

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
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5 Tier: apex

[Medium risk] Adds per-call time limits to code execution.

The PR is not safe to merge until oversized permitted deadlines stop cancelling scripts immediately.

Findings

  1. P1 Long deadlines cancel immediately ▶
  2. P2 Completed scripts appear stopped ▶
  3. P2 Background lifetime is misstated ▶
  4. P2 Configured ceiling is ignored ▶
  5. P2 Short lifetimes display as zero ▶
Fix with agent prompt
### Issue 1
src/code-execution/py-runner.ts:174
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.

### Issue 2
src/code-execution/py-runner.ts:404-407
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.

### Issue 3
src/code-execution/tool-definition.ts:44-45
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)}). ` +
```

### Issue 4
src/code-execution/tool-definition.ts:108
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.

### Issue 5
src/framework.ts:10874
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.

Summary

Adds an optional, deployment-capped timeout_ms for foreground and background Python scripts, updates the tool contract and deadline diagnostics, and adds integration tests.

  • Existing foreground defaults remain unchanged unless an operator raises the new maximum.
  • Background requests can shorten the configured watcher lifetime.

Reviews (1) · Last reviewed commit: "feat(code-execution): per-call time limi..."

Comment thread src/code-execution/py-runner.ts Outdated

const execId = `e${++this.execCounter}`;
const deadlineMs = background?.lifetimeMs ?? this.scriptTimeoutMs;
const deadlineMs = background?.lifetimeMs ?? opts?.deadlineMs ?? this.scriptTimeoutMs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/code-execution/py-runner.ts Outdated
Comment on lines +404 to +407
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 } : {}) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment thread src/code-execution/tool-definition.ts Outdated
Comment on lines +44 to +45
`A script is stopped after ${formatLimit(limits.defaultMs)}; pass timeout_ms to set this call's limit ` +
`(at most ${formatLimit(limits.maxMs)}). ` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
`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.

Comment thread src/code-execution/tool-definition.ts Outdated
return {
defaultMs,
// Never below the default: asking for the default must always be allowed.
maxMs: Math.max(defaultMs, opts?.maxScriptTimeoutMs ?? defaultMs),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment thread src/framework.ts
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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
@Meganeuridae Meganeuridae changed the title feat(code-execution): per-call time limit (timeout_ms) feat(code-execution): per-call time limit (time_limit_ms) Oct 2, 2026
- 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
@Meganeuridae

Copy link
Copy Markdown
Author

Thanks — all five were valid; fixed in e6ae0ce:

  1. Long deadlines cancel immediately — every deadline is now clamped to Node's timer range (~24.8 days), in PyRunner.exec and in the reported limits.
  2. Completed scripts appear stopped — the "script stopped" note is added only when the script ended with a non-zero return code.
  3. Background lifetime misstated — the description now says "A foreground script is stopped after …" and states the background lifetime.
  4. Configured ceiling ignored — maxScriptTimeoutMs below scriptTimeoutMs is now refused at AgentFramework.create, before anything opens.
  5. Short lifetimes display as zero — the background start result adds an exact lifetime (e.g. "30s") beside lifetime_hours.

Each has a test; the runner fixes' two tests fail without them. npm test: 1111 tests, 1107 pass, 0 fail, 4 skipped. (The option is now time_limit_ms, renamed after this review ran.)

@antra-tess
antra-tess merged commit e45c1a1 into anima-research:main Oct 2, 2026
5 checks passed
Meganeuridae pushed a commit to Meganeuridae/agent-framework that referenced this pull request Oct 2, 2026
… 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>
rergards pushed a commit to rergards/agent-framework that referenced this pull request Oct 8, 2026
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>
rergards pushed a commit to rergards/agent-framework that referenced this pull request Oct 8, 2026
…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>
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.

2 participants