Skip to content

feat(terminus-2): honour Terminal-Bench per-task agent timeouts - #3148

Open
wprazuch wants to merge 1 commit into
mainfrom
wprazuch/tb21-per-task-agent-timeout
Open

wprazuch wants to merge 1 commit into
mainfrom
wprazuch/tb21-per-task-agent-timeout

Conversation

@wprazuch

@wprazuch wprazuch commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Problem

Terminal-Bench declares an agent time budget per task, in each task's task.toml:

[agent]
timeout_sec = 900.0

The sandboxed Terminus-2 agent never reads it. prepare.py takes only task.name,
environment.docker_image and the task folder, and app.py wraps the agent loop in a single
flat sandbox_timeout (10800 s by default) that applies to every task equally.

Across the 89 tasks in Terminal-Bench 2.1 the declared budgets range from 600 s to 12000 s, with
a median of 900 s — 48 of the 89 tasks budget exactly 900 s. A flat three-hour wall is therefore
12x the declared budget for the majority of the suite.

On a recent 712-rollout run (89 tasks x 8 repeats) this had a measurable effect on the reported
score, not just on cost:

  • 338 of 712 rollouts (47.5%) ran past their own task's budget, the worst by 14.4x.
  • 24 of the 134 solved rollouts were solved out of spec — they were still running long after
    the task's declared budget had expired. Of those 24, 19 first reported task completion only
    after the budget had already passed
    , so they are not solves under the benchmark's own rules.
  • Mean trial duration was 3432 s. For comparison, every accepted Terminus-2 submission on the
    public Terminal-Bench 2.1 leaderboard reports a mean trial duration between 548 s and 1043 s.

The reference implementation does honour the per-task value. Harbor's own trial layer resolves
config.agent.override_timeout_sec or self._task.config.agent.timeout_sec, caps it with
config.agent.max_timeout_sec, and applies the result as the deadline for agent.run(...).
Because this agent calls agent.run(...) directly rather than going through Trial, that logic
is bypassed.

Change

  • benchmarks/terminal_bench_2_1/prepare.py emits agent_timeout_sec on every row, read from
    task.toml.
  • terminus_2_sandboxed_agent uses that value as the agent-loop deadline when the dataset row
    provides one, and falls back to sandbox_timeout when it does not.
  • New max_agent_timeout config key caps the resolved budget. This mirrors Harbor's
    agent.max_timeout_sec and exists so a long-tail task cannot stretch a whole job's wall clock.
    It defaults to null (uncapped), so it is opt-in.
  • The resolved deadline is recorded as an agent_timeout metric, so a completed run can be
    audited against the budget it actually ran under.

I followed Harbor's semantics (per-task value, capped) rather than anyterminal_agent's
(per-task value with a global_agent_timeout that replaces it), because a cap is what keeps a
job's wall clock bounded without discarding the per-task budgets. The dataset field is named
agent_timeout_sec to match the name anyterminal_agent already uses for the same task.toml
field.

Compatibility

Behaviour is unchanged for any dataset that does not emit agent_timeout_sec — that path still
resolves to sandbox_timeout. Terminal-Bench 2.1 runs will change, which is the intent.

Anyone comparing to earlier Terminal-Bench 2.1 numbers should treat this as a new baseline rather
than a regression: the previous numbers measure reward under a flat wall, and these measure reward
under the benchmark's declared budgets. On the run described above the change is worth roughly
-0.034 absolute (0.188 to 0.155), because it withdraws the 24 out-of-spec solves.

Testing

  • uv run python -m pytest responses_api_agents/terminus_2_sandboxed_agent/tests/test_app.py — 15 passed.
  • New parametrised test covers fallback, per-task override in both directions, and the cap
    binding and not binding.
  • Ran the new prepare.py extraction expression against all 89 real task.toml files: 89/89
    yield a non-null budget, distribution {600: 1, 750: 1, 900: 48, 1200: 5, 1800: 17, 2400: 2, 3600: 13, 7200: 1, 12000: 1}.
  • Confirmed the extra dataset field survives onto Terminus2AgentRunRequest (extra="allow") and
    that its absence resolves to None.
  • ruff check and ruff format --check clean on the touched trees.

Terminal-Bench declares an agent budget per task in task.toml ([agent]
timeout_sec). The sandboxed Terminus-2 agent ignored it and applied the flat
sandbox_timeout to every task, so short tasks ran far past their own budget.

prepare.py now carries agent_timeout_sec on each benchmark row, and the agent
uses it as the agent-loop deadline, falling back to sandbox_timeout when a
dataset supplies no per-task value. A new max_agent_timeout config key caps the
resolved budget, mirroring Harbor's agent.max_timeout_sec. The resolved value is
recorded as the agent_timeout metric so runs are auditable after the fact.

Behaviour is unchanged for datasets that do not emit agent_timeout_sec.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
@github-actions github-actions Bot added the sla:triage-overdue Review assignment is over the one-business-day SLA label Sep 8, 2026
@yaoyu-33 yaoyu-33 added area:agent Agent harnesses and Responses API agent behavior bug Something isn't working needs-review PR is ready for code review and waiting on a reviewer labels Sep 8, 2026
Comment on lines +332 to +337
def _resolve_agent_timeout(self, task_timeout: float | None) -> float:
"""Per-task budget when the dataset supplies one, else the flat fallback, then capped."""
timeout = self.config.sandbox_timeout if task_timeout is None else float(task_timeout)
if self.config.max_agent_timeout is not None:
timeout = min(timeout, self.config.max_agent_timeout)
return timeout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we keep sandbox_timeout as the existing global hard cap? The per-task wiring is necessary, but adding max_agent_timeout is not: with its default of null, a task timeout can now exceed sandbox_timeout, changing the existing config contract from “maximum agent wall clock” to “fallback only.” Taking the minimum preserves backward compatibility while still honoring shorter per-task budgets. If Terminal-Bench 2.1 needs the full 12,000-second long-tail budget, its benchmark config can explicitly raise sandbox_timeout.

Suggested change
def _resolve_agent_timeout(self, task_timeout: float | None) -> float:
"""Per-task budget when the dataset supplies one, else the flat fallback, then capped."""
timeout = self.config.sandbox_timeout if task_timeout is None else float(task_timeout)
if self.config.max_agent_timeout is not None:
timeout = min(timeout, self.config.max_agent_timeout)
return timeout
def _resolve_agent_timeout(self, task_timeout: float | None) -> float:
"""Use the per-task budget without exceeding the configured sandbox timeout."""
if task_timeout is None:
return self.config.sandbox_timeout
return min(float(task_timeout), self.config.sandbox_timeout)

With this, max_agent_timeout can also be removed from the config model and YAML.

@yaoyu-33 yaoyu-33 added waiting-on-customer Waiting on the original author to respond and removed needs-review PR is ready for code review and waiting on a reviewer labels Sep 10, 2026
@github-actions github-actions Bot removed the sla:triage-overdue Review assignment is over the one-business-day SLA label Sep 10, 2026
@yaoyu-33 yaoyu-33 added the complexity:low Localized change in one scope with a small, straightforward review surface label Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:agent Agent harnesses and Responses API agent behavior bug Something isn't working complexity:low Localized change in one scope with a small, straightforward review surface waiting-on-customer Waiting on the original author to respond

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants