faberun 0.12.0 → 0.13.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/package.json +1 -1
- package/skills/faberun/references/contract.md +10 -10
- package/src/contract/assert.mjs +5 -1
- package/src/contract/index.mjs +43 -7
- package/src/contract/runtime.mjs +4 -1
- package/src/contract/snapshot.mjs +25 -1
- package/src/contract/task-packet.mjs +5 -1
- package/src/engine/backoff.mjs +2 -1
- package/src/engine/capacity.mjs +74 -0
- package/src/engine/dispatch.mjs +12 -186
- package/src/engine/lifecycle.mjs +7 -2
- package/src/engine/phase-session.mjs +217 -0
- package/src/engine/process.mjs +26 -108
- package/src/engine/scheduler.mjs +15 -3
- package/src/engine/settle.mjs +11 -2
- package/src/engine/transcript.mjs +145 -0
- package/src/harnesses/claude/index.mjs +5 -0
- package/src/harnesses/dsh/runner.mjs +38 -3
- package/src/harnesses/exec-jsonl/index.mjs +0 -499
- package/src/harnesses/index.mjs +55 -8
- package/src/harnesses/protocol.mjs +22 -2
- package/src/harnesses/session-metrics.mjs +674 -0
- package/src/host/tool-policy-decisions.mjs +78 -21
- package/src/host/tool-policy-hook.mjs +12 -3
- package/src/plan/freeze.mjs +24 -6
- package/src/plan/pipeline.mjs +232 -67
- package/src/plan/sizing.mjs +0 -0
- package/src/plan/template.mjs +110 -8
- package/src/run/operations.mjs +1 -1
- package/src/run/usage.mjs +9 -3
- package/src/util.mjs +0 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session metering over a provider transcript: what the controller reads from
|
|
3
|
+
* a still-growing stdout log while a turn runs -- provider requests (turns),
|
|
4
|
+
* tool calls, cache reads, completion -- and the bounded incremental parser
|
|
5
|
+
* behind it. Separate from the harness adapters because every adapter owns
|
|
6
|
+
* one stream shape and this module has to know all of them: it is the one
|
|
7
|
+
* place that says what "progress" and "a request" mean for claude, codex,
|
|
8
|
+
* agy and dsh alike. Its readers are the engine (stall detection, the live
|
|
9
|
+
* budget) and the usage ledger; no adapter imports it.
|
|
10
|
+
*/
|
|
11
|
+
import { canonicalUsage, eventItem, extractJson } from "./protocol.mjs";
|
|
12
|
+
import { finite } from "../util.mjs";
|
|
13
|
+
|
|
14
|
+
/** @typedef {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean, requests: number, contextFirst: number|null, contextMax: number|null, contextLast: number|null, contextSum: number}} SessionTotals */
|
|
15
|
+
/**
|
|
16
|
+
* The per-request ledger one transcript proves: provider requests that
|
|
17
|
+
* reported usage, and the context each re-sent -- uncached input (cache
|
|
18
|
+
* writes included) plus cache reads -- for the first, largest and last of
|
|
19
|
+
* them, with their sum. Persisted on the invocation and in usage.jsonl.
|
|
20
|
+
* measured 2026-09-20 over 149 stored claude worker turns: median 49
|
|
21
|
+
* requests, context growing 1.9x within a turn, 3.3M tokens re-sent per
|
|
22
|
+
* turn; the two 600-request turns re-sent 190M and 167M.
|
|
23
|
+
* Null request fields mean the stream carried no usage per request (codex
|
|
24
|
+
* reports per turn, zcode emits one document), never that none were made.
|
|
25
|
+
* @typedef {{turns: number, toolCalls: number, requests: number|null, contextFirst: number|null, contextMax: number|null, contextLast: number|null, contextSum: number|null, completed: boolean}} SessionLedger
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The harnesses whose streams carry usage per provider request; the others meter turns and tool calls only. */
|
|
29
|
+
const PER_REQUEST_USAGE = new Set(["claude", "dsh", "agy", "exec-jsonl", "replay"]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Best-effort input-token meter over a still-growing transcript. The
|
|
33
|
+
* controller never owns the provider stream (the gate writes stdout straight
|
|
34
|
+
* to the log fd), so budget enforcement polls this instead. Lenient by
|
|
35
|
+
* design: unparsable or partial lines count as zero, and providers that only
|
|
36
|
+
* report usage at completion (exec-jsonl, replay) meter as 0 mid-run.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} harness
|
|
39
|
+
* @param {string} stdout bounded transcript tail
|
|
40
|
+
* @returns {{inputTokens: number|null, cacheReadInputTokens: number|null}}
|
|
41
|
+
*/
|
|
42
|
+
export function liveUsage(harness, stdout) {
|
|
43
|
+
if (harness === "exec-jsonl" || harness === "replay") {
|
|
44
|
+
// Completion-only harnesses: usage arrives in the terminal envelope, which
|
|
45
|
+
// the close path normalizes, never in a mid-run live observation.
|
|
46
|
+
return { inputTokens: null, cacheReadInputTokens: null };
|
|
47
|
+
}
|
|
48
|
+
const events = parsedEvents(stdout);
|
|
49
|
+
if (harness === "codex") {
|
|
50
|
+
// turn.completed usage is cumulative for the session; the last one wins.
|
|
51
|
+
// Codex counts input_tokens with their cached portion included, so the
|
|
52
|
+
// uncached total is what the ledger calls `inputTokens`.
|
|
53
|
+
const records = events
|
|
54
|
+
.filter((event) => event?.type === "turn.completed" && event.usage && typeof event.usage === "object")
|
|
55
|
+
.map((event) => {
|
|
56
|
+
const rawInput = finite(event.usage.input_tokens ?? event.usage.inputTokens);
|
|
57
|
+
if (rawInput === null) return null;
|
|
58
|
+
const cacheReadInputTokens = finite(
|
|
59
|
+
event.usage.cached_input_tokens ?? event.usage.cacheReadInputTokens ?? event.usage.cache_read_tokens,
|
|
60
|
+
) ?? 0;
|
|
61
|
+
return { inputTokens: Math.max(0, rawInput - cacheReadInputTokens), cacheReadInputTokens };
|
|
62
|
+
})
|
|
63
|
+
.filter((record) => record !== null);
|
|
64
|
+
if (!records.length) return { inputTokens: null, cacheReadInputTokens: null };
|
|
65
|
+
return records.reduce((best, record) => (
|
|
66
|
+
record.inputTokens + record.cacheReadInputTokens > best.inputTokens + best.cacheReadInputTokens ? record : best
|
|
67
|
+
));
|
|
68
|
+
}
|
|
69
|
+
if (harness === "claude") {
|
|
70
|
+
// The terminal result event carries the session total; before it lands,
|
|
71
|
+
// sum per-request assistant usage (each request re-reads full context).
|
|
72
|
+
// Claude's input_tokens already exclude cache reads.
|
|
73
|
+
const resultEvent = events.findLast((event) => event?.type === "result");
|
|
74
|
+
const resultUsage = resultEvent?.usage && typeof resultEvent.usage === "object"
|
|
75
|
+
? finite(resultEvent.usage.input_tokens ?? resultEvent.usage.inputTokens)
|
|
76
|
+
: null;
|
|
77
|
+
if (resultUsage !== null) {
|
|
78
|
+
return {
|
|
79
|
+
inputTokens: resultUsage,
|
|
80
|
+
cacheReadInputTokens: finite(resultEvent.usage.cache_read_input_tokens ?? resultEvent.usage.cacheReadInputTokens) ?? null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
inputTokens: events.reduce((sum, event) => {
|
|
85
|
+
if (event?.type !== "assistant") return sum;
|
|
86
|
+
const usage = event.message?.usage;
|
|
87
|
+
const value = usage && typeof usage === "object" ? finite(usage.input_tokens ?? usage.inputTokens) : null;
|
|
88
|
+
return sum + (value ?? 0);
|
|
89
|
+
}, 0) || null,
|
|
90
|
+
cacheReadInputTokens: null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (harness === "dsh") {
|
|
94
|
+
// The terminal event carries the harness's own session total; before it
|
|
95
|
+
// lands, sum the per-message usage the runner forwards.
|
|
96
|
+
const terminal = events.findLast((event) => event?.type === "dsh.completed" || event?.type === "dsh.failed");
|
|
97
|
+
const terminalUsage = terminal ? canonicalUsage(terminal.usage) : null;
|
|
98
|
+
if (terminalUsage && terminalUsage.inputTokens !== null) {
|
|
99
|
+
return { inputTokens: terminalUsage.inputTokens, cacheReadInputTokens: terminalUsage.cacheReadInputTokens };
|
|
100
|
+
}
|
|
101
|
+
return sumRequestUsage(events.filter((event) => event?.type === "dsh.message").map((event) => event.usage));
|
|
102
|
+
}
|
|
103
|
+
if (harness === "agy") {
|
|
104
|
+
const resultRecord = events.map((event) => agyResult(event)).findLast((result) => result !== null);
|
|
105
|
+
const resultUsage = resultRecord ? canonicalUsage(resultRecord.usage) : null;
|
|
106
|
+
if (resultUsage && resultUsage.inputTokens !== null) {
|
|
107
|
+
return { inputTokens: resultUsage.inputTokens, cacheReadInputTokens: resultUsage.cacheReadInputTokens };
|
|
108
|
+
}
|
|
109
|
+
return sumRequestUsage(events.map((event) => agyStep(event))
|
|
110
|
+
.filter((step) => step !== null && step.step_type === "agent_response" && step.state === "DONE")
|
|
111
|
+
.map((step) => step?.usage));
|
|
112
|
+
}
|
|
113
|
+
return { inputTokens: null, cacheReadInputTokens: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Sum per-request usage records into the live meter's shape; no record with
|
|
118
|
+
* a measured input leaves the meter null rather than a plausible zero.
|
|
119
|
+
*
|
|
120
|
+
* @param {unknown[]} usages
|
|
121
|
+
* @returns {{inputTokens: number|null, cacheReadInputTokens: number|null}}
|
|
122
|
+
*/
|
|
123
|
+
function sumRequestUsage(usages) {
|
|
124
|
+
let inputTokens = null;
|
|
125
|
+
let cacheReadInputTokens = 0;
|
|
126
|
+
for (const raw of usages) {
|
|
127
|
+
const usage = canonicalUsage(raw);
|
|
128
|
+
if (usage.inputTokens === null) continue;
|
|
129
|
+
inputTokens = (inputTokens ?? 0) + usage.inputTokens;
|
|
130
|
+
cacheReadInputTokens += usage.cacheReadInputTokens ?? 0;
|
|
131
|
+
}
|
|
132
|
+
return inputTokens === null ? { inputTokens: null, cacheReadInputTokens: null } : { inputTokens, cacheReadInputTokens };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Budgeted live meter: one number, with cache reads weighted by the campaign
|
|
137
|
+
* policy so it is comparable with the persisted ledger. A provider that only
|
|
138
|
+
* reports usage at completion (exec-jsonl, replay) meters as 0 mid-run.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} harness
|
|
141
|
+
* @param {string} stdout bounded transcript tail
|
|
142
|
+
* @param {number} [cacheReadWeight] cached-to-uncached rate ratio, default 1
|
|
143
|
+
* @returns {number}
|
|
144
|
+
*/
|
|
145
|
+
export function liveInputTokens(harness, stdout, cacheReadWeight = 1) {
|
|
146
|
+
const usage = liveUsage(harness, stdout);
|
|
147
|
+
if (usage.inputTokens === null) return 0;
|
|
148
|
+
const weighted = usage.inputTokens + (usage.cacheReadInputTokens ?? 0) * cacheReadWeight;
|
|
149
|
+
return Math.round(weighted * 1000) / 1000;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse each JSONL line independently. A bounded transcript tail can start or
|
|
154
|
+
* end mid-line, so unparsable lines are skipped rather than failing the live
|
|
155
|
+
* observation.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} stdout
|
|
158
|
+
*/
|
|
159
|
+
function parsedEvents(stdout) {
|
|
160
|
+
return String(stdout).split(/\r?\n/u).flatMap((line) => {
|
|
161
|
+
try {
|
|
162
|
+
return [JSON.parse(line)];
|
|
163
|
+
} catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Codex item types whose completion proves one tool invocation. */
|
|
170
|
+
const CODEX_TOOL_ITEM_TYPES = new Set(["tool_call", "command_execution", "mcp_tool_call", "web_search", "file_change"]);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Session evidence from a bounded live transcript: completed turns, cache-read
|
|
174
|
+
* input, tool invocations, and whether the harness's terminal record has been
|
|
175
|
+
* folded. Each harness exposes only what its own events prove, and anything
|
|
176
|
+
* unparsable or unsupported meters as zero — a live observation never throws.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} harness
|
|
179
|
+
* @param {string} stdout bounded transcript tail
|
|
180
|
+
* @returns {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}}
|
|
181
|
+
*/
|
|
182
|
+
export function liveSessionMetrics(harness, stdout) {
|
|
183
|
+
const parser = new SessionMetricsParser(harness);
|
|
184
|
+
parser.push(String(stdout));
|
|
185
|
+
parser.flush();
|
|
186
|
+
return parser.metrics();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Retention bound for one streamed record: records at or below it parse whole. */
|
|
190
|
+
const SESSION_RECORD_MAX_BYTES = 64 * 1024;
|
|
191
|
+
|
|
192
|
+
/** Fragment evidence kept for a record that outgrew the retention bound. */
|
|
193
|
+
const SESSION_FRAGMENT_BYTES = SESSION_RECORD_MAX_BYTES / 2;
|
|
194
|
+
|
|
195
|
+
/** Claude-family content-block needle proving one tool invocation. */
|
|
196
|
+
const TOOL_USE_NEEDLE = Buffer.from('"type":"tool_use"', "utf8");
|
|
197
|
+
|
|
198
|
+
/** Cache-read evidence spellings across harness streams. */
|
|
199
|
+
const CACHE_READ_PATTERN = /"(?:cache_read_input_tokens|cached_input_tokens|cacheReadInputTokens)":(\d+)/gu;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Bounded incremental session-metrics parser: fold fixed-size chunks into
|
|
203
|
+
* running rotation totals without ever holding a buffer that scales with the
|
|
204
|
+
* unread transcript. Records within `SESSION_RECORD_MAX_BYTES` parse whole;
|
|
205
|
+
* a larger record keeps head and tail fragments plus streamed needle counts,
|
|
206
|
+
* so its turn and usage evidence still lands in the totals instead of being
|
|
207
|
+
* silently skipped.
|
|
208
|
+
*/
|
|
209
|
+
export class SessionMetricsParser {
|
|
210
|
+
/**
|
|
211
|
+
* @param {string} harness
|
|
212
|
+
* @param {{turns?: number, cacheReadInputTokens?: number, toolCalls?: number, completed?: boolean}} [previous]
|
|
213
|
+
*/
|
|
214
|
+
constructor(harness, previous = {}) {
|
|
215
|
+
this.harness = harness;
|
|
216
|
+
/** @type {SessionTotals} */
|
|
217
|
+
this.totals = {
|
|
218
|
+
turns: previous.turns ?? 0,
|
|
219
|
+
cacheReadInputTokens: previous.cacheReadInputTokens ?? 0,
|
|
220
|
+
toolCalls: previous.toolCalls ?? 0,
|
|
221
|
+
completed: previous.completed === true,
|
|
222
|
+
requests: 0,
|
|
223
|
+
contextFirst: null,
|
|
224
|
+
contextMax: null,
|
|
225
|
+
contextLast: null,
|
|
226
|
+
contextSum: 0,
|
|
227
|
+
};
|
|
228
|
+
/** @type {string|null} */
|
|
229
|
+
this.continuationId = null;
|
|
230
|
+
/** @type {string|null} Most recent folded item-completed type, for the codex completion rule. */
|
|
231
|
+
this.lastItemType = null;
|
|
232
|
+
/** @type {string|null} Text of the most recent folded agent message, for the codex completion rule. */
|
|
233
|
+
this.lastAgentText = null;
|
|
234
|
+
/** @type {Buffer} */
|
|
235
|
+
this.pending = Buffer.alloc(0);
|
|
236
|
+
/** @type {{head: Buffer, tail: Buffer, streamedToolUse: number, carry: Buffer}|null} */
|
|
237
|
+
this.oversized = null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Fold every newline-terminated record in one chunk. A trailing partial
|
|
242
|
+
* record stays buffered (bounded) for the next chunk.
|
|
243
|
+
*
|
|
244
|
+
* @param {string|Buffer} chunk
|
|
245
|
+
*/
|
|
246
|
+
push(chunk) {
|
|
247
|
+
let data = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
248
|
+
while (data.length > 0) {
|
|
249
|
+
const newline = data.indexOf(10);
|
|
250
|
+
if (newline < 0) {
|
|
251
|
+
this.absorb(data);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.absorb(data.subarray(0, newline));
|
|
255
|
+
this.completeRecord();
|
|
256
|
+
data = data.subarray(newline + 1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Fold the buffered partial record as if a newline had ended it. */
|
|
261
|
+
flush() {
|
|
262
|
+
if (this.pending.length > 0 || this.oversized) this.completeRecord();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** @returns {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} */
|
|
266
|
+
metrics() {
|
|
267
|
+
return {
|
|
268
|
+
turns: this.totals.turns,
|
|
269
|
+
cacheReadInputTokens: this.totals.cacheReadInputTokens,
|
|
270
|
+
toolCalls: this.totals.toolCalls,
|
|
271
|
+
completed: this.totals.completed === true,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The per-request ledger folded so far. The claude, dsh, agy, exec-jsonl and
|
|
277
|
+
* replay streams carry usage per provider request; codex reports it cumulatively
|
|
278
|
+
* per turn and zcode emits one document per invocation, so for those the
|
|
279
|
+
* request count and the context sizes are unknown, not zero. Measured
|
|
280
|
+
* 2026-09-20 in the orchestration-arms campaign: four writer arms on codex
|
|
281
|
+
* and zcode were reported at zero requests until this distinction was made.
|
|
282
|
+
*
|
|
283
|
+
* @returns {SessionLedger}
|
|
284
|
+
*/
|
|
285
|
+
session() {
|
|
286
|
+
const totals = this.totals;
|
|
287
|
+
const metered = PER_REQUEST_USAGE.has(this.harness);
|
|
288
|
+
return {
|
|
289
|
+
turns: totals.turns,
|
|
290
|
+
toolCalls: totals.toolCalls,
|
|
291
|
+
requests: metered ? totals.requests : null,
|
|
292
|
+
contextFirst: metered ? totals.contextFirst : null,
|
|
293
|
+
contextMax: metered ? totals.contextMax : null,
|
|
294
|
+
contextLast: metered ? totals.contextLast : null,
|
|
295
|
+
contextSum: metered && totals.requests > 0 ? totals.contextSum : null,
|
|
296
|
+
completed: totals.completed === true,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Retain one piece of a record still under assembly. Once the record
|
|
302
|
+
* outgrows the retention bound, only its head and a rolling tail are kept;
|
|
303
|
+
* the bytes leaving the tail are scanned for tool_use evidence instead of
|
|
304
|
+
* being buffered.
|
|
305
|
+
*
|
|
306
|
+
* @param {Buffer} piece
|
|
307
|
+
*/
|
|
308
|
+
absorb(piece) {
|
|
309
|
+
if (this.oversized) {
|
|
310
|
+
const window = Buffer.concat([this.oversized.tail, piece]);
|
|
311
|
+
const keep = window.subarray(Math.max(0, window.length - SESSION_FRAGMENT_BYTES));
|
|
312
|
+
const dropped = window.subarray(0, window.length - keep.length);
|
|
313
|
+
if (this.harness === "claude") {
|
|
314
|
+
const counted = countWithCarry(dropped, this.oversized.carry, TOOL_USE_NEEDLE);
|
|
315
|
+
this.oversized.streamedToolUse += counted.hits;
|
|
316
|
+
this.oversized.carry = counted.carry;
|
|
317
|
+
}
|
|
318
|
+
this.oversized.tail = keep;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (this.pending.length + piece.length <= SESSION_RECORD_MAX_BYTES) {
|
|
322
|
+
// Copy: `piece` may be a view of a scratch buffer the caller reuses for
|
|
323
|
+
// the next read, which would corrupt a record buffered mid-chunk.
|
|
324
|
+
this.pending = this.pending.length > 0 ? Buffer.concat([this.pending, piece]) : Buffer.from(piece);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const whole = Buffer.concat([this.pending, piece]);
|
|
328
|
+
this.pending = Buffer.alloc(0);
|
|
329
|
+
const head = whole.subarray(0, Math.min(SESSION_FRAGMENT_BYTES, whole.length));
|
|
330
|
+
const tail = whole.subarray(Math.max(0, whole.length - SESSION_FRAGMENT_BYTES));
|
|
331
|
+
/** @type {{head: Buffer, tail: Buffer, streamedToolUse: number, carry: Buffer}} */
|
|
332
|
+
const oversized = { head, tail, streamedToolUse: 0, carry: Buffer.alloc(0) };
|
|
333
|
+
if (this.harness === "claude") {
|
|
334
|
+
// Count from the record start up to where the rolling tail takes over,
|
|
335
|
+
// so a needle straddling any region boundary is counted exactly once.
|
|
336
|
+
const counted = countWithCarry(whole.subarray(0, Math.max(0, whole.length - tail.length)), oversized.carry, TOOL_USE_NEEDLE);
|
|
337
|
+
oversized.streamedToolUse = counted.hits;
|
|
338
|
+
oversized.carry = counted.carry;
|
|
339
|
+
}
|
|
340
|
+
this.oversized = oversized;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Fold the assembled record into the running totals. */
|
|
344
|
+
completeRecord() {
|
|
345
|
+
const oversized = this.oversized;
|
|
346
|
+
if (oversized) {
|
|
347
|
+
this.oversized = null;
|
|
348
|
+
/** @type {{head: string, tail: string, toolUse: number}} */
|
|
349
|
+
let fragments;
|
|
350
|
+
if (this.harness === "claude") {
|
|
351
|
+
const counted = countWithCarry(oversized.tail, oversized.carry, TOOL_USE_NEEDLE);
|
|
352
|
+
fragments = {
|
|
353
|
+
head: decodeFragment(oversized.head),
|
|
354
|
+
tail: decodeFragment(oversized.tail),
|
|
355
|
+
toolUse: oversized.streamedToolUse + counted.hits,
|
|
356
|
+
};
|
|
357
|
+
} else {
|
|
358
|
+
fragments = { head: decodeFragment(oversized.head), tail: decodeFragment(oversized.tail), toolUse: 0 };
|
|
359
|
+
}
|
|
360
|
+
foldFragmentRecord(this.harness, this.totals, fragments);
|
|
361
|
+
this.continuationId ??= fragmentContinuationId(this.harness, fragments);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const line = this.pending.toString("utf8");
|
|
365
|
+
this.pending = Buffer.alloc(0);
|
|
366
|
+
let event;
|
|
367
|
+
try {
|
|
368
|
+
event = JSON.parse(line);
|
|
369
|
+
} catch {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
|
373
|
+
const record = /** @type {Record<string, unknown>} */ (event);
|
|
374
|
+
foldRecord(this.harness, this.totals, record);
|
|
375
|
+
this.continuationId ??= recordContinuationId(this.harness, record);
|
|
376
|
+
this.foldCompletionEvidence(record);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Fold the completion evidence one parsed record proves into the sticky
|
|
381
|
+
* totals. A harness is completed when its terminal record was folded; for
|
|
382
|
+
* codex that means a turn.completed that ends the turn with the
|
|
383
|
+
* result-carrying final agent message, so a live observation never treats a
|
|
384
|
+
* still-working or already-answered session ambiguously.
|
|
385
|
+
*
|
|
386
|
+
* @param {Record<string, unknown>} record
|
|
387
|
+
*/
|
|
388
|
+
foldCompletionEvidence(record) {
|
|
389
|
+
const totals = this.totals;
|
|
390
|
+
if (this.harness === "codex") {
|
|
391
|
+
if (record.type === "turn.completed" && this.lastItemType === "agent_message"
|
|
392
|
+
&& extractJson(this.lastAgentText) !== null) {
|
|
393
|
+
totals.completed = true;
|
|
394
|
+
}
|
|
395
|
+
} else if (this.harness === "claude" && record.type === "result") {
|
|
396
|
+
totals.completed = true;
|
|
397
|
+
} else if (this.harness === "dsh" && (record.type === "dsh.completed" || record.type === "dsh.failed")) {
|
|
398
|
+
totals.completed = true;
|
|
399
|
+
} else if (this.harness === "agy" && record.event === "result") {
|
|
400
|
+
totals.completed = true;
|
|
401
|
+
} else if (this.harness === "exec-jsonl" && record.type === "run.completed") {
|
|
402
|
+
totals.completed = true;
|
|
403
|
+
} else if (this.harness === "replay" && typeof record.status === "string") {
|
|
404
|
+
// The replay envelope is the terminal record: the bin emits exactly one
|
|
405
|
+
// envelope line per invocation, so folding one proves completion.
|
|
406
|
+
totals.completed = true;
|
|
407
|
+
}
|
|
408
|
+
const item = eventItem(record);
|
|
409
|
+
if (record.type === "item.completed" && item) {
|
|
410
|
+
this.lastItemType = String(item.type ?? "");
|
|
411
|
+
this.lastAgentText = this.lastItemType === "agent_message" && typeof item.text === "string"
|
|
412
|
+
? item.text
|
|
413
|
+
: null;
|
|
414
|
+
} else {
|
|
415
|
+
this.lastItemType = null;
|
|
416
|
+
this.lastAgentText = null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Fold one parsed record into the running totals. Turn and tool counts are
|
|
423
|
+
* additive; cache-read is a running max for Codex (each turn.completed
|
|
424
|
+
* counter is already cumulative) and additive for claude-style streams until
|
|
425
|
+
* a terminal result event carries the authoritative session total.
|
|
426
|
+
*
|
|
427
|
+
* @param {string} harness
|
|
428
|
+
* @param {SessionTotals} totals
|
|
429
|
+
* @param {Record<string, unknown>} record
|
|
430
|
+
*/
|
|
431
|
+
function foldRecord(harness, totals, record) {
|
|
432
|
+
if (harness === "codex") {
|
|
433
|
+
if (record.type === "turn.completed") {
|
|
434
|
+
totals.turns += 1;
|
|
435
|
+
totals.cacheReadInputTokens = Math.max(totals.cacheReadInputTokens, canonicalUsage(record.usage).cacheReadInputTokens ?? 0);
|
|
436
|
+
} else if (record.type === "item.completed" && CODEX_TOOL_ITEM_TYPES.has(String(eventItem(record)?.type))) {
|
|
437
|
+
totals.toolCalls += 1;
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (harness === "claude") {
|
|
442
|
+
if (record.type === "assistant") {
|
|
443
|
+
const message = /** @type {Record<string, unknown>} */ (record.message ?? {});
|
|
444
|
+
totals.turns += 1;
|
|
445
|
+
totals.cacheReadInputTokens += canonicalUsage(message.usage).cacheReadInputTokens ?? 0;
|
|
446
|
+
foldRequestUsage(totals, canonicalUsage(message.usage));
|
|
447
|
+
totals.toolCalls += Array.isArray(message.content)
|
|
448
|
+
? message.content.filter((/** @type {{type?: unknown}} */ block) => block?.type === "tool_use").length
|
|
449
|
+
: 0;
|
|
450
|
+
} else if (record.type === "result") {
|
|
451
|
+
const sessionTotal = canonicalUsage(record.usage).cacheReadInputTokens;
|
|
452
|
+
if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (harness === "dsh") {
|
|
457
|
+
// The runner folds the sdk firehose to one `dsh.message` per assistant
|
|
458
|
+
// message (with that message's usage) and one `dsh.tool` per tool call.
|
|
459
|
+
// measured 2026-09-20: before this branch a dsh turn metered zero events,
|
|
460
|
+
// so once progress became event-only (668f6c1) the stall detector cut
|
|
461
|
+
// every dsh worker at the contract's stallTimeoutSec regardless of
|
|
462
|
+
// activity -- three kills on 2026-09-16 at 903s against a 900s limit.
|
|
463
|
+
if (record.type === "dsh.message") {
|
|
464
|
+
totals.turns += 1;
|
|
465
|
+
totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
|
|
466
|
+
foldRequestUsage(totals, canonicalUsage(record.usage));
|
|
467
|
+
} else if (record.type === "dsh.tool") {
|
|
468
|
+
totals.toolCalls += 1;
|
|
469
|
+
} else if (record.type === "dsh.completed" || record.type === "dsh.failed") {
|
|
470
|
+
const sessionTotal = canonicalUsage(record.usage).cacheReadInputTokens;
|
|
471
|
+
if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (harness === "agy") {
|
|
476
|
+
// agy streams one `step_update` per step: an agent_response reaching DONE
|
|
477
|
+
// is one provider request carrying its own usage, a tool step reaching
|
|
478
|
+
// DONE or ERROR is one tool call, and the terminal `result` carries the
|
|
479
|
+
// session total (measured 2026-09-20 over 6 agy invocations: 140 DONE and
|
|
480
|
+
// 4 ERROR tool steps, 166 DONE agent responses).
|
|
481
|
+
const step = agyStep(record);
|
|
482
|
+
if (step) {
|
|
483
|
+
if (step.step_type === "agent_response" && step.state === "DONE") {
|
|
484
|
+
totals.turns += 1;
|
|
485
|
+
totals.cacheReadInputTokens += canonicalUsage(step.usage).cacheReadInputTokens ?? 0;
|
|
486
|
+
foldRequestUsage(totals, canonicalUsage(step.usage));
|
|
487
|
+
} else if (step.step_type === "tool" && (step.state === "DONE" || step.state === "ERROR")) {
|
|
488
|
+
totals.toolCalls += 1;
|
|
489
|
+
}
|
|
490
|
+
} else if (record.event === "result") {
|
|
491
|
+
const sessionTotal = canonicalUsage(agyResult(record)?.usage).cacheReadInputTokens;
|
|
492
|
+
if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (harness === "exec-jsonl" && record.type === "run.completed") {
|
|
497
|
+
// The protocol carries no tool events; only a completed run proves a turn.
|
|
498
|
+
totals.turns += 1;
|
|
499
|
+
totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
|
|
500
|
+
foldRequestUsage(totals, canonicalUsage(record.usage));
|
|
501
|
+
}
|
|
502
|
+
if (harness === "replay" && typeof record.status === "string") {
|
|
503
|
+
// A replayed envelope is the whole invocation: one completed turn, no
|
|
504
|
+
// tool events, usage only in the terminal record.
|
|
505
|
+
totals.turns += 1;
|
|
506
|
+
totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
|
|
507
|
+
foldRequestUsage(totals, canonicalUsage(record.usage));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Fold one provider request's usage into the per-request ledger. A request
|
|
513
|
+
* with no measured input is not one the ledger can count.
|
|
514
|
+
*
|
|
515
|
+
* @param {SessionTotals} totals
|
|
516
|
+
* @param {{inputTokens: number|null, cacheReadInputTokens: number|null}} usage
|
|
517
|
+
*/
|
|
518
|
+
function foldRequestUsage(totals, usage) {
|
|
519
|
+
if (usage.inputTokens === null) return;
|
|
520
|
+
const context = usage.inputTokens + (usage.cacheReadInputTokens ?? 0);
|
|
521
|
+
totals.requests += 1;
|
|
522
|
+
totals.contextFirst ??= context;
|
|
523
|
+
totals.contextMax = Math.max(totals.contextMax ?? 0, context);
|
|
524
|
+
totals.contextLast = context;
|
|
525
|
+
totals.contextSum += context;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** @param {Record<string, unknown>} record @returns {Record<string, any>|null} the agy step_update payload, if this record is one */
|
|
529
|
+
function agyStep(record) {
|
|
530
|
+
if (record.event !== "step_update") return null;
|
|
531
|
+
const step = record.step_update;
|
|
532
|
+
return step && typeof step === "object" && !Array.isArray(step) ? /** @type {Record<string, any>} */ (step) : null;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** @param {Record<string, unknown>} record @returns {Record<string, any>|null} the agy terminal result payload, if this record is one */
|
|
536
|
+
function agyResult(record) {
|
|
537
|
+
if (record.event !== "result") return null;
|
|
538
|
+
const result = record.result;
|
|
539
|
+
return result && typeof result === "object" && !Array.isArray(result) ? /** @type {Record<string, any>} */ (result) : null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Fold the head-plus-tail fragments of one record that outgrew the retention
|
|
544
|
+
* bound: the same evidence foldRecord extracts, read as fragments so an
|
|
545
|
+
* oversized record is never silently skipped.
|
|
546
|
+
*
|
|
547
|
+
* @param {string} harness
|
|
548
|
+
* @param {SessionTotals} totals
|
|
549
|
+
* @param {{head: string, tail: string, toolUse: number}} fragments
|
|
550
|
+
*/
|
|
551
|
+
function foldFragmentRecord(harness, totals, fragments) {
|
|
552
|
+
const text = `${fragments.head}\n${fragments.tail}`;
|
|
553
|
+
if (harness === "claude") {
|
|
554
|
+
if (text.includes('"type":"assistant"')) {
|
|
555
|
+
totals.turns += 1;
|
|
556
|
+
totals.toolCalls += fragments.toolUse;
|
|
557
|
+
const cacheRead = lastCacheRead(text);
|
|
558
|
+
if (cacheRead !== null) totals.cacheReadInputTokens += cacheRead;
|
|
559
|
+
} else if (text.includes('"type":"result"')) {
|
|
560
|
+
const sessionTotal = lastCacheRead(text);
|
|
561
|
+
if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
|
|
562
|
+
totals.completed = true;
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (harness === "codex") {
|
|
567
|
+
if (text.includes('"type":"turn.completed"')) {
|
|
568
|
+
totals.turns += 1;
|
|
569
|
+
const cacheRead = lastCacheRead(text);
|
|
570
|
+
totals.cacheReadInputTokens = Math.max(totals.cacheReadInputTokens, cacheRead ?? 0);
|
|
571
|
+
// Fragment approximation of the parsed-record completion rule: the
|
|
572
|
+
// turn ends with the final agent message when that message appears
|
|
573
|
+
// before the completed marker in the retained head and tail.
|
|
574
|
+
const agentAt = text.indexOf('"type":"agent_message"');
|
|
575
|
+
if (agentAt >= 0 && agentAt < text.indexOf('"type":"turn.completed"')) totals.completed = true;
|
|
576
|
+
} else if (text.includes('"type":"item.completed"') && [...CODEX_TOOL_ITEM_TYPES].some((type) => text.includes(`"type":"${type}"`))) {
|
|
577
|
+
totals.toolCalls += 1;
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (harness === "exec-jsonl" && text.includes('"type":"run.completed"')) {
|
|
582
|
+
totals.turns += 1;
|
|
583
|
+
const cacheRead = lastCacheRead(text);
|
|
584
|
+
if (cacheRead !== null) totals.cacheReadInputTokens += cacheRead;
|
|
585
|
+
totals.completed = true;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The provider session identity one record proves.
|
|
591
|
+
*
|
|
592
|
+
* @param {string} harness
|
|
593
|
+
* @param {Record<string, unknown>} record
|
|
594
|
+
* @returns {string|null}
|
|
595
|
+
*/
|
|
596
|
+
function recordContinuationId(harness, record) {
|
|
597
|
+
if (harness === "codex") {
|
|
598
|
+
return record.type === "thread.started" && typeof record.thread_id === "string" ? record.thread_id : null;
|
|
599
|
+
}
|
|
600
|
+
if (harness === "claude") {
|
|
601
|
+
return record.type === "result" && typeof record.session_id === "string" ? record.session_id : null;
|
|
602
|
+
}
|
|
603
|
+
if (harness === "exec-jsonl" && (record.type === "run.started" || record.type === "run.completed")) {
|
|
604
|
+
return typeof record.continuationId === "string" ? record.continuationId : null;
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The provider session identity one record's fragments prove.
|
|
611
|
+
*
|
|
612
|
+
* @param {string} harness
|
|
613
|
+
* @param {{head: string, tail: string}} fragments
|
|
614
|
+
* @returns {string|null}
|
|
615
|
+
*/
|
|
616
|
+
function fragmentContinuationId(harness, fragments) {
|
|
617
|
+
const text = `${fragments.head}\n${fragments.tail}`;
|
|
618
|
+
const pattern = harness === "codex"
|
|
619
|
+
? /"thread_id":"([^"]+)"/u
|
|
620
|
+
: harness === "claude"
|
|
621
|
+
? /"session_id":"([^"]+)"/u
|
|
622
|
+
: /"continuationId":"([^"]+)"/u;
|
|
623
|
+
const match = pattern.exec(text);
|
|
624
|
+
return match ? match[1] : null;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Count needle occurrences in one region, keeping the trailing bytes that
|
|
629
|
+
* could complete a needle in the next region so a straddling needle is
|
|
630
|
+
* counted exactly once.
|
|
631
|
+
*
|
|
632
|
+
* @param {Buffer} region
|
|
633
|
+
* @param {Buffer} carry
|
|
634
|
+
* @param {Buffer} needle
|
|
635
|
+
* @returns {{hits: number, carry: Buffer}}
|
|
636
|
+
*/
|
|
637
|
+
function countWithCarry(region, carry, needle) {
|
|
638
|
+
const stream = carry.length > 0 ? Buffer.concat([carry, region]) : region;
|
|
639
|
+
return { hits: countNeedle(stream, needle), carry: stream.subarray(Math.max(0, stream.length - (needle.length - 1))) };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* @param {Buffer} haystack
|
|
644
|
+
* @param {Buffer} needle
|
|
645
|
+
* @returns {number}
|
|
646
|
+
*/
|
|
647
|
+
function countNeedle(haystack, needle) {
|
|
648
|
+
let hits = 0;
|
|
649
|
+
for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length)) hits += 1;
|
|
650
|
+
return hits;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Decode a retained fragment without splitting a UTF-8 sequence.
|
|
655
|
+
*
|
|
656
|
+
* @param {Buffer} fragment
|
|
657
|
+
* @returns {string}
|
|
658
|
+
*/
|
|
659
|
+
function decodeFragment(fragment) {
|
|
660
|
+
let start = 0;
|
|
661
|
+
while (start < fragment.length && (fragment[start] & 0xc0) === 0x80) start += 1;
|
|
662
|
+
return fragment.toString("utf8", start);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The last cache-read number in a fragment text, or null.
|
|
667
|
+
*
|
|
668
|
+
* @param {string} text
|
|
669
|
+
* @returns {number|null}
|
|
670
|
+
*/
|
|
671
|
+
function lastCacheRead(text) {
|
|
672
|
+
const matches = [...text.matchAll(CACHE_READ_PATTERN)];
|
|
673
|
+
return matches.length > 0 ? Number(matches.at(-1)?.[1]) : null;
|
|
674
|
+
}
|