返回 DeepSeek-Reasonix
swebench_run.go
根目录 / cmd / e2ebench / swebench_run.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "reasonix/internal/ablation"
14 fileencoding "reasonix/internal/fileutil/encoding"
15 )
16
17 type swebenchOpts struct {
18 bin string
19 subset string
20 namespace string
21 model string
22 profile string
23 permission string
24 arm ablation.Set
25 runID string
26 workDir string
27 harness string
28 dataset string
29 maxSteps int
30 timeoutSec int
31 workers int
32 keepImages bool
33 // network is the docker network agent containers join. It must have no
34 // route off-box; proxyURL is the only way out, and it allowlists the model
35 // API. Without this the agent finds the upstream fix on GitHub — SWE-bench
36 // instance ids are PR numbers and the issue text searches straight to the
37 // patch — and every solve is unearned.
38 network string
39 proxyURL string
40 }
41
42 // The agent runs unconfined inside the instance container: the container is
43 // already the isolation boundary, and Reasonix refuses to run bash at all when
44 // it cannot find bubblewrap, which the official images do not ship.
45 const swebenchAgentConfig = "[sandbox]\nbash = \"off\"\n"
46
47 func loadSwebenchSubset(path string) ([]swebenchInstance, error) {
48 data, err := fileencoding.ReadFileUTF8(path)
49 if err != nil {
50 return nil, err
51 }
52 var out []swebenchInstance
53 if err := json.Unmarshal(data, &out); err != nil {
54 return nil, fmt.Errorf("%s: %w", path, err)
55 }
56 if len(out) == 0 {
57 return nil, fmt.Errorf("%s: no instances", path)
58 }
59 return out, nil
60 }
61
62 // runSwebenchInstance drives one instance: start its evaluation container, run
63 // the agent inside it against /testbed, and take whatever the working tree
64 // became as the candidate patch. Grading happens later, in one batch.
65 func runSwebenchInstance(o swebenchOpts, inst swebenchInstance) (result, string) {
66 r := result{task: task{ID: inst.InstanceID}, Profile: o.profile}
67 r.Arm = o.arm.Arm()
68
69 image := swebenchImage(o.namespace, inst.InstanceID)
70 container := swebenchContainer(inst.InstanceID)
71 _ = dockerRun("rm", "-f", container)
72
73 runArgs := []string{"run", "-d", "--name", container}
74 if o.network != "" {
75 runArgs = append(runArgs, "--network", o.network)
76 }
77 for _, kv := range proxyEnv(o.proxyURL) {
78 runArgs = append(runArgs, "-e", kv)
79 }
80 runArgs = append(runArgs, image, "sleep", "infinity")
81 if out, err := dockerOutput(runArgs...); err != nil {
82 r.Note = "start container: " + firstLine(out)
83 r.Outcome = "container_error"
84 return r, ""
85 }
86 defer func() {
87 _ = dockerRun("rm", "-f", container)
88 if !o.keepImages {
89 _ = dockerRun("rmi", "-f", image)
90 }
91 }()
92
93 if err := provisionAgent(o, container); err != nil {
94 r.Note = "provision agent: " + err.Error()
95 r.Outcome = "container_error"
96 return r, ""
97 }
98
99 metricsPath := "/tmp/reasonix-metrics.json"
100 args := swebenchAgentArgs(metricsPath, o.model, o.profile, o.permission, o.arm, o.maxSteps, swebenchPrompt(inst))
101 agentCmd := append([]string{"exec", "-e", "REASONIX_HOME=/opt/rxhome", container},
102 testbedShell("/usr/local/bin/reasonix "+shellQuoteAll(args))...)
103
104 ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.timeoutSec)*time.Second)
105 defer cancel()
106 startedAt := time.Now()
107 runErr := dockerRunCtx(ctx, agentCmd...)
108 r.WallMs = time.Since(startedAt).Milliseconds()
109
110 // Prefer the final record; fall back to the snapshot a killed agent left.
111 // The final file is authoritative and the sidecar is only ever read when it
112 // is absent, so the two can never be counted together.
113 r.Unaccounted = true
114 for _, src := range []struct {
115 path string
116 partial bool
117 }{{metricsPath, false}, {metricsPath + ".partial", true}} {
118 raw, err := dockerOutput("exec", container, "cat", src.path)
119 if err != nil {
120 continue
121 }
122 var m runMetrics
123 if json.Unmarshal([]byte(raw), &m) != nil {
124 continue
125 }
126 arm := r.Arm
127 r.runMetrics = m
128 r.Arm = arm
129 r.Unaccounted = false
130 r.Partial = src.partial || !m.Complete
131 break
132 }
133 if ctx.Err() != nil {
134 r.Outcome = "timeout"
135 }
136 if runErr != nil && r.Outcome == "" {
137 r.Note = "agent: " + runErr.Error()
138 }
139
140 patch, err := dockerOutput("exec", container, "bash", "-lc",
141 "cd /testbed && git add -A >/dev/null 2>&1; git diff --cached")
142 if err != nil {
143 r.Note = strings.TrimSpace(r.Note + " | diff: " + firstLine(patch))
144 return r, ""
145 }
146 return r, patch
147 }
148
149 // provisionAgent copies the binary and a credential-free home into the
150 // container. The API key is streamed in rather than baked into an image layer
151 // or an argv, so it never lands anywhere a later docker inspect can read it.
152 func provisionAgent(o swebenchOpts, container string) error {
153 if err := dockerRun("cp", o.bin, container+":/usr/local/bin/reasonix"); err != nil {
154 return err
155 }
156 if err := dockerRun("exec", container, "mkdir", "-p", "/opt/rxhome"); err != nil {
157 return err
158 }
159 if err := dockerPipe(swebenchAgentConfig, "exec", "-i", container,
160 "bash", "-c", "umask 077 && cat > /opt/rxhome/config.toml"); err != nil {
161 return err
162 }
163 env, err := os.ReadFile(filepath.Join(os.Getenv("REASONIX_HOME"), ".env"))
164 if err != nil {
165 return fmt.Errorf("read credentials from $REASONIX_HOME/.env: %w", err)
166 }
167 return dockerPipe(string(env), "exec", "-i", container,
168 "bash", "-c", "umask 077 && cat > /opt/rxhome/.env")
169 }
170
171 // gradeSwebench writes the predictions file and hands it to the official
172 // harness, then reads back its report. We never decide resolution ourselves.
173 func gradeSwebench(o swebenchOpts, patches map[string]string, order []string) (swebenchReport, error) {
174 var report swebenchReport
175 predictions, err := encodePredictions("reasonix", patches, order)
176 if err != nil {
177 return report, err
178 }
179 path := filepath.Join(o.workDir, "predictions.jsonl")
180 if err := os.WriteFile(path, []byte(predictions), 0o600); err != nil {
181 return report, err
182 }
183
184 args := []string{"-m", "swebench.harness.run_evaluation",
185 "--dataset_name", o.dataset,
186 "--predictions_path", path,
187 "--run_id", o.runID,
188 "--max_workers", fmt.Sprint(o.workers),
189 "--instance_ids"}
190 args = append(args, order...)
191 cmd := exec.Command(o.harness, args...)
192 cmd.Dir = o.workDir
193 cmd.Stdout = os.Stderr
194 cmd.Stderr = os.Stderr
195 if err := cmd.Run(); err != nil {
196 return report, fmt.Errorf("run_evaluation: %w", err)
197 }
198
199 raw, err := fileencoding.ReadFileUTF8(filepath.Join(o.workDir, swebenchReportPath("reasonix", o.runID)))
200 if err != nil {
201 return report, err
202 }
203 return report, json.Unmarshal(raw, &report)
204 }
205
206 // preflight fails before the first container instead of after fifty. A unit
207 // test can only check the argv we build; it cannot know whether this binary
208 // accepts it. An earlier run lost a full arm to a flag that existed on the
209 // interactive command but not on `run`.
210 func preflight(o swebenchOpts) error {
211 posture, err := permissionFlag(o.permission)
212 if err != nil {
213 return err
214 }
215 help, err := exec.Command(o.bin, "run", "--help").CombinedOutput()
216 if err != nil {
217 return fmt.Errorf("%s run --help: %w", o.bin, err)
218 }
219 name, _, _ := strings.Cut(strings.TrimPrefix(posture, "--"), "=")
220 if !strings.Contains(string(help), "--"+name) {
221 return fmt.Errorf("%s run does not accept --%s; the %q posture would fail on every instance", o.bin, name, o.permission)
222 }
223 if o.network == "" || o.proxyURL == "" {
224 return fmt.Errorf("-network and -proxy are required: with off-box egress the agent reads the upstream fix and every solve is unearned")
225 }
226 return nil
227 }
228
229 func runSwebench(o swebenchOpts) string {
230 if err := preflight(o); err != nil {
231 fmt.Fprintln(os.Stderr, "preflight:", err)
232 os.Exit(2)
233 }
234 instances, err := loadSwebenchSubset(o.subset)
235 if err != nil {
236 fmt.Fprintln(os.Stderr, "load subset:", err)
237 os.Exit(1)
238 }
239
240 patches := map[string]string{}
241 order := make([]string, 0, len(instances))
242 results := make([]result, 0, len(instances))
243 for i, inst := range instances {
244 fmt.Fprintf(os.Stderr, "\n=== [%d/%d] %s ===\n", i+1, len(instances), inst.InstanceID)
245 r, patch := runSwebenchInstance(o, inst)
246 order = append(order, inst.InstanceID)
247 if strings.TrimSpace(patch) != "" {
248 patches[inst.InstanceID] = patch
249 }
250 results = append(results, r)
251 }
252
253 report, err := gradeSwebench(o, patches, order)
254 if err != nil {
255 fmt.Fprintln(os.Stderr, "grade:", err)
256 }
257 for i := range results {
258 class := report.gradedClass(results[i].ID)
259 results[i].Passed = class == "solved"
260 // A guard that stopped the agent explains the failure better than the
261 // grader's generic "unresolved", so an agent-side outcome wins.
262 if results[i].Outcome == "" || results[i].Outcome == "success" {
263 results[i].Outcome = class
264 }
265 }
266 return renderSwebench(results, o)
267 }
268
269 func renderSwebench(results []result, o swebenchOpts) string {
270 model := o.model
271 if strings.TrimSpace(model) == "" {
272 model = "config default"
273 }
274 var b strings.Builder
275 fmt.Fprintf(&b, "## SWE-bench Verified — Reasonix (arm `%s`)\n\n", o.arm.Arm())
276 posture := o.permission
277 if posture == "" {
278 posture = "auto"
279 }
280 fmt.Fprintf(&b, "<sub>model `%s` · permissions `%s` · subset `%s` · run `%s` · agent runs inside the official instance image · graded by the official harness</sub>\n\n",
281 model, posture, filepath.Base(o.subset), o.runID)
282 b.WriteString(renderBody(results))
283 return b.String()
284 }
285
286 // proxyEnv sets both cases because the Go client reads the lowercase names via
287 // httpproxy.FromEnvironment while curl and pip inside the image read either.
288 func proxyEnv(url string) []string {
289 if strings.TrimSpace(url) == "" {
290 return nil
291 }
292 return []string{
293 "http_proxy=" + url, "https_proxy=" + url,
294 "HTTP_PROXY=" + url, "HTTPS_PROXY=" + url,
295 }
296 }
297
298 func firstLine(s string) string {
299 if i := strings.IndexByte(s, '\n'); i >= 0 {
300 return strings.TrimSpace(s[:i])
301 }
302 return strings.TrimSpace(s)
303 }
304
305 // shellQuoteAll renders argv for a `bash -lc` string. Prompts carry newlines,
306 // quotes and backticks straight from a GitHub issue, so nothing may be passed
307 // unquoted.
308 func shellQuoteAll(args []string) string {
309 quoted := make([]string, len(args))
310 for i, a := range args {
311 quoted[i] = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
312 }
313 return strings.Join(quoted, " ")
314 }
315
316 func dockerRun(args ...string) error {
317 return exec.Command("docker", args...).Run()
318 }
319
320 func dockerRunCtx(ctx context.Context, args ...string) error {
321 cmd := exec.CommandContext(ctx, "docker", args...)
322 cmd.Stdout = os.Stderr
323 cmd.Stderr = os.Stderr
324 cmd.WaitDelay = 10 * time.Second
325 return cmd.Run()
326 }
327
328 func dockerOutput(args ...string) (string, error) {
329 out, err := exec.Command("docker", args...).CombinedOutput()
330 return string(out), err
331 }
332
333 func dockerPipe(stdin string, args ...string) error {
334 cmd := exec.Command("docker", args...)
335 cmd.Stdin = strings.NewReader(stdin)
336 return cmd.Run()
337 }
338
338 lines GO