feat(terminal-bench): selectable agent harness plus full control and tracking - #20
feat(terminal-bench): selectable agent harness plus full control and tracking#20abhinav-pola wants to merge 4 commits into
Conversation
…tracking Terminal Bench hardcoded the `pi` coding agent, so a result measured pi-plus-model with no way to compare a different agent on the same tasks. Add a selectable agent and fill in the control knobs and result columns both paths were missing. Agent selection: - `agent: "pi" | "claude"`, defaulting to `pi`. The pi path keeps its direct `pi --print --mode json` invocation, so existing runs are unchanged. - `claude` runs Claude Code inside the sandbox through Ori Harness (`ori claude`), which injects OpenRouter auth from the `OPENROUTER_API_KEY` the sandbox already receives. ori installs as a standalone Linux binary; no extra runtime is needed. Control, now available for both agents: - reasoning level: `thinking` (pi `--thinking`, gains `max`) and `effort` (claude `--effort`). Separate enums because claude has no `off`/`minimal`. - `systemPrompt` override, `allowedTools`, `disallowedTools`. - `isolateAgentConfig` disables extension, skill, prompt-template and context-file discovery for reproducible runs. Defaults to `false` to preserve current pi behavior. Tracking. `generation_ids` now resolves against the OpenRouter generations API for either agent, so spend is reconcilable from stored results: - pi: `responseId` per assistant message, `usage.reasoning` for reasoning tokens, wall-clock `generationTimeMs` (was hardcoded 0), and stream events as `responseItems` (was null). - claude: `message.id`, `total_cost_usd`, `thinking_tokens`, `duration_ms`, `num_turns`, reconstructed assistant turns. - both: turn and tool-call counts, plus `agent`, `agentExitCode` and `agentIsError` in sample metadata. A pi request that fails at the API (for example an invalid model id) exits 0 with empty content and zero usage, which was indistinguishable from a cheap successful run. `stopReason: "error"` and `errorMessage` are now collected and surfaced in the verifier output. `terminalBenchScorer` emits a `verifier_log` trajectory, filling `scorer_trajectory` for both agents. Score values are unchanged; no score movement is expected from any change here. Prompts and tool lists pass through env vars rather than argv to avoid shell quoting issues. Flags were verified against pi 0.84.1 and Claude Code 2.1.228, and parsers against captured real streams from both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Terminal Bench is a Harbor benchmark - its tasks come from harbor-framework/terminal-bench-2-1 and its task.toml is the same format deep-swe parses - but it carried a parallel reimplementation of the harbor sandbox. That divergence silently dropped per-task settings. `TaskTomlSchema` requires `cpus`, `memory_mb` and `gpus` and defaults `allow_internet`, but the dataset never read them, the local `CreateSessionInput` had no fields for them, and the local Modal layer passed only a timeout, workdir and command. Every task therefore ran at Modal's defaults: 22 of 90 tasks request more than 2048 MB and 6 request more than 1 CPU. An under-provisioned task does not fail cleanly, it OOMs or thrashes and scores 0, which reads as model failure. Delete `terminal-bench/sandbox.ts` and `terminal-bench/modal-sandbox.ts` and move both solvers onto `harbor/sandbox.ts`: - `session.ts` builds harbor's `CreateSessionInput`, passing the task's declared cpus, memory and network policy, with the tests directory and instruction as `uploads`. - `runTerminalBenchVerifier` replaces the sandbox-owned `runTests()`, matching how swe-atlas verifies, and uses harbor's `parseReward`. - The Modal layer is harbor's, with the terminal-bench app name. Reward parsing is unchanged in practice: every one of the 90 tasks writes `echo 1` or `echo 0` to reward.txt, so harbor's `parseReward` and the old strict `=== "1"` agree on this dataset. Terminal Bench also gains `attach()`, which checkpoint and resume would need later. Expected score change: tasks that declare more than the default cpus or memory were under-provisioned and are now given what they ask for. Scores on those 28 tasks may move, presumably upward, and are not comparable to previous runs. Nothing else here alters scoring. Test fakes live in `test/helpers/terminal-bench-sandbox.ts` over harbor's callback fake, so no test-only code sits in src. A dataset test walks all 90 real task files and asserts every declared cpus and memory survives into sample metadata. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With terminal-bench on the harbor sandbox, the agent-CLI machinery it grew is no longer terminal-bench specific. Lift it into `agent-cli/` and let the other harbor benchmarks select it. - `agent-cli/schema.ts` owns the agent enums, effort levels and package defaults. `terminal-bench/schema` re-exports what it previously declared, so the published `benchmarks/terminal-bench/schema` entry point is unchanged. - `agent-cli/harness.ts` is the former `terminal-bench/ori-harness.ts`, with the instruction and log paths now parameters rather than constants, since each benchmark mounts its instruction elsewhere. - `agent-cli/runner.ts` holds the shared exec, stream parse, generation-id recording, failure summary and metadata shaping. `terminal-bench/ori-solver` drops to a thin wrapper over it. swe_atlas (all three tracks) and deep_swe gain `agent`, defaulting to `native`, which is the existing `runAgentLoop` path. Selecting `claude` installs the agent into the task image and runs it in place of the loop; each benchmark keeps its own verifier, so swe-atlas still verifies in the agent sandbox and deep-swe still extracts a patch and verifies in a second one. The shared control knobs (effort, system prompt, tool allow and deny lists, config isolation) come along through `AgenticOptionsSchema`. deep-swe additionally uploads its instruction to /instruction.md on the CLI path, because the native path passes the prompt in memory and never needed a file in the sandbox. Two capability gaps on the CLI path, both inherent to driving an external process rather than the loop: - `stepLimit` has no equivalent. Neither pi 0.84.1 nor Claude Code 2.1.228 exposes a turn limit, so the wall-clock agent timeout is the only bound. - deep-swe checkpoint resume is skipped. A `claude -p` process cannot be resumed from step N, so the CLI path always starts a fresh sandbox rather than attaching to a checkpointed one. wandr is deliberately excluded: it injects OpenRouter server tools into its own requests, which an external CLI replaces with its own tools, so the benchmark would no longer measure what it is for. Results from the CLI path measure a different system than `runAgentLoop` and are not comparable to existing harbor baselines. The native default keeps current numbers reproducible; tests assert the native path still builds no image steps and still drives the model loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| if (cliHarness !== undefined) { | ||
| const cliRun = yield* runAgentCli({ | ||
| session: agentSession, | ||
| harness: cliHarness, | ||
| opts: cliOpts, | ||
| instructionPath: REMOTE_AGENT_INSTRUCTION, | ||
| timeoutMs: meta.maxAgentTimeoutSec * 1000 + 30000, | ||
| }); |
There was a problem hiding this comment.
🔴 Coding-agent runs for DeepSWE tasks cannot reach the model provider, so every such run fails
The DeepSWE agent container is started with outside network access turned off (allowInternet: meta.allowInternet at src/benchmarks/deep-swe/solver.ts:135) even when the newly added in-container coding agent is selected, so that agent can never contact the model service and the run always fails.
Impact: Selecting the Claude agent for DeepSWE produces failed/zero-scoring runs for every task.
Why deep-swe tasks are always network blocked on the CLI path
DeepSWE declares allow_internet with default false (src/benchmarks/deep-swe/schema.ts:36) and readDeepSweMeta also defaults it to false (src/benchmarks/deep-swe/dataset.ts:152-155, DEFAULT_ALLOW_INTERNET = false at src/benchmarks/deep-swe/dataset.ts:34). The Modal layer maps this to blockNetwork: !input.allowInternet (src/benchmarks/harbor/modal-sandbox.ts:81).
On the native path this is harmless because model calls are made from the host. On the new CLI path (src/benchmarks/deep-swe/solver.ts:190-197) the ori claude process runs inside the sandbox and must reach OpenRouter over the network, so with blockNetwork: true the agent invocation cannot succeed. Note the image build steps do have network (build phase), which masks the problem until the agent actually runs.
swe-atlas is unaffected because its schema defaults allow_internet to true (src/benchmarks/swe-atlas/schema.ts:28).
Prompt for agents
When the deep-swe solver runs an in-sandbox agent CLI (agent=claude), the agent process itself needs outbound network access to reach OpenRouter, but deep-swe tasks declare allow_internet=false by default (see src/benchmarks/deep-swe/schema.ts and src/benchmarks/deep-swe/dataset.ts), and makeModalSandboxLayer translates that into blockNetwork: true (src/benchmarks/harbor/modal-sandbox.ts). As a result the CLI path will always fail on deep-swe. Consider whether the agent sandbox should force allowInternet: true when cliHarness !== undefined (documenting the deviation from the task's declared network policy), or whether an explicit config knob should be required, and make sure the verifier sandbox keeps the task-declared policy so scoring stays offline.
Was this helpful? React with 👍 or 👎 to provide feedback.
| return [ | ||
| "RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates git", | ||
| "ENV NVM_DIR=/root/.nvm", | ||
| `RUN curl -o- ${NVM_INSTALL_URL} | bash`, | ||
| `RUN . /root/.nvm/nvm.sh && nvm install ${NODE_VERSION} && npm install -g ${opts.agentPackage} && ln -sf $(which ${opts.binaryName}) /usr/local/bin/${opts.binaryName} && ln -sf $(which node) /usr/local/bin/node && ln -sf $(which npm) /usr/local/bin/npm`, | ||
| `RUN curl -fsSL ${opts.oriInstallUrl} | ORI_INSTALL_DIR=${ORI_INSTALL_DIR} bash`, | ||
| `RUN ori --version && ${opts.binaryName} --version`, | ||
| ]; |
There was a problem hiding this comment.
🟨 Unvalidated config values are interpolated into Dockerfile RUN commands and piped to a shell
agentPackage and oriInstallUrl come straight from user-supplied benchmark config (agentPackage: z.string().optional() and oriInstallUrl: z.string().default(...) in src/benchmarks/benchmark-config.ts:146-147) and are interpolated unescaped into RUN lines, including curl -fsSL ${opts.oriInstallUrl} | ... bash and npm install -g ${opts.agentPackage}. A value containing shell metacharacters becomes arbitrary commands executed during the image build, and an attacker-influenced install URL causes an unverified remote script to be piped into bash inside the build container.
Was this helpful? React with 👍 or 👎 to provide feedback.
Why
Terminal Bench hardcoded the
picoding agent, so a result measured pi + model with no way to compare a different agent on the same tasks. This PR adds a selectable agent, converges Terminal Bench onto the Harbor sandbox it should always have used, and extends the agent selection to the other Harbor benchmarks.Three commits, each reviewable on its own:
feat(terminal-bench)— selectable agent harness plus full control and trackingrefactor(terminal-bench)— converge onto the harbor sandboxfeat(harbor)— run Claude Code via ori on swe-atlas and deep-swe1. Selectable agent on Terminal Bench
agentdefaults topi, whose directpi --print --mode jsoninvocation is unchanged.Ori Harness is not a proxy — it sets
ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKENto OpenRouter and execs the real CLI, resolving auth from theOPENROUTER_API_KEYthe sandbox already receives. It installs as a standalone Linux binary.Control
--thinking(gainsmax)--effortsystemPromptoverride--system-prompt--system-promptappendSystemPrompt--append-system-prompt--append-system-promptallowedTools/disallowedTools--tools/--exclude-tools--allowedTools/--disallowedToolsisolateAgentConfig--no-extensions/-skills/-prompt-templates/-context-files--exclude-dynamic-system-prompt-sectionsthinkingandeffortare separate enums because claude's scale has nooff/minimal. Prompts and tool lists pass through env vars rather than argv to avoid shell quoting issues.Tracking
generation_idsnow resolves against the OpenRouter generations API for either agent, so spend is reconcilable from stored results.generation_idsresponseIdmessage.idusage.cost.totaltotal_cost_usdusage.reasoningthinking_tokensgeneration_time_ms0)duration_msagentTurns/agentToolCallsnum_turns/tool_useblocksresponse_itemsori-parquet.test.tsruns the solver through the real parquet writer, reads the file back, and asserts no unexpectedly null columns.Bug fix: silent pi API failures
A pi request failing at the API — for example a model id valid at Anthropic but not on OpenRouter — exits 0 with empty content,
stopReason: "error"and all-zero usage. The old parser returnedundefinedfor zero tokens and the solver substituted zeroed usage, making a hard 400 indistinguishable from a cheap successful run scoring 0. That is now surfaced in the verifier output withagentIsErrorin metadata. Past pi runs showing reward 0 with zero cost are worth re-checking.2. Harbor convergence, and a dropped-settings bug
Terminal Bench is a Harbor benchmark — tasks come from
harbor-framework/terminal-bench-2-1and itstask.tomlis the format deep-swe parses — but it carried a parallel reimplementation of the harbor sandbox.That divergence silently dropped per-task settings.
TaskTomlSchemarequirescpus,memory_mb,gpusand defaultsallow_internet, but the dataset never read them and the local Modal layer passed only a timeout, workdir and command. Every task ran at Modal's defaults: 22 of 90 request more than 2048 MB and 6 request more than 1 CPU. An under-provisioned task OOMs or thrashes and scores 0, which reads as model failure.terminal-bench/sandbox.tsandterminal-bench/modal-sandbox.tsare deleted;session.tsbuilds harbor'sCreateSessionInputwith the declared resources and the task files asuploads, andrunTerminalBenchVerifierreplaces the sandbox-ownedrunTests(). Terminal Bench also gainsattach().Reward parsing is unchanged in practice: all 90 tasks write
echo 1orecho 0, so harbor'sparseRewardand the old strict=== "1"agree on this dataset.3. Claude Code on swe-atlas and deep-swe
The agent-CLI machinery moves to
agent-cli/(schema.ts,harness.ts,runner.ts);terminal-bench/ori-solverdrops to a thin wrapper.terminal-bench/schemare-exports what it previously declared, so the published entry point is unchanged.swe_atlas (all three tracks) and deep_swe gain
agent, defaulting tonative(the existingrunAgentLoop). Selectingclaudeinstalls the agent into the task image and runs it in place of the loop; each benchmark keeps its own verifier, so swe-atlas verifies in the agent sandbox and deep-swe still extracts a patch and verifies in a second one.bun src/cli/index.ts --benchmark deep_swe --model anthropic/claude-opus-5 \ --solver-config '{"agent":"claude","taskSubset":["abs-stepped-slices"]}'wandris deliberately excluded: it injects OpenRouter server tools into its own requests, which an external CLI replaces with its own, so it would no longer measure what it is for.Capability gaps on the CLI path
stepLimithas no equivalent. Neither pi 0.84.1 nor Claude Code 2.1.228 exposes a turn limit, so wall-clock timeout is the only bound.claude -pprocess cannot resume from step N, so the CLI path always starts a fresh sandbox instead of attaching.Behavior changes to call out
Per CONTRIBUTING, for solver and scorer changes:
terminalBenchScorernow emits averifier_logtrajectory, fillingscorer_trajectoryfor both agents. Additive; score values unchanged.generation_time_msandreasoning_tokensfrom hardcoded0to real numbers,response_itemsfrom null to the full event stream (larger files).runAgentLoopand are not comparable to existing harbor baselines. Native remains the default everywhere it existed.isolateAgentConfigdefaults tofalse, preserving current pi behavior. With the default, pi readsAGENTS.md/CLAUDE.mdfrom the task working directory, so task-shipped files can influence the agent; the knob now exists to rule that out, but flipping the default would move scores and is left as a separate decision.Verification
All five gate commands exit 0:
format:check,check,typecheck,bun test(1248 pass, 0 fail),build.Beyond fixtures, flags and parsers were checked against the real CLIs (pi 0.84.1, Claude Code 2.1.228):
--thinking maxwith all isolation flags returnsstopReason: stop;--effort high --disallowedToolsreturnsis_error: false; parsers run against captured success and failure streams from both agents; a captured generation id resolves via/api/v1/generationto matching cost and reasoning tokens; generated run scripts passbash -nin minimal and all-knobs-on forms. A dataset test walks all 90 real task files asserting every declared cpus and memory survives into metadata.Not yet verified: no Modal run has happened. The in-sandbox image build (nvm + agent package + ori install) and real autonomy behavior are unproven, and the score impact of honoring resources is unquantified. Validate one task per agent per benchmark before any sweep, and confirm
generation_idsis non-null in the resulting parquet.Known and not addressed
oriInstallUrlandagentPackageare interpolated into DockerfileRUNsteps without validation, as flagged by Devin Review.piPackagealready had this shape on main. Config reaches these values only via--solver-configfrom a caller who already has a shell, so this is hardening rather than a live boundary; deliberately left out of this PR.🤖 Generated with Claude Code