Skip to content

Commit 53c0bfa

Browse files
fix: bound the optimizer's tool calls from the harness, not the prompt
The two commits under this PR diagnosed the failure correctly and then fixed it in the one place that cannot enforce anything. The optimizer's stdout reaches harbor as one long-lived stream, a harness flushes a command's output only when that command returns, and an idle stream gets reaped while the machine, the connection and the sandbox stay healthy. So "how long may one tool call run" and "how long may the stream go silent" are the same question, and the outer trial is not retried when the answer is too long. Asking the optimizer to wait in bounded steps is the right shape at the wrong layer. The evidence is in this branch's own history: the recipe shipped in the first commit spun forever on both arms and had to be replaced in the second. A prompt is advisory, a model reconstructing the loop from memory reintroduces the failure, and nothing in the system notices when it does. Both harnesses already expose the bound as a setting, so set it. HARNESS_TOOL_TIMEOUT_SECONDS = 300 goes out per harness through harbor's --ae seam: opencode reads OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS, claude-code reads BASH_DEFAULT_TIMEOUT_MS and BASH_MAX_TIMEOUT_MS. Harnesses with no verified knob (codex, mini-swe-agent) are sent nothing rather than a variable they ignore. It is placed ahead of the build's own agent_env so a build can raise or drop the cap by naming the same variable, since harbor's parse_env_vars keeps the last value for a key. opencode's is a default only: packages/opencode/src/tool/shell.ts resolves `flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000` and then `params.timeout ?? defaultTimeoutMs` with no clamp, so a model naming its own timeout still escapes it. It covers the case that actually killed the run, where the instruction said to let the call block and the model therefore never named one, and it lowers the number quoted in the tool description the model reads. claude-code's MAX is a true ceiling. The cap alone would be worse than nothing: a five-minute kill on a thirty-minute `evals run` hands the model opencode's own "retry with a larger timeout" message, which restores the silence and burns an evaluation. So `evals run` now always starts a job and polls it internally on a 240s bound, 255s worst case, returning the job record with its job_id when the bound expires. `evals wait` resumes, and the evaluation keeps running in the sidecar throughout. Same evaluation as before: the sidecar drives POST /eval and POST /eval/jobs through the same _execute_tracked_job, same budget, same SidecarEvaluationResult. The detached path records failures on the job record instead of raising, so _await_evaluation_job reads the reason back off it and raises a ClickException, preserving today's non-zero exit. The instruction template loses the recipe, the contract essay and the incident narrative that the two earlier commits added, about 45 lines, and states the behavior in four. The number it quotes is read from WAIT_TIMEOUT_SECONDS through the template context rather than restated, so prompt and code cannot drift. SKILL.md and docs/guide.md follow. test_harbor_cli_builds_canonical_selection pinned the old contract that a plain run posts to /eval; it now pins /eval/jobs. That was the only existing test the change broke. Test plan: 490 passed, 15 skipped (485 before, five new tests: the per-harness bound including the HARNESS_TOOL_TIMEOUT_SECONDS > WAIT_TIMEOUT_SECONDS ordering, a build overriding the cap through agent_env, and the three _await_evaluation_job paths, bound expiry, completion and failure). Credentials must be exported or five unrelated tests fail on compiler.py credential validation. Not verified end to end. The failure is probabilistic, the same stream in the dead run survived a 24.5-minute gap and then died after ten, so a live A/B would need many full optimization runs to say anything. What is verified is that the variables reach the harness and that the CLI returns inside the cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 08ee80f commit 53c0bfa

8 files changed

Lines changed: 376 additions & 79 deletions

File tree

