Skip to content

Commit 3d58ea7

Browse files
fix: repair five swe-bench-pro seed-agent defects that corrupt the reward
Each of these silently biases the measurement rather than failing loudly, so the optimizer would have hill-climbed against a broken baseline. 1. REPO_DIR was `/app/repo`. The swebenchpro task images set `WORKDIR /app` and reset the checkout in place there, and the task instruction says so verbatim ("I've uploaded a code repository in the directory /app"). Every shell command ran in a directory that does not exist. 2. `reasoning: {effort: high}` was sent unconditionally. Non-reasoning models reject it with a hard 400 on the very first turn, so a gpt-4o run could not complete a single trial. Gated on capability, matching the sibling agents. 3. `_write_file` joined a model-supplied path onto `logs_dir`. Models routinely answer with the absolute path they just saw in shell output, and `Path(logs_dir) / "staged" / "/app/x.py"` discards the left side entirely, so the write landed on the HOST root and killed the trial with `OSError [Errno 30] Read-only file system: '/app'`. Seen on 2 of the first 12 held-out trials. Paths are now normalized repo-relative and traversal is refused. 4. Exhausting the turn budget raised RuntimeError. Reward comes from the task's hidden suite run against whatever the agent left in the repository, so raising discarded real edits, errored the trial, made harbor re-run the whole thing (9 of the first 49 held-out trials) and scored a hard 0 for a fix that may well have been correct. It now records the exhaustion in metadata and lets the verifier grade, which is exactly what the "model stopped calling tools" branch already did. 5. Harbor decodes exec output as strict UTF-8. These are real repositories, so a model that cats or greps a binary raised UnicodeDecodeError out of `exec` and killed the trial: 5 of the first 49 held-out trials. A non-decodable stream is now a tool error the model can react to, with a hint to re-run through `strings`/`file`. Verified end to end: a single-case smoke on instance_ansible__ansible-0ea40e with a gpt-4o agent completed with reward 1.0, all 16 required tests PASSED, 0 errored trials. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e14eaff commit 3d58ea7

1 file changed

Lines changed: 102 additions & 10 deletions

File tree

  • harness-engineering-bench/swe-bench-pro/baseline/target/src/swebench_pro_agent

harness-engineering-bench/swe-bench-pro/baseline/target/src/swebench_pro_agent/agent.py

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,41 @@
1717
import json
1818
import os
1919
import random
20+
from pathlib import PurePosixPath
2021
from typing import Any, override
2122

2223
from harbor.agents.base import BaseAgent
2324
from harbor.environments.base import BaseEnvironment
2425
from harbor.models.agent.context import AgentContext
2526
from openai import AsyncOpenAI
2627

28+
29+
class _BinaryOutputError(Exception):
30+
"""A shell stream could not be decoded as UTF-8."""
31+
32+
33+
def _is_reasoning_model(model: str) -> bool:
34+
"""Whether `model` is an OpenAI reasoning model.
35+
36+
Capability, not provider: Azure gpt-4o is not Fireworks yet still rejects
37+
`reasoning`, and every gpt-5 model accepts it. Fireworks-served open models
38+
match none of these prefixes, so they keep the legacy shape.
39+
"""
40+
name = model.lower()
41+
return name.startswith(("gpt-5", "o1", "o3", "o4")) or "codex" in name
42+
43+
2744
# SWE-bench-Pro tasks build a real repository and run its test suite, so the agent
2845
# needs more turns than a short-answer benchmark like GAIA.
2946
MAX_TURNS = 50
3047
MAX_TOOL_OUTPUT_CHARS = 20_000
3148
MAX_FILE_READ_CHARS = 60_000
3249

33-
# Repository checkout location inside the task environment. SWE-bench-Pro task
34-
# packages check the target project out here; adjust in one place if the pinned
35-
# task source uses a different mount.
36-
REPO_DIR = "/app/repo"
50+
# Repository checkout location inside the task environment. The pinned
51+
# swebenchpro task images set `WORKDIR /app` and reset the project's git
52+
# checkout in place there, and the task instruction says so verbatim ("I've
53+
# uploaded a code repository in the directory /app").
54+
REPO_DIR = "/app"
3755

3856
# Retry policy for the Responses API. The GAIA baseline scored 0.0 in the first
3957
# VeRO run because a single transient error on ``responses.create`` crashed the
@@ -273,10 +291,36 @@ def _trace(self, event: dict[str, Any]) -> None:
273291
with trace_path.open("a", encoding="utf-8") as file:
274292
file.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
275293

