pi-mega-compact 0.8.24 → 0.8.26
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/README.md +26 -0
- package/dist/extensions/mega-compact-s38.test.js +263 -14
- package/dist/extensions/mega-compact.js +15 -0
- package/dist/extensions/mega-config.js +3 -0
- package/dist/extensions/mega-events/agent-handlers.js +211 -26
- package/dist/extensions/mega-events/context-handler.js +45 -7
- package/dist/extensions/mega-events/error-classifier.js +125 -18
- package/dist/extensions/mega-pipeline/compact.js +24 -13
- package/dist/extensions/mega-pipeline/recall.js +31 -2
- package/dist/extensions/mega-runtime/dashboard-snapshot.js +4 -0
- package/dist/extensions/mega-runtime/runtime-snapshot.js +4 -0
- package/dist/extensions/mega-runtime/runtime.js +58 -5
- package/dist/src/boundary.js +79 -43
- package/dist/src/boundary.test.js +119 -2
- package/dist/src/canary.js +10 -0
- package/dist/src/config/dedup.js +14 -0
- package/dist/src/config.js +3 -1
- package/dist/src/dedup/raptor/buildHistory.js +164 -0
- package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
- package/dist/src/dedup/raptor/index.js +38 -0
- package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
- package/dist/src/dedup/raptor/multilevel.js +17 -5
- package/dist/src/dedup/raptor/multilevel.test.js +36 -1
- package/dist/src/dedup/raptor/raptor.test.js +43 -0
- package/dist/src/dedup/raptor/retrieval.js +14 -2
- package/dist/src/dedup/raptor/retrieval.test.js +95 -0
- package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
- package/dist/src/dedup/raptor/summarizer.js +1 -0
- package/dist/src/dedup/raptor/tree.js +16 -2
- package/dist/src/engine.js +18 -2
- package/dist/src/httpEmbedder.js +96 -6
- package/dist/src/httpEmbedder.test.js +277 -0
- package/dist/src/mechanical-fix.test.js +65 -0
- package/dist/src/raptor-inject-summaries.test.js +162 -0
- package/dist/src/recall.js +153 -24
- package/dist/src/recall.test.js +179 -4
- package/dist/src/store/sqlite/dedup-mirror.js +32 -15
- package/dist/src/store/sqlite/maintenance.js +2 -2
- package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
- package/dist/src/store/sqlite/memories.js +5 -5
- package/dist/src/store/sqlite/meta.js +1 -1
- package/dist/src/store/sqlite/raptor.js +56 -17
- package/dist/src/store/sqlite/raptor.test.js +106 -0
- package/dist/src/store/sqlite/schema.js +90 -1
- package/dist/src/store/sqlite/session-state.js +9 -3
- package/dist/src/store/sqlite/stats.js +9 -5
- package/dist/src/store/sqlite/turns.js +179 -0
- package/dist/src/store/sqlite/turns.test.js +183 -0
- package/dist/src/store/sqlite/utils.js +15 -4
- package/dist/src/store/sqlite.js +1 -0
- package/dist/src/store.js +2 -2
- package/dist/src/vector-search-cache.test.js +157 -0
- package/dist/src/vector-search.js +107 -15
- package/dist/src/vectorStore.js +36 -8
- package/extensions/mega-compact-s38.test.ts +259 -14
- package/extensions/mega-compact.ts +15 -0
- package/extensions/mega-config.ts +18 -0
- package/extensions/mega-dashboard.ts +10 -1
- package/extensions/mega-events/agent-handlers.ts +211 -26
- package/extensions/mega-events/context-handler.ts +43 -7
- package/extensions/mega-events/error-classifier.ts +125 -17
- package/extensions/mega-pipeline/compact.ts +28 -16
- package/extensions/mega-pipeline/recall.ts +34 -2
- package/extensions/mega-runtime/dashboard-snapshot.ts +8 -0
- package/extensions/mega-runtime/helpers.ts +25 -1
- package/extensions/mega-runtime/runtime-snapshot.ts +4 -0
- package/extensions/mega-runtime/runtime.ts +69 -23
- package/package.json +1 -1
- package/src/boundary.test.ts +128 -2
- package/src/boundary.ts +75 -39
- package/src/canary.ts +10 -0
- package/src/config/dedup.ts +25 -0
- package/src/config.ts +3 -1
- package/src/dedup/raptor/buildHistory.test.ts +353 -0
- package/src/dedup/raptor/buildHistory.ts +259 -0
- package/src/dedup/raptor/index.ts +38 -0
- package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
- package/src/dedup/raptor/multilevel.test.ts +47 -0
- package/src/dedup/raptor/multilevel.ts +18 -8
- package/src/dedup/raptor/raptor.test.ts +59 -0
- package/src/dedup/raptor/retrieval.test.ts +118 -0
- package/src/dedup/raptor/retrieval.ts +14 -2
- package/src/dedup/raptor/serve-gate.test.ts +348 -0
- package/src/dedup/raptor/summarizer.ts +1 -0
- package/src/dedup/raptor/tree.ts +17 -2
- package/src/engine.ts +32 -3
- package/src/httpEmbedder.test.ts +286 -0
- package/src/httpEmbedder.ts +98 -8
- package/src/mechanical-fix.test.ts +70 -0
- package/src/raptor-inject-summaries.test.ts +228 -0
- package/src/recall.test.ts +220 -4
- package/src/recall.ts +462 -265
- package/src/store/sqlite/dedup-mirror.ts +35 -18
- package/src/store/sqlite/maintenance.ts +2 -2
- package/src/store/sqlite/mechanical-fix.test.ts +162 -0
- package/src/store/sqlite/memories.ts +5 -5
- package/src/store/sqlite/meta.ts +1 -1
- package/src/store/sqlite/raptor.test.ts +139 -0
- package/src/store/sqlite/raptor.ts +135 -81
- package/src/store/sqlite/schema.ts +90 -1
- package/src/store/sqlite/session-state.ts +9 -3
- package/src/store/sqlite/stats.ts +10 -8
- package/src/store/sqlite/turns.test.ts +218 -0
- package/src/store/sqlite/turns.ts +302 -0
- package/src/store/sqlite/utils.ts +14 -4
- package/src/store/sqlite.ts +1 -0
- package/src/store.ts +9 -2
- package/src/vector-search-cache.test.ts +190 -0
- package/src/vector-search.ts +273 -156
- package/src/vectorStore.ts +443 -382
- package/extensions/mega-runtime/reset-runtime.ts +0 -80
|
@@ -146,12 +146,21 @@ export interface DashboardSnapshot {
|
|
|
146
146
|
liveTrimFires: number;
|
|
147
147
|
liveTrimReplays: number;
|
|
148
148
|
};
|
|
149
|
-
/** S38.8: error-retry state for dashboard "retries" tile.
|
|
149
|
+
/** S38.8: error-retry state for dashboard "retries" tile.
|
|
150
|
+
* R7 (retry redesign): sessionRetryCount / sessionMax / poisonedCount are
|
|
151
|
+
* ADDITIVE fields — older dashboards that don't read them stay
|
|
152
|
+
* backward-compatible. */
|
|
150
153
|
retries?: {
|
|
151
154
|
errorRetryCount: number;
|
|
152
155
|
consecutiveErrors: number;
|
|
153
156
|
maxConsecutiveErrors: number;
|
|
154
157
|
errorRetryHardStop: boolean;
|
|
158
|
+
/** R2: total S38 nudges fired this session across ALL bursts. */
|
|
159
|
+
sessionRetryCount: number;
|
|
160
|
+
/** R2: session-global cap (errorRetrySessionMax). 0 = disabled. */
|
|
161
|
+
sessionMax: number;
|
|
162
|
+
/** R3/R7: number of poisoned-context events this session. */
|
|
163
|
+
poisonedCount: number;
|
|
155
164
|
};
|
|
156
165
|
}
|
|
157
166
|
|
|
@@ -14,11 +14,12 @@ import {
|
|
|
14
14
|
memoryReviewCadence,
|
|
15
15
|
type MegaConfig,
|
|
16
16
|
} from "../mega-config.js";
|
|
17
|
-
import { recordScore } from "../../src/store/sqlite.js";
|
|
17
|
+
import { recordScore, recordTurn } from "../../src/store/sqlite.js";
|
|
18
18
|
import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
|
|
19
|
+
import { ensureConversationId } from "../../src/store/sqlite/turns.js";
|
|
19
20
|
import { isMegaCache } from "../../src/game/scoring.js";
|
|
20
21
|
import { resolveRepoRoot } from "../mega-config.js";
|
|
21
|
-
import { classifyError } from "./error-classifier.js";
|
|
22
|
+
import { classifyError, errorRetryBackoffMs, extractErrorSignature } from "./error-classifier.js";
|
|
22
23
|
import { safeSendUserMessage } from "./send-safe.js";
|
|
23
24
|
import { vectorStats } from "../../src/vectorStore.js";
|
|
24
25
|
|
|
@@ -230,6 +231,15 @@ export function registerAgentHandlers(
|
|
|
230
231
|
runtime.currentTurn = event.turnIndex;
|
|
231
232
|
runtime.rt.lengthStopPending = false; // S28: re-arm defensively each user turn
|
|
232
233
|
runtime.rt.errorRetryCount = 0; // S38: reset error-retry counter each user turn
|
|
234
|
+
// R4 (turn_end hygiene): a genuine new user prompt consumes any queued
|
|
235
|
+
// retry nudge (deliverAs:'followUp') — pi dispatches it as the prompt for
|
|
236
|
+
// this turn. Clearing retryNudgePending re-arms the dedup gate so the
|
|
237
|
+
// next error turn can fire a fresh nudge (subject to backoff).
|
|
238
|
+
// NOTE: the poisoned-repeat tracker (lastErrorText / errorTextRepeatCount)
|
|
239
|
+
// is NOT reset here — a retry turn consuming the queued nudge is still the
|
|
240
|
+
// same error sequence. The tracker resets on a SUCCESSFUL turn (null) or
|
|
241
|
+
// when a different error text appears, not on the turn boundary.
|
|
242
|
+
runtime.rt.retryNudgePending = false;
|
|
233
243
|
runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
|
|
234
244
|
runtime.snapshot(ctx);
|
|
235
245
|
});
|
|
@@ -238,6 +248,26 @@ export function registerAgentHandlers(
|
|
|
238
248
|
runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
|
|
239
249
|
runtime.snapshot(ctx);
|
|
240
250
|
|
|
251
|
+
// S43 (per-turn tracking): record one turn row with the cached metrics so
|
|
252
|
+
// the turn layer is queryable + forkable. Best-effort + non-fatal: a write
|
|
253
|
+
// failure never breaks the agent loop.
|
|
254
|
+
try {
|
|
255
|
+
const convId = ensureConversationId(runtime.rt.sessionId, runtime.currentStateDir);
|
|
256
|
+
recordTurn({
|
|
257
|
+
conversationId: convId,
|
|
258
|
+
sessionId: runtime.rt.sessionId,
|
|
259
|
+
turnIndex: event.turnIndex,
|
|
260
|
+
endedAt: Date.now(),
|
|
261
|
+
startedAt: undefined,
|
|
262
|
+
ctxTokens: runtime.lastCtxTokens ?? undefined,
|
|
263
|
+
ctxPercent: runtime.lastCtxPercent ?? undefined,
|
|
264
|
+
pressureBand: runtime.pressureBand ?? undefined,
|
|
265
|
+
modelId: runtime.currentModel?.modelId ?? undefined,
|
|
266
|
+
}, runtime.currentStateDir);
|
|
267
|
+
} catch {
|
|
268
|
+
/* non-fatal: per-turn tracking never breaks the agent loop */
|
|
269
|
+
}
|
|
270
|
+
|
|
241
271
|
// S33: game-mode scoring — record turns + cache metrics per repo, and arm
|
|
242
272
|
// the MEGA CACHE flare (oopsie gag) when the real dedup hit rate exceeds
|
|
243
273
|
// 100%. Gated behind game_mode_on (no scoring when off). Best-effort +
|
|
@@ -319,9 +349,11 @@ export function registerAgentHandlers(
|
|
|
319
349
|
|
|
320
350
|
// S38: broader error-retry safety net. S28 only catches stopReason==='length';
|
|
321
351
|
// this catches ALL other error types (provider failure, network timeout, 5xx,
|
|
322
|
-
// 429, auth, compaction-noop) that surface at turn_end.
|
|
323
|
-
// try/catch so a classifier/retry failure never breaks
|
|
324
|
-
// PREVENT-PI-003: retry nudge fires via pi.sendUserMessage
|
|
352
|
+
// 429, auth, compaction-noop, poisoned-context) that surface at turn_end.
|
|
353
|
+
// Non-fatal: wrapped in try/catch so a classifier/retry failure never breaks
|
|
354
|
+
// the agent loop. PREVENT-PI-003: retry nudge fires via pi.sendUserMessage
|
|
355
|
+
// (user-role). R1-R4 (retry redesign) bound the loop: in-flight dedup,
|
|
356
|
+
// gating backoff, session-global cap, poisoned-context detection.
|
|
325
357
|
try {
|
|
326
358
|
// (1) S28 owns length — skip the classifier entirely for it.
|
|
327
359
|
const sr = (event.message as { stopReason?: string } | undefined)?.stopReason;
|
|
@@ -329,17 +361,51 @@ export function registerAgentHandlers(
|
|
|
329
361
|
// S28 handles; nothing for S38 to do here.
|
|
330
362
|
} else {
|
|
331
363
|
const category = classifyError(event.message);
|
|
332
|
-
|
|
364
|
+
// R3: stateful poisoned-context signal — repeated identical error text
|
|
365
|
+
// across consecutive turns. The classifier is stateless, so this
|
|
366
|
+
// upgrade happens here. A 'transient' (or 'poisoned-context') turn
|
|
367
|
+
// whose normalized error signature matches the previous one bumps
|
|
368
|
+
// errorTextRepeatCount; once it crosses the threshold, the category
|
|
369
|
+
// is upgraded to 'poisoned-context' (the request is deterministic).
|
|
370
|
+
// Conservative: only upgrade, never downgrade, and only for transient
|
|
371
|
+
// (a 'permanent' auth error repeating is still permanent).
|
|
372
|
+
let effectiveCategory = category;
|
|
373
|
+
const errSig = extractErrorSignature(event.message);
|
|
374
|
+
if (category === 'transient' || category === 'poisoned-context') {
|
|
375
|
+
if (errSig) {
|
|
376
|
+
if (runtime.rt.lastErrorText === errSig) {
|
|
377
|
+
runtime.rt.errorTextRepeatCount++;
|
|
378
|
+
} else {
|
|
379
|
+
runtime.rt.lastErrorText = errSig;
|
|
380
|
+
runtime.rt.errorTextRepeatCount = 1;
|
|
381
|
+
}
|
|
382
|
+
if (
|
|
383
|
+
runtime.rt.errorTextRepeatCount >= config.poisonedContextRepeatThreshold &&
|
|
384
|
+
category === 'transient'
|
|
385
|
+
) {
|
|
386
|
+
effectiveCategory = 'poisoned-context';
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
// Non-transient (success, cancelled, compaction-noop, context-overflow,
|
|
391
|
+
// permanent): a fresh non-identical signal clears the repeat tracker.
|
|
392
|
+
runtime.rt.lastErrorText = undefined;
|
|
393
|
+
runtime.rt.errorTextRepeatCount = 0;
|
|
394
|
+
}
|
|
395
|
+
if (effectiveCategory === null) {
|
|
333
396
|
// (3) success / normal flow / unknown-but-non-retryable — reset.
|
|
334
397
|
runtime.rt.errorRetryCount = 0;
|
|
335
398
|
runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset on success
|
|
336
|
-
|
|
399
|
+
// R4: a successful assistant turn consumes any queued nudge.
|
|
400
|
+
runtime.rt.retryNudgePending = false;
|
|
401
|
+
} else if (effectiveCategory === 'compaction-noop') {
|
|
337
402
|
// (4) pi race / manual compact catch — NOT retryable. The compaction
|
|
338
403
|
// already succeeded via pi's native path; retrying would race again
|
|
339
404
|
// (FAIL-2026071701). Log a diagnostic, reset the counter, and surface
|
|
340
405
|
// the original error WITHOUT firing a retry nudge.
|
|
341
406
|
runtime.rt.errorRetryCount = 0;
|
|
342
407
|
runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset
|
|
408
|
+
runtime.rt.retryNudgePending = false; // R4: terminal for this burst
|
|
343
409
|
runtime.dashboard.event('compaction_noop_diagnostic', {
|
|
344
410
|
turnIndex: event.turnIndex,
|
|
345
411
|
sessionId: runtime.rt.sessionId,
|
|
@@ -348,13 +414,14 @@ export function registerAgentHandlers(
|
|
|
348
414
|
sessionId: runtime.rt.sessionId,
|
|
349
415
|
turnIndex: event.turnIndex,
|
|
350
416
|
});
|
|
351
|
-
} else if (
|
|
417
|
+
} else if (effectiveCategory === 'cancelled') {
|
|
352
418
|
// (4c) User ESC / Ctrl-C — stopReason === 'aborted'. NOT retryable:
|
|
353
419
|
// nudging would restart a task the user explicitly stopped.
|
|
354
420
|
// Reset both counters (a cancel is not an error for circuit-breaker
|
|
355
421
|
// purposes). Emit a diagnostic so the dashboard shows it.
|
|
356
422
|
runtime.rt.errorRetryCount = 0;
|
|
357
423
|
runtime.rt.consecutiveErrors = 0;
|
|
424
|
+
runtime.rt.retryNudgePending = false; // R4: terminal for this burst
|
|
358
425
|
runtime.dashboard.event('error_retry_cancelled', {
|
|
359
426
|
turnIndex: event.turnIndex,
|
|
360
427
|
sessionId: runtime.rt.sessionId,
|
|
@@ -363,7 +430,7 @@ export function registerAgentHandlers(
|
|
|
363
430
|
sessionId: runtime.rt.sessionId,
|
|
364
431
|
turnIndex: event.turnIndex,
|
|
365
432
|
});
|
|
366
|
-
} else if (
|
|
433
|
+
} else if (effectiveCategory === 'context-overflow') {
|
|
367
434
|
// (4b) context-window overflow 400 ("too long... even after compaction").
|
|
368
435
|
// NOT a blind retry: re-submitting the same oversized prompt would just
|
|
369
436
|
// re-400 and busy-loop. Reset the counters (this turn is terminal, not
|
|
@@ -376,6 +443,7 @@ export function registerAgentHandlers(
|
|
|
376
443
|
// separate nudge here.
|
|
377
444
|
runtime.rt.errorRetryCount = 0;
|
|
378
445
|
runtime.rt.consecutiveErrors = 0;
|
|
446
|
+
runtime.rt.retryNudgePending = false; // R4: terminal for this burst
|
|
379
447
|
runtime.dashboard.event('context_overflow', {
|
|
380
448
|
turnIndex: event.turnIndex,
|
|
381
449
|
sessionId: runtime.rt.sessionId,
|
|
@@ -405,13 +473,76 @@ export function registerAgentHandlers(
|
|
|
405
473
|
}, 500);
|
|
406
474
|
}
|
|
407
475
|
}
|
|
476
|
+
} else if (effectiveCategory === 'poisoned-context') {
|
|
477
|
+
// R3: poisoned context — a DETERMINISTIC request-rejection that
|
|
478
|
+
// retrying cannot fix (0-token 'error', generic "request failed",
|
|
479
|
+
// 400 non-overflow, or repeated identical error text). NO blind
|
|
480
|
+
// retry nudge — re-submitting the same poisoned prompt re-triggers
|
|
481
|
+
// the same rejection (the 2026-07-28 incident: ~60-message spam).
|
|
482
|
+
// Instead: (a) dashboard event + warn log naming the suspected
|
|
483
|
+
// poison; (b) ONE user-role advise message (/clear or /new) per
|
|
484
|
+
// session, throttled; (c) if config.auto, ONE guarded compact per
|
|
485
|
+
// error signature (same race-guarded deferred path as context-
|
|
486
|
+
// overflow) — only if not already attempted for this signature.
|
|
487
|
+
runtime.rt.errorRetryCount = 0; // poisoned is terminal for this burst
|
|
488
|
+
runtime.rt.consecutiveErrors++; // still counts toward the circuit breaker
|
|
489
|
+
runtime.rt.poisonedCount++; // R7: dashboard counter
|
|
490
|
+
const sig = errSig || 'unknown';
|
|
491
|
+
// (a) dashboard + log
|
|
492
|
+
runtime.dashboard.event('poisoned_context', {
|
|
493
|
+
signature: sig,
|
|
494
|
+
repeatCount: runtime.rt.errorTextRepeatCount,
|
|
495
|
+
turnIndex: event.turnIndex,
|
|
496
|
+
sessionId: runtime.rt.sessionId,
|
|
497
|
+
});
|
|
498
|
+
runtime.logger.warn('poisoned-context', {
|
|
499
|
+
sessionId: runtime.rt.sessionId,
|
|
500
|
+
turnIndex: event.turnIndex,
|
|
501
|
+
signature: sig,
|
|
502
|
+
repeatCount: runtime.rt.errorTextRepeatCount,
|
|
503
|
+
});
|
|
504
|
+
// (b) one-per-session advise message (throttled by poisonedAdviseSent)
|
|
505
|
+
if (!runtime.rt.poisonedAdviseSent) {
|
|
506
|
+
runtime.rt.poisonedAdviseSent = true;
|
|
507
|
+
// PREVENT-PI-003: user-role sendUserMessage only.
|
|
508
|
+
await safeSendUserMessage(
|
|
509
|
+
pi,
|
|
510
|
+
'[mega-compact] this session\'s context may be poisoned (the provider is rejecting every request). Run /clear or /new to start a fresh context.',
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
// (c) one guarded compact per error signature (attempt to remove
|
|
514
|
+
// the poisoned region). Race-guarded + deferred, mirroring the
|
|
515
|
+
// context-overflow path. Only if not already attempted for this
|
|
516
|
+
// signature (poisonedCompactSignatures).
|
|
517
|
+
if (config.auto && !runtime.rt.poisonedCompactSignatures.has(sig)) {
|
|
518
|
+
runtime.rt.poisonedCompactSignatures.add(sig);
|
|
519
|
+
const nowP = Date.now();
|
|
520
|
+
const cooldownMsP = config.raceGuardStrict ? 30_000 : 10_000;
|
|
521
|
+
const sinceCompactP = nowP - (runtime.rt.lastNativeCompactAt ?? 0);
|
|
522
|
+
if (sinceCompactP >= cooldownMsP && !piCompactWouldNoop(ctx)) {
|
|
523
|
+
runtime.debounceUntil = nowP + 0;
|
|
524
|
+
const stampP = runtime.rt.lastNativeCompactAt;
|
|
525
|
+
const liveSidP = runtime.rt.sessionId;
|
|
526
|
+
setTimeout(() => {
|
|
527
|
+
try {
|
|
528
|
+
if (runtime.rt.sessionId !== liveSidP) return; // session reset
|
|
529
|
+
const sinceP = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
|
|
530
|
+
if (runtime.rt.lastNativeCompactAt !== stampP && sinceP < cooldownMsP) return;
|
|
531
|
+
if (piCompactWouldNoop(ctx)) return;
|
|
532
|
+
ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated. One guarded compact after a poisoned-context detection.
|
|
533
|
+
} catch {
|
|
534
|
+
/* non-fatal */
|
|
535
|
+
}
|
|
536
|
+
}, 500);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
408
539
|
} else {
|
|
409
540
|
// (5) transient or permanent — retry with exponential backoff.
|
|
410
541
|
// S38.7: hard-stop switch — bypass ALL retry logic when set.
|
|
411
542
|
if (config.errorRetryHardStop) {
|
|
412
543
|
runtime.rt.errorRetryCount = 0;
|
|
413
544
|
runtime.dashboard.event('error_retry_disabled', {
|
|
414
|
-
category,
|
|
545
|
+
category: effectiveCategory,
|
|
415
546
|
turnIndex: event.turnIndex,
|
|
416
547
|
reason: 'hard-stop',
|
|
417
548
|
});
|
|
@@ -433,7 +564,7 @@ export function registerAgentHandlers(
|
|
|
433
564
|
return; // early exit — circuit breaker tripped
|
|
434
565
|
}
|
|
435
566
|
const max =
|
|
436
|
-
|
|
567
|
+
effectiveCategory === 'transient'
|
|
437
568
|
? config.autoRetryTransientMax
|
|
438
569
|
: config.autoRetryPermanentMax;
|
|
439
570
|
// max === 0 disables the category entirely (revert to S28-only).
|
|
@@ -444,39 +575,93 @@ export function registerAgentHandlers(
|
|
|
444
575
|
if (runtime.rt.errorRetryCount > max) {
|
|
445
576
|
// Exhausted — surface the error, reset for the next burst.
|
|
446
577
|
runtime.dashboard.event('error_retry_exhausted', {
|
|
447
|
-
category,
|
|
578
|
+
category: effectiveCategory,
|
|
448
579
|
count: runtime.rt.errorRetryCount,
|
|
449
580
|
max,
|
|
450
581
|
turnIndex: event.turnIndex,
|
|
451
582
|
});
|
|
452
583
|
runtime.logger.info('error-retry-exhausted', {
|
|
453
584
|
sessionId: runtime.rt.sessionId,
|
|
454
|
-
category,
|
|
585
|
+
category: effectiveCategory,
|
|
455
586
|
count: runtime.rt.errorRetryCount,
|
|
456
587
|
max,
|
|
457
588
|
});
|
|
458
589
|
runtime.rt.errorRetryCount = 0;
|
|
459
590
|
} else {
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
591
|
+
// R2: session-global cap — total S38 nudges per session across
|
|
592
|
+
// ALL bursts. Independent of the per-burst max and the circuit
|
|
593
|
+
// breaker. Hitting it is terminal for the session: log +
|
|
594
|
+
// dashboard event, stop nudging. `0` disables (reverts to
|
|
595
|
+
// per-burst + circuit-breaker only).
|
|
596
|
+
if (
|
|
597
|
+
config.errorRetrySessionMax > 0 &&
|
|
598
|
+
runtime.rt.errorRetrySessionCount >= config.errorRetrySessionMax
|
|
599
|
+
) {
|
|
600
|
+
runtime.dashboard.event('error_retry_session_exhausted', {
|
|
601
|
+
count: runtime.rt.errorRetrySessionCount,
|
|
602
|
+
max: config.errorRetrySessionMax,
|
|
603
|
+
category: effectiveCategory,
|
|
604
|
+
turnIndex: event.turnIndex,
|
|
605
|
+
});
|
|
606
|
+
runtime.logger.warn('error-retry-session-exhausted', {
|
|
607
|
+
sessionId: runtime.rt.sessionId,
|
|
608
|
+
count: runtime.rt.errorRetrySessionCount,
|
|
609
|
+
max: config.errorRetrySessionMax,
|
|
610
|
+
category: effectiveCategory,
|
|
611
|
+
});
|
|
612
|
+
runtime.rt.errorRetryCount = 0;
|
|
613
|
+
return; // terminal for the session — no nudge
|
|
614
|
+
}
|
|
615
|
+
// R1: in-flight nudge dedup — a nudge queued via
|
|
616
|
+
// deliverAs:'followUp' must not be re-sent until it has been
|
|
617
|
+
// consumed by an actual new agent turn (turn_start resets
|
|
618
|
+
// retryNudgePending). Without this, a fast-erroring provider +
|
|
619
|
+
// a per-turn nudge → N nudges queue up and pi dispatches N
|
|
620
|
+
// retry turns, each re-submitting the same failing prompt
|
|
621
|
+
// (the 2026-07-28 incident). errorRetryCount still advances
|
|
622
|
+
// (this IS an error turn), so the per-burst max + circuit
|
|
623
|
+
// breaker still bound the burst.
|
|
624
|
+
if (runtime.rt.retryNudgePending) {
|
|
625
|
+
runtime.dashboard.event('error_retry_dedup_skip', {
|
|
626
|
+
category: effectiveCategory,
|
|
627
|
+
count: runtime.rt.errorRetryCount,
|
|
628
|
+
max,
|
|
629
|
+
turnIndex: event.turnIndex,
|
|
630
|
+
});
|
|
631
|
+
return; // pending nudge not yet consumed — skip
|
|
632
|
+
}
|
|
633
|
+
// R1: gating backoff — errorRetryUntil is now GATING
|
|
634
|
+
// (previously documented as non-gating). A nudge cannot fire
|
|
635
|
+
// before the previous backoff elapses. This paces retries
|
|
636
|
+
// (5s/10s/20s/30s by default) so a fast-erroring provider
|
|
637
|
+
// doesn't slam N nudges in <1s.
|
|
638
|
+
const now = Date.now();
|
|
639
|
+
if (now < runtime.rt.errorRetryUntil) {
|
|
640
|
+
runtime.dashboard.event('error_retry_backoff_skip', {
|
|
641
|
+
category: effectiveCategory,
|
|
642
|
+
count: runtime.rt.errorRetryCount,
|
|
643
|
+
max,
|
|
644
|
+
turnIndex: event.turnIndex,
|
|
645
|
+
});
|
|
646
|
+
return; // backoff not elapsed — skip
|
|
647
|
+
}
|
|
648
|
+
// Fire the retry nudge. Set pending (R1 dedup) + backoff
|
|
649
|
+
// (R1 pacing) + session count (R2 cap) BEFORE the await so a
|
|
650
|
+
// re-entrant turn_end during the send can't double-fire.
|
|
651
|
+
runtime.rt.retryNudgePending = true;
|
|
652
|
+
runtime.rt.lastErrorRetryAt = now;
|
|
653
|
+
runtime.rt.errorRetryUntil =
|
|
654
|
+
now + errorRetryBackoffMs(runtime.rt.errorRetryCount, config.errorRetryBackoffMs);
|
|
655
|
+
runtime.rt.errorRetrySessionCount++;
|
|
471
656
|
runtime.dashboard.event('error_retry', {
|
|
472
|
-
category,
|
|
657
|
+
category: effectiveCategory,
|
|
473
658
|
count: runtime.rt.errorRetryCount,
|
|
474
659
|
max,
|
|
475
660
|
turnIndex: event.turnIndex,
|
|
476
661
|
});
|
|
477
662
|
runtime.logger.info('error-retry', {
|
|
478
663
|
sessionId: runtime.rt.sessionId,
|
|
479
|
-
category,
|
|
664
|
+
category: effectiveCategory,
|
|
480
665
|
count: runtime.rt.errorRetryCount,
|
|
481
666
|
max,
|
|
482
667
|
});
|
|
@@ -29,12 +29,43 @@ import {
|
|
|
29
29
|
pressureRatio,
|
|
30
30
|
type MegaConfig,
|
|
31
31
|
} from "../mega-config.js";
|
|
32
|
-
import {
|
|
32
|
+
import { computeContentDigest } from "../../src/dedup/digest.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Recursively canonicalize a value for deterministic JSON serialization:
|
|
36
|
+
* - Objects: keys sorted alphabetically, values recursively canonicalized
|
|
37
|
+
* - Arrays: elements recursively canonicalized (array ORDER is preserved)
|
|
38
|
+
* - Primitives: returned as-is
|
|
39
|
+
*
|
|
40
|
+
* F5 fix: shallow-sorted JSON.stringify(content, Object.keys(content).sort())
|
|
41
|
+
* omitted nested keys not in the top-level keys array and only sorted one level.
|
|
42
|
+
* This recursively sorts every object at every depth so semantically-equal
|
|
43
|
+
* differently-ordered content hashes identically.
|
|
44
|
+
*/
|
|
45
|
+
function canonicalize(value: unknown): unknown {
|
|
46
|
+
if (value === null || value === undefined) return value;
|
|
47
|
+
if (typeof value !== "object") return value;
|
|
48
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
49
|
+
// Plain object: sort keys, recursively canonicalize values.
|
|
50
|
+
const sorted = Object.keys(value as Record<string, unknown>).sort();
|
|
51
|
+
const out: Record<string, unknown> = {};
|
|
52
|
+
for (const k of sorted) {
|
|
53
|
+
out[k] = canonicalize((value as Record<string, unknown>)[k]);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
33
57
|
|
|
34
58
|
/**
|
|
35
59
|
* Convert a pi AgentMessage to a RawTranscriptRow for the DB mirror.
|
|
36
|
-
* content_bytes is canonical JSON (sorted keys) for deterministic
|
|
37
|
-
*
|
|
60
|
+
* content_bytes is canonical JSON (sorted keys, recursive) for deterministic
|
|
61
|
+
* storage; contentHash is the canonical digest (normalize + hash) that matches
|
|
62
|
+
* what dedupTranscript computes so the two pipelines use the same linkage key.
|
|
63
|
+
*
|
|
64
|
+
* F4 fix: the hash key MUST match on both sides of the append/dedup split.
|
|
65
|
+
* Using computeContentDigest here (normalize → hash) ensures content_ref set
|
|
66
|
+
* by dedupTranscript always resolves to an existing dedup_mirror row even for
|
|
67
|
+
* whitespace/case-variant content. F5 fix: recursive canonicalize replaces the
|
|
68
|
+
* broken shallow-sorted JSON.stringify replacer.
|
|
38
69
|
*/
|
|
39
70
|
function toRawTranscriptRow(
|
|
40
71
|
msg: AgentMessage,
|
|
@@ -50,12 +81,17 @@ function toRawTranscriptRow(
|
|
|
50
81
|
};
|
|
51
82
|
const content = m.content;
|
|
52
83
|
if (content == null || content === "") return null;
|
|
53
|
-
// Canonical
|
|
84
|
+
// Canonical bytes: string content → normalize for consistent byte content;
|
|
85
|
+
// object content → recursive canonicalize then JSON (no replacer).
|
|
54
86
|
const contentBytes =
|
|
55
87
|
typeof content === "string"
|
|
56
|
-
? content
|
|
57
|
-
: JSON.stringify(
|
|
58
|
-
|
|
88
|
+
? content // F5: strings are primitives; their byte content is fixed
|
|
89
|
+
: JSON.stringify(canonicalize(content));
|
|
90
|
+
// F4 fix: use the same digest as dedupTranscript so contentHash is consistent
|
|
91
|
+
// on both sides of the append/dedup split. computeContentDigest normalizes
|
|
92
|
+
// (strip ANSI, NFC, case-fold, collapse whitespace) then hashes → the same
|
|
93
|
+
// key is stored in raw_transcript.content_hash AND used as the dedup link.
|
|
94
|
+
const { contentHash } = computeContentDigest(contentBytes);
|
|
59
95
|
return {
|
|
60
96
|
contentHash,
|
|
61
97
|
sessionId,
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* Classifies turn-end error/stop signals into retry categories.
|
|
5
5
|
* Ordering matters: compaction-noop is matched BEFORE generic transient
|
|
6
6
|
* so a pi race / manual compact catch is never misclassified.
|
|
7
|
+
*
|
|
8
|
+
* R3 (retry redesign): a new 'poisoned-context' category for DETERMINISTIC
|
|
9
|
+
* request-rejection failures that retrying cannot fix. See agent-handlers.ts
|
|
10
|
+
* for the poisoned handling (no blind retry; one /clear advise; one guarded
|
|
11
|
+
* compact per signature). Conservative: when unsure between transient and
|
|
12
|
+
* poisoned, choose transient (bounded by R1 dedup + R2 session cap).
|
|
7
13
|
*/
|
|
8
14
|
|
|
9
15
|
/** S38.2: classify a turn-end error/stop signal into a retry category.
|
|
@@ -12,7 +18,7 @@
|
|
|
12
18
|
* exclusively (its agent_end nudge path is separate and must not be doubled).
|
|
13
19
|
*
|
|
14
20
|
* @param message the event.message (a pi AgentMessage) or an error string
|
|
15
|
-
* @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | 'cancelled' | null (success/unknown)
|
|
21
|
+
* @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | 'cancelled' | 'poisoned-context' | null (success/unknown)
|
|
16
22
|
*/
|
|
17
23
|
export function classifyError(message: unknown):
|
|
18
24
|
| 'transient'
|
|
@@ -20,9 +26,18 @@ export function classifyError(message: unknown):
|
|
|
20
26
|
| 'compaction-noop'
|
|
21
27
|
| 'context-overflow'
|
|
22
28
|
| 'cancelled'
|
|
29
|
+
| 'poisoned-context'
|
|
23
30
|
| null {
|
|
24
31
|
// Resolve a searchable text blob from a pi AgentMessage or raw string.
|
|
25
32
|
let text = '';
|
|
33
|
+
// R3: usage-token signal. Only when `usage` is explicitly present with
|
|
34
|
+
// inputTokens=0 AND outputTokens=0 do we know the turn never reached the
|
|
35
|
+
// model. An ABSENT usage field means "unknown" (the event didn't carry
|
|
36
|
+
// token counts) — we do NOT treat that as the 0-token poisoned signal,
|
|
37
|
+
// because mid-response stream deaths and partial-content turns routinely
|
|
38
|
+
// omit usage while still being transient (preserves all pre-R3 tests).
|
|
39
|
+
let usagePresent = false;
|
|
40
|
+
let totalTokens = 0;
|
|
26
41
|
if (typeof message === 'string') {
|
|
27
42
|
text = message;
|
|
28
43
|
} else if (message && typeof message === 'object') {
|
|
@@ -30,6 +45,7 @@ export function classifyError(message: unknown):
|
|
|
30
45
|
stopReason?: string;
|
|
31
46
|
content?: unknown;
|
|
32
47
|
error?: unknown;
|
|
48
|
+
usage?: { inputTokens?: number; outputTokens?: number } | undefined;
|
|
33
49
|
};
|
|
34
50
|
const sr = typeof m.stopReason === 'string' ? m.stopReason : '';
|
|
35
51
|
// S28 guard: length stopReason is handled exclusively by the S28 path.
|
|
@@ -39,6 +55,15 @@ export function classifyError(message: unknown):
|
|
|
39
55
|
if (sr === 'aborted') return 'cancelled';
|
|
40
56
|
// Success / normal tool flow — not an error, nothing to retry.
|
|
41
57
|
if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use') return null;
|
|
58
|
+
// R3: extract usage tokens for the 0-token poisoned-context signal.
|
|
59
|
+
const usage = m.usage;
|
|
60
|
+
usagePresent = usage != null && typeof usage === 'object';
|
|
61
|
+
if (usagePresent) {
|
|
62
|
+
const u = usage as { inputTokens?: number; outputTokens?: number };
|
|
63
|
+
totalTokens =
|
|
64
|
+
(typeof u.inputTokens === 'number' ? u.inputTokens : 0) +
|
|
65
|
+
(typeof u.outputTokens === 'number' ? u.outputTokens : 0);
|
|
66
|
+
}
|
|
42
67
|
const parts: string[] = [];
|
|
43
68
|
if (sr) parts.push(sr);
|
|
44
69
|
const c = m.content;
|
|
@@ -47,7 +72,7 @@ export function classifyError(message: unknown):
|
|
|
47
72
|
for (const b of c) {
|
|
48
73
|
if (b && typeof b === 'object' && 'text' in b) {
|
|
49
74
|
parts.push(String((b as { text?: string }).text ?? ''));
|
|
50
|
-
|
|
75
|
+
}
|
|
51
76
|
}
|
|
52
77
|
}
|
|
53
78
|
if (m.error) {
|
|
@@ -109,31 +134,114 @@ export function classifyError(message: unknown):
|
|
|
109
134
|
if (/too long|context window|maximum context length|context length exceeded|requires at least \d+ tokens|even after compaction|reduce the conversation|reduce your input/.test(s)) {
|
|
110
135
|
return 'context-overflow';
|
|
111
136
|
}
|
|
112
|
-
// --- transient (
|
|
113
|
-
|
|
114
|
-
|
|
137
|
+
// --- transient (specific markers FIRST — these override poisoned signals) ---
|
|
138
|
+
// R3: network failures (timeout, ECONNRESET, 5xx, 429) MUST stay transient
|
|
139
|
+
// even when usage is 0 tokens, because they are retryable. The specific
|
|
140
|
+
// markers below return before any poisoned signal is evaluated.
|
|
141
|
+
if (/max(imum)? output token/.test(s)) return 'transient';
|
|
142
|
+
if (/rate[\s.-]?limit|429|too many requests/.test(s)) return 'transient';
|
|
143
|
+
if (/5\d\d|internal server|bad gateway|service unavailable/.test(s)) return 'transient';
|
|
144
|
+
// 'connection aborted' is a network failure (ECONNABORTED), NOT a user ESC
|
|
145
|
+
// (which is stopReason 'aborted', handled by the early-return above). Added
|
|
146
|
+
// so "Connection aborted by peer" stays transient under R3. ECONNRESET and
|
|
147
|
+
// common errno names are included so a bare 0-token 'ECONNRESET' turn is not
|
|
148
|
+
// misclassified as poisoned (R6: ECONNRESET → transient).
|
|
149
|
+
if (/network|timeout|econnreset|econnrefused|epipe|connection (lost|refused|reset|aborted)|stream (interrupted|closed|ended|failed)|disconnected/.test(s)) return 'transient';
|
|
150
|
+
// --- poisoned-context (R3: ORDER AFTER specific transient markers, BEFORE
|
|
151
|
+
// the generic 'error' transient fallthrough) ---
|
|
152
|
+
// A DETERMINISTIC request-rejection that retrying cannot fix. Re-submitting
|
|
153
|
+
// the same prompt re-triggers the same rejection. The 2026-07-28 incident
|
|
154
|
+
// was a provider returning 0-token "Request failed — please retry." turns
|
|
155
|
+
// for every request; classifyError mapped stopReason 'error' → 'transient'
|
|
156
|
+
// and the session emitted ~9 nudge bursts (~60 context-bloating messages)
|
|
157
|
+
// before stopping.
|
|
158
|
+
//
|
|
159
|
+
// Signal 1: provider request-validation 400 that is NOT context-overflow
|
|
160
|
+
// (context-overflow already returned above). Orphaned tool result / malformed
|
|
161
|
+
// message structure / unexpected role ordering / empty content. These were
|
|
162
|
+
// previously classified 'permanent' (max 1 retry) — but a single retry
|
|
163
|
+
// re-submits the SAME malformed structure and re-400s, so they are poisoned,
|
|
164
|
+
// not permanent. Auth/permission 400s stay permanent (separate check below).
|
|
165
|
+
if (/invalid_request_error|invalid request|malformed|bad request|orphaned tool|tool (result )?(without|for )|unexpected role|role (ordering|sequence)|empty (content|message|request)/.test(s)) {
|
|
166
|
+
return 'poisoned-context';
|
|
167
|
+
}
|
|
168
|
+
// Signal 2: generic catch-all "request failed" / "request error" with no
|
|
169
|
+
// specific transient marker (transient markers already returned above).
|
|
170
|
+
// This is the exact phrasing the 2026-07-28 incident used. Without a
|
|
171
|
+
// specific retryable cause, treat the rejection as deterministic.
|
|
172
|
+
if (/request failed|request error/.test(s)) {
|
|
173
|
+
return 'poisoned-context';
|
|
174
|
+
}
|
|
175
|
+
// Signal 3: stopReason 'error' + usage explicitly 0 tokens (the turn never
|
|
176
|
+
// reached the model). Conservative: only when `usage` is PRESENT and 0 — an
|
|
177
|
+
// absent usage field means "unknown" and stays transient (mid-response
|
|
178
|
+
// deaths / partial-content turns often omit usage). The transient markers
|
|
179
|
+
// above already returned, so reaching here with 'error' text and 0 tokens
|
|
180
|
+
// means the request was rejected before any model work.
|
|
181
|
+
if (usagePresent && totalTokens === 0 && /error/.test(s)) {
|
|
182
|
+
return 'poisoned-context';
|
|
183
|
+
}
|
|
184
|
+
// --- transient (generic 'error' stopReason fallthrough) ---
|
|
185
|
+
// Reaches here for a generic stopReason 'error' with no specific marker and
|
|
186
|
+
// no 0-token signal (usage absent or > 0). Stays transient — conservative.
|
|
187
|
+
if (s.includes('error') && !/\b(auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
|
|
188
|
+
return 'transient'; // generic pi stopReason 'error'
|
|
115
189
|
}
|
|
116
190
|
// NOTE: 'aborted' stopReason is handled by the sr==='aborted' early-return above.
|
|
117
191
|
// The text-based s.includes('aborted') was removed — it was the old path that
|
|
118
192
|
// misclassified user ESC/Ctrl-C as 'transient' (5 retry nudges after cancel).
|
|
119
|
-
if (/max(imum)? output token/.test(s)) return 'transient';
|
|
120
|
-
if (/rate[\s.-]?limit|429|too many requests/.test(s)) return 'transient';
|
|
121
|
-
if (/5\d\d|internal server|bad gateway|service unavailable/.test(s)) return 'transient';
|
|
122
|
-
if (/network|timeout|connection (lost|refused|reset)|stream (interrupted|closed|ended|failed)|disconnected/.test(s)) return 'transient';
|
|
123
193
|
// --- permanent (NOT retryable beyond 1) ---
|
|
124
194
|
if (/auth|unauthorized|invalid (api )?key|permission/.test(s)) return 'permanent';
|
|
125
|
-
if (/invalid request|malformed|bad request/.test(s)) return 'permanent';
|
|
126
195
|
// Unknown — do not retry (avoid busy-looping on an unclassified signal).
|
|
127
196
|
return null;
|
|
128
197
|
}
|
|
129
198
|
|
|
130
|
-
/** S38.2: exponential backoff for error-retry nudges
|
|
131
|
-
* count is 1-based (the retry about to fire).
|
|
132
|
-
|
|
199
|
+
/** S38.2: exponential backoff for error-retry nudges.
|
|
200
|
+
* count is 1-based (the retry about to fire). baseMs is the unit
|
|
201
|
+
* (config.errorRetryBackoffMs, default 5000 → 5s/10s/20s/30s cap).
|
|
202
|
+
* R1: errorRetryUntil is now GATING — a nudge cannot fire before the previous
|
|
203
|
+
* backoff elapses (previously documented as non-gating). */
|
|
204
|
+
export function errorRetryBackoffMs(count: number, baseMs = 5000): number {
|
|
133
205
|
switch (count) {
|
|
134
|
-
case 1: return
|
|
135
|
-
case 2: return
|
|
136
|
-
case 3: return
|
|
137
|
-
default: return
|
|
206
|
+
case 1: return baseMs;
|
|
207
|
+
case 2: return baseMs * 2;
|
|
208
|
+
case 3: return baseMs * 4;
|
|
209
|
+
default: return baseMs * 6; // cap from the 4th retry onward
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** R3: extract a normalized error-signature string from a turn_end message for
|
|
214
|
+
* the stateful "repeated identical error text" signal. Returns the lowercased
|
|
215
|
+
* content + error.message blob (WITHOUT the stopReason, which is constant
|
|
216
|
+
* 'error' for retryable turns) so two turns with the same error content share
|
|
217
|
+
* a signature. Empty string when there is no error text (e.g. a bare
|
|
218
|
+
* stopReason 'error' with no content) — the caller skips repeat detection in
|
|
219
|
+
* that case (the stateless 0-token signal handles bare errors). */
|
|
220
|
+
export function extractErrorSignature(message: unknown): string {
|
|
221
|
+
if (typeof message === 'string') return message.toLowerCase().trim();
|
|
222
|
+
if (!message || typeof message !== 'object') return '';
|
|
223
|
+
const m = message as {
|
|
224
|
+
content?: unknown;
|
|
225
|
+
error?: unknown;
|
|
226
|
+
};
|
|
227
|
+
const parts: string[] = [];
|
|
228
|
+
const c = m.content;
|
|
229
|
+
if (typeof c === 'string') parts.push(c);
|
|
230
|
+
else if (Array.isArray(c)) {
|
|
231
|
+
for (const b of c) {
|
|
232
|
+
if (b && typeof b === 'object' && 'text' in b) {
|
|
233
|
+
parts.push(String((b as { text?: string }).text ?? ''));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (m.error) {
|
|
238
|
+
const err = m.error;
|
|
239
|
+
if (typeof err === 'string') parts.push(err);
|
|
240
|
+
else if (err && typeof err === 'object') {
|
|
241
|
+
const errObj = err as Record<string, unknown>;
|
|
242
|
+
if (typeof errObj.message === 'string') parts.push(errObj.message);
|
|
243
|
+
else parts.push(JSON.stringify(err));
|
|
244
|
+
}
|
|
138
245
|
}
|
|
246
|
+
return parts.join(' ').toLowerCase().trim();
|
|
139
247
|
}
|