Skip to content

Commit 1b45a8f

Browse files
fix: stop submitting truncated reasoning as the browsecomp-plus answer
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>
1 parent 5723247 commit 1b45a8f

1 file changed

Lines changed: 230 additions & 54 deletions

File tree

  • harness-engineering-bench/browsecomp-plus/baseline/target/src/browsecomp_plus_agent

harness-engineering-bench/browsecomp-plus/baseline/target/src/browsecomp_plus_agent/agent.py

Lines changed: 230 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,53 @@
33
from __future__ import annotations
44

55
import json
6+
import os
67
import shlex
8+
import time
79
from typing import Any, override
810

911
from harbor.agents.base import BaseAgent
1012
from harbor.environments.base import BaseEnvironment
1113
from harbor.models.agent.context import AgentContext
1214
from openai import AsyncOpenAI
1315

16+
17+
def _is_reasoning_model(model: str) -> bool:
18+
"""Whether `model` is an OpenAI reasoning model.
19+
20+
Capability, not provider: Azure gpt-4o is not Fireworks yet still rejects
21+
reasoning_effort, and every gpt-5 model rejects max_tokens. Fireworks-served
22+
open models match none of these prefixes, so they keep the legacy shape.
23+
"""
24+
name = model.lower()
25+
return name.startswith(("gpt-5", "o1", "o3", "o4")) or "codex" in name
26+
27+
1428
MAX_TURNS = 32
1529
MAX_TOOL_OUTPUT_CHARS = 40_000
1630