294+
async def _exec(
295+
self, environment: BaseEnvironment, command: str, timeout_sec: int
296+
) -> Any:
297+
"""`environment.exec`, but a non-UTF-8 stream is a tool error, not a crash.
298+
299+
Harbor decodes the command's stdout/stderr as strict UTF-8. These are real
300+
repositories, so a model that cats or greps a binary (an image fixture, a
301+
compiled artifact) raises UnicodeDecodeError out of `exec` and kills the
302+
whole trial -- observed on 5 of the first 49 held-out trials. The task is
303+
graded on the repository state, so a bad read must degrade to a message
304+
the model can react to.
305+
"""
306+
try:
307+
return await environment.exec(
308+
command, cwd=REPO_DIR, timeout_sec=timeout_sec
309+
)
310+
except UnicodeDecodeError as error:
311+
raise _BinaryOutputError(str(error)) from error
312+
276313
async def _run_shell(
277314
self, environment: BaseEnvironment, command: str, timeout_sec: int = 300
278315
) -> dict[str, Any]:
279-
result = await environment.exec(command, cwd=REPO_DIR, timeout_sec=timeout_sec)
316+
try:
317+
result = await self._exec(environment, command, timeout_sec)
318+
except _BinaryOutputError as error:
319+
return {
320+
"error": "command produced non-UTF-8 output (binary file?): "
321+
f"{error}. Re-run it filtered through `strings`, `head -c`, or "
322+
"`file` instead."
323+
}
280324
return {
281325
"return_code": result.return_code,
282326
"stdout": self._truncate(result.stdout or ""),
@@ -290,10 +334,17 @@ async def _read_file(
290334
start_line: int | None,
291335
end_line: int | None,
292336
) -> dict[str, Any]:
337+
try:
338+
path = self._repo_relative(path)
339+
except ValueError as error:
340+
return {"error": str(error)}
293341
# `cat -A`-free read via shell so we do not need a download round-trip.
294-
result = await environment.exec(
295-
f"cat -- {json.dumps(path)}", cwd=REPO_DIR, timeout_sec=60
296-
)
342+
try:
343+
result = await self._exec(
344+
environment, f"cat -- {json.dumps(path)}", timeout_sec=60
345+
)
346+
except _BinaryOutputError as error:
347+
return {"error": f"{path} is not UTF-8 text ({error})"}
297348
if result.return_code != 0:
298349
return {"error": self._truncate(result.stderr or "could not read file")}
299350
lines = (result.stdout or "").splitlines()
@@ -306,9 +357,35 @@ async def _read_file(
306357
body = body[:MAX_FILE_READ_CHARS] + "\n...[truncated]..."
307358
return {"path": path, "start_line": lo, "end_line": hi, "content": body}
308359

360+
@staticmethod
361+
def _repo_relative(path: str) -> str:
362+
"""Normalize a model-supplied path to a safe repo-relative one.
363+
364+
The tool schema documents these as repo-relative, but models routinely
365+
answer with the absolute path they just saw in shell output. That is not
366+
merely cosmetic: ``Path(a) / "/app/x.py"`` discards ``a`` entirely, so an
367+
absolute path made ``_write_file`` stage onto the HOST root instead of
368+
under ``logs_dir`` and crashed the whole trial with
369+
``OSError [Errno 30] Read-only file system: '/app'`` (seen on 2 of the
370+
first 12 held-out trials). Strip the repo prefix, refuse traversal.
371+
"""
372+
cleaned = path.strip()
373+
if cleaned == REPO_DIR:
374+
cleaned = ""
375+
elif cleaned.startswith(f"{REPO_DIR}/"):
376+
cleaned = cleaned[len(REPO_DIR) + 1 :]
377+
cleaned = cleaned.lstrip("/")
378+
if not cleaned or ".." in PurePosixPath(cleaned).parts:
379+
raise ValueError(f"expected a repo-relative path under {REPO_DIR}")
380+
return cleaned
381+
309382
async def _write_file(
310383
self, environment: BaseEnvironment, path: str, content: str
311384
) -> dict[str, Any]:
385+
try:
386+
path = self._repo_relative(path)
387+
except ValueError as error:
388+
return {"error": str(error)}
312389
local_path = self.logs_dir / "staged" / path
313390
local_path.parent.mkdir(parents=True, exist_ok=True)
314391
local_path.write_text(content, encoding="utf-8")
@@ -382,10 +459,13 @@ async def run(
382459
"instructions": INSTRUCTIONS,
383460
"input": next_input,
384461
"tools": TOOLS,
385-
"reasoning": {"effort": "high"},
386462
"max_output_tokens": 12_000,
387463
"parallel_tool_calls": False,
388464
}
465+
# Only reasoning models accept `reasoning`; sending it to gpt-4o or
466+
# gpt-4.1 is a hard 400 on the very first turn.
467+
if _is_reasoning_model(self._api_model):
468+
request["reasoning"] = {"effort": "high"}
389469
if previous_response_id is not None:
390470
request["previous_response_id"] = previous_response_id
391471
response = await self._responses_create(**request)
@@ -465,7 +545,19 @@ async def run(
465545
break
466546
previous_response_id = response.id
467547
else:
468-
raise RuntimeError(f"SWE-bench-Pro agent exceeded {MAX_TURNS} turns")
548+
# Exhausting the turn budget is NOT a trial failure. The reward comes
549+
# from the task's hidden suite run against whatever the agent left in
550+
# the repository, exactly as in the "model stopped calling tools"
551+
# branch above. Raising here instead discarded real edits, errored the
552+
# trial, and made harbor re-run the whole thing -- 9 of the first 49
553+
# held-out trials -- and would have scored a hard 0 for a fix that may
554+
# well have been correct. Record it and let the verifier grade.
555+
self._trace({"event": "turn_budget_exhausted", "turns": MAX_TURNS})
556+
context.metadata = {
557+
"turns": MAX_TURNS,
558+
"turn_budget_exhausted": True,
559+
"trace": "swe-bench-pro-trace.jsonl",
560+
}
469561

470562
context.n_input_tokens = input_tokens
471563
context.n_output_tokens = output_tokens

0 commit comments

Comments
 (0)