opencode-harness-kit 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +249 -1
- package/README.md +222 -5
- package/bin/oc-harness.mjs +85 -7
- package/dist/budget.d.ts +247 -0
- package/dist/config.d.ts +34 -1
- package/dist/effort.d.ts +12 -1
- package/dist/guard.d.ts +5 -0
- package/dist/health.d.ts +12 -1
- package/dist/index.js +556 -149
- package/dist/profile.d.ts +2 -1
- package/dist/prune.d.ts +22 -0
- package/dist/rules.d.ts +6 -2
- package/docs/EXIT-CODES.md +18 -4
- package/docs/REVIEW-VERDICT.md +73 -0
- package/eval/RESULTS.md +130 -7
- package/eval/ab.mjs +591 -68
- package/eval/compare.mjs +267 -0
- package/eval/verify.mjs +9 -0
- package/lanes.json +18 -9
- package/package.json +6 -2
- package/prompts/reviewer.md +24 -0
- package/src/budget.js +418 -0
- package/src/config.js +172 -6
- package/src/driver/run.mjs +780 -42
- package/src/effort.js +17 -3
- package/src/guard.ts +304 -1
- package/src/health.js +13 -1
- package/src/index.ts +23 -12
- package/src/profile.js +1 -1
- package/src/prune.ts +21 -1
- package/src/rules.js +145 -0
- package/src/tasks.js +31 -0
package/eval/ab.mjs
CHANGED
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
// spawning a single agent. The grader has been wrong five times in this repo and
|
|
24
24
|
// every fix so far would otherwise have cost another twenty runs.
|
|
25
25
|
|
|
26
|
-
import {
|
|
27
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync } from "node:fs"
|
|
26
|
+
import { spawn } from "node:child_process"
|
|
27
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync } from "node:fs"
|
|
28
28
|
import { createHash } from "node:crypto"
|
|
29
29
|
import { homedir, tmpdir } from "node:os"
|
|
30
30
|
import { join } from "node:path"
|
|
@@ -34,6 +34,7 @@ import { countFlag, numberFlag } from "./flags.mjs"
|
|
|
34
34
|
import { gradeAnswer, gradeMutation, gradeMutationMany } from "./verify.mjs"
|
|
35
35
|
import { checkPremises } from "./premises.mjs"
|
|
36
36
|
import { parseJsonc, configDir } from "../src/profile.js"
|
|
37
|
+
import { requestTokens } from "../src/budget.js"
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* The message of a caught value, whatever it turned out to be.
|
|
@@ -81,28 +82,112 @@ const suitePath = flag("--suite")
|
|
|
81
82
|
// has been wrong five times in this repo and every fix would otherwise have
|
|
82
83
|
// cost another twenty runs.
|
|
83
84
|
const rescoreDir = flag("--rescore")
|
|
85
|
+
// Where the streams go, and therefore whether a suite can be continued.
|
|
86
|
+
//
|
|
87
|
+
// Without it every run lands in a fresh temp directory, so an interrupted
|
|
88
|
+
// suite has nothing to continue from: a 36-run suite stopped at run 13 pays
|
|
89
|
+
// for those 13 again. With `--out` the verdict for each run is written beside
|
|
90
|
+
// its stream, and a later invocation with the same `--out` skips what is
|
|
91
|
+
// already there.
|
|
92
|
+
const outFlag = flag("--out")
|
|
93
|
+
// A ceiling on what the whole suite may spend, in context tokens - the same
|
|
94
|
+
// number the guard counts. The free tier is one shared window and the suite is
|
|
95
|
+
// the biggest single consumer of it; a cap is the difference between "the
|
|
96
|
+
// suite stopped" and "the account is out until the window rolls". Counting
|
|
97
|
+
// uncached input here made the cap 5.6 times too generous on the measured
|
|
98
|
+
// streams, and 43 times on a single fully cached step.
|
|
99
|
+
let suiteBudget = 0
|
|
100
|
+
try {
|
|
101
|
+
const n = numberFlag("--budget")
|
|
102
|
+
if (n !== null) {
|
|
103
|
+
if (!(n > 0)) throw new Error("--budget must be more than 0 tokens, or the cap is removed")
|
|
104
|
+
suiteBudget = Math.floor(n)
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(String(errorMessage(err)))
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
84
110
|
// The lane to run, for a suite that measures the kit's PROCESS rather than
|
|
85
111
|
// its answers: a reviewer that does not edit, a verify that does not fix.
|
|
86
112
|
const agent = flag("--agent")
|
|
87
113
|
// One pathological run burned 4.4 minutes once. Without a ceiling a single bad
|
|
88
114
|
// arm can hold the whole suite open indefinitely.
|
|
89
115
|
//
|
|
90
|
-
// A timeout of zero would REMOVE the ceiling -
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// spawnSync.
|
|
116
|
+
// A timeout of zero would REMOVE the ceiling - a zero-delay timer fires at
|
|
117
|
+
// once - which is the opposite of what the flag says. And a non-numeric one
|
|
118
|
+
// was NaN, which a setTimeout treats as zero and kills every run instantly.
|
|
94
119
|
let runTimeoutMs
|
|
95
120
|
try {
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
runTimeoutMs =
|
|
121
|
+
const seconds = numberFlag("--timeout") ?? 600
|
|
122
|
+
if (seconds <= 0) throw new Error("--timeout must be more than 0, or the ceiling is removed")
|
|
123
|
+
runTimeoutMs = seconds * 1000
|
|
99
124
|
} catch (err) {
|
|
100
125
|
console.error(String(errorMessage(err)))
|
|
101
126
|
process.exit(1)
|
|
102
127
|
}
|
|
103
128
|
|
|
129
|
+
// A spent free route does not fail: it hangs, then the per-run ceiling kills
|
|
130
|
+
// it, and the run is recorded as a timeout with no tool calls and no text.
|
|
131
|
+
// At 600s a dead route costs ten minutes per attempt, and a 36-run suite
|
|
132
|
+
// spends six hours measuring the provider's quota rather than the kit.
|
|
133
|
+
//
|
|
134
|
+
// Two in a row is not a slow task, it is a route that is not answering. Stop
|
|
135
|
+
// and say so, with the raw streams kept so `--rescore` can run later.
|
|
136
|
+
let failFast
|
|
137
|
+
try {
|
|
138
|
+
failFast = countFlag("--fail-fast") ?? 2
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error(String(errorMessage(err)))
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The free tier does not always fail the process. `opencode run --format json`
|
|
145
|
+
// prints an error event on stdout, then keeps retrying or exits 0 - and the
|
|
146
|
+
// A/B treated that as an empty answer, kept going, and spent the suite
|
|
147
|
+
// measuring the provider. The same text can appear on stderr. Either one is
|
|
148
|
+
// the provider refusing the run, so the suite stops on the first one.
|
|
149
|
+
const QUOTA_RE = /Free usage limit reached|FreeUsageLimitError|HTTP 429|"statusCode"\s*:\s*429/i
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The provider's own words when a run was refused for quota, or null.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} stdout
|
|
155
|
+
* @param {string} stderr
|
|
156
|
+
* @returns {string | null}
|
|
157
|
+
*/
|
|
158
|
+
/**
|
|
159
|
+
* The first error event in a stream, or null.
|
|
160
|
+
*
|
|
161
|
+
* `opencode run --format json` reports a failure as a `{"type":"error"}` line
|
|
162
|
+
* and exits 0 or 1; the message is all the caller gets. The first one is the
|
|
163
|
+
* one that stopped the run.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} stdout
|
|
166
|
+
* @returns {string | null}
|
|
167
|
+
*/
|
|
168
|
+
function streamError(stdout) {
|
|
169
|
+
for (const line of stdout.split("\n")) {
|
|
170
|
+
if (!line.includes('"type":"error"')) continue
|
|
171
|
+
try {
|
|
172
|
+
const ev = JSON.parse(line)
|
|
173
|
+
const msg = ev?.error?.data?.message ?? ev?.error?.message ?? ev?.error?.name
|
|
174
|
+
if (msg) return String(msg).slice(0, 240)
|
|
175
|
+
} catch {
|
|
176
|
+
return line.trim().slice(0, 240)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function quotaReason(stdout, stderr) {
|
|
183
|
+
const text = `${stdout}\n${stderr}`
|
|
184
|
+
if (!QUOTA_RE.test(text)) return null
|
|
185
|
+
const line = text.split("\n").find((l) => QUOTA_RE.test(l)) ?? ""
|
|
186
|
+
return line.trim().slice(0, 240)
|
|
187
|
+
}
|
|
188
|
+
|
|
104
189
|
if (!target || (!task && !suitePath)) {
|
|
105
|
-
console.error('usage: node eval/ab.mjs --target <dir> (--task "<prompt>" | --suite <file>) [--reps 3] [--expect token]... [--timeout 600]')
|
|
190
|
+
console.error('usage: node eval/ab.mjs --target <dir> (--task "<prompt>" | --suite <file>) [--reps 3] [--expect token]... [--timeout 600] [--fail-fast 2]')
|
|
106
191
|
process.exit(1)
|
|
107
192
|
}
|
|
108
193
|
if (!existsSync(target)) {
|
|
@@ -409,6 +494,13 @@ function liveModel() {
|
|
|
409
494
|
// The comparison this file exists to make is "the same model, with the kit
|
|
410
495
|
// and without it". The model is now pinned to make that literally true, and
|
|
411
496
|
// the report prints it so a reader can check.
|
|
497
|
+
//
|
|
498
|
+
// The SMALL model is pinned too. `model` only covers the main loop; the title
|
|
499
|
+
// agent runs on `small_model`, and an empty config leaves that at opencode's
|
|
500
|
+
// default - which is a PAID model. A real A/B run made 1,073 title calls to
|
|
501
|
+
// `gpt-5.4-nano` and every one came back "Insufficient account funds". It did
|
|
502
|
+
// not consume the free window, but on a funded account it would have spent
|
|
503
|
+
// money on titles for a benchmark that is supposed to be free.
|
|
412
504
|
function makeNokitHome(model) {
|
|
413
505
|
const home = mkdtempSync(join(tmpdir(), "opencode-ab-"))
|
|
414
506
|
mkdirSync(join(home, ".config/opencode"), { recursive: true })
|
|
@@ -418,12 +510,26 @@ function makeNokitHome(model) {
|
|
|
418
510
|
// not exist" off for exactly that case.
|
|
419
511
|
/** @type {Record<string, string>} */
|
|
420
512
|
const config = { $schema: "https://opencode.ai/config.json" }
|
|
421
|
-
if (model)
|
|
513
|
+
if (model) {
|
|
514
|
+
config.model = model
|
|
515
|
+
config.small_model = model
|
|
516
|
+
}
|
|
422
517
|
writeFileSync(join(home, ".config/opencode/opencode.json"), JSON.stringify(config, null, 2))
|
|
423
518
|
const link = join(home, ".local/share/opencode")
|
|
424
519
|
try {
|
|
425
520
|
symlinkSync(join(REAL_HOME, ".local/share/opencode"), link, "dir")
|
|
426
521
|
} catch {}
|
|
522
|
+
// And the cache, which is where opencode keeps `models.json` - the catalogue
|
|
523
|
+
// it resolves a `provider/model` string against. Without it a bare
|
|
524
|
+
// `model: "opencode/deepseek-v4.1-flash-free"` is a model the off arm has
|
|
525
|
+
// never heard of: every off run failed with "Unexpected server error" and
|
|
526
|
+
// the cause was only visible in `--print-logs` as ProviderModelNotFoundError.
|
|
527
|
+
// The cache is opencode's, not the kit's, so sharing it keeps the arm
|
|
528
|
+
// kit-free without making it a different installation.
|
|
529
|
+
mkdirSync(join(home, ".cache"), { recursive: true })
|
|
530
|
+
try {
|
|
531
|
+
symlinkSync(join(REAL_HOME, ".cache/opencode"), join(home, ".cache/opencode"), "dir")
|
|
532
|
+
} catch {}
|
|
427
533
|
return home
|
|
428
534
|
}
|
|
429
535
|
|
|
@@ -450,7 +556,7 @@ function fingerprint(dir) {
|
|
|
450
556
|
|
|
451
557
|
function parseStream(file, root = TARGET_REAL) {
|
|
452
558
|
const reads = [], searches = [], others = [], escaped = new Set(), readOutside = new Set()
|
|
453
|
-
let steps = 0, inputTokens = 0, reasoning = 0, finalCtx = 0, text = "", first = null, last = null
|
|
559
|
+
let steps = 0, inputTokens = 0, contextTokens = 0, reasoning = 0, finalCtx = 0, text = "", first = null, last = null
|
|
454
560
|
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
455
561
|
const s = line.trim()
|
|
456
562
|
if (!s) continue
|
|
@@ -485,7 +591,14 @@ function parseStream(file, root = TARGET_REAL) {
|
|
|
485
591
|
} else if (d.type === "step_finish") {
|
|
486
592
|
const t = d.part?.tokens ?? {}
|
|
487
593
|
steps++
|
|
594
|
+
// `inputTokens` is the uncached part only. It is kept because it is what
|
|
595
|
+
// every table before this one reported, and comparing against those
|
|
596
|
+
// numbers needs the same definition. `contextTokens` is the cost: the
|
|
597
|
+
// same step reports a few hundred uncached against tens of thousands
|
|
598
|
+
// cached, so a suite budget built on `inputTokens` was 5.6 times
|
|
599
|
+
// too generous.
|
|
488
600
|
inputTokens += t.input ?? 0
|
|
601
|
+
contextTokens += requestTokens(t)
|
|
489
602
|
reasoning += t.reasoning ?? 0
|
|
490
603
|
finalCtx = (t.input ?? 0) + (t.cache?.read ?? 0)
|
|
491
604
|
} else if (d.type === "text") {
|
|
@@ -502,6 +615,7 @@ function parseStream(file, root = TARGET_REAL) {
|
|
|
502
615
|
searches: searches.length,
|
|
503
616
|
other: others.reduce((a, t) => (a[t] = (a[t] ?? 0) + 1, a), {}),
|
|
504
617
|
inputTokens,
|
|
618
|
+
contextTokens,
|
|
505
619
|
reasoning,
|
|
506
620
|
finalCtx,
|
|
507
621
|
seconds: first !== null && last !== null ? elapsedSeconds(first, last) : null,
|
|
@@ -511,51 +625,140 @@ function parseStream(file, root = TARGET_REAL) {
|
|
|
511
625
|
}
|
|
512
626
|
}
|
|
513
627
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
628
|
+
// The child that is running right now, if any. Needed because `spawn` is
|
|
629
|
+
// async: the event loop stays free, so a Ctrl-C reaches this file instead of
|
|
630
|
+
// being eaten by a blocked spawnSync. The old code could not react to one -
|
|
631
|
+
// the parent died, the agent was reparented to systemd and kept running
|
|
632
|
+
// against the real auth store. One such run survived 2h54m.
|
|
633
|
+
/** @type {import("node:child_process").ChildProcess | null} */
|
|
634
|
+
let activeChild = null
|
|
635
|
+
|
|
636
|
+
function killActiveChild() {
|
|
637
|
+
const child = activeChild
|
|
638
|
+
if (!child || child.killed) return
|
|
639
|
+
// The child is its own process-group leader (detached), so a negative pid
|
|
640
|
+
// reaches the helpers `opencode` starts. A plain child.kill() left those
|
|
641
|
+
// running after the parent was gone.
|
|
642
|
+
try {
|
|
643
|
+
process.kill(-child.pid, "SIGKILL")
|
|
644
|
+
} catch {
|
|
645
|
+
try {
|
|
646
|
+
child.kill("SIGKILL")
|
|
647
|
+
} catch {}
|
|
524
648
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
for (const [sig, code] of /** @type {const} */ ([["SIGINT", 130], ["SIGTERM", 143]])) {
|
|
652
|
+
process.on(sig, () => {
|
|
653
|
+
killActiveChild()
|
|
654
|
+
process.exit(code)
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Run one arm and capture its stream.
|
|
660
|
+
*
|
|
661
|
+
* Async on purpose. The timeout is a timer that kills the process GROUP; the
|
|
662
|
+
* signal handlers above kill the same group on Ctrl-C. Neither is possible
|
|
663
|
+
* with spawnSync, which blocks the event loop for the whole run.
|
|
664
|
+
*
|
|
665
|
+
* @param {"on" | "off"} arm
|
|
666
|
+
* @param {string | null} home
|
|
667
|
+
* @param {string} id
|
|
668
|
+
* @param {number} idx
|
|
669
|
+
* @param {string} outDir
|
|
670
|
+
* @param {string} prompt
|
|
671
|
+
* @param {string} runDir
|
|
672
|
+
*/
|
|
673
|
+
function runArmCapture(arm, home, id, idx, outDir, prompt, runDir) {
|
|
674
|
+
return new Promise((resolve) => {
|
|
675
|
+
const out = join(outDir, `${id}-${arm}-${idx}.json`)
|
|
676
|
+
const env = { ...process.env }
|
|
677
|
+
// The kit's own guard must not decide the outcome of the suite. 120 runs
|
|
678
|
+
// spend more than the shared window's stop, so from the run that crosses it
|
|
679
|
+
// the guard would refuse tool calls - and every run after that point would
|
|
680
|
+
// be scored as a wrong answer for a reason the suite is not measuring. The
|
|
681
|
+
// suite has its own cap (`--budget`), and it is the one that counts here.
|
|
682
|
+
// The ledger is pointed at the run directory for the same reason: a
|
|
683
|
+
// benchmark should not charge the account's real window, or leave it
|
|
684
|
+
// looking spent to the session that started the benchmark.
|
|
685
|
+
env.OC_HARNESS_BUDGET_WARN = "0"
|
|
686
|
+
env.OC_HARNESS_BUDGET_STOP = "0"
|
|
687
|
+
env.OC_HARNESS_BUDGET_WINDOW_WARN = "0"
|
|
688
|
+
env.OC_HARNESS_BUDGET_WINDOW_STOP = "0"
|
|
689
|
+
env.OC_HARNESS_BUDGET_FILE = join(outDir, "budget.json")
|
|
690
|
+
if (arm === "off" && home) {
|
|
691
|
+
env.HOME = home
|
|
692
|
+
// The kit-free home is only kit-free if the config is not found
|
|
693
|
+
// somewhere else. `OPENCODE_CONFIG_DIR` is what opencode looks at
|
|
694
|
+
// FIRST, so with it set the "off" arm loaded the kit's own config, used
|
|
695
|
+
// its rules, and used its model - and the arm was not an off arm at all.
|
|
696
|
+
delete env.OPENCODE_CONFIG_DIR
|
|
697
|
+
}
|
|
698
|
+
const t0 = Date.now()
|
|
699
|
+
// --dir is not optional. Nested inside an opencode session, `opencode run`
|
|
700
|
+
// otherwise inherits the *parent* session's project directory and reads the
|
|
701
|
+
// wrong repository entirely - which it did on the first pilot.
|
|
702
|
+
// The LANE, when the suite wants one. The kit-off arm has no kit and
|
|
703
|
+
// therefore no lanes, so the flag is only for the arm that has them - which
|
|
704
|
+
// is the point: the lane is part of the kit, and measuring it means
|
|
705
|
+
// measuring the kit.
|
|
706
|
+
const argv = ["run", "--dir", runDir, "--format", "json"]
|
|
707
|
+
if (arm === "on" && agent) argv.push("--agent", agent)
|
|
708
|
+
argv.push(prompt)
|
|
709
|
+
const child = spawn("opencode", argv, {
|
|
710
|
+
cwd: runDir,
|
|
711
|
+
env,
|
|
712
|
+
// A process-group leader, so the timeout and the signal handlers can
|
|
713
|
+
// take the agent's helpers with it.
|
|
714
|
+
detached: true,
|
|
715
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
716
|
+
})
|
|
717
|
+
activeChild = child
|
|
718
|
+
let stdout = ""
|
|
719
|
+
let stderr = ""
|
|
720
|
+
let timedOut = false
|
|
721
|
+
let settled = false
|
|
722
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
723
|
+
let timer
|
|
724
|
+
|
|
725
|
+
const finish = (code) => {
|
|
726
|
+
if (settled) return
|
|
727
|
+
settled = true
|
|
728
|
+
clearTimeout(timer)
|
|
729
|
+
if (activeChild === child) activeChild = null
|
|
730
|
+
// The stream is the evidence --rescore needs later, so it is written
|
|
731
|
+
// even when the run was killed or refused.
|
|
732
|
+
writeFileSync(out, stdout)
|
|
733
|
+
resolve({
|
|
734
|
+
out,
|
|
735
|
+
code: code === 0 ? 0 : 1,
|
|
736
|
+
timedOut,
|
|
737
|
+
wall: elapsedSeconds(t0, Date.now()),
|
|
738
|
+
stderr,
|
|
739
|
+
quota: quotaReason(stdout, stderr),
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
child.stdout?.setEncoding("utf8")
|
|
744
|
+
child.stdout?.on("data", (d) => {
|
|
745
|
+
stdout += d
|
|
746
|
+
})
|
|
747
|
+
child.stderr?.setEncoding("utf8")
|
|
748
|
+
child.stderr?.on("data", (d) => {
|
|
749
|
+
stderr += d
|
|
750
|
+
})
|
|
751
|
+
child.on("error", (err) => {
|
|
752
|
+
stderr += `\n${errorMessage(err)}`
|
|
753
|
+
finish(null)
|
|
754
|
+
})
|
|
755
|
+
child.on("close", (code) => finish(code))
|
|
756
|
+
|
|
757
|
+
timer = setTimeout(() => {
|
|
758
|
+
timedOut = true
|
|
759
|
+
killActiveChild()
|
|
760
|
+
}, runTimeoutMs)
|
|
539
761
|
})
|
|
540
|
-
const stdout = res.stdout ?? ""
|
|
541
|
-
writeFileSync(out, stdout)
|
|
542
|
-
// Whether the run was killed by the ceiling.
|
|
543
|
-
//
|
|
544
|
-
// The old check was `stdout === null`, and `spawnSync` NEVER returns null
|
|
545
|
-
// for stdout - it returns the partial output and reports the kill through
|
|
546
|
-
// `error` and `signal`. So the timeout branch could never fire, every run
|
|
547
|
-
// scored as a success, and a run that hung until the ceiling was counted in
|
|
548
|
-
// the correctness ratio and in every median.
|
|
549
|
-
const timedOut =
|
|
550
|
-
/** @type {any} */ (res.error)?.code === "ETIMEDOUT" ||
|
|
551
|
-
res.signal === "SIGKILL" ||
|
|
552
|
-
(res.status === null && Date.now() - t0 >= runTimeoutMs)
|
|
553
|
-
return {
|
|
554
|
-
out,
|
|
555
|
-
code: res.status === 0 ? 0 : 1,
|
|
556
|
-
timedOut,
|
|
557
|
-
wall: elapsedSeconds(t0, Date.now()),
|
|
558
|
-
}
|
|
559
762
|
}
|
|
560
763
|
|
|
561
764
|
const armModel = liveModel()
|
|
@@ -564,19 +767,227 @@ if (!armModel) {
|
|
|
564
767
|
console.warn(" which is not necessarily the model the kit-on arm uses.")
|
|
565
768
|
}
|
|
566
769
|
const nokit = rescoreDir ? null : makeNokitHome(armModel)
|
|
567
|
-
|
|
770
|
+
// `--out` is what makes a suite resumable: the streams and their verdicts go
|
|
771
|
+
// to a directory the caller names, so a second invocation can pick up where
|
|
772
|
+
// the first stopped instead of paying for the same runs again.
|
|
773
|
+
const outDir = rescoreDir ?? outFlag ?? mkdtempSync(join(tmpdir(), "opencode-ab-out-"))
|
|
774
|
+
if (outFlag && !rescoreDir) mkdirSync(outFlag, { recursive: true })
|
|
568
775
|
const before = fingerprint(target)
|
|
569
776
|
|
|
777
|
+
// A resume trusts the sidecars in `--out`, and every one of them was measured
|
|
778
|
+
// against a target, a suite and a model. Two of those can change between
|
|
779
|
+
// invocations without any file name changing, and the mixed run reads as one
|
|
780
|
+
// arm's result. The manifest is written once, when the directory is new; a
|
|
781
|
+
// directory from before the manifest existed is adopted with a warning rather
|
|
782
|
+
// than refused, because the runs in it were still measured against something
|
|
783
|
+
// and the warning says which.
|
|
784
|
+
const suiteHash = createHash("sha256")
|
|
785
|
+
.update(suitePath ? readFileSync(suitePath) : (task ?? ""))
|
|
786
|
+
.digest("hex")
|
|
787
|
+
const manifestFile = join(outDir, "run-manifest.json")
|
|
788
|
+
const manifest = { v: 1, suiteHash, task: task ?? null, target: TARGET_REAL, fingerprint: before, model: armModel, reps, timeoutMs: runTimeoutMs }
|
|
789
|
+
if (outFlag && !rescoreDir) {
|
|
790
|
+
if (existsSync(manifestFile)) {
|
|
791
|
+
const old = JSON.parse(readFileSync(manifestFile, "utf8"))
|
|
792
|
+
const changed = ["fingerprint", "suiteHash", "model"].filter((k) => old[k] !== manifest[k])
|
|
793
|
+
if (changed.length) {
|
|
794
|
+
console.error(`FAIL: --out ${outDir} was measured against a different ${changed.join(", ")}, so resuming it would mix two experiments.`)
|
|
795
|
+
console.error(` manifest: ${manifestFile}`)
|
|
796
|
+
console.error(` recorded ${changed.map((k) => `${k}=${JSON.stringify(old[k])}`).join(", ")}`)
|
|
797
|
+
console.error(` now ${changed.map((k) => `${k}=${JSON.stringify(manifest[k])}`).join(", ")}`)
|
|
798
|
+
console.error(` use a new --out, or restore the target/suite/model the manifest names.`)
|
|
799
|
+
process.exit(2)
|
|
800
|
+
}
|
|
801
|
+
} else {
|
|
802
|
+
const sidecars = readdirSync(outDir).filter((f) => f.endsWith(".result.json"))
|
|
803
|
+
if (sidecars.length) {
|
|
804
|
+
console.warn(` warn: ${outDir} has ${sidecars.length} result(s) but no run-manifest.json;`)
|
|
805
|
+
console.warn(` adopting them as measured against this target, suite and model.`)
|
|
806
|
+
manifest.adopted = true
|
|
807
|
+
}
|
|
808
|
+
writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + "\n")
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* The recorded verdict for one run, if `--out` was used and it is there.
|
|
814
|
+
*
|
|
815
|
+
* A sidecar rather than the stream alone, because grading a mutation needs the
|
|
816
|
+
* copy of the repository the run produced, and that copy is deleted. The
|
|
817
|
+
* verdict is written when it is known, and read back unchanged on a resume -
|
|
818
|
+
* re-grading a saved answer would be a second, possibly different, opinion.
|
|
819
|
+
*
|
|
820
|
+
* @param {string} id
|
|
821
|
+
* @param {"on" | "off"} arm
|
|
822
|
+
* @param {number} i
|
|
823
|
+
* @returns {any | null}
|
|
824
|
+
*/
|
|
825
|
+
function resumeRun(id, arm, i) {
|
|
826
|
+
if (!outFlag || rescoreDir) return null
|
|
827
|
+
const file = join(outDir, `${id}-${arm}-${i}.result.json`)
|
|
828
|
+
if (!existsSync(file)) return null
|
|
829
|
+
try {
|
|
830
|
+
const saved = JSON.parse(readFileSync(file, "utf8"))
|
|
831
|
+
// A sidecar from a different suite or a half-written file is not a
|
|
832
|
+
// result: the id and arm have to match, and there has to be a verdict.
|
|
833
|
+
if (saved?.id !== id || saved?.arm !== arm || saved?.i !== i) return null
|
|
834
|
+
if (!("correct" in saved)) return null
|
|
835
|
+
// A sidecar with no verdict and no step is not a result: the route was
|
|
836
|
+
// already dead when it was written. Skipping it would make the hole
|
|
837
|
+
// permanent on every later resume - a dead window that leaves one empty
|
|
838
|
+
// sidecar per attempt never fills in. A timeout that did work is a
|
|
839
|
+
// censored result, and stays skipped.
|
|
840
|
+
if (saved.correct === null && (saved.steps ?? 0) === 0) return null
|
|
841
|
+
// A sidecar written by an older version is missing fields the report
|
|
842
|
+
// reads - `escaped` crashed the whole report on a resumed run, after every
|
|
843
|
+
// run had already been paid for. The report treats a missing list as an
|
|
844
|
+
// empty one; a resume must not turn a finished suite into a stack trace.
|
|
845
|
+
if (!Array.isArray(saved.escaped)) saved.escaped = []
|
|
846
|
+
if (!Array.isArray(saved.tools)) saved.tools = []
|
|
847
|
+
if (typeof saved.text !== "string") saved.text = ""
|
|
848
|
+
if (typeof saved.steps !== "number") saved.steps = 0
|
|
849
|
+
// The sidecar holds the verdict, the stream holds the cost. A sidecar
|
|
850
|
+
// written before the counter counted cached context would otherwise resume
|
|
851
|
+
// with a number 5.6 times too small, and the suite's own cap would
|
|
852
|
+
// read as unspent. Only the token fields are taken: `escaped` is relative
|
|
853
|
+
// to a run copy that is gone, and re-deriving it here would report every
|
|
854
|
+
// path as an escape.
|
|
855
|
+
const stream = join(outDir, `${id}-${arm}-${i}.json`)
|
|
856
|
+
if (existsSync(stream)) {
|
|
857
|
+
try {
|
|
858
|
+
const m = parseStream(stream, TARGET_REAL)
|
|
859
|
+
if (m.steps > 0) {
|
|
860
|
+
saved.inputTokens = m.inputTokens
|
|
861
|
+
saved.contextTokens = m.contextTokens
|
|
862
|
+
// The report prints medians of these too, and a sidecar from before
|
|
863
|
+
// the counter counted the cached prefix has both at zero. Leaving
|
|
864
|
+
// them stale would make "final ctx" disagree with "context tokens"
|
|
865
|
+
// for the same run.
|
|
866
|
+
saved.finalCtx = m.finalCtx
|
|
867
|
+
saved.reasoning = m.reasoning
|
|
868
|
+
// Write it back: the correction is the whole reason the stream is
|
|
869
|
+
// read, and a second resume should not depend on the stream still
|
|
870
|
+
// being there.
|
|
871
|
+
saveRun(saved)
|
|
872
|
+
}
|
|
873
|
+
} catch {
|
|
874
|
+
// An unreadable stream is not a reason to throw away a scored run.
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return saved
|
|
878
|
+
} catch {
|
|
879
|
+
return null
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Record one run's verdict so a later invocation can reuse it.
|
|
885
|
+
*
|
|
886
|
+
* @param {any} run
|
|
887
|
+
*/
|
|
888
|
+
function saveRun(run) {
|
|
889
|
+
if (!outFlag || rescoreDir) return
|
|
890
|
+
const file = join(outDir, `${run.id}-${run.arm}-${run.i}.result.json`)
|
|
891
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
892
|
+
writeFileSync(tmp, JSON.stringify(run))
|
|
893
|
+
renameSync(tmp, file)
|
|
894
|
+
}
|
|
895
|
+
|
|
570
896
|
console.log(`A/B: ${jobs.length} task(s) x ${reps} rep(s) per arm${rescoreDir ? ", rescored" : ", alternating"}`)
|
|
571
897
|
console.log(` target ${target}`)
|
|
572
898
|
for (const j of jobs) console.log(` task ${j.id}`)
|
|
573
|
-
console.log(` timeout ${runTimeoutMs / 1000}s per run`)
|
|
899
|
+
console.log(` timeout ${runTimeoutMs / 1000}s per run, giving up after ${failFast} empty run(s)`)
|
|
574
900
|
console.log(` model ${armModel ?? "(opencode's default - NOT pinned)"}`)
|
|
575
901
|
if (nokit) console.log(` kit-off home ${nokit}`)
|
|
576
902
|
console.log("")
|
|
577
903
|
|
|
904
|
+
// One cheap run before the suite, because the expensive failure is not a
|
|
905
|
+
// wrong answer, it is spending the first twenty runs discovering the route is
|
|
906
|
+
// closed. A preflight costs a few hundred tokens and turns that into one.
|
|
907
|
+
//
|
|
908
|
+
// `--no-preflight` skips it: a paid route has no window to spend, and the
|
|
909
|
+
// check is a round trip.
|
|
910
|
+
if (!rescoreDir && !args.includes("--no-preflight")) {
|
|
911
|
+
const probeDir = mkdtempSync(join(tmpdir(), "opencode-ab-preflight-"))
|
|
912
|
+
process.stdout.write("preflight ... ")
|
|
913
|
+
// Into the probe directory, not `--out`: a preflight stream has no task and
|
|
914
|
+
// would only be skipped with a warning by a later --rescore.
|
|
915
|
+
const probe = await runArmCapture("off", nokit, "preflight", 0, probeDir, "Reply with the single word: ok", probeDir)
|
|
916
|
+
if (probe.quota) {
|
|
917
|
+
console.log("")
|
|
918
|
+
console.error("FAIL: the model route is out of quota before the suite started, so nothing was measured.")
|
|
919
|
+
console.error(` ${probe.quota}`)
|
|
920
|
+
console.error(" The free tier is one shared window: this suite and every chat session draw on it.")
|
|
921
|
+
console.error(` preflight stream kept: ${probe.out}`)
|
|
922
|
+
process.exit(2)
|
|
923
|
+
}
|
|
924
|
+
if (probe.timedOut) {
|
|
925
|
+
console.log("")
|
|
926
|
+
console.error(`FAIL: the preflight did not answer within ${runTimeoutMs / 1000}s.`)
|
|
927
|
+
console.error(" A spent or unreachable route looks like this. Nothing was measured.")
|
|
928
|
+
console.error(" Check it by hand: opencode run --print-logs -m <model> hi")
|
|
929
|
+
console.error(` preflight stream kept: ${probe.out}`)
|
|
930
|
+
process.exit(2)
|
|
931
|
+
}
|
|
932
|
+
// Answering is the test, not exiting. A run that reports an error and exits
|
|
933
|
+
// in three seconds used to print "ok" here - the first suite with this
|
|
934
|
+
// preflight passed it and then spent eighteen runs discovering that every
|
|
935
|
+
// off arm had failed.
|
|
936
|
+
const probeErr = streamError(readFileSync(probe.out, "utf8"))
|
|
937
|
+
if (probeErr) {
|
|
938
|
+
console.log("")
|
|
939
|
+
console.error("FAIL: the preflight run failed before the suite started, so nothing was measured.")
|
|
940
|
+
console.error(` ${probeErr}`)
|
|
941
|
+
console.error(" A model the installation cannot resolve, or a provider that refuses the request,")
|
|
942
|
+
console.error(" fails every run the same way. Run one by hand to see the whole log:")
|
|
943
|
+
console.error(" opencode run --print-logs -m <model> hi")
|
|
944
|
+
console.error(` preflight stream kept: ${probe.out}`)
|
|
945
|
+
process.exit(2)
|
|
946
|
+
}
|
|
947
|
+
// Exiting is not answering. A spent route can exit 0 with an empty stream -
|
|
948
|
+
// the shape the fail-fast test uses - and a preflight that reads only the
|
|
949
|
+
// exit code calls that "ok", after which the suite spends one timeout per
|
|
950
|
+
// run learning otherwise. The suite needs a step and a word.
|
|
951
|
+
const probeMetrics = parseStream(probe.out, realpathSync(probeDir))
|
|
952
|
+
if (probe.code !== 0 || probeMetrics.steps === 0 || probeMetrics.text.trim() === "") {
|
|
953
|
+
console.log("")
|
|
954
|
+
console.error("FAIL: the preflight produced no answer, so nothing was measured.")
|
|
955
|
+
console.error(` exit code ${probe.code}, ${probeMetrics.steps} step(s), ${probeMetrics.text.trim().length} character(s) of text.`)
|
|
956
|
+
console.error(" A spent route or a broken install looks like this. Nothing was measured.")
|
|
957
|
+
console.error(" Check it by hand: opencode run --print-logs -m <model> hi")
|
|
958
|
+
console.error(` preflight stream kept: ${probe.out}`)
|
|
959
|
+
process.exit(2)
|
|
960
|
+
}
|
|
961
|
+
console.log(`ok (${probe.wall}s)`)
|
|
962
|
+
}
|
|
963
|
+
|
|
578
964
|
const runs = []
|
|
579
965
|
const keptCopies = []
|
|
966
|
+
// Consecutive runs that produced neither a tool call nor a word. See
|
|
967
|
+
// --fail-fast above: this is the shape of a spent route, not of a slow task.
|
|
968
|
+
let deadStreak = 0
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Stop the suite when the route, not the task, is what failed.
|
|
972
|
+
*
|
|
973
|
+
* @param {number} n
|
|
974
|
+
* @param {string} detail
|
|
975
|
+
* @param {{ id: string }} job
|
|
976
|
+
* @param {"on" | "off"} arm
|
|
977
|
+
* @param {number} i
|
|
978
|
+
*/
|
|
979
|
+
function stopDeadRoute(n, detail, job, arm, i) {
|
|
980
|
+
console.log("")
|
|
981
|
+
console.error(`FAIL: the model route is not answering.`)
|
|
982
|
+
console.error(` ${n} runs in a row ${detail},`)
|
|
983
|
+
console.error(` most recently ${job.id} ${arm}-${i} on ${armModel ?? "opencode's default model"}.`)
|
|
984
|
+
console.error(` A spent free route looks exactly like this; the provider's reason is only`)
|
|
985
|
+
console.error(` printed by \`opencode run --print-logs\`. Nothing was measured, so this`)
|
|
986
|
+
console.error(` stops instead of timing out one run at a time.`)
|
|
987
|
+
console.error(` raw streams kept: ${outDir}`)
|
|
988
|
+
console.error(` rescore them later: node eval/ab.mjs --rescore ${outDir}, with the same --suite or --task`)
|
|
989
|
+
process.exit(2)
|
|
990
|
+
}
|
|
580
991
|
if (rescoreDir) {
|
|
581
992
|
const byId = new Map(jobs.map((j) => [j.id, j]))
|
|
582
993
|
for (const f of readdirSync(rescoreDir).filter((x) => x.endsWith(".json")).sort()) {
|
|
@@ -589,11 +1000,29 @@ if (rescoreDir) {
|
|
|
589
1000
|
}
|
|
590
1001
|
const file = join(rescoreDir, f)
|
|
591
1002
|
const s = parseStream(file, TARGET_REAL)
|
|
592
|
-
|
|
1003
|
+
// The sidecar is the only place that knows a run hit the ceiling: its
|
|
1004
|
+
// stream stops mid-sentence and looks exactly like a run that answered
|
|
1005
|
+
// nothing. Grading it as wrong is the mistake this file warns about, and
|
|
1006
|
+
// the re-score path made it - four runs that timed out at 12 steps came
|
|
1007
|
+
// back as four wrong answers.
|
|
1008
|
+
let timedOut = false
|
|
1009
|
+
let sideSaved = null
|
|
1010
|
+
const side = join(rescoreDir, f.replace(/\.json$/, ".result.json"))
|
|
1011
|
+
if (existsSync(side)) {
|
|
1012
|
+
try {
|
|
1013
|
+
sideSaved = JSON.parse(readFileSync(side, "utf8"))
|
|
1014
|
+
timedOut = sideSaved.timedOut === true
|
|
1015
|
+
} catch {
|
|
1016
|
+
// A sidecar that will not parse leaves the run scored on its stream.
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const invalid = s.steps === 0 || timedOut
|
|
593
1020
|
let ok = null
|
|
594
1021
|
let why
|
|
595
1022
|
if (job.mutate) {
|
|
596
1023
|
why = "mutation not rescored"
|
|
1024
|
+
} else if (timedOut) {
|
|
1025
|
+
why = "TIMED OUT, not scored"
|
|
597
1026
|
} else if (invalid) {
|
|
598
1027
|
why = "no output, not scored"
|
|
599
1028
|
} else {
|
|
@@ -601,17 +1030,55 @@ if (rescoreDir) {
|
|
|
601
1030
|
// the answers differently from the run it is re-scoring would be a
|
|
602
1031
|
// measurement of the grader, and the number it produced would not be
|
|
603
1032
|
// comparable with the one it is meant to explain.
|
|
604
|
-
|
|
1033
|
+
// `want` is the suite's field and `expect` is the one a bare `--task`
|
|
1034
|
+
// sets; reading only the first graded every single-task re-score against
|
|
1035
|
+
// the string "undefined" and reported "none held" for a right answer.
|
|
1036
|
+
const g = gradeAnswer(target, s.text, job.want ?? job.expect, { exclude: SUITE_FILES })
|
|
605
1037
|
ok = g.ok
|
|
606
1038
|
why = g.ok ? `cited ${g.cited}` : g.cited === 0 ? "no citation" : `cited ${g.cited}, none held`
|
|
607
1039
|
}
|
|
608
|
-
|
|
1040
|
+
// The wall time is the ceiling the run was measured against, not the
|
|
1041
|
+
// span of its last event. A run killed at 180s has a stream that stops
|
|
1042
|
+
// at 58s, and reading the stream alone reported it as a 58s run.
|
|
1043
|
+
runs.push({ id: job.id, arm: m[2], i: Number(m[3]), ...s, wall: sideSaved?.wall ?? s.seconds, code: 0, timedOut, invalid: invalid || job.mutate, correct: ok, why })
|
|
609
1044
|
console.log(` ${job.id} ${m[2]}-${m[3]} ... ${why}`)
|
|
610
1045
|
}
|
|
611
1046
|
console.log("")
|
|
612
1047
|
} else for (const job of jobs) {
|
|
613
1048
|
for (let i = 1; i <= reps; i++) {
|
|
614
|
-
for (const arm of ["on", "off"]) {
|
|
1049
|
+
for (const arm of /** @type {const} */ (["on", "off"])) {
|
|
1050
|
+
// A run already in `--out` is not run again. This is the difference
|
|
1051
|
+
// between an interrupted suite costing its remaining runs and costing
|
|
1052
|
+
// all of them: a 36-run suite stopped at 13 paid for 13 twice.
|
|
1053
|
+
const resumed = resumeRun(job.id, arm, i)
|
|
1054
|
+
if (resumed) {
|
|
1055
|
+
runs.push(resumed)
|
|
1056
|
+
console.log(` ${job.id} ${arm}-${i} ... resumed: ${resumed.why ?? (resumed.correct ? "correct" : "wrong")}`)
|
|
1057
|
+
continue
|
|
1058
|
+
}
|
|
1059
|
+
// Say it out loud when a sidecar was there but is not a result. Silence
|
|
1060
|
+
// would make the re-run look like a missing run, and the extra cost
|
|
1061
|
+
// like a bug in the resume.
|
|
1062
|
+
if (outFlag && !rescoreDir && existsSync(join(outDir, `${job.id}-${arm}-${i}.result.json`))) {
|
|
1063
|
+
console.log(` ${job.id} ${arm}-${i} ... retrying (previous run was not scored)`)
|
|
1064
|
+
}
|
|
1065
|
+
// A ceiling on the suite's own spend, checked before each run rather
|
|
1066
|
+
// than after: the point is not to buy the last run, it is to stop with
|
|
1067
|
+
// room left in the window.
|
|
1068
|
+
if (suiteBudget > 0) {
|
|
1069
|
+
// An older sidecar has no `contextTokens`; fall back to the uncached
|
|
1070
|
+
// count rather than to zero, and let resumeRun() fill the real number
|
|
1071
|
+
// in from the stream when the stream is still there.
|
|
1072
|
+
const spent = runs.reduce((n, r) => n + (r.contextTokens ?? r.inputTokens ?? 0), 0)
|
|
1073
|
+
if (spent >= suiteBudget) {
|
|
1074
|
+
console.log("")
|
|
1075
|
+
console.error(`STOP: the suite has spent ${spent.toLocaleString("en-US")} context tokens, its --budget is ${suiteBudget.toLocaleString("en-US")}.`)
|
|
1076
|
+
console.error(` ${runs.length} run(s) measured, the rest were not started. A run's cost is only`)
|
|
1077
|
+
console.error(` known after it finishes, so the cap stops the run after the one that crossed it.`)
|
|
1078
|
+
console.error(` resume later with the same --out: node eval/ab.mjs ... --out ${outDir}`)
|
|
1079
|
+
process.exit(2)
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
615
1082
|
// Every run gets its own throwaway copy of the subject. A read-only task
|
|
616
1083
|
// does not need one, but sharing a tree between an editing task and
|
|
617
1084
|
// anything else is how a run inherits the previous run's work.
|
|
@@ -619,9 +1086,57 @@ if (rescoreDir) {
|
|
|
619
1086
|
cpSync(target, runDir, { recursive: true })
|
|
620
1087
|
if (sanitize && leaks.length) sanitizeCopy(runDir)
|
|
621
1088
|
process.stdout.write(` ${job.id} ${arm}-${i} ... `)
|
|
622
|
-
const r = runArmCapture(arm, nokit, job.id, i, outDir, job.task, runDir)
|
|
1089
|
+
const r = await runArmCapture(arm, nokit, job.id, i, outDir, job.task, runDir)
|
|
1090
|
+
// The provider refusing the run is not a result. It used to look like
|
|
1091
|
+
// one: the error event was on stdout, the A/B read it as an empty
|
|
1092
|
+
// stream, and --fail-fast needed two of them before stopping. One is
|
|
1093
|
+
// enough to know the window is closed.
|
|
1094
|
+
if (r.quota) {
|
|
1095
|
+
console.log("")
|
|
1096
|
+
console.error("FAIL: the model route is out of quota, so this measured the provider, not the kit.")
|
|
1097
|
+
console.error(` ${r.quota}`)
|
|
1098
|
+
console.error(" The free tier is shared: a long chat session and this suite draw on the same window.")
|
|
1099
|
+
console.error(" Nothing after this run was measured. Wait for the window to roll, or use a paid route.")
|
|
1100
|
+
console.error(` raw stream kept: ${r.out}`)
|
|
1101
|
+
process.exit(2)
|
|
1102
|
+
}
|
|
623
1103
|
const runReal = realpathSync(runDir)
|
|
624
1104
|
const m = parseStream(r.out, runReal)
|
|
1105
|
+
if (m.steps === 0 && m.text.trim() === "") {
|
|
1106
|
+
// An error the installation can state is not a dead route to wait out:
|
|
1107
|
+
// it will fail every run the same way, and --fail-fast would need two
|
|
1108
|
+
// of them per task. Stop now and show the message.
|
|
1109
|
+
const why = streamError(readFileSync(r.out, "utf8"))
|
|
1110
|
+
if (why) {
|
|
1111
|
+
console.log("")
|
|
1112
|
+
console.error("FAIL: the run failed before it started, so this measured the installation, not the kit.")
|
|
1113
|
+
console.error(` ${why}`)
|
|
1114
|
+
console.error(` ${job.id} ${arm}-${i} produced no tool calls and no text. Nothing after it was measured.`)
|
|
1115
|
+
console.error(` A model the arm cannot resolve is the usual cause: the off arm has its own HOME.`)
|
|
1116
|
+
console.error(` raw stream kept: ${r.out}`)
|
|
1117
|
+
process.exit(2)
|
|
1118
|
+
}
|
|
1119
|
+
deadStreak++
|
|
1120
|
+
if (deadStreak >= failFast) {
|
|
1121
|
+
stopDeadRoute(deadStreak, "produced no tool calls and no text", job, arm, i)
|
|
1122
|
+
}
|
|
1123
|
+
} else {
|
|
1124
|
+
// A timeout that kept emitting events is a slow task. A timeout that
|
|
1125
|
+
// went quiet well before the ceiling is a route that stopped
|
|
1126
|
+
// answering after one step - the 10-rep run had four of those at
|
|
1127
|
+
// 58-164s against a 180s ceiling, and resetting the streak on each
|
|
1128
|
+
// one measured the rest of the suite at 180s per run.
|
|
1129
|
+
const quietS = m.seconds === null ? r.wall : r.wall - m.seconds
|
|
1130
|
+
const stalled = r.timedOut && quietS >= Math.min(60, runTimeoutMs / 2000)
|
|
1131
|
+
if (stalled) {
|
|
1132
|
+
deadStreak++
|
|
1133
|
+
if (deadStreak >= failFast) {
|
|
1134
|
+
stopDeadRoute(deadStreak, "stalled before the ceiling", job, arm, i)
|
|
1135
|
+
}
|
|
1136
|
+
} else if (!r.timedOut) {
|
|
1137
|
+
deadStreak = 0
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
625
1140
|
// Grade against the repository, not against a string I typed. The
|
|
626
1141
|
// substring grader produced three wrong numbers in RESULTS.md; this
|
|
627
1142
|
// resolves every citation the agent made and reads the real line.
|
|
@@ -682,7 +1197,11 @@ if (rescoreDir) {
|
|
|
682
1197
|
ok = g.ok
|
|
683
1198
|
why = g.ok ? `cited ${g.cited}` : g.cited === 0 ? "no citation" : `cited ${g.cited}, none held`
|
|
684
1199
|
}
|
|
685
|
-
|
|
1200
|
+
const run = { id: job.id, arm, i, ...m, wall: r.wall, code: r.code, timedOut: r.timedOut, invalid, correct: ok, why }
|
|
1201
|
+
runs.push(run)
|
|
1202
|
+
// Written before the escape check, because a run that escaped is still
|
|
1203
|
+
// a result and re-running it would repeat the escape.
|
|
1204
|
+
saveRun(run)
|
|
686
1205
|
// Stop the moment an arm reaches outside the run directory. The first
|
|
687
1206
|
// pilot wrote to the real repository while the task said not to modify
|
|
688
1207
|
// anything, so a second arm is not worth the risk once this has happened.
|
|
@@ -712,11 +1231,14 @@ if (rescoreDir) {
|
|
|
712
1231
|
|
|
713
1232
|
const after = fingerprint(target)
|
|
714
1233
|
// Median, never mean. See median() in ./metrics.js for the run that proves why.
|
|
715
|
-
// Invalid runs are excluded
|
|
716
|
-
// tools, no tokens and no answer, and including its zeros would
|
|
717
|
-
// median toward looking as though the agent did nothing at all.
|
|
1234
|
+
// Invalid runs are excluded from the work metrics: a process that emitted
|
|
1235
|
+
// nothing has no tools, no tokens and no answer, and including its zeros would
|
|
1236
|
+
// drag every median toward looking as though the agent did nothing at all.
|
|
1237
|
+
// Wall time is the exception. A run that timed out still spent its ceiling,
|
|
1238
|
+
// and hiding it would report a suite that cost less time than it did.
|
|
718
1239
|
const scored = runs.filter((r) => !r.invalid)
|
|
719
1240
|
const stat = (arm, key) => median(scored.filter((r) => r.arm === arm).map((r) => r[key]))
|
|
1241
|
+
const wallStat = (arm) => median(runs.filter((r) => r.arm === arm).map((r) => r.wall))
|
|
720
1242
|
const scoreOf = (arm) => {
|
|
721
1243
|
const rs = scored.filter((r) => r.arm === arm)
|
|
722
1244
|
return `${rs.filter((r) => r.correct).length}/${rs.length}`
|
|
@@ -734,9 +1256,10 @@ const rows = [
|
|
|
734
1256
|
["narrow %", stat("on", "narrow_pct"), stat("off", "narrow_pct")],
|
|
735
1257
|
["grep/glob", stat("on", "searches"), stat("off", "searches")],
|
|
736
1258
|
["steps", stat("on", "steps"), stat("off", "steps")],
|
|
737
|
-
["
|
|
1259
|
+
["context tokens", stat("on", "contextTokens"), stat("off", "contextTokens")],
|
|
1260
|
+
["uncached input", stat("on", "inputTokens"), stat("off", "inputTokens")],
|
|
738
1261
|
["final ctx", stat("on", "finalCtx"), stat("off", "finalCtx")],
|
|
739
|
-
["wall seconds",
|
|
1262
|
+
["wall seconds", wallStat("on"), wallStat("off")],
|
|
740
1263
|
["not scored", runs.filter((r) => r.arm === "on" && r.invalid).length, runs.filter((r) => r.arm === "off" && r.invalid).length],
|
|
741
1264
|
// The escape check compares against a per-run copy that no longer exists when
|
|
742
1265
|
// rescoring, so it would report every path as an escape. Report nothing rather
|