vanishingradient commited on
Commit
4d620d9
·
verified ·
1 Parent(s): a0141b0

Add deepagents_rl package sources

Browse files
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__ = ["build_train_eval", "load_mbpp_sanitized", "save_jsonl", "to_hf_dataset"]
 
 
 
 
 
 
 
 
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__ = ["init_langfuse", "log_checkpoint_eval"]
 
 
 
 
 
 
 
 
 
 
 
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__ = ["resolve_train_model"]
 
 
 
 
 
 
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