pi-subagent-feather 0.0.0-stage → 0.2.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/LICENSE +22 -0
- package/NOTICE +7 -0
- package/README.md +149 -2
- package/extensions/subagent/index.ts +693 -0
- package/package.json +30 -4
- package/src/collector.ts +698 -0
package/src/collector.ts
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
// Isolated, bounded JSON event collector for the pi-subagent-lite fork.
|
|
2
|
+
//
|
|
3
|
+
// Design goals:
|
|
4
|
+
// - NO Pi dependencies (this module must be importable stand-alone).
|
|
5
|
+
// - Never retain raw events, message objects, tool args, images or thinking.
|
|
6
|
+
// - Bound every retained string (bytes + lines) while appending, never store
|
|
7
|
+
// an unbounded suffix and slice afterwards.
|
|
8
|
+
// - Correctly decode chunked UTF-8 (stdout NDJSON and stderr), including
|
|
9
|
+
// multibyte characters split across chunks and buffers ending without a
|
|
10
|
+
// trailing newline.
|
|
11
|
+
// - Recover from oversized NDJSON lines without poisoning the next line.
|
|
12
|
+
|
|
13
|
+
import { StringDecoder } from "node:string_decoder";
|
|
14
|
+
|
|
15
|
+
/** Numeric byte/line budgets enforced by {@link ChildCollector}. */
|
|
16
|
+
export interface CollectorLimits {
|
|
17
|
+
resultBytes: number;
|
|
18
|
+
resultLines: number;
|
|
19
|
+
progressBytes: number;
|
|
20
|
+
stderrBytes: number;
|
|
21
|
+
eventBytes: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Default budgets, identical in spirit to upstream but chosen for a safe fork. */
|
|
25
|
+
/** Where an oversized completed answer keeps content: head (default) or tail. */
|
|
26
|
+
export type KeepMode = "head" | "tail";
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_LIMITS: CollectorLimits = {
|
|
29
|
+
resultBytes: 8192,
|
|
30
|
+
resultLines: 200,
|
|
31
|
+
progressBytes: 2048,
|
|
32
|
+
stderrBytes: 4096,
|
|
33
|
+
eventBytes: 1024 * 1024,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Accumulated token/cost accounting exposed by {@link ChildCollector.usage}. */
|
|
37
|
+
export interface CollectorUsage {
|
|
38
|
+
input: number;
|
|
39
|
+
output: number;
|
|
40
|
+
cacheRead: number;
|
|
41
|
+
cacheWrite: number;
|
|
42
|
+
totalTokens: number;
|
|
43
|
+
cost: {
|
|
44
|
+
input: number;
|
|
45
|
+
output: number;
|
|
46
|
+
cacheRead: number;
|
|
47
|
+
cacheWrite: number;
|
|
48
|
+
total: number;
|
|
49
|
+
};
|
|
50
|
+
turns: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function emptyUsage(): CollectorUsage {
|
|
54
|
+
return {
|
|
55
|
+
input: 0,
|
|
56
|
+
output: 0,
|
|
57
|
+
cacheRead: 0,
|
|
58
|
+
cacheWrite: 0,
|
|
59
|
+
totalTokens: 0,
|
|
60
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
61
|
+
turns: 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function num(value: unknown): number {
|
|
66
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function utf8Length(text: string): number {
|
|
70
|
+
return Buffer.byteLength(text, "utf8");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Longest prefix of `text` whose UTF-8 byte length does not exceed `maxBytes`.
|
|
75
|
+
* Never splits a surrogate pair.
|
|
76
|
+
*/
|
|
77
|
+
function utf8Prefix(text: string, maxBytes: number): string {
|
|
78
|
+
if (maxBytes <= 0) return "";
|
|
79
|
+
let bytes = 0;
|
|
80
|
+
let i = 0;
|
|
81
|
+
while (i < text.length) {
|
|
82
|
+
const cp = text.codePointAt(i) as number;
|
|
83
|
+
const size = cp <= 0x7f ? 1 : cp <= 0x7ff ? 2 : cp <= 0xffff ? 3 : 4;
|
|
84
|
+
if (bytes + size > maxBytes) break;
|
|
85
|
+
bytes += size;
|
|
86
|
+
i += cp > 0xffff ? 2 : 1;
|
|
87
|
+
}
|
|
88
|
+
return text.slice(0, i);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Longest suffix of `text` whose UTF-8 byte length does not exceed `maxBytes`.
|
|
93
|
+
* Never splits a surrogate pair.
|
|
94
|
+
*/
|
|
95
|
+
function utf8Tail(text: string, maxBytes: number): string {
|
|
96
|
+
if (maxBytes <= 0) return "";
|
|
97
|
+
let bytes = 0;
|
|
98
|
+
let i = text.length;
|
|
99
|
+
while (i > 0) {
|
|
100
|
+
let start = i - 1;
|
|
101
|
+
let cp = text.charCodeAt(start);
|
|
102
|
+
if (cp >= 0xdc00 && cp <= 0xdfff && start > 0) {
|
|
103
|
+
start -= 1;
|
|
104
|
+
cp = text.codePointAt(start) as number;
|
|
105
|
+
}
|
|
106
|
+
const size = cp <= 0x7f ? 1 : cp <= 0x7ff ? 2 : cp <= 0xffff ? 3 : 4;
|
|
107
|
+
if (bytes + size > maxBytes) break;
|
|
108
|
+
bytes += size;
|
|
109
|
+
i = start;
|
|
110
|
+
}
|
|
111
|
+
return text.slice(i);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Byte caps for retained scalar metadata strings. */
|
|
115
|
+
const SCALAR_LIMITS = {
|
|
116
|
+
toolName: 128,
|
|
117
|
+
sessionId: 256,
|
|
118
|
+
model: 512,
|
|
119
|
+
stopReason: 64,
|
|
120
|
+
errorMessage: 2048,
|
|
121
|
+
} as const;
|
|
122
|
+
|
|
123
|
+
/** Scalar token/cost snapshot copied out of an event (no event reference kept). */
|
|
124
|
+
interface TurnUsage {
|
|
125
|
+
input: number;
|
|
126
|
+
output: number;
|
|
127
|
+
cacheRead: number;
|
|
128
|
+
cacheWrite: number;
|
|
129
|
+
totalTokens: number;
|
|
130
|
+
cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Force a fresh V8 string so a bounded slice cannot retain a huge backing store. */
|
|
134
|
+
function detach(text: string): string {
|
|
135
|
+
return Buffer.from(text, "utf8").toString("utf8");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Detached UTF-8 prefix of `value`, capped at `maxBytes`. */
|
|
139
|
+
function capScalar(value: unknown, maxBytes: number): string | undefined {
|
|
140
|
+
if (value === undefined || value === null) return undefined;
|
|
141
|
+
const text = typeof value === "string" ? value : String(value);
|
|
142
|
+
if (text.length === 0) return undefined;
|
|
143
|
+
return detach(utf8Prefix(text, maxBytes));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Copy only finite numeric token/cost fields into a fresh small object. */
|
|
147
|
+
function copyUsage(usage: unknown): TurnUsage | undefined {
|
|
148
|
+
if (!usage || typeof usage !== "object") return undefined;
|
|
149
|
+
const u = usage as Record<string, unknown>;
|
|
150
|
+
const rawCost = u.cost;
|
|
151
|
+
const c = rawCost && typeof rawCost === "object" ? (rawCost as Record<string, unknown>) : {};
|
|
152
|
+
return {
|
|
153
|
+
input: num(u.input),
|
|
154
|
+
output: num(u.output),
|
|
155
|
+
cacheRead: num(u.cacheRead),
|
|
156
|
+
cacheWrite: num(u.cacheWrite),
|
|
157
|
+
totalTokens: num(u.totalTokens),
|
|
158
|
+
cost: {
|
|
159
|
+
input: num(c.input),
|
|
160
|
+
output: num(c.output),
|
|
161
|
+
cacheRead: num(c.cacheRead),
|
|
162
|
+
cacheWrite: num(c.cacheWrite),
|
|
163
|
+
total: num(c.total),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Incremental, budget-bounded text accumulator.
|
|
170
|
+
*
|
|
171
|
+
* Appends are never stored unbounded: once `maxBytes` or `maxLines` is reached
|
|
172
|
+
* the accumulator sets {@link truncated} and silently ignores the rest.
|
|
173
|
+
*/
|
|
174
|
+
class BoundedText {
|
|
175
|
+
private readonly chunks: string[] = [];
|
|
176
|
+
private bytes = 0;
|
|
177
|
+
private newlines = 0;
|
|
178
|
+
private hasContent = false;
|
|
179
|
+
truncated = false;
|
|
180
|
+
|
|
181
|
+
private readonly maxBytes: number;
|
|
182
|
+
private readonly maxLines: number;
|
|
183
|
+
private readonly separator: string;
|
|
184
|
+
|
|
185
|
+
constructor(maxBytes: number, maxLines: number, separator: string) {
|
|
186
|
+
this.maxBytes = maxBytes;
|
|
187
|
+
this.maxLines = maxLines;
|
|
188
|
+
this.separator = separator;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
append(input: string): void {
|
|
192
|
+
if (this.truncated) return;
|
|
193
|
+
if (input.length === 0) return;
|
|
194
|
+
if (this.maxBytes <= 0 || this.maxLines <= 0) {
|
|
195
|
+
this.truncated = true;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (this.hasContent && this.separator.length > 0) {
|
|
200
|
+
if (this.bytes + 1 > this.maxBytes || this.newlines >= this.maxLines - 1) {
|
|
201
|
+
this.truncated = true;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this.chunks.push(this.separator);
|
|
205
|
+
this.bytes += 1;
|
|
206
|
+
this.newlines += 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let i = 0;
|
|
210
|
+
while (i < input.length) {
|
|
211
|
+
const nl = input.indexOf("\n", i);
|
|
212
|
+
const end = nl === -1 ? input.length : nl;
|
|
213
|
+
const segment = input.slice(i, end);
|
|
214
|
+
const room = this.maxBytes - this.bytes;
|
|
215
|
+
|
|
216
|
+
// Every code unit is at least one UTF-8 byte, so a segment longer
|
|
217
|
+
// than the remaining room is guaranteed over budget: avoid scanning
|
|
218
|
+
// giant segments in full.
|
|
219
|
+
if (segment.length > room) {
|
|
220
|
+
if (room > 0) {
|
|
221
|
+
const partial = utf8Prefix(segment, room);
|
|
222
|
+
if (partial.length > 0) {
|
|
223
|
+
this.chunks.push(detach(partial));
|
|
224
|
+
this.bytes += utf8Length(partial);
|
|
225
|
+
this.hasContent = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this.truncated = true;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const segBytes = utf8Length(segment);
|
|
233
|
+
if (this.bytes + segBytes > this.maxBytes) {
|
|
234
|
+
const partial = utf8Prefix(segment, room);
|
|
235
|
+
if (partial.length > 0) {
|
|
236
|
+
this.chunks.push(detach(partial));
|
|
237
|
+
this.bytes += utf8Length(partial);
|
|
238
|
+
this.hasContent = true;
|
|
239
|
+
}
|
|
240
|
+
this.truncated = true;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (segment.length > 0) {
|
|
245
|
+
this.chunks.push(detach(segment));
|
|
246
|
+
this.bytes += segBytes;
|
|
247
|
+
this.hasContent = true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (nl === -1) return;
|
|
251
|
+
|
|
252
|
+
if (this.newlines >= this.maxLines - 1) {
|
|
253
|
+
this.truncated = true;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (this.bytes + 1 > this.maxBytes) {
|
|
257
|
+
this.truncated = true;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
this.chunks.push("\n");
|
|
261
|
+
this.bytes += 1;
|
|
262
|
+
this.newlines += 1;
|
|
263
|
+
i = nl + 1;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
value(): string {
|
|
268
|
+
return this.chunks.length === 0 ? "" : this.chunks.join("");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Anything BoundedText/TailText can append into. */
|
|
273
|
+
interface TextSink {
|
|
274
|
+
append(input: string): void;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Raw-substring markers for the event types this collector reads. Pi writes compact
|
|
279
|
+
* JSON (`"type":"x"`, no spaces), so a line without any needle cannot be one of them;
|
|
280
|
+
* such lines skip JSON.parse entirely. Key order does not matter (scan, not prefix match).
|
|
281
|
+
*/
|
|
282
|
+
const EVENT_TYPE_NEEDLES = [
|
|
283
|
+
"session",
|
|
284
|
+
"message_start",
|
|
285
|
+
"message_update",
|
|
286
|
+
"message_end",
|
|
287
|
+
"tool_execution_start",
|
|
288
|
+
"tool_execution_end",
|
|
289
|
+
].map(type => `"type":"${type}"`);
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Bounded accumulator that keeps the most recent content (tail) instead of the head.
|
|
293
|
+
* Compaction re-slices only when the budget is exceeded; at the default few-KiB budgets
|
|
294
|
+
* the amortized cost per appended byte is O(1).
|
|
295
|
+
*/
|
|
296
|
+
class TailText {
|
|
297
|
+
private readonly maxBytes: number;
|
|
298
|
+
private readonly maxLines: number;
|
|
299
|
+
private readonly separator: string;
|
|
300
|
+
private buf = "";
|
|
301
|
+
private segments = 0;
|
|
302
|
+
truncated = false;
|
|
303
|
+
|
|
304
|
+
constructor(maxBytes: number, maxLines: number, separator: string) {
|
|
305
|
+
this.maxBytes = maxBytes;
|
|
306
|
+
this.maxLines = maxLines;
|
|
307
|
+
this.separator = separator;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
append(input: string): void {
|
|
311
|
+
if (input.length === 0) return;
|
|
312
|
+
if (this.maxBytes <= 0 || this.maxLines <= 0) {
|
|
313
|
+
this.truncated = true;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (this.segments > 0 && this.separator.length > 0) this.buf += this.separator;
|
|
317
|
+
this.segments += 1;
|
|
318
|
+
this.buf += input;
|
|
319
|
+
this.compact();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private compact(): void {
|
|
323
|
+
if (utf8Length(this.buf) > this.maxBytes) {
|
|
324
|
+
this.buf = detach(utf8Tail(this.buf, this.maxBytes));
|
|
325
|
+
this.truncated = true;
|
|
326
|
+
}
|
|
327
|
+
let newlines = 0;
|
|
328
|
+
for (let i = this.buf.indexOf("\n"); i !== -1; i = this.buf.indexOf("\n", i + 1)) newlines += 1;
|
|
329
|
+
if (newlines >= this.maxLines) {
|
|
330
|
+
const lines = this.buf.split("\n");
|
|
331
|
+
this.buf = detach(lines.slice(lines.length - this.maxLines).join("\n"));
|
|
332
|
+
this.truncated = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
value(): string {
|
|
337
|
+
return this.buf;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function normalizeLimits(overrides?: Partial<CollectorLimits>): CollectorLimits {
|
|
342
|
+
const out: CollectorLimits = { ...DEFAULT_LIMITS };
|
|
343
|
+
if (overrides) {
|
|
344
|
+
for (const key of Object.keys(DEFAULT_LIMITS) as (keyof CollectorLimits)[]) {
|
|
345
|
+
const value = overrides[key];
|
|
346
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
347
|
+
out[key] = Math.floor(value);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Best-effort short tool *name* only, e.g. `mcp__server__search` -> `search`. No tool id is tracked or matched. */
|
|
355
|
+
function shortToolName(name: unknown): string {
|
|
356
|
+
const raw = typeof name === "string" && name.length > 0 ? name : "unknown";
|
|
357
|
+
const slash = raw.lastIndexOf("/");
|
|
358
|
+
const base = slash >= 0 ? raw.slice(slash + 1) : raw;
|
|
359
|
+
const dunder = base.lastIndexOf("__");
|
|
360
|
+
return dunder >= 0 ? base.slice(dunder + 2) : base;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function appendTextContent(acc: TextSink, content: unknown): void {
|
|
364
|
+
if (typeof content === "string") {
|
|
365
|
+
acc.append(content);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (!Array.isArray(content)) return;
|
|
369
|
+
for (const part of content) {
|
|
370
|
+
if (!part || typeof part !== "object") continue;
|
|
371
|
+
const record = part as { type?: unknown; text?: unknown };
|
|
372
|
+
if (record.type === "text" && typeof record.text === "string") {
|
|
373
|
+
acc.append(record.text);
|
|
374
|
+
}
|
|
375
|
+
// Thinking, tool-args, tool results and images are intentionally ignored.
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function addUsage(target: CollectorUsage, usage: TurnUsage): void {
|
|
380
|
+
target.input += usage.input;
|
|
381
|
+
target.output += usage.output;
|
|
382
|
+
target.cacheRead += usage.cacheRead;
|
|
383
|
+
target.cacheWrite += usage.cacheWrite;
|
|
384
|
+
if (usage.totalTokens !== 0) target.totalTokens = usage.totalTokens;
|
|
385
|
+
target.cost.input += usage.cost.input;
|
|
386
|
+
target.cost.output += usage.cost.output;
|
|
387
|
+
target.cost.cacheRead += usage.cost.cacheRead;
|
|
388
|
+
target.cost.cacheWrite += usage.cost.cacheWrite;
|
|
389
|
+
target.cost.total += usage.cost.total;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Bounded, isolated collector for a single child Pi run's JSON event stream.
|
|
394
|
+
*
|
|
395
|
+
* Feed it either structured events via {@link accept} or raw child stdout via
|
|
396
|
+
* {@link pushStdout}. Only scalar metadata and bounded strings are retained.
|
|
397
|
+
*/
|
|
398
|
+
export class ChildCollector {
|
|
399
|
+
readonly limits: CollectorLimits;
|
|
400
|
+
/** Whether oversized completed answers keep their head or tail. */
|
|
401
|
+
readonly resultKeep: KeepMode;
|
|
402
|
+
|
|
403
|
+
/** Most recent completed non-empty assistant answer (bounded). */
|
|
404
|
+
finalText = "";
|
|
405
|
+
/** Current in-flight assistant answer (bounded). */
|
|
406
|
+
partialText = "";
|
|
407
|
+
/** Bounded UTF-8 tail of stderr. */
|
|
408
|
+
stderr = "";
|
|
409
|
+
errorMessage?: string;
|
|
410
|
+
model?: string;
|
|
411
|
+
sessionId?: string;
|
|
412
|
+
stopReason?: string;
|
|
413
|
+
|
|
414
|
+
readonly usage: CollectorUsage = emptyUsage();
|
|
415
|
+
toolCount = 0;
|
|
416
|
+
/** Best-effort current tool short name (no tool id retained). */
|
|
417
|
+
currentTool?: string;
|
|
418
|
+
/** Number of oversized NDJSON events dropped. */
|
|
419
|
+
droppedEvents = 0;
|
|
420
|
+
|
|
421
|
+
private partialBuffer: BoundedText;
|
|
422
|
+
private finalTruncated = false;
|
|
423
|
+
private currentTurnUsage: TurnUsage | undefined;
|
|
424
|
+
/** Set when an oversized event was dropped; may mean the answer is incomplete. */
|
|
425
|
+
private droppedTruncation = false;
|
|
426
|
+
private readonly stdoutDecoder: StringDecoder;
|
|
427
|
+
private readonly stderrDecoder: StringDecoder;
|
|
428
|
+
private buffer = "";
|
|
429
|
+
private bufferBytes = 0;
|
|
430
|
+
private dropping = false;
|
|
431
|
+
|
|
432
|
+
constructor(limits?: Partial<CollectorLimits>, options?: { resultKeep?: KeepMode }) {
|
|
433
|
+
this.limits = normalizeLimits(limits);
|
|
434
|
+
this.resultKeep = options?.resultKeep === "tail" ? "tail" : "head";
|
|
435
|
+
this.partialBuffer = new BoundedText(this.limits.resultBytes, this.limits.resultLines, "");
|
|
436
|
+
this.stdoutDecoder = new StringDecoder("utf8");
|
|
437
|
+
this.stderrDecoder = new StringDecoder("utf8");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------
|
|
441
|
+
// Answer / progress
|
|
442
|
+
// ---------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Prefers the current partial answer when present, otherwise the most
|
|
446
|
+
* recent completed non-empty assistant text.
|
|
447
|
+
*/
|
|
448
|
+
answer(): string {
|
|
449
|
+
if (this.partialText.trim().length > 0) return this.partialText.trim();
|
|
450
|
+
if (this.finalText.trim().length > 0) return this.finalText.trim();
|
|
451
|
+
return "";
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Bounded progress view (≤ progressBytes) of the current answer. */
|
|
455
|
+
progress(): string {
|
|
456
|
+
const source = this.partialText.trim().length > 0 ? this.partialText : this.finalText;
|
|
457
|
+
if (source.length === 0) return "";
|
|
458
|
+
return utf8Prefix(source, this.limits.progressBytes);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Truncation flag for the answer {@link answer} would currently return. */
|
|
462
|
+
get truncated(): boolean {
|
|
463
|
+
if (this.partialText.trim().length > 0) {
|
|
464
|
+
return this.partialBuffer.truncated || this.droppedTruncation;
|
|
465
|
+
}
|
|
466
|
+
return this.finalTruncated;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private resetPartial(): void {
|
|
470
|
+
this.partialBuffer = new BoundedText(this.limits.resultBytes, this.limits.resultLines, "");
|
|
471
|
+
this.partialText = "";
|
|
472
|
+
this.droppedTruncation = false;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---------------------------------------------------------------------
|
|
476
|
+
// Structured event ingestion
|
|
477
|
+
// ---------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
accept(event: unknown): void {
|
|
480
|
+
if (!event || typeof event !== "object") return;
|
|
481
|
+
const e = event as Record<string, any>;
|
|
482
|
+
|
|
483
|
+
switch (e.type) {
|
|
484
|
+
case "session": {
|
|
485
|
+
if (e.id !== undefined && e.id !== null) this.sessionId = capScalar(e.id, SCALAR_LIMITS.sessionId);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
case "message_start": {
|
|
489
|
+
if (e.message?.role === "assistant") {
|
|
490
|
+
// Flush usage left unresolved by an oversized/missing prior message_end
|
|
491
|
+
// before the new turn's accounting begins (no double count if it was handled).
|
|
492
|
+
if (this.currentTurnUsage) {
|
|
493
|
+
addUsage(this.usage, this.currentTurnUsage);
|
|
494
|
+
this.currentTurnUsage = undefined;
|
|
495
|
+
}
|
|
496
|
+
this.resetPartial();
|
|
497
|
+
this.errorMessage = undefined;
|
|
498
|
+
this.stopReason = undefined;
|
|
499
|
+
this.usage.turns += 1;
|
|
500
|
+
}
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
case "message_update": {
|
|
504
|
+
const streamedUsage = copyUsage(e.usage ?? e.message?.usage ?? e.assistantMessageEvent?.partial?.usage);
|
|
505
|
+
if (streamedUsage) this.currentTurnUsage = streamedUsage;
|
|
506
|
+
const delta = e.assistantMessageEvent;
|
|
507
|
+
if (delta?.type === "text_delta" && typeof delta.delta === "string") {
|
|
508
|
+
this.partialBuffer.append(delta.delta);
|
|
509
|
+
this.partialText = this.partialBuffer.value();
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
case "message_end": {
|
|
514
|
+
this.handleMessageEnd(e.message);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
case "tool_execution_start": {
|
|
518
|
+
this.toolCount += 1;
|
|
519
|
+
this.currentTool = capScalar(shortToolName(e.toolName), SCALAR_LIMITS.toolName);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
case "tool_execution_end": {
|
|
523
|
+
const name = capScalar(shortToolName(e.toolName), SCALAR_LIMITS.toolName);
|
|
524
|
+
if (this.currentTool === name) this.currentTool = undefined;
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
default:
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private handleMessageEnd(message: unknown): void {
|
|
533
|
+
if (!message || typeof message !== "object") return;
|
|
534
|
+
const m = message as Record<string, any>;
|
|
535
|
+
if (m.role !== "assistant") return;
|
|
536
|
+
|
|
537
|
+
const acc = this.resultKeep === "tail"
|
|
538
|
+
? new TailText(this.limits.resultBytes, this.limits.resultLines, "\n")
|
|
539
|
+
: new BoundedText(this.limits.resultBytes, this.limits.resultLines, "\n");
|
|
540
|
+
appendTextContent(acc, m.content);
|
|
541
|
+
const text = acc.value();
|
|
542
|
+
if (text.trim().length > 0) {
|
|
543
|
+
this.finalText = detach(text);
|
|
544
|
+
this.finalTruncated = acc.truncated;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// A completed assistant turn ends the in-flight partial.
|
|
548
|
+
this.resetPartial();
|
|
549
|
+
|
|
550
|
+
// Accumulate usage exactly once, preferring the finalized message usage.
|
|
551
|
+
const endUsage = copyUsage(m.usage);
|
|
552
|
+
if (endUsage) {
|
|
553
|
+
addUsage(this.usage, endUsage);
|
|
554
|
+
this.currentTurnUsage = undefined;
|
|
555
|
+
} else if (this.currentTurnUsage) {
|
|
556
|
+
addUsage(this.usage, this.currentTurnUsage);
|
|
557
|
+
this.currentTurnUsage = undefined;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const reportedModel = capScalar(m.model, SCALAR_LIMITS.model);
|
|
561
|
+
if (reportedModel) {
|
|
562
|
+
const provider = capScalar(m.provider, SCALAR_LIMITS.model);
|
|
563
|
+
this.model = capScalar(provider ? `${provider}/${reportedModel}` : reportedModel, SCALAR_LIMITS.model);
|
|
564
|
+
}
|
|
565
|
+
const stopReason = capScalar(m.stopReason, SCALAR_LIMITS.stopReason);
|
|
566
|
+
if (stopReason) this.stopReason = stopReason;
|
|
567
|
+
const errorMessage = capScalar(m.errorMessage, SCALAR_LIMITS.errorMessage);
|
|
568
|
+
if (errorMessage) this.errorMessage = errorMessage;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Flush any usage recorded for a turn that never received a message_end. */
|
|
572
|
+
finishUsage(): void {
|
|
573
|
+
if (this.currentTurnUsage) {
|
|
574
|
+
addUsage(this.usage, this.currentTurnUsage);
|
|
575
|
+
this.currentTurnUsage = undefined;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ---------------------------------------------------------------------
|
|
580
|
+
// Raw stdout NDJSON ingestion
|
|
581
|
+
// ---------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
pushStdout(chunk: Buffer): void {
|
|
584
|
+
const text = this.stdoutDecoder.write(chunk as any);
|
|
585
|
+
if (text.length > 0) this.appendStdout(text);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
endStdout(): void {
|
|
589
|
+
const rest = this.stdoutDecoder.end();
|
|
590
|
+
if (rest.length > 0) this.appendStdout(rest);
|
|
591
|
+
if (this.dropping) {
|
|
592
|
+
this.dropping = false;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (this.buffer.length > 0) {
|
|
596
|
+
const line = this.buffer;
|
|
597
|
+
this.buffer = "";
|
|
598
|
+
this.bufferBytes = 0;
|
|
599
|
+
this.processLine(line);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private appendStdout(text: string): void {
|
|
604
|
+
let data = text;
|
|
605
|
+
const limit = this.limits.eventBytes;
|
|
606
|
+
|
|
607
|
+
while (data.length > 0) {
|
|
608
|
+
if (this.dropping) {
|
|
609
|
+
const nl = data.indexOf("\n");
|
|
610
|
+
if (nl === -1) return;
|
|
611
|
+
data = data.slice(nl + 1);
|
|
612
|
+
this.dropping = false;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const nl = data.indexOf("\n");
|
|
617
|
+
if (nl === -1) {
|
|
618
|
+
// Incomplete line: buffer it, capping while we buffer.
|
|
619
|
+
const room = limit - this.bufferBytes;
|
|
620
|
+
if (data.length > room || this.bufferBytes + utf8Length(data) > limit) {
|
|
621
|
+
this.buffer = "";
|
|
622
|
+
this.bufferBytes = 0;
|
|
623
|
+
this.dropping = true;
|
|
624
|
+
this.droppedEvents += 1;
|
|
625
|
+
this.droppedTruncation = true;
|
|
626
|
+
} else {
|
|
627
|
+
this.buffer += detach(data);
|
|
628
|
+
this.bufferBytes += utf8Length(data);
|
|
629
|
+
}
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const head = data.slice(0, nl);
|
|
634
|
+
data = data.slice(nl + 1);
|
|
635
|
+
const room = limit - this.bufferBytes;
|
|
636
|
+
let over = false;
|
|
637
|
+
if (head.length > room) {
|
|
638
|
+
over = true;
|
|
639
|
+
} else if (this.bufferBytes + utf8Length(head) > limit) {
|
|
640
|
+
over = true;
|
|
641
|
+
}
|
|
642
|
+
if (over) {
|
|
643
|
+
this.buffer = "";
|
|
644
|
+
this.bufferBytes = 0;
|
|
645
|
+
this.droppedEvents += 1;
|
|
646
|
+
this.droppedTruncation = true;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const line = this.buffer + head;
|
|
650
|
+
this.buffer = "";
|
|
651
|
+
this.bufferBytes = 0;
|
|
652
|
+
this.processLine(line);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private processLine(line: string): void {
|
|
657
|
+
// Cheap pre-filter before JSON.parse: skip lines whose event type we never read.
|
|
658
|
+
let interesting = false;
|
|
659
|
+
for (const needle of EVENT_TYPE_NEEDLES) {
|
|
660
|
+
if (line.includes(needle)) {
|
|
661
|
+
interesting = true;
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (!interesting) return;
|
|
666
|
+
const trimmed = line.trim();
|
|
667
|
+
if (trimmed.length === 0) return;
|
|
668
|
+
let event: unknown;
|
|
669
|
+
try {
|
|
670
|
+
event = JSON.parse(trimmed);
|
|
671
|
+
} catch {
|
|
672
|
+
return; // malformed JSON is ignored, never retained
|
|
673
|
+
}
|
|
674
|
+
this.accept(event);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ---------------------------------------------------------------------
|
|
678
|
+
// Raw stderr ingestion
|
|
679
|
+
// ---------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
pushStderr(chunk: Buffer): void {
|
|
682
|
+
this.appendStderr(this.stderrDecoder.write(chunk as any));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Flush any pending bytes from the stderr decoder and re-cap the tail. */
|
|
686
|
+
endStderr(): void {
|
|
687
|
+
this.appendStderr(this.stderrDecoder.end());
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private appendStderr(text: string): void {
|
|
691
|
+
if (text.length === 0) return;
|
|
692
|
+
this.stderr += text;
|
|
693
|
+
const max = this.limits.stderrBytes;
|
|
694
|
+
if (utf8Length(this.stderr) > max) {
|
|
695
|
+
this.stderr = detach(utf8Tail(this.stderr, max));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|