Skip to content

Commit dc8c7ee

Browse files
varunursekarclaude
andcommitted
Act on the PR review: benchmark and baseline-agent fixes
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>
1 parent aed36f3 commit dc8c7ee

7 files changed

Lines changed: 97 additions & 41 deletions

File tree

harness-engineering-bench/CONFIGURATION.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ benchmark can be checked against the others at a glance.
3838
grounded-reasoning benchmarks (officeqa/browsecomp 0.60) at roughly gpt-oss
3939
cost — far cheaper than mini. `swe-atlas-qna` is pinned to
4040
`fireworks_ai/gpt-oss-120b`, the one benchmark where deepseek is weaker
41-
(0.30 vs 0.59 mean rubric) and gpt-oss is both cheaper and stronger.
41+
(0.30 vs 0.59 mean rubric) and gpt-oss is both cheaper and stronger. `gaia` is
42+
pinned to `gpt-5.4-mini` for a different reason: it is the one multimodal
43+
benchmark, 5 of its 66 held-out tasks send image inputs, and deepseek-v4-flash
44+
rejects those outright (`This model does not support image inputs`), capping
45+
achievable reward near 0.92 and disguising the shortfall as agent failure.
4246
- **Execution**: `harbor[modal]==0.20.0`, python 3.12, `n_attempts: 1`,
4347
`max_retries: 1`, 3 infrastructure attempts at 5s, `aggregate_attempts:
4448
best`, `max_concurrency: 8`, `error_rate_threshold: 0.1`,
@@ -53,7 +57,7 @@ benchmark can be checked against the others at a glance.
5357

5458
| | gaia | officeqa | swe-atlas-qna | tau3 | browsecomp-plus |
5559
|---|---|---|---|---|---|
56-
| target model | deepseek-v4-flash | deepseek-v4-flash | gpt-oss-120b | deepseek-v4-flash | deepseek-v4-flash |
60+
| target model | gpt-5.4-mini ◇ | deepseek-v4-flash | gpt-oss-120b | deepseek-v4-flash | deepseek-v4-flash |
5761
| held-out baseline (K=3) ◆ || 0.360 ±0.042 | 0.097 ±0.011 (agg 0.632) | 0.611 ±0.021 | 0.449 ±0.007 |
5862
| split dev/val/test | 33/66/66 | 49/98/99 | 25/49/50 | 75/150/150 | 33/66/66 |
5963
| dev budget (runs / cases) | 100 / 132 | 100 / 196 | 100 / 100 | 100 / 300 | 100 / 132 |
@@ -95,6 +99,16 @@ deepseek-v4-flash (gpt-oss-120b on swe-atlas); the three deepseek benchmarks
9599
logged zero exceptions over 945 trials, swe-atlas lost 5/150 to gpt-oss 128k
96100
context overflow.
97101

