Files
OpenJarvis/tests/evals/test_agentic_runner.py
T
b21463aab6 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>
2026-06-11 14:30:10 -07:00

282 lines
9.5 KiB
Python

"""Tests for AgenticRunner with mock agent and dataset."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
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
# ---------------------------------------------------------------------------
@dataclass
class MockRecord:
record_id: str
problem: str
expected: str = ""
category: str = "test"
metadata: Dict[str, Any] = field(default_factory=dict)
class MockDataset:
def __init__(self, records: List[MockRecord]):
self._records = records
def iter_records(self):
return iter(self._records)
class MockAgent:
"""Agent that echoes the query."""
def ask(self, query: str) -> dict:
return {
"content": f"Response to: {query}",
"usage": {"prompt_tokens": 50, "completion_tokens": 25},
"cost_usd": 0.001,
}
class MockFailingAgent:
"""Agent that always raises."""
def ask(self, query: str) -> dict:
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
# ---------------------------------------------------------------------------
class TestAgenticRunner:
def _run_async(self, coro):
return asyncio.get_event_loop().run_until_complete(coro)
@pytest.fixture(autouse=True)
def _setup_loop(self):
try:
asyncio.get_event_loop()
except RuntimeError:
asyncio.set_event_loop(asyncio.new_event_loop())
def test_basic_run(self):
records = [
MockRecord(record_id="r1", problem="What is 2+2?"),
MockRecord(record_id="r2", problem="What is 3+3?"),
]
dataset = MockDataset(records)
agent = MockAgent()
runner = AgenticRunner(agent=agent, dataset=dataset)
traces = self._run_async(runner.run())
assert len(traces) == 2
assert all(t.completed for t in traces)
assert traces[0].query_id == "q0000"
assert traces[1].query_id == "q0001"
assert "Response to: What is 2+2?" in traces[0].response_text
def test_max_queries(self):
records = [MockRecord(record_id=f"r{i}", problem=f"Q{i}") for i in range(10)]
dataset = MockDataset(records)
runner = AgenticRunner(agent=MockAgent(), dataset=dataset)
traces = self._run_async(runner.run(max_queries=3))
assert len(traces) == 3
def test_agent_failure(self):
records = [MockRecord(record_id="r1", problem="test")]
dataset = MockDataset(records)
runner = AgenticRunner(agent=MockFailingAgent(), dataset=dataset)
traces = self._run_async(runner.run())
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")]
dataset = MockDataset(records)
runner = AgenticRunner(agent=MockAgent(), dataset=dataset)
traces = self._run_async(runner.run())
assert traces[0].num_turns == 1
assert traces[0].turns[0].input_tokens == 50
assert traces[0].turns[0].output_tokens == 25
def test_traces_property(self):
records = [MockRecord(record_id="r1", problem="test")]
dataset = MockDataset(records)
runner = AgenticRunner(agent=MockAgent(), dataset=dataset)
self._run_async(runner.run())
assert len(runner.traces) == 1
def test_artifacts_saved(self, tmp_path):
records = [MockRecord(record_id="r1", problem="test")]
dataset = MockDataset(records)
runner = AgenticRunner(agent=MockAgent(), dataset=dataset, run_dir=tmp_path)
self._run_async(runner.run())
arts = tmp_path / "artifacts"
assert arts.exists()
subdirs = list(arts.iterdir())
assert len(subdirs) == 1
assert (subdirs[0] / "response.txt").exists()
assert (subdirs[0] / "metadata.json").exists()
def test_query_timeout_configured(self):
"""Verify timeout is stored and runner accepts the parameter."""
records = [MockRecord(record_id="r1", problem="test")]
dataset = MockDataset(records)
runner = AgenticRunner(agent=MockAgent(), dataset=dataset, query_timeout=30.0)
assert runner._query_timeout == 30.0
class TestExtractPatch:
def test_fenced_diff(self):
text = (
"Here's the fix:\n```diff\n"
"--- a/foo.py\n+++ b/foo.py\n"
"@@ -1 +1 @@\n-old\n+new\n```\n"
)
patch = _extract_patch(text)
assert patch is not None
assert "--- a/foo.py" in patch
def test_unfenced_diff(self):
text = (
"Some explanation\n"
"diff --git a/x.py b/x.py\n"
"--- a/x.py\n+++ b/x.py\n"
"@@ -1 +1 @@\n-old\n+new\n"
)
patch = _extract_patch(text)
assert patch is not None
assert "diff --git" in patch
def test_no_patch(self):
text = "This is just a regular response with no code changes."
patch = _extract_patch(text)
assert patch is None