fix(evals): harden terminalbench-native harness against tmux death and setup hangs (#536)

Two failure classes hit by a downstream team:

1) tmux/task-env death (TerminalBenchTaskEnv.__enter__, terminalbench_env.py):
   create_session ran inside the spin_up_terminal generator-CM with no
   exception safety — a tmux failure leaked the docker compose project
   (down deferred to GC, never if the env was retained) and surfaced as an
   opaque mid-run death. Now: exception-safe __enter__ with an idempotent
   _teardown(), a tmux/asciinema preflight in the container BEFORE the
   agent loop (TaskEnvironmentError naming the task image + remedy), and
   fail-that-task-cleanly semantics — the failure is recorded as a harness
   error (QueryTrace.error_kind="harness_error"), the container is downed,
   and the run continues.

2) OpenHands in-container SETUP hang misattributed as a model result:
   harness.run() had no bound (terminal-bench runs installed-agent setup
   with max_timeout_sec=inf inside the per-trial agent budget), and the
   summary conversion read the nonexistent results.trial_results attr and
   hardcoded errors=0, folding zero-model-request trials into resolve-rate
   as model misses. Now: global_agent_timeout_sec / global_timeout_multiplier
   are threaded config -> backend -> Harness kwargs (default 1800 s bound on
   SETUP+RUN; configurable per [run]/[[benchmarks]] TOML), and
   summarize_benchmark_results() classifies harness/infra failures out of
   the accuracy denominator keyed on token usage (zero/missing tokens +
   unresolved = the agent never contacted the model), NOT failure_mode —
   terminal-bench 0.2.18 leaves failure_mode UNSET on success AND on
   genuine misses, so a failure_mode-based check would misflag every real
   model miss. Genuine misses (tokens>0, is_resolved=false) stay in the
   denominator.

QueryTrace gains error/error_kind (wired through to_dict/from_dict so the
fields actually reach traces.jsonl; backward-compatible loads), the
AgenticRunner flags zero-model-contact traces and TaskEnvironmentError as
harness errors, and export/summary/console output exclude harness errors
from resolve-rate while reporting them loudly.

