fix: stop submitting truncated reasoning as the browsecomp-plus answer#61
fix: stop submitting truncated reasoning as the browsecomp-plus answer#61shehabyasser-scale wants to merge 45 commits into
Conversation
Bring the benchmark harness with each benchmark as a top-level sibling of gaia/ (ale-bench, officeqa, swe-atlas-qna, tau3), flattening the former candidates/ grouping.
The floor gates a ship on a validation comparison while gaia's reward is on the test partition (different distributions), so keep it opt-in / off. Now that the harbor default is false this is explicit-for-clarity.
Now that the sidecar image pre-warms the harness uv cache, the inner eval runs as the unprivileged harness user again, so the candidate harness cannot read the held-out records, budgets, or admin token.
Move each benchmark to harbor 0.20.0 in lockstep so the eval resolves: build.yaml harbor_requirement (the evaluator's --with harbor[modal]) and target/pyproject.toml (the candidate's own harbor dep) must match, else `uv run --project <target> --with harbor[modal]==0.20.0` is unsatisfiable. uv.lock regenerated with `uv lock` (not hand-edited). Covers gaia, officeqa, swe-atlas-qna, tau3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the pr2 rename: instructions, READMEs, and the ale-bench reference solution now teach `evals run/status/result/submit` and the .evals/tasks + .evals/results paths; baseline gitignores cover .evals/. Compiled trees are build outputs (not committed) and pick this up on rebuild. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All four vero-compiled benchmarks pass --ek app_name=harness-engineering-bench so their Modal sandboxes are grouped under one app instead of __harbor__. Docker inner environments ignore the kwarg.
The task's verifier runs inside the Modal task container and templates EVAL_BASE_URL from OPENAI_API_BASE, unreachable-from-Modal gateway URLs aside. Enable task_services_use_upstream so the judge gets the real upstream on OPENAI_*, and point the baseline agent at the metered gateway via VERO_AGENT_INFERENCE_* (falling back to OPENAI_* locally).
The Modal app-name commit added a second extra_harbor_args key; YAML last-key-wins silently dropped --no-force-build, which would have force-built the guard Dockerfile instead of pulling the prebuilt corpus image.
Killing the outer harbor process leaves the compose topology and any in-flight Modal sandboxes running (each layer's cleanup lives in the layer above). Cap sandbox idle lifetime at 1h via --ek, and add scripts/cleanup_orphans.sh to tear down task__* containers and terminate sandboxes in the dedicated Modal app.
Across gaia/officeqa/swe-atlas-qna/tau3: 100-run budgets and 4x-pass
case budgets on both agent partitions, submit-mode selection,
baseline_floor off, 16k feedback, parameterized inner environment
(${inner_env:-modal}), and verifier_timeout = 2x timeout_seconds.
swe-atlas-qna and tau3 get explicit case/task-agent timeouts derived
from their datasets' declared agent timeouts (enforced budgets 1800s
and 900s, provisional until empirical wall_seconds data). harness_user
is explicit on gaia/officeqa; tau3 and swe-atlas-qna stay unisolated
pending off-env delivery of task-service credentials. Also fix the
vendor script's stale pre-restructure path in its usage comment.
Root cause of every swe-atlas trial dying at setup: the task images set ENTRYPOINT ["/bin/bash"], Modal prepends it to the sandbox command, so harbor's default keepalive ["sh","-c","sleep infinity"] ran as 'bash sh -c ...' - bash interprets the sh binary as a script and exits 126, killing the sandbox before the first exec. Pass bash's own args via the JSON-decoded keepalive env kwarg instead. Validated end-to-end on Modal (3 dev trials, 379-602s wall). CONFIGURATION.md records the empirical probe timings behind the provisional case timeouts.
scripts/per_trial_tokens.py reads a session's gateway request log and attributes token/latency to Harbor trials: by the gateway's stamped thread_id when present (request_log.attribution builds → full coverage incl. stateful chains), else a legacy fallback that recovers each conversation's root turn (labelable via --tasks-dir) and residualizes unrecoverable follow-ups. Reports gateway-total vs attributed vs agent-reported per evaluation. Stdlib-only; tests cover both paths.
…r at turn cap Chat Completions is the only universally-supported inference surface — the OpenAI Responses API doesn't translate cleanly to non-OpenAI models via litellm (parallel_tool_calls rejected, output_text crashes on a null message part). Rewrite AtlasAgent's loop to stateless chat messages + tool_calls, validated cross-provider (gpt-5.4-mini and fireworks_ai/gpt-oss-120b both run the multi-turn tool loop). Also: at the turn cap, force one final tool-free answer instead of raising, so a budget-exhausted trial scores best-effort rather than losing the case.
Same port as swe-atlas: stateless chat messages + tool_calls, works across providers (validated cross-provider on gpt-5.4-mini and fireworks_ai/gpt-oss-120b). officeqa drops the hosted web_search tool (closed-corpus benchmark, and chat/completions has no hosted-tool equivalent) and moves read_image feedback to chat image_url content. tau3 keeps its host-side MCP loop; the stateless history also fixes the previous_response_id reset that dropped context after plain-text turns, and the MCP session id is now charset-validated before shell interpolation (greptile injection finding).
Mirrors the swe-atlas fix: at MAX_TURNS force one tool-free completion for the best answer and submit it, so a budget-exhausted case scores best-effort rather than being lost with no answer recorded.
A fifth harness-engineering benchmark: optimize a retrieval + reasoning agent over BrowseComp-Plus's fixed corpus and canonical BM25 index, graded by a semantic answer judge (gpt-4.1). Task packages are generated from the encrypted upstream dataset by scripts/build_tasks.py and are gitignored (they contain decrypted questions/answers); the upstream repo is pinned as a submodule and the 2.2 GB index is pulled at image-build time. Wired consistently with the normalized suite: 33/66/66 stratified split, 4-pass budgets, submit-mode selection, harness_user null + task_services_use_upstream for the in-container judge. Baseline agent uses Chat Completions (works across providers) and forces a best-effort answer at the turn cap. The task image installs a full JDK with JAVA_HOME resolved from it, so pyserini's Lucene BM25 searcher works (a headless JRE with JAVA_HOME unset left jnius unable to locate the JVM).
… succeeds pyserini 1.2.0 constructs an openai.OpenAI() client at import time for its unused OpenAI encoders; BM25/Lucene search never calls OpenAI, so set a placeholder key in search.py before the import.
The task.toml had no verifier.env, so the gpt-4.1 answer judge ran without OPENAI_API_KEY and scored every case 0 (Missing credentials). Template the upstream creds and judge model into the verifier phase.
…las) From a 10-trial per-benchmark probe of gpt-5.4-mini vs gpt-oss-120b vs deepseek-v4-flash: deepseek matches/beats both on tau3 (0.875) and is ~2-3x gpt-oss on the grounded-reasoning benchmarks (officeqa/browsecomp 0.60) at roughly gpt-oss cost and far below mini. It is weaker only on swe-atlas (0.30 vs gpt-oss 0.59 mean rubric), which is pinned to gpt-oss-120b — cheaper and stronger there. Updates each benchmark's target `model` and the evaluation-scope `allowed_models` (kept with the fireworks_ai/ prefix, since agents strip only the openai/ prefix and the gateway matches the sent model exactly). Producer scopes and tau3's user/assertion models are unchanged. Also hardens the swe-atlas atlas agent: a shell command emitting non-UTF-8 (binary) output raised UnicodeDecodeError and killed the whole trial; it now returns a tool error so the model can retry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… relock - .gitignore: remove the paper-planning benchmark-scoping.md ignore - README.md: correct candidate paths (candidates/ -> top level), add browsecomp-plus, drop the stale nested table - vero/uv.lock: relock Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fireworks' shared-org rate limit surfaces as a RateLimitError inside the agent's OpenAI client; harbor only retries infra/env failures, not LLM 429s, so a burst lost trials. The SDK's default 2 retries were exhausted under sustained load. Bump to 8 so transient 429s back off in-place instead of failing the trial (durable fix is gateway-level retry, tracked separately). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…line Full held-out (test) pass of the seed harness, 3 independent rounds, under the new targets (deepseek-v4-flash; gpt-oss-120b on swe-atlas): swe-atlas 0.097 ±0.011 (agg_score 0.632) [gpt-oss-120b] tau3 0.611 ±0.021 [deepseek-v4-flash] officeqa 0.360 ±0.042 [deepseek-v4-flash] browsecomp 0.449 ±0.007 [deepseek-v4-flash] Each pinned into its target's baseline_reward with score_baseline: false, so finalization uses the number instead of re-scoring the seed every run. The three deepseek benchmarks logged zero exceptions over 945 trials (max_retries absorbed the shared-quota 429s); swe-atlas lost 5/150 to gpt-oss 128k overflow. CONFIGURATION.md documents the numbers, method, and the swe-atlas binary-vs- agg_score note. gaia deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
It was the suite's only Family B task and the only one that bypassed the compiler: a hand-written task directory with no build.yaml, so it got none of the build-time validation the other five benchmarks get. Its optimizer was also unmetered -- no gateway container, raw upstream credentials in main, no model allow-list -- which is a fair-play hole in a benchmark whose whole point is controlled measurement. Its sidecar factory had additionally gone stale against SidecarEvaluationPolicy and would have died at startup. The nested-backend refactor now supports `evaluation_backend: command`, so a replacement Family B task can be built through the compiler and get the gateway for free. examples/harbor-circle-packing carries a worked version of exactly that shape, verified end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These tests moved here from pr2-add-vero, where they could only ever skip. They had been failing for a while against a pinned literal -- evaluation allowed_models == ["gpt-5.4-mini-2026-03-17"] -- that stopped matching when the benchmarks switched to fireworks-served targets. Rewrite the assertions so they cannot drift that way again: the evaluation scope must allow exactly [config.model], derived rather than pinned. That is the real invariant, and it now matches the build-time validator that rejects a model outside its scope's allow-list. Also drop the skip guard, so a missing harness-engineering-bench fails rather than passing vacuously, and extend coverage from three benchmarks to all five. 415 passed, 5 skipped -- the three long-standing failures are 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>
These tests fail 6 of 16 on a clean checkout. officeqa and browsecomp-plus point task_source at a gitignored tasks/ tree built by their own scripts/, and the loader only absolutizes a relative task_source when it exists, so on a fresh clone the path stays literal and is rejected as an unpinned registry reference. They pass here only because this machine has already run the vendor scripts. Dropping the blanket skip guard in d131412 was the right call -- that guard is what let the earlier version rot unnoticed -- but it was aimed at the wrong condition. A missing harness-engineering-bench still errors loudly; what is skipped now is one benchmark whose tasks are not fetched yet, and the skip names the directory and points at the script. That is a precondition of the machine, not a property of the config. Verified by moving both tasks/ directories aside: 10 passed, 6 skipped with the reason attached, where before it was 6 failed. Reported by shehabyasser-scale on #46. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gateway matches a requested model against the scope allow-list as an
exact string, so the producer default and the -m the outer trial is
launched with have to agree character for character. Both gpt-5.4 and
openai/gpt-5.4 resolve on the router, so this is a convention choice, not a
correctness fix: prefixed matches how every evaluation scope names its
model and how harbor's own uploader keeps the two spellings apart.
Note the resulting asymmetry in gaia, which is deliberate. Its producer is
openai/gpt-5.4 while its target is bare gpt-5.4-mini, because the baseline
agents send model_name.removeprefix("openai/") and the optimizer agent does
not. The allow-list has to match what each actually sends.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gaia still had the try/except/else shape the last round removed from officeqa and swe-atlas-qna: only json.loads sat inside the try, so every argument lookup and every tool call ran unguarded. It matters more here than it did there, because gaia is the one agent with read_image, which downloads a model-supplied path and then stats and reads it -- a hallucinated path fails in the filesystem, not in json.loads, and took the whole trial with it. gaia is also the benchmark about to be re-baselined on a multimodal model, so that path is now exercised rather than dormant. OSError joins the caught set in all four agents, not just gaia. Every one of them writes a file in _submit, and three download or upload through the environment, so the previous tuple of JSONDecodeError/KeyError/TypeError/ ValueError left the I/O half of the dispatch uncovered everywhere. A failed tool call should reach the model as a tool error it can react to, not end the trial. Reported by greptile on #46 (gaia); extended to the other three for the same reason rather than waiting to be told about each in turn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gaia was never ported off the Responses API and lacked two robustness
guards the other four baseline agents have, causing ~27% of held-out trials
to error out (and silently inflate any reward computed over survivors):
- A turn where the model only reasoned, ran a hosted web_search, or was
truncated at max_output_tokens returns no function_call and no message.
The loop treated that as fatal ("neither answer nor tool call"); it now
carries the response chain forward with a nudge. MAX_TURNS bounds it.
- Exceeding MAX_TURNS raised RuntimeError; it now forces one final tool-free
answer so the case scores best-effort.
Held-out crash rate drops 27% -> 2% (189 OK / 5 forced / 4 infra of 198);
clean baseline_reward 0.5736 (sd 0.010 across 3 rounds).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clean K=3 held-out pass with gpt-5.4-mini after the gaia agent crash fix: baseline_reward 0.5736 (sd 0.010 across rounds 0.576/0.561/0.585), 197/198 trials scored (189 OK, 5 forced-final, 4 infra). Pins the number and disables per-run baseline re-scoring, matching the other four benchmarks. CONFIGURATION.md baseline row + footnote updated (gaia now measured). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Set the test target's per-target n_attempts=3 + aggregate_attempts=mean so the selected candidate's held-out reward is a 3x-per-case mean (comparable to the K=3 pinned baseline); search/validation keep n_attempts=1. Point W&B at the shared harness-engineering-bench project, grouped by benchmark, with a per-launch run name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A provider-agnostic runbook for launching one benchmark's optimization end-to-end (compile -> gateway -> optimizer -> sandboxed evals -> finalize), with the preflight to confirm before launching and the health checks after. No secrets or environment-specific paths; points at CONFIGURATION.md and each build.yaml for the per-benchmark specifics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mizer loop Audited the held-out baseline per-case wall times: the codex-probe-sized case_timeouts were too tight for the real target agents, so ~9/13/26% of gaia/officeqa/browsecomp candidate cases would be killed (scored failure) against leniently-measured baselines. Raise case_timeout to >= each benchmark's baseline max: gaia 180->900, officeqa 300->1200, tau3 900->1200, browsecomp 900->2100 (swe-atlas 1800 already fine). The pinned baselines themselves ran lenient (near-zero timeout exceptions), so they stay valid. Harbor runs the agent and verifier phases on independent clocks, and the optimizer agent phase is unbounded (governed by the gateway token budget), so verifier_timeout must cover only finalization. A verifier timeout loses the score, so size generously for n_attempts x held-out + rescore headroom: gaia 7200->10800, officeqa 14400->25200, tau3 28800->43200, browsecomp 57600->64800. Per-eval timeouts already have ample headroom; unchanged. officeqa also gets agent_env raising BASH_MAX_TIMEOUT_MS + disabling forced background tasks, so the Claude Code optimizer blocks on a whole 15-30min eval in one Bash call instead of detach/poll/end-turn (which ended the headless run after a single eval). CONFIGURATION.md updated with the new values + rationale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n officeqa Tokens (not dollars) are the reported unit: with a fixed target model, dollars are a downstream linear function of the (input, cached, output) triple, so they are not stored. Turn on the gateway's per-trial thread attribution for officeqa (request_log_attribution: true) so per_trial_tokens.py attributes gateway tokens to trials via stamped thread_ids instead of content-matching. Extend the aggregator with a multi-session grid rollup (flat CSV keyed by run/eval/task), per-trial wall time, and a gateway-vs-attributed-vs-agent-reported reconciliation. Document the token surfaces in CONFIGURATION.md and the run-benchmark skill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give every token and latency measure a mean, median, and max over trials: these distributions are heavy-tailed, so a mean alone hides that a few trials carry much of an evaluation's spend. Fix the run label for a session passed as "." (an empty name blanked the run column) and key the single-session JSON payload off the grid rather than the argument name. Document the per-case statistics in CONFIGURATION.md and the run-benchmark skill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The headline is a scoring bug. When a completion came back with
`finish_reason == "length"`, the agent submitted `message.content`
verbatim as its final answer. This target writes its chain-of-thought
into ordinary `content` (it has no separate reasoning_content channel)
and routinely runs past the token cap, so what got submitted was 46-52k
characters of truncated mid-sentence reasoning. The judge scores that 0
every time, and the trial looks like a genuine wrong answer rather than
a harness failure. Truncated turns are now treated as recoverable: the
model is asked for just the three required labeled lines instead of
having its thinking submitted for it.
Four supporting changes, all aimed at the same failure mode of losing a
trial that had real retrieved evidence behind it:
- Wall clock, not turn count, is this agent's binding constraint. Most
of a turn is sequential `search.py` calls, each booting a fresh
pyserini/Lucene JVM against the BM25 index (~13s apiece) against ~3-9s
of inference, so trials ran out of clock long before MAX_TURNS. The
turn-budget fallback therefore never fired and harbor killed the trial
mid-search with no answer recorded. There is now a deadline
(BROWSECOMP_AGENT_BUDGET_SEC) with a final-answer reserve
(BROWSECOMP_FINAL_RESERVE_SEC) so the best-effort answer is written
before harbor's AgentTimeoutError. Tool calls past the deadline are
stubbed rather than skipped, because every tool_call still needs a
tool message or the forced-final request is rejected.
- Completion shape is validated in one place. A gateway can answer 200
with a body the SDK does not turn into a completion, observed as
`'str' object has no attribute 'usage'`, which crashed the trial on an
attribute access two lines later. Anything without usable `.choices`
is now rejected as a retryable error.
- An empty or malformed assistant turn nudges instead of raising
`RuntimeError("model returned neither a response nor a tool call")`,
giving up only after MAX_EMPTY_RETRIES consecutive nudges. An
assistant message carrying neither content nor tool calls is dropped
rather than appended, since strict OpenAI-compatible servers reject it.
- The forced-final call is bounded (max_retries=1, ANSWER_MAX_TOKENS) and
degrades to a stub answer on failure rather than propagating, and the
trace/metadata now record finish_reason, per-turn latency, remaining
budget, and a `forced_final_reason` so a post-hoc conservative score
can treat exactly the trials the pre-fix agent would have lost as
zeros.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| final = await self._complete( | ||
| messages, | ||
| tools=False, | ||
| timeout=max(30.0, remaining() - 20), |
There was a problem hiding this comment.
forced_final_reason is "turns" for empty-retry exhaustion, not just turn-count exhaustion
When the loop exits because empty_turns >= MAX_EMPTY_RETRIES (three consecutive truncated/empty responses) with out_of_time=False, the trace and context.metadata both record "forced_final_reason": "turns". That conflates two distinct causes: MAX_TURNS exhaustion and MAX_EMPTY_RETRIES exhaustion. A conservative post-hoc scorer using forced_final_reason to identify pre-fix losses would not be able to distinguish a trial that hit turn 32 from one the model kept returning truncated responses on turn 5. Consider adding a third reason string (e.g. "empty_retries") or including "empty_turns": empty_turns in the forced-final trace event.
Prompt To Fix With AI
This is a comment left during a code review.
Path: harness-engineering-bench/browsecomp-plus/baseline/target/src/browsecomp_plus_agent/agent.py
Line: 466-469
Comment:
**`forced_final_reason` is `"turns"` for empty-retry exhaustion, not just turn-count exhaustion**
When the loop exits because `empty_turns >= MAX_EMPTY_RETRIES` (three consecutive truncated/empty responses) with `out_of_time=False`, the trace and `context.metadata` both record `"forced_final_reason": "turns"`. That conflates two distinct causes: MAX_TURNS exhaustion and MAX_EMPTY_RETRIES exhaustion. A conservative post-hoc scorer using `forced_final_reason` to identify pre-fix losses would not be able to distinguish a trial that hit turn 32 from one the model kept returning truncated responses on turn 5. Consider adding a third reason string (e.g. `"empty_retries"`) or including `"empty_turns": empty_turns` in the forced-final trace event.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
5723247 to
20d2bc5
Compare
Stacked on
pr3-harness-bench(#46). One file: the BrowseComp+ baseline agent.The scoring bug
When a completion came back with
finish_reason == "length", the agent submittedmessage.contentverbatim as its final answer:This target writes its chain-of-thought into ordinary
content(it has no separatereasoning_contentchannel) and routinely runs past the token cap. So what got submitted was not an answer, it was 42k to 52k characters of truncated, mid-sentence reasoning. The judge scores that 0 every time, and the trial is indistinguishable from a genuine wrong answer.Measured across the smoke runs carrying this change: 6 length-truncated turns over 3 trials, content length min 42,253 / median 46,793 / max 52,104 chars. The pre-fix baseline traces (198 trials over 3 rounds) contain no
finish_reasonfield at all, which is exactly why this went unnoticed. The instrumentation added here is what surfaced it.Truncated turns are now recoverable rather than final: the model is asked for only the three required labeled lines instead of having its unfinished thinking submitted on its behalf.
Supporting changes
All four target the same failure mode of losing a trial that had real retrieved evidence behind it.
Wall-clock deadline with a final-answer reserve. Turn count is not this agent's binding constraint. Most of a turn is sequential
search.pyinvocations, each booting a fresh pyserini/Lucene JVM against the BM25 index (~13s apiece) against ~3-9s of inference. Trials ran out of clock long beforeMAX_TURNS, so the existing turn-budget fallback never fired and harbor killed the trial mid-search with no answer recorded. There is now a deadline (BROWSECOMP_AGENT_BUDGET_SEC, default 900) with a reserve (BROWSECOMP_FINAL_RESERVE_SEC, default 120) so the best-effort answer is written before harbor'sAgentTimeoutError. Harbor does not tell an agent its own timeout, hence the env var; keep it in step with the run's--agent-timeout-multiplier.Tool calls past the deadline are stubbed, never skipped, because every
tool_callstill needs a matching tool message or the forced-final request is rejected for an unanswered call.Completion shape validation. A gateway can answer 200 with a body the SDK does not turn into a completion, observed once as
'str' object has no attribute 'usage', which crashed the trial on an attribute access two lines later._complete()now rejects anything without usable.choicesin one place.Empty turns nudge instead of raising.
raise RuntimeError("model returned neither a response nor a tool call")became a bounded retry, giving up only afterMAX_EMPTY_RETRIESconsecutive empty turns. An assistant message carrying neither content nor tool calls is dropped rather than appended, since strict OpenAI-compatible servers reject it.Bounded forced-final. The last-resort call uses
max_retries=1(the SDK default of 8 could outlast the reserve on its own) andANSWER_MAX_TOKENS(so the model cannot spend the whole reserve on chain-of-thought and get truncated a second time), and degrades to a stub answer on failure instead of propagating.Extra trace fields.
finish_reason, per-turnllm_sec,left_sec, and aforced_final_reasonofdeadlinevsturns, which lets a post-hoc conservative score treat exactly the trials the pre-fix agent would have lost as zeros.Overlap with #54
This diff includes the
_is_reasoning_modelcapability gate for this agent, which is also in #54. It is not separable here: the same_completion_kwargsline that #54 rewrites is the one this PR parameterizes to takemax_tokensfor the bounded forced-final call.Whichever lands second will need that hunk dropped. Flagging rather than pre-emptively rewriting #54's scope.
Verification
pytest tests/test_v05_benchmark_configs.pyon this branch: 10 passed, 6 skipped, no failures. Agent compiles clean. Exercised live in thesmoke-browsecomp-plus-qwenfix1/fix2 runs cited above.🤖 Generated with Claude Code
Greptile Summary
This PR fixes a scoring bug where
finish_reason == \"length\"completions had their 42–52k character truncated chain-of-thought submitted as the final answer (scoring 0 every time), and adds complementary hardening so trials that ran out of wall-clock time now record a best-effort answer instead of being killed mid-search with no answer at all.finish_reason == \"length\", content is no longer submitted; the model is nudged to produce only the three required labeled lines, with up toMAX_EMPTY_RETRIESattempts before falling through to the forced-final path.AGENT_BUDGET_SECwall-clock budget with aFINAL_RESERVE_SECreserve breaks the search loop before harbor'sAgentTimeoutErrorfires; tool calls past the deadline are stubbed (never skipped) to keep the OpenAI message history valid._complete()validates completion shape in one place, the forced-final call is bounded bymax_retries=1andANSWER_MAX_TOKENS, and a bareexcept Exceptiondegrades to a stub answer rather than losing the trial entirely. Extra trace fields (finish_reason,llm_sec,left_sec,forced_final_reason) make the failure modes observable post-hoc.Confidence Score: 4/5
Safe to merge; the primary truncation fix is correct and the deadline logic is sound. The two observations are diagnostic quality issues in the trace output, not correctness bugs.
The core fix—blocking submission of length-truncated content and nudging for a proper answer—is implemented correctly. The deadline management, message-history invariants (stubbing rather than skipping tool calls, dropping empty assistant messages), and forced-final fallback all look sound. The two flagged items are both about trace completeness: stubbed tool calls emit no individual trace events, and the forced_final_reason field conflates turn-budget exhaustion with empty-retry exhaustion. Neither affects the submitted answer or trial scoring, but both limit the usefulness of the post-hoc forensics the PR explicitly aims to enable.
Files Needing Attention: The single changed file agent.py is worth a second read around the deadline-drain tool-loop (lines 377–434) and the forced-final block (lines 436–492) to confirm the trace gaps are acceptable for the audit use-case described in the PR.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Start run] --> B[Initialize: messages, deadline, empty_turns=0, completed=False] B --> C{remaining <= FINAL_RESERVE_SEC?} C -- yes --> D[out_of_time=True, trace 'stopping: deadline', break] C -- no --> E[_complete: LLM call with timeout=remaining - FINAL_RESERVE_SEC/2] E --> F{choices valid?} F -- no --> G[raise RuntimeError: malformed completion] F -- yes --> H[Extract message, finish_reason, tool_calls] H --> I{tool_calls?} I -- no --> J{content present AND finish_reason != length?} J -- yes --> K[_submit answer, completed=True, break] J -- no --> L[empty_turns++ trace truncated/empty] L --> M{empty_turns >= MAX_EMPTY_RETRIES?} M -- yes --> N[break to forced-final] M -- no --> O[Append nudge user message, continue] I -- yes --> P[empty_turns = 0] P --> Q{for each tool_call} Q --> R{out_of_time OR remaining <= FINAL_RESERVE_SEC?} R -- yes --> S[stub result: search budget exhausted] R -- no --> T[Execute: search / get_document / submit_response] T --> U{submit_response called?} U -- yes --> V[submitted=True, completed=True, break] U -- no --> W[Append tool message] S --> W W --> Q Q -- done --> X{submitted?} X -- yes --> V X -- no --> Y{out_of_time?} Y -- yes --> N Y -- no --> C D --> N N --> Z{completed?} Z -- yes --> END[Update token counts, return] Z -- no --> AA[Append forced-final user message] AA --> BB[_complete: tools=False, max_retries=1, max_tokens=ANSWER_MAX_TOKENS] BB --> CC{Exception?} CC -- yes --> DD[trace error, use stub answer] CC -- no --> EE[answer = final content or stub] DD --> FF[_submit answer] EE --> FF FF --> ENDPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix: stop submitting truncated reasoning..." | Re-trigger Greptile