@quadiusai/dsh-greenharness 0.0.0-stage → 0.1.1
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/GETTING-STARTED.md +90 -0
- package/README.md +149 -2
- package/bench/BREV.md +127 -0
- package/bench/lib/patch.mjs +60 -0
- package/bench/mock-openai-server.mjs +210 -0
- package/bench/presets/mock-chat-template.json +40 -0
- package/bench/presets/mock.json +32 -0
- package/bench/presets/nemo-brev.json +40 -0
- package/bench/run-case.mjs +242 -0
- package/bench/run-mock-suite.mjs +85 -0
- package/bench/scenarios/loop-stuck.json +11 -0
- package/bench/scenarios/subtract-fix.json +12 -0
- package/bench/tasks/subtract-bug/TASK.md +2 -0
- package/bench/tasks/subtract-bug/package.json +9 -0
- package/bench/tasks/subtract-bug/src/math.mjs +8 -0
- package/bench/tasks/subtract-bug/test/math.test.mjs +11 -0
- package/bench/tasks/subtract-bug/test/noise.test.mjs +9 -0
- package/cordis.patch.yml +4 -0
- package/package.json +42 -4
- package/src/config.mjs +100 -0
- package/src/control.mjs +261 -0
- package/src/core/ansi.mjs +15 -0
- package/src/core/cascade.mjs +81 -0
- package/src/core/distill.mjs +128 -0
- package/src/core/ledger.mjs +21 -0
- package/src/core/loop-guard.mjs +52 -0
- package/src/core/patterns.mjs +26 -0
- package/src/core/pricing.mjs +23 -0
- package/src/core/read-index.mjs +74 -0
- package/src/core/route.mjs +41 -0
- package/src/core/verify.mjs +89 -0
- package/src/index.mjs +233 -0
- package/src/runtime.mjs +297 -0
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { usageCostUsd } from './core/pricing.mjs'
|
|
3
|
+
import { distillOutput } from './core/distill.mjs'
|
|
4
|
+
import { createCascade, escalate, recordStep, recordVerification } from './core/cascade.mjs'
|
|
5
|
+
import { createLoopGuard, noteWorkspaceChange, recordCommandOutcome, streakOf } from './core/loop-guard.mjs'
|
|
6
|
+
import { clearSession, createReadIndex, findUnchangedRead, forgetPath, rememberRead } from './core/read-index.mjs'
|
|
7
|
+
import {
|
|
8
|
+
classifyOutcome, compileVerifierPatterns, isVerifierCommand, normalizeCommand, parseExitMarker,
|
|
9
|
+
} from './core/verify.mjs'
|
|
10
|
+
|
|
11
|
+
/** Session events after which earlier tool results may have left the model context. */
|
|
12
|
+
const CONTEXT_REMOVAL_EVENTS = new Set(['compaction/start', 'compaction/end', 'compaction/prune', 'compaction/summary'])
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One tool call as GreenHarness sees it. The Cordis adapter builds this from the DSH execution
|
|
16
|
+
* and result; tests build it directly.
|
|
17
|
+
* @typedef {object} ToolCall
|
|
18
|
+
* @property {string} sessionId - owning session id.
|
|
19
|
+
* @property {string} name - tool name (`bash`, `read`, `edit`, `write`, ...).
|
|
20
|
+
* @property {Record<string, any>} args - the arguments the model sent.
|
|
21
|
+
* @property {string} [callId] - the tool call id.
|
|
22
|
+
* @property {boolean} [isError] - whether the tool reported an error.
|
|
23
|
+
* @property {number} [exitCode] - structured exit status for bash, when DSH reports it.
|
|
24
|
+
* @property {boolean} [timedOut] - whether bash timed out.
|
|
25
|
+
* @property {string} [text] - the rendered result text the model reads.
|
|
26
|
+
* @property {(text: string) => Promise<string | undefined>} [spill] - stores full output and returns a retrieval hint.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the GreenHarness runtime: routing, output distillation, read dedupe, loop breaking and
|
|
31
|
+
* the cost ledger. The Cordis adapter asks it for the route of each model call, feeds it tool
|
|
32
|
+
* calls, and passes it session events.
|
|
33
|
+
*
|
|
34
|
+
* @param {ReturnType<import('./config.mjs').normalizeSettings>} settings - complete settings.
|
|
35
|
+
* @param {{ ledger: { write(record: object): void }, logger?: { warn(message: string): void } }} deps - sinks.
|
|
36
|
+
*/
|
|
37
|
+
export function createGreenHarness(settings, { ledger, logger } = {}) {
|
|
38
|
+
const verifierRegexes = compileVerifierPatterns(settings.verifier.patterns)
|
|
39
|
+
const sessions = new Map()
|
|
40
|
+
const reads = createReadIndex()
|
|
41
|
+
// Tallies for the /greenharness status command: every record written, by type (and action for tools).
|
|
42
|
+
const tally = {}
|
|
43
|
+
const log = {
|
|
44
|
+
write(record) {
|
|
45
|
+
const key = record.type === 'tool' ? `tool:${record.action}` : record.type
|
|
46
|
+
tally[key] = (tally[key] ?? 0) + 1
|
|
47
|
+
if (record.type === 'tool' && record.rawChars > record.outChars) {
|
|
48
|
+
tally.charsSaved = (tally.charsSaved ?? 0) + (record.rawChars - record.outChars)
|
|
49
|
+
}
|
|
50
|
+
if (record.type === 'request') {
|
|
51
|
+
const u = record.usage ?? {}
|
|
52
|
+
const tier = record.tier === 'large' ? 'large' : 'small'
|
|
53
|
+
tally[`${tier}Tokens`] = (tally[`${tier}Tokens`] ?? 0) + (u.inputTokens ?? 0) + (u.outputTokens ?? 0)
|
|
54
|
+
tally.costUsd = (tally.costUsd ?? 0) + (record.costUsd ?? 0)
|
|
55
|
+
}
|
|
56
|
+
ledger.write(record)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} sessionId
|
|
62
|
+
*/
|
|
63
|
+
function stateOf(sessionId) {
|
|
64
|
+
let state = sessions.get(sessionId)
|
|
65
|
+
if (state === undefined) {
|
|
66
|
+
state = { cascade: createCascade(settings.cascade), loop: createLoopGuard(), route: undefined }
|
|
67
|
+
sessions.set(sessionId, state)
|
|
68
|
+
}
|
|
69
|
+
return state
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The provider, model and reasoning effort of one tier. An empty effort means the provider's default.
|
|
74
|
+
* @param {'small' | 'large'} tier
|
|
75
|
+
* @returns {{ provider: string, model: string, reasoningEffort?: string }}
|
|
76
|
+
*/
|
|
77
|
+
function tierRoute(tier) {
|
|
78
|
+
const route = settings.tiers[tier]
|
|
79
|
+
if (route === undefined) return undefined
|
|
80
|
+
return route.reasoningEffort === ''
|
|
81
|
+
? { provider: route.provider, model: route.model }
|
|
82
|
+
: { provider: route.provider, model: route.model, reasoningEffort: route.reasoningEffort }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The tier a logged route belongs to. Any route that is not the large tier counts as small.
|
|
87
|
+
* @param {{ provider?: string, model?: string } | undefined} route
|
|
88
|
+
* @returns {'small' | 'large'}
|
|
89
|
+
*/
|
|
90
|
+
function tierOfRoute(route) {
|
|
91
|
+
const large = settings.tiers.large
|
|
92
|
+
if (large === undefined) return 'small'
|
|
93
|
+
return route?.provider === large.provider && route?.model === large.model ? 'large' : 'small'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Record a tier change. It takes effect on the session's next model call, which asks
|
|
98
|
+
* `routeForNextCall` for its route.
|
|
99
|
+
* @param {ToolCall} call
|
|
100
|
+
* @param {{ from: string, to: string, reason: string } | null} change
|
|
101
|
+
* @param {Record<string, unknown>} [extra]
|
|
102
|
+
*/
|
|
103
|
+
function applyChange(call, change, extra = {}) {
|
|
104
|
+
if (change === null) return
|
|
105
|
+
log.write({
|
|
106
|
+
type: 'route', session: call.sessionId, from: change.from, to: change.to,
|
|
107
|
+
reason: change.reason, selection: tierRoute(change.to), ...extra,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The route of the next model call of a session. The call counts as one step in the current
|
|
113
|
+
* tier. The Cordis adapter applies the returned route in its `agent/request` waterfall.
|
|
114
|
+
* @param {string} sessionId
|
|
115
|
+
* @returns {{ provider: string, model: string, reasoningEffort?: string }}
|
|
116
|
+
*/
|
|
117
|
+
function routeForNextCall(sessionId, proposed) {
|
|
118
|
+
if (!settings.routing.enabled || settings.tiers.large === undefined) return undefined
|
|
119
|
+
// Without an explicit small tier, the small tier is the model DSH would use by default. It is
|
|
120
|
+
// captured from the first call that is routed, before any route has been applied.
|
|
121
|
+
if (settings.tiers.small === undefined && proposed?.provider && proposed?.model) {
|
|
122
|
+
settings.tiers.small = { provider: proposed.provider, model: proposed.model, reasoningEffort: proposed.reasoningEffort ?? '' }
|
|
123
|
+
}
|
|
124
|
+
if (settings.tiers.small === undefined) return undefined
|
|
125
|
+
const state = stateOf(sessionId)
|
|
126
|
+
recordStep(state.cascade)
|
|
127
|
+
return tierRoute(state.cascade.tier)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {ToolCall} call */
|
|
131
|
+
function beforeTool(call) {
|
|
132
|
+
if (!settings.loopBreaker.enabled || call.name !== 'bash') return undefined
|
|
133
|
+
const command = normalizeCommand(call.args?.command)
|
|
134
|
+
const state = stateOf(call.sessionId)
|
|
135
|
+
const count = streakOf(state.loop, command)
|
|
136
|
+
if (count < settings.loopBreaker.threshold) return undefined
|
|
137
|
+
applyChange(call, escalate(state.cascade, 'loop-breaker'))
|
|
138
|
+
log.write({ type: 'loop-break', session: call.sessionId, command: truncate(command), count })
|
|
139
|
+
return {
|
|
140
|
+
kind: 'deny',
|
|
141
|
+
reason: `GreenHarness loop guard: \`${command}\` has failed ${count} times in a row with the same failure and no file change since. Do not run it again unchanged. Read the failure, change the code or the command, then rerun. The next step uses the stronger model.`,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Verifier bookkeeping, loop streaks and output distillation for one bash result.
|
|
147
|
+
* @param {ToolCall} call
|
|
148
|
+
* @param {ReturnType<typeof stateOf>} state
|
|
149
|
+
* @returns {Promise<{ text: string } | undefined>} the replacement result text, when distilled.
|
|
150
|
+
*/
|
|
151
|
+
async function afterBash(call, state) {
|
|
152
|
+
const command = normalizeCommand(call.args?.command)
|
|
153
|
+
if (call.isError === true && call.exitCode === undefined && call.timedOut !== true) {
|
|
154
|
+
// The command never ran (its arguments were rejected), so it is no verifier outcome and
|
|
155
|
+
// does not move the cascade or the loop streak.
|
|
156
|
+
log.write({
|
|
157
|
+
type: 'tool', session: call.sessionId, tool: 'bash', action: 'not-run', command: truncate(command),
|
|
158
|
+
rawChars: (call.text ?? '').length, outChars: (call.text ?? '').length,
|
|
159
|
+
})
|
|
160
|
+
return undefined
|
|
161
|
+
}
|
|
162
|
+
const exitCode = call.exitCode ?? parseExitMarker(call.text ?? '') ?? (call.isError ? 1 : 0)
|
|
163
|
+
const outcome = classifyOutcome({ exitCode, timedOut: call.timedOut === true, text: call.text ?? '' })
|
|
164
|
+
const repeats = recordCommandOutcome(state.loop, command, outcome)
|
|
165
|
+
const verifier = isVerifierCommand(command, verifierRegexes)
|
|
166
|
+
if (verifier) applyChange(call, recordVerification(state.cascade, outcome.passed), { command: truncate(command) })
|
|
167
|
+
|
|
168
|
+
let replacement
|
|
169
|
+
let action = 'none'
|
|
170
|
+
if (settings.distill.enabled && (call.text ?? '').length > 0) {
|
|
171
|
+
const distilled = distillOutput(call.text, { maxChars: settings.distill.maxChars, contextLines: settings.distill.contextLines })
|
|
172
|
+
if (distilled.changed) {
|
|
173
|
+
const hint = call.spill ? await call.spill(call.text).catch(() => undefined) : undefined
|
|
174
|
+
const candidate = composeDistilled(distilled, hint)
|
|
175
|
+
if (candidate.length < call.text.length) {
|
|
176
|
+
replacement = candidate
|
|
177
|
+
action = 'distill'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
log.write({
|
|
183
|
+
type: 'tool', session: call.sessionId, tool: 'bash', action,
|
|
184
|
+
verifier, passed: outcome.passed, exitCode, fingerprint: outcome.fingerprint, repeats,
|
|
185
|
+
command: truncate(command), rawChars: (call.text ?? '').length, outChars: replacement?.length ?? (call.text ?? '').length,
|
|
186
|
+
})
|
|
187
|
+
return replacement === undefined ? undefined : { text: replacement }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {ToolCall} call */
|
|
191
|
+
function afterRead(call) {
|
|
192
|
+
if (!settings.readDedupe.enabled || call.isError) return undefined
|
|
193
|
+
const path = call.args?.file_path
|
|
194
|
+
if (typeof path !== 'string') return undefined
|
|
195
|
+
const text = call.text ?? ''
|
|
196
|
+
const read = {
|
|
197
|
+
path,
|
|
198
|
+
offset: numberOrNull(call.args?.offset),
|
|
199
|
+
limit: numberOrNull(call.args?.limit),
|
|
200
|
+
digest: createHash('sha1').update(text).digest('hex'),
|
|
201
|
+
}
|
|
202
|
+
const earlier = findUnchangedRead(reads, call.sessionId, read)
|
|
203
|
+
if (earlier !== undefined) {
|
|
204
|
+
const replacement = `[greenharness: this read returns the same lines as the earlier read of ${path} (call ${earlier}), which is still in context. Omitted to save tokens. Reuse that result.]`
|
|
205
|
+
log.write({ type: 'tool', session: call.sessionId, tool: 'read', action: 'dedupe', path, rawChars: text.length, outChars: replacement.length })
|
|
206
|
+
return { text: replacement }
|
|
207
|
+
}
|
|
208
|
+
rememberRead(reads, call.sessionId, { ...read, callId: call.callId ?? '' })
|
|
209
|
+
log.write({ type: 'tool', session: call.sessionId, tool: 'read', action: 'none', path, rawChars: text.length, outChars: text.length })
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @param {ToolCall} call */
|
|
214
|
+
async function afterTool(call) {
|
|
215
|
+
const state = stateOf(call.sessionId)
|
|
216
|
+
if (call.name === 'bash') return afterBash(call, state)
|
|
217
|
+
if (call.name === 'read') return afterRead(call)
|
|
218
|
+
if ((call.name === 'edit' || call.name === 'write') && call.isError !== true) {
|
|
219
|
+
if (typeof call.args?.file_path === 'string') forgetPath(reads, call.sessionId, call.args.file_path)
|
|
220
|
+
noteWorkspaceChange(state.loop)
|
|
221
|
+
}
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Session event observer: writes one ledger record per model call, with the route that call
|
|
227
|
+
* actually used, and clears the read index when the context shrinks.
|
|
228
|
+
* @param {string} sessionId
|
|
229
|
+
* @param {{ type: string, data?: any }} event
|
|
230
|
+
*/
|
|
231
|
+
function observeEvent(sessionId, event) {
|
|
232
|
+
const state = stateOf(sessionId)
|
|
233
|
+
if (event.type === 'request/header') {
|
|
234
|
+
const config = event.data?.header?.config
|
|
235
|
+
if (config !== undefined) state.route = { provider: config.provider, model: config.model, reasoningEffort: config.reasoningEffort }
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (event.type === 'assistant/message') {
|
|
239
|
+
const usage = event.data?.usage
|
|
240
|
+
if (usage === undefined) return
|
|
241
|
+
const route = state.route ?? tierRoute(state.cascade.tier) ?? { provider: '', model: '' }
|
|
242
|
+
log.write({
|
|
243
|
+
type: 'request', session: sessionId, turn: event.data.turn, step: event.data.step,
|
|
244
|
+
tier: tierOfRoute(route), provider: route.provider, model: route.model,
|
|
245
|
+
reasoningEffort: route.reasoningEffort ?? null, usage,
|
|
246
|
+
costUsd: usageCostUsd(usage, settings.ledger.pricing[route.provider]),
|
|
247
|
+
})
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (CONTEXT_REMOVAL_EVENTS.has(event.type)) clearSession(reads, sessionId)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
/** Counts of ledger records written by this process. */
|
|
255
|
+
stats() {
|
|
256
|
+
return { ...tally }
|
|
257
|
+
},
|
|
258
|
+
beforeTool,
|
|
259
|
+
afterTool,
|
|
260
|
+
observeEvent,
|
|
261
|
+
routeForNextCall,
|
|
262
|
+
/**
|
|
263
|
+
* Read-only view of one session's routing state, for tests and diagnostics.
|
|
264
|
+
* @param {string} sessionId
|
|
265
|
+
*/
|
|
266
|
+
inspect(sessionId) {
|
|
267
|
+
const state = stateOf(sessionId)
|
|
268
|
+
return { tier: state.cascade.tier, stepsInTier: state.cascade.stepsInTier, consecutiveFailures: state.cascade.consecutiveFailures }
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Text that replaces a distilled tool result, with a pointer to the full output when it was spilled.
|
|
275
|
+
* @param {ReturnType<typeof distillOutput>} distilled
|
|
276
|
+
* @param {string | undefined} hint
|
|
277
|
+
*/
|
|
278
|
+
function composeDistilled(distilled, hint) {
|
|
279
|
+
const { stats } = distilled
|
|
280
|
+
const pointer = hint === undefined ? '' : ` Full output: ${hint}`
|
|
281
|
+
return `${distilled.text}\n[greenharness: distilled ${stats.inChars} to ${stats.outChars} chars; dropped ${stats.droppedLines} progress lines and ${stats.droppedFrames} library frames.${pointer}]`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @param {string} text
|
|
286
|
+
* @param {number} [limit]
|
|
287
|
+
*/
|
|
288
|
+
function truncate(text, limit = 240) {
|
|
289
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}…`
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {unknown} value
|
|
294
|
+
*/
|
|
295
|
+
function numberOrNull(value) {
|
|
296
|
+
return typeof value === 'number' ? value : null
|
|
297
|
+
}
|