vero/docs/guide.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,15 @@ build.yaml --output task` compiles without running, for inspection.
9696
> Harbor constructs the agent; a raw `harbor run` would let the agent adapter
9797
> read the upstream key from its own host process first.
9898

99-
Inside the container the agent evaluates candidates with `evals run
100-
--detach`, then `evals status JOB` / `evals result JOB` / `evals status` (via `VERO_EVAL_URL`).
101-
Detached evaluations are **durable jobs** — the candidate version is captured
102-
before the command returns, so ending the agent process can't lose or race a
103-
running measurement.
99+
Inside the container the agent evaluates candidates with `evals run` (via
100+
`VERO_EVAL_URL`), which waits for the result and, if the evaluation outlives
101+
that wait's bound, hands back a `job_id` for `evals wait JOB`; `--detach`
102+
returns the id immediately instead, for evaluating concurrently. Either way the
103+
evaluation is a **durable job**: the candidate version is captured before the
104+
command returns, so ending the agent process can't lose or race a running
105+
measurement. The bound matters because vero also caps how long one of the
106+
optimizer's tool calls may run (`HARNESS_TOOL_TIMEOUT_SECONDS`), so an `evals`
107+
call has to return on its own terms rather than be killed mid-evaluation.
104108

105109
### How the boundaries hold
106110

vero/src/vero/evals_cli.py

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@
2626
CONTEXT_DIRECTORY = ".evals"
2727
_CELL_WIDTH = 48
2828

29+
# How long a blocking `evals run` / `evals wait` waits before returning what it
30+
# has so far.
31+
#
32+
# Not ergonomics: the optimizer's whole process is read through one long-lived
33+
# stdout stream, and an agent harness only flushes a command's output when that
34+
# command *returns*. A call that sits silent long enough for the network path to
35+
# reap that stream kills the trial, and the outer trial is not retried. Observed
36+
# 2026-07-31: a cell died at 71 minutes, 9m57s into a single silent wait,
37+
# discarding a candidate that had already scored 0.1224 on 49 validation cases.
38+
# Returning on a bound is what keeps the stream alive, and each return costs one
39+
# line of output.
40+
#
41+
# Deliberately below HARNESS_TOOL_TIMEOUT_SECONDS (`vero/harbor/cli.py`), the cap
42+
# vero sets on the optimizer's harness: these commands must return on their own
43+
# terms, handing back a job id that can be waited on again, rather than be killed
44+
# by the harness and hand the model a "retry with a larger timeout" message.
45+
WAIT_TIMEOUT_SECONDS = 240.0
46+
WAIT_POLL_INTERVAL_SECONDS = 15.0
47+
2948

3049
# --------------------------------------------------------------------------
3150
# Context discovery and shared helpers
@@ -301,10 +320,11 @@ def evals() -> None:
301320
resources), `candidates/` (prior program versions), and `plan.json`
302321
(what you may evaluate, and remaining budget).
303322
304-
Typical loop: `evals plan` -> edit + commit -> `evals run` (blocks and
305-
returns the result) -> `evals diff BASELINE CANDIDATE` ->
306-
`evals cases ID --sort score` -> `evals trace ID CASE`. Add `--detach` only
307-
to run several evaluations at once, then poll `evals status JOB`.
323+
Typical loop: `evals plan` -> edit + commit -> `evals run` (waits for the
324+
result, or returns a `job_id` to `evals wait` on) -> `evals diff BASELINE
325+
CANDIDATE` -> `evals cases ID --sort score` -> `evals trace ID CASE`. Add
326+
`--detach` only to run several evaluations at once, then poll
327+
`evals status JOB`.
308328
"""
309329

310330

@@ -384,34 +404,41 @@ def status_command(job_id):
384404
@click.argument("job_id")
385405
@click.option(
386406
"--poll-interval",
387-
default=15.0,
407+
default=WAIT_POLL_INTERVAL_SECONDS,
388408
show_default=True,
389409
type=click.FloatRange(min=1),
390410
help="Seconds between status polls.",
391411
)
392412
@click.option(
393413
"--timeout",
414+
default=WAIT_TIMEOUT_SECONDS,
415+
show_default=True,
394416
type=click.FloatRange(min=0),
395-
help="Optional max seconds to wait. On expiry, print the current "
396-
"(still-running) status and exit 0 so you can simply call `evals wait` "
397-
"again. Default: wait until the job finishes.",
417+
help="Max seconds to wait. On expiry, print the current (still-running) "
418+
"status and exit 0, so calling `evals wait` again resumes the wait. The "
419+
"default is bounded on purpose; raising it risks the run (see the docstring).",
398420
)
399421
def wait_command(job_id, poll_interval, timeout):
400-
"""Block until a detached job finishes, then print its result.
422+
"""Wait for a detached job, then print its result.
401423
402424
The blocking companion to `evals run --detach`: one call that waits, so you
403-
never hand-roll a poll loop. Idempotent — safe to call again if it returns
404-
while the job is still running (only happens when --timeout is set).
425+
never hand-roll a poll loop. Idempotent, so it is always safe to call again
426+
when it returns while the job is still running.
427+
428+
The wait is *bounded* by default rather than open-ended. A call that returns
429+
nothing for tens of minutes can get the whole run killed (see
430+
WAIT_TIMEOUT_SECONDS), and re-entering costs one line of output, so the
431+
bound is the default rather than something to opt into.
405432
"""
406433
request = _sidecar_request()
407434
terminal = {"complete", "failed", "cancelled"}
408-
deadline = None if timeout is None else time.monotonic() + timeout
435+
deadline = time.monotonic() + timeout
409436
while True:
410437
job = request("GET", f"/eval/jobs/{job_id}")
411438
status = job.get("status") if isinstance(job, dict) else None
412439
if status in terminal:
413440
break
414-
if deadline is not None and time.monotonic() >= deadline:
441+
if time.monotonic() >= deadline:
415442
click.echo(json.dumps(_enrich_job(job), indent=2))
416443
return
417444
time.sleep(poll_interval)

vero/src/vero/harbor/build/compiler.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from importlib.metadata import version as distribution_version
1616
from pathlib import Path, PurePosixPath
1717

18+
from vero.evals_cli import WAIT_TIMEOUT_SECONDS
1819
from vero.evaluation import (
1920
EvaluationBudget,
2021
EvaluationLimits,
@@ -787,6 +788,9 @@ def compile_harbor_task(
787788
],
788789
"exhaust_budget": config.instruct_exhaust_budget,
789790
"disclose_budget": config.disclose_budget,
791+
# Read from the CLI constant rather than restated here, so the number the
792+
# instruction quotes cannot drift from the one `evals run` enforces.
793+
"wait_timeout_seconds": WAIT_TIMEOUT_SECONDS,
790794
"build_timeout": config.build_timeout_seconds,
791795
"verifier_timeout": (
792796
config.verifier_timeout_seconds or max(1, int(config.timeout_seconds))

vero/src/vero/harbor/build/templates/instruction.md.j2

Lines changed: 13 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ hidden final evaluation. The trusted evaluation sidecar owns the cases, scoring,
2525
--partition {{ selection_partition }}
2626
```
2727

28-
An evaluation can take many minutes. For a **short** one, let the call block
29-
and read the result it returns. For anything longer than a few minutes, use
30-
the bounded wait loop below instead: a single call that blocks in silence for
31-
half an hour can get your run killed. Iterate cheaply on a subset first
32-
(`--start 0 --stop N`, or repeated `--case-id ID`).
28+
An evaluation can take many minutes. `evals run` waits for it and prints the
29+
result. If it is still running after {{ wait_timeout_seconds | int }}s the
30+
command returns the job record instead, carrying a `job_id`; the evaluation
31+
keeps running and `evals wait JOB_ID` resumes the wait. Iterate cheaply on a
32+
subset first (`--start 0 --stop N`, or repeated `--case-id ID`).
3333
{% if seed_supported %}
3434
Pass `--seed N` to reproduce a noisy comparison exactly.
3535
{% else %}
@@ -47,50 +47,19 @@ hidden final evaluation. The trusted evaluation sidecar owns the cases, scoring,
4747
and `evals diff OLD NEW`. Do not re-run an evaluation to see a number you
4848
truncated — look it up.
4949

50-
Add `--detach` to run several evaluations at once, and to keep any long one
51-
from going silent: it returns a `job_id` immediately instead of blocking.
52-
Then wait for it in **bounded** steps rather than one open-ended call:
53-
54-
```bash
55-
evals run --detach --backend ... --evaluation-set ... --partition ...
56-
# -> prints a job_id
57-
evals wait JOB_ID --timeout 300
58-
# -> returns within 5 minutes whether or not the job is done; call it again
59-
# while it is still running
60-
```
61-
62-
`evals wait --timeout N` returns when the job reaches a terminal state OR
63-
after N seconds, whichever comes first, and exits 0 either way, so calling it
64-
again is always safe. A job is terminal when `evals status JOB_ID` reports
65-
`complete`, `failed` or `cancelled`. **Check the status; do not loop on the
66-
text of the wait output.** On success `evals wait` prints the evaluation
67-
*result*, not the job record, so a loop that waits for the word "complete" in
68-
that output never terminates, and on a `failed` or `cancelled` job every
69-
further wait returns instantly, turning the same loop into a busy spin that
70-
burns the rest of your run. Plain `evals wait JOB_ID` with no `--timeout`
71-
blocks until the job finishes, which is fine for a short evaluation and is
72-
the thing to avoid for a long one.
73-
74-
**Why bounded, and not one long block.** Your process is read through a
75-
single long-lived stdout stream, and a harness only flushes a command's
76-
output when that command *returns*. A wait that blocks for half an hour
77-
therefore emits nothing for half an hour, and a stream idle that long can be
78-
torn down by the network path even though the machine, the connection and the
79-
sandbox are all healthy. When that happens the run dies with
80-
`StreamTerminatedError` / "Connection lost", the outer trial is not retried,
81-
and the whole optimization is lost. Observed 2026-07-31: a cell
82-
died at 71 minutes, 9m57s into a silent wait, having already earned a 0.1224
83-
validation score. A `--timeout 300` loop caps that silence at five minutes
84-
and costs nothing, because each return is just one more line of output.
50+
Add `--detach` **only** to run several evaluations at once: it returns a
51+
`job_id` immediately without waiting. `evals wait JOB_ID` then waits for one
52+
on the same bound, and `evals status JOB_ID` reports its state and elapsed
53+
time without waiting at all. A job is finished when its status is `complete`,
54+
`failed` or `cancelled`; any other status means it is still running, so wait
55+
on it again.
8556