terminal-bench stays an undeclared dep on purpose: it requires Python
>=3.12 while this project supports >=3.10,<3.14, so an unmarked extra
would break uv lock. pyproject/uv.lock untouched.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-06-11 14:30:10 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0cac61d3bb
commit b21463aab6
14 changed files with 1290 additions and 77 deletions
@@ -5,11 +5,13 @@ Uses Harness for Docker-based execution and scoring.
from __future__ import annotations
import inspect
import logging
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, Tuple
from openjarvis.evals.core.backend import InferenceBackend
from openjarvis.evals.core.types import RunSummary
LOGGER = logging.getLogger(__name__)
@@ -20,6 +22,97 @@ try:
except ImportError:
_HAS_TB = False
# terminal-bench FailureMode values that are definitionally infrastructure
# failures (the harness broke before/while driving the agent), never a
# judgment on the model's answer. NOTE: clean trials leave failure_mode
# "unset" in terminal-bench 0.2.18 — both on success AND on genuine
# unresolved misses — so failure_mode alone can NOT be used to detect
# harness errors (it would misflag every real model miss).
_INFRA_FAILURE_MODES = frozenset({"agent_installation_failed", "unknown_agent_error"})
# Harness kwargs that older terminal-bench versions may not support.
_TIMEOUT_KWARGS = ("global_agent_timeout_sec", "global_timeout_multiplier")
def summarize_benchmark_results(
results: Any,
*,
model: str,
benchmark: str = "terminalbench-native",
) -> Tuple[RunSummary, List[Dict[str, str]]]:
"""Convert terminal-bench ``BenchmarkResults`` into a ``RunSummary``.
Trials are classified into three buckets:
- resolved: ``is_resolved`` is True -> counted correct.
- model miss: unresolved, but the model was actually contacted ->
counted in the accuracy denominator.
- harness/infra failure: excluded from the accuracy denominator and
reported in ``RunSummary.errors`` plus the returned failure list.
Zero-model-contact signal choice: terminal-bench 0.2.18 leaves
``failure_mode`` UNSET both on clean success and on genuine unresolved
misses, so failure_mode cannot distinguish "the model tried and failed"
from "the agent never called the model". Token usage can: this backend
always runs terminus-2, which reports real LiteLLM usage, so an
unresolved trial with zero/missing input+output tokens means no model
request ever completed — an infrastructure failure (in-container setup
hang/death, tmux failure), not a model miss. CAVEAT: terminal-bench
"installed agents" (openhands, claude-code, ...) hardcode 0 tokens even
on success; if this backend ever honors ``agent_name`` for installed
agents, this heuristic must be gated on the agent type.
"""
trials = list(getattr(results, "results", None) or [])
harness_failures: List[Dict[str, str]] = []
scored = 0
correct = 0
for tr in trials:
task_id = getattr(tr, "task_id", None) or getattr(tr, "trial_name", "unknown")
is_resolved = getattr(tr, "is_resolved", None) is True
fm = getattr(tr, "failure_mode", None)
fm_value = str(getattr(fm, "value", fm) or "unset").lower()
tokens = (getattr(tr, "total_input_tokens", None) or 0) + (
getattr(tr, "total_output_tokens", None) or 0
)
zero_model_contact = not is_resolved and tokens == 0
infra_failure_mode = fm_value in _INFRA_FAILURE_MODES
if zero_model_contact or infra_failure_mode:
harness_failures.append(
{
"task_id": str(task_id),
"failure_mode": fm_value,
"reason": (
"zero_model_requests" if zero_model_contact else fm_value
),
}
)
continue
scored += 1
if is_resolved:
correct += 1
return (
RunSummary(
benchmark=benchmark,
category="agentic",
backend="terminalbench-native",
model=model,
total_samples=len(trials),
scored_samples=scored,
correct=correct,
accuracy=correct / scored if scored else 0.0,
errors=len(harness_failures),
mean_latency_seconds=0.0,
total_cost_usd=0.0,
),
harness_failures,
)
class TerminalBenchNativeBackend(InferenceBackend):
"""Runs terminal-bench tasks natively via Harness with Docker execution.
@@ -44,7 +137,22 @@ class TerminalBenchNativeBackend(InferenceBackend):
system_prompt: str = "",
max_tokens: int = 16384,
n_concurrent: int = 4,
global_agent_timeout_sec: Optional[float] = 1800.0,
global_timeout_multiplier: Optional[float] = None,
) -> None:
"""Args of note:
global_agent_timeout_sec: Hard wall-clock bound for each trial's
agent phase. terminal-bench runs installed-agent SETUP inside
this same budget with an infinite tmux timeout, so this bounds
SETUP+RUN together (a setup-only timeout needs an upstream
terminal-bench change). When set, it REPLACES each task's own
``max_agent_timeout_sec``. Set ``None`` or ``0`` to fall back
to per-task budgets.
global_timeout_multiplier: Scales per-task budgets when
``global_agent_timeout_sec`` is not set. ``None`` keeps
terminal-bench's default (1.0).
"""
if not _HAS_TB:
raise ImportError("terminal-bench is required: pip install terminal-bench")
@@ -59,6 +167,8 @@ class TerminalBenchNativeBackend(InferenceBackend):
self._system_prompt = system_prompt
self._max_tokens = max_tokens
self._n_concurrent = n_concurrent
self._global_agent_timeout_sec = global_agent_timeout_sec
self._global_timeout_multiplier = global_timeout_multiplier
self._results: Optional[BenchmarkResults] = None
def run_harness(self, run_id: str) -> BenchmarkResults:
@@ -91,10 +201,53 @@ class TerminalBenchNativeBackend(InferenceBackend):
if self._max_samples is not None:
harness_kwargs["n_tasks"] = self._max_samples
# Bound each trial's agent phase. Without this, an in-container
# installed-agent SETUP hang runs with an infinite tmux timeout,
# bounded only by whatever budget the task happens to declare.
if self._global_agent_timeout_sec:
harness_kwargs["global_agent_timeout_sec"] = float(
self._global_agent_timeout_sec
)
if self._global_timeout_multiplier is not None:
harness_kwargs["global_timeout_multiplier"] = float(
self._global_timeout_multiplier
)
self._check_timeout_kwargs_supported(harness_kwargs)
harness = Harness(**harness_kwargs)
self._results = harness.run()
return self._results
@staticmethod
def _check_timeout_kwargs_supported(harness_kwargs: Dict[str, Any]) -> None:
"""Fail loudly if this terminal-bench build lacks the timeout kwargs.
terminal-bench is an undeclared, unpinned dependency, so installs may
predate the global timeout kwargs (added by 0.2.x). Passing an
unknown kwarg raises an opaque TypeError; dropping it silently would
re-create the unbounded-setup hang. Detect and explain instead.
"""
try:
params = inspect.signature(Harness.__init__).parameters
except (TypeError, ValueError):
return
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
return
unsupported = [
key
for key in _TIMEOUT_KWARGS
if key in harness_kwargs and key not in params
]
if unsupported:
raise RuntimeError(
"The installed terminal-bench does not support "
f"{', '.join(unsupported)} (requires terminal-bench >= "
"0.2.18). Upgrade terminal-bench, or disable the bound by "
"setting global_agent_timeout_sec = 0 in the eval config "
"[run] section."
)
def generate(
self,
prompt: str,
@@ -121,4 +274,4 @@ class TerminalBenchNativeBackend(InferenceBackend):
pass
__all__ = ["TerminalBenchNativeBackend"]
__all__ = ["TerminalBenchNativeBackend", "summarize_benchmark_results"]
+44 -25
View File
@@ -690,14 +690,22 @@ def _run_terminalbench_native(
from openjarvis.engine.openai_compat_engines import normalize_openai_base_url
from openjarvis.evals.backends.terminalbench_native import (
TerminalBenchNativeBackend,
summarize_benchmark_results,
)
from openjarvis.evals.core.types import RunSummary
model = config.model
# LiteLLM expects "openai/<model>" for OpenAI-compatible servers
litellm_model = f"openai/{model}"
output_dir = getattr(config, "output_path", None) or "results/terminalbench-native/"
# Harness budgets: only forward explicit config values so the backend
# defaults (global_agent_timeout_sec=1800) apply otherwise.
timeout_kwargs = {}
if getattr(config, "global_agent_timeout_sec", None) is not None:
timeout_kwargs["global_agent_timeout_sec"] = config.global_agent_timeout_sec
if getattr(config, "global_timeout_multiplier", None) is not None:
timeout_kwargs["global_timeout_multiplier"] = config.global_timeout_multiplier
# Normalize to exactly one trailing "/v1" — LiteLLM's api_base wants the
# full OpenAI-compatible prefix, and users pass both forms of the URL.
if base_url:
@@ -712,6 +720,7 @@ def _run_terminalbench_native(
max_samples=config.max_samples,
output_dir=output_dir,
n_concurrent=config.max_workers or 4,
**timeout_kwargs,
)
import re
@@ -740,28 +749,20 @@ def _run_terminalbench_native(
else:
results = backend.run_harness(run_id)
# Convert BenchmarkResults to RunSummary
total = len(results.trial_results) if hasattr(results, "trial_results") else 0
correct = 0
if hasattr(results, "trial_results"):
for tr in results.trial_results:
if getattr(tr, "is_resolved", False):
correct += 1
accuracy = correct / total if total > 0 else 0.0
return RunSummary(
benchmark="terminalbench-native",
category="agentic",
backend="terminalbench-native",
model=model,
total_samples=total,
scored_samples=total,
correct=correct,
accuracy=accuracy,
errors=0,
mean_latency_seconds=0.0,
total_cost_usd=0.0,
)
# Convert BenchmarkResults to RunSummary, classifying harness/infra
# failures (e.g. zero-model-contact setup hangs) out of the resolve-rate.
summary, harness_failures = summarize_benchmark_results(results, model=model)
if harness_failures:
console.print(
f" [red bold]{len(harness_failures)} harness/infra failure(s) "
"excluded from resolve-rate:[/red bold]"
)
for failure in harness_failures:
console.print(
f" [red]- {failure['task_id']}: {failure['reason']} "
f"(failure_mode={failure['failure_mode']})[/red]"
)
return summary
def _run_single(
@@ -1039,7 +1040,9 @@ def _print_agentic_summary(console: Console, traces, config) -> None:
from rich.table import Table
completed = sum(1 for t in traces if t.completed)
resolved = sum(1 for t in traces if t.is_resolved is True)
harness_errors = [t for t in traces if t.error_kind == "harness_error"]
model_traces = [t for t in traces if t.error_kind != "harness_error"]
resolved = sum(1 for t in model_traces if t.is_resolved is True)
timed_out = sum(1 for t in traces if t.timed_out)
total_turns = sum(t.num_turns for t in traces)
total_tool_calls = sum(t.total_tool_calls for t in traces)
@@ -1067,8 +1070,11 @@ def _print_agentic_summary(console: Console, traces, config) -> None:
table.add_row("Queries", str(len(traces)))
table.add_row("Completed", f"{completed}/{len(traces)}")
if any(t.is_resolved is not None for t in traces):
table.add_row("Resolved", f"{resolved}/{len(traces)}")
# Harness errors are excluded from the resolve-rate denominator:
# they are infra failures, not model misses.
table.add_row("Resolved", f"{resolved}/{len(model_traces)}")
table.add_row("Timed out", str(timed_out))
table.add_row("Harness errors", str(len(harness_errors)))
table.add_row("Total turns", str(total_turns))
avg_t = f"{total_turns / len(traces):.1f}" if traces else "0"
table.add_row("Avg turns/query", avg_t)
@@ -1095,6 +1101,19 @@ def _print_agentic_summary(console: Console, traces, config) -> None:
console.print(table)
if harness_errors:
console.print(
f"[red bold]{len(harness_errors)} harness/infra failure(s) "
"excluded from resolve-rate:[/red bold]"
)
for t in harness_errors[:5]:
console.print(f"[red] {t.query_id}: {(t.error or '')[:300]}[/red]")
if len(harness_errors) > 5:
console.print(
f"[red] ... and {len(harness_errors) - 5} more "
"(see traces.jsonl)[/red]"
)
def _run_from_config(
config_path: str,
+76 -9
View File
@@ -20,6 +20,7 @@ from contextlib import nullcontext
from pathlib import Path
from typing import Any, Callable, Optional
from openjarvis.evals.core.environment import TaskEnvironmentError
from openjarvis.evals.core.event_recorder import AgentEvent, EventRecorder, EventType
from openjarvis.evals.core.trace import QueryTrace, TurnTrace
@@ -176,9 +177,12 @@ class AgenticRunner:
)
self._traces.append(trace)
status = (
"TIMEOUT" if trace.timed_out else ("OK" if trace.completed else "FAIL")
)
if trace.timed_out:
status = "TIMEOUT"
elif trace.error_kind == "harness_error":
status = "HARNESS_ERROR"
else:
status = "OK" if trace.completed else "FAIL"
LOGGER.info(
"Task %s: %s in %.1fs",
query_id,
@@ -260,11 +264,12 @@ class AgenticRunner:
is_resolved=record.metadata.get("is_resolved"),
)
status = (
"TIMEOUT"
if trace.timed_out
else ("OK" if trace.completed else "FAIL")
)
if trace.timed_out:
status = "TIMEOUT"
elif trace.error_kind == "harness_error":
status = "HARNESS_ERROR"
else:
status = "OK" if trace.completed else "FAIL"
LOGGER.info(
"Task %s: %s in %.1fs",
query_id,
@@ -444,7 +449,23 @@ class AgenticRunner:
_run_body()
except Exception as exc:
LOGGER.warning("Agent failed on query %s: %s", query_id, exc)
# Distinguish infrastructure breakage (task env failed to start,
# Docker/tmux death) from agent failures: harness errors must be
# recorded distinctly so scoring excludes them from resolve-rate
# instead of silently counting a model miss. Either way the run
# continues with the next record (fail THIS task, not the run).
is_harness_error = isinstance(exc, TaskEnvironmentError) or bool(
record.metadata.get("harness_error")
)
if is_harness_error:
LOGGER.error(
"Harness/environment failure on query %s (record %s): %s",
query_id,
getattr(record, "record_id", "?"),
exc,
)
else:
LOGGER.warning("Agent failed on query %s: %s", query_id, exc)
end_time = time.time()
# Unsubscribe EventBus relays
if agent_bus is not None:
@@ -461,6 +482,8 @@ class AgenticRunner:
total_wall_clock_s=end_time - start_time,
completed=False,
is_resolved=record.metadata.get("is_resolved"),
error=str(exc),
error_kind="harness_error" if is_harness_error else "agent_error",
)
# Unsubscribe EventBus relays
@@ -534,6 +557,48 @@ class AgenticRunner:
model, turn.input_tokens, turn.output_tokens
)
# --- Zero-model-contact sanity check ----------------------------
# Failed in-container setups (e.g. an installed agent's SETUP phase
# hanging or dying) historically produced traces that looked like
# model results: completed=True, a synthetic 1-event turn,
# is_resolved=False from run_tests, and zero tokens — silently
# dragging resolve-rate down as a fake model miss. Signal choice:
# token usage plus LM inference events is the reliable discriminator
# here — a genuine model miss has token usage (and/or LM events),
# while "the agent never called the model" has neither. We do NOT
# key off completion status or is_resolved, which are identical in
# both cases. run_agent_loop envs drive the model directly
# (bypassing usage reporting), so their turn_wall_clocks count as
# model contact.
had_lm_events = any(
e.event_type in (EventType.LM_INFERENCE_START, EventType.LM_INFERENCE_END)
for e in events
)
had_loop_turns = bool(
task_env is not None and getattr(task_env, "turn_wall_clocks", None)
)
turn_tokens = sum(t.input_tokens + t.output_tokens for t in turns)
error: Optional[str] = None
error_kind: Optional[str] = None
if (
not had_lm_events
and not had_loop_turns
and in_tok + out_tok == 0
and turn_tokens == 0
):
error = (
"zero_model_requests: the agent produced no LM inference "
"events and reported zero token usage — the model was never "
"contacted. This is a harness/infrastructure failure (e.g. "
"in-container agent setup hang/death), not a model miss, and "
"is excluded from resolve-rate. If your agent genuinely "
"contacted the model, make it report token usage or emit "
"LM_INFERENCE events. Response tail: "
f"{(response_text or '')[-500:]!r}"
)
error_kind = "harness_error"
LOGGER.error("Query %s: %s", query_id, error)
# Query-level energy from telemetry window
query_gpu_energy = _compute_energy_delta(readings, "gpu_energy_j")
query_cpu_energy = _compute_energy_delta(readings, "cpu_energy_j")
@@ -563,6 +628,8 @@ class AgenticRunner:
is_resolved=record.metadata.get("is_resolved"),
query_mbu_avg_pct=query_mbu_avg,
query_mbu_max_pct=query_mbu_max,
error=error,
error_kind=error_kind,
)
# Correlate energy with trace
+30
View File
@@ -143,6 +143,16 @@ def load_eval_config(path: str | Path) -> EvalSuiteConfig:
sheets_worksheet=run_raw.get("sheets_worksheet", "Results"),
sheets_credentials_path=run_raw.get("sheets_credentials_path", ""),
max_turns=(int(run_raw["max_turns"]) if "max_turns" in run_raw else None),
global_agent_timeout_sec=(
float(run_raw["global_agent_timeout_sec"])
if "global_agent_timeout_sec" in run_raw
else None
),
global_timeout_multiplier=(
float(run_raw["global_timeout_multiplier"])
if "global_timeout_multiplier" in run_raw
else None
),
)
# Parse [[models]]
@@ -212,6 +222,16 @@ def load_eval_config(path: str | Path) -> EvalSuiteConfig:
max_tokens=int(b["max_tokens"]) if "max_tokens" in b else None,
subset=b.get("subset"),
record_ids=record_ids,
global_agent_timeout_sec=(
float(b["global_agent_timeout_sec"])
if "global_agent_timeout_sec" in b
else None
),
global_timeout_multiplier=(
float(b["global_timeout_multiplier"])
if "global_timeout_multiplier" in b
else None
),
)
)
@@ -275,6 +295,14 @@ def expand_suite(suite: EvalSuiteConfig) -> List[RunConfig]:
if bench.judge_model is not None:
judge_model = bench.judge_model
# terminal-bench harness budgets: benchmark > [run]
global_agent_timeout_sec = suite.run.global_agent_timeout_sec
if bench.global_agent_timeout_sec is not None:
global_agent_timeout_sec = bench.global_agent_timeout_sec
global_timeout_multiplier = suite.run.global_timeout_multiplier
if bench.global_timeout_multiplier is not None:
global_timeout_multiplier = bench.global_timeout_multiplier
# Auto-generate output path
model_slug = model.name.replace("/", "-").replace(":", "-")
output_path = f"{output_dir}/{bench.name}_{model_slug}.jsonl"
@@ -328,6 +356,8 @@ def expand_suite(suite: EvalSuiteConfig) -> List[RunConfig]:
base_url=suite.backend_external_base_url,
api_key=suite.backend_external_api_key,
record_ids=bench.record_ids,
global_agent_timeout_sec=global_agent_timeout_sec,
global_timeout_multiplier=global_timeout_multiplier,
)
)
+15
View File
@@ -8,6 +8,18 @@ from typing import Any, Dict, Tuple
from openjarvis.evals.core.types import EvalRecord
class TaskEnvironmentError(RuntimeError):
"""A task execution environment failed to start or operate.
Raised when infrastructure backing a task (Docker container, docker
compose project, tmux session, recording binaries, ...) breaks. This is
a harness/environment failure, **not** a model failure: runners record
it distinctly (``QueryTrace.error_kind == "harness_error"``) so scoring
can exclude the sample from resolve-rate instead of silently counting
it as a model miss.
"""
class EnvironmentProvider(ABC):
"""Manages an external environment for evaluation benchmarks.
@@ -50,3 +62,6 @@ class EnvironmentProvider(ABC):
@abstractmethod
def teardown(self) -> None:
"""Stop the environment and release resources."""
__all__ = ["EnvironmentProvider", "TaskEnvironmentError"]
+24 -4
View File
@@ -26,13 +26,22 @@ def _agg_stats(values: Sequence[Optional[float]]) -> dict[str, Optional[float]]:
}
def _model_attributable(traces: list[QueryTrace]) -> list[QueryTrace]:
"""Traces whose outcome is attributable to the model.
Harness errors (infra/setup failures, zero-model-contact runs) are
excluded so they never count as model misses in resolve-rate.
"""
return [t for t in traces if t.error_kind != "harness_error"]
def _compute_efficiency(
traces: list[QueryTrace],
total_gpu_energy: Optional[float],
total_cpu_energy: Optional[float],
) -> dict[str, Optional[float]]:
"""Compute efficiency metrics from traces and aggregate energy."""
scored = [t for t in traces if t.is_resolved is not None]
scored = [t for t in _model_attributable(traces) if t.is_resolved is not None]
resolved = sum(1 for t in scored if t.is_resolved is True)
accuracy = resolved / len(scored) if scored else None
gpu_powers = [
@@ -247,8 +256,12 @@ def export_summary_json(
cpu_energy_values.append(sum(cpu_vals))
total_cpu_energy = sum(cpu_energy_values) if cpu_energy_values else None
resolved = sum(1 for t in traces if t.is_resolved is True)
unresolved = sum(1 for t in traces if t.is_resolved is False)
# Harness errors (infra failures, zero-model-contact runs) are excluded
# from the resolve-rate denominator: they are not model misses.
harness_error_traces = [t for t in traces if t.error_kind == "harness_error"]
model_traces = _model_attributable(traces)
resolved = sum(1 for t in model_traces if t.is_resolved is True)
unresolved = sum(1 for t in model_traces if t.is_resolved is False)
cost_values = [t.total_cost_usd for t in traces if t.total_cost_usd is not None]
total_cost = sum(cost_values) if cost_values else None
@@ -345,6 +358,7 @@ def export_summary_json(
"completed": completed,
"resolved": resolved,
"unresolved": unresolved,
"harness_errors": len(harness_error_traces),
"accuracy": accuracy,
"turns": total_turns,
"tool_calls": total_tool_calls,
@@ -365,6 +379,12 @@ def export_summary_json(
"efficiency": efficiency,
}
if harness_error_traces:
summary["harness_error_details"] = [
{"query_id": t.query_id, "error": (t.error or "")[:500]}
for t in harness_error_traces
]
if action_totals:
summary["action_energy_summary"] = action_totals
@@ -399,7 +419,7 @@ def export_summary_json(
accuracy_vals: list[float] = [
1.0 if t.is_resolved is True else 0.0
for t in traces
for t in _model_attributable(traces)
if t.is_resolved is not None
]
latency_vals = [t.total_wall_clock_s for t in traces if t.total_wall_clock_s > 0]
+11
View File
@@ -91,6 +91,13 @@ class QueryTrace:
is_resolved: Optional[bool] = None
query_mbu_avg_pct: Optional[float] = None
query_mbu_max_pct: Optional[float] = None
# Error taxonomy. ``error_kind`` distinguishes infrastructure failures
# ("harness_error": task env / Docker / tmux broke, or the agent never
# contacted the model) from agent failures ("agent_error"). Harness
# errors are excluded from resolve-rate by export/summary code so they
# are never silently counted as model misses.
error: Optional[str] = None
error_kind: Optional[str] = None
@property
def num_turns(self) -> int:
@@ -196,6 +203,8 @@ class QueryTrace:
"is_resolved": self.is_resolved,
"query_mbu_avg_pct": self.query_mbu_avg_pct,
"query_mbu_max_pct": self.query_mbu_max_pct,
"error": self.error,
"error_kind": self.error_kind,
}
@classmethod
@@ -216,6 +225,8 @@ class QueryTrace:
is_resolved=d.get("is_resolved"),
query_mbu_avg_pct=d.get("query_mbu_avg_pct"),
query_mbu_max_pct=d.get("query_mbu_max_pct"),
error=d.get("error"),
error_kind=d.get("error_kind"),
)
def save_jsonl(self, path: Path) -> None:
+16
View File
@@ -102,6 +102,15 @@ class RunConfig:
# specific records (e.g. recovering silent-fake records without
# re-running the entire benchmark).
record_ids: Optional[List[str]] = None
# terminal-bench harness budgets (terminalbench-native backend).
# global_agent_timeout_sec bounds each trial's agent phase — SETUP+RUN
# together, since terminal-bench runs installed-agent setup inside the
# agent budget with an infinite tmux timeout. When set it REPLACES the
# per-task max_agent_timeout_sec; 0 disables the bound (per-task budgets
# apply); None uses the backend default (1800 s).
global_agent_timeout_sec: Optional[float] = None
# Scales per-task budgets when global_agent_timeout_sec is not set.
global_timeout_multiplier: Optional[float] = None
@dataclass(slots=True)
@@ -232,6 +241,9 @@ class ExecutionConfig:
# to JarvisConfig.agent.max_turns (default 10). Bump to 30-50 for
# thinking/reasoning models on agentic benchmarks (GAIA, LiveResearch).
max_turns: Optional[int] = None
# terminal-bench harness budgets (see RunConfig for semantics).
global_agent_timeout_sec: Optional[float] = None
global_timeout_multiplier: Optional[float] = None
@dataclass(slots=True)
@@ -265,6 +277,10 @@ class BenchmarkConfig:
max_tokens: Optional[int] = None
subset: Optional[str] = None
record_ids: Optional[List[str]] = None
# Per-benchmark override of the terminal-bench harness budgets
# (see RunConfig for semantics).
global_agent_timeout_sec: Optional[float] = None
global_timeout_multiplier: Optional[float] = None
@dataclass(slots=True)
@@ -8,6 +8,8 @@ from pathlib import Path
from types import TracebackType
from typing import Any, MutableMapping, Optional, Type
from openjarvis.evals.core.environment import TaskEnvironmentError
LOGGER = logging.getLogger(__name__)
@@ -29,8 +31,6 @@ class TerminalBenchTaskEnv:
# ------------------------------------------------------------------
def __enter__(self) -> TerminalBenchTaskEnv:
from terminal_bench.terminal.terminal import spin_up_terminal
task = self._metadata.get("task")
task_paths = self._metadata.get("task_paths")
task_id = self._metadata.get("task_id", "unknown")
@@ -41,6 +41,8 @@ class TerminalBenchTaskEnv:
"Use the 'terminalbench-native' dataset."
)
from terminal_bench.terminal.terminal import spin_up_terminal
docker_image_prefix = f"tb__{task_id}".replace(".", "-")
client_image_name = f"{docker_image_prefix}__client"
client_container_name = f"oj-{task_id}".replace(".", "-")
@@ -48,19 +50,59 @@ class TerminalBenchTaskEnv:
self._logs_tmpdir = tempfile.TemporaryDirectory(prefix="oj_tb_logs_")
logs_path = Path(self._logs_tmpdir.name)
self._terminal_cm = spin_up_terminal(
client_container_name=client_container_name,
client_image_name=client_image_name,
docker_compose_path=task_paths.docker_compose_path,
docker_image_name_prefix=docker_image_prefix,
sessions_logs_path=logs_path,
disable_recording=task.disable_asciinema,
)
self._terminal = self._terminal_cm.__enter__()
# Everything below is exception-safe: a failure mid-startup (docker
# compose, tmux, asciinema) tears the spun-up terminal back down
# immediately instead of leaking the docker compose project until GC
# (or forever, when the env object is retained), and re-raises as a
# loud TaskEnvironmentError naming the task image so the runner can
# record a harness error for THIS task and continue with the rest.
try:
self._terminal_cm = spin_up_terminal(
client_container_name=client_container_name,
client_image_name=client_image_name,
docker_compose_path=task_paths.docker_compose_path,
docker_image_name_prefix=docker_image_prefix,
sessions_logs_path=logs_path,
disable_recording=task.disable_asciinema,
)
self._terminal = self._terminal_cm.__enter__()
session = self._terminal.create_session(
"agent", is_active_stream=False, as_configured_user=True
)
# Preflight BEFORE the agent loop: terminal-bench drives the
# agent through tmux (and records via asciinema unless the task
# disables it). A missing binary otherwise surfaces mid-run as
# an opaque RuntimeError or a fake TimeoutError.
self._preflight_container_binaries(
task, task_id, client_image_name, client_container_name
)
session = self._terminal.create_session(
"agent", is_active_stream=False, as_configured_user=True
)
except BaseException as exc:
self._teardown(type(exc), exc, exc.__traceback__)
if not isinstance(exc, Exception):
# KeyboardInterrupt / SystemExit: clean up but never mask.
raise
if isinstance(exc, TaskEnvironmentError):
self._metadata["harness_error"] = str(exc)
raise
message = (
f"Task '{task_id}': failed to start the task environment "
f"(image '{client_image_name}', container "
f"'{client_container_name}'): {exc}. This is a harness/"
"environment failure, not a model failure. Check that the "
"Docker daemon is healthy and that tmux is installed in the "
"task image."
)
# docker compose stderr is only logged at DEBUG by
# terminal-bench; surface it here so the failure is actionable.
stderr = getattr(exc, "stderr", None)
if stderr:
if isinstance(stderr, bytes):
stderr = stderr.decode("utf-8", errors="replace")
message += f"\ndocker compose stderr (tail):\n{stderr[-2000:]}"
self._metadata["harness_error"] = message
raise TaskEnvironmentError(message) from exc
self._metadata["terminal"] = self._terminal
self._metadata["session"] = session
@@ -68,24 +110,99 @@ class TerminalBenchTaskEnv:
return self
def _preflight_container_binaries(
self,
task: Any,
task_id: str,
client_image_name: str,
client_container_name: str,
) -> None:
"""Verify tmux (and asciinema if recording) exist in the container.
Raises:
TaskEnvironmentError: naming the task image and the missing
binary, with the remedy, before any agent work starts.
"""
container = getattr(self._terminal, "container", None)
if container is None:
# Terminal implementation without a container handle (e.g. a
# future terminal-bench version); fall through to terminal-bench's
# own checks rather than guessing.
return
checks: list[tuple[str, list[str], str]] = [
(
"tmux",
["tmux", "-V"],
f"install tmux in the task image '{client_image_name}'",
),
]
if not getattr(task, "disable_asciinema", False):
checks.append(
(
"asciinema",
["asciinema", "--version"],
f"install asciinema in the task image '{client_image_name}' "
"or set disable_asciinema in task.yaml",
)
)
for binary, cmd, remedy in checks:
result = container.exec_run(cmd)
exit_code = getattr(result, "exit_code", 0)
if exit_code == 0:
continue
output = getattr(result, "output", b"")
if isinstance(output, bytes):
output = output.decode("utf-8", errors="replace")
raise TaskEnvironmentError(
f"Task '{task_id}': required binary '{binary}' is not usable "
f"in task image '{client_image_name}' (container "
f"'{client_container_name}'): exec exit code {exit_code}, "
f"output {str(output).strip()!r}. Remedy: {remedy}. This is "
"a harness/environment failure, not a model failure."
)
def _teardown(
self,
exc_type: Optional[Type[BaseException]] = None,
exc_val: Optional[BaseException] = None,
exc_tb: Optional[TracebackType] = None,
) -> None:
"""Idempotent cleanup shared by ``__exit__`` and failed ``__enter__``.
Secondary cleanup errors are logged, never raised, so they cannot
mask the original failure.
"""
self._metadata.pop("terminal", None)
self._metadata.pop("session", None)
self._metadata.pop("container", None)
terminal_cm, self._terminal_cm, self._terminal = self._terminal_cm, None, None
if terminal_cm is not None:
try:
terminal_cm.__exit__(exc_type, exc_val, exc_tb)
except Exception:
LOGGER.exception(
"Secondary error while tearing down the terminal for "
"task %s (original error, if any, is re-raised)",
self._metadata.get("task_id", "unknown"),
)
logs_tmpdir, self._logs_tmpdir = self._logs_tmpdir, None
if logs_tmpdir is not None:
try:
logs_tmpdir.cleanup()
except Exception:
LOGGER.exception("Failed to clean up session logs tmpdir")
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
self._metadata.pop("terminal", None)
self._metadata.pop("session", None)
self._metadata.pop("container", None)
if self._terminal_cm is not None:
self._terminal_cm.__exit__(exc_type, exc_val, exc_tb)
self._terminal_cm = None
self._terminal = None
if self._logs_tmpdir is not None:
self._logs_tmpdir.cleanup()
self._logs_tmpdir = None
self._teardown(exc_type, exc_val, exc_tb)
# ------------------------------------------------------------------
# Test execution
@@ -93,12 +210,6 @@ class TerminalBenchTaskEnv:
def run_tests(self) -> tuple[bool, dict[str, Any]]:
"""Copy test scripts into container, execute, parse results."""
from terminal_bench.parsers.base_parser import UnitTestStatus
from terminal_bench.parsers.parser_factory import ParserFactory
from terminal_bench.terminal.docker_compose_manager import (
DockerComposeManager,
)
task = self._metadata["task"]
task_paths = self._metadata["task_paths"]
terminal = self._terminal
@@ -110,6 +221,12 @@ class TerminalBenchTaskEnv:
self._metadata["test_results"] = results
return False, results
from terminal_bench.parsers.base_parser import UnitTestStatus
from terminal_bench.parsers.parser_factory import ParserFactory
from terminal_bench.terminal.docker_compose_manager import (
DockerComposeManager,
)
try:
paths_to_copy = [task_paths.run_tests_path]
if task_paths.test_dir.exists():
+72
View File
@@ -0,0 +1,72 @@
"""Tests for terminal-bench harness timeout plumbing through TOML configs."""
from __future__ import annotations
import textwrap
from openjarvis.evals.core.config import expand_suite, load_eval_config
def _write(tmp_path, body: str):
path = tmp_path / "suite.toml"
path.write_text(textwrap.dedent(body))
return path
BASE = """
[meta]
name = "timeouts"
[run]
output_dir = "results/"
{run_extra}
[[models]]
name = "test-model"
[[benchmarks]]
name = "terminalbench-native"
backend = "terminalbench-native"
{bench_extra}
"""
class TestTimeoutConfigPlumbing:
def test_run_level_timeouts_parse_and_expand(self, tmp_path):
path = _write(
tmp_path,
BASE.format(
run_extra=(
"global_agent_timeout_sec = 1200\n"
" global_timeout_multiplier = 1.5"
),
bench_extra="",
),
)
suite = load_eval_config(path)
assert suite.run.global_agent_timeout_sec == 1200.0
assert suite.run.global_timeout_multiplier == 1.5
(rc,) = expand_suite(suite)
assert rc.global_agent_timeout_sec == 1200.0
assert rc.global_timeout_multiplier == 1.5
def test_benchmark_override_wins(self, tmp_path):
path = _write(
tmp_path,
BASE.format(
run_extra="global_agent_timeout_sec = 1200",
bench_extra="global_agent_timeout_sec = 300",
),
)
(rc,) = expand_suite(load_eval_config(path))
assert rc.global_agent_timeout_sec == 300.0
def test_defaults_are_none(self, tmp_path):
path = _write(tmp_path, BASE.format(run_extra="", bench_extra=""))
suite = load_eval_config(path)
assert suite.run.global_agent_timeout_sec is None
assert suite.run.global_timeout_multiplier is None
(rc,) = expand_suite(suite)
assert rc.global_agent_timeout_sec is None
assert rc.global_timeout_multiplier is None
+114
View File
@@ -9,6 +9,7 @@ from typing import Any, Dict, List
import pytest
from openjarvis.evals.core.agentic_runner import AgenticRunner, _extract_patch
from openjarvis.evals.core.environment import TaskEnvironmentError
# ---------------------------------------------------------------------------
# Mock objects
@@ -50,6 +51,66 @@ class MockFailingAgent:
raise RuntimeError("Agent error")
class MockZeroContactAgent:
"""Agent that returns without ever contacting the model.
Mirrors the downstream failure signature: a hung/dead in-container
setup yields zero LM events and zero token usage, while run_tests
still stamps is_resolved=False.
"""
def ask(self, query: str) -> dict:
return {"content": "setup log tail ...", "usage": {}}
class FailingTaskEnv:
"""Task env whose __enter__ fails like a tmux/compose breakage."""
def __init__(self, metadata: Dict[str, Any]) -> None:
self._metadata = metadata
def __enter__(self) -> "FailingTaskEnv":
message = (
"Task 't1': required binary 'tmux' is not usable in task image "
"'tb__t1__client'"
)
self._metadata["harness_error"] = message
raise TaskEnvironmentError(message)
def __exit__(self, *args: Any) -> None:
return None
class ResolvingTaskEnv:
"""Task env that stamps is_resolved into metadata like run_tests does."""
def __init__(self, metadata: Dict[str, Any], is_resolved: bool) -> None:
self._metadata = metadata
self._is_resolved = is_resolved
def __enter__(self) -> "ResolvingTaskEnv":
return self
def __exit__(self, *args: Any) -> None:
return None
def run_tests(self):
self._metadata["is_resolved"] = self._is_resolved
return self._is_resolved, {}
class EnvDataset(MockDataset):
"""Dataset whose create_task_env is configurable per record id."""
def __init__(self, records: List[MockRecord], env_factories: Dict[str, Any]):
super().__init__(records)
self._env_factories = env_factories
def create_task_env(self, record: MockRecord):
factory = self._env_factories.get(record.record_id)
return factory(record.metadata) if factory is not None else None
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@@ -99,6 +160,59 @@ class TestAgenticRunner:
assert len(traces) == 1
assert not traces[0].completed
assert "Agent error" in traces[0].response_text
assert traces[0].error_kind == "agent_error"
assert "Agent error" in (traces[0].error or "")
def test_harness_failure_recorded_and_run_continues(self):
"""(c) one task's env breakage doesn't kill the run."""
records = [
MockRecord(record_id="r1", problem="broken env"),
MockRecord(record_id="r2", problem="healthy"),
]
dataset = EnvDataset(records, {"r1": FailingTaskEnv})
runner = AgenticRunner(agent=MockAgent(), dataset=dataset)
traces = self._run_async(runner.run())
assert len(traces) == 2
# Failed task: recorded distinctly as a harness error, not a miss.
assert traces[0].error_kind == "harness_error"
assert not traces[0].completed
assert "tmux" in (traces[0].error or "")
assert traces[0].is_resolved is None
# Healthy task still ran to completion.
assert traces[1].completed
assert traces[1].error_kind is None
assert "Response to: healthy" in traces[1].response_text
def test_zero_model_contact_flagged_as_harness_error(self):
"""(e) zero model requests -> harness_error, not a model miss."""
records = [MockRecord(record_id="r1", problem="task")]
dataset = EnvDataset(
records,
{"r1": lambda meta: ResolvingTaskEnv(meta, is_resolved=False)},
)
runner = AgenticRunner(agent=MockZeroContactAgent(), dataset=dataset)
traces = self._run_async(runner.run())
assert traces[0].error_kind == "harness_error"
assert "zero_model_requests" in (traces[0].error or "")
assert traces[0].total_input_tokens == 0
assert traces[0].total_output_tokens == 0
def test_genuine_model_miss_not_flagged(self):
"""(e) control: tokens>0 + is_resolved=False is a model miss."""
records = [MockRecord(record_id="r1", problem="task")]
dataset = EnvDataset(
records,
{"r1": lambda meta: ResolvingTaskEnv(meta, is_resolved=False)},
)
runner = AgenticRunner(agent=MockAgent(), dataset=dataset)
traces = self._run_async(runner.run())
assert traces[0].is_resolved is False
assert traces[0].error_kind is None
assert traces[0].error is None
assert traces[0].total_input_tokens > 0
def test_synthetic_turn_created(self):
records = [MockRecord(record_id="r1", problem="test")]
+78
View File
@@ -127,6 +127,84 @@ class TestExportSummaryJson:
assert summary["totals"]["queries"] == 0
class TestHarnessErrorExclusion:
"""Harness errors must never count as model misses in resolve-rate."""
def _traces(self):
return [
QueryTrace(
query_id="q0000",
workload_type="agentic",
completed=True,
is_resolved=True,
turns=[TurnTrace(turn_index=0, input_tokens=10, output_tokens=5)],
),
QueryTrace(
query_id="q0001",
workload_type="agentic",
completed=True,
is_resolved=False, # genuine model miss: stays in denominator
turns=[TurnTrace(turn_index=0, input_tokens=10, output_tokens=5)],
),
QueryTrace(
query_id="q0002",
workload_type="agentic",
completed=False,
is_resolved=False, # stamped by run_tests despite zero contact
error="zero_model_requests: the agent never contacted the model",
error_kind="harness_error",
),
]
def test_summary_excludes_harness_errors_from_accuracy(self, tmp_path):
path = tmp_path / "summary.json"
export_summary_json(self._traces(), {"model": "m"}, path)
summary = json.loads(path.read_text())
totals = summary["totals"]
assert totals["harness_errors"] == 1
assert totals["resolved"] == 1
assert totals["unresolved"] == 1 # NOT 2: harness error excluded
assert totals["accuracy"] == 0.5 # NOT 1/3
# Flat table_gen metrics exclude the harness error too.
assert summary["metrics"]["accuracy"]["n"] == 2
assert summary["metrics"]["accuracy"]["mean"] == 0.5
# Details surfaced for diagnosis.
details = summary["harness_error_details"]
assert details[0]["query_id"] == "q0002"
assert "zero_model_requests" in details[0]["error"]
def test_efficiency_excludes_harness_errors(self):
result = _compute_efficiency(self._traces(), None, None)
assert result["accuracy"] == 0.5
def test_no_harness_errors_key_absent(self, tmp_path):
path = tmp_path / "summary.json"
export_summary_json(_make_traces(), {}, path)
summary = json.loads(path.read_text())
assert summary["totals"]["harness_errors"] == 0
assert "harness_error_details" not in summary
class TestTraceErrorFieldRoundTrip:
def test_round_trip_preserves_error_fields(self):
trace = QueryTrace(
query_id="q0",
workload_type="agentic",
error="zero_model_requests: ...",
error_kind="harness_error",
)
restored = QueryTrace.from_dict(trace.to_dict())
assert restored.error == trace.error
assert restored.error_kind == "harness_error"
def test_old_trace_dicts_still_load(self):
"""Backward compat: traces.jsonl written before the schema change."""
restored = QueryTrace.from_dict({"query_id": "q0", "workload_type": "agentic"})
assert restored.error is None
assert restored.error_kind is None
class TestExportArtifactsManifest:
def test_no_artifacts_dir(self, tmp_path):
result = export_artifacts_manifest(tmp_path)
+204 -5
View File
@@ -1,15 +1,104 @@
"""Tests for TerminalBenchTaskEnv (mocked terminal_bench dependency)."""
"""Tests for TerminalBenchTaskEnv (mocked terminal_bench dependency).
These tests install a fake ``terminal_bench`` module tree into
``sys.modules`` so they run without the real package or a Docker daemon
(terminal-bench is an undeclared optional dep that CI never installs).
"""
from __future__ import annotations
import sys
import types
from contextlib import contextmanager
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any, Dict, List
import pytest
from openjarvis.evals.core.environment import TaskEnvironmentError
from openjarvis.evals.execution.terminalbench_env import TerminalBenchTaskEnv
# terminal_bench is an optional dep — skip all tests if unavailable
terminal_bench = pytest.importorskip(
"terminal_bench", reason="terminal_bench not installed"
)
# ---------------------------------------------------------------------------
# Fake terminal_bench seam
# ---------------------------------------------------------------------------
@dataclass
class FakeExecResult:
exit_code: int = 0
output: bytes = b""
@dataclass
class FakeContainer:
"""Container stub whose exec_run results are configurable per binary."""
exec_results: Dict[str, FakeExecResult] = field(default_factory=dict)
exec_calls: List[List[str]] = field(default_factory=list)
def exec_run(self, cmd: List[str]) -> FakeExecResult:
self.exec_calls.append(list(cmd))
return self.exec_results.get(cmd[0], FakeExecResult())
class FakeTerminal:
def __init__(self, events: List[str], container: FakeContainer) -> None:
self._events = events
self.container = container
self.create_session_error: Exception | None = None
def create_session(self, name: str, **_kwargs: Any) -> str:
self._events.append(f"create_session({name})")
if self.create_session_error is not None:
raise self.create_session_error
return f"session-{name}"
@pytest.fixture()
def fake_tb(monkeypatch, tmp_path):
"""Install a fake terminal_bench tree; return the shared test state."""
events: List[str] = []
container = FakeContainer()
terminal = FakeTerminal(events, container)
state = SimpleNamespace(
events=events,
container=container,
terminal=terminal,
spin_up_error=None,
)
@contextmanager
def spin_up_terminal(**kwargs: Any):
events.append("compose_up")
try:
if state.spin_up_error is not None:
raise state.spin_up_error
yield terminal
finally:
events.append("compose_down")
mod_tb = types.ModuleType("terminal_bench")
mod_terminal_pkg = types.ModuleType("terminal_bench.terminal")
mod_terminal = types.ModuleType("terminal_bench.terminal.terminal")
mod_terminal.spin_up_terminal = spin_up_terminal
mod_tb.terminal = mod_terminal_pkg
mod_terminal_pkg.terminal = mod_terminal
monkeypatch.setitem(sys.modules, "terminal_bench", mod_tb)
monkeypatch.setitem(sys.modules, "terminal_bench.terminal", mod_terminal_pkg)
monkeypatch.setitem(sys.modules, "terminal_bench.terminal.terminal", mod_terminal)
state.metadata = {
"task_id": "hello.world",
"task": SimpleNamespace(disable_asciinema=True),
"task_paths": SimpleNamespace(docker_compose_path=tmp_path / "compose.yaml"),
}
return state
# ---------------------------------------------------------------------------
# Existing behavior (now running without the real terminal_bench package)
# ---------------------------------------------------------------------------
class TestTerminalBenchTaskEnv:
@@ -46,3 +135,113 @@ class TestTerminalBenchTaskEnv:
assert is_resolved is False
assert results["error"] == "terminal_not_running"
assert metadata["is_resolved"] is False
# ---------------------------------------------------------------------------
# Exception-safe __enter__ / preflight / teardown
# ---------------------------------------------------------------------------
class TestEnterExceptionSafety:
def test_success_path(self, fake_tb):
env = TerminalBenchTaskEnv(fake_tb.metadata)
with env:
assert fake_tb.metadata["terminal"] is fake_tb.terminal
assert fake_tb.metadata["session"] == "session-agent"
assert fake_tb.metadata["container"] == "oj-hello-world"
assert "compose_down" not in fake_tb.events
assert fake_tb.events.count("compose_down") == 1
assert "terminal" not in fake_tb.metadata
def test_create_session_failure_tears_down_terminal(self, fake_tb):
"""(a) tmux failure in __enter__ -> terminal torn down, loud error."""
fake_tb.terminal.create_session_error = RuntimeError(
"tmux is not installed in the container."
)
env = TerminalBenchTaskEnv(fake_tb.metadata)
with pytest.raises(TaskEnvironmentError) as excinfo:
env.__enter__()
# No leak: compose project downed BEFORE the exception escaped.
assert "compose_down" in fake_tb.events
# Actionable: names the task, the image, and the failure.
message = str(excinfo.value)
assert "hello.world" in message
assert "tb__hello-world__client" in message
assert "tmux is not installed" in message
# Recorded for the runner / scorers; handles cleared.
assert fake_tb.metadata["harness_error"] == message
assert "terminal" not in fake_tb.metadata
assert "session" not in fake_tb.metadata
assert "container" not in fake_tb.metadata
assert env._terminal is None
assert env._terminal_cm is None
assert env._logs_tmpdir is None
def test_preflight_catches_missing_tmux(self, fake_tb):
"""(b) preflight catches missing tmux, naming the task image."""
fake_tb.container.exec_results["tmux"] = FakeExecResult(
exit_code=127,
output=b'exec: "tmux": executable file not found in $PATH',
)
env = TerminalBenchTaskEnv(fake_tb.metadata)
with pytest.raises(TaskEnvironmentError) as excinfo:
env.__enter__()
message = str(excinfo.value)
assert "tmux" in message
assert "tb__hello-world__client" in message # task image named
assert "127" in message
# Fired BEFORE the agent session was created.
assert not any(e.startswith("create_session") for e in fake_tb.events)
assert "compose_down" in fake_tb.events
assert fake_tb.metadata["harness_error"] == message
def test_preflight_checks_asciinema_when_recording(self, fake_tb):
fake_tb.metadata["task"] = SimpleNamespace(disable_asciinema=False)
fake_tb.container.exec_results["asciinema"] = FakeExecResult(exit_code=127)
env = TerminalBenchTaskEnv(fake_tb.metadata)
with pytest.raises(TaskEnvironmentError, match="asciinema"):
env.__enter__()
assert "disable_asciinema" in fake_tb.metadata["harness_error"]
assert "compose_down" in fake_tb.events
def test_preflight_skips_asciinema_when_disabled(self, fake_tb):
fake_tb.container.exec_results["asciinema"] = FakeExecResult(exit_code=127)
env = TerminalBenchTaskEnv(fake_tb.metadata)
with env:
pass
assert ["asciinema", "--version"] not in fake_tb.container.exec_calls
def test_compose_up_failure_includes_stderr(self, fake_tb):
import subprocess
fake_tb.spin_up_error = subprocess.CalledProcessError(
returncode=1,
cmd=["docker", "compose", "up"],
stderr="no space left on device",
)
env = TerminalBenchTaskEnv(fake_tb.metadata)
with pytest.raises(TaskEnvironmentError) as excinfo:
env.__enter__()
assert "no space left on device" in str(excinfo.value)
def test_keyboard_interrupt_not_masked(self, fake_tb):
fake_tb.terminal.create_session_error = KeyboardInterrupt()
env = TerminalBenchTaskEnv(fake_tb.metadata)
with pytest.raises(KeyboardInterrupt):
env.__enter__()
# Still cleaned up, but the interrupt is not wrapped.
assert "compose_down" in fake_tb.events
def test_teardown_is_idempotent(self, fake_tb):
env = TerminalBenchTaskEnv(fake_tb.metadata)
env.__enter__()
env.__exit__(None, None, None)
env.__exit__(None, None, None)
assert fake_tb.events.count("compose_down") == 1
@@ -0,0 +1,302 @@
"""Tests for the TerminalBench native backend (mocked terminal_bench).
Covers: timeout kwargs threading (config -> backend -> Harness kwargs),
loud failure on terminal-bench builds without the timeout kwargs, and the
harness-error classification in ``summarize_benchmark_results`` —
including the zero-model-contact vs genuine-model-miss distinction.
"""
from __future__ import annotations
import sys
import types
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
import pytest
import openjarvis.evals.backends.terminalbench_native as tbn
from openjarvis.evals.backends.terminalbench_native import (
summarize_benchmark_results,
)
# ---------------------------------------------------------------------------
# Helpers / fakes
# ---------------------------------------------------------------------------
def make_trial(
task_id: str,
*,
is_resolved: bool,
failure_mode: str = "unset",
input_tokens: Optional[int] = None,
output_tokens: Optional[int] = None,
) -> SimpleNamespace:
"""Build a duck-typed terminal-bench 0.2.18 TrialResults."""
return SimpleNamespace(
task_id=task_id,
trial_name=f"{task_id}.1-of-1",
is_resolved=is_resolved,
failure_mode=SimpleNamespace(value=failure_mode),
total_input_tokens=input_tokens,
total_output_tokens=output_tokens,
)
class FakeHarness:
"""Records constructor kwargs; run() returns the configured results."""
captured_kwargs: Dict[str, Any] = {}
results: Any = SimpleNamespace(results=[])
def __init__(self, **kwargs: Any) -> None:
type(self).captured_kwargs = kwargs
def run(self) -> Any:
return type(self).results
class OldFakeHarness:
"""A pre-timeout-kwargs Harness signature (no **kwargs)."""
def __init__(
self,
output_path: Any = None,
run_id: Any = None,
dataset_name: Any = None,
dataset_version: Any = None,
model_name: Any = None,
n_concurrent_trials: Any = None,
cleanup: Any = None,
agent_name: Any = None,
agent_kwargs: Any = None,
n_tasks: Any = None,
) -> None:
pass
def run(self) -> Any:
return SimpleNamespace(results=[])
@pytest.fixture()
def fake_tb_backend(monkeypatch):
"""Enable the backend without the real terminal_bench package."""
FakeHarness.captured_kwargs = {}
FakeHarness.results = SimpleNamespace(results=[])
monkeypatch.setattr(tbn, "_HAS_TB", True)
monkeypatch.setattr(tbn, "Harness", FakeHarness, raising=False)
mod_tb = types.ModuleType("terminal_bench")
mod_agents = types.ModuleType("terminal_bench.agents")
mod_agent_name = types.ModuleType("terminal_bench.agents.agent_name")
mod_agent_name.AgentName = lambda name: name
mod_tb.agents = mod_agents
mod_agents.agent_name = mod_agent_name
monkeypatch.setitem(sys.modules, "terminal_bench", mod_tb)
monkeypatch.setitem(sys.modules, "terminal_bench.agents", mod_agents)
monkeypatch.setitem(sys.modules, "terminal_bench.agents.agent_name", mod_agent_name)
return FakeHarness
# ---------------------------------------------------------------------------
# Timeout kwargs threading
# ---------------------------------------------------------------------------
class TestTimeoutKwargs:
def test_default_bound_reaches_harness(self, fake_tb_backend, tmp_path):
backend = tbn.TerminalBenchNativeBackend(output_dir=str(tmp_path))
backend.run_harness("run-1")
kwargs = fake_tb_backend.captured_kwargs
assert kwargs["global_agent_timeout_sec"] == 1800.0
assert "global_timeout_multiplier" not in kwargs
def test_explicit_values_reach_harness(self, fake_tb_backend, tmp_path):
backend = tbn.TerminalBenchNativeBackend(
output_dir=str(tmp_path),
global_agent_timeout_sec=1234.0,
global_timeout_multiplier=2.0,
)
backend.run_harness("run-1")
kwargs = fake_tb_backend.captured_kwargs
assert kwargs["global_agent_timeout_sec"] == 1234.0
assert kwargs["global_timeout_multiplier"] == 2.0
def test_zero_disables_bound(self, fake_tb_backend, tmp_path):
backend = tbn.TerminalBenchNativeBackend(
output_dir=str(tmp_path), global_agent_timeout_sec=0
)
backend.run_harness("run-1")
assert "global_agent_timeout_sec" not in fake_tb_backend.captured_kwargs
def test_old_terminal_bench_fails_loud(
self, fake_tb_backend, monkeypatch, tmp_path
):
"""An old Harness without the kwargs must not hang silently."""
monkeypatch.setattr(tbn, "Harness", OldFakeHarness, raising=False)
backend = tbn.TerminalBenchNativeBackend(output_dir=str(tmp_path))
with pytest.raises(RuntimeError, match="global_agent_timeout_sec"):
backend.run_harness("run-1")
# ---------------------------------------------------------------------------
# Harness-error classification (zero-model-contact detection)
# ---------------------------------------------------------------------------
class TestSummarizeBenchmarkResults:
def test_genuine_model_miss_is_not_flagged(self):
"""MANDATORY: a real miss (tokens>0, failure_mode unset) stays a miss.
terminal-bench 0.2.18 leaves failure_mode UNSET on success and on
genuine unresolved misses — classifying on failure_mode would flag
every real miss as a harness error and inflate accuracy.
"""
results = SimpleNamespace(
results=[
make_trial(
"t-ok", is_resolved=True, input_tokens=900, output_tokens=100
),
make_trial(
"t-miss", is_resolved=False, input_tokens=800, output_tokens=50
),
make_trial(
"t-setup-dead",
is_resolved=False,
failure_mode="agent_installation_failed",
input_tokens=0,
output_tokens=0,
),
]
)
summary, failures = summarize_benchmark_results(results, model="m")
assert summary.total_samples == 3
assert summary.scored_samples == 2 # genuine miss stays in denominator
assert summary.correct == 1
assert summary.accuracy == 0.5 # not 1.0 (miss kept), not 1/3 (infra out)
assert summary.errors == 1
assert [f["task_id"] for f in failures] == ["t-setup-dead"]
assert failures[0]["reason"] == "zero_model_requests"
def test_zero_contact_flagged_even_with_unset_failure_mode(self):
"""Setup hang signature: unresolved, zero requests, failure_mode unset."""
results = SimpleNamespace(
results=[
make_trial("t-hang", is_resolved=False, input_tokens=0),
]
)
summary, failures = summarize_benchmark_results(results, model="m")
assert summary.errors == 1
assert summary.scored_samples == 0
assert failures[0]["reason"] == "zero_model_requests"
def test_missing_token_fields_treated_as_zero_contact(self):
results = SimpleNamespace(results=[make_trial("t-none", is_resolved=False)])
summary, failures = summarize_benchmark_results(results, model="m")
assert summary.errors == 1
def test_infra_failure_mode_flagged_despite_tokens(self):
results = SimpleNamespace(
results=[
make_trial(
"t-crash",
is_resolved=False,
failure_mode="unknown_agent_error",
input_tokens=500,
output_tokens=20,
),
]
)
summary, failures = summarize_benchmark_results(results, model="m")
assert summary.errors == 1
assert failures[0]["reason"] == "unknown_agent_error"
def test_resolved_with_zero_tokens_not_flagged(self):
"""Installed agents report 0 tokens on success — never flag resolved."""
results = SimpleNamespace(
results=[
make_trial("t-ok", is_resolved=True, input_tokens=0, output_tokens=0),
]
)
summary, failures = summarize_benchmark_results(results, model="m")
assert summary.errors == 0
assert summary.correct == 1
assert summary.accuracy == 1.0
def test_empty_results(self):
summary, failures = summarize_benchmark_results(
SimpleNamespace(results=[]), model="m"
)
assert summary.total_samples == 0
assert summary.accuracy == 0.0
assert failures == []
# ---------------------------------------------------------------------------
# CLI wiring: config -> backend -> harness kwargs -> RunSummary
# ---------------------------------------------------------------------------
class TestRunTerminalbenchNativeWiring:
def _run(self, fake_tb_backend, tmp_path, trials: List[Any], **config_kwargs):
from rich.console import Console
from openjarvis.evals.cli import _run_terminalbench_native
from openjarvis.evals.core.types import RunConfig
fake_tb_backend.results = SimpleNamespace(results=trials)
config = RunConfig(
benchmark="terminalbench-native",
backend="terminalbench-native",
model="test-model",
output_path=str(tmp_path / "out"),
**config_kwargs,
)
console = Console(record=True, width=120)
summary = _run_terminalbench_native(config, console)
return summary, console.export_text()
def test_config_timeouts_reach_harness_kwargs(self, fake_tb_backend, tmp_path):
"""(d) timeout kwargs travel config -> backend -> harness_kwargs."""
self._run(
fake_tb_backend,
tmp_path,
[],
global_agent_timeout_sec=901.0,
global_timeout_multiplier=1.5,
)
kwargs = fake_tb_backend.captured_kwargs
assert kwargs["global_agent_timeout_sec"] == 901.0
assert kwargs["global_timeout_multiplier"] == 1.5
def test_config_defaults_use_backend_bound(self, fake_tb_backend, tmp_path):
self._run(fake_tb_backend, tmp_path, [])
assert fake_tb_backend.captured_kwargs["global_agent_timeout_sec"] == 1800.0
def test_summary_counts_real_trials(self, fake_tb_backend, tmp_path):
"""Regression: results field is ``results``, not ``trial_results``.
The old conversion read the nonexistent ``trial_results`` attribute
and hardcoded errors=0, rendering every run as 0 samples / 0.0.
"""
trials = [
make_trial("t-ok", is_resolved=True, input_tokens=10, output_tokens=10),
make_trial("t-miss", is_resolved=False, input_tokens=10, output_tokens=2),
make_trial(
"t-hang",
is_resolved=False,
failure_mode="agent_timeout",
input_tokens=0,
output_tokens=0,
),
]
summary, output = self._run(fake_tb_backend, tmp_path, trials)
assert summary.total_samples == 3
assert summary.scored_samples == 2
assert summary.correct == 1
assert summary.accuracy == 0.5
assert summary.errors == 1
# The harness failure is reported loudly with its task id.
assert "t-hang" in output
assert "zero_model_requests" in output