返回 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 permission string
23 arm ablation.Set
24 runID string
25 workDir string
26 harness string
27 dataset string
28 maxSteps int
29 timeoutSec int
30 workers int
31 keepImages bool
32 // network is the isolated Docker network; proxyURL is the allowlisted API path.
33 network string
34 proxyURL string
35 }
36
37 // The only benchmark config is sandbox.bash=off, required because official
38 // images do not ship bubblewrap. Network facts are intentionally not injected.
39 const swebenchAgentConfig = "[sandbox]\nbash = \"off\"\n"
40
41 func loadSwebenchSubset(path string) ([]swebenchInstance, error) {
42 data, err := fileencoding.ReadFileUTF8(path)
43 if err != nil {
44 return nil, err
45 }
46 var out []swebenchInstance
47 if err := json.Unmarshal(data, &out); err != nil {
48 return nil, fmt.Errorf("%s: %w", path, err)
49 }
50 if len(out) == 0 {
51 return nil, fmt.Errorf("%s: no instances", path)
52 }
53 return out, nil
54 }
55
56 // runSwebenchInstance drives one instance: start its evaluation container, run
57 // the agent inside it against /testbed, and take whatever the working tree
58 // became as the candidate patch. Grading happens later, in one batch.
59 func runSwebenchInstance(o swebenchOpts, inst swebenchInstance) (result, string) {
60 r := result{task: task{ID: inst.InstanceID}, Profile: benchmarkProfileStandard}
61 r.Arm = o.arm.Arm()
62
63 image := swebenchImage(o.namespace, inst.InstanceID)
64 container := swebenchContainer(inst.InstanceID)
65 _ = dockerRun("rm", "-f", container)
66
67 runArgs := []string{"run", "-d", "--name", container}
68 if o.network != "" {
69 runArgs = append(runArgs, "--network", o.network)
70 }
71 for _, kv := range proxyEnv(o.proxyURL) {
72 runArgs = append(runArgs, "-e", kv)
73 }
74 runArgs = append(runArgs, image, "sleep", "infinity")
75 if out, err := dockerOutput(runArgs...); err != nil {
76 r.Note = "start container: " + firstLine(out)
77 r.Outcome = "container_error"
78 return r, ""
79 }
80 defer func() {
81 _ = dockerRun("rm", "-f", container)
82 if !o.keepImages {
83 _ = dockerRun("rmi", "-f", image)
84 }
85 }()
86
87 if err := provisionAgent(o, container); err != nil {
88 r.Note = "provision agent: " + err.Error()
89 r.Outcome = "container_error"
90 return r, ""
91 }
92
93 metricsPath := "/tmp/reasonix-metrics.json"
94 args := swebenchAgentArgs(metricsPath, o.model, o.permission, o.arm, o.maxSteps, swebenchPrompt(inst))
95 agentCmd := append([]string{"exec", "-e", "REASONIX_HOME=/opt/rxhome", container},
96 testbedShell("/usr/local/bin/reasonix "+shellQuoteAll(args))...)
97
98 ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.timeoutSec)*time.Second)
99 defer cancel()
100 startedAt := time.Now()
101 runErr := dockerRunCtx(ctx, agentCmd...)
102 r.WallMs = time.Since(startedAt).Milliseconds()
103
104 // Prefer the final record; fall back to the snapshot a killed agent left.
105 // The final file is authoritative and the sidecar is only ever read when it
106 // is absent, so the two can never be counted together.
107 r.Unaccounted = true
108 for _, src := range []struct {
109 path string
110 partial bool
111 }{{metricsPath, false}, {metricsPath + ".partial", true}} {
112 raw, err := dockerOutput("exec", container, "cat", src.path)
113 if err != nil {
114 continue
115 }
116 var m runMetrics
117 if json.Unmarshal([]byte(raw), &m) != nil {
118 continue
119 }
120 arm := r.Arm
121 r.runMetrics = m
122 r.Arm = arm
123 r.Unaccounted = false
124 r.Partial = src.partial || !m.Complete
125 break
126 }
127 if ctx.Err() != nil {
128 r.Outcome = "timeout"
129 }
130 if runErr != nil && r.Outcome == "" {
131 r.Note = "agent: " + runErr.Error()
132 }
133
134 patch, err := extractTestbedPatch(container)
135 if err != nil {
136 r.Note = strings.TrimSpace(r.Note + " | diff: " + err.Error())
137 return r, ""
138 }
139 return r, patch
140 }
141
142 const patchArgBudget = 16 << 10
143
144 // extractTestbedPatch drops only binary entries, which git apply cannot carry;
145 // all text files are retained and paths are passed as argv.
146 func extractTestbedPatch(container string) (string, error) {
147 if out, err := dockerOutput("exec", container, "git", "-C", "/testbed", "add", "-A"); err != nil {
148 return "", fmt.Errorf("add: %s", firstLine(out))
149 }
150 numstat, err := dockerOutput("exec", container, "git", "-C", "/testbed",
151 "diff", "--cached", "--no-renames", "--numstat", "-z")
152 if err != nil {
153 return "", fmt.Errorf("numstat: %s", firstLine(numstat))
154 }
155 files := patchFileList(numstat)
156 if len(files) == 0 {
157 return "", nil
158 }
159 batches, err := patchFileBatches(container, files)
160 if err != nil {
161 return "", fmt.Errorf("diff args: %w", err)
162 }
163 var patch strings.Builder
164 for _, batch := range batches {
165 out, err := dockerOutput(testbedPatchDiffArgs(container, batch)...)
166 if err != nil {
167 return "", fmt.Errorf("diff: %s", firstLine(out))
168 }
169 patch.WriteString(out)
170 }
171 return patch.String(), nil
172 }
173
174 func testbedPatchDiffArgs(container string, files []string) []string {
175 return append([]string{"exec", container, "git", "--literal-pathspecs", "-C", "/testbed",
176 "diff", "--cached", "--no-renames", "--"}, files...)
177 }
178
179 func patchFileBatches(container string, files []string) ([][]string, error) {
180 baseBytes := argvBytes(testbedPatchDiffArgs(container, nil))
181 var batches [][]string
182 current := make([]string, 0)
183 currentBytes := baseBytes
184 for _, path := range files {
185 pathBytes := len(path) + 1
186 if baseBytes+pathBytes > patchArgBudget {
187 return nil, fmt.Errorf("path %q exceeds the %d-byte argv budget", path, patchArgBudget)
188 }
189 if len(current) > 0 && currentBytes+pathBytes > patchArgBudget {
190 batches = append(batches, current)
191 current = make([]string, 0)
192 currentBytes = baseBytes
193 }
194 current = append(current, path)
195 currentBytes += pathBytes
196 }
197 if len(current) > 0 {
198 batches = append(batches, current)
199 }
200 return batches, nil
201 }
202
203 func argvBytes(args []string) int {
204 total := 0
205 for _, arg := range args {
206 total += len(arg) + 1
207 }
208 return total
209 }
210
211 // patchFileList returns every text path from numstat, excluding binary entries.
212 func patchFileList(numstat string) []string {
213 var files []string
214 for entry := range strings.SplitSeq(numstat, "\x00") {
215 if entry == "" {
216 continue
217 }
218 fields := strings.SplitN(entry, "\t", 3)
219 if len(fields) != 3 {
220 continue
221 }
222 added, deleted, path := fields[0], fields[1], fields[2]
223 if added == "-" || deleted == "-" || path == "" {
224 continue // binary: not representable in an appliable text diff
225 }
226 files = append(files, path)
227 }
228 return files
229 }
230
231 // provisionAgent copies the binary and a credential-free home into the
232 // container. The API key is streamed in rather than baked into an image layer
233 // or an argv, so it never lands anywhere a later docker inspect can read it.
234 func provisionAgent(o swebenchOpts, container string) error {
235 if err := dockerRun("cp", o.bin, container+":/usr/local/bin/reasonix"); err != nil {
236 return err
237 }
238 if err := dockerRun("exec", container, "mkdir", "-p", "/opt/rxhome"); err != nil {
239 return err
240 }
241 if err := dockerPipe(swebenchAgentConfig, "exec", "-i", container,
242 "bash", "-c", "umask 077 && cat > /opt/rxhome/config.toml"); err != nil {
243 return err
244 }
245 env, err := os.ReadFile(filepath.Join(os.Getenv("REASONIX_HOME"), ".env"))
246 if err != nil {
247 return fmt.Errorf("read credentials from $REASONIX_HOME/.env: %w", err)
248 }
249 return dockerPipe(string(env), "exec", "-i", container,
250 "bash", "-c", "umask 077 && cat > /opt/rxhome/.env")
251 }
252
253 // gradeSwebench writes the predictions file and hands it to the official
254 // harness, then reads back its report. We never decide resolution ourselves.
255 func gradeSwebench(o swebenchOpts, patches map[string]string, order []string) (swebenchReport, error) {
256 var report swebenchReport
257 predictions, err := encodePredictions("reasonix", patches, order)
258 if err != nil {
259 return report, err
260 }
261 path := filepath.Join(o.workDir, "predictions.jsonl")
262 if err := os.WriteFile(path, []byte(predictions), 0o600); err != nil {
263 return report, err
264 }
265
266 args := []string{"-m", "swebench.harness.run_evaluation",
267 "--dataset_name", o.dataset,
268 "--predictions_path", path,
269 "--run_id", o.runID,
270 "--max_workers", fmt.Sprint(o.workers),
271 "--instance_ids"}
272 args = append(args, order...)
273 cmd := exec.Command(o.harness, args...)
274 cmd.Dir = o.workDir
275 cmd.Stdout = os.Stderr
276 cmd.Stderr = os.Stderr
277 if err := cmd.Run(); err != nil {
278 return report, fmt.Errorf("run_evaluation: %w", err)
279 }
280
281 raw, err := fileencoding.ReadFileUTF8(filepath.Join(o.workDir, swebenchReportPath("reasonix", o.runID)))
282 if err != nil {
283 return report, err
284 }
285 return report, json.Unmarshal(raw, &report)
286 }
287
288 // preflight fails before the first container instead of after fifty. A unit
289 // test can only check the argv we build; it cannot know whether this binary
290 // accepts it. An earlier run lost a full arm to a flag that existed on the
291 // interactive command but not on `run`.
292 func preflight(o swebenchOpts) error {
293 posture, err := permissionFlag(o.permission)
294 if err != nil {
295 return err
296 }
297 help, err := exec.Command(o.bin, "run", "--help").CombinedOutput()
298 if err != nil {
299 return fmt.Errorf("%s run --help: %w", o.bin, err)
300 }
301 name, _, _ := strings.Cut(strings.TrimPrefix(posture, "--"), "=")
302 if !strings.Contains(string(help), "--"+name) {
303 return fmt.Errorf("%s run does not accept --%s; the %q posture would fail on every instance", o.bin, name, o.permission)
304 }
305 if o.network == "" || o.proxyURL == "" {
306 return fmt.Errorf("-network and -proxy are required: with off-box egress the agent reads the upstream fix and every solve is unearned")
307 }
308 return nil
309 }
310
311 func runSwebench(o swebenchOpts) string {
312 if err := preflight(o); err != nil {
313 fmt.Fprintln(os.Stderr, "preflight:", err)
314 os.Exit(2)
315 }
316 instances, err := loadSwebenchSubset(o.subset)
317 if err != nil {
318 fmt.Fprintln(os.Stderr, "load subset:", err)
319 os.Exit(1)
320 }
321
322 patches := map[string]string{}
323 order := make([]string, 0, len(instances))
324 results := make([]result, 0, len(instances))
325 for i, inst := range instances {
326 fmt.Fprintf(os.Stderr, "\n=== [%d/%d] %s ===\n", i+1, len(instances), inst.InstanceID)
327 r, patch := runSwebenchInstance(o, inst)
328 order = append(order, inst.InstanceID)
329 if strings.TrimSpace(patch) != "" {
330 patches[inst.InstanceID] = patch
331 }
332 results = append(results, r)
333 }
334
335 report, err := gradeSwebench(o, patches, order)
336 if err != nil {
337 fmt.Fprintln(os.Stderr, "grade:", err)
338 }
339 for i := range results {
340 class := report.gradedClass(results[i].ID)
341 results[i].Passed = class == "solved"
342 // A guard that stopped the agent explains the failure better than the
343 // grader's generic "unresolved", so an agent-side outcome wins.
344 if results[i].Outcome == "" || results[i].Outcome == "success" {
345 results[i].Outcome = class
346 }
347 }
348 return renderSwebench(results, o)
349 }
350
351 func renderSwebench(results []result, o swebenchOpts) string {
352 model := o.model
353 if strings.TrimSpace(model) == "" {
354 model = "config default"
355 }
356 var b strings.Builder
357 fmt.Fprintf(&b, "## SWE-bench Verified — Reasonix (arm `%s`)\n\n", o.arm.Arm())
358 posture := o.permission
359 if posture == "" {
360 posture = "auto"
361 }
362 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",
363 model, posture, filepath.Base(o.subset), o.runID)
364 b.WriteString(renderBody(results))
365 return b.String()
366 }
367
368 // proxyEnv sets both cases because the Go client reads the lowercase names via
369 // httpproxy.FromEnvironment while curl and pip inside the image read either.
370 func proxyEnv(url string) []string {
371 if strings.TrimSpace(url) == "" {
372 return nil
373 }
374 return []string{
375 "http_proxy=" + url, "https_proxy=" + url,
376 "HTTP_PROXY=" + url, "HTTPS_PROXY=" + url,
377 }
378 }
379
380 func firstLine(s string) string {
381 if before, _, ok := strings.Cut(s, "\n"); ok {
382 return strings.TrimSpace(before)
383 }
384 return strings.TrimSpace(s)
385 }
386
387 // shellQuoteAll renders argv for a `bash -lc` string. Prompts carry newlines,
388 // quotes and backticks straight from a GitHub issue, so nothing may be passed
389 // unquoted.
390 func shellQuoteAll(args []string) string {
391 quoted := make([]string, len(args))
392 for i, a := range args {
393 quoted[i] = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
394 }
395 return strings.Join(quoted, " ")
396 }
397
398 func dockerRun(args ...string) error {
399 return exec.Command("docker", args...).Run()
400 }
401
402 func dockerRunCtx(ctx context.Context, args ...string) error {
403 cmd := exec.CommandContext(ctx, "docker", args...)
404 cmd.Stdout = os.Stderr
405 cmd.Stderr = os.Stderr
406 cmd.WaitDelay = 10 * time.Second
407 return cmd.Run()
408 }
409
410 func dockerOutput(args ...string) (string, error) {
411 out, err := exec.Command("docker", args...).CombinedOutput()
412 return string(out), err
413 }
414
415 func dockerPipe(stdin string, args ...string) error {
416 cmd := exec.Command("docker", args...)
417 cmd.Stdin = strings.NewReader(stdin)
418 return cmd.Run()
419 }
420
420 lines GO