@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.
@@ -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
+ }