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/src/effort.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// `max` gets the nearest level that actually exists.
|
|
13
13
|
|
|
14
14
|
import { loadProfile, resolveRoles, resolveDefaultVariant, resolveLaneModel, LANE_NAMES } from "./profile.js"
|
|
15
|
+
import { unhealthyModels } from "./health.js"
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* @typedef {import("./profile.js").Profile} Profile
|
|
@@ -39,14 +40,27 @@ import { loadProfile, resolveRoles, resolveDefaultVariant, resolveLaneModel, LAN
|
|
|
39
40
|
export const MANAGED_ROLES = LANE_NAMES
|
|
40
41
|
|
|
41
42
|
/**
|
|
43
|
+
* The spent routes the effort plan honours.
|
|
44
|
+
*
|
|
45
|
+
* `opts.unhealthy` is a bare-model set a caller computed already - the one
|
|
46
|
+
* contract the tests and the CLI use. `opts.dir` is the project root, and it
|
|
47
|
+
* asks the plan to read the health file itself under the profile's OWN
|
|
48
|
+
* provider: a health file keyed `openrouter/<x>` must not read as a spent
|
|
49
|
+
* `opencode/<x>`, and a bare set has already lost the provider it came from,
|
|
50
|
+
* so the caller cannot narrow it after the fact. Passing both is the
|
|
51
|
+
* caller's narrowed set, and it wins.
|
|
52
|
+
*
|
|
42
53
|
* @param {string} profileId
|
|
43
|
-
* @param {{ unhealthy?: Set<string
|
|
54
|
+
* @param {{ unhealthy?: Set<string>, dir?: string }} [opts]
|
|
44
55
|
* @returns {EffortResult}
|
|
45
56
|
*/
|
|
46
57
|
export function planEffort(profileId, opts = {}) {
|
|
47
58
|
const p = loadProfile(profileId)
|
|
59
|
+
const unhealthy =
|
|
60
|
+
opts.unhealthy ?? (opts.dir ? unhealthyModels(opts.dir, p.provider) : undefined)
|
|
61
|
+
const scoped = unhealthy === undefined ? opts : { ...opts, unhealthy }
|
|
48
62
|
const levels = p.effort?.levels ?? []
|
|
49
|
-
const roles = resolveRoles(p,
|
|
63
|
+
const roles = resolveRoles(p, scoped)
|
|
50
64
|
const notes = []
|
|
51
65
|
|
|
52
66
|
if (levels.length === 0) {
|
|
@@ -75,7 +89,7 @@ export function planEffort(profileId, opts = {}) {
|
|
|
75
89
|
/** @type {Record<string, string>} */
|
|
76
90
|
const laneModels = {}
|
|
77
91
|
for (const role of Object.keys(p.roles ?? {})) {
|
|
78
|
-
laneModels[role] = resolveLaneModel(p, role,
|
|
92
|
+
laneModels[role] = resolveLaneModel(p, role, scoped).model
|
|
79
93
|
}
|
|
80
94
|
|
|
81
95
|
return {
|
package/src/guard.ts
CHANGED
|
@@ -10,6 +10,20 @@ import type { Plugin } from "./plugin.js"
|
|
|
10
10
|
import { homedir, tmpdir } from "node:os"
|
|
11
11
|
import { join, resolve } from "node:path"
|
|
12
12
|
import { checkBash, checkDenied, checkWrite, insideRoot } from "./rules.js"
|
|
13
|
+
import {
|
|
14
|
+
budgetLimits,
|
|
15
|
+
createBudget,
|
|
16
|
+
ledgerPath,
|
|
17
|
+
readSpend,
|
|
18
|
+
recordSpend,
|
|
19
|
+
recordStop,
|
|
20
|
+
repairSpend,
|
|
21
|
+
requestTokens,
|
|
22
|
+
stopRecorded,
|
|
23
|
+
tokens,
|
|
24
|
+
windowLimits,
|
|
25
|
+
windowSpend,
|
|
26
|
+
} from "./budget.js"
|
|
13
27
|
|
|
14
28
|
import { write } from "./telemetry-store.js"
|
|
15
29
|
import { projectRoot } from "./project.js"
|
|
@@ -53,6 +67,25 @@ function hunkFollows(lines: string[], from: number): boolean {
|
|
|
53
67
|
return false
|
|
54
68
|
}
|
|
55
69
|
|
|
70
|
+
/**
|
|
71
|
+
* How many lines a standard unified-diff hunk header announces.
|
|
72
|
+
*
|
|
73
|
+
* The body of a hunk is where a `---`/`+++` pair can appear as content, so
|
|
74
|
+
* the body has to be stepped over rather than read. `@@ -1 +1 @@` has one
|
|
75
|
+
* old and one new line; `@@ -1,0 +1,2 @@` has two; `@@ -1,2 +1,3 @@` has
|
|
76
|
+
* five; `@@` with no counts (opencode's own marker) announces none.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} header
|
|
79
|
+
* @returns {number}
|
|
80
|
+
*/
|
|
81
|
+
function hunkLineCount(header: string): number {
|
|
82
|
+
const m = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(header)
|
|
83
|
+
if (!m) return 0
|
|
84
|
+
const oldCount = m[2] === undefined ? 1 : Number(m[2])
|
|
85
|
+
const newCount = m[4] === undefined ? 1 : Number(m[4])
|
|
86
|
+
return oldCount + newCount
|
|
87
|
+
}
|
|
88
|
+
|
|
56
89
|
/**
|
|
57
90
|
* The files a patch touches.
|
|
58
91
|
*
|
|
@@ -61,6 +94,11 @@ function hunkFollows(lines: string[], from: number): boolean {
|
|
|
61
94
|
* markers, and a plain unified diff with `--- a/path` and `+++ b/path`
|
|
62
95
|
* headers.
|
|
63
96
|
*
|
|
97
|
+
* An update may also carry `*** Move to: path`, which is the path the file
|
|
98
|
+
* is written TO. Reading only the `*** Update File:` line checked the file
|
|
99
|
+
* the patch read from and left the destination unchecked, so a patch could
|
|
100
|
+
* move a file out of the project while every path it named was inside it.
|
|
101
|
+
*
|
|
64
102
|
* A unified diff names the file TWICE: `--- a/path` is where the change
|
|
65
103
|
* comes FROM, `+++ b/path` is where it goes TO. A deletion is `--- a/path`
|
|
66
104
|
* and `+++ /dev/null`, so the path that has to be scope-checked is the one
|
|
@@ -92,15 +130,48 @@ export function pathsInPatch(text: string): string[] {
|
|
|
92
130
|
// Whether a hunk header has been seen. After one, a `+++` line is added
|
|
93
131
|
// content and not the header of another file.
|
|
94
132
|
let seenHunk = false
|
|
133
|
+
// How many body lines of the current hunk are still to be stepped over.
|
|
134
|
+
// Reading a hunk body as headers is what turned a removed line whose
|
|
135
|
+
// content is "-- foo" into a path.
|
|
136
|
+
let hunkRemaining = 0
|
|
137
|
+
// opencode's own patch format names files with `*** ... File:` markers,
|
|
138
|
+
// and everything between them is content. A body line in that format is
|
|
139
|
+
// not a diff header, and the parser must not read one as a path.
|
|
140
|
+
let applyPatch = false
|
|
95
141
|
for (let i = 0; i < lines.length; i++) {
|
|
96
142
|
const line = lines[i]
|
|
97
143
|
const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line)
|
|
98
144
|
if (marker) {
|
|
145
|
+
applyPatch = true
|
|
99
146
|
push(marker[1])
|
|
100
147
|
continue
|
|
101
148
|
}
|
|
149
|
+
// The destination of an update that moves the file. It is a write just
|
|
150
|
+
// like the `*** Update File:` path, and the path it moves to is the one
|
|
151
|
+
// that can leave the project.
|
|
152
|
+
const move = /^\*\*\*\s+Move to:\s*(.+)$/.exec(line)
|
|
153
|
+
if (move) {
|
|
154
|
+
push(move[1])
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (applyPatch) continue
|
|
158
|
+
// Step over the body of a hunk before looking for headers in it. A
|
|
159
|
+
// hunk body line starts with a space, `-` or `+`; `\ No newline...` is
|
|
160
|
+
// a note and counts for nothing.
|
|
161
|
+
if (hunkRemaining > 0) {
|
|
162
|
+
const first = line[0]
|
|
163
|
+
if (first === " " || first === "-" || first === "+") {
|
|
164
|
+
hunkRemaining--
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
if (first === "\\") continue
|
|
168
|
+
// A line that cannot be a hunk body means the counts were wrong. Fall
|
|
169
|
+
// through and read this line normally rather than swallow a header.
|
|
170
|
+
hunkRemaining = 0
|
|
171
|
+
}
|
|
102
172
|
if (/^@@/.test(line)) {
|
|
103
173
|
seenHunk = true
|
|
174
|
+
hunkRemaining = hunkLineCount(line)
|
|
104
175
|
continue
|
|
105
176
|
}
|
|
106
177
|
// The `---` and `+++` headers of a unified diff are one pair: the
|
|
@@ -167,7 +238,7 @@ function truncate(s: string, n = 200): string {
|
|
|
167
238
|
return one.length > n ? `${one.slice(0, n)}...` : one
|
|
168
239
|
}
|
|
169
240
|
|
|
170
|
-
export const GuardPlugin: Plugin = async ({ worktree, directory }) => {
|
|
241
|
+
export const GuardPlugin: Plugin = async ({ worktree, directory, client }) => {
|
|
171
242
|
// The project root, not the worktree: the worktree is the filesystem root
|
|
172
243
|
// in a project that is not a git repository, so the scope check was against
|
|
173
244
|
// "/" - which contains every path - and the denial log went nowhere.
|
|
@@ -188,9 +259,241 @@ export const GuardPlugin: Plugin = async ({ worktree, directory }) => {
|
|
|
188
259
|
deny(why, detail)
|
|
189
260
|
}
|
|
190
261
|
|
|
262
|
+
// The free tier is a shared window and opencode never shows a number for
|
|
263
|
+
// it. One session made 599 requests and 12,025,980 uncached input tokens;
|
|
264
|
+
// the first sign of trouble was the provider refusing the next one, which
|
|
265
|
+
// reads as a broken model rather than a spent budget. This counts the window
|
|
266
|
+
// and says so while there is still room to act.
|
|
267
|
+
//
|
|
268
|
+
// Per session, because a subagent gets its own session and its own prompt:
|
|
269
|
+
// charging its tokens to the parent would warn the wrong conversation.
|
|
270
|
+
const limits = budgetLimits()
|
|
271
|
+
const budgets = new Map<string, ReturnType<typeof createBudget>>()
|
|
272
|
+
/** `step-finish` parts already counted, so an update is not a second step. */
|
|
273
|
+
const counted = new Set<string>()
|
|
274
|
+
// A single request that re-sent more than `OC_HARNESS_TURN_STOP`. This is
|
|
275
|
+
// the count that sees a deny/retry loop: each retry is a new request whose
|
|
276
|
+
// per-session total still looks small, but whose context re-send is the
|
|
277
|
+
// whole prompt. Keyed by session, valued by the size that tripped it.
|
|
278
|
+
const turnStops = new Map<string, number>()
|
|
279
|
+
|
|
280
|
+
// The window is shared, so the count cannot live in this process: two
|
|
281
|
+
// sessions open at once are two processes, and the one that has to warn is
|
|
282
|
+
// often not the one that spent. Every step is appended to a ledger file and
|
|
283
|
+
// the last hour is summed from it.
|
|
284
|
+
const window = windowLimits()
|
|
285
|
+
const ledger = ledgerPath()
|
|
286
|
+
let windowWarned = false
|
|
287
|
+
let windowSpent = false
|
|
288
|
+
/** Why the window is spent, so the refusal states the real reason. */
|
|
289
|
+
let windowWhy = ""
|
|
290
|
+
|
|
291
|
+
/** The sum crossing its stop. Also the reason a recorded stop keeps holding. */
|
|
292
|
+
const sumWhy = () =>
|
|
293
|
+
`all sessions have used ${tokens(window.stopAt)} context tokens in the last ` +
|
|
294
|
+
`${Math.round(window.windowMs / 60_000)} min. Wait for it to roll, or raise OC_HARNESS_BUDGET_WINDOW_STOP.`
|
|
295
|
+
|
|
296
|
+
const budgetFor = (sessionID: string) => {
|
|
297
|
+
let b = budgets.get(sessionID)
|
|
298
|
+
if (!b) {
|
|
299
|
+
b = createBudget(limits)
|
|
300
|
+
budgets.set(sessionID, b)
|
|
301
|
+
}
|
|
302
|
+
return b
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Why the shared window is spent, or null if it is not.
|
|
307
|
+
*
|
|
308
|
+
* Checked against the ledger, not only the cached flag: a session that opens
|
|
309
|
+
* after the window ran out has seen no step of its own, so the flag would
|
|
310
|
+
* still be false and its first tool call would go through.
|
|
311
|
+
*
|
|
312
|
+
* Three ways to be spent, and all three fail closed:
|
|
313
|
+
*
|
|
314
|
+
* - a stop was recorded and its marker has not aged out. The sum is over
|
|
315
|
+
* the last hour; the provider's window is not, so a lock that ends when
|
|
316
|
+
* the sum rolls over was only partly on.
|
|
317
|
+
* - the sum crossed the stop.
|
|
318
|
+
* - the ledger exists and cannot be read. That is not a window with room
|
|
319
|
+
* in it - it is an unknown count, and the old code read it as zero.
|
|
320
|
+
*/
|
|
321
|
+
const sharedWindowSpent = (): string | null => {
|
|
322
|
+
if (windowSpent) return windowWhy
|
|
323
|
+
if (window.stopAt <= 0) return null
|
|
324
|
+
const entries = readSpend(ledger)
|
|
325
|
+
if (entries === null) {
|
|
326
|
+
// A corrupt ledger used to fail closed, and the only way out was a
|
|
327
|
+
// hand-delete the user had never heard of. Repair it instead and say so
|
|
328
|
+
// in telemetry; only a repair that cannot be written still fails closed.
|
|
329
|
+
if (repairSpend(ledger)) {
|
|
330
|
+
write(telemetryDir, { kind: "budget repair", tool: "ledger", rule: "corrupt ledger moved to .corrupt" })
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
windowSpent = true
|
|
334
|
+
windowWhy =
|
|
335
|
+
`the ledger at ${ledger} exists but cannot be read or repaired, so the spend cannot be counted. ` +
|
|
336
|
+
`It is treated as spent rather than as zero: delete that file to start the window over, ` +
|
|
337
|
+
`or raise OC_HARNESS_BUDGET_WINDOW_STOP.`
|
|
338
|
+
return windowWhy
|
|
339
|
+
}
|
|
340
|
+
const now = Date.now()
|
|
341
|
+
if (window.sticky && stopRecorded(entries, now)) {
|
|
342
|
+
windowSpent = true
|
|
343
|
+
windowWhy =
|
|
344
|
+
`a stop was recorded in this ledger and holds until its entries age out (six hours). ` +
|
|
345
|
+
`The provider's window does not reopen on the ${Math.round(window.windowMs / 60_000)}-min timer, ` +
|
|
346
|
+
`so this one does not either. Raise OC_HARNESS_BUDGET_WINDOW_STOP, or set ` +
|
|
347
|
+
`OC_HARNESS_BUDGET_WINDOW_STICKY=0 to let the sum alone decide.`
|
|
348
|
+
return windowWhy
|
|
349
|
+
}
|
|
350
|
+
if (windowSpend(entries, now, window.windowMs) >= window.stopAt) {
|
|
351
|
+
windowSpent = true
|
|
352
|
+
windowWhy = sumWhy()
|
|
353
|
+
// Persist the lock. The sum rolls out of the one-hour window while the
|
|
354
|
+
// provider's window stays shut, and a session that opens after the roll
|
|
355
|
+
// must not get a fresh turn out of the same spent budget.
|
|
356
|
+
try {
|
|
357
|
+
recordStop(ledger)
|
|
358
|
+
} catch {
|
|
359
|
+
// An unwritable ledger still blocks this process through windowSpent.
|
|
360
|
+
}
|
|
361
|
+
return windowWhy
|
|
362
|
+
}
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** A toast is a courtesy, never a reason to fail a turn. */
|
|
367
|
+
const toast = async (message: string, variant: "info" | "warning" | "error") => {
|
|
368
|
+
try {
|
|
369
|
+
await client?.tui?.showToast({ body: { message, variant, duration: 12_000 } })
|
|
370
|
+
} catch {
|
|
371
|
+
// A client that cannot show a toast still gets the block below.
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
191
375
|
return {
|
|
376
|
+
// The tokens are reported on the `step-finish` part, once per request.
|
|
377
|
+
// `message.updated` was the other candidate, but its token count grows
|
|
378
|
+
// while the request streams and would have to be diffed per message; the
|
|
379
|
+
// step part is already the final cost of that request.
|
|
380
|
+
//
|
|
381
|
+
// The cost is the whole context, not `tokens.input`: with prompt caching
|
|
382
|
+
// the uncached part is a few hundred tokens against tens of thousands of
|
|
383
|
+
// `cache.read`, so counting `input` alone made every threshold here about
|
|
384
|
+
// five to forty times too high. See `requestTokens` in ./budget.js.
|
|
385
|
+
event: async ({ event }: any) => {
|
|
386
|
+
if (event?.type !== "message.part.updated") return
|
|
387
|
+
const part = event.properties?.part
|
|
388
|
+
if (!part || part.type !== "step-finish" || !part.sessionID) return
|
|
389
|
+
if (part.id) {
|
|
390
|
+
if (counted.has(part.id)) return
|
|
391
|
+
counted.add(part.id)
|
|
392
|
+
}
|
|
393
|
+
const b = budgetFor(part.sessionID)
|
|
394
|
+
const spent = requestTokens(part.tokens)
|
|
395
|
+
const crossed = b.add(spent)
|
|
396
|
+
|
|
397
|
+
// The shared window, from the ledger every session writes to.
|
|
398
|
+
let recent = 0
|
|
399
|
+
if (spent > 0) {
|
|
400
|
+
try {
|
|
401
|
+
recent = windowSpend(recordSpend(ledger, spent), Date.now(), window.windowMs)
|
|
402
|
+
} catch {
|
|
403
|
+
// An unwritable state directory must not break the session. The
|
|
404
|
+
// per-session budget above still applies.
|
|
405
|
+
}
|
|
406
|
+
if (!windowWarned && window.warnAt > 0 && recent >= window.warnAt) {
|
|
407
|
+
windowWarned = true
|
|
408
|
+
await toast(
|
|
409
|
+
`All sessions have used ${tokens(recent)} context tokens in the last ${Math.round(window.windowMs / 60_000)} min ` +
|
|
410
|
+
`(warn at ${tokens(window.warnAt)}). The provider's window is shared, so a new session does not reset it.`,
|
|
411
|
+
"warning",
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
if (!windowSpent && window.stopAt > 0 && recent >= window.stopAt) {
|
|
415
|
+
windowSpent = true
|
|
416
|
+
// Without this, `sharedWindowSpent()` returned the empty windowWhy
|
|
417
|
+
// and every tool call was allowed: the toast said "blocked" while
|
|
418
|
+
// the guard passed. The reason and the marker have to be set here,
|
|
419
|
+
// where the sum crossed.
|
|
420
|
+
windowWhy = sumWhy()
|
|
421
|
+
try {
|
|
422
|
+
recordStop(ledger)
|
|
423
|
+
} catch {
|
|
424
|
+
// The in-process flag still blocks this session.
|
|
425
|
+
}
|
|
426
|
+
await toast(
|
|
427
|
+
`The shared window hit ${tokens(window.stopAt)} context tokens in ${Math.round(window.windowMs / 60_000)} min: ` +
|
|
428
|
+
`tool calls are now blocked in every session. Wait for it to roll, or raise OC_HARNESS_BUDGET_WINDOW_STOP.`,
|
|
429
|
+
"error",
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The turn stop, before the total: a loop is one request re-sending the
|
|
435
|
+
// whole prompt, and the total may still be far below its stop. Block the
|
|
436
|
+
// next tool call in this session and say not to retry - a retry is what
|
|
437
|
+
// spent the window.
|
|
438
|
+
if (
|
|
439
|
+
limits.turnStopAt > 0 &&
|
|
440
|
+
spent >= limits.turnStopAt &&
|
|
441
|
+
!turnStops.has(part.sessionID)
|
|
442
|
+
) {
|
|
443
|
+
turnStops.set(part.sessionID, spent)
|
|
444
|
+
await toast(
|
|
445
|
+
`One request in this session re-sent ${tokens(spent)} context tokens ` +
|
|
446
|
+
`(turn stop ${tokens(limits.turnStopAt)}). Tool calls in this session are now blocked: ` +
|
|
447
|
+
`stop, do not retry the same step. Start a new session with a smaller task.`,
|
|
448
|
+
"error",
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (crossed.warn) {
|
|
453
|
+
await toast(
|
|
454
|
+
`This session has used ${tokens(b.total)} context tokens (warn at ${tokens(limits.warnAt)}). ` +
|
|
455
|
+
`Start a new session before the provider's window runs out.`,
|
|
456
|
+
"warning",
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
if (crossed.stop) {
|
|
460
|
+
await toast(
|
|
461
|
+
`This session hit its ${tokens(limits.stopAt)}-token budget: tool calls are now blocked. ` +
|
|
462
|
+
`Start a new session, or raise OC_HARNESS_BUDGET_STOP.`,
|
|
463
|
+
"error",
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
},
|
|
192
467
|
"tool.execute.before": async (input: any, output: any) => {
|
|
193
468
|
try {
|
|
469
|
+
const budget = input.sessionID ? budgets.get(input.sessionID) : undefined
|
|
470
|
+
if (budget?.stopped) {
|
|
471
|
+
refuse(
|
|
472
|
+
input.tool,
|
|
473
|
+
"session token budget",
|
|
474
|
+
"the session hit its token budget",
|
|
475
|
+
`it has used ${tokens(budget.total)} context tokens, and the stop threshold is ${tokens(limits.stopAt)}. ` +
|
|
476
|
+
`Start a new session, or raise OC_HARNESS_BUDGET_STOP.`,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
// A new session does not reset the provider's window, so the shared
|
|
480
|
+
// stop has to hold across sessions - otherwise the fix for a spent
|
|
481
|
+
// session is "open another one", which spends the same window again.
|
|
482
|
+
const spentReason = sharedWindowSpent()
|
|
483
|
+
if (spentReason) {
|
|
484
|
+
refuse(input.tool, "shared token window", "the shared token window is spent", spentReason)
|
|
485
|
+
}
|
|
486
|
+
const turnSpend = input.sessionID ? turnStops.get(input.sessionID) : undefined
|
|
487
|
+
if (turnSpend !== undefined) {
|
|
488
|
+
refuse(
|
|
489
|
+
input.tool,
|
|
490
|
+
"single request budget",
|
|
491
|
+
"a single request in this session re-sent too much context",
|
|
492
|
+
`it re-sent ${tokens(turnSpend)} tokens against a turn stop of ${tokens(limits.turnStopAt)}. ` +
|
|
493
|
+
`Stop: do not retry the same step, because every retry re-sends the same prompt. ` +
|
|
494
|
+
`Start a new session with a smaller task, or raise OC_HARNESS_TURN_STOP.`,
|
|
495
|
+
)
|
|
496
|
+
}
|
|
194
497
|
if (input.tool === "bash") {
|
|
195
498
|
// The bash tool takes a `workdir`, and it becomes the command's
|
|
196
499
|
// working directory. A command run from outside the project is
|
package/src/health.js
CHANGED
|
@@ -236,16 +236,28 @@ export function unhealthyRoutes(dir) {
|
|
|
236
236
|
* lane chains list bare model ids, so the prefix is dropped here. Comparing
|
|
237
237
|
* the two directly would never match, and a spent route would look healthy.
|
|
238
238
|
*
|
|
239
|
+
* The drop collides when two providers host the same model id: a recorded
|
|
240
|
+
* `openrouter/<x>` then reads as a spent `opencode/<x>`, and every lane whose
|
|
241
|
+
* chain names the opencode one is pushed off a route that is fine. `provider`
|
|
242
|
+
* narrows the answer to the routes a session of THAT provider can actually
|
|
243
|
+
* hit - a provider-qualified key for another provider is dropped, and a key
|
|
244
|
+
* with no provider at all is kept, because a bare key is a standing statement
|
|
245
|
+
* about the model id and it is not readable as any narrower one. Callers
|
|
246
|
+
* including the router, whose lane set may not share one provider, keep the
|
|
247
|
+
* unfiltered call and its old behaviour.
|
|
248
|
+
*
|
|
239
249
|
* A route whose window has passed is not spent any more, and is not returned
|
|
240
250
|
* here: the provider's quota comes back at 00:00 UTC, and a lane that stays
|
|
241
251
|
* on its fallback forever is a lane that never comes back either.
|
|
242
252
|
*
|
|
243
253
|
* @param {string} dir the project root
|
|
254
|
+
* @param {string} [provider] only count routes served by this provider
|
|
244
255
|
* @returns {Set<string>}
|
|
245
256
|
*/
|
|
246
|
-
export function unhealthyModels(dir) {
|
|
257
|
+
export function unhealthyModels(dir, provider) {
|
|
247
258
|
const out = new Set()
|
|
248
259
|
for (const key of Object.keys(unhealthyRoutes(dir))) {
|
|
260
|
+
if (provider && key.includes("/") && !key.startsWith(`${provider}/`)) continue
|
|
249
261
|
out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key)
|
|
250
262
|
}
|
|
251
263
|
return out
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,6 @@ import { TelemetryPlugin } from "./telemetry.js"
|
|
|
20
20
|
import { applyEffort, planEffort } from "./effort.js"
|
|
21
21
|
import { profileIdFromConfig } from "./profile.js"
|
|
22
22
|
import { projectRoot } from "./project.js"
|
|
23
|
-
import { unhealthyModels } from "./health.js"
|
|
24
23
|
|
|
25
24
|
export interface HarnessOptions {
|
|
26
25
|
/** Profile id. Defaults to the one recorded in the config by `oc-harness use`. */
|
|
@@ -69,9 +68,9 @@ export const HarnessPlugin: Plugin = async (ctx, options?: HarnessOptions) => {
|
|
|
69
68
|
// file the router writes is somewhere else entirely.
|
|
70
69
|
const project = projectRoot(ctx.directory, ctx.worktree)
|
|
71
70
|
try {
|
|
72
|
-
// The same set of spent routes the router used
|
|
73
|
-
//
|
|
74
|
-
//
|
|
71
|
+
// The same set of spent routes the router used, read HERE under the
|
|
72
|
+
// profile's own provider: a health file keyed `openrouter/<x>` must not
|
|
73
|
+
// read as a spent `opencode/<x>` for a profile served by opencode.
|
|
75
74
|
//
|
|
76
75
|
// The session model is read before the effort plan is applied, because
|
|
77
76
|
// the router may have moved it onto a fallback while the base route was
|
|
@@ -80,16 +79,28 @@ export const HarnessPlugin: Plugin = async (ctx, options?: HarnessOptions) => {
|
|
|
80
79
|
typeof config?.harness?.sessionModel === "string"
|
|
81
80
|
? config.harness.sessionModel
|
|
82
81
|
: undefined
|
|
83
|
-
applyEffort(config, planEffort(id, {
|
|
82
|
+
applyEffort(config, planEffort(id, { dir: project }), {
|
|
84
83
|
sessionModel,
|
|
85
84
|
})
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
} catch {
|
|
86
|
+
// Never block startup on a bad profile.
|
|
87
|
+
}
|
|
88
|
+
// Record it where the prune hook looks, so pruning picks up this
|
|
89
|
+
// profile's thresholds without access to the resolved config.
|
|
90
|
+
//
|
|
91
|
+
// Under the PROJECT root, not the worktree: the worktree is the
|
|
92
|
+
// filesystem root in a project that is not a git repository, and the
|
|
93
|
+
// write fails there with a permission error that this catch swallows.
|
|
94
|
+
// The pruner then finds no active profile and does nothing at all.
|
|
95
|
+
//
|
|
96
|
+
// This used to sit inside the same try as applyEffort, and an applyEffort
|
|
97
|
+
// throw skipped it: a config that could not accept the effort plan (an
|
|
98
|
+
// agent dictionary OpenCode froze, a model line it rejects) left the
|
|
99
|
+
// marker stale, and the pruner ran on the PREVIOUS profile's thresholds
|
|
100
|
+
// until the next clean startup. The marker is therefore written
|
|
101
|
+
// independently - it is a statement about which profile the config names,
|
|
102
|
+
// not a consequence of whether the effort plan applied.
|
|
103
|
+
try {
|
|
93
104
|
const dir = join(project, ".space")
|
|
94
105
|
mkdirSync(dir, { recursive: true })
|
|
95
106
|
writeFileSync(join(dir, "active"), `${id}\n`)
|
package/src/profile.js
CHANGED
|
@@ -57,7 +57,7 @@ export const ROOT = findRoot(resolve(HERE, ".."))
|
|
|
57
57
|
* @typedef {"zero-retention" | "retained" | "may-train" | "unknown"} Retention
|
|
58
58
|
* @typedef {string | null} EffortRequest
|
|
59
59
|
* @typedef {{ model: string, levels?: string[], chain?: string[] }} RoleModel
|
|
60
|
-
* @typedef {{ description: string, mode?: string, steps?: number, permission?: Record<string, any>, prompt: string }} Lane
|
|
60
|
+
* @typedef {{ description: string, mode?: string, steps?: number, permission?: Record<string, any>, prompt: string, skills?: boolean }} Lane
|
|
61
61
|
* @typedef {object} Profile
|
|
62
62
|
* @property {string} id
|
|
63
63
|
* @property {string} [title]
|
package/src/prune.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { loadProfile, pruneThreshold, ROOT } from "./profile.js"
|
|
|
21
21
|
import { projectRoot } from "./project.js"
|
|
22
22
|
import { readFileSync, existsSync } from "node:fs"
|
|
23
23
|
import { join } from "node:path"
|
|
24
|
+
import { rotate as rotateFile, MAX_BYTES } from "./telemetry-store.js"
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Which profile is active. In order: an explicit environment variable, then the
|
|
@@ -486,13 +487,26 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
|
|
|
486
487
|
}
|
|
487
488
|
}
|
|
488
489
|
|
|
489
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Append one line to the project's prune log, and rotate it when the file
|
|
492
|
+
* outgrew `maxBytes`.
|
|
493
|
+
*
|
|
494
|
+
* Exported so the rotation can be driven directly in the tests: reaching it
|
|
495
|
+
* through the hook needs a 4MB fixture, which is not a fixture.
|
|
496
|
+
*
|
|
497
|
+
* @param {string} worktree the project the log belongs to
|
|
498
|
+
* @param {string} profileId the profile the prune ran under
|
|
499
|
+
* @param {StubStats & { saved: number, threshold: number }} stats
|
|
500
|
+
* @param {number} [maxBytes] rotate above this; the telemetry default
|
|
501
|
+
*/
|
|
502
|
+
export async function logPrune(
|
|
490
503
|
worktree: string,
|
|
491
504
|
profileId: string,
|
|
492
505
|
stats: StubStats & {
|
|
493
506
|
saved: number
|
|
494
507
|
threshold: number
|
|
495
508
|
},
|
|
509
|
+
maxBytes: number = MAX_BYTES,
|
|
496
510
|
) {
|
|
497
511
|
// The WORKTREE, not `process.cwd()`. Every other writer in this kit uses
|
|
498
512
|
// the worktree, and the plugin's worktree is the project. The process cwd
|
|
@@ -504,6 +518,12 @@ async function logPrune(
|
|
|
504
518
|
try {
|
|
505
519
|
const { mkdir, appendFile } = await import("node:fs/promises")
|
|
506
520
|
await mkdir(dir, { recursive: true })
|
|
521
|
+
// Rotated on the same scheme as the telemetry log: the file records one
|
|
522
|
+
// line per prune run, nothing samples its SIZE before appending, and an
|
|
523
|
+
// active project filled it to 1.5 megabytes in a day. The telemetry store
|
|
524
|
+
// already owns the three-generation rotation; the prune log borrows it
|
|
525
|
+
// rather than naming its own file schedule.
|
|
526
|
+
rotateFile(join(dir, "prune.jsonl"), maxBytes)
|
|
507
527
|
await appendFile(
|
|
508
528
|
join(dir, "prune.jsonl"),
|
|
509
529
|
JSON.stringify({
|