Skip to content

feat(terminal-bench): selectable agent harness plus full control and tracking - #20

Open
abhinav-pola wants to merge 4 commits into
mainfrom
abhinav-pola/ahead-frown
Open

feat(terminal-bench): selectable agent harness plus full control and tracking#20
abhinav-pola wants to merge 4 commits into
mainfrom
abhinav-pola/ahead-frown

Conversation

@abhinav-pola

@abhinav-pola abhinav-pola commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Why

Terminal Bench hardcoded the pi coding 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:

  1. feat(terminal-bench) — selectable agent harness plus full control and tracking
  2. refactor(terminal-bench) — converge onto the harbor sandbox
  3. feat(harbor) — run Claude Code via ori on swe-atlas and deep-swe

1. Selectable agent on Terminal Bench

agent defaults to pi, whose direct pi --print --mode json invocation is unchanged.

# pi (default)
bun src/cli/index.ts --benchmark terminal_bench --model anthropic/claude-opus-5 \
  --solver-config '{"taskSubset":["build-cython-ext"]}'

# Claude Code via Ori Harness
bun src/cli/index.ts --benchmark terminal_bench --model anthropic/claude-opus-5 \
  --solver-config '{"agent":"claude","taskSubset":["build-cython-ext"]}'

Ori Harness is not a proxy — it sets ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN to OpenRouter and execs the real CLI, resolving auth from the OPENROUTER_API_KEY the sandbox already receives. It installs as a standalone Linux binary.

Control

Knob pi claude
reasoning level --thinking (gains max) --effort
systemPrompt override --system-prompt --system-prompt
appendSystemPrompt --append-system-prompt --append-system-prompt
allowedTools / disallowedTools --tools / --exclude-tools --allowedTools / --disallowedTools
isolateAgentConfig --no-extensions/-skills/-prompt-templates/-context-files --exclude-dynamic-system-prompt-sections

thinking and effort are separate enums because claude's scale has no off/minimal. Prompts and tool lists pass through env vars rather than argv to avoid shell quoting issues.

Tracking

generation_ids now resolves against the OpenRouter generations API for either agent, so spend is reconcilable from stored results.

Field pi claude
generation_ids responseId message.id
cost usage.cost.total total_cost_usd
reasoning tokens usage.reasoning thinking_tokens
generation_time_ms wall clock (was 0) duration_ms
agentTurns / agentToolCalls event counts num_turns / tool_use blocks
response_items stream events (was null) stream events

ori-parquet.test.ts runs 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 returned undefined for 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 with agentIsError in 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-1 and its task.toml is the 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, gpus and defaults allow_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.ts and terminal-bench/modal-sandbox.ts are deleted; session.ts builds harbor's CreateSessionInput with the declared resources and the task files as uploads, and runTerminalBenchVerifier replaces the sandbox-owned runTests(). Terminal Bench also gains attach().

Reward parsing is unchanged in practice: all 90 tasks write echo 1 or echo 0, so harbor's parseReward and 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-solver drops to a thin wrapper. terminal-bench/schema re-exports what it previously declared, so the published entry point is unchanged.

swe_atlas (all three tracks) and deep_swe gain agent, defaulting to native (the existing runAgentLoop). 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 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"]}'

wandr is 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

  • stepLimit has 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.
  • deep-swe checkpoint resume is skipped. A claude -p process 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:

  • Honoring declared resources will move scores on the 28 tasks that request more than the default cpus or memory. Presumably upward, since they were under-provisioned, but the new numbers are not comparable to previous runs.
  • terminalBenchScorer now emits a verifier_log trajectory, filling scorer_trajectory for both agents. Additive; score values unchanged.
  • pi parquet columns change value: generation_time_ms and reasoning_tokens from hardcoded 0 to real numbers, response_items from null to the full event stream (larger files).
  • CLI-path results are a different system than runAgentLoop and are not comparable to existing harbor baselines. Native remains the default everywhere it existed.
  • No other change here alters scoring.

isolateAgentConfig defaults to false, preserving current pi behavior. With the default, pi reads AGENTS.md/CLAUDE.md from 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 max with all isolation flags returns stopReason: stop; --effort high --disallowedTools returns is_error: false; parsers run against captured success and failure streams from both agents; a captured generation id resolves via /api/v1/generation to matching cost and reasoning tokens; generated run scripts pass bash -n in 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_ids is non-null in the resulting parquet.

Known and not addressed

oriInstallUrl and agentPackage are interpolated into Dockerfile RUN steps without validation, as flagged by Devin Review. piPackage already had this shape on main. Config reaches these values only via --solver-config from 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

…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>
@abhinav-pola
abhinav-pola requested a review from a team as a code owner August 12, 2026 01:18
devin-ai-integration[bot]

This comment was marked as resolved.

abhinav-pola and others added 3 commits August 12, 2026 08:17
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>

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 2 new potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +190 to +197
if (cliHarness !== undefined) {
const cliRun = yield* runAgentCli({
session: agentSession,
harness: cliHarness,
opts: cliOpts,
instructionPath: REMOTE_AGENT_INSTRUCTION,
timeoutMs: meta.maxAgentTimeoutSec * 1000 + 30000,
});

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.

🔴 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +57 to +64
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`,
];

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.

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

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant