|
11 | 11 | import subprocess |
12 | 12 | import sys |
13 | 13 | import tempfile |
| 14 | +import time |
14 | 15 | import urllib.error |
15 | 16 | import urllib.request |
16 | 17 | from pathlib import Path |
17 | 18 |
|
18 | 19 | import click |
19 | 20 |
|
| 21 | +from vero.evals_cli import ( |
| 22 | + WAIT_POLL_INTERVAL_SECONDS, |
| 23 | + WAIT_TIMEOUT_SECONDS, |
| 24 | + _enrich_job, |
| 25 | +) |
20 | 26 | from vero.evaluation import ( |
21 | 27 | CaseIds, |
22 | 28 | CaseRange, |
@@ -231,6 +237,42 @@ def _load_env_file(path: Path) -> dict[str, str]: |
231 | 237 | # the gateway token cap are the intended limits. |
232 | 238 | OPENCODE_STEP_LIMIT = 1000 |
233 | 239 |
|
| 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 | + |
234 | 276 | # Harnesses that drive the model through litellm rather than a provider SDK. |
235 | 277 | # litellm reads the base URL as <PROVIDER>_API_BASE; the SDKs read |
236 | 278 | # <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 |
375 | 417 | return ["--ak", f"opencode_config={json.dumps(payload, separators=(',', ':'))}"] |
376 | 418 |
|
377 | 419 |
|
| 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 | + |
378 | 442 | def _outer_app_name_args( |
379 | 443 | environment: str, config_name: str, extra: tuple[str, ...] |
380 | 444 | ) -> list[str]: |
@@ -806,6 +870,10 @@ def run_command(config_path, agent, model, environment, params, env_file, extra) |
806 | 870 | ] |
807 | 871 | if model is not None: |
808 | 872 | 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)) |
809 | 877 | # Forward the build's declared agent env to the optimizer agent's shell. |
810 | 878 | # Harbor's `--ae KEY=VALUE` populates the agent's extra_env, which harbor |
811 | 879 | # injects into the agent's setup/install exec (scoped_exec_env). Sorted |
@@ -887,6 +955,43 @@ def inference_gateway_command(config_path, host, port): |
887 | 955 | serve_inference_gateway(config_path=config_path, host=host, port=port) |
888 | 956 |
|
889 | 957 |
|
| 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 | + |
890 | 995 | @harbor.command("eval") |
891 | 996 | @click.option( |
892 | 997 | "--backend", "backend_id", required=True, |
@@ -1018,15 +1123,12 @@ def evaluate_command( |
1018 | 1123 | limits=EvaluationLimits(**limit_values) if limit_values else None, |
1019 | 1124 | seed=seed, |
1020 | 1125 | ) |
| 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 |
1021 | 1130 | 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) |
1030 | 1132 | ) |
1031 | 1133 |
|
1032 | 1134 |
|
|
0 commit comments