8657
**Run every `evals` call in the foreground.** You are a single-shot headless
8758
run: nothing exists to deliver a notification or wake you later. If you put a
8859
long call in a background task, schedule a wake-up, or say you will "report
8960
back when it finishes", the run simply ends there and whatever you have not
90-
submitted is lost. The bounded wait loop above is still foreground: it blocks
91-
the whole time, it just returns and re-enters every few minutes instead of
92-
sitting silent, so it satisfies this rule rather than bending it. A call that
93-
blocks for a few minutes is working correctly — let it block. To wait on two
61+
submitted is lost. A call that waits for minutes and hands back a
62+
still-running job is working correctly: wait on it again. To wait on two
9463
jobs, wait out the first, then the second. Do not claim an improvement before
9564
its comparison baseline has been scored on the same cases.
9665

vero/src/vero/harbor/cli.py

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,18 @@
1111
import subprocess
1212
import sys
1313
import tempfile
14+
import time
1415
import urllib.error
1516
import urllib.request
1617
from pathlib import Path
1718

1819
import click
1920

21+
from vero.evals_cli import (
22+
WAIT_POLL_INTERVAL_SECONDS,
23+
WAIT_TIMEOUT_SECONDS,
24+
_enrich_job,
25+
)
2026
from vero.evaluation import (
2127
CaseIds,
2228
CaseRange,
@@ -231,6 +237,42 @@ def _load_env_file(path: Path) -> dict[str, str]:
231237
# the gateway token cap are the intended limits.
232238
OPENCODE_STEP_LIMIT = 1000
233239

240+
# The ceiling vero puts on a single optimizer tool call, in seconds.
241+
#
242+
# The optimizer's stdout reaches harbor as one long-lived stream, and an agent
243+
# harness only flushes output when a tool call *returns*. "How long may one tool
244+
# call run" is therefore the same question as "how long may that stream go
245+
# silent", and an idle stream gets reaped by the network path while the machine,
246+
# the connection and the sandbox all stay healthy. The outer trial is not
247+
# retried, so the whole optimization goes with it: on 2026-07-31 a cell died at
248+
# 71 minutes, 9m57s into one silent call, discarding a candidate that had
249+
# already scored 0.1224 on 49 validation cases.
250+
#
251+
# Configured on the harness rather than requested in the instruction, because
252+
# the instruction is advisory and this is not. Telling the optimizer to wait in
253+
# bounded steps leaves it free to ignore the advice, and free to reconstruct the
254+
# loop wrongly. Sits above WAIT_TIMEOUT_SECONDS so the evals CLI always returns
255+
# first on its own terms; the cap is the backstop for everything else the
256+
# optimizer runs.
257+
HARNESS_TOOL_TIMEOUT_SECONDS = 300
258+
259+
# How each harness spells "bound one tool call". Only knobs verified in the
260+
# harness's own source or docs are listed: a harness missing here keeps its own
261+
# default rather than being sent a variable it silently ignores.
262+
_TOOL_TIMEOUT_ENVIRONMENT: dict[str, tuple[str, ...]] = {
263+
# opencode reads a *default* only. `packages/opencode/src/tool/shell.ts`
264+
# resolves `flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000` and then
265+
# `params.timeout ?? defaultTimeoutMs` with no clamp, so a model that names
266+
# its own timeout still escapes the bound. It covers the case that actually
267+
# killed the run -- the instruction said to let the call block, so the model
268+
# never named one -- and it lowers the default quoted in the tool
269+
# description the model reads.
270+
"opencode": ("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS",),
271+
# claude-code takes both, and its MAX is a true ceiling: a per-call timeout
272+
# from inside the conversation cannot raise it.
273+
"claude-code": ("BASH_DEFAULT_TIMEOUT_MS", "BASH_MAX_TIMEOUT_MS"),
274+
}
275+
234276
# Harnesses that drive the model through litellm rather than a provider SDK.
235277
# litellm reads the base URL as <PROVIDER>_API_BASE; the SDKs read
236278
# <PROVIDER>_BASE_URL. vero sets the SDK names, so a litellm-based harness sees no
@@ -375,6 +417,28 @@ def _opencode_gateway_args(agent: str, model: str | None, task: Path) -> list[st
375417
return ["--ak", f"opencode_config={json.dumps(payload, separators=(',', ':'))}"]
376418

377419

420+
def _agent_tool_timeout_args(agent: str) -> list[str]:
421+
"""Bound one optimizer tool call, so no single call can idle the stream.
422+
423+
The mechanism the previous fix reached for was the instruction: it told the
424+
optimizer to wait in bounded steps instead of one open-ended block. That is
425+
the right shape and the wrong layer. A prompt cannot enforce anything, the
426+
recipe it shipped had to be corrected twice in review, and a model that
427+
reconstructs the loop from memory reintroduces the failure. The harnesses
428+
already expose the bound as a setting; set it.
429+
430+
Returns `--ae NAME=VALUE` pairs, which harbor merges into the scoped exec env
431+
wrapping the agent's run phase. Values are milliseconds, the unit every
432+
harness here uses.
433+
"""
434+
435+
milliseconds = int(HARNESS_TOOL_TIMEOUT_SECONDS * 1000)
436+
arguments: list[str] = []
437+
for name in _TOOL_TIMEOUT_ENVIRONMENT.get(agent, ()):
438+
arguments.extend(["--ae", f"{name}={milliseconds}"])
439+
return arguments
440+
441+
378442
def _outer_app_name_args(
379443
environment: str, config_name: str, extra: tuple[str, ...]
380444
) -> list[str]:
@@ -806,6 +870,10 @@ def run_command(config_path, agent, model, environment, params, env_file, extra)
806870
]
807871
if model is not None:
808872
command.extend(["-m", model])
873+
# Ahead of the build's own agent env, so a build can raise or drop the
874+
# cap by naming the same variable: harbor keeps the last value for a key,
875+
# and a vero default must not silently outrank an explicit choice.
876+
command.extend(_agent_tool_timeout_args(agent))
809877
# Forward the build's declared agent env to the optimizer agent's shell.
810878
# Harbor's `--ae KEY=VALUE` populates the agent's extra_env, which harbor
811879
# injects into the agent's setup/install exec (scoped_exec_env). Sorted
@@ -887,6 +955,43 @@ def inference_gateway_command(config_path, host, port):
887955
serve_inference_gateway(config_path=config_path, host=host, port=port)
888956

889957

958+
def _await_evaluation_job(job: dict, timeout: float = WAIT_TIMEOUT_SECONDS) -> dict:
959+
"""Wait out a started evaluation job, bounded, and return what to print.
960+
961+
A blocking `POST /eval` is one HTTP call that can take half an hour and
962+
prints nothing until it returns, which is precisely the silence that killed
963+
an optimization on 2026-07-31. The sidecar drives both entry points through
964+
the same tracked job (`Sidecar._execute_tracked_job`), so starting a job and
965+
polling it is the same evaluation, the same budget and the same
966+
`SidecarEvaluationResult` -- only interruptible.
967+
968+
Returns the evaluation result once the job completes, or the job record when
969+
the bound expires first, in which case the caller re-enters with
970+
`evals wait JOB_ID` and the evaluation keeps running in the sidecar
971+
regardless. A failed or cancelled job raises, so a bounded run still exits
972+
non-zero carrying the sidecar's own reason, exactly as the blocking call did.
973+
"""
974+
975+
job_id = job.get("job_id") if isinstance(job, dict) else None
976+
if not job_id:
977+
return job
978+
terminal = {"complete", "failed", "cancelled"}
979+
deadline = time.monotonic() + timeout
980+
while True:
981+
status = job.get("status")
982+
if status in terminal:
983+
break
984+
if time.monotonic() >= deadline:
985+
return _enrich_job(job)
986+
time.sleep(WAIT_POLL_INTERVAL_SECONDS)
987+
job = _request("GET", f"/eval/jobs/{job_id}")
988+
if status == "complete":
989+
return _request("GET", f"/eval/jobs/{job_id}/result")
990+
raise click.ClickException(
991+
f"evaluation job {job_id} {status}: {job.get('error') or 'no reason recorded'}"
992+
)
993+
994+
890995
@harbor.command("eval")
891996
@click.option(
892997
"--backend", "backend_id", required=True,
@@ -1018,15 +1123,12 @@ def evaluate_command(
10181123
limits=EvaluationLimits(**limit_values) if limit_values else None,
10191124
seed=seed,
10201125
)
1126+
payload = body.model_dump(mode="json")
1127+
if detach:
1128+
click.echo(json.dumps(_request("POST", "/eval/jobs", payload=payload), indent=2))
1129+
return
10211130
click.echo(
1022-
json.dumps(
1023-
_request(
1024-
"POST",
1025-
"/eval/jobs" if detach else "/eval",
1026-
payload=body.model_dump(mode="json"),
1027-
),
1028-
indent=2,
1029-
)
1131+
json.dumps(_await_evaluation_job(_request("POST", "/eval/jobs", payload=payload)), indent=2)
10301132
)
10311133

10321134

0 commit comments

Comments
 (0)