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/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,253 @@ is not happening.
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## [0.7.0] — 2026-10-25
|
|
16
|
+
|
|
17
|
+
`oc-harness run` now stops for the reasons it already claimed to stop for,
|
|
18
|
+
the read-only lanes no longer have a write-capable shell, and the token budget
|
|
19
|
+
counts the context a request reads instead of only its uncached part.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **A denied tool call was fed back as another turn, and the retry spent the
|
|
24
|
+
window.** `experimental.continue_loop_on_deny` was `true`, so opencode
|
|
25
|
+
handed the guard's refusal back to the model and continued the loop. The
|
|
26
|
+
measured session made 138 requests and 7,559,016 context tokens in 29
|
|
27
|
+
minutes on six `shared token window` denials, each retry re-sending the
|
|
28
|
+
whole prompt. The generated config now sets it to `false`, and the guard
|
|
29
|
+
blocks a single request that re-sends more than `OC_HARNESS_TURN_STOP`
|
|
30
|
+
(default 300k) with "do not retry".
|
|
31
|
+
- **The in-event shared-window stop did not refuse anything.** When the
|
|
32
|
+
`step-finish` sum crossed `OC_HARNESS_BUDGET_WINDOW_STOP`, the guard set
|
|
33
|
+
`windowSpent` but never set `windowWhy`, so `sharedWindowSpent()` returned
|
|
34
|
+
an empty reason and every tool call was allowed. The reason is set where
|
|
35
|
+
the sum crosses, and the stop is written to the ledger with
|
|
36
|
+
`recordStop()` so a new session cannot outlive the provider's window.
|
|
37
|
+
- **A corrupt ledger locked the machine for six hours, or vanished.** It is
|
|
38
|
+
now moved to `budget.json.corrupt` and the window starts empty; the broken
|
|
39
|
+
bytes are kept as evidence. `recordSpend()` and `recordStop()` repair
|
|
40
|
+
before writing instead of reading the corruption as an empty window.
|
|
41
|
+
- **The bundled ECC subagents were loaded on every request.** Their
|
|
42
|
+
descriptions came from `prompts/agents/*.txt`, were not lanes, and so were
|
|
43
|
+
not covered by the stale-lane rule. They are removed from the generated
|
|
44
|
+
agent map when that is the prompt, and named in the config header; a
|
|
45
|
+
hand-written agent that reuses that prompt path is removed like the
|
|
46
|
+
bundle's own, while any other prompt is left alone.
|
|
47
|
+
- **The token budget undercounted a cached session five to forty times, so it
|
|
48
|
+
never fired.** A `step-finish` part reports `input`, `cache.read`,
|
|
49
|
+
`cache.write`, `output` and `reasoning`. The guard summed `input` alone -
|
|
50
|
+
the part of the prompt that was not cached - while the cached prefix, which
|
|
51
|
+
is what a long conversation actually re-reads, was ignored. One measured
|
|
52
|
+
stream reported 844 uncached tokens against 36,480 cached ones on the same
|
|
53
|
+
step, then 391 against 9,088. The 2M warn and 5M stop were therefore five
|
|
54
|
+
to forty times the real spend, and the provider's window ran out before
|
|
55
|
+
either fired. `requestTokens()` in `src/budget.js` now counts `input + cache.read +
|
|
56
|
+
cache.write` for both the session total and the shared-window ledger; the
|
|
57
|
+
toasts and refusals say "context tokens" because that is what they count.
|
|
58
|
+
- **The A/B's `--budget` had the same undercount.** The suite cap summed
|
|
59
|
+
`inputTokens`, so 120 runs reported about 1.3M of spend while their streams
|
|
60
|
+
carried 7.1M. `parseStream` now also returns `contextTokens`, the cap and the
|
|
61
|
+
report use it, and `--rescore` re-reads the old streams to put the corrected
|
|
62
|
+
number on them. `inputTokens` is still recorded so a table can be compared
|
|
63
|
+
with the ones written before this.
|
|
64
|
+
- **The test suite wrote to the real window ledger.** `guard-hook.test.js`
|
|
65
|
+
exercised the guard with the default `OC_HARNESS_BUDGET_FILE`, so every test
|
|
66
|
+
run added its 1,500 and 600 tokens to
|
|
67
|
+
`~/.local/state/opencode-harness-kit/budget.json` - the live ledger showed
|
|
68
|
+
twelve test entries and nothing else. The tests now point the ledger at
|
|
69
|
+
their own temporary directory.
|
|
70
|
+
- **`--rescore` graded a timeout as a wrong answer.** A run that hit the
|
|
71
|
+
ceiling has a stream that stops mid-sentence, which reads exactly like an
|
|
72
|
+
answer that cited nothing; only the sidecar knows which it was. Four runs
|
|
73
|
+
that timed out at 12 steps were re-scored as four wrong answers, and the
|
|
74
|
+
table then showed 18/23 where the honest count is 18/19.
|
|
75
|
+
- **A resume crashed the report on a sidecar from an older version.** The
|
|
76
|
+
re-score reads `escaped`, `tools` and `text` from every result; a sidecar
|
|
77
|
+
written before those fields existed threw a `TypeError` after every run had
|
|
78
|
+
already been paid for. Missing fields are read as empty, and the corrected
|
|
79
|
+
token count is written back so a second resume does not depend on the stream
|
|
80
|
+
still being there.
|
|
81
|
+
- **The A/B's preflight said "ok" to a run that had failed.** It checked only
|
|
82
|
+
the timeout and the quota, so a run that reported an error event and exited
|
|
83
|
+
in three seconds passed. The first suite with that preflight spent eighteen
|
|
84
|
+
runs learning that every kit-off arm had failed with
|
|
85
|
+
`ProviderModelNotFoundError`, scored as "no output, not scored". It now
|
|
86
|
+
reads the stream, and any error event stops the suite with exit 2 and the
|
|
87
|
+
provider's own message. An exit 0 with zero steps and no text is the same
|
|
88
|
+
shape - a spent route - and is refused the same way.
|
|
89
|
+
- **The kit-off arm could not resolve the model it was told to use.** opencode
|
|
90
|
+
keeps its model catalogue in `~/.cache/opencode/models.json`, and the arm's
|
|
91
|
+
temporary `HOME` had no cache, so a bare `model:` string was a model the
|
|
92
|
+
installation had never heard of. The cache is opencode's, not the kit's, so
|
|
93
|
+
it is symlinked into the temporary home the same way `~/.local/share/opencode`
|
|
94
|
+
already was.
|
|
95
|
+
- **An error in a measured run was counted as a wrong answer.** A run that
|
|
96
|
+
produced no tool call and no text, but did report an error, now stops the
|
|
97
|
+
suite at once with that error instead of waiting for `--fail-fast` and then
|
|
98
|
+
scoring the refusal.
|
|
99
|
+
- **The lane counter's "no telemetry" test failed on a machine that had one
|
|
100
|
+
two directories up.** The lookup walks up to the project root by design, so
|
|
101
|
+
`/tmp/.space/telemetry.jsonl` - written by any `opencode run` started in
|
|
102
|
+
`/tmp` - made it find a file and fail for a reason that was not the code's.
|
|
103
|
+
The test now runs twelve levels down, past the walk-up's ceiling, so nothing
|
|
104
|
+
above its own tree is looked at; the walk-up itself is unchanged.
|
|
105
|
+
- **The A/B was governed by the kit's own budget.** The kit-on arm runs on the
|
|
106
|
+
real config, so the guard's session and window thresholds applied to it: 120
|
|
107
|
+
runs spend more than the window's stop, and from the run that crossed it the
|
|
108
|
+
guard refused tool calls - every later run would be scored as a wrong answer
|
|
109
|
+
for a reason the suite is not measuring. The suite now sets all four
|
|
110
|
+
thresholds to `0` for its runs and points `OC_HARNESS_BUDGET_FILE` at the run
|
|
111
|
+
directory, so a benchmark neither trips the guard nor charges the account's
|
|
112
|
+
real window. The suite's own cap is `--budget`.
|
|
113
|
+
- **An empty plan reported success.** A plan with no tasks printed
|
|
114
|
+
"every task is done (0/0)" and exited 0. Nothing was done, so it now stops
|
|
115
|
+
with exit 2 and says the plan has no tasks.
|
|
116
|
+
- **A blocked task did not stop the tasks after it.** The loop marked the
|
|
117
|
+
blocker and carried on, then reported it at the end. It now runs in file
|
|
118
|
+
order and stops at the first blocked task, so pending work before the
|
|
119
|
+
blocker still lands and work after it no longer builds on a failure.
|
|
120
|
+
- **The task file was written back from the copy read at the start.** A
|
|
121
|
+
human edit or an agent deletion made mid-run was overwritten, and a deleted
|
|
122
|
+
task file was recreated. Write-back re-reads the file, refuses to write a
|
|
123
|
+
stale copy, and goes through `rename` so a reader never sees half a plan.
|
|
124
|
+
- **A task was matched by title and position only.** With two same-titled
|
|
125
|
+
tasks, removing or renaming the first made the loop mark the second one
|
|
126
|
+
done. Matching now uses title + `change` + `files`, and an ambiguous match
|
|
127
|
+
stops the run instead of guessing.
|
|
128
|
+
- **`verify: true` and `verify: echo ok` were treated as checks.** Both exit
|
|
129
|
+
0 whatever the agent did. A verify line made only of no-ops now blocks the
|
|
130
|
+
task before the agent runs; a no-op before a real command
|
|
131
|
+
(`true && npm test`) is still accepted.
|
|
132
|
+
- **`doc` and `verify` opened their bash maps with `"*": "allow"`.** The
|
|
133
|
+
read-only lanes could write through `sed -i`, `awk` or `python3 -c`
|
|
134
|
+
regardless of their `edit: "deny"`. `doc` now defaults to deny and `verify`
|
|
135
|
+
defaults to ask, each with the commands its job actually needs.
|
|
136
|
+
- **A run that hit the lane's step cap reported only "verify failed".** At the
|
|
137
|
+
cap OpenCode exits 0 with a text-only answer, so the loop saw a failed
|
|
138
|
+
check and blamed the change, while the log held the real reason. `run` now
|
|
139
|
+
counts `step_finish` events in the transcript and, when the count reaches
|
|
140
|
+
the role's configured cap, says so in the stop message and in the task's
|
|
141
|
+
recorded output, with the setting to raise.
|
|
142
|
+
- **An agent that answered nothing was reported as a failed change.** A spent
|
|
143
|
+
model route shows up in one of two shapes. Some providers exit 0 with an
|
|
144
|
+
empty stdout and the reason on stderr: the loop then ran the verify against
|
|
145
|
+
an untouched tree and blocked the task for work the model never attempted.
|
|
146
|
+
Others hang until the ceiling kills them, which at the default budget is two
|
|
147
|
+
hours spent on one task with nothing to show for it.
|
|
148
|
+
`run` now stops with exit 3 when stdout is empty without running the verify,
|
|
149
|
+
and a timeout names the route and the limit instead of saying the agent
|
|
150
|
+
"failed". The task is marked blocked either way, with the stderr and the
|
|
151
|
+
advice in its output.
|
|
152
|
+
- **The A/B ground through a quota error as if it were an answer.** The free
|
|
153
|
+
tier can print `{"type":"error"}` with "Free usage limit reached" on stdout
|
|
154
|
+
and exit 0; the suite read that as an empty stream and needed `--fail-fast`
|
|
155
|
+
runs before stopping. It now stops on the first one with exit 2 and the
|
|
156
|
+
provider's own words, because the refusal measures the provider and not the
|
|
157
|
+
kit.
|
|
158
|
+
- **The kit-off arm made paid title calls.** `makeNokitHome` pinned `model`
|
|
159
|
+
but not `small_model`, so the title agent used opencode's default paid
|
|
160
|
+
model. A real run made 1,073 title calls to `gpt-5.4-nano`; every one was
|
|
161
|
+
refused for insufficient funds. Both are pinned now.
|
|
162
|
+
- **A killed A/B left the agent running.** `spawnSync` blocks the event loop,
|
|
163
|
+
so Ctrl-C killed the parent and the child was reparented to systemd - one
|
|
164
|
+
survived 2h54m against the real auth store. The capture uses `spawn`, a
|
|
165
|
+
process group, a timeout timer that kills the group, and `SIGINT`/`SIGTERM`
|
|
166
|
+
handlers that do the same.
|
|
167
|
+
- **`run` started without `opencode`, and outside a git work tree.** The
|
|
168
|
+
first was reported as a failed agent that blocked the first task; the
|
|
169
|
+
second silently switched off the `files:` scope check. Both are now
|
|
170
|
+
refused before the lock is taken, the second unless `--allow-extra-files`
|
|
171
|
+
is passed.
|
|
172
|
+
- **The scope check counted the loop's own bookkeeping as an out-of-scope
|
|
173
|
+
edit.** The plugin writes `.space/telemetry.jsonl` on every agent call, so
|
|
174
|
+
a task that touched only the files it named was still blocked for changing
|
|
175
|
+
`.space/`. Loop state is excluded now; a real edit outside `files:` is
|
|
176
|
+
still caught.
|
|
177
|
+
|
|
178
|
+
- **The A/B's wall-time row read the stream, not the ceiling.** A run killed
|
|
179
|
+
at 180s has a stream that stops at 58s, so the report printed it as a 58s
|
|
180
|
+
run and the wall column favored whichever arm was killed sooner. Each
|
|
181
|
+
sidecar now records the harness's own timer, `--rescore` prefers it, and a
|
|
182
|
+
resumed sidecar without one falls back to the stream's last event.
|
|
183
|
+
|
|
184
|
+
### Added
|
|
185
|
+
|
|
186
|
+
- **A per-session token budget, counted by the guard.** The free tier is one
|
|
187
|
+
shared window and opencode shows no number for it, so a long session spends
|
|
188
|
+
it silently - one made 599 requests and 12,025,980 input tokens, and the
|
|
189
|
+
first sign was the provider refusing the next request. The guard sums the
|
|
190
|
+
input tokens each `step-finish` part reports, warns once at
|
|
191
|
+
`OC_HARNESS_BUDGET_WARN` (default 2M) and refuses tool calls at
|
|
192
|
+
`OC_HARNESS_BUDGET_STOP` (default 5M) with the number and the fix. `0`
|
|
193
|
+
disables either. The count is per session, so a subagent is not charged to
|
|
194
|
+
the conversation that started it.
|
|
195
|
+
- **`oc-harness use --no-skills`.** opencode writes every discovered skill's
|
|
196
|
+
name and description into the system message, so a large skill collection
|
|
197
|
+
is a fixed cost on every request. The flag turns the skill tool off for the
|
|
198
|
+
lanes, which carry their instructions in `prompts/`, and the config header
|
|
199
|
+
says so. Off by default.
|
|
200
|
+
- **A shared-window ledger, so a new session does not reset the budget.** The
|
|
201
|
+
per-session count answered the wrong question: the free tier is one window
|
|
202
|
+
per account, and two sessions were measured spending one window 47 and 33
|
|
203
|
+
minutes apart. The guard now writes each session's spend to
|
|
204
|
+
`OC_HARNESS_BUDGET_FILE` (default `$XDG_STATE_HOME/opencode-harness-kit/budget.json`)
|
|
205
|
+
and sums the entries inside `OC_HARNESS_BUDGET_WINDOW_MINUTES` (default 60),
|
|
206
|
+
warning at `OC_HARNESS_BUDGET_WINDOW_WARN` (4M) and refusing tool calls at
|
|
207
|
+
`OC_HARNESS_BUDGET_WINDOW_STOP` (8M) in every session, including one opened
|
|
208
|
+
after the window ran out. The file is written atomically; an unreadable file
|
|
209
|
+
is moved to `budget.json.corrupt` and the window starts empty (if the move
|
|
210
|
+
cannot be written, the call fails closed), and entries older than six hours
|
|
211
|
+
are pruned.
|
|
212
|
+
- **`eval/ab.mjs --out <dir>`**: one stream and one result per run, and a
|
|
213
|
+
rerun with the same `--out` skips the runs that already have a result. A
|
|
214
|
+
suite stopped by the quota or by a reboot resumes instead of starting over.
|
|
215
|
+
The directory also gets `run-manifest.json` with the suite hash, target
|
|
216
|
+
fingerprint and model; a resume whose manifest differs stops with exit 2
|
|
217
|
+
instead of mixing two experiments, and a directory from before the manifest
|
|
218
|
+
existed is adopted with a warning.
|
|
219
|
+
- **`eval/ab.mjs --budget <tokens>`**: cap the suite's input tokens. Checked
|
|
220
|
+
before each run, so the run that crosses the cap is still measured; the next
|
|
221
|
+
one stops the suite with exit 2.
|
|
222
|
+
- **A preflight run in `eval/ab.mjs`** (on by default, `--no-preflight` to
|
|
223
|
+
skip): one question to the model before the suite, exit 2 if it does not
|
|
224
|
+
answer, times out, or its stream carries an error event. Answering is the
|
|
225
|
+
test: exit 0 with zero steps and no text stops the suite too.
|
|
226
|
+
- `oc-harness run --review`: after verify passes, ask the reviewer lane for
|
|
227
|
+
a schema'd verdict, `oc-harness/review@1`. A fail, prose, a malformed
|
|
228
|
+
object or a crashed reviewer stops the run with exit 5 and marks the task
|
|
229
|
+
blocked. The verdict is kept at `.space/review/<round>-<slug>.json`; the
|
|
230
|
+
contract is in `docs/REVIEW-VERDICT.md`.
|
|
231
|
+
- `.space/run.lock`: one writer per task file. A lock whose pid is gone is
|
|
232
|
+
cleared; a live one is not. `--force-unlock` breaks one explicitly.
|
|
233
|
+
- `run --allow-extra-files`: documented escape hatch for the scope check.
|
|
234
|
+
- `eval/compare.mjs`: grade any agent CLI against the same suite with the same
|
|
235
|
+
citation resolver and mutation comparison. `eval/ab.mjs` can only compare
|
|
236
|
+
the kit with opencode, because it reads opencode's event stream; this runs a
|
|
237
|
+
plain command in a fresh copy of the subject, so an Aider or SWE-agent
|
|
238
|
+
number is comparable with the kit's own. A command that is not installed is
|
|
239
|
+
reported as unusable, not as a 0% pass rate.
|
|
240
|
+
- `eval/ab.mjs --fail-fast <n>` (default 2): stop when `n` runs in a row produce
|
|
241
|
+
no tool call and no text. A spent free route hangs and times out rather than
|
|
242
|
+
failing, which looked like a benchmark that "stopped after a few minutes"
|
|
243
|
+
when it was really waiting out one dead run at a time. A timeout whose last
|
|
244
|
+
60 seconds (or a twentieth of the ceiling, whichever is smaller) are quiet
|
|
245
|
+
counts toward the streak too, because a spent route hangs before the
|
|
246
|
+
ceiling. The raw streams are kept for `--rescore`.
|
|
247
|
+
|
|
248
|
+
### Changed
|
|
249
|
+
|
|
250
|
+
- **The `build` lane's step cap is 120, not 40.** A session that reaches the
|
|
251
|
+
cap is forced into a text-only response, which in an interactive session
|
|
252
|
+
looks like the agent stopping mid-task after a few minutes and in
|
|
253
|
+
`oc-harness run` looks like a task that never finished. 40 steps is a
|
|
254
|
+
reasonable ceiling for one planned task and not for a session that is
|
|
255
|
+
building and testing a change. The other lanes keep their caps.
|
|
256
|
+
- The README and `docs/EXIT-CODES.md` describe `run` as build + verify, not
|
|
257
|
+
plan → build → review → finish. It never planned, committed or pushed.
|
|
258
|
+
- Exit code 5 is now used, and documented.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
15
262
|
## [0.6.4] — 2026-10-23
|
|
16
263
|
|
|
17
264
|
Fixed, each one found by re-reading what 0.6.3 published, not by running
|
|
@@ -1913,7 +2160,8 @@ silent in the worst way:
|
|
|
1913
2160
|
|
|
1914
2161
|
---
|
|
1915
2162
|
|
|
1916
|
-
[
|
|
2163
|
+
[0.7.0]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.6.4...v0.7.0
|
|
2164
|
+
[Unreleased]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.7.0...HEAD
|
|
1917
2165
|
[0.5.7]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.6...v0.5.7
|
|
1918
2166
|
[0.5.6]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.5...v0.5.6
|
|
1919
2167
|
[0.5.5]: https://github.com/pham-canh/opencode-harness-kit/compare/v0.5.4...v0.5.5
|
package/README.md
CHANGED
|
@@ -560,6 +560,7 @@ oc-harness auto refresh + install + use + verify, asking nothing
|
|
|
560
560
|
oc-harness auto --json the same reconcile, as a report a script can read
|
|
561
561
|
oc-harness use <id> compile a profile into ~/.config/opencode/opencode.jsonc
|
|
562
562
|
oc-harness use <id> --dryrun print a summary of what would be written, write nothing
|
|
563
|
+
oc-harness use <id> --no-skills leave the skill list out of the lanes' prompt
|
|
563
564
|
oc-harness refresh <id> re-derive a profile against the live model list
|
|
564
565
|
oc-harness install symlink the plugin, prompts, skills and GLOBAL.md into the config dir
|
|
565
566
|
(from `npx`, they link into a package copy it makes first - see Install)
|
|
@@ -578,7 +579,7 @@ oc-harness uninstall remove the plugin, prompts, skills and GLOBAL.md again
|
|
|
578
579
|
oc-harness cost what the kit is doing on this project, in numbers
|
|
579
580
|
oc-harness restore <file> put one back
|
|
580
581
|
oc-harness baseline measure the current install
|
|
581
|
-
oc-harness run
|
|
582
|
+
oc-harness run build and verify .space/tasks.md, one task at a time
|
|
582
583
|
```
|
|
583
584
|
|
|
584
585
|
The measurement scripts are in `eval/`, and each one says what it measures
|
|
@@ -590,6 +591,7 @@ node eval/measure-prune.mjs the pruner, on real sessions
|
|
|
590
591
|
node eval/measure-install.mjs before and after the install
|
|
591
592
|
node eval/models.mjs every model the profiles use, one line each
|
|
592
593
|
node eval/ab.mjs the A/B: the same task with and without the kit
|
|
594
|
+
node eval/compare.mjs the same suite against any agent CLI, same grader
|
|
593
595
|
```
|
|
594
596
|
|
|
595
597
|
Every flag accepts both spellings: `--profile fleet-safe` and
|
|
@@ -1033,6 +1035,20 @@ except the lanes it may hand work to. That is the mechanism behind "hand the
|
|
|
1033
1035
|
job to the lane that has it": a lane that is not allowed to spawn anything
|
|
1034
1036
|
cannot quietly become a general-purpose agent on the most expensive tier.
|
|
1035
1037
|
|
|
1038
|
+
Reaching the cap is not an error OpenCode reports: it exits 0 with a text-only
|
|
1039
|
+
answer. `oc-harness run` counts the turns in the transcript, so a verify that
|
|
1040
|
+
failed after the cap says "the agent used its whole 120-step allowance" instead
|
|
1041
|
+
of looking like an ordinary failure. The number quoted is the one in the
|
|
1042
|
+
installed config for the role being run.
|
|
1043
|
+
|
|
1044
|
+
A spent model route is the other way a run looks like a failure and is not
|
|
1045
|
+
one. It shows up as an empty transcript - some providers exit 0 with it, others
|
|
1046
|
+
hang until the ceiling kills them. In the first shape `run` stops with exit 3
|
|
1047
|
+
and "the agent answered nothing" rather than verifying an untouched tree; in
|
|
1048
|
+
the second it names the route and the limit it waited out. Either way the
|
|
1049
|
+
advice is `opencode run --print-logs -m <model> hi`, and a smaller `--budget`
|
|
1050
|
+
fails sooner.
|
|
1051
|
+
|
|
1036
1052
|
## The skill loop
|
|
1037
1053
|
|
|
1038
1054
|
The lanes above are the *who*. The skills are the *order*, and they load by
|
|
@@ -1054,6 +1070,25 @@ the conversation instead of the requirement.
|
|
|
1054
1070
|
Publishing is never a loop step. `space-finish` prepares the change; a human
|
|
1055
1071
|
decides it ships.
|
|
1056
1072
|
|
|
1073
|
+
`oc-harness run` automates the build-and-verify half of that loop, not the
|
|
1074
|
+
whole loop. It hands one task at a time to an agent, re-runs that task's own
|
|
1075
|
+
`verify:` command, and writes the real output back into `.space/tasks.md`. It
|
|
1076
|
+
does not plan, commit or push: those stay with `space-plan`, `space-finish`
|
|
1077
|
+
and the human. A task with no `verify:` is refused rather than assumed done,
|
|
1078
|
+
and so is one whose whole verify line is a command that cannot fail (`true`,
|
|
1079
|
+
`echo ok`). A task that edits files outside its `files:` line stops the run
|
|
1080
|
+
unless `--allow-extra-files` says otherwise. Because that check reads
|
|
1081
|
+
`git status`, `run` itself needs a work tree; outside one it refuses to start
|
|
1082
|
+
rather than run unfenced. `--force-unlock` clears a lock held by a live pid
|
|
1083
|
+
that has been reused by something else.
|
|
1084
|
+
|
|
1085
|
+
`--review` closes the review gap without pretending a sentence is a verdict:
|
|
1086
|
+
after verify passes, the `reviewer` lane is asked for one JSON object matching
|
|
1087
|
+
[`docs/REVIEW-VERDICT.md`](docs/REVIEW-VERDICT.md). A pass marks the task done.
|
|
1088
|
+
A fail, a malformed reply or a crashed reviewer marks it blocked and stops the
|
|
1089
|
+
run with exit 5. The validated verdict is written to `.space/review/`, so the
|
|
1090
|
+
decision can be audited after the fact.
|
|
1091
|
+
|
|
1057
1092
|
## What it costs, measured
|
|
1058
1093
|
|
|
1059
1094
|
The kit's own A/B - the same task with and without it, ten reps per arm, 120
|
|
@@ -1064,14 +1099,14 @@ so anyone can repeat it:
|
|
|
1064
1099
|
|---|---|---|
|
|
1065
1100
|
| **correct** | **54/60** | **55/60** |
|
|
1066
1101
|
| tool calls | 12.5 | 8 |
|
|
1067
|
-
| input
|
|
1102
|
+
| uncached input | 56,205 | 10,540 |
|
|
1068
1103
|
| final context | 39,928 | 12,718 |
|
|
1069
1104
|
| wall seconds | 43 | 21 |
|
|
1070
1105
|
| paths written outside the target | 0 | 0 |
|
|
1071
1106
|
|
|
1072
1107
|
**On a task that fits in one read, the kit is a cost.** It spends over five
|
|
1073
|
-
times the input tokens and takes twice as long, and the answers are a
|
|
1074
|
-
54 against 55, and a second run of the same 120 runs gave 55 against 54.
|
|
1108
|
+
times the uncached input tokens and takes twice as long, and the answers are a
|
|
1109
|
+
tie - 54 against 55, and a second run of the same 120 runs gave 55 against 54.
|
|
1075
1110
|
The two edit tasks are a tie at 10/10, and the guard stopped no writes in
|
|
1076
1111
|
either arm.
|
|
1077
1112
|
|
|
@@ -1113,6 +1148,97 @@ Whether it is worth it depends on whether the work is long. A repository of
|
|
|
1113
1148
|
one-file lookups should not install this. The full table is in
|
|
1114
1149
|
[eval/RESULTS.md](eval/RESULTS.md).
|
|
1115
1150
|
|
|
1151
|
+
## The token budget
|
|
1152
|
+
|
|
1153
|
+
A free tier is one shared window: every request in every session draws on it,
|
|
1154
|
+
and when it runs out the provider answers with a limit error instead of
|
|
1155
|
+
tokens. opencode never shows a number for it. One real chat session made 599
|
|
1156
|
+
requests and 12,025,980 uncached input tokens; the first sign of trouble was
|
|
1157
|
+
the next request being refused, which reads as a broken model rather than a
|
|
1158
|
+
spent budget.
|
|
1159
|
+
|
|
1160
|
+
The guard counts it. Each `step-finish` part reports what that request cost:
|
|
1161
|
+
|
|
1162
|
+
| field | what it is | counted |
|
|
1163
|
+
|---|---|---|
|
|
1164
|
+
| `input` | the part of the prompt that was not in the provider's cache | yes |
|
|
1165
|
+
| `cache.read` | the cached prefix the model still had to read | yes |
|
|
1166
|
+
| `cache.write` | the prefix stored for the next request | yes |
|
|
1167
|
+
| `output`, `reasoning` | what the model wrote | no |
|
|
1168
|
+
|
|
1169
|
+
The count is the whole context, not `input`. Counting `input` alone looks
|
|
1170
|
+
right and is not: with prompt caching, one measured stream reported 844, 1,289
|
|
1171
|
+
and 391 uncached tokens against 36,480, 7,808 and 9,088 cached ones. A budget
|
|
1172
|
+
built on the first column needed five to forty times the real spend to fire, and
|
|
1173
|
+
the window ran out first - which is exactly what happened. The guard sums the
|
|
1174
|
+
context per session, and at two thresholds it acts:
|
|
1175
|
+
|
|
1176
|
+
| | default | what happens |
|
|
1177
|
+
|---|---|---|
|
|
1178
|
+
| `OC_HARNESS_BUDGET_WARN` | 2,000,000 | one toast: this session has used N context tokens |
|
|
1179
|
+
| `OC_HARNESS_BUDGET_STOP` | 5,000,000 | tool calls are refused, with the number and the fix |
|
|
1180
|
+
| `OC_HARNESS_TURN_STOP` | 300,000 | tool calls are refused after one request re-sends this much: the loop stop |
|
|
1181
|
+
|
|
1182
|
+
```bash
|
|
1183
|
+
OC_HARNESS_BUDGET_STOP=2000000 OC_HARNESS_TURN_STOP=150000 opencode # a smaller window for a long task
|
|
1184
|
+
OC_HARNESS_BUDGET_WARN=0 OC_HARNESS_BUDGET_STOP=0 OC_HARNESS_TURN_STOP=0 opencode # off
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
The stop is a refusal with an explanation, not a crash: the model gets the
|
|
1188
|
+
message that the session hit its budget and that a new session is the fix. A
|
|
1189
|
+
subagent gets its own session and its own count, so its tokens are not
|
|
1190
|
+
charged to the conversation that started it.
|
|
1191
|
+
|
|
1192
|
+
### The window is shared, so a new session does not reset it
|
|
1193
|
+
|
|
1194
|
+
The per-session count is the wrong shape for the thing that actually runs out.
|
|
1195
|
+
The free tier is one window per account: session A spends it, and session B -
|
|
1196
|
+
opened a minute later to get a clean count - is refused on its first request.
|
|
1197
|
+
Two sessions were measured spending one window 47 and 33 minutes apart, so
|
|
1198
|
+
"open a new session" is not a fix once the window is gone.
|
|
1199
|
+
|
|
1200
|
+
The guard therefore keeps a ledger, not just a counter. Every session's spend
|
|
1201
|
+
is written to one file and the window is the sum of the entries inside it:
|
|
1202
|
+
|
|
1203
|
+
| | default | what happens |
|
|
1204
|
+
|---|---|---|
|
|
1205
|
+
| `OC_HARNESS_BUDGET_WINDOW_MINUTES` | 60 | how far back the sum reaches |
|
|
1206
|
+
| `OC_HARNESS_BUDGET_WINDOW_WARN` | 4,000,000 | one toast: the account has used N in the window |
|
|
1207
|
+
| `OC_HARNESS_BUDGET_WINDOW_STOP` | 8,000,000 | tool calls are refused in *every* session, old or new |
|
|
1208
|
+
| `OC_HARNESS_BUDGET_WINDOW_STICKY` | 1 | a crossed stop is recorded and holds until its entries age out (six hours) |
|
|
1209
|
+
|
|
1210
|
+
The ledger is `OC_HARNESS_BUDGET_FILE`, default
|
|
1211
|
+
`$XDG_STATE_HOME/opencode-harness-kit/budget.json` (`~/.local/state/...` when
|
|
1212
|
+
`XDG_STATE_HOME` is unset).
|
|
1213
|
+
|
|
1214
|
+
The file is written atomically (write, then rename); an unreadable file is
|
|
1215
|
+
moved to `budget.json.corrupt` and the window starts empty instead of failing
|
|
1216
|
+
the session or locking it for six hours. If the repair itself cannot be
|
|
1217
|
+
written - a read-only state directory, for example - the call fails closed
|
|
1218
|
+
rather than pretending the window is empty. Entries older than six hours are
|
|
1219
|
+
dropped on the next write. A crossed stop is written into the ledger as a
|
|
1220
|
+
marker, so a session opened after the one-hour sum rolls out is still refused.
|
|
1221
|
+
`OC_HARNESS_BUDGET_WINDOW_STICKY=0` turns that marker off. `0` disables either
|
|
1222
|
+
threshold. The stop is checked when the tool is called, not when the session
|
|
1223
|
+
starts, so a session opened after the window ran out is still refused - with
|
|
1224
|
+
the window's number, not the session's.
|
|
1225
|
+
|
|
1226
|
+
A denial ends the turn: the generated config sets `experimental.continue_loop_on_deny: false`. With it on, opencode fed the guard's refusal back to the model as another turn. The measured session retried six times and made 138 requests / 7,559,016 context tokens in 29 minutes, each retry re-sending the whole prompt. `OC_HARNESS_TURN_STOP` is the second half: it blocks a single request that re-sends more than 300k, while the total still looks small.
|
|
1227
|
+
|
|
1228
|
+
The other half is the fixed cost. opencode writes every discovered skill's
|
|
1229
|
+
name and description into the system message, so a machine with a large skill
|
|
1230
|
+
collection pays tens of thousands of tokens on every request, before the
|
|
1231
|
+
conversation starts. The lanes carry their instructions in `prompts/`, so:
|
|
1232
|
+
|
|
1233
|
+
```bash
|
|
1234
|
+
oc-harness use fleet-safe --no-skills
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
omits the skill list from the lanes' prompt and says so in the config header.
|
|
1238
|
+
It is off by default: removing a capability is the user's call, not the kit's.
|
|
1239
|
+
|
|
1240
|
+
It covers the ten lanes. **Subagents are a separate surface**: `--no-skills` does not change a subagent's `skill` tool. It does remove every entry in the generated agent map whose prompt path is `prompts/agents/<name>.txt` - the bundled ECC subagents - because their descriptions were loaded on every request while their prompts were read only when one was delegated to. A lane, or an agent whose prompt is any other path, is left alone; a hand-written agent that reuses the `prompts/agents/` path is removed like the bundle's own. The config header names every entry removed, and the `skill` tool on a subagent left in place is still your call, not the kit's.
|
|
1241
|
+
|
|
1116
1242
|
## Known limits
|
|
1117
1243
|
|
|
1118
1244
|
Read [docs/KNOWN-LIMITS.md](docs/KNOWN-LIMITS.md) before relying on failover.
|
|
@@ -1180,9 +1306,100 @@ wants a subject it can hand each run a private copy of:
|
|
|
1180
1306
|
```bash
|
|
1181
1307
|
node eval/prepare.mjs --repo ~/work/lablab-harness --ref 8a674f5
|
|
1182
1308
|
node eval/ab.mjs --target <printed path> --suite eval/tasks.json --reps 2 --sanitize
|
|
1183
|
-
node eval/ab.mjs --
|
|
1309
|
+
node eval/ab.mjs --target <printed path> --suite eval/tasks.json \
|
|
1310
|
+
--rescore <saved stream dir> # re-grade without spawning agents
|
|
1311
|
+
node eval/premises.mjs --target <printed path> --suite eval/tasks.json
|
|
1184
1312
|
```
|
|
1185
1313
|
|
|
1314
|
+
`premises` is the freshness check for a suite: it resolves every `file:line`
|
|
1315
|
+
citation the tasks make against the target and exits non-zero on the first one
|
|
1316
|
+
that no longer exists. Run it after a subject moves, before spending runs or
|
|
1317
|
+
trusting a rescore. Point it at this repository and `eval/tasks-self.json` to
|
|
1318
|
+
check the kit's own suite.
|
|
1319
|
+
|
|
1320
|
+
`--fail-fast <n>` (default 2) stops the suite when `n` runs in a row produce
|
|
1321
|
+
no tool call and no text, or when `n` timeouts in a row went quiet well before
|
|
1322
|
+
the per-run ceiling. That is what a spent free route looks like: the run hangs
|
|
1323
|
+
until the per-run ceiling and is recorded as a timeout, so without this a
|
|
1324
|
+
36-run suite spends hours measuring the provider's quota. A quota error the
|
|
1325
|
+
provider states outright (`Free usage limit reached`, an HTTP 429) stops the
|
|
1326
|
+
suite on the first run instead of waiting for two empty ones, and says so
|
|
1327
|
+
rather than scoring the refusal as a wrong answer. The raw streams are kept
|
|
1328
|
+
either way, so `--rescore <dir>` can still grade them later. The wall time
|
|
1329
|
+
comes from the sidecar, so a run killed at the ceiling is reported at the
|
|
1330
|
+
ceiling, not at the timestamp of its last event.
|
|
1331
|
+
|
|
1332
|
+
Both arms pin `small_model` as well as `model`. `model` only covers the main
|
|
1333
|
+
loop; the title agent runs on `small_model`, whose default is a paid model, so
|
|
1334
|
+
an unpinned kit-off arm makes paid title calls for a benchmark that is meant
|
|
1335
|
+
to be free.
|
|
1336
|
+
|
|
1337
|
+
The kit-off arm has its own `HOME` with no kit config in it, and that is
|
|
1338
|
+
enough to break it: opencode resolves `provider/model` against a catalogue it
|
|
1339
|
+
keeps in `~/.cache/opencode/models.json`, so an arm with a bare `HOME` reports
|
|
1340
|
+
`ProviderModelNotFoundError` for every model the live install knows. The
|
|
1341
|
+
catalogue is opencode's, not the kit's, so `eval/ab.mjs` symlinks the cache
|
|
1342
|
+
into the temporary home along with `~/.local/share/opencode`. If a run still
|
|
1343
|
+
fails before it starts, the error event is now fatal on the spot:
|
|
1344
|
+
|
|
1345
|
+
- `--out <dir>` keeps each run's stream as `<id>-<arm>-<i>.json` next to a
|
|
1346
|
+
`<id>-<arm>-<i>.result.json`, and a rerun with the same `--out` skips the
|
|
1347
|
+
runs that already have a result. A sidecar with no verdict and no step is
|
|
1348
|
+
retried instead of skipped: it is a run that never started, and skipping it
|
|
1349
|
+
would make the hole permanent. A suite interrupted by the quota, or by a
|
|
1350
|
+
reboot, continues instead of starting over. The directory gets a
|
|
1351
|
+
`run-manifest.json`; resuming it against a different target, suite or model
|
|
1352
|
+
is refused rather than mixed into one result.
|
|
1353
|
+
- `--budget <tokens>` caps the suite's context tokens - the same number the
|
|
1354
|
+
guard counts, cached prefix included. It is checked before each run, so the
|
|
1355
|
+
run that crosses the cap is still measured and counted; the next one stops
|
|
1356
|
+
the suite with exit 2.
|
|
1357
|
+
- A preflight run (on by default, `--no-preflight` to skip) asks the model one
|
|
1358
|
+
question before the suite and stops with exit 2 if it does not answer, if it
|
|
1359
|
+
times out, if it exits cleanly with an empty stream, or if its stream carries
|
|
1360
|
+
an error event. It costs one request and
|
|
1361
|
+
saves the eighteen that a broken arm would otherwise spend being scored as
|
|
1362
|
+
wrong answers. Its stream goes to a scratch directory, not `--out`, so
|
|
1363
|
+
`--rescore` does not warn about a file it cannot grade.
|
|
1364
|
+
|
|
1365
|
+
It earns that cost on the day the window is out. `opencode run` then retries
|
|
1366
|
+
the refused request three times and does not exit - one probe sat at zero
|
|
1367
|
+
bytes until a 50-second timeout killed it, and the error text only appears in
|
|
1368
|
+
`--print-logs`. A suite started in that state would have written 120
|
|
1369
|
+
results, every one of them a timeout, and called it a benchmark. The
|
|
1370
|
+
preflight turns it into `exit 2` and no result files. The 10-rep run that
|
|
1371
|
+
produced the numbers in `eval/RESULTS.md` hit this at 47 of 120 runs: the 47
|
|
1372
|
+
are real, the other 73 were never started, and resuming with the same
|
|
1373
|
+
`--out` skips the 47. The window is also account-wide, not per model - a second free
|
|
1374
|
+
model on the same provider answered "Free usage limit reached" too.
|
|
1375
|
+
|
|
1376
|
+
The suite also turns the kit's own budget off for its runs and points the
|
|
1377
|
+
ledger at the run directory. A hundred and twenty runs spend more than the
|
|
1378
|
+
shared window's stop, and from that run on the guard would refuse tool calls -
|
|
1379
|
+
so the runs after it would be scored as wrong answers for a reason the suite
|
|
1380
|
+
is not measuring. The suite has its own cap, and the account's real window is
|
|
1381
|
+
not charged by a benchmark. The cap counts context, not uncached input: 47
|
|
1382
|
+
runs of one 10-rep attempt reported 1,284,594 uncached input tokens while the
|
|
1383
|
+
same streams carried 7,130,164 context tokens, so a cap set against the first
|
|
1384
|
+
number let the suite walk into the provider's wall.
|
|
1385
|
+
|
|
1386
|
+
`eval/ab.mjs` knows how to read opencode's event stream, so it can only compare
|
|
1387
|
+
the kit with opencode. `eval/compare.mjs` grades any CLI the same way: it runs
|
|
1388
|
+
the command in a fresh copy of the subject, treats stdout as the answer, and
|
|
1389
|
+
uses the same citation resolver and mutation comparison. That is what makes a
|
|
1390
|
+
number for Aider or SWE-agent comparable with the kit's own.
|
|
1391
|
+
|
|
1392
|
+
```bash
|
|
1393
|
+
node eval/compare.mjs --target <prepared path> --suite eval/tasks-self.json \
|
|
1394
|
+
--reps 3 --timeout 300 \
|
|
1395
|
+
--arm 'aider=aider --yes --no-auto-commits --message "$PROMPT"' \
|
|
1396
|
+
--arm 'swe=python -m sweagent run --problem "$PROMPT"'
|
|
1397
|
+
```
|
|
1398
|
+
|
|
1399
|
+
The command runs through `sh -c` with `$PROMPT`, `$TASK_ID`, `$DIR`, `$REP` and
|
|
1400
|
+
`$ARM` in the environment. A command that is not on PATH stops the run with
|
|
1401
|
+
exit 2 instead of being reported as a 0% pass rate.
|
|
1402
|
+
|
|
1186
1403
|
`npm test` and `npm run typecheck` need nothing but the repo, so CI runs those.
|
|
1187
1404
|
The rest read `~/.local/share/opencode/opencode.db` and `~/.config/opencode`,
|
|
1188
1405
|
which only exist on a machine that has run OpenCode with the kit installed —
|