31+
# Wall clock, not turn count, is this agent's binding constraint. Most of a turn
32+
# is the sequential `search.py` invocations, each of which boots a fresh
33+
# pyserini/Lucene JVM against the BM25 index (~13s apiece), against ~3-9s of
34+
# inference. Trials therefore run out of clock long before MAX_TURNS, so the
35+
# turn-budget fallback below never fires and harbor instead kills the trial
36+
# mid-search with no answer recorded. This gives that same best-effort safety
37+
# net a deadline to watch. Harbor does not tell an agent its own timeout, so the
38+
# budget is read from the environment; keep it in step with whatever
39+
# --agent-timeout-multiplier the run uses.
40+
AGENT_BUDGET_SEC = float(os.environ.get("BROWSECOMP_AGENT_BUDGET_SEC", "900"))
41+
# Enough to prefill the accumulated history and write a short answer (~8s for a
42+
# 120k-token prompt measured against the target endpoint), plus the answer
43+
# upload and margin for one retry.
44+
FINAL_RESERVE_SEC = float(os.environ.get("BROWSECOMP_FINAL_RESERVE_SEC", "120"))
45+
# A malformed, empty, or length-truncated assistant turn is recoverable; nudge
46+
# rather than abort, and only give up after this many consecutive nudges.
47+
MAX_EMPTY_RETRIES = 3
48+
# The required answer is three short labeled lines, so the forced-final call is
49+
# capped well below the per-turn budget to stop the model from spending its last
50+
# seconds on chain-of-thought and getting truncated a second time.
51+
ANSWER_MAX_TOKENS = 3_000
52+
1753
INSTRUCTIONS = """You are a persistent deep-research agent working only against the
1854
fixed BrowseComp-Plus corpus. Find the precise answer by issuing focused searches,
1955
opening promising documents, reformulating queries, and cross-checking evidence.
@@ -123,13 +159,17 @@ def _trace(self, event: dict[str, Any]) -> None:
123159
file.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
124160

125161
async def _index_command(
126-
self, environment: BaseEnvironment, command: str, value: str
162+
self,
163+
environment: BaseEnvironment,
164+
command: str,
165+
value: str,
166+
timeout_sec: float = 120,
127167
) -> dict[str, Any]:
128168
flag = "--query" if command == "search" else "--docid"
129169
result = await environment.exec(
130170
f"python3 /opt/browsecomp/search.py {command} {flag} {shlex.quote(value)}",
131171
cwd="/app",
132-
timeout_sec=120,
172+
timeout_sec=max(15, int(timeout_sec)),
133173
)
134174
if result.return_code != 0:
135175
return {
@@ -156,21 +196,60 @@ def _usage_value(value: Any, name: str) -> int:
156196
return int(result or 0)
157197

158198
def _completion_kwargs(
159-
self, messages: list[dict[str, Any]], *, tools: bool = True
199+
self,
200+
messages: list[dict[str, Any]],
201+
*,
202+
tools: bool = True,
203+
max_tokens: int = 12_000,
160204
) -> dict[str, Any]:
161205
kwargs: dict[str, Any] = {
162206
"model": self._api_model,
163207
"messages": messages,
164-
"max_tokens": 12_000,
208+
**{
209+
(
210+
"max_completion_tokens"
211+
if _is_reasoning_model(self._api_model)
212+
else "max_tokens"
213+
): max_tokens
214+
},
165215
}
166216
if tools:
167217
kwargs["tools"] = TOOLS
168218
# OpenAI reasoning models accept reasoning_effort; other providers
169219
# (e.g. Fireworks-served open models) reject it.
170-
if "fireworks" not in self._api_model:
220+
if _is_reasoning_model(self._api_model):
171221
kwargs["reasoning_effort"] = "medium"
172222
return kwargs
173223

224+
async def _complete(
225+
self,
226+
messages: list[dict[str, Any]],
227+
*,
228+
tools: bool = True,
229+
timeout: float,
230+
max_retries: int | None = None,
231+
max_tokens: int = 12_000,
232+
) -> Any:
233+
"""One chat completion, validated for shape.
234+
235+
A gateway can answer 200 with a body the SDK does not turn into a
236+
completion (observed once as `'str' object has no attribute 'usage'`),
237+
so anything without usable `.choices` is rejected here rather than
238+
crashing the trial on an attribute access two lines later.
239+
"""
240+
options: dict[str, Any] = {"timeout": max(30.0, timeout)}
241+
if max_retries is not None:
242+
options["max_retries"] = max_retries
243+
response = await self._client.with_options(**options).chat.completions.create(
244+
**self._completion_kwargs(messages, tools=tools, max_tokens=max_tokens)
245+
)
246+
choices = getattr(response, "choices", None)
247+
if not choices or getattr(choices[0], "message", None) is None:
248+
raise RuntimeError(
249+
f"malformed completion from provider: {type(response).__name__}"
250+
)
251+
return response
252+
174253
def _account(self, usage: Any, totals: dict[str, int]) -> None:
175254
totals["input"] += self._usage_value(usage, "prompt_tokens")
176255
totals["output"] += self._usage_value(usage, "completion_tokens")
@@ -193,25 +272,49 @@ async def run(
193272
{"role": "user", "content": instruction},
194273
]
195274
totals = {"input": 0, "output": 0, "cached": 0}
275+
deadline = time.monotonic() + AGENT_BUDGET_SEC
276+
277+
def remaining() -> float:
278+
return deadline - time.monotonic()
279+
280+
completed = False
281+
out_of_time = False
282+
empty_turns = 0
283+
last_turn = 0
196284

197285
for turn in range(1, MAX_TURNS + 1):
198-
response = await self._client.chat.completions.create(
199-
**self._completion_kwargs(messages)
286+
last_turn = turn
287+
# Stop starting new work once only the final-answer reserve is left,
288+
# so the answer is written before harbor's AgentTimeoutError fires.
289+
if remaining() <= FINAL_RESERVE_SEC:
290+
out_of_time = True
291+
self._trace({"turn": turn, "stopping": "deadline", "left": remaining()})
292+
break
293+
call_started = time.monotonic()
294+
response = await self._complete(
295+
messages, timeout=remaining() - FINAL_RESERVE_SEC / 2
200296
)
201-
self._account(response.usage, totals)
297+
llm_sec = time.monotonic() - call_started
298+
self._account(getattr(response, "usage", None), totals)
202299
message = response.choices[0].message
300+
finish_reason = getattr(response.choices[0], "finish_reason", None)
203301
calls = message.tool_calls or []
204302
self._trace(
205303
{
206304
"turn": turn,
207305
"content": message.content,
306+
"finish_reason": finish_reason,
307+
"llm_sec": round(llm_sec, 2),
308+
"left_sec": round(remaining(), 1),
208309
"tool_calls": [
209310
{"name": c.function.name, "arguments": c.function.arguments}
210311
for c in calls
211312
],
212313
}
213314
)
214315
# Record the assistant turn (with any tool calls) in the history.
316+
# An assistant message carrying neither is rejected by strict
317+
# OpenAI-compatible servers, so it is dropped rather than appended.
215318
assistant: dict[str, Any] = {"role": "assistant"}
216319
if message.content:
217320
assistant["content"] = message.content
@@ -227,47 +330,88 @@ async def run(
227330
}
228331
for c in calls
229332
]
230-
messages.append(assistant)
333+
if len(assistant) > 1:
334+
messages.append(assistant)
231335

232336
if not calls:
233-
if (message.content or "").strip():
337+
# `length` means the model was cut off mid-thought, so its
338+
# content is truncated reasoning, not an answer. This target
339+
# writes its chain-of-thought into ordinary `content` (it has no
340+
# separate reasoning_content channel) and can run past the token
341+
# cap, and the truncated ramble was previously submitted verbatim
342+
# as the final answer, which the judge always scores 0. Ask for
343+
# the answer instead of submitting the thinking.
344+
truncated = finish_reason == "length"
345+
if (message.content or "").strip() and not truncated:
234346
await self._submit(environment, message.content)
347+
completed = True
235348
context.metadata = {
236349
"turns": turn,
237350
"trace": "browsecomp-plus-trace.jsonl",
238351
}
239352
break
240-
raise RuntimeError("model returned neither a response nor a tool call")
353+
empty_turns += 1
354+
self._trace(
355+
{"turn": turn, "recover": empty_turns, "truncated": truncated}
356+
)
357+
if empty_turns >= MAX_EMPTY_RETRIES:
358+
break
359+
messages.append(
360+
{
361+
"role": "user",
362+
"content": (
363+
"You were cut off mid-thought. Stop reasoning and "
364+
"reply with only the three required labeled lines "
365+
"(Explanation, Exact Answer, Confidence), keeping the "
366+
"explanation to a few sentences."
367+
if truncated
368+
else "Your last message was empty. Either call a tool "
369+
"or give your final response in the required format."
370+
),
371+
}
372+
)
373+
continue
374+
empty_turns = 0
241375

242376
submitted = False
243377
for call in calls:
244-
try:
245-
arguments = json.loads(call.function.arguments)
246-
if call.function.name == "search":
247-
result = await self._index_command(
248-
environment, "search", arguments["query"]
249-
)
250-
elif call.function.name == "get_document":
251-
result = await self._index_command(
252-
environment, "get-document", arguments["docid"]
253-
)
254-
elif call.function.name == "submit_response":
255-
await self._submit(environment, arguments["response"])
256-
result = {"submitted": True}
257-
submitted = True
258-
else:
259-
result = {"error": f"unknown tool: {call.function.name}"}
260-
except (
261-
json.JSONDecodeError,
262-
KeyError,
263-
OSError,
264-
TypeError,
265-
ValueError,
266-
) as error:
267-
result = {"error": str(error)}
268-
self._trace(
269-
{"turn": turn, "tool": call.function.name, "result": result}
270-
)
378+
# Every tool_call must still get a tool message even once the
379+
# deadline hits, or the forced-final request below is rejected
380+
# for an unanswered tool call. So stub, never skip.
381+
if out_of_time or remaining() <= FINAL_RESERVE_SEC:
382+
if not out_of_time:
383+
out_of_time = True
384+
self._trace({"turn": turn, "stopping": "deadline-midturn"})
385+
result = {"error": "search budget exhausted"}
386+
else:
387+
try:
388+
arguments = json.loads(call.function.arguments)
389+
budget = remaining() - FINAL_RESERVE_SEC
390+
if call.function.name == "search":
391+
result = await self._index_command(
392+
environment, "search", arguments["query"], budget
393+
)
394+
elif call.function.name == "get_document":
395+
result = await self._index_command(
396+
environment, "get-document", arguments["docid"], budget
397+
)
398+
elif call.function.name == "submit_response":
399+
await self._submit(environment, arguments["response"])
400+
result = {"submitted": True}
401+
submitted = True
402+
else:
403+
result = {"error": f"unknown tool: {call.function.name}"}
404+
except (
405+
json.JSONDecodeError,
406+
KeyError,
407+
OSError,
408+
TypeError,
409+
ValueError,
410+
) as error:
411+
result = {"error": str(error)}
412+
self._trace(
413+
{"turn": turn, "tool": call.function.name, "result": result}
414+
)
271415
messages.append(
272416
{
273417
"role": "tool",
@@ -280,39 +424,71 @@ async def run(
280424
if submitted:
281425
break
282426
if submitted:
427+
completed = True
283428
context.metadata = {
284429
"turns": turn,
285430
"trace": "browsecomp-plus-trace.jsonl",
286431
}
287432
break
288-
else:
289-
# Turn budget exhausted: force one final tool-free response from what
290-
# was retrieved rather than crashing, so the case scores best-effort
433+
if out_of_time:
434+
break
435+
436+
if not completed:
437+
# Budget exhausted (wall clock, turns, or a model that kept coming
438+
# back empty): force one final tool-free response from what was
439+
# retrieved rather than crashing, so the case scores best-effort
291440
# instead of being lost with no answer recorded.
441+
stub = (
442+
"Explanation: no answer could be determined within the search "
443+
"budget.\nExact Answer: unknown\nConfidence: 0%"
444+
)
292445
messages.append(
293446
{
294447
"role": "user",
295448
"content": (
296-
"You have used your full search budget. Give your single "
297-
"best final response now, in the required format, based on "
298-
"the documents you have retrieved."
449+
"You have used your full search budget. Do not reason "
450+
"further. Reply with only the three required labeled "
451+
"lines (Explanation, Exact Answer, Confidence), keeping "
452+
"the explanation to a few sentences, giving your single "
453+
"best answer from the documents you have retrieved."
299454
),
300455
}
301456
)
302-
final = await self._client.chat.completions.create(
303-
**self._completion_kwargs(messages, tools=False)
304-
)
305-
self._account(final.usage, totals)
306-
answer = (final.choices[0].message.content or "").strip() or (
307-
"Explanation: no answer could be determined within the search "
308-
"budget.\nExact Answer: unknown\nConfidence: 0%"
309-
)
457+
# This call is the last thing standing between a completed trial and
458+
# a lost one, so a failure here degrades to the stub answer instead
459+
# of propagating.
460+
answer = stub
461+
try:
462+
# Bounded retries: the SDK default of 8 could outlast the
463+
# reserve on its own and hand the trial back to the killer.
464+
# A tight token cap keeps the model from spending the whole
465+
# reserve on chain-of-thought and getting truncated again.
466+
final = await self._complete(
467+
messages,
468+
tools=False,
469+
timeout=max(30.0, remaining() - 20),
470+
max_retries=1,
471+
max_tokens=ANSWER_MAX_TOKENS,
472+
)
473+
self._account(getattr(final, "usage", None), totals)
474+
answer = (final.choices[0].message.content or "").strip() or stub
475+
except Exception as error: # noqa: BLE001 - last-resort safety net
476+
self._trace({"forced_final_error": f"{type(error).__name__}: {error}"})
310477
await self._submit(environment, answer)
311-
self._trace({"turn": MAX_TURNS, "forced_final_answer": answer})
478+
self._trace(
479+
{
480+
"turn": last_turn,
481+
"forced_final_answer": answer,
482+
"reason": "deadline" if out_of_time else "turns",
483+
}
484+
)
312485
context.metadata = {
313-
"turns": MAX_TURNS,
486+
"turns": last_turn,
314487
"trace": "browsecomp-plus-trace.jsonl",
315488
"forced_final": True,
489+
# Lets a post-hoc conservative score treat exactly the trials
490+
# that the pre-fix agent would have lost as zeros.
491+
"forced_final_reason": "deadline" if out_of_time else "turns",
316492
}
317493

318494
context.n_input_tokens = totals["input"]

0 commit comments

Comments
 (0)