Add deepagents_rl package sources
Browse files- src/deepagents_rl/data/__init__.py +9 -1
- src/deepagents_rl/data/__pycache__/__init__.cpython-312.pyc +0 -0
- src/deepagents_rl/data/__pycache__/swe_bench_lite.cpython-312.pyc +0 -0
- src/deepagents_rl/data/swe_bench_lite.py +179 -0
- src/deepagents_rl/harness/__init__.py +7 -0
- src/deepagents_rl/harness/__pycache__/__init__.cpython-312.pyc +0 -0
- src/deepagents_rl/harness/__pycache__/episode.cpython-312.pyc +0 -0
- src/deepagents_rl/harness/__pycache__/grade.cpython-312.pyc +0 -0
- src/deepagents_rl/harness/__pycache__/repo.cpython-312.pyc +0 -0
- src/deepagents_rl/harness/__pycache__/swe_docker_eval.cpython-312.pyc +0 -0
- src/deepagents_rl/harness/episode.py +863 -0
- src/deepagents_rl/harness/grade.py +91 -0
- src/deepagents_rl/harness/repo.py +128 -0
- src/deepagents_rl/harness/swe_docker_eval.py +465 -0
- src/deepagents_rl/observability/__init__.py +13 -1
- src/deepagents_rl/observability/__pycache__/__init__.cpython-312.pyc +0 -0
- src/deepagents_rl/observability/__pycache__/langfuse_eval.cpython-312.pyc +0 -0
- src/deepagents_rl/observability/__pycache__/langfuse_trace.cpython-312.pyc +0 -0
- src/deepagents_rl/observability/__pycache__/logging.cpython-312.pyc +0 -0
- src/deepagents_rl/observability/langfuse_trace.py +31 -0
- src/deepagents_rl/observability/logging.py +180 -0
- src/deepagents_rl/train/__init__.py +7 -1
- src/deepagents_rl/train/swe_env.py +338 -0
src/deepagents_rl/data/__init__.py
CHANGED
|
@@ -1,3 +1,11 @@
|
|
| 1 |
from .mbpp import build_train_eval, load_mbpp_sanitized, save_jsonl, to_hf_dataset
|
|
|
|
| 2 |
|
| 3 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from .mbpp import build_train_eval, load_mbpp_sanitized, save_jsonl, to_hf_dataset
|
| 2 |
+
from .swe_bench_lite import from_hf_record, load_swe_bench_lite
|
| 3 |
|
| 4 |
+
__all__ = [
|
| 5 |
+
"build_train_eval",
|
| 6 |
+
"load_mbpp_sanitized",
|
| 7 |
+
"save_jsonl",
|
| 8 |
+
"to_hf_dataset",
|
| 9 |
+
"load_swe_bench_lite",
|
| 10 |
+
"from_hf_record",
|
| 11 |
+
]
|
src/deepagents_rl/data/__pycache__/__init__.cpython-312.pyc
CHANGED
|
Binary files a/src/deepagents_rl/data/__pycache__/__init__.cpython-312.pyc and b/src/deepagents_rl/data/__pycache__/__init__.cpython-312.pyc differ
|
|
|
src/deepagents_rl/data/__pycache__/swe_bench_lite.cpython-312.pyc
ADDED
|
Binary file (9.45 kB). View file
|
|
|
src/deepagents_rl/data/swe_bench_lite.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SWE-bench Lite loader for deepagents rollouts + official Docker eval.
|
| 2 |
+
|
| 3 |
+
Source: https://huggingface.co/datasets/SWE-bench/SWE-bench_Lite
|
| 4 |
+
|
| 5 |
+
Each row includes the prebuilt instance `image` and `eval_script` used by the
|
| 6 |
+
SWE-bench harness. Agent sandboxes are full git checkouts at `base_commit`
|
| 7 |
+
(see `deepagents_rl.harness.repo`), not sparse file dumps.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
DIFF_PATH_RE = re.compile(r"^diff --git a/(.+?) b/(.+)$", re.MULTILINE)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_paths_from_patch(patch: str) -> list[str]:
|
| 19 |
+
paths: list[str] = []
|
| 20 |
+
for m in DIFF_PATH_RE.finditer(patch or ""):
|
| 21 |
+
p = m.group(2).strip()
|
| 22 |
+
if p and p != "/dev/null" and p not in paths:
|
| 23 |
+
paths.append(p)
|
| 24 |
+
return paths
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _as_str_list(val: Any) -> list[str]:
|
| 28 |
+
if val is None:
|
| 29 |
+
return []
|
| 30 |
+
if isinstance(val, list):
|
| 31 |
+
return [str(x) for x in val]
|
| 32 |
+
if isinstance(val, str):
|
| 33 |
+
s = val.strip()
|
| 34 |
+
if not s:
|
| 35 |
+
return []
|
| 36 |
+
try:
|
| 37 |
+
parsed = json.loads(s)
|
| 38 |
+
if isinstance(parsed, list):
|
| 39 |
+
return [str(x) for x in parsed]
|
| 40 |
+
except json.JSONDecodeError:
|
| 41 |
+
pass
|
| 42 |
+
return [s]
|
| 43 |
+
return [str(val)]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def build_agent_prompt(problem_statement: str, *, instance_id: str) -> str:
|
| 47 |
+
return (
|
| 48 |
+
"You are a software engineering agent inside a full git checkout of the repo.\n"
|
| 49 |
+
"Resolve the GitHub issue by exploring and editing files with your tools "
|
| 50 |
+
"(ls, glob, grep, read_file, write_file, edit_file, execute).\n"
|
| 51 |
+
"Prefer minimal correct edits. Do not modify tests unless necessary.\n"
|
| 52 |
+
"When finished, stop calling tools and reply with DONE plus a short summary.\n\n"
|
| 53 |
+
f"Instance: {instance_id}\n\n"
|
| 54 |
+
f"Issue:\n{problem_statement.strip()}\n"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _row_to_task(ex: dict[str, Any], split: str) -> dict[str, Any]:
|
| 59 |
+
patch = ex.get("patch") or ""
|
| 60 |
+
test_patch = ex.get("test_patch") or ""
|
| 61 |
+
instance_id = str(ex["instance_id"])
|
| 62 |
+
problem_statement = str(ex.get("problem_statement") or "")
|
| 63 |
+
return {
|
| 64 |
+
"task_id": instance_id,
|
| 65 |
+
"instance_id": instance_id,
|
| 66 |
+
"split": split,
|
| 67 |
+
"repo": str(ex["repo"]),
|
| 68 |
+
"base_commit": str(ex["base_commit"]),
|
| 69 |
+
"problem_statement": problem_statement,
|
| 70 |
+
"prompt": build_agent_prompt(problem_statement, instance_id=instance_id),
|
| 71 |
+
"workspace_paths": parse_paths_from_patch(patch),
|
| 72 |
+
"gold_patch": patch,
|
| 73 |
+
"test_patch": test_patch,
|
| 74 |
+
"fail_to_pass": _as_str_list(ex.get("FAIL_TO_PASS")),
|
| 75 |
+
"pass_to_pass": _as_str_list(ex.get("PASS_TO_PASS")),
|
| 76 |
+
"hints_text": str(ex.get("hints_text") or ""),
|
| 77 |
+
"version": str(ex.get("version") or ""),
|
| 78 |
+
"difficulty": str(ex.get("difficulty") or ""),
|
| 79 |
+
"image": str(ex.get("image") or ""),
|
| 80 |
+
"eval_script": str(ex.get("eval_script") or ""),
|
| 81 |
+
"eval_type": str(ex.get("eval_type") or "pass_and_fail"),
|
| 82 |
+
"log_parser": str(ex.get("log_parser") or ""),
|
| 83 |
+
"environment_setup_commit": str(ex.get("environment_setup_commit") or ""),
|
| 84 |
+
"source_dataset": "SWE-bench/SWE-bench_Lite",
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def load_swe_bench_lite(
|
| 89 |
+
*,
|
| 90 |
+
splits: tuple[str, ...] = ("dev", "test"),
|
| 91 |
+
max_per_split: int | None = None,
|
| 92 |
+
token: str | None = None,
|
| 93 |
+
) -> dict[str, list[dict[str, Any]]]:
|
| 94 |
+
from datasets import load_dataset
|
| 95 |
+
|
| 96 |
+
kwargs: dict[str, Any] = {}
|
| 97 |
+
if token:
|
| 98 |
+
kwargs["token"] = token
|
| 99 |
+
ds = load_dataset("SWE-bench/SWE-bench_Lite", **kwargs)
|
| 100 |
+
out: dict[str, list[dict[str, Any]]] = {}
|
| 101 |
+
for split in splits:
|
| 102 |
+
if split not in ds:
|
| 103 |
+
continue
|
| 104 |
+
rows = []
|
| 105 |
+
for i, ex in enumerate(ds[split]):
|
| 106 |
+
if max_per_split is not None and i >= max_per_split:
|
| 107 |
+
break
|
| 108 |
+
rows.append(_row_to_task(dict(ex), split=split))
|
| 109 |
+
out[split] = rows
|
| 110 |
+
return out
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def to_hf_records(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 114 |
+
records = []
|
| 115 |
+
for t in tasks:
|
| 116 |
+
records.append(
|
| 117 |
+
{
|
| 118 |
+
"task_id": t["task_id"],
|
| 119 |
+
"instance_id": t["instance_id"],
|
| 120 |
+
"split": t["split"],
|
| 121 |
+
"repo": t["repo"],
|
| 122 |
+
"base_commit": t["base_commit"],
|
| 123 |
+
"problem_statement": t["problem_statement"],
|
| 124 |
+
"prompt": t["prompt"],
|
| 125 |
+
"workspace_paths_json": json.dumps(t["workspace_paths"]),
|
| 126 |
+
"gold_patch": t["gold_patch"],
|
| 127 |
+
"test_patch": t["test_patch"],
|
| 128 |
+
"fail_to_pass_json": json.dumps(t["fail_to_pass"]),
|
| 129 |
+
"pass_to_pass_json": json.dumps(t["pass_to_pass"]),
|
| 130 |
+
"hints_text": t.get("hints_text") or "",
|
| 131 |
+
"version": t.get("version") or "",
|
| 132 |
+
"difficulty": t.get("difficulty") or "",
|
| 133 |
+
"image": t.get("image") or "",
|
| 134 |
+
"eval_script": t.get("eval_script") or "",
|
| 135 |
+
"eval_type": t.get("eval_type") or "pass_and_fail",
|
| 136 |
+
"log_parser": t.get("log_parser") or "",
|
| 137 |
+
"environment_setup_commit": t.get("environment_setup_commit") or "",
|
| 138 |
+
"source_dataset": t.get("source_dataset") or "SWE-bench/SWE-bench_Lite",
|
| 139 |
+
}
|
| 140 |
+
)
|
| 141 |
+
return records
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def from_hf_record(rec: dict[str, Any]) -> dict[str, Any]:
|
| 145 |
+
# Backward compat: older sparse dataset rows may lack image/eval_script —
|
| 146 |
+
# callers should reload from SWE-bench/SWE-bench_Lite in that case.
|
| 147 |
+
return {
|
| 148 |
+
"task_id": rec["task_id"],
|
| 149 |
+
"instance_id": rec["instance_id"],
|
| 150 |
+
"split": rec.get("split") or "train",
|
| 151 |
+
"repo": rec["repo"],
|
| 152 |
+
"base_commit": rec["base_commit"],
|
| 153 |
+
"problem_statement": rec["problem_statement"],
|
| 154 |
+
"prompt": rec["prompt"],
|
| 155 |
+
"workspace_paths": json.loads(rec.get("workspace_paths_json") or "[]"),
|
| 156 |
+
"gold_patch": rec.get("gold_patch") or "",
|
| 157 |
+
"test_patch": rec.get("test_patch") or "",
|
| 158 |
+
"fail_to_pass": json.loads(rec.get("fail_to_pass_json") or "[]"),
|
| 159 |
+
"pass_to_pass": json.loads(rec.get("pass_to_pass_json") or "[]"),
|
| 160 |
+
"hints_text": rec.get("hints_text") or "",
|
| 161 |
+
"version": rec.get("version") or "",
|
| 162 |
+
"difficulty": rec.get("difficulty") or "",
|
| 163 |
+
"image": rec.get("image") or "",
|
| 164 |
+
"eval_script": rec.get("eval_script") or "",
|
| 165 |
+
"eval_type": rec.get("eval_type") or "pass_and_fail",
|
| 166 |
+
"log_parser": rec.get("log_parser") or "",
|
| 167 |
+
"environment_setup_commit": rec.get("environment_setup_commit") or "",
|
| 168 |
+
"source_dataset": rec.get("source_dataset") or "SWE-bench/SWE-bench_Lite",
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def save_jsonl(tasks: list[dict[str, Any]], path) -> None:
|
| 173 |
+
from pathlib import Path
|
| 174 |
+
|
| 175 |
+
path = Path(path)
|
| 176 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 177 |
+
with path.open("w", encoding="utf-8") as f:
|
| 178 |
+
for t in to_hf_records(tasks):
|
| 179 |
+
f.write(json.dumps(t) + "\n")
|
src/deepagents_rl/harness/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deepagents harness: multi-turn tool episodes + official SWE-bench Docker grading."""
|
| 2 |
+
|
| 3 |
+
from deepagents_rl.harness.episode import run_deepagent_episode
|
| 4 |
+
from deepagents_rl.harness.grade import grade_episode
|
| 5 |
+
from deepagents_rl.harness.swe_docker_eval import evaluate_model_patch
|
| 6 |
+
|
| 7 |
+
__all__ = ["run_deepagent_episode", "grade_episode", "evaluate_model_patch"]
|
src/deepagents_rl/harness/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (538 Bytes). View file
|
|
|
src/deepagents_rl/harness/__pycache__/episode.cpython-312.pyc
ADDED
|
Binary file (34 kB). View file
|
|
|
src/deepagents_rl/harness/__pycache__/grade.cpython-312.pyc
ADDED
|
Binary file (4.18 kB). View file
|
|
|
src/deepagents_rl/harness/__pycache__/repo.cpython-312.pyc
ADDED
|
Binary file (6.53 kB). View file
|
|
|
src/deepagents_rl/harness/__pycache__/swe_docker_eval.cpython-312.pyc
ADDED
|
Binary file (21.4 kB). View file
|
|
|
src/deepagents_rl/harness/episode.py
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run one deepagents episode on a long-horizon coding task and record everything."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import tempfile
|
| 7 |
+
import time
|
| 8 |
+
import uuid
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from deepagents_rl.harness.grade import grade_episode
|
| 14 |
+
from deepagents_rl.harness.repo import materialize_repo_at_commit
|
| 15 |
+
from deepagents_rl.observability.logging import (
|
| 16 |
+
call,
|
| 17 |
+
error as log_error,
|
| 18 |
+
result,
|
| 19 |
+
span,
|
| 20 |
+
step,
|
| 21 |
+
truncate,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
SYSTEM_PROMPT = (
|
| 26 |
+
"You are a software engineering agent with tools.\n"
|
| 27 |
+
"You MUST use tools to inspect and edit files. Never answer with only prose.\n"
|
| 28 |
+
"First action: call ls or glob to see the sandbox.\n"
|
| 29 |
+
"Then read_file the relevant source, edit_file/write_file to fix the bug, "
|
| 30 |
+
"optionally execute tests.\n"
|
| 31 |
+
"When finished, stop calling tools and reply DONE plus a short summary.\n"
|
| 32 |
+
"Do not delete unrelated files."
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _utc_now() -> str:
|
| 37 |
+
return datetime.now(timezone.utc).isoformat()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _msg_to_dict(msg: Any) -> dict[str, Any]:
|
| 41 |
+
role = getattr(msg, "type", None) or getattr(msg, "role", None) or msg.__class__.__name__
|
| 42 |
+
content = getattr(msg, "content", None)
|
| 43 |
+
if not isinstance(content, str):
|
| 44 |
+
content = str(content) if content is not None else ""
|
| 45 |
+
out: dict[str, Any] = {"role": role, "content": content[:8000]}
|
| 46 |
+
tool_calls = getattr(msg, "tool_calls", None)
|
| 47 |
+
if tool_calls:
|
| 48 |
+
out["tool_calls"] = tool_calls
|
| 49 |
+
name = getattr(msg, "name", None)
|
| 50 |
+
if name:
|
| 51 |
+
out["name"] = name
|
| 52 |
+
tool_call_id = getattr(msg, "tool_call_id", None)
|
| 53 |
+
if tool_call_id:
|
| 54 |
+
out["tool_call_id"] = tool_call_id
|
| 55 |
+
return out
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _extract_tool_calls(messages: list[Any]) -> tuple[list[str], list[dict[str, Any]]]:
|
| 59 |
+
tools_used: list[str] = []
|
| 60 |
+
tool_calls_log: list[dict[str, Any]] = []
|
| 61 |
+
pending: dict[str, dict[str, Any]] = {}
|
| 62 |
+
turn = 0
|
| 63 |
+
for msg in messages:
|
| 64 |
+
tname = msg.__class__.__name__
|
| 65 |
+
if tname == "AIMessage" and getattr(msg, "tool_calls", None):
|
| 66 |
+
turn += 1
|
| 67 |
+
for tc in msg.tool_calls:
|
| 68 |
+
name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
|
| 69 |
+
args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
|
| 70 |
+
tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)
|
| 71 |
+
if name:
|
| 72 |
+
tools_used.append(name)
|
| 73 |
+
entry = {
|
| 74 |
+
"turn": turn,
|
| 75 |
+
"name": name,
|
| 76 |
+
"args": args or {},
|
| 77 |
+
"tool_call_id": tc_id,
|
| 78 |
+
"result": None,
|
| 79 |
+
"ok": None,
|
| 80 |
+
}
|
| 81 |
+
if tc_id:
|
| 82 |
+
pending[tc_id] = entry
|
| 83 |
+
tool_calls_log.append(entry)
|
| 84 |
+
elif tname == "ToolMessage":
|
| 85 |
+
tc_id = getattr(msg, "tool_call_id", None)
|
| 86 |
+
content = getattr(msg, "content", "")
|
| 87 |
+
content_s = content if isinstance(content, str) else str(content)
|
| 88 |
+
entry = pending.get(tc_id) if tc_id else None
|
| 89 |
+
if entry is None:
|
| 90 |
+
entry = {
|
| 91 |
+
"turn": turn,
|
| 92 |
+
"name": getattr(msg, "name", None),
|
| 93 |
+
"args": {},
|
| 94 |
+
"tool_call_id": tc_id,
|
| 95 |
+
}
|
| 96 |
+
tool_calls_log.append(entry)
|
| 97 |
+
entry["result"] = content_s[:4000]
|
| 98 |
+
entry["ok"] = not content_s.startswith("Error") and "ERROR" not in content_s[:40]
|
| 99 |
+
return tools_used, tool_calls_log
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _short_prompt(task: dict[str, Any]) -> str:
|
| 103 |
+
"""Compact prompt — long issue bodies break weak GGUF tool-calling templates."""
|
| 104 |
+
body = (task.get("problem_statement") or "").strip()
|
| 105 |
+
if len(body) > 1800:
|
| 106 |
+
body = body[:1800] + "\n...(truncated)..."
|
| 107 |
+
paths = task.get("workspace_paths") or list((task.get("workspace_files") or {}).keys())
|
| 108 |
+
path_hint = ", ".join(paths[:8]) if paths else "(explore with ls/glob)"
|
| 109 |
+
return (
|
| 110 |
+
f"Fix this GitHub issue in the sandbox using tools.\n"
|
| 111 |
+
f"Instance: {task['task_id']}\n"
|
| 112 |
+
f"Likely files: {path_hint}\n\n"
|
| 113 |
+
f"Issue:\n{body}\n\n"
|
| 114 |
+
f"Required: call tools (start with ls or read_file). "
|
| 115 |
+
f"When done, reply DONE."
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _result_to_str(obj: Any) -> str:
|
| 120 |
+
if obj is None:
|
| 121 |
+
return ""
|
| 122 |
+
if hasattr(obj, "model_dump"):
|
| 123 |
+
try:
|
| 124 |
+
return json.dumps(obj.model_dump(), default=str)[:4000]
|
| 125 |
+
except Exception:
|
| 126 |
+
pass
|
| 127 |
+
if hasattr(obj, "__dict__"):
|
| 128 |
+
try:
|
| 129 |
+
return json.dumps(obj.__dict__, default=str)[:4000]
|
| 130 |
+
except Exception:
|
| 131 |
+
pass
|
| 132 |
+
return str(obj)[:4000]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _build_backend_tools(backend: Any):
|
| 136 |
+
"""LangChain tools wrapping deepagents LocalShellBackend (same FS + execute)."""
|
| 137 |
+
from langchain_core.tools import tool
|
| 138 |
+
|
| 139 |
+
@tool
|
| 140 |
+
def ls(path: str = ".") -> str:
|
| 141 |
+
"""List files in a directory inside the sandbox."""
|
| 142 |
+
return _result_to_str(backend.ls(path))
|
| 143 |
+
|
| 144 |
+
@tool
|
| 145 |
+
def glob(pattern: str, path: str = ".") -> str:
|
| 146 |
+
"""Glob for files matching pattern under path."""
|
| 147 |
+
return _result_to_str(backend.glob(pattern, path))
|
| 148 |
+
|
| 149 |
+
@tool
|
| 150 |
+
def grep(pattern: str, path: str = ".") -> str:
|
| 151 |
+
"""Search file contents for a regex pattern."""
|
| 152 |
+
return _result_to_str(backend.grep(pattern, path))
|
| 153 |
+
|
| 154 |
+
@tool
|
| 155 |
+
def read_file(file_path: str, offset: int = 0, limit: int = 200) -> str:
|
| 156 |
+
"""Read a text file from the sandbox (line offset/limit)."""
|
| 157 |
+
return _result_to_str(backend.read(file_path, offset=offset, limit=limit))
|
| 158 |
+
|
| 159 |
+
@tool
|
| 160 |
+
def write_file(file_path: str, content: str) -> str:
|
| 161 |
+
"""Write/create a text file in the sandbox."""
|
| 162 |
+
return _result_to_str(backend.write(file_path, content))
|
| 163 |
+
|
| 164 |
+
@tool
|
| 165 |
+
def edit_file(file_path: str, old_string: str, new_string: str) -> str:
|
| 166 |
+
"""Replace old_string with new_string in an existing file."""
|
| 167 |
+
return _result_to_str(backend.edit(file_path, old_string, new_string))
|
| 168 |
+
|
| 169 |
+
@tool
|
| 170 |
+
def execute(command: str) -> str:
|
| 171 |
+
"""Run a shell command in the sandbox (e.g. python -c '...')."""
|
| 172 |
+
return _result_to_str(backend.execute(command))
|
| 173 |
+
|
| 174 |
+
return [ls, glob, grep, read_file, write_file, edit_file, execute]
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _run_tool_loop(
|
| 178 |
+
*,
|
| 179 |
+
model: Any,
|
| 180 |
+
backend: Any,
|
| 181 |
+
prompt: str,
|
| 182 |
+
max_turns: int,
|
| 183 |
+
langfuse_client: Any | None = None,
|
| 184 |
+
callback_handler: Any | None = None,
|
| 185 |
+
episode_id: str | None = None,
|
| 186 |
+
) -> tuple[list[Any], str, str]:
|
| 187 |
+
"""Reliable multi-turn tool loop on deepagents LocalShellBackend tools.
|
| 188 |
+
|
| 189 |
+
When `langfuse_client` is set, each LLM turn is traced via CallbackHandler
|
| 190 |
+
(generation) and each tool execution is a nested `as_type="tool"` span.
|
| 191 |
+
"""
|
| 192 |
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
| 193 |
+
|
| 194 |
+
from deepagents_rl.observability.langfuse_trace import llm_invoke_config
|
| 195 |
+
|
| 196 |
+
tools = _build_backend_tools(backend)
|
| 197 |
+
tool_map = {t.name: t for t in tools}
|
| 198 |
+
tool_names = list(tool_map.keys())
|
| 199 |
+
step("agent.bind_tools", f"tools={tool_names}", tools=tool_names)
|
| 200 |
+
bound = model.bind_tools(tools)
|
| 201 |
+
messages: list[Any] = [
|
| 202 |
+
SystemMessage(content=SYSTEM_PROMPT),
|
| 203 |
+
HumanMessage(content=prompt),
|
| 204 |
+
]
|
| 205 |
+
step("agent.prompt", truncate(prompt, 500), prompt_chars=len(prompt))
|
| 206 |
+
final_text = ""
|
| 207 |
+
termination = "max_steps"
|
| 208 |
+
|
| 209 |
+
def _llm_call(turn: int) -> AIMessage:
|
| 210 |
+
cfg = None
|
| 211 |
+
if callback_handler is not None:
|
| 212 |
+
cfg = llm_invoke_config(
|
| 213 |
+
handler=callback_handler,
|
| 214 |
+
turn=turn,
|
| 215 |
+
episode_id=episode_id,
|
| 216 |
+
)
|
| 217 |
+
if cfg is not None:
|
| 218 |
+
return bound.invoke(messages, config=cfg)
|
| 219 |
+
return bound.invoke(messages)
|
| 220 |
+
|
| 221 |
+
for turn in range(max_turns):
|
| 222 |
+
def _one_turn(turn: int = turn) -> str | None:
|
| 223 |
+
"""Run one LLM+tools turn. Returns termination if loop should stop."""
|
| 224 |
+
nonlocal final_text, termination
|
| 225 |
+
with span("agent.llm_turn", turn=turn):
|
| 226 |
+
call("llm.invoke", f"turn={turn} n_messages={len(messages)}")
|
| 227 |
+
t0 = time.time()
|
| 228 |
+
ai: AIMessage = _llm_call(turn)
|
| 229 |
+
dt = int((time.time() - t0) * 1000)
|
| 230 |
+
messages.append(ai)
|
| 231 |
+
content_preview = (
|
| 232 |
+
ai.content if isinstance(ai.content, str) else str(ai.content)
|
| 233 |
+
)
|
| 234 |
+
tcs = getattr(ai, "tool_calls", None) or []
|
| 235 |
+
result(
|
| 236 |
+
"llm.invoke",
|
| 237 |
+
f"turn={turn} ({dt}ms)",
|
| 238 |
+
duration_ms=dt,
|
| 239 |
+
content=truncate(content_preview, 400),
|
| 240 |
+
n_tool_calls=len(tcs),
|
| 241 |
+
tool_calls=[
|
| 242 |
+
{
|
| 243 |
+
"name": tc.get("name"),
|
| 244 |
+
"args": truncate(tc.get("args") or {}, 300),
|
| 245 |
+
}
|
| 246 |
+
for tc in tcs
|
| 247 |
+
],
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
if not tcs:
|
| 251 |
+
final_text = content_preview
|
| 252 |
+
termination = (
|
| 253 |
+
"agent_done"
|
| 254 |
+
if "DONE" in (final_text or "").upper()
|
| 255 |
+
else "no_tool_final"
|
| 256 |
+
)
|
| 257 |
+
step("agent.final", termination, final_text=truncate(final_text, 400))
|
| 258 |
+
return termination
|
| 259 |
+
|
| 260 |
+
tool_results_for_turn: list[dict[str, Any]] = []
|
| 261 |
+
for tc in tcs:
|
| 262 |
+
name = tc["name"]
|
| 263 |
+
args = tc.get("args") or {}
|
| 264 |
+
tool_out: Any
|
| 265 |
+
with span("agent.tool", turn=turn, tool=name):
|
| 266 |
+
call("tool", name, args=truncate(args, 500))
|
| 267 |
+
|
| 268 |
+
def _exec_tool() -> Any:
|
| 269 |
+
try:
|
| 270 |
+
return tool_map[name].invoke(args)
|
| 271 |
+
except Exception as e: # noqa: BLE001
|
| 272 |
+
err = f"ERROR: {type(e).__name__}: {e}"
|
| 273 |
+
log_error("tool", err, tool=name)
|
| 274 |
+
return err
|
| 275 |
+
|
| 276 |
+
if langfuse_client is not None:
|
| 277 |
+
with langfuse_client.start_as_current_observation(
|
| 278 |
+
name=name,
|
| 279 |
+
as_type="tool",
|
| 280 |
+
input=args if isinstance(args, dict) else {"value": args},
|
| 281 |
+
metadata={
|
| 282 |
+
"turn": turn,
|
| 283 |
+
"tool_call_id": tc.get("id"),
|
| 284 |
+
"episode_id": episode_id,
|
| 285 |
+
},
|
| 286 |
+
) as tool_span:
|
| 287 |
+
tool_out = _exec_tool()
|
| 288 |
+
tool_span.update(
|
| 289 |
+
output={"result": truncate(str(tool_out), 4000)}
|
| 290 |
+
)
|
| 291 |
+
else:
|
| 292 |
+
tool_out = _exec_tool()
|
| 293 |
+
|
| 294 |
+
result("tool", name, output=truncate(str(tool_out), 600))
|
| 295 |
+
tool_results_for_turn.append(
|
| 296 |
+
{
|
| 297 |
+
"name": name,
|
| 298 |
+
"args": truncate(args, 300),
|
| 299 |
+
"result": truncate(str(tool_out), 300),
|
| 300 |
+
}
|
| 301 |
+
)
|
| 302 |
+
messages.append(
|
| 303 |
+
ToolMessage(
|
| 304 |
+
content=str(tool_out)[:4000],
|
| 305 |
+
tool_call_id=tc["id"],
|
| 306 |
+
name=name,
|
| 307 |
+
)
|
| 308 |
+
)
|
| 309 |
+
return None # continue
|
| 310 |
+
|
| 311 |
+
if langfuse_client is not None:
|
| 312 |
+
with langfuse_client.start_as_current_observation(
|
| 313 |
+
name=f"turn-{turn}",
|
| 314 |
+
as_type="span",
|
| 315 |
+
metadata={"turn": turn, "episode_id": episode_id},
|
| 316 |
+
input={"n_messages": len(messages)},
|
| 317 |
+
) as turn_span:
|
| 318 |
+
stop = _one_turn()
|
| 319 |
+
if stop:
|
| 320 |
+
turn_span.update(
|
| 321 |
+
output={
|
| 322 |
+
"termination": stop,
|
| 323 |
+
"final_text": truncate(final_text, 1000),
|
| 324 |
+
"n_tool_calls": 0,
|
| 325 |
+
}
|
| 326 |
+
)
|
| 327 |
+
break
|
| 328 |
+
n_tc = 0
|
| 329 |
+
for m in reversed(messages):
|
| 330 |
+
if m.__class__.__name__ == "AIMessage" and getattr(
|
| 331 |
+
m, "tool_calls", None
|
| 332 |
+
):
|
| 333 |
+
n_tc = len(m.tool_calls)
|
| 334 |
+
break
|
| 335 |
+
turn_span.update(output={"n_tool_calls": n_tc, "continued": True})
|
| 336 |
+
else:
|
| 337 |
+
if _one_turn():
|
| 338 |
+
break
|
| 339 |
+
else:
|
| 340 |
+
termination = "max_steps"
|
| 341 |
+
final_text = "(hit max turns)"
|
| 342 |
+
step("agent.max_turns", f"hit max_turns={max_turns}")
|
| 343 |
+
|
| 344 |
+
return messages, final_text, termination
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def _run_graph(
|
| 348 |
+
*,
|
| 349 |
+
model: Any,
|
| 350 |
+
backend: Any,
|
| 351 |
+
prompt: str,
|
| 352 |
+
recursion_limit: int,
|
| 353 |
+
langfuse_client: Any | None = None,
|
| 354 |
+
callback_handler: Any | None = None,
|
| 355 |
+
episode_id: str | None = None,
|
| 356 |
+
) -> tuple[list[Any], str, str]:
|
| 357 |
+
from deepagents import create_deep_agent
|
| 358 |
+
|
| 359 |
+
step("agent.create_deep_agent", f"recursion_limit={recursion_limit}")
|
| 360 |
+
agent = create_deep_agent(
|
| 361 |
+
model=model,
|
| 362 |
+
backend=backend,
|
| 363 |
+
system_prompt=SYSTEM_PROMPT,
|
| 364 |
+
name="deepagents-rl-swe",
|
| 365 |
+
)
|
| 366 |
+
step("agent.prompt", truncate(prompt, 500), prompt_chars=len(prompt))
|
| 367 |
+
|
| 368 |
+
invoke_cfg: dict[str, Any] = {"recursion_limit": recursion_limit}
|
| 369 |
+
if callback_handler is not None:
|
| 370 |
+
invoke_cfg["callbacks"] = [callback_handler]
|
| 371 |
+
invoke_cfg["run_name"] = "deepagent-graph"
|
| 372 |
+
invoke_cfg["tags"] = ["deepagents-rl", "graph"]
|
| 373 |
+
invoke_cfg["metadata"] = {"episode_id": episode_id, "harness": "graph"}
|
| 374 |
+
|
| 375 |
+
def _invoke_graph() -> Any:
|
| 376 |
+
with span("agent.graph_invoke", recursion_limit=recursion_limit):
|
| 377 |
+
call("graph.invoke", "create_deep_agent.invoke")
|
| 378 |
+
return agent.invoke(
|
| 379 |
+
{"messages": [{"role": "user", "content": prompt}]},
|
| 380 |
+
config=invoke_cfg,
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
if langfuse_client is not None:
|
| 384 |
+
with langfuse_client.start_as_current_observation(
|
| 385 |
+
name="agent-graph",
|
| 386 |
+
as_type="span",
|
| 387 |
+
metadata={"episode_id": episode_id, "recursion_limit": recursion_limit},
|
| 388 |
+
input={"prompt": prompt[:4000]},
|
| 389 |
+
) as graph_span:
|
| 390 |
+
graph_out = _invoke_graph()
|
| 391 |
+
msgs = list(graph_out.get("messages") or [])
|
| 392 |
+
final_text = ""
|
| 393 |
+
for m in reversed(msgs):
|
| 394 |
+
if m.__class__.__name__ == "AIMessage" and not getattr(
|
| 395 |
+
m, "tool_calls", None
|
| 396 |
+
):
|
| 397 |
+
content = getattr(m, "content", "")
|
| 398 |
+
final_text = content if isinstance(content, str) else str(content)
|
| 399 |
+
break
|
| 400 |
+
tools_used, tool_calls_log = _extract_tool_calls(msgs)
|
| 401 |
+
# Replay tool spans under this graph span for UI granularity
|
| 402 |
+
for tc in tool_calls_log:
|
| 403 |
+
with langfuse_client.start_as_current_observation(
|
| 404 |
+
name=tc.get("name") or "tool",
|
| 405 |
+
as_type="tool",
|
| 406 |
+
input=tc.get("args") or {},
|
| 407 |
+
metadata={
|
| 408 |
+
"turn": tc.get("turn"),
|
| 409 |
+
"tool_call_id": tc.get("tool_call_id"),
|
| 410 |
+
"episode_id": episode_id,
|
| 411 |
+
"source": "graph_replay",
|
| 412 |
+
},
|
| 413 |
+
) as tool_span:
|
| 414 |
+
tool_span.update(
|
| 415 |
+
output={"result": truncate(tc.get("result") or "", 4000)}
|
| 416 |
+
)
|
| 417 |
+
if tools_used:
|
| 418 |
+
termination = (
|
| 419 |
+
"agent_done"
|
| 420 |
+
if "DONE" in (final_text or "").upper()
|
| 421 |
+
else "max_steps_or_halt"
|
| 422 |
+
)
|
| 423 |
+
else:
|
| 424 |
+
termination = "no_tools"
|
| 425 |
+
graph_span.update(
|
| 426 |
+
output={
|
| 427 |
+
"termination": termination,
|
| 428 |
+
"n_messages": len(msgs),
|
| 429 |
+
"tools_used": tools_used,
|
| 430 |
+
"final_text": truncate(final_text, 1000),
|
| 431 |
+
}
|
| 432 |
+
)
|
| 433 |
+
else:
|
| 434 |
+
graph_out = _invoke_graph()
|
| 435 |
+
msgs = list(graph_out.get("messages") or [])
|
| 436 |
+
final_text = ""
|
| 437 |
+
for m in reversed(msgs):
|
| 438 |
+
if m.__class__.__name__ == "AIMessage" and not getattr(m, "tool_calls", None):
|
| 439 |
+
content = getattr(m, "content", "")
|
| 440 |
+
final_text = content if isinstance(content, str) else str(content)
|
| 441 |
+
break
|
| 442 |
+
tools_used, tool_calls_log = _extract_tool_calls(msgs)
|
| 443 |
+
if tools_used:
|
| 444 |
+
termination = (
|
| 445 |
+
"agent_done" if "DONE" in (final_text or "").upper() else "max_steps_or_halt"
|
| 446 |
+
)
|
| 447 |
+
else:
|
| 448 |
+
termination = "no_tools"
|
| 449 |
+
|
| 450 |
+
for tc in tool_calls_log:
|
| 451 |
+
result(
|
| 452 |
+
"tool",
|
| 453 |
+
tc.get("name") or "?",
|
| 454 |
+
turn=tc.get("turn"),
|
| 455 |
+
args=truncate(tc.get("args") or {}, 400),
|
| 456 |
+
output=truncate(tc.get("result") or "", 400),
|
| 457 |
+
)
|
| 458 |
+
step(
|
| 459 |
+
"agent.graph_done",
|
| 460 |
+
termination,
|
| 461 |
+
n_messages=len(msgs),
|
| 462 |
+
tools_used=tools_used,
|
| 463 |
+
final_text=truncate(final_text, 400),
|
| 464 |
+
)
|
| 465 |
+
return msgs, final_text, termination
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def run_deepagent_episode(
|
| 469 |
+
task: dict[str, Any],
|
| 470 |
+
*,
|
| 471 |
+
model: Any | None = None,
|
| 472 |
+
recursion_limit: int = 80,
|
| 473 |
+
max_turns: int = 16,
|
| 474 |
+
mode: str | None = None,
|
| 475 |
+
langfuse_client: Any | None = None,
|
| 476 |
+
artifacts_dir: Path | None = None,
|
| 477 |
+
keep_sandbox: bool = False,
|
| 478 |
+
) -> dict[str, Any]:
|
| 479 |
+
"""
|
| 480 |
+
Full-repo git checkout -> deepagents LocalShellBackend + agent loop ->
|
| 481 |
+
git diff model_patch -> official SWE-bench Docker eval -> trajectory.
|
| 482 |
+
"""
|
| 483 |
+
from deepagents.backends import LocalShellBackend
|
| 484 |
+
from langchain_openai import ChatOpenAI
|
| 485 |
+
|
| 486 |
+
episode_id = str(uuid.uuid4())
|
| 487 |
+
started = time.time()
|
| 488 |
+
if not task.get("image") or not task.get("eval_script"):
|
| 489 |
+
raise ValueError(
|
| 490 |
+
f"Task {task.get('task_id')} missing image/eval_script — "
|
| 491 |
+
"reload from SWE-bench/SWE-bench_Lite (not the old sparse dataset)."
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
step(
|
| 495 |
+
"episode.begin",
|
| 496 |
+
task["task_id"],
|
| 497 |
+
episode_id=episode_id,
|
| 498 |
+
repo=task.get("repo"),
|
| 499 |
+
base_commit=task.get("base_commit"),
|
| 500 |
+
image=task.get("image"),
|
| 501 |
+
mode=mode or os.getenv("HARNESS_MODE") or "tool_loop",
|
| 502 |
+
n_f2p=len(task.get("fail_to_pass") or []),
|
| 503 |
+
n_p2p=len(task.get("pass_to_pass") or []),
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
sandbox_parent = Path(tempfile.mkdtemp(prefix=f"darl_swe_{task['task_id'][:40]}_"))
|
| 507 |
+
sandbox = sandbox_parent / "repo"
|
| 508 |
+
materialize_repo_at_commit(
|
| 509 |
+
repo=task["repo"],
|
| 510 |
+
base_commit=task["base_commit"],
|
| 511 |
+
dest=sandbox,
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
mode = (mode or os.getenv("HARNESS_MODE") or "tool_loop").strip().lower()
|
| 515 |
+
# tool_loop: langchain bind_tools over LocalShellBackend (reliable on local GGUF)
|
| 516 |
+
# graph: create_deep_agent CompiledStateGraph
|
| 517 |
+
|
| 518 |
+
if model is None:
|
| 519 |
+
step(
|
| 520 |
+
"episode.model",
|
| 521 |
+
"ChatOpenAI",
|
| 522 |
+
base_url=os.environ.get("MODEL_BASE_URL"),
|
| 523 |
+
model=os.environ.get("MODEL_NAME"),
|
| 524 |
+
)
|
| 525 |
+
model = ChatOpenAI(
|
| 526 |
+
base_url=os.environ["MODEL_BASE_URL"],
|
| 527 |
+
api_key=os.getenv("MODEL_API_KEY", "local"),
|
| 528 |
+
model=os.environ["MODEL_NAME"],
|
| 529 |
+
temperature=0,
|
| 530 |
+
max_tokens=1024,
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
step("episode.backend", f"LocalShellBackend root={sandbox}")
|
| 534 |
+
backend = LocalShellBackend(root_dir=sandbox, virtual_mode=True, timeout=60)
|
| 535 |
+
prompt = _short_prompt(task)
|
| 536 |
+
eval_log_dir = (artifacts_dir / "evals" / episode_id) if artifacts_dir else None
|
| 537 |
+
|
| 538 |
+
messages_dump: list[dict[str, Any]] = [{"role": "user", "content": prompt}]
|
| 539 |
+
final_text = ""
|
| 540 |
+
termination = "unknown"
|
| 541 |
+
tools_used: list[str] = []
|
| 542 |
+
tool_calls_log: list[dict[str, Any]] = []
|
| 543 |
+
trace_id = ""
|
| 544 |
+
error: str | None = None
|
| 545 |
+
msgs: list[Any] = []
|
| 546 |
+
|
| 547 |
+
callback_handler = None
|
| 548 |
+
if langfuse_client is not None:
|
| 549 |
+
from deepagents_rl.observability.langfuse_trace import make_callback_handler
|
| 550 |
+
|
| 551 |
+
callback_handler = make_callback_handler()
|
| 552 |
+
step("episode.langfuse_handler", "CallbackHandler ready for LLM generations")
|
| 553 |
+
|
| 554 |
+
def _invoke() -> None:
|
| 555 |
+
nonlocal final_text, termination, tools_used, tool_calls_log, messages_dump, msgs
|
| 556 |
+
step("episode.agent_loop", f"mode={mode}")
|
| 557 |
+
if mode == "graph":
|
| 558 |
+
msgs, final_text, termination = _run_graph(
|
| 559 |
+
model=model,
|
| 560 |
+
backend=backend,
|
| 561 |
+
prompt=prompt,
|
| 562 |
+
recursion_limit=recursion_limit,
|
| 563 |
+
langfuse_client=langfuse_client,
|
| 564 |
+
callback_handler=callback_handler,
|
| 565 |
+
episode_id=episode_id,
|
| 566 |
+
)
|
| 567 |
+
else:
|
| 568 |
+
msgs, final_text, termination = _run_tool_loop(
|
| 569 |
+
model=model,
|
| 570 |
+
backend=backend,
|
| 571 |
+
prompt=prompt,
|
| 572 |
+
max_turns=max_turns,
|
| 573 |
+
langfuse_client=langfuse_client,
|
| 574 |
+
callback_handler=callback_handler,
|
| 575 |
+
episode_id=episode_id,
|
| 576 |
+
)
|
| 577 |
+
messages_dump = [_msg_to_dict(m) for m in msgs]
|
| 578 |
+
tools_used, tool_calls_log = _extract_tool_calls(msgs)
|
| 579 |
+
step(
|
| 580 |
+
"episode.agent_done",
|
| 581 |
+
termination,
|
| 582 |
+
n_messages=len(messages_dump),
|
| 583 |
+
n_tool_calls=len(tool_calls_log),
|
| 584 |
+
tools_used=tools_used,
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
def _grade() -> dict[str, Any]:
|
| 588 |
+
from deepagents_rl.harness.repo import git_diff_patch
|
| 589 |
+
|
| 590 |
+
step("episode.grade", "starting Docker SWE-bench eval")
|
| 591 |
+
if langfuse_client is not None:
|
| 592 |
+
with langfuse_client.start_as_current_observation(
|
| 593 |
+
name="reward_grader",
|
| 594 |
+
as_type="evaluator",
|
| 595 |
+
input={
|
| 596 |
+
"fail_to_pass": task.get("fail_to_pass"),
|
| 597 |
+
"pass_to_pass": task.get("pass_to_pass"),
|
| 598 |
+
"grader": "swebench_docker_official_protocol",
|
| 599 |
+
"image": task.get("image"),
|
| 600 |
+
"n_tool_calls": len(tool_calls_log),
|
| 601 |
+
"tools_used": tools_used,
|
| 602 |
+
},
|
| 603 |
+
metadata={
|
| 604 |
+
"episode_id": episode_id,
|
| 605 |
+
"task_id": task["task_id"],
|
| 606 |
+
},
|
| 607 |
+
) as reward_span:
|
| 608 |
+
with langfuse_client.start_as_current_observation(
|
| 609 |
+
name="git_diff_model_patch",
|
| 610 |
+
as_type="span",
|
| 611 |
+
metadata={"episode_id": episode_id},
|
| 612 |
+
) as diff_span:
|
| 613 |
+
model_patch = git_diff_patch(sandbox)
|
| 614 |
+
diff_span.update(
|
| 615 |
+
output={
|
| 616 |
+
"patch_bytes": len(model_patch.encode("utf-8")),
|
| 617 |
+
"patch_preview": truncate(model_patch, 1500),
|
| 618 |
+
"empty": not bool(model_patch.strip()),
|
| 619 |
+
}
|
| 620 |
+
)
|
| 621 |
+
with langfuse_client.start_as_current_observation(
|
| 622 |
+
name="docker_swe_eval",
|
| 623 |
+
as_type="span",
|
| 624 |
+
input={
|
| 625 |
+
"image": task.get("image"),
|
| 626 |
+
"patch_bytes": len(model_patch.encode("utf-8")),
|
| 627 |
+
},
|
| 628 |
+
metadata={"episode_id": episode_id},
|
| 629 |
+
) as docker_span:
|
| 630 |
+
g = grade_episode(
|
| 631 |
+
sandbox=sandbox,
|
| 632 |
+
task=task,
|
| 633 |
+
tools_used=tools_used,
|
| 634 |
+
tool_calls=tool_calls_log,
|
| 635 |
+
model_patch=model_patch,
|
| 636 |
+
eval_log_dir=eval_log_dir,
|
| 637 |
+
)
|
| 638 |
+
docker_span.update(
|
| 639 |
+
output={
|
| 640 |
+
"resolved": g.get("resolved"),
|
| 641 |
+
"resolution": g.get("resolution"),
|
| 642 |
+
"pass_frac": g.get("pass_frac"),
|
| 643 |
+
"patch_successfully_applied": g.get(
|
| 644 |
+
"patch_successfully_applied"
|
| 645 |
+
),
|
| 646 |
+
"fail_to_pass": g.get("fail_to_pass"),
|
| 647 |
+
"pass_to_pass": {
|
| 648 |
+
"frac": (g.get("pass_to_pass") or {}).get("frac"),
|
| 649 |
+
"n_failure": len(
|
| 650 |
+
(g.get("pass_to_pass") or {}).get("failure") or []
|
| 651 |
+
),
|
| 652 |
+
},
|
| 653 |
+
"status_map_sample": list(
|
| 654 |
+
(g.get("status_map") or {}).items()
|
| 655 |
+
)[:15],
|
| 656 |
+
"eval_error": g.get("eval_error"),
|
| 657 |
+
"eval_exit_code": g.get("eval_exit_code"),
|
| 658 |
+
"eval_log_dir": g.get("eval_log_dir"),
|
| 659 |
+
}
|
| 660 |
+
)
|
| 661 |
+
reward_span.update(
|
| 662 |
+
output={
|
| 663 |
+
k: g.get(k)
|
| 664 |
+
for k in (
|
| 665 |
+
"pass_frac",
|
| 666 |
+
"reward_total",
|
| 667 |
+
"resolved",
|
| 668 |
+
"resolution",
|
| 669 |
+
"patch_successfully_applied",
|
| 670 |
+
"fail_to_pass",
|
| 671 |
+
"pass_to_pass",
|
| 672 |
+
"eval_error",
|
| 673 |
+
)
|
| 674 |
+
}
|
| 675 |
+
)
|
| 676 |
+
else:
|
| 677 |
+
g = grade_episode(
|
| 678 |
+
sandbox=sandbox,
|
| 679 |
+
task=task,
|
| 680 |
+
tools_used=tools_used,
|
| 681 |
+
tool_calls=tool_calls_log,
|
| 682 |
+
eval_log_dir=eval_log_dir,
|
| 683 |
+
)
|
| 684 |
+
step(
|
| 685 |
+
"episode.grade_done",
|
| 686 |
+
f"resolved={g.get('resolved')} f2p={g.get('pass_frac')}",
|
| 687 |
+
resolution=g.get("resolution"),
|
| 688 |
+
eval_error=g.get("eval_error"),
|
| 689 |
+
)
|
| 690 |
+
return g
|
| 691 |
+
|
| 692 |
+
try:
|
| 693 |
+
if langfuse_client is not None:
|
| 694 |
+
with langfuse_client.start_as_current_observation(
|
| 695 |
+
name="deepagent-swe-episode",
|
| 696 |
+
as_type="agent",
|
| 697 |
+
metadata={
|
| 698 |
+
"episode_id": episode_id,
|
| 699 |
+
"task_id": task["task_id"],
|
| 700 |
+
"repo": task.get("repo"),
|
| 701 |
+
"split": task.get("split"),
|
| 702 |
+
"source_dataset": task.get("source_dataset"),
|
| 703 |
+
"harness": f"deepagents:{mode}",
|
| 704 |
+
"model": os.getenv("MODEL_NAME"),
|
| 705 |
+
"tags": ["deepagents-rl", "swe-bench", mode],
|
| 706 |
+
},
|
| 707 |
+
input={
|
| 708 |
+
"task_id": task["task_id"],
|
| 709 |
+
"prompt": prompt[:4000],
|
| 710 |
+
"workspace_paths": task.get("workspace_paths"),
|
| 711 |
+
"mode": mode,
|
| 712 |
+
"fail_to_pass": task.get("fail_to_pass"),
|
| 713 |
+
},
|
| 714 |
+
) as root:
|
| 715 |
+
# Nested agent-loop span so LLM/tool children group cleanly
|
| 716 |
+
with langfuse_client.start_as_current_observation(
|
| 717 |
+
name="agent-loop",
|
| 718 |
+
as_type="span",
|
| 719 |
+
metadata={"mode": mode, "episode_id": episode_id},
|
| 720 |
+
input={"max_turns": max_turns, "recursion_limit": recursion_limit},
|
| 721 |
+
) as loop_span:
|
| 722 |
+
_invoke()
|
| 723 |
+
loop_span.update(
|
| 724 |
+
output={
|
| 725 |
+
"termination": termination,
|
| 726 |
+
"n_tool_calls": len(tool_calls_log),
|
| 727 |
+
"tools_used": tools_used,
|
| 728 |
+
"final_text": truncate(final_text, 1000),
|
| 729 |
+
}
|
| 730 |
+
)
|
| 731 |
+
|
| 732 |
+
grade = _grade()
|
| 733 |
+
|
| 734 |
+
langfuse_client.score_current_trace(
|
| 735 |
+
name="pass_frac", value=float(grade["pass_frac"])
|
| 736 |
+
)
|
| 737 |
+
langfuse_client.score_current_trace(
|
| 738 |
+
name="reward_total", value=float(grade["reward_total"])
|
| 739 |
+
)
|
| 740 |
+
langfuse_client.score_current_trace(
|
| 741 |
+
name="resolved", value=1.0 if grade.get("resolved") else 0.0
|
| 742 |
+
)
|
| 743 |
+
langfuse_client.score_current_trace(
|
| 744 |
+
name="n_tool_calls", value=float(len(tool_calls_log))
|
| 745 |
+
)
|
| 746 |
+
root.update(
|
| 747 |
+
output={
|
| 748 |
+
"termination": termination,
|
| 749 |
+
"final_text": final_text[:2000],
|
| 750 |
+
"tools_used": tools_used,
|
| 751 |
+
"n_tool_calls": len(tool_calls_log),
|
| 752 |
+
"tool_calls": [
|
| 753 |
+
{
|
| 754 |
+
"turn": t.get("turn"),
|
| 755 |
+
"name": t.get("name"),
|
| 756 |
+
"args": t.get("args"),
|
| 757 |
+
"result": truncate(t.get("result") or "", 500),
|
| 758 |
+
}
|
| 759 |
+
for t in tool_calls_log[:40]
|
| 760 |
+
],
|
| 761 |
+
"grade": {
|
| 762 |
+
k: grade.get(k)
|
| 763 |
+
for k in (
|
| 764 |
+
"pass_frac",
|
| 765 |
+
"reward_total",
|
| 766 |
+
"resolved",
|
| 767 |
+
"resolution",
|
| 768 |
+
"eval_error",
|
| 769 |
+
)
|
| 770 |
+
},
|
| 771 |
+
}
|
| 772 |
+
)
|
| 773 |
+
trace_id = langfuse_client.get_current_trace_id() or ""
|
| 774 |
+
langfuse_client.flush()
|
| 775 |
+
step("episode.langfuse_flush", trace_id or "(no trace id)")
|
| 776 |
+
else:
|
| 777 |
+
_invoke()
|
| 778 |
+
grade = _grade()
|
| 779 |
+
except Exception as e: # noqa: BLE001
|
| 780 |
+
error = f"{type(e).__name__}: {e}"
|
| 781 |
+
log_error("episode", error)
|
| 782 |
+
termination = "error"
|
| 783 |
+
grade = {
|
| 784 |
+
"pass_frac": 0.0,
|
| 785 |
+
"reward_total": 0.0,
|
| 786 |
+
"resolved": False,
|
| 787 |
+
"resolution": "NO",
|
| 788 |
+
"eval_error": error,
|
| 789 |
+
"grader": "swebench_docker_official_protocol",
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
duration_s = time.time() - started
|
| 793 |
+
trajectory = {
|
| 794 |
+
"episode_id": episode_id,
|
| 795 |
+
"task_id": task["task_id"],
|
| 796 |
+
"instance_id": task.get("instance_id"),
|
| 797 |
+
"split": task.get("split"),
|
| 798 |
+
"repo": task.get("repo"),
|
| 799 |
+
"base_commit": task.get("base_commit"),
|
| 800 |
+
"source_dataset": task.get("source_dataset") or "SWE-bench/SWE-bench_Lite",
|
| 801 |
+
"harness": f"deepagents:{mode}",
|
| 802 |
+
"model_name": os.getenv("MODEL_NAME"),
|
| 803 |
+
"model_base_url": os.getenv("MODEL_BASE_URL"),
|
| 804 |
+
"created_at": _utc_now(),
|
| 805 |
+
"duration_s": duration_s,
|
| 806 |
+
"recursion_limit": recursion_limit,
|
| 807 |
+
"max_turns": max_turns,
|
| 808 |
+
"sandbox": str(sandbox),
|
| 809 |
+
"prompt": prompt,
|
| 810 |
+
"messages": messages_dump,
|
| 811 |
+
"tool_calls": tool_calls_log,
|
| 812 |
+
"tools_used": tools_used,
|
| 813 |
+
"final_text": final_text,
|
| 814 |
+
"termination": termination,
|
| 815 |
+
"grade": grade,
|
| 816 |
+
"reward": {
|
| 817 |
+
"pass_frac": grade.get("pass_frac", 0.0),
|
| 818 |
+
"reward_total": grade.get("reward_total", 0.0),
|
| 819 |
+
"resolved": grade.get("resolved", False),
|
| 820 |
+
"resolution": grade.get("resolution"),
|
| 821 |
+
},
|
| 822 |
+
"model_patch": grade.get("model_patch"),
|
| 823 |
+
"langfuse_trace_id": trace_id,
|
| 824 |
+
"error": error,
|
| 825 |
+
"gold_paths": task.get("workspace_paths"),
|
| 826 |
+
"image": task.get("image"),
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
if artifacts_dir is not None:
|
| 830 |
+
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
| 831 |
+
traj_path = artifacts_dir / f"episode_{episode_id}.json"
|
| 832 |
+
traj_path.write_text(json.dumps(trajectory, indent=2), encoding="utf-8")
|
| 833 |
+
jsonl_path = artifacts_dir / "trajectories.jsonl"
|
| 834 |
+
with jsonl_path.open("a", encoding="utf-8") as f:
|
| 835 |
+
f.write(json.dumps(trajectory) + "\n")
|
| 836 |
+
if grade.get("model_patch"):
|
| 837 |
+
(artifacts_dir / f"patch_{episode_id}.diff").write_text(
|
| 838 |
+
grade["model_patch"], encoding="utf-8"
|
| 839 |
+
)
|
| 840 |
+
trajectory["artifact_json"] = str(traj_path)
|
| 841 |
+
trajectory["artifact_jsonl"] = str(jsonl_path)
|
| 842 |
+
|
| 843 |
+
if not keep_sandbox:
|
| 844 |
+
try:
|
| 845 |
+
import shutil
|
| 846 |
+
|
| 847 |
+
step("episode.cleanup_sandbox", str(sandbox_parent))
|
| 848 |
+
shutil.rmtree(sandbox_parent, ignore_errors=True)
|
| 849 |
+
trajectory["sandbox"] = None
|
| 850 |
+
except Exception:
|
| 851 |
+
pass
|
| 852 |
+
|
| 853 |
+
step(
|
| 854 |
+
"episode.end",
|
| 855 |
+
termination,
|
| 856 |
+
episode_id=episode_id,
|
| 857 |
+
duration_s=round(duration_s, 2),
|
| 858 |
+
n_tool_calls=len(tool_calls_log),
|
| 859 |
+
resolved=grade.get("resolved"),
|
| 860 |
+
pass_frac=grade.get("pass_frac"),
|
| 861 |
+
artifact=trajectory.get("artifact_json"),
|
| 862 |
+
)
|
| 863 |
+
return trajectory
|
src/deepagents_rl/harness/grade.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grade long-horizon coding episodes with official SWE-bench Docker evaluation.
|
| 2 |
+
|
| 3 |
+
Reward = FAIL_TO_PASS fraction after applying the agent's git patch inside the
|
| 4 |
+
instance image and running the official eval_script (test_patch + pytest).
|
| 5 |
+
|
| 6 |
+
Resolution follows SWE-bench: FULL only if all F2P pass and all P2P maintained.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from deepagents_rl.harness.repo import git_diff_patch
|
| 14 |
+
from deepagents_rl.harness.swe_docker_eval import evaluate_model_patch
|
| 15 |
+
from deepagents_rl.observability.logging import span, step, truncate
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def grade_episode(
|
| 19 |
+
*,
|
| 20 |
+
sandbox: Path,
|
| 21 |
+
task: dict[str, Any],
|
| 22 |
+
tools_used: list[str] | None = None,
|
| 23 |
+
tool_calls: list[dict[str, Any]] | None = None,
|
| 24 |
+
model_patch: str | None = None,
|
| 25 |
+
eval_log_dir: Path | None = None,
|
| 26 |
+
timeout_s: int = 1800,
|
| 27 |
+
) -> dict[str, Any]:
|
| 28 |
+
"""
|
| 29 |
+
Produce model_patch from the agent sandbox (if not provided), then run
|
| 30 |
+
Docker SWE-bench evaluation.
|
| 31 |
+
"""
|
| 32 |
+
tools_used = tools_used or []
|
| 33 |
+
tool_calls = tool_calls or []
|
| 34 |
+
|
| 35 |
+
with span(
|
| 36 |
+
"grade.episode",
|
| 37 |
+
task.get("instance_id") or task.get("task_id"),
|
| 38 |
+
sandbox=str(sandbox),
|
| 39 |
+
n_tool_calls=len(tool_calls),
|
| 40 |
+
):
|
| 41 |
+
if model_patch is None:
|
| 42 |
+
step("grade.diff", "collecting git diff from agent sandbox")
|
| 43 |
+
model_patch = git_diff_patch(sandbox)
|
| 44 |
+
else:
|
| 45 |
+
step("grade.diff", "using provided model_patch")
|
| 46 |
+
|
| 47 |
+
step(
|
| 48 |
+
"grade.patch_ready",
|
| 49 |
+
f"bytes={len(model_patch.encode('utf-8'))}",
|
| 50 |
+
empty=not bool(model_patch.strip()),
|
| 51 |
+
preview=truncate(model_patch, 400),
|
| 52 |
+
)
|
| 53 |
+
step(
|
| 54 |
+
"grade.docker_eval",
|
| 55 |
+
f"image={task.get('image')}",
|
| 56 |
+
n_f2p=len(task.get("fail_to_pass") or []),
|
| 57 |
+
n_p2p=len(task.get("pass_to_pass") or []),
|
| 58 |
+
)
|
| 59 |
+
report = evaluate_model_patch(
|
| 60 |
+
task,
|
| 61 |
+
model_patch,
|
| 62 |
+
timeout_s=timeout_s,
|
| 63 |
+
log_dir=eval_log_dir,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
out = {
|
| 67 |
+
"pass_frac": float(report.get("pass_frac") or 0.0),
|
| 68 |
+
"reward_total": float(report.get("reward_total") or 0.0),
|
| 69 |
+
"resolved": bool(report.get("resolved")),
|
| 70 |
+
"resolution": report.get("resolution") or "NO",
|
| 71 |
+
"patch_successfully_applied": bool(report.get("patch_successfully_applied")),
|
| 72 |
+
"model_patch": model_patch,
|
| 73 |
+
"fail_to_pass": report.get("fail_to_pass"),
|
| 74 |
+
"pass_to_pass": report.get("pass_to_pass"),
|
| 75 |
+
"status_map": report.get("status_map") or {},
|
| 76 |
+
"eval_error": report.get("error"),
|
| 77 |
+
"eval_log_dir": report.get("log_dir"),
|
| 78 |
+
"eval_exit_code": report.get("eval_exit_code"),
|
| 79 |
+
"n_tool_calls": len(tool_calls),
|
| 80 |
+
"tools_used": tools_used,
|
| 81 |
+
"image": report.get("image") or task.get("image"),
|
| 82 |
+
"grader": "swebench_docker_official_protocol",
|
| 83 |
+
}
|
| 84 |
+
step(
|
| 85 |
+
"grade.done",
|
| 86 |
+
f"resolution={out['resolution']}",
|
| 87 |
+
resolved=out["resolved"],
|
| 88 |
+
pass_frac=out["pass_frac"],
|
| 89 |
+
eval_error=out["eval_error"],
|
| 90 |
+
)
|
| 91 |
+
return out
|
src/deepagents_rl/harness/repo.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Full git checkout of a SWE-bench instance repo at base_commit."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
import subprocess
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from deepagents_rl.observability.logging import call, error, result, span, truncate
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def repo_cache_root() -> Path:
|
| 13 |
+
root = Path(os.getenv("SWE_REPO_CACHE", "")) if os.getenv("SWE_REPO_CACHE") else None
|
| 14 |
+
if root is None:
|
| 15 |
+
root = Path(__file__).resolve().parents[3] / "data" / "repos"
|
| 16 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
return root
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _run_git(
|
| 21 |
+
args: list[str],
|
| 22 |
+
*,
|
| 23 |
+
cwd: str | Path | None = None,
|
| 24 |
+
check: bool = True,
|
| 25 |
+
timeout: int = 600,
|
| 26 |
+
) -> subprocess.CompletedProcess[str]:
|
| 27 |
+
cmd = ["git", *args]
|
| 28 |
+
call("git", " ".join(cmd), cwd=str(cwd) if cwd else None, timeout_s=timeout)
|
| 29 |
+
try:
|
| 30 |
+
proc = subprocess.run(
|
| 31 |
+
cmd,
|
| 32 |
+
cwd=str(cwd) if cwd else None,
|
| 33 |
+
check=False,
|
| 34 |
+
capture_output=True,
|
| 35 |
+
text=True,
|
| 36 |
+
timeout=timeout,
|
| 37 |
+
)
|
| 38 |
+
except subprocess.TimeoutExpired as e:
|
| 39 |
+
error("git", f"timeout after {timeout}s", cmd=cmd)
|
| 40 |
+
raise e
|
| 41 |
+
out = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
| 42 |
+
result(
|
| 43 |
+
"git",
|
| 44 |
+
f"exit={proc.returncode}",
|
| 45 |
+
exit_code=proc.returncode,
|
| 46 |
+
output=truncate(out, 600),
|
| 47 |
+
)
|
| 48 |
+
if check and proc.returncode != 0:
|
| 49 |
+
raise subprocess.CalledProcessError(proc.returncode, cmd, proc.stdout, proc.stderr)
|
| 50 |
+
return proc
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def clone_or_fetch(repo: str, cache_dir: Path | None = None) -> Path:
|
| 54 |
+
"""Mirror-clone (once) github.com/{repo} into the local cache."""
|
| 55 |
+
cache = cache_dir or repo_cache_root()
|
| 56 |
+
mirror = cache / f"{repo.replace('/', '__')}.git"
|
| 57 |
+
url = f"https://github.com/{repo}.git"
|
| 58 |
+
with span("repo.clone_or_fetch", repo=repo, mirror=str(mirror)):
|
| 59 |
+
if mirror.exists():
|
| 60 |
+
call("repo.fetch", f"fetch existing mirror {mirror}")
|
| 61 |
+
_run_git(
|
| 62 |
+
["fetch", "--all", "--tags", "--prune"],
|
| 63 |
+
cwd=mirror,
|
| 64 |
+
check=False,
|
| 65 |
+
timeout=600,
|
| 66 |
+
)
|
| 67 |
+
return mirror
|
| 68 |
+
mirror.parent.mkdir(parents=True, exist_ok=True)
|
| 69 |
+
call("repo.mirror_clone", f"clone --mirror {url}")
|
| 70 |
+
_run_git(["clone", "--mirror", url, str(mirror)], timeout=1800)
|
| 71 |
+
return mirror
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def materialize_repo_at_commit(
|
| 75 |
+
*,
|
| 76 |
+
repo: str,
|
| 77 |
+
base_commit: str,
|
| 78 |
+
dest: Path,
|
| 79 |
+
cache_dir: Path | None = None,
|
| 80 |
+
) -> Path:
|
| 81 |
+
"""
|
| 82 |
+
Create a full working tree at `dest` checked out to `base_commit`.
|
| 83 |
+
|
| 84 |
+
This is the agent sandbox — not a sparse file dump.
|
| 85 |
+
"""
|
| 86 |
+
with span(
|
| 87 |
+
"repo.materialize",
|
| 88 |
+
f"{repo}@{base_commit[:12]}",
|
| 89 |
+
repo=repo,
|
| 90 |
+
base_commit=base_commit,
|
| 91 |
+
dest=str(dest),
|
| 92 |
+
):
|
| 93 |
+
if dest.exists():
|
| 94 |
+
call("repo.rmtree", str(dest))
|
| 95 |
+
shutil.rmtree(dest, ignore_errors=True)
|
| 96 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 97 |
+
|
| 98 |
+
mirror = clone_or_fetch(repo, cache_dir=cache_dir)
|
| 99 |
+
call("repo.workdir_clone", f"from {mirror} -> {dest}")
|
| 100 |
+
_run_git(["clone", str(mirror), str(dest)], timeout=600)
|
| 101 |
+
call("repo.checkout", base_commit)
|
| 102 |
+
_run_git(["checkout", "--force", base_commit], cwd=dest, timeout=120)
|
| 103 |
+
_run_git(["reset", "--hard", "HEAD"], cwd=dest, timeout=60)
|
| 104 |
+
_run_git(["clean", "-fdx"], cwd=dest, timeout=60)
|
| 105 |
+
head = _run_git(["rev-parse", "HEAD"], cwd=dest, timeout=30)
|
| 106 |
+
result(
|
| 107 |
+
"repo.materialize",
|
| 108 |
+
"sandbox ready",
|
| 109 |
+
head=(head.stdout or "").strip(),
|
| 110 |
+
dest=str(dest),
|
| 111 |
+
)
|
| 112 |
+
return dest
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def git_diff_patch(repo_dir: Path) -> str:
|
| 116 |
+
"""Unified diff of all agent edits (tracked + untracked as new files)."""
|
| 117 |
+
with span("repo.git_diff_patch", repo_dir=str(repo_dir)):
|
| 118 |
+
_run_git(["add", "-A"], cwd=repo_dir, timeout=60)
|
| 119 |
+
proc = _run_git(["diff", "--cached", "--binary"], cwd=repo_dir, timeout=60)
|
| 120 |
+
_run_git(["reset", "HEAD"], cwd=repo_dir, check=False, timeout=60)
|
| 121 |
+
patch = proc.stdout or ""
|
| 122 |
+
result(
|
| 123 |
+
"repo.git_diff_patch",
|
| 124 |
+
f"patch_bytes={len(patch.encode('utf-8'))}",
|
| 125 |
+
patch_preview=truncate(patch, 500),
|
| 126 |
+
empty=not bool(patch.strip()),
|
| 127 |
+
)
|
| 128 |
+
return patch
|
src/deepagents_rl/harness/swe_docker_eval.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Official-style SWE-bench evaluation via the instance Docker image.
|
| 2 |
+
|
| 3 |
+
Protocol (matches SWE-bench harness):
|
| 4 |
+
1. Start the prebuilt instance image (`task['image']`)
|
| 5 |
+
2. Apply the model's git patch into `/testbed`
|
| 6 |
+
3. Run the instance `eval_script` (applies test_patch, runs pytest)
|
| 7 |
+
4. Parse status between `>>>>> Start/End Test Output`
|
| 8 |
+
5. Resolve = all FAIL_TO_PASS pass AND all PASS_TO_PASS pass
|
| 9 |
+
|
| 10 |
+
Uses the Docker CLI (not the Python SDK) for reliability on Windows.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import shutil
|
| 16 |
+
import subprocess
|
| 17 |
+
import tempfile
|
| 18 |
+
import time
|
| 19 |
+
import uuid
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from deepagents_rl.observability.logging import call, error, result, span, step, truncate
|
| 24 |
+
|
| 25 |
+
START_TEST_OUTPUT = ">>>>> Start Test Output"
|
| 26 |
+
END_TEST_OUTPUT = ">>>>> End Test Output"
|
| 27 |
+
|
| 28 |
+
GIT_APPLY_CMDS = [
|
| 29 |
+
"git apply --verbose",
|
| 30 |
+
"git apply --verbose --3way",
|
| 31 |
+
"git apply --verbose --reject",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def fix_eval_script_for_docker_desktop(script: str) -> str:
|
| 36 |
+
"""
|
| 37 |
+
Upstream eval_script uses `conda activate testbed`. On Docker Desktop the
|
| 38 |
+
image's *base* conda entrypoint is often broken (`conda` -> TypeError), while
|
| 39 |
+
`/opt/miniconda3/envs/testbed/bin/*` works. Prefetch that env onto PATH and
|
| 40 |
+
stub `conda activate` so the official test_patch + pytest body still runs.
|
| 41 |
+
"""
|
| 42 |
+
preamble = (
|
| 43 |
+
"#!/bin/bash\n"
|
| 44 |
+
"set -uxo pipefail\n"
|
| 45 |
+
'export PATH="/opt/miniconda3/envs/testbed/bin:/opt/miniconda3/bin:$PATH"\n'
|
| 46 |
+
"conda() {\n"
|
| 47 |
+
' if [ "$1" = "activate" ]; then\n'
|
| 48 |
+
' export PATH="/opt/miniconda3/envs/${2:-testbed}/bin:$PATH"\n'
|
| 49 |
+
" hash -r\n"
|
| 50 |
+
" return 0\n"
|
| 51 |
+
" fi\n"
|
| 52 |
+
" return 0\n"
|
| 53 |
+
"}\n"
|
| 54 |
+
)
|
| 55 |
+
body: list[str] = []
|
| 56 |
+
for line in script.replace("\r\n", "\n").splitlines():
|
| 57 |
+
if line.startswith("#!"):
|
| 58 |
+
continue
|
| 59 |
+
if line.strip().startswith("source /opt/miniconda3/bin/activate"):
|
| 60 |
+
continue
|
| 61 |
+
if "conda activate" in line:
|
| 62 |
+
continue
|
| 63 |
+
body.append(line)
|
| 64 |
+
return preamble + "\n".join(body) + "\n"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def parse_log_pytest(log: str) -> dict[str, str]:
|
| 68 |
+
"""SWE-bench pytest log parser (status token first on the line)."""
|
| 69 |
+
status_map: dict[str, str] = {}
|
| 70 |
+
statuses = ("PASSED", "FAILED", "ERROR", "SKIPPED", "XFAIL", "XPASS")
|
| 71 |
+
for line in log.splitlines():
|
| 72 |
+
if not any(line.startswith(s) for s in statuses):
|
| 73 |
+
continue
|
| 74 |
+
if line.startswith("FAILED"):
|
| 75 |
+
line = line.replace(" - ", " ")
|
| 76 |
+
parts = line.split()
|
| 77 |
+
if len(parts) <= 1:
|
| 78 |
+
continue
|
| 79 |
+
status_map[parts[1]] = parts[0]
|
| 80 |
+
return status_map
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _extract_test_region(log: str) -> str | None:
|
| 84 |
+
if START_TEST_OUTPUT not in log or END_TEST_OUTPUT not in log:
|
| 85 |
+
return None
|
| 86 |
+
return log.split(START_TEST_OUTPUT, 1)[1].split(END_TEST_OUTPUT, 1)[0]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _test_passed(case: str, sm: dict[str, str]) -> bool:
|
| 90 |
+
return case in sm and sm[case] in ("PASSED", "XFAIL")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _test_failed(case: str, sm: dict[str, str]) -> bool:
|
| 94 |
+
return case not in sm or sm[case] in ("FAILED", "ERROR", "SKIPPED")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _test_maintained(case: str, sm: dict[str, str]) -> bool:
|
| 98 |
+
return _test_passed(case, sm) or (case in sm and sm[case] == "SKIPPED")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def grade_status_map(
|
| 102 |
+
status_map: dict[str, str],
|
| 103 |
+
fail_to_pass: list[str],
|
| 104 |
+
pass_to_pass: list[str],
|
| 105 |
+
) -> dict[str, Any]:
|
| 106 |
+
"""Official resolution: FULL iff all F2P pass and all P2P maintained."""
|
| 107 |
+
f2p_ok = [t for t in fail_to_pass if _test_passed(t, status_map)]
|
| 108 |
+
f2p_fail = [t for t in fail_to_pass if _test_failed(t, status_map)]
|
| 109 |
+
p2p_ok = [t for t in pass_to_pass if _test_maintained(t, status_map)]
|
| 110 |
+
p2p_fail = [t for t in pass_to_pass if not _test_maintained(t, status_map)]
|
| 111 |
+
|
| 112 |
+
f2p_frac = (len(f2p_ok) / len(fail_to_pass)) if fail_to_pass else 1.0
|
| 113 |
+
p2p_frac = (len(p2p_ok) / len(pass_to_pass)) if pass_to_pass else 1.0
|
| 114 |
+
|
| 115 |
+
if f2p_frac == 1.0 and p2p_frac == 1.0:
|
| 116 |
+
resolved = "FULL"
|
| 117 |
+
elif 0.0 < f2p_frac < 1.0 and p2p_frac == 1.0:
|
| 118 |
+
resolved = "PARTIAL"
|
| 119 |
+
else:
|
| 120 |
+
resolved = "NO"
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
"resolved": resolved == "FULL",
|
| 124 |
+
"resolution": resolved,
|
| 125 |
+
"fail_to_pass": {"success": f2p_ok, "failure": f2p_fail, "frac": f2p_frac},
|
| 126 |
+
"pass_to_pass": {"success": p2p_ok, "failure": p2p_fail, "frac": p2p_frac},
|
| 127 |
+
"pass_frac": float(f2p_frac),
|
| 128 |
+
# P2P regressions discount reward (cannot claim FULL/PARTIAL)
|
| 129 |
+
"reward_total": float(f2p_frac if p2p_frac == 1.0 else f2p_frac * 0.5),
|
| 130 |
+
"status_map": status_map,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _docker(
|
| 135 |
+
*args: str,
|
| 136 |
+
timeout: int | None = None,
|
| 137 |
+
merge_stderr: bool = False,
|
| 138 |
+
) -> subprocess.CompletedProcess[str]:
|
| 139 |
+
exe = shutil.which("docker")
|
| 140 |
+
if not exe:
|
| 141 |
+
raise RuntimeError("docker CLI not found on PATH")
|
| 142 |
+
cmdline = "docker " + " ".join(args)
|
| 143 |
+
call("docker", cmdline, timeout_s=timeout, merge_stderr=merge_stderr)
|
| 144 |
+
t0 = time.perf_counter()
|
| 145 |
+
try:
|
| 146 |
+
if merge_stderr:
|
| 147 |
+
proc = subprocess.run(
|
| 148 |
+
[exe, *args],
|
| 149 |
+
stdout=subprocess.PIPE,
|
| 150 |
+
stderr=subprocess.STDOUT,
|
| 151 |
+
text=True,
|
| 152 |
+
timeout=timeout,
|
| 153 |
+
encoding="utf-8",
|
| 154 |
+
errors="replace",
|
| 155 |
+
)
|
| 156 |
+
else:
|
| 157 |
+
proc = subprocess.run(
|
| 158 |
+
[exe, *args],
|
| 159 |
+
capture_output=True,
|
| 160 |
+
text=True,
|
| 161 |
+
timeout=timeout,
|
| 162 |
+
encoding="utf-8",
|
| 163 |
+
errors="replace",
|
| 164 |
+
)
|
| 165 |
+
except subprocess.TimeoutExpired:
|
| 166 |
+
error("docker", f"timeout after {timeout}s", cmd=cmdline)
|
| 167 |
+
raise
|
| 168 |
+
dt = int((time.perf_counter() - t0) * 1000)
|
| 169 |
+
out = (proc.stdout or "") + ("" if merge_stderr else (proc.stderr or ""))
|
| 170 |
+
result(
|
| 171 |
+
"docker",
|
| 172 |
+
f"exit={proc.returncode} ({dt}ms)",
|
| 173 |
+
exit_code=proc.returncode,
|
| 174 |
+
duration_ms=dt,
|
| 175 |
+
output=truncate(out, 800),
|
| 176 |
+
)
|
| 177 |
+
return proc
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def evaluate_model_patch(
|
| 181 |
+
task: dict[str, Any],
|
| 182 |
+
model_patch: str,
|
| 183 |
+
*,
|
| 184 |
+
timeout_s: int = 1800,
|
| 185 |
+
log_dir: Path | None = None,
|
| 186 |
+
keep_container: bool = False,
|
| 187 |
+
) -> dict[str, Any]:
|
| 188 |
+
"""
|
| 189 |
+
Grade one prediction with the official instance Docker image + eval_script.
|
| 190 |
+
|
| 191 |
+
`task` must include: instance_id, image, eval_script, fail_to_pass, pass_to_pass
|
| 192 |
+
"""
|
| 193 |
+
instance_id = task["instance_id"]
|
| 194 |
+
image = task.get("image")
|
| 195 |
+
eval_script = task.get("eval_script") or ""
|
| 196 |
+
fail_to_pass = list(task.get("fail_to_pass") or [])
|
| 197 |
+
pass_to_pass = list(task.get("pass_to_pass") or [])
|
| 198 |
+
|
| 199 |
+
if not image:
|
| 200 |
+
raise ValueError(f"{instance_id}: missing Docker image field from SWE-bench Lite")
|
| 201 |
+
if not eval_script.strip():
|
| 202 |
+
raise ValueError(f"{instance_id}: missing eval_script")
|
| 203 |
+
|
| 204 |
+
if log_dir is None:
|
| 205 |
+
log_dir = Path(tempfile.mkdtemp(prefix="swe_eval_"))
|
| 206 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 207 |
+
|
| 208 |
+
report: dict[str, Any] = {
|
| 209 |
+
"instance_id": instance_id,
|
| 210 |
+
"image": image,
|
| 211 |
+
"patch_exists": bool(model_patch and model_patch.strip()),
|
| 212 |
+
"patch_successfully_applied": False,
|
| 213 |
+
"resolved": False,
|
| 214 |
+
"resolution": "NO",
|
| 215 |
+
"pass_frac": 0.0,
|
| 216 |
+
"reward_total": 0.0,
|
| 217 |
+
"fail_to_pass": {"success": [], "failure": fail_to_pass, "frac": 0.0},
|
| 218 |
+
"pass_to_pass": {"success": [], "failure": [], "frac": 0.0},
|
| 219 |
+
"status_map": {},
|
| 220 |
+
"error": None,
|
| 221 |
+
"log_dir": str(log_dir),
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
step(
|
| 225 |
+
"eval.begin",
|
| 226 |
+
f"instance={instance_id}",
|
| 227 |
+
image=image,
|
| 228 |
+
patch_bytes=len((model_patch or "").encode("utf-8")),
|
| 229 |
+
n_f2p=len(fail_to_pass),
|
| 230 |
+
n_p2p=len(pass_to_pass),
|
| 231 |
+
log_dir=str(log_dir),
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
if not model_patch or not model_patch.strip():
|
| 235 |
+
step("eval.empty_patch", "no model_patch — unresolved")
|
| 236 |
+
report["error"] = "empty_model_patch"
|
| 237 |
+
(log_dir / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 238 |
+
return report
|
| 239 |
+
|
| 240 |
+
patch_path = log_dir / "model.patch"
|
| 241 |
+
eval_path = log_dir / "eval.sh"
|
| 242 |
+
eval_raw_path = log_dir / "eval.upstream.sh"
|
| 243 |
+
patch_path.write_text(model_patch.replace("\r\n", "\n"), encoding="utf-8", newline="\n")
|
| 244 |
+
eval_raw_path.write_text(eval_script.replace("\r\n", "\n"), encoding="utf-8", newline="\n")
|
| 245 |
+
eval_path.write_text(
|
| 246 |
+
fix_eval_script_for_docker_desktop(eval_script),
|
| 247 |
+
encoding="utf-8",
|
| 248 |
+
newline="\n",
|
| 249 |
+
)
|
| 250 |
+
step(
|
| 251 |
+
"eval.write_files",
|
| 252 |
+
"wrote model.patch + eval.sh",
|
| 253 |
+
patch_path=str(patch_path),
|
| 254 |
+
eval_path=str(eval_path),
|
| 255 |
+
patch_preview=truncate(model_patch, 400),
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
run_tag = uuid.uuid4().hex[:10]
|
| 259 |
+
container = f"darl-eval-{run_tag}"
|
| 260 |
+
|
| 261 |
+
try:
|
| 262 |
+
with span("eval.docker_lifecycle", container=container, image=image):
|
| 263 |
+
inspect = _docker("image", "inspect", image, timeout=60)
|
| 264 |
+
if inspect.returncode != 0:
|
| 265 |
+
step("eval.docker_pull", f"image missing locally, pulling {image}")
|
| 266 |
+
pull = _docker("pull", image, timeout=max(timeout_s, 3600))
|
| 267 |
+
if pull.returncode != 0:
|
| 268 |
+
report["error"] = f"docker_pull_failed: {(pull.stderr or pull.stdout)[-2000:]}"
|
| 269 |
+
error("eval.docker_pull", report["error"])
|
| 270 |
+
(log_dir / "report.json").write_text(
|
| 271 |
+
json.dumps(report, indent=2), encoding="utf-8"
|
| 272 |
+
)
|
| 273 |
+
return report
|
| 274 |
+
else:
|
| 275 |
+
step("eval.docker_image", "image already present")
|
| 276 |
+
|
| 277 |
+
_docker("rm", "-f", container, timeout=60)
|
| 278 |
+
|
| 279 |
+
create = _docker(
|
| 280 |
+
"create",
|
| 281 |
+
"--name",
|
| 282 |
+
container,
|
| 283 |
+
"--workdir",
|
| 284 |
+
"/testbed",
|
| 285 |
+
image,
|
| 286 |
+
"tail",
|
| 287 |
+
"-f",
|
| 288 |
+
"/dev/null",
|
| 289 |
+
timeout=120,
|
| 290 |
+
)
|
| 291 |
+
if create.returncode != 0:
|
| 292 |
+
report["error"] = (
|
| 293 |
+
f"docker_create_failed: {(create.stderr or create.stdout)[-2000:]}"
|
| 294 |
+
)
|
| 295 |
+
error("eval.docker_create", report["error"])
|
| 296 |
+
(log_dir / "report.json").write_text(
|
| 297 |
+
json.dumps(report, indent=2), encoding="utf-8"
|
| 298 |
+
)
|
| 299 |
+
return report
|
| 300 |
+
|
| 301 |
+
start = _docker("start", container, timeout=120)
|
| 302 |
+
if start.returncode != 0:
|
| 303 |
+
report["error"] = (
|
| 304 |
+
f"docker_start_failed: {(start.stderr or start.stdout)[-2000:]}"
|
| 305 |
+
)
|
| 306 |
+
error("eval.docker_start", report["error"])
|
| 307 |
+
(log_dir / "report.json").write_text(
|
| 308 |
+
json.dumps(report, indent=2), encoding="utf-8"
|
| 309 |
+
)
|
| 310 |
+
return report
|
| 311 |
+
|
| 312 |
+
step("eval.docker_cp", "copy patch + eval.sh into container")
|
| 313 |
+
cp1 = _docker("cp", str(patch_path), f"{container}:/tmp/model.patch", timeout=120)
|
| 314 |
+
cp2 = _docker("cp", str(eval_path), f"{container}:/eval.sh", timeout=120)
|
| 315 |
+
if cp1.returncode != 0 or cp2.returncode != 0:
|
| 316 |
+
report["error"] = (
|
| 317 |
+
f"docker_cp_failed: patch={(cp1.stderr or '')[-500:]} "
|
| 318 |
+
f"eval={(cp2.stderr or '')[-500:]}"
|
| 319 |
+
)
|
| 320 |
+
error("eval.docker_cp", report["error"])
|
| 321 |
+
(log_dir / "report.json").write_text(
|
| 322 |
+
json.dumps(report, indent=2), encoding="utf-8"
|
| 323 |
+
)
|
| 324 |
+
return report
|
| 325 |
+
|
| 326 |
+
_docker(
|
| 327 |
+
"exec",
|
| 328 |
+
"-w",
|
| 329 |
+
"/testbed",
|
| 330 |
+
container,
|
| 331 |
+
"git",
|
| 332 |
+
"config",
|
| 333 |
+
"--global",
|
| 334 |
+
"--add",
|
| 335 |
+
"safe.directory",
|
| 336 |
+
"/testbed",
|
| 337 |
+
timeout=60,
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
apply_log = []
|
| 341 |
+
applied = False
|
| 342 |
+
step("eval.apply_patch", "trying git apply cascade")
|
| 343 |
+
for cmd in GIT_APPLY_CMDS:
|
| 344 |
+
parts = cmd.split()
|
| 345 |
+
resp = _docker(
|
| 346 |
+
"exec",
|
| 347 |
+
"-w",
|
| 348 |
+
"/testbed",
|
| 349 |
+
container,
|
| 350 |
+
*parts,
|
| 351 |
+
"/tmp/model.patch",
|
| 352 |
+
timeout=120,
|
| 353 |
+
)
|
| 354 |
+
apply_log.append(
|
| 355 |
+
{
|
| 356 |
+
"cmd": f"{cmd} /tmp/model.patch",
|
| 357 |
+
"exit": resp.returncode,
|
| 358 |
+
"out": ((resp.stdout or "") + (resp.stderr or ""))[-2000:],
|
| 359 |
+
}
|
| 360 |
+
)
|
| 361 |
+
if resp.returncode == 0:
|
| 362 |
+
applied = True
|
| 363 |
+
step("eval.apply_patch_ok", cmd)
|
| 364 |
+
break
|
| 365 |
+
step("eval.apply_patch_retry", f"failed: {cmd}", exit_code=resp.returncode)
|
| 366 |
+
(log_dir / "apply_patch.json").write_text(
|
| 367 |
+
json.dumps(apply_log, indent=2), encoding="utf-8"
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
if not applied:
|
| 371 |
+
report["error"] = "patch_apply_failed"
|
| 372 |
+
report["apply_log"] = apply_log
|
| 373 |
+
error("eval.apply_patch", "all git apply strategies failed")
|
| 374 |
+
(log_dir / "test_output.txt").write_text(
|
| 375 |
+
json.dumps(apply_log, indent=2), encoding="utf-8"
|
| 376 |
+
)
|
| 377 |
+
(log_dir / "report.json").write_text(
|
| 378 |
+
json.dumps(report, indent=2), encoding="utf-8"
|
| 379 |
+
)
|
| 380 |
+
return report
|
| 381 |
+
|
| 382 |
+
report["patch_successfully_applied"] = True
|
| 383 |
+
|
| 384 |
+
step("eval.run_eval_script", "bash /eval.sh (official test_patch + pytest)")
|
| 385 |
+
t0 = time.time()
|
| 386 |
+
eval_proc = _docker(
|
| 387 |
+
"exec",
|
| 388 |
+
"-w",
|
| 389 |
+
"/testbed",
|
| 390 |
+
container,
|
| 391 |
+
"/bin/bash",
|
| 392 |
+
"-lc",
|
| 393 |
+
"/bin/bash /eval.sh 2>&1",
|
| 394 |
+
timeout=timeout_s,
|
| 395 |
+
merge_stderr=True,
|
| 396 |
+
)
|
| 397 |
+
elapsed = time.time() - t0
|
| 398 |
+
test_output = eval_proc.stdout or ""
|
| 399 |
+
(log_dir / "test_output.txt").write_text(test_output, encoding="utf-8")
|
| 400 |
+
report["eval_exit_code"] = eval_proc.returncode
|
| 401 |
+
report["eval_seconds"] = elapsed
|
| 402 |
+
step(
|
| 403 |
+
"eval.eval_script_done",
|
| 404 |
+
f"exit={eval_proc.returncode} seconds={elapsed:.1f}",
|
| 405 |
+
output_chars=len(test_output),
|
| 406 |
+
output_tail=truncate(test_output, 600),
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
region = _extract_test_region(test_output)
|
| 410 |
+
status_map = parse_log_pytest(region or "")
|
| 411 |
+
if not status_map:
|
| 412 |
+
step("eval.parse_fallback", "marked region empty — parsing full log")
|
| 413 |
+
status_map = parse_log_pytest(test_output)
|
| 414 |
+
if not status_map:
|
| 415 |
+
report["error"] = (
|
| 416 |
+
"missing_test_output_markers"
|
| 417 |
+
if region is None
|
| 418 |
+
else "no_parsed_test_statuses"
|
| 419 |
+
)
|
| 420 |
+
error("eval.parse", report["error"])
|
| 421 |
+
(log_dir / "report.json").write_text(
|
| 422 |
+
json.dumps(report, indent=2), encoding="utf-8"
|
| 423 |
+
)
|
| 424 |
+
return report
|
| 425 |
+
|
| 426 |
+
step(
|
| 427 |
+
"eval.parse_ok",
|
| 428 |
+
f"parsed {len(status_map)} test statuses",
|
| 429 |
+
sample=list(status_map.items())[:8],
|
| 430 |
+
)
|
| 431 |
+
graded = grade_status_map(status_map, fail_to_pass, pass_to_pass)
|
| 432 |
+
report.update(graded)
|
| 433 |
+
report["error"] = None
|
| 434 |
+
step(
|
| 435 |
+
"eval.grade",
|
| 436 |
+
f"resolution={graded['resolution']} f2p={graded['pass_frac']}",
|
| 437 |
+
resolved=graded["resolved"],
|
| 438 |
+
resolution=graded["resolution"],
|
| 439 |
+
pass_frac=graded["pass_frac"],
|
| 440 |
+
reward_total=graded["reward_total"],
|
| 441 |
+
f2p_success=graded["fail_to_pass"]["success"],
|
| 442 |
+
f2p_failure=graded["fail_to_pass"]["failure"],
|
| 443 |
+
p2p_frac=graded["pass_to_pass"]["frac"],
|
| 444 |
+
)
|
| 445 |
+
(log_dir / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 446 |
+
return report
|
| 447 |
+
|
| 448 |
+
except subprocess.TimeoutExpired as e:
|
| 449 |
+
report["error"] = f"timeout: {e}"
|
| 450 |
+
error("eval.timeout", report["error"])
|
| 451 |
+
(log_dir / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 452 |
+
return report
|
| 453 |
+
except Exception as e: # noqa: BLE001
|
| 454 |
+
report["error"] = f"{type(e).__name__}: {e}"
|
| 455 |
+
error("eval.exception", report["error"])
|
| 456 |
+
(log_dir / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 457 |
+
return report
|
| 458 |
+
finally:
|
| 459 |
+
if not keep_container:
|
| 460 |
+
try:
|
| 461 |
+
step("eval.cleanup", f"rm container {container}")
|
| 462 |
+
_docker("rm", "-f", container, timeout=120)
|
| 463 |
+
except Exception as e: # noqa: BLE001
|
| 464 |
+
error("eval.cleanup", str(e))
|
| 465 |
+
|
src/deepagents_rl/observability/__init__.py
CHANGED
|
@@ -1,3 +1,15 @@
|
|
| 1 |
from .langfuse_eval import init_langfuse, log_checkpoint_eval
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from .langfuse_eval import init_langfuse, log_checkpoint_eval
|
| 2 |
+
from .langfuse_trace import llm_invoke_config, make_callback_handler
|
| 3 |
+
from .logging import configure_logging, get_logger, log_event, span, step
|
| 4 |
|
| 5 |
+
__all__ = [
|
| 6 |
+
"init_langfuse",
|
| 7 |
+
"log_checkpoint_eval",
|
| 8 |
+
"make_callback_handler",
|
| 9 |
+
"llm_invoke_config",
|
| 10 |
+
"configure_logging",
|
| 11 |
+
"get_logger",
|
| 12 |
+
"log_event",
|
| 13 |
+
"span",
|
| 14 |
+
"step",
|
| 15 |
+
]
|
src/deepagents_rl/observability/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (547 Bytes). View file
|
|
|
src/deepagents_rl/observability/__pycache__/langfuse_eval.cpython-312.pyc
ADDED
|
Binary file (4.69 kB). View file
|
|
|
src/deepagents_rl/observability/__pycache__/langfuse_trace.cpython-312.pyc
ADDED
|
Binary file (1.25 kB). View file
|
|
|
src/deepagents_rl/observability/__pycache__/logging.cpython-312.pyc
ADDED
|
Binary file (8.27 kB). View file
|
|
|
src/deepagents_rl/observability/langfuse_trace.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers for granular Langfuse traces (LLM generations + tool spans)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def make_callback_handler():
|
| 8 |
+
"""LangChain CallbackHandler that nests generations under the current observation."""
|
| 9 |
+
from langfuse.langchain import CallbackHandler
|
| 10 |
+
|
| 11 |
+
return CallbackHandler()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def llm_invoke_config(
|
| 15 |
+
*,
|
| 16 |
+
handler: Any,
|
| 17 |
+
turn: int,
|
| 18 |
+
episode_id: str | None = None,
|
| 19 |
+
extra_metadata: dict[str, Any] | None = None,
|
| 20 |
+
) -> dict[str, Any]:
|
| 21 |
+
meta = {"turn": turn}
|
| 22 |
+
if episode_id:
|
| 23 |
+
meta["episode_id"] = episode_id
|
| 24 |
+
if extra_metadata:
|
| 25 |
+
meta.update(extra_metadata)
|
| 26 |
+
return {
|
| 27 |
+
"callbacks": [handler],
|
| 28 |
+
"run_name": f"llm-turn-{turn}",
|
| 29 |
+
"tags": ["deepagents-rl", "llm", f"turn-{turn}"],
|
| 30 |
+
"metadata": meta,
|
| 31 |
+
}
|
src/deepagents_rl/observability/logging.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structured step logging for deepagents-rl pipelines.
|
| 2 |
+
|
| 3 |
+
Emits:
|
| 4 |
+
- Console (stderr) with timestamps + step tags
|
| 5 |
+
- Optional JSONL event file for full replay (`events.jsonl`)
|
| 6 |
+
|
| 7 |
+
Env:
|
| 8 |
+
DARL_LOG_LEVEL DEBUG|INFO|WARNING (default INFO)
|
| 9 |
+
DARL_LOG_FILE path to text log (optional)
|
| 10 |
+
DARL_EVENTS_JSONL path to JSONL events (optional; auto under artifacts if set via configure)
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import threading
|
| 19 |
+
import time
|
| 20 |
+
import traceback
|
| 21 |
+
from contextlib import contextmanager
|
| 22 |
+
from datetime import datetime, timezone
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Any, Iterator
|
| 25 |
+
|
| 26 |
+
_LOCK = threading.Lock()
|
| 27 |
+
_EVENTS_PATH: Path | None = None
|
| 28 |
+
_CONFIGURED = False
|
| 29 |
+
|
| 30 |
+
LOGGER_NAME = "deepagents_rl"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _utc() -> str:
|
| 34 |
+
return datetime.now(timezone.utc).isoformat()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_logger(name: str | None = None) -> logging.Logger:
|
| 38 |
+
return logging.getLogger(name or LOGGER_NAME)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def configure_logging(
|
| 42 |
+
*,
|
| 43 |
+
level: str | None = None,
|
| 44 |
+
log_file: str | Path | None = None,
|
| 45 |
+
events_jsonl: str | Path | None = None,
|
| 46 |
+
force: bool = False,
|
| 47 |
+
) -> logging.Logger:
|
| 48 |
+
"""Idempotent logging setup for CLI scripts and harness modules."""
|
| 49 |
+
global _CONFIGURED, _EVENTS_PATH
|
| 50 |
+
log = get_logger()
|
| 51 |
+
|
| 52 |
+
lvl_name = (level or os.getenv("DARL_LOG_LEVEL") or "INFO").upper()
|
| 53 |
+
lvl = getattr(logging, lvl_name, logging.INFO)
|
| 54 |
+
|
| 55 |
+
if _CONFIGURED and not force:
|
| 56 |
+
log.setLevel(lvl)
|
| 57 |
+
if events_jsonl:
|
| 58 |
+
_EVENTS_PATH = Path(events_jsonl)
|
| 59 |
+
_EVENTS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
return log
|
| 61 |
+
|
| 62 |
+
log.setLevel(lvl)
|
| 63 |
+
log.handlers.clear()
|
| 64 |
+
log.propagate = False
|
| 65 |
+
|
| 66 |
+
fmt = logging.Formatter(
|
| 67 |
+
fmt="%(asctime)s | %(levelname)-5s | %(name)s | %(message)s",
|
| 68 |
+
datefmt="%H:%M:%S",
|
| 69 |
+
)
|
| 70 |
+
sh = logging.StreamHandler(sys.stderr)
|
| 71 |
+
sh.setLevel(lvl)
|
| 72 |
+
sh.setFormatter(fmt)
|
| 73 |
+
log.addHandler(sh)
|
| 74 |
+
|
| 75 |
+
file_path = log_file or os.getenv("DARL_LOG_FILE")
|
| 76 |
+
if file_path:
|
| 77 |
+
fp = Path(file_path)
|
| 78 |
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
| 79 |
+
fh = logging.FileHandler(fp, encoding="utf-8")
|
| 80 |
+
fh.setLevel(lvl)
|
| 81 |
+
fh.setFormatter(fmt)
|
| 82 |
+
log.addHandler(fh)
|
| 83 |
+
|
| 84 |
+
ev = events_jsonl or os.getenv("DARL_EVENTS_JSONL")
|
| 85 |
+
if ev:
|
| 86 |
+
_EVENTS_PATH = Path(ev)
|
| 87 |
+
_EVENTS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
else:
|
| 89 |
+
_EVENTS_PATH = None
|
| 90 |
+
|
| 91 |
+
_CONFIGURED = True
|
| 92 |
+
log.debug("logging configured level=%s file=%s events=%s", lvl_name, file_path, _EVENTS_PATH)
|
| 93 |
+
return log
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _write_event(event: dict[str, Any]) -> None:
|
| 97 |
+
if _EVENTS_PATH is None:
|
| 98 |
+
return
|
| 99 |
+
with _LOCK:
|
| 100 |
+
with _EVENTS_PATH.open("a", encoding="utf-8") as f:
|
| 101 |
+
f.write(json.dumps(event, default=str) + "\n")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def log_event(
|
| 105 |
+
event_type: str,
|
| 106 |
+
message: str,
|
| 107 |
+
*,
|
| 108 |
+
level: int = logging.INFO,
|
| 109 |
+
**fields: Any,
|
| 110 |
+
) -> None:
|
| 111 |
+
"""Log a structured event to console + JSONL."""
|
| 112 |
+
log = get_logger()
|
| 113 |
+
payload = {
|
| 114 |
+
"ts": _utc(),
|
| 115 |
+
"event": event_type,
|
| 116 |
+
"message": message,
|
| 117 |
+
**fields,
|
| 118 |
+
}
|
| 119 |
+
# Compact console line
|
| 120 |
+
extras = {k: v for k, v in fields.items() if k not in ("traceback",)}
|
| 121 |
+
extra_s = ""
|
| 122 |
+
if extras:
|
| 123 |
+
try:
|
| 124 |
+
extra_s = " | " + json.dumps(extras, default=str)[:1500]
|
| 125 |
+
except Exception:
|
| 126 |
+
extra_s = f" | {extras!r}"[:1500]
|
| 127 |
+
log.log(level, f"[{event_type}] {message}{extra_s}")
|
| 128 |
+
_write_event(payload)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def step(name: str, message: str = "", **fields: Any) -> None:
|
| 132 |
+
log_event("step", message or name, step=name, **fields)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def call(name: str, message: str = "", **fields: Any) -> None:
|
| 136 |
+
log_event("call", message or name, call=name, **fields)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def result(name: str, message: str = "", **fields: Any) -> None:
|
| 140 |
+
log_event("result", message or name, call=name, **fields)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def error(name: str, message: str, **fields: Any) -> None:
|
| 144 |
+
log_event("error", message, level=logging.ERROR, call=name, **fields)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@contextmanager
|
| 148 |
+
def span(name: str, message: str = "", **fields: Any) -> Iterator[dict[str, Any]]:
|
| 149 |
+
"""Timed span: logs start + end with duration_ms."""
|
| 150 |
+
t0 = time.perf_counter()
|
| 151 |
+
ctx: dict[str, Any] = {"span": name, **fields}
|
| 152 |
+
log_event("span_start", message or name, **ctx)
|
| 153 |
+
try:
|
| 154 |
+
yield ctx
|
| 155 |
+
except Exception as e:
|
| 156 |
+
dt = int((time.perf_counter() - t0) * 1000)
|
| 157 |
+
error(
|
| 158 |
+
name,
|
| 159 |
+
f"{type(e).__name__}: {e}",
|
| 160 |
+
duration_ms=dt,
|
| 161 |
+
traceback=traceback.format_exc()[-3000:],
|
| 162 |
+
**{k: v for k, v in ctx.items() if k != "span"},
|
| 163 |
+
)
|
| 164 |
+
raise
|
| 165 |
+
else:
|
| 166 |
+
dt = int((time.perf_counter() - t0) * 1000)
|
| 167 |
+
log_event(
|
| 168 |
+
"span_end",
|
| 169 |
+
message or name,
|
| 170 |
+
duration_ms=dt,
|
| 171 |
+
ok=True,
|
| 172 |
+
**ctx,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def truncate(s: Any, n: int = 800) -> str:
|
| 177 |
+
text = s if isinstance(s, str) else json.dumps(s, default=str)
|
| 178 |
+
if len(text) <= n:
|
| 179 |
+
return text
|
| 180 |
+
return text[:n] + f"...(+{len(text)-n} chars)"
|
src/deepagents_rl/train/__init__.py
CHANGED
|
@@ -1,3 +1,9 @@
|
|
| 1 |
from .resolve_model import resolve_train_model
|
|
|
|
| 2 |
|
| 3 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from .resolve_model import resolve_train_model
|
| 2 |
+
from .swe_env import DeepAgentSWEEnv, grade_patch_remote, prepare_swe_grpo_rows
|
| 3 |
|
| 4 |
+
__all__ = [
|
| 5 |
+
"resolve_train_model",
|
| 6 |
+
"DeepAgentSWEEnv",
|
| 7 |
+
"grade_patch_remote",
|
| 8 |
+
"prepare_swe_grpo_rows",
|
| 9 |
+
]
|
src/deepagents_rl/train/swe_env.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TRL GRPO `environment_factory` for deepagents SWE-bench sandboxes.
|
| 2 |
+
|
| 3 |
+
Kaggle has no Docker for instance images — `get_reward` POSTs the model patch
|
| 4 |
+
to `SWE_GRADER_URL` (local `scripts/swe_grader_server.py` + tunnel).
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import shutil
|
| 11 |
+
import tempfile
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _result_to_str(obj: Any) -> str:
|
| 17 |
+
if obj is None:
|
| 18 |
+
return ""
|
| 19 |
+
if isinstance(obj, str):
|
| 20 |
+
return obj[:8000]
|
| 21 |
+
if hasattr(obj, "output") and isinstance(obj.output, str):
|
| 22 |
+
return obj.output[:8000]
|
| 23 |
+
if hasattr(obj, "error") and obj.error:
|
| 24 |
+
return f"ERROR: {obj.error}"[:8000]
|
| 25 |
+
try:
|
| 26 |
+
return json.dumps(obj, default=str)[:8000]
|
| 27 |
+
except Exception:
|
| 28 |
+
return str(obj)[:8000]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _as_str_list(val: Any) -> list[str]:
|
| 32 |
+
if val is None:
|
| 33 |
+
return []
|
| 34 |
+
if isinstance(val, list):
|
| 35 |
+
return [str(x) for x in val]
|
| 36 |
+
if isinstance(val, str):
|
| 37 |
+
s = val.strip()
|
| 38 |
+
if not s:
|
| 39 |
+
return []
|
| 40 |
+
try:
|
| 41 |
+
parsed = json.loads(s)
|
| 42 |
+
if isinstance(parsed, list):
|
| 43 |
+
return [str(x) for x in parsed]
|
| 44 |
+
except json.JSONDecodeError:
|
| 45 |
+
pass
|
| 46 |
+
return [s]
|
| 47 |
+
return [str(val)]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def grade_patch_remote(
|
| 51 |
+
*,
|
| 52 |
+
instance_id: str,
|
| 53 |
+
docker_image: str,
|
| 54 |
+
eval_script: str,
|
| 55 |
+
fail_to_pass: list[str],
|
| 56 |
+
pass_to_pass: list[str],
|
| 57 |
+
model_patch: str,
|
| 58 |
+
timeout_s: int = 1800,
|
| 59 |
+
) -> dict[str, Any]:
|
| 60 |
+
"""POST patch to the local Docker grader service."""
|
| 61 |
+
import requests
|
| 62 |
+
|
| 63 |
+
base = (os.environ.get("SWE_GRADER_URL") or "").rstrip("/")
|
| 64 |
+
if not base:
|
| 65 |
+
raise RuntimeError("SWE_GRADER_URL is not set (point at the local grader tunnel)")
|
| 66 |
+
|
| 67 |
+
headers = {
|
| 68 |
+
"Content-Type": "application/json",
|
| 69 |
+
# ngrok free tier interstitial
|
| 70 |
+
"ngrok-skip-browser-warning": "1",
|
| 71 |
+
}
|
| 72 |
+
token = os.environ.get("SWE_GRADER_TOKEN") or ""
|
| 73 |
+
if token:
|
| 74 |
+
headers["Authorization"] = f"Bearer {token}"
|
| 75 |
+
|
| 76 |
+
payload = {
|
| 77 |
+
"instance_id": instance_id,
|
| 78 |
+
"image": docker_image,
|
| 79 |
+
"eval_script": eval_script,
|
| 80 |
+
"fail_to_pass": fail_to_pass,
|
| 81 |
+
"pass_to_pass": pass_to_pass,
|
| 82 |
+
"model_patch": model_patch or "",
|
| 83 |
+
"timeout_s": timeout_s,
|
| 84 |
+
}
|
| 85 |
+
resp = requests.post(f"{base}/grade", headers=headers, json=payload, timeout=timeout_s + 60)
|
| 86 |
+
resp.raise_for_status()
|
| 87 |
+
return resp.json()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class DeepAgentSWEEnv:
|
| 91 |
+
"""Per-rollout TRL environment: LocalShellBackend tools + remote Docker reward."""
|
| 92 |
+
|
| 93 |
+
def __init__(self) -> None:
|
| 94 |
+
self._sandbox_parent: Path | None = None
|
| 95 |
+
self._sandbox: Path | None = None
|
| 96 |
+
self._backend: Any = None
|
| 97 |
+
self._task: dict[str, Any] = {}
|
| 98 |
+
self._last_reward: float = 0.0
|
| 99 |
+
self._last_report: dict[str, Any] = {}
|
| 100 |
+
|
| 101 |
+
def reset(self, **kwargs: Any) -> None:
|
| 102 |
+
"""Materialize repo sandbox from a SWE Lite row. Prompt stays on the dataset."""
|
| 103 |
+
self._cleanup()
|
| 104 |
+
|
| 105 |
+
instance_id = str(kwargs.get("instance_id") or kwargs.get("task_id") or "")
|
| 106 |
+
repo = str(kwargs.get("repo") or "")
|
| 107 |
+
base_commit = str(kwargs.get("base_commit") or "")
|
| 108 |
+
docker_image = str(
|
| 109 |
+
kwargs.get("docker_image") or kwargs.get("image") or ""
|
| 110 |
+
)
|
| 111 |
+
eval_script = str(kwargs.get("eval_script") or "")
|
| 112 |
+
fail_to_pass = _as_str_list(
|
| 113 |
+
kwargs.get("fail_to_pass")
|
| 114 |
+
if kwargs.get("fail_to_pass") is not None
|
| 115 |
+
else kwargs.get("fail_to_pass_json")
|
| 116 |
+
)
|
| 117 |
+
pass_to_pass = _as_str_list(
|
| 118 |
+
kwargs.get("pass_to_pass")
|
| 119 |
+
if kwargs.get("pass_to_pass") is not None
|
| 120 |
+
else kwargs.get("pass_to_pass_json")
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
if not instance_id or not repo or not base_commit:
|
| 124 |
+
raise ValueError(
|
| 125 |
+
f"DeepAgentSWEEnv.reset requires instance_id/repo/base_commit; got keys={list(kwargs)}"
|
| 126 |
+
)
|
| 127 |
+
if not docker_image or not eval_script.strip():
|
| 128 |
+
raise ValueError(f"{instance_id}: missing docker_image/eval_script")
|
| 129 |
+
|
| 130 |
+
from deepagents.backends import LocalShellBackend
|
| 131 |
+
|
| 132 |
+
from deepagents_rl.harness.repo import materialize_repo_at_commit
|
| 133 |
+
|
| 134 |
+
self._task = {
|
| 135 |
+
"instance_id": instance_id,
|
| 136 |
+
"task_id": instance_id,
|
| 137 |
+
"repo": repo,
|
| 138 |
+
"base_commit": base_commit,
|
| 139 |
+
"image": docker_image,
|
| 140 |
+
"docker_image": docker_image,
|
| 141 |
+
"eval_script": eval_script,
|
| 142 |
+
"fail_to_pass": fail_to_pass,
|
| 143 |
+
"pass_to_pass": pass_to_pass,
|
| 144 |
+
}
|
| 145 |
+
self._last_reward = 0.0
|
| 146 |
+
self._last_report = {}
|
| 147 |
+
|
| 148 |
+
self._sandbox_parent = Path(
|
| 149 |
+
tempfile.mkdtemp(prefix=f"darl_grpo_{instance_id[:40]}_")
|
| 150 |
+
)
|
| 151 |
+
self._sandbox = self._sandbox_parent / "repo"
|
| 152 |
+
materialize_repo_at_commit(repo=repo, base_commit=base_commit, dest=self._sandbox)
|
| 153 |
+
self._backend = LocalShellBackend(
|
| 154 |
+
root_dir=str(self._sandbox),
|
| 155 |
+
virtual_mode=True,
|
| 156 |
+
timeout=60,
|
| 157 |
+
)
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
def _cleanup(self) -> None:
|
| 161 |
+
self._backend = None
|
| 162 |
+
if self._sandbox_parent is not None and self._sandbox_parent.exists():
|
| 163 |
+
shutil.rmtree(self._sandbox_parent, ignore_errors=True)
|
| 164 |
+
self._sandbox_parent = None
|
| 165 |
+
self._sandbox = None
|
| 166 |
+
|
| 167 |
+
def _require_backend(self) -> Any:
|
| 168 |
+
if self._backend is None:
|
| 169 |
+
raise RuntimeError("DeepAgentSWEEnv tool called before reset()")
|
| 170 |
+
return self._backend
|
| 171 |
+
|
| 172 |
+
def ls(self, path: str = ".") -> str:
|
| 173 |
+
"""List files in a directory inside the sandbox.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
path: Directory path relative to the repo root.
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
Directory listing as text.
|
| 180 |
+
"""
|
| 181 |
+
return _result_to_str(self._require_backend().ls(path))
|
| 182 |
+
|
| 183 |
+
def glob(self, pattern: str, path: str = ".") -> str:
|
| 184 |
+
"""Glob for files matching pattern under path.
|
| 185 |
+
|
| 186 |
+
Args:
|
| 187 |
+
pattern: Glob pattern (e.g. '**/*.py').
|
| 188 |
+
path: Root path for the search.
|
| 189 |
+
|
| 190 |
+
Returns:
|
| 191 |
+
Matching paths as text.
|
| 192 |
+
"""
|
| 193 |
+
return _result_to_str(self._require_backend().glob(pattern, path))
|
| 194 |
+
|
| 195 |
+
def grep(self, pattern: str, path: str = ".") -> str:
|
| 196 |
+
"""Search file contents for a regex pattern.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
pattern: Regex pattern to search for.
|
| 200 |
+
path: Root path for the search.
|
| 201 |
+
|
| 202 |
+
Returns:
|
| 203 |
+
Matching lines as text.
|
| 204 |
+
"""
|
| 205 |
+
return _result_to_str(self._require_backend().grep(pattern, path))
|
| 206 |
+
|
| 207 |
+
def read_file(self, file_path: str, offset: int = 0, limit: int = 200) -> str:
|
| 208 |
+
"""Read a text file from the sandbox.
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
file_path: Path relative to the repo root.
|
| 212 |
+
offset: Starting line offset.
|
| 213 |
+
limit: Max number of lines to return.
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
File contents as text.
|
| 217 |
+
"""
|
| 218 |
+
return _result_to_str(
|
| 219 |
+
self._require_backend().read(file_path, offset=offset, limit=limit)
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
def write_file(self, file_path: str, content: str) -> str:
|
| 223 |
+
"""Write or create a text file in the sandbox.
|
| 224 |
+
|
| 225 |
+
Args:
|
| 226 |
+
file_path: Path relative to the repo root.
|
| 227 |
+
content: Full file contents to write.
|
| 228 |
+
|
| 229 |
+
Returns:
|
| 230 |
+
Write result as text.
|
| 231 |
+
"""
|
| 232 |
+
return _result_to_str(self._require_backend().write(file_path, content))
|
| 233 |
+
|
| 234 |
+
def edit_file(self, file_path: str, old_string: str, new_string: str) -> str:
|
| 235 |
+
"""Replace old_string with new_string in an existing file.
|
| 236 |
+
|
| 237 |
+
Args:
|
| 238 |
+
file_path: Path relative to the repo root.
|
| 239 |
+
old_string: Exact text to find.
|
| 240 |
+
new_string: Replacement text.
|
| 241 |
+
|
| 242 |
+
Returns:
|
| 243 |
+
Edit result as text.
|
| 244 |
+
"""
|
| 245 |
+
return _result_to_str(
|
| 246 |
+
self._require_backend().edit(file_path, old_string, new_string)
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
def execute(self, command: str) -> str:
|
| 250 |
+
"""Run a shell command in the sandbox.
|
| 251 |
+
|
| 252 |
+
Args:
|
| 253 |
+
command: Shell command (e.g. python -c '...').
|
| 254 |
+
|
| 255 |
+
Returns:
|
| 256 |
+
Command stdout/stderr as text.
|
| 257 |
+
"""
|
| 258 |
+
return _result_to_str(self._require_backend().execute(command))
|
| 259 |
+
|
| 260 |
+
def get_reward(self) -> float:
|
| 261 |
+
"""Export git diff and grade via remote Docker SWE evaluator."""
|
| 262 |
+
if self._sandbox is None:
|
| 263 |
+
self._last_reward = 0.0
|
| 264 |
+
return 0.0
|
| 265 |
+
|
| 266 |
+
from deepagents_rl.harness.repo import git_diff_patch
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
model_patch = git_diff_patch(self._sandbox)
|
| 270 |
+
except Exception as e: # noqa: BLE001
|
| 271 |
+
self._last_report = {"error": f"git_diff_failed: {e}"}
|
| 272 |
+
self._last_reward = 0.0
|
| 273 |
+
return 0.0
|
| 274 |
+
|
| 275 |
+
if not model_patch.strip():
|
| 276 |
+
self._last_report = {"error": "empty_model_patch", "reward_total": 0.0}
|
| 277 |
+
self._last_reward = 0.0
|
| 278 |
+
return 0.0
|
| 279 |
+
|
| 280 |
+
try:
|
| 281 |
+
report = grade_patch_remote(
|
| 282 |
+
instance_id=self._task["instance_id"],
|
| 283 |
+
docker_image=self._task["docker_image"],
|
| 284 |
+
eval_script=self._task["eval_script"],
|
| 285 |
+
fail_to_pass=list(self._task["fail_to_pass"]),
|
| 286 |
+
pass_to_pass=list(self._task["pass_to_pass"]),
|
| 287 |
+
model_patch=model_patch,
|
| 288 |
+
)
|
| 289 |
+
except Exception as e: # noqa: BLE001
|
| 290 |
+
self._last_report = {"error": f"grader_failed: {e}"}
|
| 291 |
+
self._last_reward = 0.0
|
| 292 |
+
return 0.0
|
| 293 |
+
|
| 294 |
+
self._last_report = report
|
| 295 |
+
self._last_reward = float(report.get("reward_total") or 0.0)
|
| 296 |
+
return self._last_reward
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def prepare_swe_grpo_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 300 |
+
"""Normalize HF SWE Lite rows for TRL GRPO + DeepAgentSWEEnv.
|
| 301 |
+
|
| 302 |
+
Renames `image` -> `docker_image` so TRL does not treat it as multimodal.
|
| 303 |
+
Converts `prompt` to conversational chat messages (required for tool calling).
|
| 304 |
+
"""
|
| 305 |
+
from deepagents_rl.data.swe_bench_lite import from_hf_record
|
| 306 |
+
|
| 307 |
+
rows: list[dict[str, Any]] = []
|
| 308 |
+
for rec in records:
|
| 309 |
+
# Accept both HF records and already-normalized tasks
|
| 310 |
+
if "fail_to_pass_json" in rec or (
|
| 311 |
+
"prompt" in rec and isinstance(rec.get("prompt"), str) and "image" in rec
|
| 312 |
+
):
|
| 313 |
+
task = from_hf_record(rec)
|
| 314 |
+
else:
|
| 315 |
+
task = dict(rec)
|
| 316 |
+
|
| 317 |
+
prompt = task.get("prompt") or ""
|
| 318 |
+
if isinstance(prompt, list):
|
| 319 |
+
messages = prompt
|
| 320 |
+
else:
|
| 321 |
+
messages = [{"role": "user", "content": str(prompt)}]
|
| 322 |
+
|
| 323 |
+
docker_image = str(task.get("docker_image") or task.get("image") or "")
|
| 324 |
+
rows.append(
|
| 325 |
+
{
|
| 326 |
+
"prompt": messages,
|
| 327 |
+
"task_id": task["task_id"],
|
| 328 |
+
"instance_id": task["instance_id"],
|
| 329 |
+
"repo": task["repo"],
|
| 330 |
+
"base_commit": task["base_commit"],
|
| 331 |
+
"docker_image": docker_image,
|
| 332 |
+
"eval_script": task.get("eval_script") or "",
|
| 333 |
+
"fail_to_pass": list(task.get("fail_to_pass") or []),
|
| 334 |
+
"pass_to_pass": list(task.get("pass_to_pass") or []),
|
| 335 |
+
"problem_statement": task.get("problem_statement") or "",
|
| 336 |
+
}
|
| 337 |
+
)
|
| 338 |
+
return rows
|