forked from THUDM/slime
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathswe.py
More file actions
492 lines (415 loc) · 20.1 KB
/
Copy pathswe.py
File metadata and controls
492 lines (415 loc) · 20.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
"""SWE task layer: dataset parsing, workspace prep, diff capture, fresh-sandbox eval.
One module, two grading protocols selected per-call (never an import-time side
effect):
- "scaleswe" (default): scaleswe data shape (image_url + pre_commands +
swepro/eval_cmd/f2p_script); custom "exit 0 == solved" grading.
- "swebench": SWE-bench Verified (remote_env_info.{image,base_commit,
test_patch,FAIL_TO_PASS,PASS_TO_PASS,version}); graded with swebench's
official make_test_spec + get_eval_report so each repo uses its own
test_cmd and log parser.
The only thing that varies by protocol is the dataset schema and how a
diff is scored. Everything sandbox-side (prepare_workspace / git_diff /
apply_diff / pre_commands) is shared and lives here once.
``get_metadata(sample, protocol)`` produces the ``md`` dict; the
protocol-specific grading payload is carried under ``md["grading"]``
and is opaque to generate.py (which only reads instance_id / image / workdir).
Harness-agnostic on purpose -- nothing here is Claude-specific. ``SWE_PROMPT`` is
the task instruction (semantics, not CLI syntax). The only place a task meets a
harness is the prompt, which the orchestrator passes into ``harness.run()``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any, NamedTuple
from slime.agent import sandbox as agent_sandbox
from slime.agent.adapters.common import flatten_content
from slime.agent.sandbox import E2BSandbox, Sandbox, exec_and_wait
from slime.utils.types import Sample
try:
from swebench.harness.grading import get_eval_report # type: ignore
from swebench.harness.test_spec.test_spec import make_test_spec # type: ignore
_SWEBENCH_IMPORT_ERROR: Exception | None = None
except Exception as _exc: # pragma: no cover - import-time diagnostic
get_eval_report = None # type: ignore
make_test_spec = None # type: ignore
_SWEBENCH_IMPORT_ERROR = _exc
logger = logging.getLogger(__name__)
PROTOCOL_SCALESWE = "scaleswe"
PROTOCOL_SWEBENCH = "swebench"
# Paths inside the sandbox (avoid clashes with image-shipped paths).
_PATCH = "/workspace/__cagent_patch__.diff"
_PRE = "/workspace/__cagent_pre__.sh"
_F2P = "/workspace/__cagent_f2p__.py"
_SWEPRO_DIR = "/workspace/swepro_eval"
SWE_PROMPT = os.environ.get(
"SWE_CC_PROMPT",
"Read PROBLEM_STATEMENT.md in the current directory and resolve the issue. "
"Edit source files only (do NOT touch tests). After editing, run the relevant "
"tests to verify your fix passes. Do NOT modify PROBLEM_STATEMENT.md and do "
"NOT commit. When finished, print a one-line summary and exit.",
)
class EvalResult(NamedTuple):
"""Grading outcome. Tuple-compatible: ``reward, applied = run_evaluation(...)``."""
reward: float
applied_cleanly: bool
def get_metadata(sample: Sample, protocol: str = PROTOCOL_SCALESWE) -> dict[str, Any]:
if protocol == PROTOCOL_SWEBENCH:
return _metadata_swebench(sample)
return _metadata_scaleswe(sample)
def _metadata_scaleswe(sample: Sample) -> dict[str, Any]:
"""scaleswe shape: flat ``metadata.*`` (+ a few ``remote_env_info`` fallbacks).
``f2p_script`` (a self-contained pytest file ending in
``sys.exit(pytest.main(...))``) is carried verbatim; the grader materializes
and runs it via ``write_file`` so no shell-quoting workaround is needed here.
"""
m = sample.metadata or {}
rem = m.get("remote_env_info") or {}
label = sample.label if (isinstance(sample.label, str) and len(sample.label) < 256) else None
swepro = m.get("swepro")
eval_cmd = m.get("eval_cmd")
f2p_script = rem.get("f2p_script")
looks_swebench = bool(rem.get("test_patch")) and not (swepro or eval_cmd or f2p_script)
return {
"protocol": PROTOCOL_SCALESWE,
"instance_id": m.get("instance_id") or rem.get("instance_id") or label or "unknown",
"image": m.get("image") or rem.get("image_url"),
"workdir": m.get("workdir") or rem.get("workdir"),
"problem_statement": m.get("problem_statement") or _coerce_prompt(sample.prompt),
"looks_swebench": looks_swebench,
"grading": {
"swepro": swepro,
"eval_cmd": eval_cmd,
"f2p_script": f2p_script,
"pre_commands": m.get("pre_commands") or rem.get("pre_commands"),
},
}
def _metadata_swebench(sample: Sample) -> dict[str, Any]:
"""SWE-bench Verified shape: carry the full instance dict through so
make_test_spec gets every field it needs (version, hints_text, ...)."""
m = sample.metadata or {}
rem = m.get("remote_env_info") or {}
instance = {
"instance_id": rem.get("instance_id") or "unknown",
"repo": rem.get("repo") or "",
"version": rem.get("version"),
"base_commit": rem.get("base_commit") or "",
"problem_statement": rem.get("problem_statement") or _coerce_prompt(sample.prompt),
"hints_text": rem.get("hints_text") or "",
"test_patch": rem.get("test_patch") or "",
"FAIL_TO_PASS": rem.get("FAIL_TO_PASS"),
"PASS_TO_PASS": rem.get("PASS_TO_PASS"),
"environment_setup_commit": rem.get("environment_setup_commit"),
}
return {
"protocol": PROTOCOL_SWEBENCH,
"instance_id": instance["instance_id"],
"image": rem.get("image"),
"workdir": rem.get("workdir") or "/testbed",
"problem_statement": instance["problem_statement"],
"grading": {"sweb_instance": instance},
}
def _coerce_prompt(prompt) -> str:
"""Extract the user-message text from a prompt (str or chat-message list)."""
if isinstance(prompt, str):
return prompt
if isinstance(prompt, list):
for m in prompt:
if isinstance(m, dict) and m.get("role") == "user":
return flatten_content(m.get("content"))
return ""
def evaluability_check(md: dict) -> str | None:
if md.get("protocol") == PROTOCOL_SWEBENCH:
return _evaluability_check_swebench(md)
return "protocol_row_mismatch:looks_swebench" if md.get("looks_swebench") else None
def _evaluability_check_swebench(md: dict) -> str | None:
if _SWEBENCH_IMPORT_ERROR is not None:
return f"swebench_import_failed:{type(_SWEBENCH_IMPORT_ERROR).__name__}"
inst = md.get("grading", {}).get("sweb_instance") or {}
if not inst.get("repo"):
return "missing_repo"
if not inst.get("base_commit"):
return "missing_base_commit"
if not (inst.get("test_patch") or "").strip():
return "missing_test_patch"
try:
_ = _build_test_spec(inst).eval_script # surfaces per-repo construction errors here, not later
except Exception as e: # KeyError on unknown repo/version, etc.
return f"make_test_spec_failed:{type(e).__name__}"
return None
# ---------------------------------------------------------------------------
# Workspace prep (agent sandbox, before harness.run)
# ---------------------------------------------------------------------------
async def prepare_workspace(sb: Sandbox, workdir: str, md: dict) -> None:
"""Prep the agent sandbox, then drop PROBLEM_STATEMENT.md.
Assumes the agent user already owns ``workdir`` (the harness's ``run()`` calls
``ensure_agent_user``; the orchestrator runs this before ``run()`` and the
agent user is created lazily there). To stay independent of call order we
create the agent user here too -- it is idempotent.
"""
await agent_sandbox.ensure_agent_user(sb, workdir)
if md.get("protocol") == PROTOCOL_SCALESWE:
grading = md.get("grading") or {}
swepro = grading.get("swepro")
if swepro:
await apply_before_repo_set_cmd(sb, workdir, swepro)
pre_commands = grading.get("pre_commands")
if pre_commands:
await apply_pre_commands(sb, workdir, pre_commands)
await sb.write_file(
f"{workdir}/PROBLEM_STATEMENT.md",
md.get("problem_statement") or "",
user="agent",
)
async def apply_before_repo_set_cmd(sb: Sandbox, workdir: str, swepro: dict) -> None:
"""Run swepro['before_repo_set_cmd'] in the sandbox if present (no-op if not)."""
before = swepro.get("before_repo_set_cmd")
if not before:
return
payload = f"set -e\ncd {workdir}\n{before}\n"
await sb.exec(
"mkdir -p /workspace/swepro_setup && chown agent:agent /workspace/swepro_setup", user="root", check=True
)
await sb.write_file("/workspace/swepro_setup/before.sh", payload, user="agent")
await sb.exec("bash /workspace/swepro_setup/before.sh", user="agent", check=False, timeout=600)
async def apply_pre_commands(sb: Sandbox, workdir: str, pre: list[str] | str) -> None:
# Public: also called for the work sandbox to keep its baseline aligned with
# eval (sweb-style pre_commands typically `git checkout <base_sha> -f`, so
# skipping in the work sandbox makes the model's diff context mismatch the
# eval base -> 100% apply failure).
if isinstance(pre, str):
body = pre.replace("\\n", "\n")
else:
body = "\n".join(c for c in (pre or []) if c)
await sb.write_file(_PRE, "set -e\n" + body, user="agent")
await sb.exec(f"chmod 755 {_PRE} && cd {workdir} && bash {_PRE}", user="agent", check=False, timeout=600)
# ---------------------------------------------------------------------------
# Diff capture (agent sandbox, after harness.run)
# ---------------------------------------------------------------------------
async def git_diff(sb: Sandbox, workdir: str) -> str:
cmd = f"cd {workdir} && git add -N . && git diff -- . ':(exclude)PROBLEM_STATEMENT.md' ':(exclude).harness/'"
_, out, _ = await sb.exec(cmd, user="agent", timeout=120)
return out
# ---------------------------------------------------------------------------
# Eval dispatch (fresh sandbox, apply diff, run dataset tests)
# ---------------------------------------------------------------------------
async def run_evaluation(md: dict, *, diff_text: str, timeout_sec: int) -> EvalResult:
"""Uniform entry point: dispatch to the protocol's grader.
No-test-cheating guarantee (both grading protocols): the eval sandbox is built from
the same image but starts CLEAN, so only the model-produced diff affects
reward."""
if md.get("protocol") == PROTOCOL_SWEBENCH:
return await _grade_swebench(md, diff_text, timeout_sec)
return await _grade_scaleswe(md, diff_text, timeout_sec)
# ---------------------------------------------------------------------------
# scaleswe grader
# ---------------------------------------------------------------------------
async def _grade_scaleswe(md: dict, diff_text: str, timeout_sec: int) -> EvalResult:
"""Three mutually-exclusive grading paths, in priority order: swepro test
harness, a shell ``eval_cmd``, or a self-contained ``f2p_script`` pytest
file. All resolve to "exit 0 == solved", reward is 1.0 iff solved."""
image = md["image"]
workdir = md["workdir"]
grading = md.get("grading") or {}
swepro = grading.get("swepro")
eval_cmd = grading.get("eval_cmd")
f2p_script = grading.get("f2p_script")
pre_commands = grading.get("pre_commands")
if not (swepro or eval_cmd or f2p_script):
logger.warning("[swe.scaleswe] no swepro/eval_cmd/f2p_script; reward=0")
return EvalResult(0.0, True)
async with E2BSandbox(image) as ev:
await agent_sandbox.ensure_agent_user(ev, workdir)
if swepro:
await _setup_swepro_assets(ev, swepro)
await apply_before_repo_set_cmd(ev, workdir, swepro)
if pre_commands:
await apply_pre_commands(ev, workdir, pre_commands)
applied = await _apply_diff(ev, workdir, diff_text)
if not applied:
return EvalResult(0.0, False)
if swepro:
r = await _run_swepro(ev, workdir, swepro, timeout_sec)
elif eval_cmd:
r = await _run_eval_cmd(ev, workdir, eval_cmd, timeout_sec)
else:
r = await _run_f2p_script(ev, workdir, f2p_script, timeout_sec)
return EvalResult(r, True)
async def _setup_swepro_assets(ev: Sandbox, swepro: dict) -> None:
await ev.exec(f"mkdir -p {_SWEPRO_DIR} && chmod 777 {_SWEPRO_DIR}", user="root", check=True)
for k, dst in [("run_script_path", "run_script.sh"), ("parser_script_path", "parser.py")]:
host_p = swepro.get(k)
if host_p:
await ev.write_file(f"{_SWEPRO_DIR}/{dst}", Path(host_p), user="root")
await ev.exec(f"chmod 755 {_SWEPRO_DIR}/* && chown -R agent:agent {_SWEPRO_DIR}", user="root", check=True)
async def _apply_diff(ev: Sandbox, workdir: str, diff_text: str) -> bool:
if not diff_text.strip():
return True
await ev.write_file(_PATCH, diff_text, user="agent")
# First-success-wins ladder collapsed into one exec (one sandbox round-trip).
ladder = " || ".join(
f"({cmd})"
for cmd in (
f"git apply --3way --whitespace=nowarn {_PATCH}",
f"git apply --whitespace=nowarn {_PATCH}",
f"patch -p1 --no-backup-if-mismatch < {_PATCH}",
)
)
ec, _, _ = await ev.exec(f"cd {workdir} && ({ladder})", user="agent", check=False, timeout=120)
return ec == 0
async def _run_swepro(ev: Sandbox, workdir: str, swepro: dict, timeout: int) -> float:
test_arg = ",".join(swepro.get("selected_test_files") or [])
stdout_f = f"{_SWEPRO_DIR}/stdout.log"
stderr_f = f"{_SWEPRO_DIR}/stderr.log"
result_f = f"{_SWEPRO_DIR}/result.json"
await ev.exec(
f"cd {workdir} && bash {_SWEPRO_DIR}/run_script.sh {json.dumps(test_arg)} > {stdout_f} 2> {stderr_f} || true",
user="agent",
check=False,
timeout=timeout,
)
await ev.exec(
f"python3 {_SWEPRO_DIR}/parser.py {stdout_f} {stderr_f} {result_f}",
user="agent",
check=False,
timeout=120,
)
raw = await ev.read_file(result_f, user="agent")
parsed = json.loads(raw) if raw else {"tests": []}
passed = {t["name"] for t in parsed.get("tests", []) if t.get("status") == "PASSED"}
required = set(swepro.get("fail_to_pass") or []) | set(swepro.get("pass_to_pass") or [])
solved = bool(required) and required.issubset(passed)
return 1.0 if solved else 0.0
async def _run_eval_cmd(ev: Sandbox, workdir: str, cmd: str, timeout: int) -> float:
ec, _, _ = await ev.exec(f"cd {workdir} && {cmd}", user="agent", check=False, timeout=timeout)
return 1.0 if ec == 0 else 0.0
async def _run_f2p_script(ev: Sandbox, workdir: str, script: str, timeout: int) -> float:
# sweb f2p_script is a self-contained pytest file ending in
# `sys.exit(pytest.main([...]))`; write it verbatim (no shell quoting) and
# let python's exit code carry the pass/fail signal.
await ev.write_file(_F2P, script, user="agent")
ec, _, _ = await ev.exec(f"cd {workdir} && python {_F2P}", user="agent", check=False, timeout=timeout)
return 1.0 if ec == 0 else 0.0
# Mirror of swebench.harness.run_evaluation.GIT_APPLY_CMDS: try each in order,
# first success wins. The `patch --fuzz` tier rescues diffs `git apply` rejects.
_GIT_APPLY_CMDS = (
"git apply --verbose",
"git apply --verbose --reject",
"patch --batch --fuzz=5 -p1 -i",
)
async def _apply_model_patch(ev: Sandbox, workdir: str) -> bool:
"""Apply /tmp/patch.diff via the GIT_APPLY_CMDS ladder; True if applied
(or empty). Empty patch is a no-op success -- eval then scores it 0 on its
own (no source change -> tests still fail)."""
ladder = " || ".join(f"{cmd} /tmp/patch.diff" for cmd in _GIT_APPLY_CMDS)
cmd = (
f"cd {workdir} && git config --global --add safe.directory {workdir} "
f"&& if [ -s /tmp/patch.diff ]; then {ladder}; fi"
)
ec, _, _ = await ev.exec(cmd, user="root", check=False, timeout=120)
return ec == 0
def _build_test_spec(inst: dict):
"""make_test_spec(inst). Shared by evaluability_check and the grader; may
raise (KeyError on unknown repo/version)."""
return make_test_spec(inst) # type: ignore[misc]
def _eval_report_from_log(ts, instance_id: str, diff_text: str, log: str) -> dict:
"""Run swebench's get_eval_report against the captured test log. It reads
from a file path, so write the log to a tempfile, parse, and clean up."""
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False)
try:
tmp.write(log)
tmp.flush()
tmp.close()
prediction = {
"instance_id": instance_id,
"model_patch": diff_text or "",
"model_name_or_path": "swe",
}
return get_eval_report( # type: ignore[misc]
test_spec=ts,
prediction=prediction,
test_log_path=tmp.name,
include_tests_status=True,
)
finally:
try:
os.unlink(tmp.name)
except OSError:
pass
def _ratio(d: dict) -> tuple[int, int]:
"""(passed, total) from a {success: [...], failure: [...]} bucket."""
passed, failed = d.get("success", []), d.get("failure", [])
return len(passed), len(passed) + len(failed)
def _log_swebench_result(instance_id: str, exit_code, info: dict, log: str) -> None:
"""Emit the per-instance grading outcome with test-bucket ratios; on a
non-resolved row that parsed NO test lines, surface the log tail so failures
(missing pytest plugin, conda not activated, ...) can be diagnosed."""
if info.get("resolved"):
logger.info("[swe.swebench] %s: reward=1 exit_code=%s", instance_id, exit_code)
return
ts_status = info.get("tests_status") or {}
f2p_pass, f2p_total = _ratio(ts_status.get("FAIL_TO_PASS", {}))
p2p_pass, p2p_total = _ratio(ts_status.get("PASS_TO_PASS", {}))
nothing_parsed = not (f2p_total or p2p_total)
tail = log[-800:] if nothing_parsed else ""
logger.info(
"[swe.swebench] %s: reward=0 exit_code=%s patch_applied=%s F2P=(%d/%d) P2P=(%d/%d)%s",
instance_id,
exit_code,
bool(info.get("patch_successfully_applied")),
f2p_pass,
f2p_total,
p2p_pass,
p2p_total,
f" tail={tail!r}" if tail else "",
)
async def _grade_swebench(md: dict, diff_text: str, timeout_sec: int) -> EvalResult:
"""reward=1.0 iff sweb's get_eval_report declares the instance ``resolved``."""
instance_id = md["instance_id"]
inst = md["grading"]["sweb_instance"]
if _SWEBENCH_IMPORT_ERROR is not None:
logger.error(
"[swe.swebench] %s: swebench import failed: %r; reward=0",
instance_id,
_SWEBENCH_IMPORT_ERROR,
)
return EvalResult(0.0, True)
try:
ts = _build_test_spec(inst)
eval_sh = ts.eval_script # may raise on unknown repo/version
except Exception as e:
logger.warning("[swe.swebench] %s: make_test_spec/eval_script failed: %s; reward=0", instance_id, e)
return EvalResult(0.0, True)
image = md["image"]
if not image:
logger.warning("[swe.swebench] %s: missing image; reward=0", instance_id)
return EvalResult(0.0, True)
async with E2BSandbox(image) as ev:
await asyncio.gather(
ev.write_file("/tmp/patch.diff", diff_text or "", user="root"),
ev.write_file("/tmp/eval.sh", eval_sh, user="root"),
)
# Apply the model patch first (eval_script assumes it is already applied);
# if no apply strategy works, the instance is unsolvable -- skip the eval.
if not await _apply_model_patch(ev, md["workdir"]):
logger.warning("[swe.swebench] %s: model patch failed to apply; reward=0", instance_id)
return EvalResult(0.0, False)
exit_code, log = await exec_and_wait(
ev, cmd="bash /tmp/eval.sh", user="root", time_budget_sec=timeout_sec, tag="eval", want_output=True
)
try:
report = _eval_report_from_log(ts, instance_id, diff_text, log)
except Exception as e:
logger.warning(
"[swe.swebench] %s: get_eval_report failed: %s; reward=0 (tail=%r)",
instance_id,
e,
log[-600:],
)
return EvalResult(0.0, True)
info = report.get(instance_id, {})
_log_swebench_result(instance_id, exit_code, info, log)
return EvalResult(1.0 if info.get("resolved") else 0.0, bool(info.get("patch_successfully_applied")))