102+
◇ gaia is the exception to the deepseek-v4-flash default: it is multimodal and
103+
that model is text-only. Verified against the same litellm endpoint the gateway
104+
proxies to — gpt-5.4-mini returns 200 for every request shape the gaia agent
105+
sends (image input, hosted `web_search`, `reasoning.effort`,
106+
`parallel_tool_calls`), while deepseek-v4-flash returns
107+
`400 This model does not support image inputs`. Written unprefixed on purpose:
108+
each agent sends `model_name.removeprefix("openai/")`, so an `openai/`-prefixed
109+
name would be allow-listed in one form and requested in another and the gateway
110+
would deny it.
111+
98112
† Sized from stock-agent probes (codex on the target model, 3 development
99113
tasks each, full declared timeouts): tau3 trials took 202-211s (900s budget
100114
≈ 4x headroom) and swe-atlas trials 379-602s (1800s ≈ 3x headroom over the

harness-engineering-bench/gaia/baseline/build.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,13 @@ score_baseline: true
4545
rescore_top_k: 3
4646
rescore_attempts: 1
4747

48-
model: fireworks_ai/deepseek-v4-flash
48+
# GAIA is multimodal: 5 of the 66 held-out tasks send image inputs, and a
49+
# text-only target 400s on them ("This model does not support image inputs"),
50+
# which reads as ordinary agent failure and caps achievable reward near 0.92.
51+
# Unprefixed on purpose -- the agent sends model_name.removeprefix("openai/"),
52+
# so an openai/-prefixed name here would be allow-listed in one form and
53+
# requested in another, and the gateway would deny it.
54+
model: gpt-5.4-mini
4955
environment_name: ${inner_env:-modal}
5056
# inner eval sandboxes share a dedicated Modal app instead of the __harbor__ default
5157
extra_harbor_args: ["--ek", "app_name=harness-engineering-bench", "--ek", "sandbox_idle_timeout_secs=3600"]
@@ -85,7 +91,7 @@ inference_gateway:
8591
allowed_models: ["${optimizer_model:-gpt-5.4}"]
8692
max_concurrency: 8
8793
evaluation:
88-
allowed_models: [fireworks_ai/deepseek-v4-flash]
94+
allowed_models: [gpt-5.4-mini]
8995
max_requests: 15000
9096
max_tokens: 100000000
9197
max_concurrency: 64

harness-engineering-bench/gaia/baseline/target/src/gaia_agent/agent.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
102102
if self.model_name is None:
103103
raise ValueError("GAIA agent requires a Harbor model")
104104
self._api_model = self.model_name.removeprefix("openai/")
105-
self._client = AsyncOpenAI()
105+
# Absorb transient 429s in-client: a within-trial infra failure scores
106+
# at the failure value for competitive evaluations, so an unretried
107+
# rate limit costs a candidate a 0.0.
108+
self._client = AsyncOpenAI(max_retries=8)
106109

107110
@override
108111
async def setup(self, environment: BaseEnvironment) -> None:

harness-engineering-bench/officeqa/baseline/target/src/officeqa_agent/agent.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -253,15 +253,16 @@ async def run(
253253

254254
submitted = False
255255
for call in calls:
256+
image_url = None
257+
# The dispatch runs inside the try rather than an else: the
258+
# argument lookups are the likelier failure, since the tool
259+
# schemas are not strict and the model can return valid JSON
260+
# that omits a required key. Feed that back as a tool error
261+
# instead of letting a KeyError end the whole trial.
256262
try:
257263
arguments = json.loads(call.function.arguments)
258-
except json.JSONDecodeError as error:
259-
result: dict[str, Any] = {"error": f"invalid arguments: {error}"}
260-
image_url = None
261-
else:
262-
image_url = None
263264
if call.function.name == "run_shell":
264-
result = await self._run_shell(
265+
result: dict[str, Any] = await self._run_shell(
265266
environment, arguments["command"]
266267
)
267268
elif call.function.name == "read_image":
@@ -274,6 +275,9 @@ async def run(
274275
submitted = True
275276
else:
276277
result = {"error": f"unknown tool: {call.function.name}"}
278+
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
279+
result = {"error": f"invalid arguments: {error}"}
280+
image_url = None
277281
self._trace(
278282
{"turn": turn, "tool": call.function.name, "result": result}
279283
)

harness-engineering-bench/scripts/per_trial_tokens.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ def load_threads(requests_dir: Path) -> dict[str, dict[str, dict]]:
123123
continue
124124
if record.get("scope") not in ("evaluation", "finalization"):
125125
continue
126-
evaluation_id = record.get("attribution")
126+
# Salvage copies can lack attribution; a None key would sort
127+
# against the real ones and crash the report.
128+
evaluation_id = record.get("attribution") or "unattributed"
127129
snippet = record.get("root_snippet") or recover_snippet(record)
128130
thread_key = record.get("thread_id") or (
129131
"snippet:" + normalize(snippet or "")[:160] or "unattributed"

harness-engineering-bench/swe-atlas-qna/baseline/target/src/atlas_agent/agent.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,13 +234,15 @@ async def run(
234234

235235
submitted = False
236236
for call in calls:
237+
# The dispatch runs inside the try rather than an else: the
238+
# argument lookups are the likelier failure, since the tool
239+
# schemas are not strict and the model can return valid JSON
240+
# that omits a required key. Feed that back as a tool error
241+
# instead of letting a KeyError end the whole trial.
237242
try:
238243
arguments = json.loads(call.function.arguments)
239-
except json.JSONDecodeError as error:
240-
result: dict[str, Any] = {"error": f"invalid arguments: {error}"}
241-
else:
242244
if call.function.name == "run_shell":
243-
result = await self._run_shell(
245+
result: dict[str, Any] = await self._run_shell(
244246
environment, arguments["command"]
245247
)
246248
elif call.function.name == "submit_answer":
@@ -249,6 +251,8 @@ async def run(
249251
submitted = True
250252
else:
251253
result = {"error": f"unknown tool: {call.function.name}"}
254+
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
255+
result = {"error": f"invalid arguments: {error}"}
252256
self._trace({"turn": turn, "tool": call.function.name, "result": result})
253257
messages.append(
254258
{

harness-engineering-bench/tau3/baseline/target/src/tau3_agent/agent.py

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import json
2121
import os
2222
import re
23+
import shlex
2324
from typing import Any, override
2425

2526
from harbor.agents.base import BaseAgent
@@ -130,8 +131,13 @@ async def _mcp_raw(
130131
encoded = base64.b64encode(
131132
json.dumps(payload, ensure_ascii=False).encode("utf-8")
132133
).decode("ascii")
134+
# Quoted for the same reason the payload above is base64-encoded: the
135+
# session id comes back from the server, and a quote in it would break
136+
# out of the surrounding single quotes into the shell.
133137
session_header = (
134-
f"-H 'mcp-session-id: {session_id}' " if session_id is not None else ""
138+
f"-H {shlex.quote(f'mcp-session-id: {session_id}')} "
139+
if session_id is not None
140+
else ""
135141
)
136142
command = (
137143
f"printf '%s' '{encoded}' | base64 -d | "
@@ -341,10 +347,15 @@ async def run(
341347
)
342348

343349
if calls:
344-
# This agent acts one tool at a time; record only the first call
345-
# so the assistant turn and its single tool reply stay balanced
346-
# (Chat Completions requires a tool message per tool_call id).
347-
call = calls[0]
350+
# Every call the model made is executed, in order. The target
351+
# model does return more than one per turn, and the usual guard
352+
# -- parallel_tool_calls: False -- is not available here:
353+
# litellm rejects it for fireworks_ai with UnsupportedParamsError.
354+
# Acting on only the first would silently skip the rest, which
355+
# for a customer-service agent means a verified identity with no
356+
# message sent. Chat Completions requires exactly one tool
357+
# message per tool_call id, so the assistant turn lists them all
358+
# and each gets its reply below.
348359
assistant: dict[str, Any] = {
349360
"role": "assistant",
350361
"tool_calls": [
@@ -356,33 +367,45 @@ async def run(
356367
"arguments": call.function.arguments,
357368
},
358369
}
370+
for call in calls
359371
],
360372
}
361373
if message.content:
362374
assistant["content"] = message.content
363375
messages.append(assistant)
364-
try:
365-
arguments = json.loads(call.function.arguments)
366-
result_text = await self._call_tool(
367-
environment, session_id, call.function.name, arguments
376+
stop = False
377+
for call in calls:
378+
try:
379+
arguments = json.loads(call.function.arguments)
380+
result_text = await self._call_tool(
381+
environment, session_id, call.function.name, arguments
382+
)
383+
except Exception as error: # noqa: BLE001 - feed failures to model
384+
result_text = json.dumps(
385+
{"error": f"{type(error).__name__}: {error}"}
386+
)
387+
self._trace(
388+
{
389+
"turn": turn,
390+
"tool": call.function.name,
391+
"result": result_text,
392+
}
368393
)
369-
except Exception as error: # noqa: BLE001 - feed failures to model
370-
result_text = json.dumps(
371-
{"error": f"{type(error).__name__}: {error}"}
394+
messages.append(
395+
{
396+
"role": "tool",
397+
"tool_call_id": call.id,
398+
"content": result_text,
399+
}
372400
)
373-
self._trace(
374-
{"turn": turn, "tool": call.function.name, "result": result_text}
375-
)
376-
messages.append(
377-
{
378-
"role": "tool",
379-
"tool_call_id": call.id,
380-
"content": result_text,
381-
}
382-
)
383-
if call.function.name == "end_conversation" or _looks_stopped(
384-
result_text
385-
):
401+
# Note the stop but keep going: leaving a tool_call id
402+
# without a reply would make the next request invalid, and
403+
# the remaining calls were ones the model actually asked for.
404+
if call.function.name == "end_conversation" or _looks_stopped(
405+
result_text
406+
):
407+
stop = True
408+
if stop:
386409
break
387410
continue
388411

0 commit comments

Comments
 (0)