opencode-harness-kit 0.4.2 → 0.4.4
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 +544 -19
- package/CONTRIBUTING.md +242 -0
- package/GLOBAL.md +34 -5
- package/MIGRATION.md +151 -0
- package/README.md +294 -37
- package/SECURITY.md +30 -0
- package/bin/baseline.mjs +15 -7
- package/bin/git-hooks/pre-commit +41 -9
- package/bin/oc-harness.mjs +1265 -82
- package/dist/config.d.ts +127 -0
- package/dist/denylist.d.ts +33 -0
- package/dist/effort.d.ts +64 -0
- package/dist/guard.d.ts +23 -0
- package/dist/health.d.ts +43 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +532 -149
- package/dist/plugin.d.ts +15 -0
- package/dist/profile.d.ts +399 -0
- package/dist/project.d.ts +8 -0
- package/dist/prune.d.ts +6 -0
- package/dist/router.d.ts +3 -0
- package/dist/rules.d.ts +109 -0
- package/dist/safe.d.ts +7 -0
- package/dist/telemetry-store.d.ts +12 -0
- package/dist/telemetry.d.ts +3 -0
- package/docs/EXIT-CODES.md +71 -0
- package/docs/KNOWN-LIMITS.md +45 -21
- package/docs/RESEARCH.md +9 -4
- package/docs/ROUTING.md +53 -8
- package/eval/RESULTS.md +443 -0
- package/eval/ab.mjs +743 -0
- package/eval/attribute.mjs +170 -0
- package/eval/flags.mjs +88 -0
- package/eval/measure-install.mjs +323 -0
- package/eval/measure-prune.mjs +339 -0
- package/eval/metrics.js +79 -0
- package/eval/premises.mjs +200 -0
- package/eval/prepare.mjs +233 -0
- package/eval/run.mjs +376 -0
- package/eval/tasks-self.json +81 -0
- package/eval/tasks-ts.json +50 -0
- package/eval/tasks.json +83 -0
- package/eval/thresholds.mjs +134 -0
- package/eval/verify.mjs +287 -0
- package/package.json +44 -17
- package/profiles/fleet-all.json +6 -1
- package/profiles/fleet-safe.json +7 -3
- package/profiles/schema.json +182 -51
- package/prompts/build.md +4 -2
- package/skills/space-build/SKILL.md +4 -2
- package/skills/space-finish/SKILL.md +6 -2
- package/skills/space-plan/reference/task-template.md +13 -5
- package/src/config.js +182 -32
- package/src/denylist.js +95 -0
- package/src/driver/run.mjs +81 -16
- package/src/effort.js +15 -4
- package/src/guard.ts +165 -13
- package/src/health.js +80 -0
- package/src/index.ts +15 -4
- package/src/lane-usage.js +72 -18
- package/src/plugin-type-check.ts +38 -0
- package/src/plugin.ts +39 -0
- package/src/profile.js +225 -26
- package/src/project.js +47 -0
- package/src/prune.ts +110 -31
- package/src/router.ts +32 -63
- package/src/rules.js +99 -7
- package/src/safe.ts +27 -0
- package/src/sqlite.js +144 -0
- package/src/tasks.js +112 -29
- package/src/telemetry-store.ts +62 -0
- package/src/telemetry.ts +60 -57
- package/src/trust.js +70 -9
package/eval/prepare.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// prepare.mjs - build the subject the A/B suite runs against.
|
|
3
|
+
//
|
|
4
|
+
// Everything measured in eval/RESULTS.md was run against a directory somebody
|
|
5
|
+
// made by hand in a temp folder. That is not a result anyone else can check, and
|
|
6
|
+
// the directory is already gone on some of the older runs. This makes it a
|
|
7
|
+
// command.
|
|
8
|
+
//
|
|
9
|
+
// node eval/prepare.mjs --repo ~/work/lablab-harness --ref 8a674f5
|
|
10
|
+
// node eval/prepare.mjs --repo ~/work/lablab-harness --ref 8a674f5 --json
|
|
11
|
+
//
|
|
12
|
+
// node eval/prepare.mjs --from ~/work/gibwork/vendor/gibwork-sentinel
|
|
13
|
+
//
|
|
14
|
+
// --repo uses `git archive`, so the subject is exactly a committed tree: no
|
|
15
|
+
// uncommitted edits, no .git, no build output that was never committed. --from
|
|
16
|
+
// copies a plain directory, for sources that are not in git.
|
|
17
|
+
//
|
|
18
|
+
// Either way the subject lands somewhere stable under the scratch root rather
|
|
19
|
+
// than a fresh mktemp, so a run can be pointed at the same bytes twice.
|
|
20
|
+
|
|
21
|
+
import { execFileSync, spawnSync } from "node:child_process"
|
|
22
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"
|
|
23
|
+
import { homedir, tmpdir } from "node:os"
|
|
24
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path"
|
|
25
|
+
|
|
26
|
+
const args = process.argv.slice(2)
|
|
27
|
+
const flag = (...n) => {
|
|
28
|
+
const i = args.findIndex((a) => n.includes(a))
|
|
29
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const repo = flag("--repo")
|
|
33
|
+
const ref = flag("--ref") ?? "HEAD"
|
|
34
|
+
const from = flag("--from")
|
|
35
|
+
const wantJson = args.includes("--json")
|
|
36
|
+
const name = flag("--name")
|
|
37
|
+
|
|
38
|
+
if (!repo && !from) {
|
|
39
|
+
console.error("usage: node eval/prepare.mjs (--repo <path> [--ref <sha>] | --from <path>) [--name <n>] [--json]")
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
if (repo && from) {
|
|
43
|
+
console.error("pass --repo or --from, not both")
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The same scan ab.mjs runs before it will start. Repeated here because the
|
|
49
|
+
* whole point is that a subject which leaks is a subject an agent can walk out
|
|
50
|
+
* of, and the person preparing it should find out now, not after twenty runs.
|
|
51
|
+
*/
|
|
52
|
+
function findLeakedPaths(dir, depth = 0) {
|
|
53
|
+
if (depth > 6) return []
|
|
54
|
+
const found = []
|
|
55
|
+
let entries = []
|
|
56
|
+
try {
|
|
57
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
58
|
+
} catch {
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
for (const e of entries) {
|
|
62
|
+
if (e.name === ".git" || e.name === ".space" || e.name === "node_modules") continue
|
|
63
|
+
const p = join(dir, e.name)
|
|
64
|
+
if (e.isDirectory()) {
|
|
65
|
+
found.push(...findLeakedPaths(p, depth + 1))
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (!/\.(md|py|ts|tsx|js|json|txt|ya?ml|toml|sh)$/.test(e.name)) continue
|
|
69
|
+
let body
|
|
70
|
+
try {
|
|
71
|
+
body = readFileSync(p, "utf8")
|
|
72
|
+
} catch {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
for (const m of body.matchAll(/\/(?:Users|home)\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*/g)) {
|
|
76
|
+
const abs = m[0].replace(/[.,;:)]+$/, "")
|
|
77
|
+
if (!existsSync(abs)) continue
|
|
78
|
+
if (abs === dir || abs.startsWith(dir + "/")) continue
|
|
79
|
+
found.push({ file: p.slice(dir.length + 1), path: abs })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const seen = new Set()
|
|
83
|
+
return found.filter((f) => (seen.has(f.path) ? false : seen.add(f.path)))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const scratch = resolve(process.env.SPACE_HARNESS_SCRATCH ?? join(tmpdir(), "space-harness-subjects"))
|
|
87
|
+
mkdirSync(scratch, { recursive: true })
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The directory NAME of a subject.
|
|
91
|
+
*
|
|
92
|
+
* A name, not a path. `--name ../..` used to resolve outside the scratch
|
|
93
|
+
* root, and the `rmSync` below then deleted whatever was there - a subject
|
|
94
|
+
* label is a command-line argument, and the command below it removes a
|
|
95
|
+
* directory tree recursively.
|
|
96
|
+
*
|
|
97
|
+
* The check is made twice on purpose: once here, on the text, and once on
|
|
98
|
+
* the resolved path, below. Either alone would be enough; both together mean
|
|
99
|
+
* a mistake in either cannot delete anything.
|
|
100
|
+
*
|
|
101
|
+
* @param {string | undefined} raw
|
|
102
|
+
* @returns {string | null}
|
|
103
|
+
*/
|
|
104
|
+
function safeLabel(raw) {
|
|
105
|
+
if (!raw) return null
|
|
106
|
+
if (/[\\/]/.test(raw)) return null
|
|
107
|
+
if (raw === "." || raw === "..") return null
|
|
108
|
+
if (raw.includes("..")) return null
|
|
109
|
+
if (raw.length === 0) return null
|
|
110
|
+
return raw
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const requested = name ?? (from ? basename(from) : `${basename(repo ?? "repo")}-${ref}`)
|
|
114
|
+
const label = safeLabel(requested)
|
|
115
|
+
if (!label) {
|
|
116
|
+
console.error(`--name must be a single directory name, not a path: ${requested}`)
|
|
117
|
+
process.exit(1)
|
|
118
|
+
}
|
|
119
|
+
const out = join(scratch, label)
|
|
120
|
+
|
|
121
|
+
// Belt and braces: the resolved path has to still be inside the scratch root.
|
|
122
|
+
const rel = relative(scratch, resolve(out))
|
|
123
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
124
|
+
console.error(`refusing to write outside the scratch root: ${out}`)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (existsSync(out)) rmSync(out, { recursive: true, force: true })
|
|
129
|
+
mkdirSync(out, { recursive: true })
|
|
130
|
+
|
|
131
|
+
let source
|
|
132
|
+
let pinned
|
|
133
|
+
if (from) {
|
|
134
|
+
if (!existsSync(from)) {
|
|
135
|
+
console.error(`--from path not found: ${from}`)
|
|
136
|
+
process.exit(1)
|
|
137
|
+
}
|
|
138
|
+
cpSync(from, out, { recursive: true })
|
|
139
|
+
source = from
|
|
140
|
+
pinned = "not a git repo, copied as found"
|
|
141
|
+
} else {
|
|
142
|
+
if (!existsSync(join(repo, ".git"))) {
|
|
143
|
+
console.error(`not a git repository: ${repo}`)
|
|
144
|
+
process.exit(1)
|
|
145
|
+
}
|
|
146
|
+
let sha
|
|
147
|
+
try {
|
|
148
|
+
sha = execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8" }).trim()
|
|
149
|
+
} catch {
|
|
150
|
+
console.error(`cannot resolve ref ${ref} in ${repo}`)
|
|
151
|
+
process.exit(1)
|
|
152
|
+
}
|
|
153
|
+
// git archive writes a tar to stdout. Piping it into tar keeps this in one
|
|
154
|
+
// process rather than leaving a tarball behind for someone to trip over.
|
|
155
|
+
//
|
|
156
|
+
// No shell. The previous version built one `sh -c` string and quoted the
|
|
157
|
+
// paths with `JSON.stringify`, which is not shell quoting: it escapes `"` and
|
|
158
|
+
// `\` and leaves `$`, backticks and `!` alone, so a path containing `$(...)`
|
|
159
|
+
// was executed by the shell. `git` and `tar` are called with argument arrays
|
|
160
|
+
// and the tar goes through a pipe, so nothing is ever parsed as a command.
|
|
161
|
+
//
|
|
162
|
+
// `eval/` and `test/` are EXCLUDED, and that is not a detail. The subject of
|
|
163
|
+
// the self suite is this repository: `eval/` holds the grader and the
|
|
164
|
+
// answer key, and `test/` quotes the answer in its own assertions. An agent
|
|
165
|
+
// that found `eval/verify.mjs` would use the grader to check its own work,
|
|
166
|
+
// and one that found the answer in a test would cite the test.
|
|
167
|
+
//
|
|
168
|
+
// Both were measured. An agent that read the grader made 48 tool calls to a
|
|
169
|
+
// task that needed 5, and the answer was somewhere else in the file it was
|
|
170
|
+
// asked about.
|
|
171
|
+
const archive = spawnSync(
|
|
172
|
+
"git",
|
|
173
|
+
[
|
|
174
|
+
"-C", repo, "archive", "--format=tar", sha, "--",
|
|
175
|
+
".",
|
|
176
|
+
":(exclude)eval",
|
|
177
|
+
":(exclude)test",
|
|
178
|
+
],
|
|
179
|
+
{
|
|
180
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
if (archive.status !== 0 || archive.error) {
|
|
184
|
+
console.error(`cannot archive ${repo} at ${sha}`)
|
|
185
|
+
if (archive.stderr?.length) console.error(archive.stderr.toString().trimEnd())
|
|
186
|
+
process.exit(1)
|
|
187
|
+
}
|
|
188
|
+
const untar = spawnSync("tar", ["-x", "-C", out], {
|
|
189
|
+
input: archive.stdout,
|
|
190
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
191
|
+
})
|
|
192
|
+
if (untar.status !== 0 || untar.error) {
|
|
193
|
+
console.error(`cannot unpack the archive into ${out}`)
|
|
194
|
+
if (untar.stderr?.length) console.error(untar.stderr.toString().trimEnd())
|
|
195
|
+
process.exit(1)
|
|
196
|
+
}
|
|
197
|
+
source = repo
|
|
198
|
+
pinned = sha.slice(0, 12)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Record what this subject is, next to it, so a later run can be checked against
|
|
202
|
+
// it without having to reconstruct the command.
|
|
203
|
+
const manifest = {
|
|
204
|
+
source,
|
|
205
|
+
ref: from ? null : ref,
|
|
206
|
+
commit: from ? null : pinned,
|
|
207
|
+
built_at: new Date().toISOString(),
|
|
208
|
+
command: `node eval/prepare.mjs ${from ? "--from " + from : `--repo ${repo} --ref ${ref}`}`,
|
|
209
|
+
}
|
|
210
|
+
writeFileSync(join(out, ".space-harness-subject.json"), JSON.stringify(manifest, null, 2) + "\n")
|
|
211
|
+
|
|
212
|
+
const leaks = findLeakedPaths(out)
|
|
213
|
+
const report = { path: out, ...manifest, leaked_paths: leaks.map((l) => l.path) }
|
|
214
|
+
|
|
215
|
+
if (wantJson) {
|
|
216
|
+
console.log(JSON.stringify(report, null, 2))
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`subject: ${out}`)
|
|
219
|
+
console.log(` from ${source}`)
|
|
220
|
+
console.log(` pinned ${pinned ?? "-"}`)
|
|
221
|
+
if (leaks.length) {
|
|
222
|
+
console.log("")
|
|
223
|
+
console.log(` ${leaks.length} absolute path(s) inside this subject resolve on this machine:`)
|
|
224
|
+
for (const l of leaks.slice(0, 8)) console.log(` ${l.path}`)
|
|
225
|
+
console.log("")
|
|
226
|
+
console.log(" ab.mjs will refuse to run against this unless you pass --sanitize,")
|
|
227
|
+
console.log(" which rewrites them in each run copy. Do that, or scrub the source.")
|
|
228
|
+
} else {
|
|
229
|
+
console.log(" no absolute paths resolve outside it")
|
|
230
|
+
}
|
|
231
|
+
console.log("")
|
|
232
|
+
console.log(` next: node eval/ab.mjs --target ${out} --suite eval/tasks.json --reps 2 --sanitize`)
|
|
233
|
+
}
|
package/eval/run.mjs
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// eval/run.mjs - did the harness actually change anything?
|
|
3
|
+
//
|
|
4
|
+
// A harness that claims to improve efficiency has to be measurable, or the
|
|
5
|
+
// claim is decoration. This runs the shipped transformers over a synthetic
|
|
6
|
+
// transcript shaped like a real one and reports what changed.
|
|
7
|
+
//
|
|
8
|
+
// It measures the mechanical effects that are deterministic and offline:
|
|
9
|
+
// - how much context the pruner removes
|
|
10
|
+
// - how many permission prompts the allowlist removes
|
|
11
|
+
// - whether effort tiering cuts the latency it predicts to cut
|
|
12
|
+
//
|
|
13
|
+
// It does NOT measure model quality. That needs real runs against real tasks,
|
|
14
|
+
// and a synthetic transcript cannot tell you whether the code got better.
|
|
15
|
+
//
|
|
16
|
+
// Usage:
|
|
17
|
+
// node eval/run.mjs human-readable
|
|
18
|
+
// node eval/run.mjs --json machine-readable
|
|
19
|
+
// node eval/run.mjs --compare measure with the kit disabled, for a diff
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from "node:child_process"
|
|
22
|
+
import { existsSync } from "node:fs"
|
|
23
|
+
import { homedir } from "node:os"
|
|
24
|
+
import { dirname, join } from "node:path"
|
|
25
|
+
import { fileURLToPath } from "node:url"
|
|
26
|
+
import { query as queryDb } from "../src/sqlite.js"
|
|
27
|
+
|
|
28
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
|
|
29
|
+
const DIST = join(ROOT, "dist", "index.js")
|
|
30
|
+
const wantJson = process.argv.includes("--json")
|
|
31
|
+
const compare = process.argv.includes("--compare")
|
|
32
|
+
|
|
33
|
+
if (!existsSync(DIST)) {
|
|
34
|
+
console.error("dist/index.js missing. Run: npm run build")
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A transcript shaped like the one that produced the real baseline: tool calls
|
|
40
|
+
* with kilobyte-scale output, a small amount of prose, and reasoning.
|
|
41
|
+
*/
|
|
42
|
+
function syntheticTranscript({ turns = 40, toolOutputBytes = 9000 } = {}) {
|
|
43
|
+
const messages = []
|
|
44
|
+
for (let i = 0; i < turns; i++) {
|
|
45
|
+
messages.push({
|
|
46
|
+
info: { id: `a${i}`, role: "assistant", time: { created: 1789000000000 + i } },
|
|
47
|
+
parts: [
|
|
48
|
+
{ id: `s${i}`, sessionID: "ses", messageID: `a${i}`, type: "step-start" },
|
|
49
|
+
{
|
|
50
|
+
id: `r${i}`,
|
|
51
|
+
sessionID: "ses",
|
|
52
|
+
messageID: `a${i}`,
|
|
53
|
+
type: "reasoning",
|
|
54
|
+
text: "thinking ".repeat(400),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: `t${i}`,
|
|
58
|
+
sessionID: "ses",
|
|
59
|
+
messageID: `a${i}`,
|
|
60
|
+
type: "tool",
|
|
61
|
+
callID: `c${i}`,
|
|
62
|
+
tool: i % 3 === 0 ? "bash" : "read",
|
|
63
|
+
state: {
|
|
64
|
+
status: "completed",
|
|
65
|
+
input: i % 3 === 0 ? { command: `pytest -q tests/test_${i}.py` } : { filePath: `src/mod_${i}.py` },
|
|
66
|
+
output: "x".repeat(toolOutputBytes),
|
|
67
|
+
title: "tool",
|
|
68
|
+
metadata: {},
|
|
69
|
+
time: { start: 1, end: 2 },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: `x${i}`,
|
|
74
|
+
sessionID: "ses",
|
|
75
|
+
messageID: `a${i}`,
|
|
76
|
+
type: "text",
|
|
77
|
+
text: "Short prose conclusion for this step.",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
})
|
|
81
|
+
if (i % 8 === 0) {
|
|
82
|
+
messages.push({
|
|
83
|
+
info: { id: `u${i}`, role: "user", time: { created: 1789000000000 + i } },
|
|
84
|
+
parts: [{ id: `ut${i}`, sessionID: "ses", messageID: `u${i}`, type: "text", text: "continue" }],
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return messages
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function bytesOf(messages) {
|
|
92
|
+
return JSON.stringify(messages).length
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function measurePruning() {
|
|
96
|
+
const { HarnessPlugin } = await import(DIST)
|
|
97
|
+
const worktree = join(ROOT, ".space-eval")
|
|
98
|
+
const { mkdirSync, writeFileSync, rmSync } = await import("node:fs")
|
|
99
|
+
mkdirSync(join(worktree, ".space"), { recursive: true })
|
|
100
|
+
writeFileSync(join(worktree, ".space", "active"), "fleet-safe\n")
|
|
101
|
+
|
|
102
|
+
const hooks = await HarnessPlugin(
|
|
103
|
+
{ worktree, directory: worktree, project: {}, client: {}, $: () => {} },
|
|
104
|
+
{ only: ["prune"], profile: "fleet-safe" },
|
|
105
|
+
)
|
|
106
|
+
const transform = hooks["experimental.chat.messages.transform"]
|
|
107
|
+
if (!transform) {
|
|
108
|
+
rmSync(worktree, { recursive: true, force: true })
|
|
109
|
+
throw new Error("prune hook did not activate; check the active marker path")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const messages = syntheticTranscript()
|
|
113
|
+
const before = bytesOf(messages)
|
|
114
|
+
await transform({}, { messages })
|
|
115
|
+
const after = bytesOf(messages)
|
|
116
|
+
|
|
117
|
+
const toolParts = messages.flatMap((m) => m.parts).filter((p) => p.type === "tool")
|
|
118
|
+
const pruned = toolParts.filter((p) => p.state?.metadata?.pruned).length
|
|
119
|
+
const assistantTurns = messages.filter((m) => m.info.role === "assistant").length
|
|
120
|
+
const usersIntact = messages
|
|
121
|
+
.filter((m) => m.info.role === "user")
|
|
122
|
+
.every((m) => m.parts[0].text === "continue")
|
|
123
|
+
|
|
124
|
+
rmSync(worktree, { recursive: true, force: true })
|
|
125
|
+
return {
|
|
126
|
+
bytesBefore: before,
|
|
127
|
+
bytesAfter: after,
|
|
128
|
+
reductionPct: +(((before - after) / before) * 100).toFixed(1),
|
|
129
|
+
assistantTurns,
|
|
130
|
+
toolParts: toolParts.length,
|
|
131
|
+
pruned,
|
|
132
|
+
usersIntact,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Replay a corpus of bash commands through an allowlist. */
|
|
137
|
+
async function measurePrompts() {
|
|
138
|
+
const { BASH_ALLOWLIST } = await import(join(ROOT, "src", "config.js"))
|
|
139
|
+
const corpus = [
|
|
140
|
+
// Shape from the measured 5,239 real commands, compressed.
|
|
141
|
+
...Array(1742).fill("cd /Users/x/proj && ls -la"),
|
|
142
|
+
...Array(326).fill("ls -la"),
|
|
143
|
+
...Array(219).fill("# build the app"),
|
|
144
|
+
...Array(217).fill('python3 -c "print(1)"'),
|
|
145
|
+
...Array(151).fill("gh pr list"),
|
|
146
|
+
...Array(148).fill("echo hello"),
|
|
147
|
+
...Array(141).fill("sleep 2"),
|
|
148
|
+
...Array(133).fill("ssh -o StrictHostKeyChecking=no deploy@host uptime"),
|
|
149
|
+
...Array(93).fill("curl -s https://example.test/api"),
|
|
150
|
+
...Array(80).fill("cat src/app.py"),
|
|
151
|
+
...Array(67).fill("TOKEN=$(curl -s https://example.test/token)"),
|
|
152
|
+
...Array(66).fill("rm -rf build/"),
|
|
153
|
+
...Array(59).fill("git add -A"),
|
|
154
|
+
...Array(47).fill("osascript -e 'tell app \"Finder\" to quit'"),
|
|
155
|
+
...Array(43).fill("pkill -f node"),
|
|
156
|
+
]
|
|
157
|
+
const glob = (cmd, pat) =>
|
|
158
|
+
new RegExp("^" + pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") + "$").test(cmd)
|
|
159
|
+
const prompts = corpus.filter((c) => !BASH_ALLOWLIST.some((p) => glob(c, p))).length
|
|
160
|
+
return {
|
|
161
|
+
commands: corpus.length,
|
|
162
|
+
prompts,
|
|
163
|
+
promptSharePct: +((prompts / corpus.length) * 100).toFixed(1),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Effort tiering, measured from the install database where that is possible.
|
|
169
|
+
*
|
|
170
|
+
* Per-variant medians are read for the profile's own model, so this no longer
|
|
171
|
+
* mixes models the way it used to. When every variant the role map depends on is
|
|
172
|
+
* present, the output is labelled measured; otherwise it falls back to the
|
|
173
|
+
* fixed constants below and says so, rather than substituting one tier's median
|
|
174
|
+
* for another.
|
|
175
|
+
*
|
|
176
|
+
* Even when measured, the saving is a median mix projected over a session, not
|
|
177
|
+
* an end-to-end session measurement. `oc-harness baseline` reports the same
|
|
178
|
+
* per-variant medians with sample sizes.
|
|
179
|
+
*/
|
|
180
|
+
/**
|
|
181
|
+
* Per-variant median request latency for one model, straight from the install
|
|
182
|
+
* database. Returns null when the database is missing or has no data for that
|
|
183
|
+
* model, so the caller can fall back rather than invent a number.
|
|
184
|
+
*
|
|
185
|
+
* Median ranks match bin/baseline.mjs: rank (n+1)/2 for odd n, the mean of the
|
|
186
|
+
* two middle ranks for even n.
|
|
187
|
+
*
|
|
188
|
+
* The query is transcribed rather than imported because bin/baseline.mjs runs
|
|
189
|
+
* its whole report at module top level and calls process.exit, so importing it
|
|
190
|
+
* would print the baseline report and kill this run. Keep the two in step when
|
|
191
|
+
* either changes.
|
|
192
|
+
*/
|
|
193
|
+
function mediansForModel(modelID, providerID) {
|
|
194
|
+
const db = join(homedir(), ".local/share/opencode/opencode.db")
|
|
195
|
+
if (!existsSync(db)) return null
|
|
196
|
+
|
|
197
|
+
const sql = `
|
|
198
|
+
select variant, printf('%.1f', avg(ms)) as median_ms, n
|
|
199
|
+
from (
|
|
200
|
+
select variant, ms, rn, n
|
|
201
|
+
from (
|
|
202
|
+
select coalesce(json_extract(data,'$.variant'),'(default)') as variant,
|
|
203
|
+
(json_extract(data,'$.time.completed')
|
|
204
|
+
- json_extract(data,'$.time.created')) / 1000.0 as ms,
|
|
205
|
+
row_number() over (
|
|
206
|
+
partition by coalesce(json_extract(data,'$.variant'),'(default)')
|
|
207
|
+
order by (json_extract(data,'$.time.completed')
|
|
208
|
+
- json_extract(data,'$.time.created')) / 1000.0) as rn,
|
|
209
|
+
count(*) over (
|
|
210
|
+
partition by coalesce(json_extract(data,'$.variant'),'(default)')) as n
|
|
211
|
+
from message
|
|
212
|
+
where json_extract(data,'$.role') = 'assistant'
|
|
213
|
+
${providerID ? `and json_extract(data,'$.providerID') = '${providerID.replace(/'/g, "''")}'` : ""}
|
|
214
|
+
and json_extract(data,'$.modelID') = '${modelID.replace(/'/g, "''")}'
|
|
215
|
+
and (json_extract(data,'$.time.completed')
|
|
216
|
+
- json_extract(data,'$.time.created')) / 1000.0 > 0
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
where rn = cast((n + 1) / 2 as integer)
|
|
220
|
+
or (n % 2 = 0 and rn = cast((n + 2) / 2 as integer))
|
|
221
|
+
group by variant, n`
|
|
222
|
+
|
|
223
|
+
let rows
|
|
224
|
+
try {
|
|
225
|
+
// The shared helper, so this script runs on a machine without the sqlite3
|
|
226
|
+
// CLI like every other measurement script.
|
|
227
|
+
rows = queryDb(db, sql)
|
|
228
|
+
} catch {
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
if (!rows.length) return null
|
|
232
|
+
|
|
233
|
+
const MEDIAN = {}
|
|
234
|
+
const samples = {}
|
|
235
|
+
for (const r of rows) {
|
|
236
|
+
MEDIAN[r.variant] = Number(r.median_ms)
|
|
237
|
+
samples[r.variant] = r.n
|
|
238
|
+
}
|
|
239
|
+
return { MEDIAN, samples, source: "measured" }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function measureEffort() {
|
|
243
|
+
const { loadProfile, resolveRoles } = await import(join(ROOT, "src", "profile.js"))
|
|
244
|
+
|
|
245
|
+
const FALLBACK = { max: 18.7, xhigh: 7.8, high: 7.8, medium: 7.8, low: 7.8 }
|
|
246
|
+
const p = loadProfile("fleet-safe")
|
|
247
|
+
const roles = resolveRoles(p)
|
|
248
|
+
|
|
249
|
+
// A realistic session: one plan, eight builds, two reviews, four verifications.
|
|
250
|
+
const mix = { plan: 1, build: 8, reviewer: 2, verify: 4, locate: 0 }
|
|
251
|
+
|
|
252
|
+
// Every variant this report depends on has to have been measured. A missing
|
|
253
|
+
// one must not be substituted with another tier's median: that would print a
|
|
254
|
+
// number under a "measured" header for a tier that was never measured.
|
|
255
|
+
const needed = new Set([...Object.values(roles).filter(Boolean), "max"])
|
|
256
|
+
const found = mediansForModel(p.model, p.provider)
|
|
257
|
+
const complete = found && [...needed].every((v) => typeof found.MEDIAN[v] === "number")
|
|
258
|
+
|
|
259
|
+
const { MEDIAN, samples, source } = complete
|
|
260
|
+
? found
|
|
261
|
+
: { MEDIAN: FALLBACK, samples: {}, source: "projection" }
|
|
262
|
+
|
|
263
|
+
let allMax = 0
|
|
264
|
+
let tiered = 0
|
|
265
|
+
for (const [role, count] of Object.entries(mix)) {
|
|
266
|
+
allMax += count * MEDIAN.max
|
|
267
|
+
const level = roles[role]
|
|
268
|
+
tiered += count * MEDIAN[level ?? "max"]
|
|
269
|
+
}
|
|
270
|
+
// Only the variants this report actually depends on: the completeness gate
|
|
271
|
+
// covers these, so listing anything else would print unchecked numbers under
|
|
272
|
+
// a measured header.
|
|
273
|
+
const shown = Object.entries(samples).filter(([v]) => needed.has(v))
|
|
274
|
+
const thin = shown.filter(([, n]) => n < 30)
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
mix,
|
|
278
|
+
source,
|
|
279
|
+
medians: MEDIAN,
|
|
280
|
+
samples: Object.fromEntries(shown),
|
|
281
|
+
allMaxSeconds: +allMax.toFixed(1),
|
|
282
|
+
tieredSeconds: +tiered.toFixed(1),
|
|
283
|
+
savedPct: +(((allMax - tiered) / allMax) * 100).toFixed(1),
|
|
284
|
+
measured: source === "measured",
|
|
285
|
+
caveat:
|
|
286
|
+
source === "measured"
|
|
287
|
+
? thin.length
|
|
288
|
+
? `Median per request on this model from the install database. ` +
|
|
289
|
+
`Treat the tiers with few samples as indicative only: ` +
|
|
290
|
+
thin.map(([v, n]) => `${v} (n=${n})`).join(", ") +
|
|
291
|
+
`. The saving below is that median mix projected over a session, ` +
|
|
292
|
+
`not a measured end-to-end session.`
|
|
293
|
+
: `Median per request on this model from the install database, ` +
|
|
294
|
+
`projected over a session mix. Not an end-to-end session measurement.`
|
|
295
|
+
: `PROJECTION, not a measurement: no complete per-variant data for ` +
|
|
296
|
+
`${p.model} in the install database, so fixed constants are used. ` +
|
|
297
|
+
`The cause may be a missing or unreadable database, or a variant ` +
|
|
298
|
+
`that has not been exercised yet. \`oc-harness baseline\` reports ` +
|
|
299
|
+
`per-variant medians with sample sizes and will say which is which.`,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const [pruning, prompts, effort] = await Promise.all([
|
|
304
|
+
measurePruning(),
|
|
305
|
+
measurePrompts(),
|
|
306
|
+
measureEffort(),
|
|
307
|
+
])
|
|
308
|
+
|
|
309
|
+
const report = { generated: new Date().toISOString(), pruning, prompts, effort }
|
|
310
|
+
|
|
311
|
+
if (wantJson) {
|
|
312
|
+
console.log(JSON.stringify(report, null, 2))
|
|
313
|
+
process.exit(0)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const n = (x) => Number(x).toLocaleString("en-US")
|
|
317
|
+
const pad = (k) => k.padEnd(34)
|
|
318
|
+
const stamp = (s) => `\x1b[2m${s}\x1b[0m`
|
|
319
|
+
const wrap = (text, width) => {
|
|
320
|
+
const words = text.split(/\s+/)
|
|
321
|
+
const lines = []
|
|
322
|
+
let line = ""
|
|
323
|
+
for (const w of words) {
|
|
324
|
+
if ((line + " " + w).trim().length > width) {
|
|
325
|
+
lines.push(line.trim())
|
|
326
|
+
line = w
|
|
327
|
+
} else {
|
|
328
|
+
line += " " + w
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (line.trim()) lines.push(line.trim())
|
|
332
|
+
return lines
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
console.log(`\nopencode-harness-kit eval ${report.generated}\n`)
|
|
336
|
+
|
|
337
|
+
console.log(`\nCONTEXT PRUNING (synthetic transcript: ${pruning.assistantTurns} assistant turns, ${pruning.toolParts} tool calls)`)
|
|
338
|
+
console.log(` ${pad("bytes before")} ${n(pruning.bytesBefore)}`)
|
|
339
|
+
console.log(` ${pad("bytes after")} ${n(pruning.bytesAfter)}`)
|
|
340
|
+
console.log(` ${pad("reduction")} ${pruning.reductionPct}%`)
|
|
341
|
+
console.log(` ${pad("tool parts pruned")} ${pruning.pruned} / ${pruning.toolParts}`)
|
|
342
|
+
console.log(` ${pad("user messages intact")} ${pruning.usersIntact ? "yes" : "NO"}`)
|
|
343
|
+
|
|
344
|
+
console.log("\nPERMISSION PROMPTS (corpus shaped from 5,239 measured commands)")
|
|
345
|
+
console.log(` ${pad("commands")} ${n(prompts.commands)}`)
|
|
346
|
+
console.log(` ${pad("prompts")} ${n(prompts.prompts)} (${prompts.promptSharePct}%)`)
|
|
347
|
+
console.log(` ${pad("previously")} 95.3% with a narrow allowlist`)
|
|
348
|
+
|
|
349
|
+
const tierHeader = effort.measured
|
|
350
|
+
? "EFFORT TIERING (medians measured on this model, projected over a session mix)"
|
|
351
|
+
: "EFFORT TIERING (PROJECTION - cross-model medians, not a measurement)"
|
|
352
|
+
console.log(`\n${tierHeader}`)
|
|
353
|
+
console.log(` ${pad("all-max session")} ${effort.allMaxSeconds}s`)
|
|
354
|
+
console.log(` ${pad("tiered session")} ${effort.tieredSeconds}s`)
|
|
355
|
+
console.log(` ${pad("implied saving")} ${effort.savedPct}%`)
|
|
356
|
+
console.log(` ${pad("measured")} ${effort.measured ? "per-variant medians yes, session no" : "no"}`)
|
|
357
|
+
if (effort.measured) {
|
|
358
|
+
// Print from samples, not medians: both are restricted to the variants the
|
|
359
|
+
// completeness gate checked, so they cannot drift apart here.
|
|
360
|
+
const med = Object.entries(effort.samples)
|
|
361
|
+
.map(([v, n]) => `${v} ${effort.medians[v]}s (n=${n})`)
|
|
362
|
+
.join(", ")
|
|
363
|
+
console.log(` ${pad("median per request")} ${stamp(med)}`)
|
|
364
|
+
}
|
|
365
|
+
for (const line of wrap(effort.caveat, 60)) console.log(` ${pad("")} ${stamp(line)}`)
|
|
366
|
+
|
|
367
|
+
if (compare) {
|
|
368
|
+
const off = syntheticTranscript()
|
|
369
|
+
console.log(
|
|
370
|
+
`\n ${pad("kit disabled")} ${n(bytesOf(off))} bytes ` +
|
|
371
|
+
`(${(((bytesOf(off) - pruning.bytesAfter) / bytesOf(off)) * 100).toFixed(1)}% larger than pruned)`,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
console.log("\n Not measured here: code quality, task completion, review defects.")
|
|
376
|
+
console.log(" Those need real runs against real tasks. See docs/RESEARCH.md.\n")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": [
|
|
3
|
+
"Verification tasks for eval/ab.mjs --suite, written against THIS repository.",
|
|
4
|
+
"",
|
|
5
|
+
"The earlier suite was written against `lablab-harness@8a674f5`, which is",
|
|
6
|
+
"not public. A subject nobody else can fetch is a result nobody else can",
|
|
7
|
+
"check, and the A/B is the one number in this repository that decides",
|
|
8
|
+
"whether the kit is worth its cost. This suite runs against the kit's own",
|
|
9
|
+
"tree at a pinned commit, so `git clone && git checkout <sha>` reproduces",
|
|
10
|
+
"it exactly.",
|
|
11
|
+
"",
|
|
12
|
+
"Grading is by citation, not by prose: each read task names `want`, a",
|
|
13
|
+
"substring of one real source line, and eval/verify.mjs resolves the path:line",
|
|
14
|
+
"the agent cited and reads that line out of the snapshot. An answer can only",
|
|
15
|
+
"pass by pointing at the code.",
|
|
16
|
+
"",
|
|
17
|
+
"The control comes first and is kept, because it is the control: one file,",
|
|
18
|
+
"one constant. The rest need enumeration, discrimination between two similar",
|
|
19
|
+
"things, or two files - which is what the multi-step tasks are for.",
|
|
20
|
+
"",
|
|
21
|
+
"Every `want` below was read out of the tree with grep before it was written",
|
|
22
|
+
"here. Re-check with: git archive | tar -x && grep -n"
|
|
23
|
+
],
|
|
24
|
+
"snapshot": "this repository, at the commit the suite is committed with",
|
|
25
|
+
"tasks": [
|
|
26
|
+
{
|
|
27
|
+
"id": "read-control",
|
|
28
|
+
"$comment": "Control. One file, one constant.",
|
|
29
|
+
"task": "In this repository, one exported constant names the five lanes that are part of the plan -> build -> review -> finish loop. Report the exact file path and line number where that constant is defined, and quote the line. Do not modify any files.",
|
|
30
|
+
"want": "export const WORKFLOW_LANES = [\"build\", \"plan\", \"reviewer\", \"locate\", \"verify\"]"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "read-multi-denylist",
|
|
34
|
+
"$comment": "One file, eleven candidates, and two of them are the same command written with and without a space - so the agent cannot stop at the first `chmod 777`.",
|
|
35
|
+
"task": "In this repository, one source file lists the bash commands the kit denies outright. The list has eleven entries, and two of them are the same command written with and without a space. Report the file and the line number of the entry that denies `gh repo delete`, and quote it. Do not modify any files.",
|
|
36
|
+
"want": "\"gh repo delete*\""
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "read-multi-gate",
|
|
40
|
+
"$comment": "Three similar checks in one file, and the task names which one. The earlier suite had the same shape and the same reason: a check that reads as a match to the wrong line is a correct answer scored wrong.",
|
|
41
|
+
"task": "One source file in this repository has a rule that decides whether a git command rewrites history. It checks three git subcommands, and each one matches a different flag. Report the file and the line number of the check that rejects `git clean -fd`, and quote the comparison. Do not modify any files.",
|
|
42
|
+
"want": "return /(^|\\s)-[a-zA-Z]*f/.test(text)"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "read-two-files",
|
|
46
|
+
"$comment": "Needs two files: the lane table is a JSON file and the list of names is a constant in the code, so the agent has to know the same ten names live in both.",
|
|
47
|
+
"task": "The ten job lanes in this repository are named in two files: a JSON table, and a source file that holds them as a constant. Report the file and the line number of the constant, and quote the line that declares it. Do not modify any files.",
|
|
48
|
+
"want": "export const LANE_NAMES = ["
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "edit-rename",
|
|
52
|
+
"$comment": "A rename, in a file with four uses of the name. mustContainMin counts the occurrences, because a plain substring check passes a partial rename. mustNotContain is as important as mustContain: leaving the old name in one place is not the change that was asked for.",
|
|
53
|
+
"task": "In src/guard.ts the constant that marks the kit's own denial message is used in four places. Rename it to `DENIAL_MARKER` everywhere in that file, without changing what it holds. Make no other changes and do not reformat anything.",
|
|
54
|
+
"mutate": {
|
|
55
|
+
"file": "src/guard.ts",
|
|
56
|
+
"mustContainMin": {
|
|
57
|
+
"DENIAL_MARKER": 4
|
|
58
|
+
},
|
|
59
|
+
"mustNotContain": [
|
|
60
|
+
"const MARKER"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "edit-multi",
|
|
66
|
+
"$comment": "Two separate edits in one file, both of which have to land. An agent that does one and stops is the obvious failure, and the grader catches it because both strings are required.",
|
|
67
|
+
"task": "In src/denylist.js the BASH_DENYLIST holds the commands the kit never allows. Change the entry `gh pr close*` to `gh pr close *`, and change the entry `gh release*` to `gh release *`. Make no other changes and do not reformat anything.",
|
|
68
|
+
"mutate": {
|
|
69
|
+
"file": "src/denylist.js",
|
|
70
|
+
"mustContain": [
|
|
71
|
+
"gh pr close *",
|
|
72
|
+
"gh release *"
|
|
73
|
+
],
|
|
74
|
+
"mustNotContain": [
|
|
75
|
+
"gh pr close*",
|
|
76
|
+
"gh release*"
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
}
|