Skip to content

Commit 0483d0f

Browse files
committed
fix(agent-challenge): stop a job once the agent cannot be constructed
Agent construction -- unpack, install, import, instantiate -- happens per trial but does not depend on the task. A submission whose ZIP ships a bare agent.py with no installable project fails identically on every task, so a 30-task job spent hours of wall clock and real LLM budget re-deriving one packaging error, 30 times, before reporting 0. Count construction failures across trials and short-circuit the remaining ones once a small threshold confirms the package is broken. The threshold is greater than one so a single transient fault, such as a flaky package index during pip install, cannot abort an otherwise healthy job. Trials that never ran are still planned, still aggregated, and still score 0, so the totals stay honest; each carries the construction reason code and an error text naming the short-circuit and what to fix.
1 parent 618e70a commit 0483d0f

2 files changed

Lines changed: 113 additions & 3 deletions

File tree

packages/challenges/agent-challenge/src/agent_challenge/evaluation/own_runner/orchestrator.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@
4343
from pathlib import Path
4444
from typing import Any
4545

46-
from agent_challenge.evaluation.own_runner.driver import AgentDriver
46+
from agent_challenge.evaluation.own_runner.driver import (
47+
AGENT_LOAD_FAILED_REASON_CODE,
48+
AgentDriver,
49+
)
4750
from agent_challenge.evaluation.own_runner.reason_codes import (
4851
REASON_CODES,
4952
is_known_reason_code,
@@ -105,6 +108,17 @@
105108
#: finalizes with its sibling trials intact.
106109
TRIAL_CRASH_REASON_CODE = "harbor_trial_failed"
107110

111+
#: How many construction failures confirm a broken submission package.
112+
#:
113+
#: Agent construction (unpack + install + import + instantiate) is
114+
#: task-independent: a ZIP missing its manifest, or importing a module it never
115+
#: shipped, breaks identically on every task. Re-running all 30 tasks to
116+
#: relearn that one fact burns hours of wall clock and real LLM budget, so the
117+
#: job stops once this many trials have failed that way. The threshold is >1 so
118+
#: a single transient fault (a flaky package index during ``pip install``)
119+
#: cannot abort an otherwise healthy job.
120+
CONSTRUCTION_FAILURE_ABORT_THRESHOLD = 2
121+
108122
# Fail fast at import if the taxonomy ever drops a code we emit.
109123
assert TRIAL_TIMEOUT_REASON_CODE in REASON_CODES
110124
assert TRIAL_CRASH_REASON_CODE in REASON_CODES
@@ -443,21 +457,36 @@ async def run(self, tasks: Sequence[TaskSpec]) -> JobResult:
443457
state_lock = asyncio.Lock()
444458
in_flight = 0
445459
peak = 0
460+
construction_failures = 0
461+
short_circuit = False
446462

447463
async def execute(trial_id: TrialId) -> TrialOutcome:
448-
nonlocal in_flight, peak
464+
nonlocal in_flight, peak, construction_failures, short_circuit
449465
# Resume: a persisted result means this trial is already done -- load
450466
# it WITHOUT acquiring the semaphore (it never re-runs, never counts
451467
# toward in-flight, never double-counts).
452468
persisted = self._load_trial(trial_id)
453469
if persisted is not None:
454470
return persisted
455471

472+
task = task_lookup[trial_id.task_name]
473+
474+
# Fail-fast: once enough trials have proven the submission's agent
475+
# cannot be constructed, the remaining trials would burn budget to
476+
# reproduce the same packaging error. Resolve them immediately as
477+
# explicit, self-describing failures instead of running them.
478+
async with state_lock:
479+
aborted = short_circuit
480+
if aborted:
481+
outcome = self._short_circuited_outcome(trial_id, task)
482+
self._persist_trial(trial_id, outcome)
483+
await self._notify_trial_listener(trial_id, outcome)
484+
return outcome
485+
456486
async with semaphore:
457487
async with state_lock:
458488
in_flight += 1
459489
peak = max(peak, in_flight)
460-
task = task_lookup[trial_id.task_name]
461490
try:
462491
# Backstop: bound the whole trial (prepare + drive + verify +
463492
# teardown) so one stalled sub-step can never wedge
@@ -481,6 +510,13 @@ async def execute(trial_id: TrialId) -> TrialOutcome:
481510
finally:
482511
async with state_lock:
483512
in_flight -= 1
513+
# Count construction failures so a broken package trips the
514+
# fail-fast gate above for every trial not yet started.
515+
if outcome.reason_code == AGENT_LOAD_FAILED_REASON_CODE:
516+
async with state_lock:
517+
construction_failures += 1
518+
if construction_failures >= CONSTRUCTION_FAILURE_ABORT_THRESHOLD:
519+
short_circuit = True
484520
# Persist immediately so a later crash cannot lose a finished
485521
# trial (and a resume skips it).
486522
self._persist_trial(trial_id, outcome)
@@ -541,6 +577,34 @@ def _crashed_outcome(
541577
error_text=f"trial crashed: {type(exc).__name__}: {exc}",
542578
)
543579

580+
def _short_circuited_outcome(self, trial_id: TrialId, task: TaskSpec) -> TrialOutcome:
581+
"""Failed outcome for a trial never run because the package is broken.
582+
583+
Mirrors :meth:`_timed_out_outcome` (``status="failed"``, ``errored=True``,
584+
``rewards=None``) so the job still aggregates every planned trial and the
585+
totals stay honest -- the task was planned, scored 0, and says exactly
586+
why it never executed.
587+
"""
588+
589+
return TrialOutcome(
590+
task_name=trial_id.task_name,
591+
trial_name=trial_id.trial_name,
592+
status="failed",
593+
rewards=None,
594+
reason_code=AGENT_LOAD_FAILED_REASON_CODE,
595+
errored=True,
596+
agent_name=self._config.agent_name,
597+
model_name=self._config.model_name,
598+
source=task.source,
599+
error_text=(
600+
"short-circuit: agent construction failed on "
601+
f"{CONSTRUCTION_FAILURE_ABORT_THRESHOLD} earlier trials, so this "
602+
"trial was not run. Agent construction is task-independent -- fix "
603+
"the submission package (installable project + importable modules) "
604+
"and resubmit."
605+
),
606+
)
607+
544608
# -- lock / persistence ------------------------------------------------
545609

546610
def _check_or_write_lock(self) -> None:

packages/challenges/agent-challenge/tests/test_own_runner_orchestrator.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,3 +963,49 @@ async def _preparer(trial_id: TrialId, task: TaskSpec) -> PreparedTrial:
963963
assert result.resolved == 2
964964
assert result.total == 2
965965
assert result.pass_at_k == {"agent__adhoc": {2: 1.0}}
966+
967+
968+
# ===========================================================================
969+
# Fail-fast: a submission whose agent cannot even be constructed
970+
# ===========================================================================
971+
972+
973+
async def test_construction_failures_short_circuit_the_remaining_trials(
974+
tmp_path: Path,
975+
) -> None:
976+
"""A package that cannot construct fails identically on every task.
977+
978+
Agent construction is task-independent: a ZIP missing its manifest or its
979+
imported modules breaks the same way for all 30 tasks. Running them all
980+
burns hours of LLM budget to relearn one fact, so the job must stop after a
981+
small confirmation threshold and mark the rest explicitly.
982+
"""
983+
attempted: list[str] = []
984+
985+
async def _run(trial_id: TrialId, task: TaskSpec) -> TrialOutcome:
986+
attempted.append(trial_id.trial_name)
987+
return TrialOutcome(
988+
task_name=trial_id.task_name,
989+
trial_name=trial_id.trial_name,
990+
status="failed",
991+
rewards=None,
992+
reason_code="harbor_submission_code_failed",
993+
errored=True,
994+
error_text="agent construction failed: no pyproject.toml",
995+
)
996+
997+
tasks = [TaskSpec(f"task-{i}") for i in range(10)]
998+
orch = TrialJobOrchestrator(
999+
config=JobConfig(n_attempts=1, n_concurrent=1),
1000+
job_dir=tmp_path / "job",
1001+
trial_runner=_run,
1002+
)
1003+
result = await orch.run(tasks)
1004+
1005+
assert len(attempted) <= 2, f"must stop early, ran {len(attempted)} trials"
1006+
# Every task still accounted for, and the skipped ones say why.
1007+
assert result.n_total_trials == 10
1008+
assert result.score == 0.0
1009+
skipped = [o for o in result.trial_outcomes if "short-circuit" in (o.error_text or "")]
1010+
assert len(skipped) == 10 - len(attempted)
1011+
assert all(o.reason_code == "harbor_submission_code_failed" for o in skipped)

0 commit comments

Comments
 (0)