fix(tbench2_env): canonical test.sh scoring, real timeouts, task-image workdir - #965
Conversation
289b100 to
4745fd2
Compare
501a20c to
82859ba
Compare
…ask env & workdir - _evaluate_task runs the task's official tests/test.sh (pinned uvx toolchain, /logs/verifier/reward.txt) when the task ships it; the bare pytest fallback (custom task dirs without the canonical harness) prefers uvx with a pinned pytest so it carries its own toolchain, and runs from the same workdir the agent worked in. - Pass timeouts through to every shell_exec call: camel's shell_exec has its own per-call default (20s) and ignores the constructor timeout, so agent commands and evaluations were silently truncated at 20s (circuit-fibsqrt's verifier declares 3600s and was cut mid-test). Agent execs use command_timeout_s (TB2_COMMAND_TIMEOUT_S-overridable); evaluate uses the task's own [verifier].timeout_sec budget. - Shell cwd resolves to the task image's own WORKDIR, parsed from the task's environment/Dockerfile (TB2_TASK_WORKDIR overrides): task images pin their own WORKDIR (fix-git: /app/personal-site, prove-plus-comm: /workspace) and both agents and oracle solutions assume commands run there. Falls back to the task source dir for plain local mode. - Drop install_dependencies=['pytest'] (evaluation carries its own pytest via uvx); with CAMEL_RUNTIME=true camel's .initial_env venv then stops hijacking PATH away from the task image's native python. - Serialize canonical evaluation under a lock so concurrent sessions on one server cannot clobber the official fixed verifier paths (/tests, /logs/verifier/reward.txt).
82859ba to
85343bd
Compare
| m = re.match(r"^\s*WORKDIR\s+(\S+)", line, re.IGNORECASE) | ||
| if m: | ||
| workdir = m.group(1) | ||
| return workdir |
There was a problem hiding this comment.
Multi-stage WORKDIR parsing is wrong
Medium Severity
_task_image_workdir keeps the last WORKDIR anywhere in the Dockerfile and claims that equals the final stage. Docker resets WORKDIR per FROM, so a builder-stage WORKDIR is used when the final stage omits one, sending agents and tests/test.sh to the wrong cwd.
Reviewed by Cursor Bugbot for commit 85343bd. Configure here.
There was a problem hiding this comment.
Checked all 89 official tasks: exactly one is multi-stage (financial-document-processor) and its final stage pins WORKDIR /app, so last-wins parses every official task correctly. Leaving the parser as is — TB2_TASK_WORKDIR covers exotic custom Dockerfiles.
| reward = float(raw) if raw else 0.0 | ||
| except ValueError: | ||
| reward = 0.0 | ||
| break |
There was a problem hiding this comment.
Timeout can drop a written reward
Medium Severity
Canonical scoring only trusts the trailing __TB2_REWARD__: echo from shell_exec output. The same timeout_s also covers staging, test.sh, tail, and that echo, so if camel truncates after test.sh has already written /logs/verifier/reward.txt, the reward is ignored and the episode scores 0.0.
Reviewed by Cursor Bugbot for commit 85343bd. Configure here.
There was a problem hiding this comment.
Working as intended: timeout_s is the task's declared verifier budget, and blowing it scores 0 in the official TB2 harness too. Recovering a reward written mid-timeout would deviate from that semantics.
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f1b0b8e. Configure here.
| self._terminal_toolkit = TerminalToolkit( | ||
| timeout=self.command_timeout_s, | ||
| working_directory=str(task_dir), | ||
| working_directory=workdir, |
There was a problem hiding this comment.
Fallback pytest path left broken
Medium Severity
Removing install_dependencies=['pytest'] drops the only mechanism that ensured pytest was available for the bare-pytest fallback, while that fallback still does python -m pytest when uvx is missing. Custom task dirs without tests/test.sh on images that lack both uvx and a preinstalled pytest will fail closed with reward 0.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit f1b0b8e. Configure here.
The tbench2_env fixes are upstream now (huggingface/OpenEnv#965 + #972); embedding the installed source remains the mechanism that guarantees the sandbox runs exactly the version validated locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The server-side fixes this backend scores through (canonical test.sh evaluate, server-side WORKDIR, TB2_WITHHOLD_TESTS) merged upstream as huggingface/OpenEnv#965 + #972, so the README no longer pins the fork (whose pinned sha was orphaned by a rebase anyway); the launcher preflight still fails fast on an older install. scan_golden now takes the test-log tail from the evaluate output itself — the patched server wipes the on-disk copy with the verify window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The server-side fixes this backend scores through (canonical test.sh evaluate, server-side WORKDIR, TB2_WITHHOLD_TESTS) merged upstream as huggingface/OpenEnv#965 + #972, so the README no longer pins the fork (whose pinned sha was orphaned by a rebase anyway); the launcher preflight still fails fast on an older install. scan_golden now takes the test-log tail from the evaluate output itself — the patched server wipes the on-disk copy with the verify window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The server-side fixes this backend scores through (canonical test.sh evaluate, server-side WORKDIR, TB2_WITHHOLD_TESTS) merged upstream as huggingface/OpenEnv#965 + #972, so the README no longer pins the fork (whose pinned sha was orphaned by a rebase anyway); the launcher preflight still fails fast on an older install. scan_golden now takes the test-log tail from the evaluate output itself — the patched server wipes the on-disk copy with the verify window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The server-side fixes this backend scores through (canonical test.sh evaluate, server-side WORKDIR, TB2_WITHHOLD_TESTS) merged upstream as huggingface/OpenEnv#965 + #972, so the README no longer pins the fork (whose pinned sha was orphaned by a rebase anyway); the launcher preflight still fails fast on an older install. scan_golden now takes the test-log tail from the evaluate output itself — the patched server wipes the on-disk copy with the verify window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tbench2_env fixes are upstream now (huggingface/OpenEnv#965 + #972); embedding the installed source remains the mechanism that guarantees the sandbox runs exactly the version validated locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The server-side fixes this backend scores through (canonical test.sh evaluate, server-side WORKDIR, TB2_WITHHOLD_TESTS) merged upstream as huggingface/OpenEnv#965 + #972, so the README no longer pins the fork (whose pinned sha was orphaned by a rebase anyway); the launcher preflight still fails fast on an older install. scan_golden now takes the test-log tail from the evaluate output itself — the patched server wipes the on-disk copy with the verify window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nction Review follow-up on miles#1675: the generic adapter no longer knows about Daytona; mode selection is explicit plugin selection. - New openenv_daytona_agent_function.py: a drop-in --custom-agent-function-path alternative that runs every episode in its own Daytona cloud sandbox. The sandbox-create machinery (throttled retries, cancel-reap, episode env) moves there from openenv_agent_function, which keeps only the shared loop, the training wrapper, and the shared-env-server leg. - Every agent-function module exposes the same two entries: run() for miles (session-server policy wiring + training failure semantics) and run_episode() for callers that bring their own policy client and timeout/failure semantics (eval_tbench2_via_api). The per-leg differences enter the loop as three keyword parameters (run_body / native_evaluate / post_episode) filled in only by each module's run_episode — no backend objects, no env-var-sniffing dispatch. - native_evaluate replaces the old per_task flag: the branches it keys are about the SERVER CONTRACT (canonical test.sh inside `evaluate`, server-side WORKDIR — upstream since huggingface/OpenEnv#965+#972), not about per-task images; the shared docker-mode server runs per-task images too. The rm-hack becomes a post_episode hook (shared-server hygiene, unrelated to scoring). - The launcher picks the agent function from openenv_tb2_tasks_dir. Behavior change: setting OPENENV_TB2_TASKS_DIR while pointing at openenv_agent_function.run no longer switches modes implicitly. - Vocabulary: the two legs are "Daytona sandbox mode" vs "shared env server"; "per-task" is reserved for image semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tbench2_env fixes are upstream now (huggingface/OpenEnv#965 + #972); embedding the installed source remains the mechanism that guarantees the sandbox runs exactly the version validated locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The server-side fixes this backend scores through (canonical test.sh evaluate, server-side WORKDIR, TB2_WITHHOLD_TESTS) merged upstream as huggingface/OpenEnv#965 + #972, so the README no longer pins the fork (whose pinned sha was orphaned by a rebase anyway); the launcher preflight still fails fast on an older install. scan_golden now takes the test-log tail from the evaluate output itself — the patched server wipes the on-disk copy with the verify window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nction Review follow-up on miles#1675: the generic adapter no longer knows about Daytona; mode selection is explicit plugin selection. - New openenv_daytona_agent_function.py: a drop-in --custom-agent-function-path alternative that runs every episode in its own Daytona cloud sandbox. The sandbox-create machinery (throttled retries, cancel-reap, episode env) moves there from openenv_agent_function, which keeps only the shared loop, the training wrapper, and the shared-env-server leg. - Every agent-function module exposes the same two entries: run() for miles (session-server policy wiring + training failure semantics) and run_episode() for callers that bring their own policy client and timeout/failure semantics (eval_tbench2_via_api). The per-leg differences enter the loop as three keyword parameters (run_body / native_evaluate / post_episode) filled in only by each module's run_episode — no backend objects, no env-var-sniffing dispatch. - native_evaluate replaces the old per_task flag: the branches it keys are about the SERVER CONTRACT (canonical test.sh inside `evaluate`, server-side WORKDIR — upstream since huggingface/OpenEnv#965+#972), not about per-task images; the shared docker-mode server runs per-task images too. The rm-hack becomes a post_episode hook (shared-server hygiene, unrelated to scoring). - The launcher picks the agent function from openenv_tb2_tasks_dir. Behavior change: setting OPENENV_TB2_TASKS_DIR while pointing at openenv_agent_function.run no longer switches modes implicitly. - Vocabulary: the two legs are "Daytona sandbox mode" vs "shared env server"; "per-task" is reserved for image semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Docker mode (Tbench2DockerEnvironment) was left behind when huggingface#965/huggingface#972 landed the canonical contract in local mode: its evaluate still ran bare `pytest tests/` from /task (the task-source copy), skipping the task's own tests/test.sh (pinned toolchain, reward.txt verdict) and the image's real WORKDIR — the same fidelity gaps huggingface#965 fixed for local mode. - evaluate now stages tests/ at the official fixed path (/tests), runs canonical tests/test.sh from the agent's workdir bounded by the task's verifier budget, and reads /logs/verifier/reward.txt; task dirs without test.sh fall back to pytest. Both fixed paths are wiped with the verify window. The command construction and marker parsing are factored into module helpers shared with local mode. - agent commands run in the task image's own WORKDIR, resolved from image metadata (Config.WorkingDir, which sees base-image WORKDIRs) with the Dockerfile parse as fallback. - exec commands ride as a bash -c argv element instead of being spliced into a single-quoted shell string, which mangled any agent command containing a single quote. - a task dir that declares no [environment] docker_image is rejected at reset. The old silent fallback ran agent commands directly on the env-server host: a containment hole and a scoring-fidelity hole at once. The containerless code paths (host subprocess exec, _evaluate_local) are gone with it. - TB2_MODE=auto is removed: it claimed to auto-detect Docker but always ran local mode; unknown modes now fail at startup instead of silently serving local. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Docker mode (Tbench2DockerEnvironment) was left behind when huggingface#965/huggingface#972 landed the canonical contract in local mode: its evaluate still ran bare `pytest tests/` from /task (the task-source copy), skipping the task's own tests/test.sh (pinned toolchain, reward.txt verdict) and the image's real WORKDIR — the same fidelity gaps huggingface#965 fixed for local mode. - evaluate now stages tests/ at the official fixed path (/tests), runs canonical tests/test.sh from the agent's workdir bounded by the task's verifier budget, and reads /logs/verifier/reward.txt; task dirs without test.sh fall back to pytest. Both fixed paths are wiped with the verify window. The command construction and marker parsing are factored into module helpers shared with local mode. - agent commands run in the task image's own WORKDIR, resolved from image metadata (Config.WorkingDir, which sees base-image WORKDIRs) with the Dockerfile parse as fallback. - exec commands ride as a bash -c argv element instead of being spliced into a single-quoted shell string, which mangled any agent command containing a single quote. - a task dir that declares no [environment] docker_image is rejected at reset. The old silent fallback ran agent commands directly on the env-server host: a containment hole and a scoring-fidelity hole at once. The containerless code paths (host subprocess exec, _evaluate_local) are gone with it. - TB2_MODE=auto is removed: it claimed to auto-detect Docker but always ran local mode; unknown modes now fail at startup instead of silently serving local. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…act (#1012) * fix(tbench2_env): bring Docker mode up to the canonical scoring contract Docker mode (Tbench2DockerEnvironment) was left behind when #965/#972 landed the canonical contract in local mode: its evaluate still ran bare `pytest tests/` from /task (the task-source copy), skipping the task's own tests/test.sh (pinned toolchain, reward.txt verdict) and the image's real WORKDIR — the same fidelity gaps #965 fixed for local mode. - evaluate now stages tests/ at the official fixed path (/tests), runs canonical tests/test.sh from the agent's workdir bounded by the task's verifier budget, and reads /logs/verifier/reward.txt; task dirs without test.sh fall back to pytest. Both fixed paths are wiped with the verify window. The command construction and marker parsing are factored into module helpers shared with local mode. - agent commands run in the task image's own WORKDIR, resolved from image metadata (Config.WorkingDir, which sees base-image WORKDIRs) with the Dockerfile parse as fallback. - exec commands ride as a bash -c argv element instead of being spliced into a single-quoted shell string, which mangled any agent command containing a single quote. - a task dir that declares no [environment] docker_image is rejected at reset. The old silent fallback ran agent commands directly on the env-server host: a containment hole and a scoring-fidelity hole at once. The containerless code paths (host subprocess exec, _evaluate_local) are gone with it. - TB2_MODE=auto is removed: it claimed to auto-detect Docker but always ran local mode; unknown modes now fail at startup instead of silently serving local. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: sync tbench2 env docs after README update Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: make docker reset fail closed --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: burtenshaw <ben.burtenshaw@gmail.com>


Summary
Makes local-mode
Tbench2Environmentfaithful to real Terminal-Bench-2 semantics:evaluatescores with each task's officialtests/test.sh(pinned uvx toolchain,/logs/verifier/reward.txt), command execution honors real time budgets, and commands run in the task image's own WORKDIR. Follow-up to #503, which fixed evaluate always returning 0 — this makes the returned reward match the official TB2 verifier.Details:
pytest tests/(current behavior) skips the toolchain the official harness pins (uvx: Python 3.13, pytest 8.4.1, ctrf) and mis-scores tasks that depend on it._evaluate_tasknow runstests/test.shwhen the task ships it; the bare-pytest fallback remains for custom task dirs, upgraded to preferuvx --with pytest==8.4.1so it carries its own toolchain too.shell_exechas a per-call default of 20s and ignores the constructor timeout, silently truncating agent commands and evaluations (e.g.circuit-fibsqrtdeclares a 3600s verifier budget and was cut mid-test, scoring a false 0). Agent execs now passcommand_timeout_s(overridable viaTB2_COMMAND_TIMEOUT_S); evaluate passes the task's own[verifier].timeout_sec./app(fix-git:/app/personal-site,prove-plus-comm:/workspace). The shell cwd now resolves from the task'senvironment/Dockerfile(TB2_TASK_WORKDIRoverrides), falling back to the task source dir for plain local mode.install_dependencies=['pytest']: evaluation now carries its own pytest via uvx; when the server runs withCAMEL_RUNTIME=true, camel's.initial_envvenv then also stops shadowing the task image's native python on PATH.Type of Change
Alignment Checklist
.claude/docs/PRINCIPLES.mdand this PR aligns with our principles (notably: rewards are computed inside the environment, per RFC 002).claude/docs/INVARIANTS.mdand no invariants are violated (Gym API signatures unchanged; server-internal changes only)bash .claude/hooks/lint.sh— the touched file is clean (pre-existing failures in other envs are untouched)RFC Status
Test Plan
Validated on real per-task TB2 containers (official task images), scoring official solutions under the official oracle convention:
setuptoolsfor pystan, etc. — happy to share the per-task evidence).circuit-fibsqrt/compile-compcert(timeouts),fix-git/prove-plus-comm(workdir), plus correct scoring across the suite via test.sh.Note: the canonical eval uses the official global paths (
/tests,/logs/verifier/reward.txt), which assumes one concurrent session per container — consistent with TB2's one-task-per-container model (and with one-env-one-trajectory); flagging for reviewers running multi-session servers against a single container.Claude Code Review
/alignment-review run on this change: Tier 1 (format) fixed; flags addressed during review — fallback now carries its own pytest toolchain; workdir resolution centralized server-side so all deployment modes agree. No principle or RFC conflicts identified.
Note
Medium Risk
Changes reward computation and shell cwd/timeouts for all local TB2 sessions; canonical eval uses shared
/testspaths and requires the lock when multiple sessions share one container.Overview
Aligns local-mode
Tbench2Environmentwith official Terminal-Bench 2 verifier behavior so rewards and execution context match what agents and the harness expect.Evaluation now prefers each task’s
tests/test.shwhen present: tests are staged under/tests, the harness runs from the resolved task workdir, and reward is read from/logs/verifier/reward.txtinstead of inferring pass/fail from barepytest. A thread lock serializes canonical evals so concurrent sessions on one server do not clash on/testsor the reward file. Tasks withouttest.shkeep a pytest fallback, now preferringuvx --with pytest==8.4.1and using the agent workdir; verifier runs use[verifier].timeout_secfromtask.toml.Shell execution passes
timeout=self.command_timeout_sintoshell_exec(camel’s per-call 20s default was truncating long commands).command_timeout_sdefaults viaTB2_COMMAND_TIMEOUT_Swhen the env is constructed with no args.Working directory is resolved on
resetfromTB2_TASK_WORKDIR, else the taskenvironment/DockerfileWORKDIR(ignored when it overlaps the server install tree), else the task source dir—replacing always using the task folder as cwd.install_dependencies=['pytest']is removed fromTerminalToolkitbecause scoring brings its own toolchain.Tests add coverage for Dockerfile workdir selection and server-tree rejection.
Reviewed by Cursor Bugbot for commit f1b0b8e. Bugbot is set up for automated code reviews on this repo. Configure here.