pi-freeflow 1.40.0 → 1.41.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.
@@ -25,7 +25,9 @@
25
25
  * `description`/`parameters`/`strict` verbatim plus every other top-level
26
26
  * caller field, except the wire-shape-forbidden keys which are remapped:
27
27
  * chat `function` wrapper <-> flat `name`, `parameters` <-> `input_schema`,
28
- * and `type` is set per target shape. Never injects `additionalProperties`.
28
+ * and `type` is set per target shape. `strict` rides top-level on the
29
+ * Responses/Anthropic shapes and inside the chat `function` wrapper. Never
30
+ * injects `additionalProperties`.
29
31
  * - Non-function tools (e.g. Responses built-ins like { type: "web_search" })
30
32
  * pass through verbatim.
31
33
  * - `parameters` and Anthropic `input_schema` are treated as the same schema.
@@ -278,8 +280,9 @@ function schemaOf(t: Record<string, unknown>, fn: Record<string, unknown> | null
278
280
  /**
279
281
  * Normalize one wire tool to canonical form. Returns null for entries that
280
282
  * are not function tools (they must pass through verbatim, not be dropped).
281
- * Captures `strict` when the caller set a boolean (top-level only; the
282
- * function wrapper never carries it on the wire).
283
+ * Captures `strict` when the caller set a boolean, at either wire placement:
284
+ * top-level (the Responses/Kilo/Anthropic position) or inside the chat
285
+ * `function` wrapper (the canonical chat position).
283
286
  */
284
287
  export function canonicalizeTool(tool: unknown): CanonicalTool | null {
285
288
  if (typeof tool !== "object" || tool === null || Array.isArray(tool)) return null;
@@ -307,21 +310,21 @@ export function canonicalizeTool(tool: unknown): CanonicalTool | null {
307
310
  parameters: schemaOf(t, fn),
308
311
  };
309
312
  if (typeof t.strict === "boolean") canon.strict = t.strict;
313
+ else if (fn && typeof fn.strict === "boolean") canon.strict = fn.strict;
310
314
  return canon;
311
315
  }
312
316
 
313
317
  /** Canonical tool -> Chat Completions shape. Carries `strict` verbatim when set. */
314
318
  export function toChatTool(t: CanonicalTool): Record<string, unknown> {
315
- const out: Record<string, unknown> = {
316
- type: "function",
317
- function: {
318
- name: t.name,
319
- description: t.description,
320
- parameters: t.parameters,
321
- },
319
+ const fn: Record<string, unknown> = {
320
+ name: t.name,
321
+ description: t.description,
322
+ parameters: t.parameters,
322
323
  };
323
- if (typeof t.strict === "boolean") out.strict = t.strict;
324
- return out;
324
+ // The chat wire puts `strict` inside the `function` wrapper (canonical
325
+ // position); a top-level `strict` on the tool object is not the chat shape.
326
+ if (typeof t.strict === "boolean") fn.strict = t.strict;
327
+ return { type: "function", function: fn };
325
328
  }
326
329
  /** Canonical tool -> Responses shape. Carries `strict` verbatim when set. */
327
330
  export function toResponsesTool(t: CanonicalTool): Record<string, unknown> {
@@ -0,0 +1,580 @@
1
+ /**
2
+ * Tool-call audit trail: a local JSONL record of tool-call turns that failed or
3
+ * needed repair, so "why did the first attempt fail, and does this need a
4
+ * remap?" stays answerable after the fact without replaying a session.
5
+ *
6
+ * PRIVACY: argument contents, message contents and prompts are never written —
7
+ * only tool names, counts, byte lengths, finish reasons and repair kinds.
8
+ * Everything is best-effort: an audit write must never break a request.
9
+ */
10
+
11
+ import { createHash } from "node:crypto";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { resolveToolcallAuditPath } from "./config.ts";
15
+
16
+ export type ToolcallWire = "chat" | "responses" | "messages";
17
+ export type ToolcallFailure =
18
+ | "no_tool_calls"
19
+ | "unknown_tool_name"
20
+ | "malformed_arguments"
21
+ | "empty_arguments";
22
+ /** "unknown" is reserved for callers that cannot classify a turn at all. */
23
+ export type ToolcallRemap = "none" | "tool-name" | "model" | "unknown";
24
+
25
+ export interface ToolcallCall {
26
+ name: string;
27
+ argsBytes: number;
28
+ argsValid: boolean;
29
+ known: boolean;
30
+ }
31
+
32
+ export interface ToolcallRepairSink {
33
+ /** `kind` is a stable label; `detail` must stay non-content (no arguments/prompts). */
34
+ repair(kind: string, detail?: string): void;
35
+ }
36
+
37
+ export interface ToolcallStreamAudit extends ToolcallRepairSink {
38
+ /** Feed one complete SSE block; may contain several `data:` lines. */
39
+ observe(blockText: string): void;
40
+ refireSkipped(): void;
41
+ /**
42
+ * Idempotent: the first call wins. `terminal` defaults to whether a terminal
43
+ * SSE event was actually observed, so a cut stream reads as terminal: false.
44
+ */
45
+ finish(info?: { finishReason?: string | null; terminal?: boolean; unclassifiable?: boolean }): void;
46
+ }
47
+
48
+ export interface ToolcallRecorder extends ToolcallStreamAudit {
49
+ /** False when declaredNames is empty: every method is a no-op and nothing is written. */
50
+ readonly active: boolean;
51
+ attemptFailure(info: { attempt: number; status?: number; reason: string }): void;
52
+ /** Non-stream path: classify an already-assembled chat message. */
53
+ finishFromChat(message: { tool_calls?: unknown }, finishReason?: string | null): void;
54
+ }
55
+
56
+ export interface ToolcallRecorderOptions {
57
+ reqId: string;
58
+ model: string;
59
+ lane: string;
60
+ wire: ToolcallWire;
61
+ stream: boolean;
62
+ declaredNames: readonly string[];
63
+ conversationSeed?: unknown;
64
+ file?: string;
65
+ now?: () => number;
66
+ maxBytes?: number;
67
+ }
68
+
69
+ const MAX_NAME = 64;
70
+ const MAX_TEXT = 300;
71
+ const MAX_DECLARED = 32;
72
+ const MAX_CALLS = 32;
73
+ const MAX_ATTEMPT_EVENTS = 32;
74
+ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
75
+ // Arguments are bounded twice: per call (a real tool argument this large is
76
+ // already pathological) and per turn across all calls, so a hostile upstream
77
+ // cannot grow the recorder by emitting many distinct call ids. `bytes` always
78
+ // reports the true payload size; only the retained copy is capped.
79
+ const MAX_ARGS_CHARS = 256 * 1024;
80
+ const MAX_TURN_ARGS_CHARS = 8 * 1024 * 1024;
81
+ const RETRY_TTL_MS = 10 * 60_000;
82
+ const RETRY_CACHE_MAX = 256;
83
+
84
+ interface RetryEntry {
85
+ reqId: string;
86
+ ts: number;
87
+ outcome: "ok" | "failed" | "pending" | "unknown";
88
+ failures: ToolcallFailure[];
89
+ attempt: number;
90
+ repairs: string[];
91
+ }
92
+
93
+ /** Insertion-ordered: a refreshed key moves to the end and eviction takes the head. */
94
+ const retryCache = new Map<string, RetryEntry>();
95
+
96
+ interface CallAcc {
97
+ index: number;
98
+ /** Upstream id (chat `id`, responses item id, messages tool_use id). */
99
+ id: string;
100
+ name: string;
101
+ /** Accumulated arguments, bounded; `bytes` is the unbounded payload size. */
102
+ args: string;
103
+ bytes: number;
104
+ }
105
+
106
+ interface StreamState {
107
+ calls: CallAcc[];
108
+ finishReason: string | null;
109
+ terminalSeen: boolean;
110
+ /** Retained argument characters across every call this turn. */
111
+ argsChars: number;
112
+ }
113
+
114
+ function clip(value: unknown, max: number = MAX_TEXT): string {
115
+ if (typeof value !== "string") return "";
116
+ return value.length > max ? value.slice(0, max) : value;
117
+ }
118
+
119
+ function isArgsObject(args: string): boolean {
120
+ if (args === "") return false;
121
+ try {
122
+ const parsed: unknown = JSON.parse(args);
123
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed);
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Classify one turn's calls. `known` is exact-case membership in the declared
131
+ * tool set, so a case-only drift surfaces both as `known: false` and as
132
+ * `unknown_tool_name` — the host rejects a case-mismatched name, and repairing
133
+ * it is a tool-name remap, not a different model.
134
+ */
135
+ export function classifyToolcalls(
136
+ calls: readonly ToolcallCall[],
137
+ finishReason: string | null | undefined,
138
+ ): { failures: ToolcallFailure[]; remap: ToolcallRemap } {
139
+ // Reserved: truncated turns ("length") classify like any other, but callers
140
+ // keep passing the reason so a later rule can distinguish them.
141
+ void finishReason;
142
+ const failures: ToolcallFailure[] = [];
143
+ const add = (failure: ToolcallFailure): void => {
144
+ if (!failures.includes(failure)) failures.push(failure);
145
+ };
146
+ if (calls.length === 0) add("no_tool_calls");
147
+ for (const call of calls) {
148
+ if (call.name === "" || !call.known) add("unknown_tool_name");
149
+ if (call.argsBytes === 0) add("empty_arguments");
150
+ else if (!call.argsValid) add("malformed_arguments");
151
+ }
152
+ let remap: ToolcallRemap = "none";
153
+ if (failures.includes("unknown_tool_name")) remap = "tool-name";
154
+ else if (failures.length > 0) remap = "model";
155
+ return { failures, remap };
156
+ }
157
+
158
+ function newCall(index: number, id: string): CallAcc {
159
+ return { index, id, name: "", args: "", bytes: 0 };
160
+ }
161
+
162
+ /**
163
+ * Chat/messages identity is the block index, but a gateway can emit two
164
+ * parallel calls that both claim index 0 while carrying distinct ids — treat a
165
+ * differing id at the same index as a separate call instead of merging them.
166
+ */
167
+ function findByIndex(calls: CallAcc[], index: number, id: string): CallAcc | null {
168
+ for (let i = calls.length - 1; i >= 0; i--) {
169
+ const call = calls[i];
170
+ if (call.index !== index) continue;
171
+ if (id !== "" && call.id !== "" && call.id !== id) continue;
172
+ return call;
173
+ }
174
+ return null;
175
+ }
176
+
177
+ function findById(calls: CallAcc[], id: string): CallAcc | null {
178
+ if (id === "") return null;
179
+ for (let i = calls.length - 1; i >= 0; i--) {
180
+ if (calls[i].id === id) return calls[i];
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function appendArgs(state: StreamState, call: CallAcc, chunk: string): void {
186
+ if (chunk === "") return;
187
+ call.bytes += Buffer.byteLength(chunk, "utf8");
188
+ const room = Math.min(
189
+ MAX_ARGS_CHARS - call.args.length,
190
+ MAX_TURN_ARGS_CHARS - state.argsChars,
191
+ );
192
+ if (room <= 0) return;
193
+ const take = chunk.length > room ? chunk.slice(0, room) : chunk;
194
+ call.args += take;
195
+ state.argsChars += take.length;
196
+ }
197
+
198
+ /** Whole-argument frame (Responses `arguments.done`, `output_item.done`). */
199
+ function setArgs(state: StreamState, call: CallAcc, args: string): void {
200
+ call.bytes = Buffer.byteLength(args, "utf8");
201
+ state.argsChars -= call.args.length;
202
+ const room = Math.max(
203
+ 0,
204
+ Math.min(MAX_ARGS_CHARS, MAX_TURN_ARGS_CHARS - state.argsChars),
205
+ );
206
+ const take = args.length > room ? args.slice(0, room) : args;
207
+ call.args = take;
208
+ state.argsChars += take.length;
209
+ }
210
+
211
+ /** Register one call unless the per-turn call budget is already spent. */
212
+ function pushCall(state: StreamState, index: number, id: string): CallAcc | null {
213
+ if (state.calls.length >= MAX_CALLS) return null;
214
+ const call = newCall(index, id);
215
+ state.calls.push(call);
216
+ return call;
217
+ }
218
+
219
+ function asRecord(value: unknown): Record<string, unknown> | null {
220
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
221
+ }
222
+
223
+ function observeChat(evt: Record<string, unknown>, state: StreamState): void {
224
+ const choices = evt["choices"];
225
+ if (!Array.isArray(choices) || choices.length === 0) return;
226
+ const choice = asRecord(choices[0]);
227
+ if (!choice) return;
228
+ const finish = choice["finish_reason"];
229
+ if (typeof finish === "string" && finish !== "") {
230
+ state.finishReason = finish;
231
+ state.terminalSeen = true;
232
+ }
233
+ const delta = asRecord(choice["delta"]) ?? asRecord(choice["message"]);
234
+ if (!delta) return;
235
+ const tcs = delta["tool_calls"];
236
+ if (!Array.isArray(tcs)) return;
237
+ for (const raw of tcs) {
238
+ const tc = asRecord(raw);
239
+ if (!tc) continue;
240
+ const index = typeof tc["index"] === "number" ? tc["index"] : state.calls.length;
241
+ const id = clip(tc["id"], MAX_TEXT);
242
+ const fn = asRecord(tc["function"]);
243
+ const name = typeof fn?.["name"] === "string" ? (fn["name"] as string) : "";
244
+ const args = typeof fn?.["arguments"] === "string" ? (fn["arguments"] as string) : "";
245
+ let call = findByIndex(state.calls, index, id);
246
+ if (!call) {
247
+ call = pushCall(state, index, id);
248
+ if (!call) continue;
249
+ } else if (call.id === "" && id !== "") {
250
+ call.id = id;
251
+ }
252
+ if (name !== "") {
253
+ // Name fragments append — except the duplicated-name upstream defect, where a
254
+ // frame carries arguments together with the whole name and would otherwise
255
+ // glue "read" onto "read".
256
+ call.name = args === "" ? call.name + name : name;
257
+ }
258
+ if (args !== "") appendArgs(state, call, args);
259
+ }
260
+ }
261
+
262
+ function observeResponses(evt: Record<string, unknown>, state: StreamState): void {
263
+ const type = typeof evt["type"] === "string" ? (evt["type"] as string) : "";
264
+ if (type === "response.output_item.added" || type === "response.output_item.done") {
265
+ const item = asRecord(evt["item"]);
266
+ if (!item || item["type"] !== "function_call") return;
267
+ const id = item["id"] !== undefined ? clip(item["id"], MAX_TEXT) : clip(item["call_id"], MAX_TEXT);
268
+ let call = findById(state.calls, id);
269
+ if (!call) {
270
+ call = pushCall(state, state.calls.length, id);
271
+ if (!call) return;
272
+ }
273
+ const name = clip(item["name"], MAX_TEXT);
274
+ if (name !== "") call.name = name;
275
+ // The id is re-announced with the whole argument string; setArgs keeps a later
276
+ // `.done` idempotent against the deltas already accumulated.
277
+ if (typeof item["arguments"] === "string" && item["arguments"] !== "") {
278
+ setArgs(state, call, item["arguments"] as string);
279
+ }
280
+ return;
281
+ }
282
+ if (type === "response.function_call_arguments.delta") {
283
+ const id = clip(evt["item_id"], MAX_TEXT);
284
+ let call = findById(state.calls, id);
285
+ if (!call) {
286
+ call = pushCall(state, state.calls.length, id);
287
+ if (!call) return;
288
+ }
289
+ if (typeof evt["delta"] === "string") appendArgs(state, call, evt["delta"] as string);
290
+ return;
291
+ }
292
+ if (type === "response.function_call_arguments.done") {
293
+ const id = clip(evt["item_id"], MAX_TEXT);
294
+ let call = findById(state.calls, id);
295
+ if (!call) {
296
+ call = pushCall(state, state.calls.length, id);
297
+ if (!call) return;
298
+ }
299
+ if (typeof evt["arguments"] === "string") setArgs(state, call, evt["arguments"] as string);
300
+ return;
301
+ }
302
+ if (
303
+ type === "response.completed" ||
304
+ type === "response.incomplete" ||
305
+ type === "response.failed"
306
+ ) {
307
+ const response = asRecord(evt["response"]);
308
+ const status = clip(response?.["status"], MAX_TEXT);
309
+ state.finishReason = status !== "" ? status : type.slice("response.".length);
310
+ state.terminalSeen = true;
311
+ }
312
+ }
313
+
314
+ function observeMessages(evt: Record<string, unknown>, state: StreamState): void {
315
+ const type = typeof evt["type"] === "string" ? (evt["type"] as string) : "";
316
+ const index = typeof evt["index"] === "number" ? (evt["index"] as number) : state.calls.length;
317
+ if (type === "content_block_start") {
318
+ const block = asRecord(evt["content_block"]);
319
+ if (!block || block["type"] !== "tool_use") return;
320
+ const id = clip(block["id"], MAX_TEXT);
321
+ let call = findByIndex(state.calls, index, id);
322
+ if (!call) {
323
+ call = pushCall(state, index, id);
324
+ if (!call) return;
325
+ }
326
+ const name = clip(block["name"], MAX_TEXT);
327
+ if (name !== "") call.name = name;
328
+ return;
329
+ }
330
+ if (type === "content_block_delta") {
331
+ const delta = asRecord(evt["delta"]);
332
+ if (!delta || delta["type"] !== "input_json_delta") return;
333
+ let call = findByIndex(state.calls, index, "");
334
+ if (!call) {
335
+ call = pushCall(state, index, "");
336
+ if (!call) return;
337
+ }
338
+ if (typeof delta["partial_json"] === "string") {
339
+ appendArgs(state, call, delta["partial_json"] as string);
340
+ }
341
+ return;
342
+ }
343
+ if (type === "message_delta") {
344
+ const delta = asRecord(evt["delta"]);
345
+ const stop = clip(delta?.["stop_reason"], MAX_TEXT);
346
+ if (stop !== "") {
347
+ state.finishReason = stop;
348
+ state.terminalSeen = true;
349
+ }
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Create the audit recorder for one turn. Other modules observe SSE blocks,
355
+ * report repairs and call finish() once; this module owns all of the
356
+ * interpretation, bounding and persistence.
357
+ */
358
+ export function createToolcallRecorder(opts: ToolcallRecorderOptions): ToolcallRecorder {
359
+ const active = opts.declaredNames.length > 0;
360
+ const now = opts.now ?? Date.now;
361
+ const maxBytes =
362
+ typeof opts.maxBytes === "number" && opts.maxBytes > 0 ? opts.maxBytes : DEFAULT_MAX_BYTES;
363
+ const declaredExact = new Set(opts.declaredNames);
364
+ const declaredSummary = opts.declaredNames.slice(0, MAX_DECLARED).map((name) => clip(name, MAX_NAME));
365
+ const state: StreamState = { calls: [], finishReason: null, terminalSeen: false, argsChars: 0 };
366
+ const repairs: Array<{ kind: string; detail?: string }> = [];
367
+ const seedKey =
368
+ !active || opts.conversationSeed === undefined ? "" : seedOf(opts.conversationSeed);
369
+ const createdAt = now();
370
+ const priorEntry = seedKey === "" ? undefined : retryCache.get(seedKey);
371
+ const prior =
372
+ priorEntry && createdAt - priorEntry.ts >= 0 && createdAt - priorEntry.ts < RETRY_TTL_MS
373
+ ? priorEntry
374
+ : undefined;
375
+ const attempt = prior ? prior.attempt + 1 : 1;
376
+ const retryOf = prior ? prior.reqId : null;
377
+ // Register the in-flight turn so a host re-send that races this turn's
378
+ // teardown still chains to it instead of starting a second attempt 1.
379
+ if (seedKey !== "") {
380
+ retryCache.delete(seedKey);
381
+ retryCache.set(seedKey, {
382
+ reqId: clip(opts.reqId, MAX_TEXT),
383
+ ts: createdAt,
384
+ outcome: "pending",
385
+ failures: [],
386
+ attempt,
387
+ repairs: [],
388
+ });
389
+ while (retryCache.size > RETRY_CACHE_MAX) {
390
+ const oldest = retryCache.keys().next();
391
+ if (oldest.done) break;
392
+ retryCache.delete(oldest.value);
393
+ }
394
+ }
395
+ let refireSkip = false;
396
+ let attemptEvents = 0;
397
+ let finished = false;
398
+
399
+ const append = (event: Record<string, unknown>): void => {
400
+ try {
401
+ const target = opts.file ?? resolveToolcallAuditPath();
402
+ try {
403
+ if (fs.statSync(target).size > maxBytes) fs.renameSync(target, `${target}.1`);
404
+ } catch {
405
+ // Missing file or a locked rename: the append is still worth doing.
406
+ }
407
+ fs.mkdirSync(path.dirname(target), { recursive: true });
408
+ fs.appendFileSync(target, `${JSON.stringify(event)}\n`, "utf8");
409
+ } catch {
410
+ // Audit is best-effort by contract.
411
+ }
412
+ };
413
+
414
+ const complete = (
415
+ finishReason: string | null,
416
+ terminal: boolean,
417
+ unclassifiable = false,
418
+ ): void => {
419
+ if (finished) return;
420
+ finished = true;
421
+ const calls = state.calls.slice(0, MAX_CALLS).map((call) => ({
422
+ name: clip(call.name, MAX_NAME),
423
+ argsBytes: call.bytes,
424
+ argsValid: isArgsObject(call.args),
425
+ known: declaredExact.has(call.name),
426
+ }));
427
+ // A body that never carried an event stream says nothing about the model:
428
+ // report it as unknown instead of accusing it of answering without tools.
429
+ const classified = unclassifiable
430
+ ? { failures: [] as ToolcallFailure[], remap: "unknown" as const }
431
+ : classifyToolcalls(calls, finishReason);
432
+ append({
433
+ ts: now(),
434
+ kind: "summary",
435
+ reqId: clip(opts.reqId, MAX_TEXT),
436
+ model: clip(opts.model, MAX_TEXT),
437
+ lane: clip(opts.lane, MAX_TEXT),
438
+ wire: opts.wire,
439
+ stream: opts.stream,
440
+ attempt,
441
+ retryOf,
442
+ declared: declaredSummary,
443
+ calls,
444
+ finishReason: finishReason === null ? null : clip(finishReason, MAX_TEXT),
445
+ terminal,
446
+ refireSkipped: refireSkip,
447
+ repairs: repairs.slice(),
448
+ failures: classified.failures,
449
+ remap: classified.remap,
450
+ });
451
+ if (seedKey !== "") {
452
+ const current = retryCache.get(seedKey);
453
+ // An earlier turn may finish after its own host re-send already chained;
454
+ // never let it overwrite the newer attempt's record.
455
+ if (current === undefined || attempt >= current.attempt) {
456
+ retryCache.delete(seedKey);
457
+ retryCache.set(seedKey, {
458
+ reqId: clip(opts.reqId, MAX_TEXT),
459
+ ts: now(),
460
+ outcome: unclassifiable
461
+ ? "unknown"
462
+ : classified.failures.length === 0
463
+ ? "ok"
464
+ : "failed",
465
+ failures: classified.failures,
466
+ attempt,
467
+ repairs: repairs.map((repair) => repair.kind),
468
+ });
469
+ }
470
+ while (retryCache.size > RETRY_CACHE_MAX) {
471
+ const oldest = retryCache.keys().next();
472
+ if (oldest.done) break;
473
+ retryCache.delete(oldest.value);
474
+ }
475
+ }
476
+ };
477
+
478
+ if (active && prior) {
479
+ append({
480
+ ts: createdAt,
481
+ kind: "retry-detected",
482
+ reqId: clip(opts.reqId, MAX_TEXT),
483
+ model: clip(opts.model, MAX_TEXT),
484
+ lane: clip(opts.lane, MAX_TEXT),
485
+ attempt,
486
+ retryOf,
487
+ firstOutcome: prior.outcome,
488
+ firstFailures: prior.failures.slice(),
489
+ firstRepairs: prior.repairs.slice(),
490
+ });
491
+ }
492
+
493
+ return {
494
+ active,
495
+ observe(blockText: string): void {
496
+ if (!active) return;
497
+ const text = typeof blockText === "string" ? blockText : "";
498
+ for (const raw of text.split("\n")) {
499
+ const line = raw.trim();
500
+ if (!line.startsWith("data:")) continue;
501
+ const payload = line.slice(5).trim();
502
+ if (payload === "" || payload === "[DONE]") continue;
503
+ let evt: Record<string, unknown> | null;
504
+ try {
505
+ evt = asRecord(JSON.parse(payload));
506
+ } catch {
507
+ continue;
508
+ }
509
+ if (!evt) continue;
510
+ if (opts.wire === "chat") observeChat(evt, state);
511
+ else if (opts.wire === "responses") observeResponses(evt, state);
512
+ else observeMessages(evt, state);
513
+ }
514
+ },
515
+ repair(kind: string, detail?: string): void {
516
+ if (!active) return;
517
+ const key = clip(kind, MAX_NAME);
518
+ if (key === "" || repairs.some((repair) => repair.kind === key)) return;
519
+ const entry: { kind: string; detail?: string } = { kind: key };
520
+ const bounded = clip(detail, MAX_NAME);
521
+ if (bounded !== "") entry.detail = bounded;
522
+ repairs.push(entry);
523
+ },
524
+ refireSkipped(): void {
525
+ if (!active) return;
526
+ refireSkip = true;
527
+ },
528
+ finish(info?: { finishReason?: string | null; terminal?: boolean; unclassifiable?: boolean }): void {
529
+ if (!active) return;
530
+ complete(
531
+ info && info.finishReason !== undefined ? info.finishReason : state.finishReason,
532
+ info && info.terminal !== undefined ? info.terminal : state.terminalSeen,
533
+ info?.unclassifiable === true,
534
+ );
535
+ },
536
+ attemptFailure(info: { attempt: number; status?: number; reason: string }): void {
537
+ if (!active || attemptEvents >= MAX_ATTEMPT_EVENTS) return;
538
+ attemptEvents++;
539
+ const event: Record<string, unknown> = {
540
+ ts: now(),
541
+ kind: "attempt-failure",
542
+ reqId: clip(opts.reqId, MAX_TEXT),
543
+ model: clip(opts.model, MAX_TEXT),
544
+ lane: clip(opts.lane, MAX_TEXT),
545
+ attempt: typeof info?.attempt === "number" ? info.attempt : attempt,
546
+ reason: clip(info?.reason, MAX_TEXT),
547
+ };
548
+ if (typeof info?.status === "number") event["status"] = info.status;
549
+ append(event);
550
+ },
551
+ finishFromChat(message: { tool_calls?: unknown }, finishReason?: string | null): void {
552
+ if (!active || finished) return;
553
+ const tcs = message?.tool_calls;
554
+ if (Array.isArray(tcs)) {
555
+ for (const raw of tcs) {
556
+ const tc = asRecord(raw);
557
+ if (!tc) continue;
558
+ const fn = asRecord(tc["function"]);
559
+ const call = pushCall(state, state.calls.length, clip(tc["id"], MAX_TEXT));
560
+ if (!call) break;
561
+ call.name = clip(fn?.["name"], MAX_NAME);
562
+ setArgs(state, call, typeof fn?.["arguments"] === "string" ? (fn["arguments"] as string) : "");
563
+ }
564
+ }
565
+ const reason = finishReason !== undefined ? finishReason : state.finishReason;
566
+ complete(reason, reason !== null && reason !== "" ? true : state.terminalSeen);
567
+ },
568
+ };
569
+ }
570
+
571
+ /** Conversation key for retry correlation: first 16 hex of sha256(seed). */
572
+ function seedOf(seed: unknown): string {
573
+ try {
574
+ const json = JSON.stringify(seed);
575
+ if (json === undefined) return "";
576
+ return createHash("sha256").update(json, "utf8").digest("hex").slice(0, 16);
577
+ } catch {
578
+ return "";
579
+ }
580
+ }