2/3 Add the redesigned VeRO system (vero/) - #45
Conversation
Introduce the v0.5 codebase: vero/ (core + harbor eval sidecar, build compiler, runtime/observability), vero-tasks/, and runs/. Root README points to legacy/ for the original-paper code.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
…back #1) Addresses the 'baseline floor fails open' regression: - baseline_floor now defaults to false. The floor gates a ship on a validation comparison while the reward is on the (possibly differently-distributed) target, so it is opt-in. - Pin the fixed seed's score to avoid re-scoring it every run (reproducibility + one fewer eval): VerificationTarget.baseline_reward (per-target, post scale/offset) and VerificationSelection .baseline_selection_score (for the floor). Plumbed through the build config + compiler. - Fail safe when the floor is on, unpinned, and the seed re-score can't be measured: ship nothing (NoCandidateError -> shipped=false + infra error) instead of shipping the best candidate unverified (the fail-open bug). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#1) Add an admin-only way to generate the number to pin: CanonicalVerifier .measure_baseline(replicates) admin-scores the fixed seed N times on the selection partition and each target and returns per-key mean/stddev. Exposed as POST /score/baseline and 'vero harbor score-baseline --replicates N'. Reuses the existing admin scoring machinery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atten The benchmark-config tests read harness-engineering-bench/ (a separate branch in the stacked split), so skip them when it isn't checked out. Update the paths for the candidates/ -> top-level flatten (all benchmarks are now top-level siblings of gaia/). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
min_aggregate_cases defaulted to 1 — a vacuous floor that let an agent read held-out validation labels one case at a time via single-case aggregate evals. Default to 5 in both the sidecar policy and the build AgentAccessSpec so a build that omits it is safe rather than unfloored (shipped builds already set 5 explicitly). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-enabling harness isolation broke the eval: 'uv run' as the unprivileged harness got a fresh empty uv cache and couldn't resolve the candidate package from it (No module named 'gaia_agent'), with no network to download at eval time. Pre-warm /home/harness/.cache/uv from the build (root) cache in the sidecar image, matching the UV_CACHE_DIR the backend sets for the harness user. Also fixes a test regression from feedback #2: with the k-anonymity floor defaulting to 5, the instruction now reads 'must include at least 5 cases' rather than 'arbitrary subsets'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack #8) The mean-of-k recorded only score/n_attempts/n_scored, so an outage-diluted cell (dead attempts zero-filled) was indistinguishable from a genuinely clean low cell. Add n_dead_infra (dead-to-infrastructure attempts, classified via the existing error taxonomy) and n_clean (the rest), so the split is visible in the live metrics rather than only reconstructable from raw trial records. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- optimizer.run: validate self.max_proposals >= 0 before the proposal_limit guard, so the specific 'must be non-negative' error is reachable (was a dead branch that always tripped the generic message first). - compiler._safe_extract_tar: extractall(filter='data') to strip device files / setuid bits and neutralize unsafe links, matching extract_harbor_session_archive (also silences the py3.14 deprecation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cold-start walkthrough of the harbor path: the three-container trust boundary, a run end-to-end, the evaluation core, disclosure/selection, the five security mechanisms, and a suggested outside-in review order keyed to the source files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update harbor_requirement pin from harbor[modal]==0.18.0 to ==0.20.0 across the README and harbor tests. Remove the PYTHONPATH injection in the harness eval launch: it was a no-op (uv strips PYTHONPATH from the child env) and did not fix the "No module named <agent>" failure under the dropped uid, so it and its test assertion are gone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The candidate is checked out at <mktemp>/repository; `mktemp -d` makes that parent 0700 root. The eval launch chowned the repository (and the staging tree) to the harness user but not the mktemp parent, so once the process dropped to harness it could not traverse into its own workspace to resolve the editable candidate package's absolute path — every eval (baseline included) failed with "No module named <agent>" while harbor itself still loaded from the harness-owned cache. Grant the harness user traversal on that parent (chmod o+x) alongside the existing chowns. o+x suffices because run_as drops to the user's own uid+gid, so it is "other" relative to the root-owned parent. The dir holds only candidate code, so widening traversal leaks no trusted data. Reproduced and fixed against the real compiled sidecar image with a faithful user+group+extra_groups privilege drop: 0700 -> "No module named gaia_agent"; 0701 -> import resolves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a reachability probe: after chowning/granting traversal, run `test -r <project_path>` as the dropped user before launching harbor. Readability requires traversing every ancestor, so any provisioning gap (a missing chown, a non-traversable parent) is caught here with a clear message instead of resurfacing as a cryptic "No module named <agent>" several retries downstream — which is exactly how the checkout-parent bug hid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the harness provisioning commands and reachability probe into vero.harbor.isolation as a single source of truth, and have the eval launch use them, so the runtime and the test can't drift. Add tests/test_v05_harbor_isolation_container.py: inside a throwaway Linux container it loads the real sandbox.py + isolation.py standalone (stdlib-only, no install) and asserts, against the real uid/gid drop and the real provisioning commands, that (1) a checkout under a 0700 mktemp parent is unreachable to the dropped user when only the leaf is chowned (the regression sentinel for the shipped bug), (2) it becomes reachable after harness_grant_commands and the probe agrees, and (3) trusted root-only state stays unreadable to the dropped user. Skips when no docker daemon is available. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Agents don't know what "vero" is; every name they meet should say what it holds. The workspace context directory is now .evals/ with results/ (was evaluations/), tasks/ (was cases/), and plan.json (was evaluations.json). Host-side paths (~/.vero, ./.vero/session) are operator-facing and unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One well-named entry point over the .evals/ context — the new avatar of the legacy VeroAgent's three structured tools. Runner subcommands (run/status/ result/submit) delegate to the sidecar client lazily; the viewers (list/show/ cases/trace/diff/plan/tasks) are unprivileged stdlib readers of the disclosure- projected tree, with bounded, paginated, metadata-first output. The compiler bakes skills/evals/SKILL.md into the optimizer workspace by default (include_evals_skill), and the instruction template now teaches `evals`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
min_aggregate_cases moves onto the core EvaluationAccessPolicy (omitted resolves to 5 under aggregate disclosure, 1 otherwise) and the engine refuses agent-chosen aggregate subsets below the floor after cost resolution, so the protection holds on every path — programmatic API and local runs included — not just compiled harbor deployments. The sidecar keeps its earlier check as defense-in-depth and now passes the floor through its explicit authorization. AgentAccessSpec.to_access_policy() is the single typed translation from build spec to runtime policy; the compiler emits it nested in serve.json and SidecarEvaluationPolicy holds it as `access`, replacing the hand-rolled flat keys (disclosure/agent_evaluable/min_aggregate_cases/expose_case_resources) that had to be kept in sync across three representations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
71860e0 to
3fdcf90
Compare
The sidecar's context writer predates the .evals rename and had its own literals: exposed task resources landed in .evals/cases/ (README, skill, and instruction docs all say tasks/) and plan.json was never written in the harbor topology, breaking the documented 'evals plan' first step. Caught live in the first gaia run of the new stack.
AgentContextDirectory now owns write_case_resources and write_evaluation_plan; both WorkspaceContextManager and the sidecar feed it ContextPlanEntry rows (policy, evaluation set, resolved budget), so the tree layout can no longer drift between topologies. Top-level names (results/, tasks/, candidates/, plan.json) are module constants. Behavior note: case-resource exposure now keys on expose_case_resources alone (which model validation already ties to agent_visible); the sidecar previously also required agent_can_evaluate.
…ecar The gateway now keeps a size-rotated JSONL log of every request it proxies or denies (scope, attribution, model, status, latency, tokens, and head+tail-truncated request/response bodies; SSE responses are captured from the chunks already tapped for usage). On by default in compiled tasks at /state/inference/requests with a 16KB per-body cap (inference_gateway.log_requests / request_log_body_bytes). The trusted sidecar - the only credential holder - mirrors the gateway state into the existing W&B run: a poller logs per-scope usage series (live optimizer producer-scope burn included) every 30s and ships rotated log files as artifacts, sharing the sink step counter. At finalize the gateway ledger and request log are copied into the session's artifacts so /session/export preserves every request-response, W&B or not.
The loader resolves an existing local task_source to an absolute path, while the committed partition manifest records it relative to itself; compare resolved locations before rejecting. Registry sources still compare literally.
|
Really nice split. Breaking #41 into relocate-legacy / add-system / add-benchmarks makes it reviewable again (Greptile passes each now), keeps the old stack intact under Two things I'd flagged on the earlier v0.5 snapshot are already handled here, thanks for that:
One integrity concern I'd want resolved before this is relied on for competitive selection: Infra-classified cases are excluded from the aggregate rather than scored at If a candidate-attributable failure can reach that path (a candidate that raises Could you confirm whether the routing prevents a candidate-raised exception from being classified transient-infra? If it doesn't, I think the fix is:
One more (high), in Smaller, non-blocking follow-ups:
Happy to pair on the aggregate/retry piece if that's useful. |
The proxy route was declared in inference.py and then independently rebuilt by string concatenation in four places that knew nothing about it: launch.json, the compose file's OPENAI_BASE_URL, the seed script, and the trusted backend. Five copies of three conventions — the scope segment, the attribution segment, and /v1. Change the route and four sites keep producing URLs the gateway 404s or 403s on, inside a container, at run time. Put the route shape in the layout as one string and derive everything from it: scope_route for the gateway to register, scope_url for callers using this layout's gateway, and scope_path for the trusted backend, which reads its base URL from the deployment config rather than assuming ours. The optimizer's attribution segment was duplicated three times and is now a field too. The templates receive a precomputed producer_base_url instead of concatenating. The test asserts on the compiled artifacts — compose, launch.json, and the seed script — rather than on the constant, so it still holds if a template goes back to building the URL by hand. Verified by doing exactly that with a wrong shape: it fails. Also folded in, both hygiene rather than risk: VERO_EVAL_URL was a producer/consumer pair split between the compose template and the CLI that reads it, and the gateway's upstream env var names were the last constants still local to the compiler. The OPENAI_* scrub set is now a named constant with the invariant written down. Worth being precise about what that invariant is, since it looks alarming: the blanking loop is default-deny, so a new credential added to `secrets` is blanked for the optimizer automatically. The exclusion list is the explicit-allow exception for the two names the template sets deliberately, and forgetting one emits a duplicate compose key rather than leaking anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
task.toml.j2 hardcoded build_timeout_sec = 1800 while the timeout_sec two lines above it was already parametrized, so one timeout in the same generated file was tunable and the other was not. The outer build is not trivial — for a source build it copies the vero tree, installs it plus harbor into the sidecar, syncs the baseline, and pre-warms a uv cache — and there was no way to raise the limit without editing the template. Typed as an int to match the neighbouring verifier timeout, so the default still renders as 1800 and compiled output is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
harbor/ held fourteen flat modules that are not peers: they run in three different processes on either side of a trust boundary, and nothing in the layout said so. Most of them were not Harbor code at all. The sidecar stack -- sidecar, app, serve, verifier, transport, auth, isolation, session -- imports only itself, never backend, build, deployment or cli, and inference.py imports only layout. deployment.py is the single module binding the two, which is exactly its job. The nested-backend work sharpened the point: a compiled task can now use a command backend and no HarborBackend at all, while every module implementing it still lived under harbor/. Split those out to vero/sidecar/ and vero/gateway/, hoist layout.py to the top level so gateway/ needs no backwards edge, and leave harbor/ holding what is genuinely Harbor: build/, backend, generation, deployment, cli. Regroup evaluation/ along its own dependency layers into backends/, scoring/ and store/. A reviewer can now read the trust architecture off the tree. Package docstrings carry what the tree cannot: that nothing in sidecar/ is Harbor-specific, and that its co-location is load-bearing -- HTTP carries the API, but candidate code, the .evals context and the admin token all move through volumes shared with main, so it is not deployable away from the task it serves. That is also why it keeps the name. evaluation/ is an internal regroup: every name is still re-exported from vero.evaluation, so only deep importers moved. harbor/ -> sidecar//gateway/ is a real move, so those exports were relocated rather than aliased back; leaving compatibility shims in harbor/__init__ would preserve the false impression this commit exists to remove. Pure moves and import rewrites. No logic changes: 399 passed / 9 skipped before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
circle_factory built SidecarEvaluationPolicy with flat disclosure and expose_case_resources keyword arguments, but those moved under a nested access=EvaluationAccessPolicy some time ago. The sidecar container therefore died at startup with three validation errors before serving anything. Predates the package restructure -- the same call fails identically on pr2-add-vero -- and no test covers it, because nothing exercises the example factories. Found by actually booting the container: it now starts and serves /health and /status, reporting full disclosure on development, aggregate on validation, and the budget ledger. ale_factory.py in harness-engineering-bench has the same stale construction and is broken the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The example only had the hand-written path: circle_factory.py wiring a CommandBackend directly, with the agent handed raw OPENAI_* credentials. That is the same shape as ale-bench, and it means the one worked example of a non-agent target is also an example of an unmetered optimizer. The nested-backend refactor makes the compiled path available to such a target, so add a build.yaml beside the hand-written task: same packing.py, same evaluate.py, but `evaluation_backend: command` and an inference gateway. The optimizer now reaches its model only through a scoped, allow-listed, metered token; the raw upstream stays in the gateway container and is scrubbed to "" in main. Two wirings of one problem, directly comparable. Verified end to end with codex/gpt-5.4: reward 2.6324 against a 0.9598 baseline and a ~2.635 known optimum, 0 exceptions in 20m46s. The gateway metered every optimizer call -- 57 requests, 3.6M tokens, no denials -- and the evaluation scope stayed at zero, since a command harness makes no model calls of its own. passthrough_environment is set and commented because it is easy to get wrong: CommandBackend deliberately runs the harness with PATH=os.defpath, so python (in /usr/local/bin on the base image) is not found unless PATH is forwarded. The failure surfaces only when the harness first runs, after the agent has already spent its budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_v05_harbor_build.py carried two tests that read build YAMLs out of harness-engineering-bench, guarded by a skipif on that directory existing. It does not exist on this branch, so they always skipped here and only ran one branch up -- where they had been failing for a while against stale expectations nobody saw. A test that can silently skip on the branch that owns it is a test that rots. Drop them and the BENCHMARK_ROOT/_requires_benchmarks harness; they move to pr3-harness-bench, where the benchmarks are always present and the guard can go away entirely. Suite is unchanged at 399 passed, with skips down from 9 to 5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
report.py was the largest module in the codebase at 43K, but 57% of that was one raw string: a complete 25K HTML document with its own CSS and JavaScript, pasted into _REPORT_HTML. Inside a string literal it gets no highlighting, no formatter, no linter, and no diff worth reading -- and it made the module look like a 43K Python problem when the Python is 19K. Extract it verbatim to templates/report.html, loaded through importlib.resources the way the packaged evals skill already is. Non-Python artifacts get their own directory rather than sitting beside the modules, matching harbor/build/templates and skills/evals. Substitution is unchanged: still one replace of __VERO_REPORT_DATA__. The extracted text is byte-identical to the old literal, so generated reports are unchanged by construction rather than by inspection. The wheel ships the file alongside the existing Jinja templates and skills/evals/SKILL.md, checked by building one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The main container blanks every declared secret except the two the compose template assigns itself -- the optimizer's scoped token and its gateway URL. GATEWAY_ROUTED_CREDENTIALS named that exemption in Python while the template spelled the same names out again, five copies of OPENAI_API_KEY and four of OPENAI_BASE_URL between them. The duplication is one-directional and quiet. Blanking is default-deny, so a newly declared secret is scrubbed automatically and adding one is safe. The failure is the other way: a name added to the exemption but not assigned by the template would be neither blanked nor set, leaving whatever the host exported in the optimizer's container -- the one outcome the scrub exists to prevent. Move both names onto LAYOUT beside the other env-name fields, derive the exemption from LAYOUT.routed_credential_envs, and have the compose file and codex's provider config reference them. Nine literals become one definition. Add a test that renders a compiled task and asserts every exempt name is positively set with a scoped value, and that the raw upstream is blanked in the same container. Verified it fails as intended by adding a third name to the exemption: "STRAY_KEY is skipped by the scrub but never set". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
config.py held three unrelated things in 953 lines: the leaf models a
build.yaml composes, HarborBuildConfig itself, and the ${NAME:-default}
substitution language plus the YAML reader. Split into specs.py (313),
config.py (635), and loader.py (155). Only two places imported deeply;
build/__init__.py re-exports the same eleven names.
validate_references was 136 lines running six unrelated checks, including
45 lines of task_manifest JSON parsing. It is now six named validators in
the original order, so error precedence is unchanged and a rejected build
points at the rule it broke. The manifest check reads and parses the file
once instead of twice.
The fields are grouped into six private base classes, each with its own
docstring and the validators that concern only itself. Grouping by
inheritance rather than nesting keeps the wire format flat, so every
checked-in build.yaml parses identically -- verified by loading all five
benchmark configs and by comparing the old and new field sets (62 each,
same annotations and defaults).
That retires a duplicate list: the Harbor-only fields were enumerated once
as declarations and once in a hand-maintained frozenset, and
_HARBOR_ONLY_FIELDS is now derived from _HarborEvaluationFields. The two
had already drifted -- reward_key is Harbor-only in fact, since a command
harness reports its own reward, but was missing from the list, so setting
it on a command build passed validation and did nothing. It is now in the
group and rejected.
Two tests. One sweeps the derived set, so a field added to the group is
covered the day it lands; the other asserts the groups stay flat on the
wire and that every field belongs to one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The package restructure moved the sidecar stack, the gateway, and the evaluation store without updating this doc, leaving fourteen paths that resolve to nothing: harbor/sidecar.py, harbor/verifier.py, harbor/session.py, harbor/app.py, harbor/inference.py, and evaluation/budget.py. Since the doc's whole purpose is a suggested reading order, a dead path is worse here than in a code comment. Also names the four modules the build pipeline now passes through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each of reserve's charge write, reserve's rollback write, and refund's write hand-rolled the same shield-and-drain loop, and each got the same detail wrong at least once: a write failure replacing the CancelledError of the run being torn down. Two were patched individually in review; the third, reserve's charge write, still called write.result() unguarded, so an OSError there discarded the cancellation and the caller saw the wrong exception. _drain_write now owns the loop and returns both outcomes rather than raising one, which is what makes the invariant checkable: no call site can propagate a write error in place of a cancellation, because it has to decide about both. Draining to completion is preserved -- an abandoned write could overwrite a later one once the ledger lock is released. Behaviour is unchanged except in the case that was broken. Reserve still rolls its charge back on cancellation, and now skips the rollback when the charge never landed. Refund still commits, since returning budget is safe even for a run that is going away; that asymmetry is now stated rather than implied. The new test fails on the old code with OSError: disk gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six findings from the reviews on #45/#46 that survived checking against the current tree. Each was verified against the live litellm endpoint the gateway proxies to, not inferred from a commit message. gaia moves to gpt-5.4-mini. It is the one multimodal benchmark and deepseek-v4-flash is text-only: 5 of the 66 held-out tasks send images and get 400 "This model does not support image inputs", which caps achievable reward near 0.92 and reads as ordinary agent failure. gpt-5.4-mini returns 200 for every request shape the gaia agent sends -- image input, hosted web_search, reasoning.effort, parallel_tool_calls -- confirmed through the SDK path the agent actually uses. The name is unprefixed on purpose: every agent sends model_name.removeprefix("openai/"), so an openai/-prefixed name would be allow-listed in one form and requested in another and the gateway would deny it. The gaia held-out baseline now needs re-measuring. tau3 executes every tool call the model returns instead of only the first. deepseek-v4-flash does return two in a turn, and the usual guard is not available: litellm rejects parallel_tool_calls for fireworks_ai with UnsupportedParamsError, so the reviewer's suggested one-liner would have 400'd every tau3 call. Dropping calls[1:] silently skipped actions -- for a customer-service agent, a verified identity with no message sent. officeqa and swe-atlas-qna run their tool dispatch inside the try rather than an else, and catch KeyError/TypeError/ValueError alongside JSONDecodeError, matching browsecomp-plus. Neither tool schema is strict, so the model can return valid JSON missing a required key; that ended the trial instead of feeding the model an error. Widening the except alone would have caught nothing, since the lookups were in the else. gaia's client gets max_retries=8, the only one of the five without it. A within-trial transient failure scores at the failure value for competitive evaluations, so an unretried 429 cost a candidate a 0.0. tau3 quotes the MCP session id before interpolating it into a shell command, for the same reason the payload beside it is base64-encoded. per_trial_tokens buckets unattributed records under a name rather than None, which crashed sorted() as soon as one appeared beside an attributed record. Not actioned: the browsecomp-plus grader was reported as vulnerable to format-string injection from curly braces in the agent's response. str.format does not rescan substituted values, so there is nothing to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_execute_record shields _record and refund in both exception handlers but left the success-path _record bare, so a cancellation arriving after the evaluation finished could abandon it part-way -- the budget charged for a run the engine never finished publishing. The mechanism is narrower than "the database write is lost": that write is an asyncio.to_thread, and a cancelled await does not stop the thread, so the file lands either way. What is actually skipped is everything after it -- the listener loop, where the trusted side mirrors an evaluation to W&B and the session archive -- plus the whole of _record when the cancellation arrives before its lock is acquired. Shield does not wait for the shielded coroutine, only protect it: the caller still unwinds at once while _record completes in the background. That is already the contract of the four shielded calls beside it, so this makes the path consistent rather than introducing a new guarantee. The test cancels during a listener, the one genuinely interruptible step, and times out on the unshielded code. Reported by greptile on #45. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six findings from the reviews on #45/#46 that survived checking against the current tree. Each was verified against the live litellm endpoint the gateway proxies to, not inferred from a commit message. gaia moves to gpt-5.4-mini. It is the one multimodal benchmark and deepseek-v4-flash is text-only: 5 of the 66 held-out tasks send images and get 400 "This model does not support image inputs", which caps achievable reward near 0.92 and reads as ordinary agent failure. gpt-5.4-mini returns 200 for every request shape the gaia agent sends -- image input, hosted web_search, reasoning.effort, parallel_tool_calls -- confirmed through the SDK path the agent actually uses. The name is unprefixed on purpose: every agent sends model_name.removeprefix("openai/"), so an openai/-prefixed name would be allow-listed in one form and requested in another and the gateway would deny it. The gaia held-out baseline now needs re-measuring. tau3 executes every tool call the model returns instead of only the first. deepseek-v4-flash does return two in a turn, and the usual guard is not available: litellm rejects parallel_tool_calls for fireworks_ai with UnsupportedParamsError, so the reviewer's suggested one-liner would have 400'd every tau3 call. Dropping calls[1:] silently skipped actions -- for a customer-service agent, a verified identity with no message sent. officeqa and swe-atlas-qna run their tool dispatch inside the try rather than an else, and catch KeyError/TypeError/ValueError alongside JSONDecodeError, matching browsecomp-plus. Neither tool schema is strict, so the model can return valid JSON missing a required key; that ended the trial instead of feeding the model an error. Widening the except alone would have caught nothing, since the lookups were in the else. gaia's client gets max_retries=8, the only one of the five without it. A within-trial transient failure scores at the failure value for competitive evaluations, so an unretried 429 cost a candidate a 0.0. tau3 quotes the MCP session id before interpolating it into a shell command, for the same reason the payload beside it is base64-encoded. per_trial_tokens buckets unattributed records under a name rather than None, which crashed sorted() as soon as one appeared beside an attributed record. Not actioned: the browsecomp-plus grader was reported as vulnerable to format-string injection from curly braces in the agent's response. str.format does not rescan substituted values, so there is nothing to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A held-out target can now set n_attempts/aggregate_attempts to score each case several times and combine them (e.g. 3x mean to average a noisy final eval), while search and selection keep the global attempts. Backends are per-partition, so the override is injected into the target partition's backend at compile time; a config validator rejects overriding an agent-evaluable partition (shared with search) or a command backend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uction The instruction's insights paragraph was gated on overlay_present, which is true whenever the always-baked evals skill is added -- so every optimizer was told to dispatch an insights-generator subagent and read skills/insights that no benchmark ships. The insights skill is retired; drop the paragraph. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Six findings from the reviews on #45/#46 that survived checking against the current tree. Each was verified against the live litellm endpoint the gateway proxies to, not inferred from a commit message. gaia moves to gpt-5.4-mini. It is the one multimodal benchmark and deepseek-v4-flash is text-only: 5 of the 66 held-out tasks send images and get 400 "This model does not support image inputs", which caps achievable reward near 0.92 and reads as ordinary agent failure. gpt-5.4-mini returns 200 for every request shape the gaia agent sends -- image input, hosted web_search, reasoning.effort, parallel_tool_calls -- confirmed through the SDK path the agent actually uses. The name is unprefixed on purpose: every agent sends model_name.removeprefix("openai/"), so an openai/-prefixed name would be allow-listed in one form and requested in another and the gateway would deny it. The gaia held-out baseline now needs re-measuring. tau3 executes every tool call the model returns instead of only the first. deepseek-v4-flash does return two in a turn, and the usual guard is not available: litellm rejects parallel_tool_calls for fireworks_ai with UnsupportedParamsError, so the reviewer's suggested one-liner would have 400'd every tau3 call. Dropping calls[1:] silently skipped actions -- for a customer-service agent, a verified identity with no message sent. officeqa and swe-atlas-qna run their tool dispatch inside the try rather than an else, and catch KeyError/TypeError/ValueError alongside JSONDecodeError, matching browsecomp-plus. Neither tool schema is strict, so the model can return valid JSON missing a required key; that ended the trial instead of feeding the model an error. Widening the except alone would have caught nothing, since the lookups were in the else. gaia's client gets max_retries=8, the only one of the five without it. A within-trial transient failure scores at the failure value for competitive evaluations, so an unretried 429 cost a candidate a 0.0. tau3 quotes the MCP session id before interpolating it into a shell command, for the same reason the payload beside it is base64-encoded. per_trial_tokens buckets unattributed records under a name rather than None, which crashed sorted() as soon as one appeared beside an attributed record. Not actioned: the browsecomp-plus grader was reported as vulnerable to format-string injection from curly braces in the agent's response. str.format does not rescan substituted values, so there is nothing to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The optimizer interacts with VeRO through the `evals` CLI; treat it as a tool. Three fixes so we stop handicapping it: - Both eval modes with trade-offs. `evals run` blocks and returns the result by default (one call, stay in-loop); --detach is only for running several evals at once, then poll `evals status`. Every doc previously pushed --detach, which against Claude Code's 10-min Bash cap trained the detach/poll/end-turn pattern that ended a headless run after one eval. Updated the instruction template, the evals skill, and the CLI group help. - Power-flags made discoverable. `evals run` options were bare; add help text so `evals run --help` self-documents subset iteration (--start/--stop/--case-id), replication (--seed), and the per-case/whole-eval wall overrides. - Submit semantics. `evals submit` now explains it nominates your best candidate to ship, that you should beat the baseline first, and that not submitting falls back to auto-best then last-commit. Also state the per-case wall limit as a rule (a slower candidate can be stopped and score the failure value) so the optimizer treats latency as a constraint, worded to stay out of the budget-blind redaction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
C1: `evals status JOB` now reports client-derived `elapsed_seconds` and, for a subset run, `requested_cases`, so a polling agent sees progress instead of a bare `running`. E: `evals wait JOB` blocks until a detached job finishes and prints its result -- the blocking companion to `evals run --detach`, so the agent never hand-rolls a poll loop (the pattern that hit the 10-min Bash cap and Exit 143). Optional --timeout returns the still-running status cleanly (exit 0), so it is safe to re-call. Instruction + evals skill updated to point at it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tokens were the odd metric out — a per-evaluation sum while latency already had per-case statistics. Token and latency distributions are unbounded and heavy-tailed, so a mean alone hides the shape: give both a mean, a median, and a max over cases. The agent-reported token counts are recorded per case and get the full treatment; the trusted gateway figure is metered per evaluation, so it contributes its sum plus a derived mean_case_inference_* (median and max need post-hoc per-case attribution). Accuracy, being bounded, keeps mean and stddev. Budget-blind redaction now matches these markers anywhere in a metric name, not just as a prefix: mean_case_inference_input_tokens is the same budget signal as inference_input_tokens and must not reach the agent. Latency carries no marker and stays visible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Six findings from the reviews on #45/#46 that survived checking against the current tree. Each was verified against the live litellm endpoint the gateway proxies to, not inferred from a commit message. gaia moves to gpt-5.4-mini. It is the one multimodal benchmark and deepseek-v4-flash is text-only: 5 of the 66 held-out tasks send images and get 400 "This model does not support image inputs", which caps achievable reward near 0.92 and reads as ordinary agent failure. gpt-5.4-mini returns 200 for every request shape the gaia agent sends -- image input, hosted web_search, reasoning.effort, parallel_tool_calls -- confirmed through the SDK path the agent actually uses. The name is unprefixed on purpose: every agent sends model_name.removeprefix("openai/"), so an openai/-prefixed name would be allow-listed in one form and requested in another and the gateway would deny it. The gaia held-out baseline now needs re-measuring. tau3 executes every tool call the model returns instead of only the first. deepseek-v4-flash does return two in a turn, and the usual guard is not available: litellm rejects parallel_tool_calls for fireworks_ai with UnsupportedParamsError, so the reviewer's suggested one-liner would have 400'd every tau3 call. Dropping calls[1:] silently skipped actions -- for a customer-service agent, a verified identity with no message sent. officeqa and swe-atlas-qna run their tool dispatch inside the try rather than an else, and catch KeyError/TypeError/ValueError alongside JSONDecodeError, matching browsecomp-plus. Neither tool schema is strict, so the model can return valid JSON missing a required key; that ended the trial instead of feeding the model an error. Widening the except alone would have caught nothing, since the lookups were in the else. gaia's client gets max_retries=8, the only one of the five without it. A within-trial transient failure scores at the failure value for competitive evaluations, so an unretried 429 cost a candidate a 0.0. tau3 quotes the MCP session id before interpolating it into a shell command, for the same reason the payload beside it is base64-encoded. per_trial_tokens buckets unattributed records under a name rather than None, which crashed sorted() as soon as one appeared beside an attributed record. Not actioned: the browsecomp-plus grader was reported as vulnerable to format-string injection from curly braces in the agent's response. str.format does not rescan substituted values, so there is nothing to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…jected A live optimizer followed the instruction's advice to "pass --seed N to reproduce a noisy comparison exactly" and got back `400: invalid evaluation request`. Two separate defects met there. HarborBackend.validate_request rejects request.seed outright, and every Harbor-scored build renders that advice, so the flag could never work where it was recommended. Gate it on a new seed_supported context flag: a command backend does its own sampling and keeps the advice; a Harbor build is told to replicate by re-running the identical selection, and that --seed is refused — naming the flag so the rejection is not a surprise. The rejection message also never reached the agent. Both the blocking handler and the detached job path collapsed EvaluationRequestError to a bare "invalid evaluation request", discarding a message that named exactly which backend capability refused the request. Pass it through on both paths. This is the same reasoning that already appends the exception type to unmapped failures. Denials stay opaque: their message is policy, not capability, and test_http_app_redacts_access_denial_details still pins that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An optimizer sizing a subset had no way to learn how many cases a partition
holds. Observed live: it asked for `--start 20 --stop 70` of a 49-case
development partition, and lost two evaluations to the rejection before
backing off to a range it had already proven safe.
write_evaluation_plan now asks each backend to cost its own base selection and
records the count, which `evals plan` surfaces as a `cases` column. The call is
advisory: a backend that cannot cost itself reports no count rather than
failing the plan for every partition.
The rejection itself was already well worded ("case range stops at 70, but the
Harbor evaluation set contains 49 cases") and was being discarded by the
opaque request-error mapping fixed in the previous commit. Together the agent
now both sees the bound up front and is told it on the way out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| except asyncio.CancelledError as error: | ||
| message = "evaluation was cancelled" | ||
| await asyncio.shield( | ||
| self._persist_failure( | ||
| store=store, | ||
| evaluation_id=evaluation_id, | ||
| backend_id=backend_id, | ||
| backend=backend, | ||
| request=request, | ||
| objective_spec=objective_spec, | ||
| principal=principal, | ||
| created_at=created_at, | ||
| code="evaluation_cancelled", | ||
| message=message, | ||
| status=EvaluationStatus.CANCELLED, | ||
| ) | ||
| ) | ||
| raise EvaluationCancelledError(evaluation_id, message) from error |
There was a problem hiding this comment.
Double-cancellation race leaks budget reservation
When asyncio.timeout fires it internally cancels the task, and the resulting CancelledError is correctly caught at line 191. The handler then calls await asyncio.shield(self._persist_failure(...)). If quiesce_agent_evaluations delivers a second task.cancel() while that shielded await is in progress, Python raises a new CancelledError from inside the except asyncio.CancelledError: block before raise EvaluationCancelledError(...) is reached. The engine only catches EvaluationCancelledError and EvaluationExecutionError; a raw CancelledError bypasses both handlers, so neither _record nor budget_ledger.refund is called — the reservation is permanently leaked.
Prompt To Fix With AI
This is a comment left during a code review.
Path: vero/src/vero/evaluation/evaluator.py
Line: 191-208
Comment:
**Double-cancellation race leaks budget reservation**
When `asyncio.timeout` fires it internally cancels the task, and the resulting `CancelledError` is correctly caught at line 191. The handler then calls `await asyncio.shield(self._persist_failure(...))`. If `quiesce_agent_evaluations` delivers a second `task.cancel()` while that shielded await is in progress, Python raises a new `CancelledError` *from inside* the `except asyncio.CancelledError:` block before `raise EvaluationCancelledError(...)` is reached. The engine only catches `EvaluationCancelledError` and `EvaluationExecutionError`; a raw `CancelledError` bypasses both handlers, so neither `_record` nor `budget_ledger.refund` is called — the reservation is permanently leaked.
How can I resolve this? If you propose a fix, please make it concise.The env-var mitigation is not sufficient. Harbor forces ENABLE_BACKGROUND_TASKS=1 and the build's agent_env overrides it back to 0 (env.update(self._resolved_env_vars) runs after the forced defaults, and the raised BASH_MAX_TIMEOUT_MS demonstrably took effect — one call blocked 10.8 minutes, impossible under the 600s default). The variable gates *automatic* backgrounding; it does not remove the Bash tool's run_in_background parameter, which the model can still choose. Observed live: the optimizer reached the validation stage, detached two jobs, then backgrounded both `evals wait` calls, polled twice, said it would wait for a notification, and ended its turn. The headless run exited there, killing the waits. The previous wording only warned against ending a turn on a detached job; backgrounding the wait itself reads as actively waiting, so it did not land. Name the actual failure: run every evals call in the foreground, and say that a background task, a scheduled wake-up, or a promise to report back later ends the run outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| except TimeoutError as error: | ||
| message = f"evaluation exceeded {request.limits.timeout_seconds} seconds" | ||
| await self._persist_failure( | ||
| store=store, | ||
| evaluation_id=evaluation_id, | ||
| backend_id=backend_id, | ||
| backend=backend, | ||
| request=request, | ||
| objective_spec=objective_spec, | ||
| principal=principal, | ||
| created_at=created_at, | ||
| code="evaluation_timeout", | ||
| message=message, | ||
| ) | ||
| raise EvaluationExecutionError(evaluation_id, message) from error | ||
| except Exception as error: | ||
| message = str(error) or type(error).__name__ | ||
| await self._persist_failure( | ||
| store=store, | ||
| evaluation_id=evaluation_id, | ||
| backend_id=backend_id, | ||
| backend=backend, | ||
| request=request, | ||
| objective_spec=objective_spec, | ||
| principal=principal, | ||
| created_at=created_at, | ||
| code="backend_error", | ||
| message=message, | ||
| ) | ||
| raise EvaluationExecutionError(evaluation_id, message) from error |
There was a problem hiding this comment.
_persist_failure unshielded in TimeoutError and Exception handlers
The CancelledError handler above (line 193) correctly wraps _persist_failure in asyncio.shield. The TimeoutError and Exception handlers do not. When asyncio.timeout fires, it absorbs the timeout's internal cancellation and re-raises as TimeoutError — but if quiesce_agent_evaluations has also called task.cancel(), that external cancellation is still pending. The first await inside the unshielded _persist_failure (typically await store.save(...) → asyncio.to_thread) will deliver that cancellation. The CancelledError propagates past the raise EvaluationExecutionError(...) line, exits Evaluator.evaluate as a raw CancelledError, bypasses both EvaluationCancelledError and EvaluationExecutionError handlers in the engine, and leaves the budget reservation permanently charged. The same race applies to the except Exception: path.
Prompt To Fix With AI
This is a comment left during a code review.
Path: vero/src/vero/evaluation/evaluator.py
Line: 209-238
Comment:
**`_persist_failure` unshielded in `TimeoutError` and `Exception` handlers**
The `CancelledError` handler above (line 193) correctly wraps `_persist_failure` in `asyncio.shield`. The `TimeoutError` and `Exception` handlers do not. When `asyncio.timeout` fires, it absorbs the timeout's internal cancellation and re-raises as `TimeoutError` — but if `quiesce_agent_evaluations` has also called `task.cancel()`, that external cancellation is still pending. The first `await` inside the unshielded `_persist_failure` (typically `await store.save(...)` → `asyncio.to_thread`) will deliver that cancellation. The `CancelledError` propagates past the `raise EvaluationExecutionError(...)` line, exits `Evaluator.evaluate` as a raw `CancelledError`, bypasses both `EvaluationCancelledError` and `EvaluationExecutionError` handlers in the engine, and leaves the budget reservation permanently charged. The same race applies to the `except Exception:` path.
How can I resolve this? If you propose a fix, please make it concise.Six findings from the reviews on #45/#46 that survived checking against the current tree. Each was verified against the live litellm endpoint the gateway proxies to, not inferred from a commit message. gaia moves to gpt-5.4-mini. It is the one multimodal benchmark and deepseek-v4-flash is text-only: 5 of the 66 held-out tasks send images and get 400 "This model does not support image inputs", which caps achievable reward near 0.92 and reads as ordinary agent failure. gpt-5.4-mini returns 200 for every request shape the gaia agent sends -- image input, hosted web_search, reasoning.effort, parallel_tool_calls -- confirmed through the SDK path the agent actually uses. The name is unprefixed on purpose: every agent sends model_name.removeprefix("openai/"), so an openai/-prefixed name would be allow-listed in one form and requested in another and the gateway would deny it. The gaia held-out baseline now needs re-measuring. tau3 executes every tool call the model returns instead of only the first. deepseek-v4-flash does return two in a turn, and the usual guard is not available: litellm rejects parallel_tool_calls for fireworks_ai with UnsupportedParamsError, so the reviewer's suggested one-liner would have 400'd every tau3 call. Dropping calls[1:] silently skipped actions -- for a customer-service agent, a verified identity with no message sent. officeqa and swe-atlas-qna run their tool dispatch inside the try rather than an else, and catch KeyError/TypeError/ValueError alongside JSONDecodeError, matching browsecomp-plus. Neither tool schema is strict, so the model can return valid JSON missing a required key; that ended the trial instead of feeding the model an error. Widening the except alone would have caught nothing, since the lookups were in the else. gaia's client gets max_retries=8, the only one of the five without it. A within-trial transient failure scores at the failure value for competitive evaluations, so an unretried 429 cost a candidate a 0.0. tau3 quotes the MCP session id before interpolating it into a shell command, for the same reason the payload beside it is base64-encoded. per_trial_tokens buckets unattributed records under a name rather than None, which crashed sorted() as soon as one appeared beside an attributed record. Not actioned: the browsecomp-plus grader was reported as vulnerable to format-string injection from curly braces in the agent's response. str.format does not rescan substituted values, so there is nothing to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sposable Observed live: run #4's optimizer piped `evals run` through `tail -60`, and the payload is long enough that the score fell off the top -- it kept the per-case wall metrics and discarded its own objective value. Truncating a large JSON blob is what any agent will naturally do, so the fix is not to reformat the output but to say plainly that nothing is lost by truncating it. The full record is written under .evals/results/ before `evals run` returns and stays there for the whole run, so `evals list` / `show` / `cases` / `diff` can recover any of it. The instruction previously mentioned .evals/results/ only in the exposed-partitions section, framed as a place to inspect failed trajectories -- not as the durable home of every result. Say it in the workflow step where the truncation actually happens, and name the failure it prevents: re-running an evaluation to recover a number that was already on disk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| if key in item: | ||
| if isinstance(item[key], list): | ||
| item[key] = item[key][0] if item[key] else "" | ||
| return item | ||
|
|
There was a problem hiding this comment.
Multi-block output silently dropped to first item
When an item has more than one entry in its output or content list — for example, a Responses API message that contains both a text block and one or more parallel tool-call blocks — process_item replaces the entire list with just the first element. Every tool call after the first is silently discarded. When the result is later passed as conversation history to another model invocation, the model will not see that those tool calls were ever made, which will cause the agent to mis-attribute or duplicate tool calls on the next turn.
The test test_run_result_to_messages_uses_first_content_item confirms this is the current behavior, but parallel tool-use is a common pattern in the Responses API. If truncation to the first block is intentional (e.g. this function is only ever called in contexts where multi-block output cannot arise), the docstring should state that precondition explicitly.
Prompt To Fix With AI
This is a comment left during a code review.
Path: vero/src/vero/utils/tokens.py
Line: 22-26
Comment:
**Multi-block output silently dropped to first item**
When an item has more than one entry in its `output` or `content` list — for example, a Responses API message that contains both a text block and one or more parallel tool-call blocks — `process_item` replaces the entire list with just the first element. Every tool call after the first is silently discarded. When the result is later passed as conversation history to another model invocation, the model will not see that those tool calls were ever made, which will cause the agent to mis-attribute or duplicate tool calls on the next turn.
The test `test_run_result_to_messages_uses_first_content_item` confirms this is the current behavior, but parallel tool-use is a common pattern in the Responses API. If truncation to the first block is intentional (e.g. this function is only ever called in contexts where multi-block output cannot arise), the docstring should state that precondition explicitly.
How can I resolve this? If you propose a fix, please make it concise.
Second of three stacked PRs splitting #41. Base:
pr1-legacy-relocate.Adds the v0.5 system at the repo root:
vero/(core + harbor eval sidecar, build compiler, runtime/observability),vero-tasks/,runs/. Root README points tolegacy/. Pure adds (197 files) + README/.gitignore.Review after 1/3. 3/3 adds
harness-engineering-bench/on top.🤖 Generated with Claude Code
Greptile Summary
This PR introduces the complete v0.5 VeRO system (197 new files) covering the core optimization engine, Harbor eval sidecar, build compiler, and runtime/observability stack. The change is a pure addition with no modifications to existing files.
EvaluationEngineandBudgetLedgerimplement correct cancellation-safe patterns via a new_drain_writehelper that centralises the previously hand-rolled shield/rollback logic; thereserve,refund, and engine exception handlers are all consistent.CanonicalVerifier,EvaluationSidecar, andInferenceGatewayare well-structured with atomic writes, timing-safe token comparison, and a principle-of-least-privilege agent/admin boundary._safe_extract_tarnow appliesfilter=\"data\"matchingextract_harbor_session_archive.run_result_to_messagesinvero/src/vero/utils/tokens.py: The helper reduces everyoutputorcontentlist to its first element; in a Responses API message that carries parallel tool calls, all but the first call are silently discarded.Confidence Score: 4/5
Safe to merge; the one functional concern is confined to a utility helper used in agent conversation history and does not affect the evaluation engine, budget ledger, or Harbor security boundary.
The evaluation engine, budget ledger, sidecar auth, and Harbor compiler are carefully implemented and the cancellation-safety issues from the previous review cycle have been addressed. The only confirmed defect is in run_result_to_messages, which silently drops all but the first content block from a multi-block Responses API output item — a scenario that arises whenever an agent makes parallel tool calls.
Files Needing Attention: vero/src/vero/utils/tokens.py — the process_item helper truncates multi-block output/content lists
Important Files Changed
Prompt To Fix All With AI
Reviews (31): Last reviewed commit: "Tell the optimizer its results are persi..." | Re-trigger Greptile