@inceptionstack/roundhouse 0.5.29 → 0.5.30
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/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/agents/shared/session-repair.test.ts +140 -0
- package/src/agents/shared/session-repair.ts +112 -41
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@inceptionstack/roundhouse` are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.5.30] — 2026-05-14
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Soft-reset robustness fixes from codex review of v0.5.29:**
|
|
9
|
+
- **P1 — byte-cap could cut mid-turn.** When `findSoftResetCutIndex()` hit the byte budget before reaching `keepRecentUserTurns`, it returned `i + 1` which could land on an assistant reply or toolResult whose user prompt was about to be dropped. The kept tail then started mid-turn and tool-pairing repair didn't fix that (only orphans, not turn boundaries). Fixed: byte-cap path now snaps to the most-recent user-message boundary we've walked through.
|
|
10
|
+
- **P2 — byte cap measured in JS code units, not real bytes.** `JSON.stringify(e).length` counts UTF-16 code units; non-ASCII content (emoji, CJK) overshot the advertised 250k ceiling 2–3x. Now uses `Buffer.byteLength(..., 'utf8')` end-to-end so reported `bytesAfter` and the cap decision both reflect actual file bytes.
|
|
11
|
+
- **P2 — trim + repair was not atomic end-to-end.** Old flow wrote the trimmed file, then called `repairSessionFile()` which re-backed-up the *already-trimmed* file and rewrote it again. A crash between the two writes left a partial state and lost the true original. Refactored: extracted `repairEntriesInMemory()` so trim + tool-pair repair compose in memory and land as a single backup + atomic rename.
|
|
12
|
+
- **P2 — `isContextOverflowError()` only inspected top-level `.message`.** Wrapped provider errors (`err.cause.message`, Bedrock `ValidationException` carrying overflow text in nested SDK fields) fell through to re-arming `pendingCompact` instead of triggering recovery. Now mirrors `isToolPairingError()`'s nested handling: walks the `cause` chain (bounded, cycle-safe) and stringify-searches gated on a 4xx/`ValidationException` shape so we don't false-positive on unrelated 5xx noise.
|
|
13
|
+
- 7 regression tests added (534 total passing): byte-cap user-boundary snap, UTF-8 byte accounting, single-atomic-write backup integrity, wrapped-cause classification, Bedrock validation classification, false-positive gating, circular-cause safety.
|
|
14
|
+
|
|
5
15
|
## [0.5.29] — 2026-05-14
|
|
6
16
|
|
|
7
17
|
### Added
|
package/package.json
CHANGED
|
@@ -505,6 +505,104 @@ describe('softResetSessionFile', () => {
|
|
|
505
505
|
expect(() => softResetSessionFile('/nonexistent/path.jsonl')).toThrow(/not found/);
|
|
506
506
|
});
|
|
507
507
|
|
|
508
|
+
it('softResetSessionFile_ByteCapHit_SnapsToUserTurnBoundary_NeverStartsMidTurn', () => {
|
|
509
|
+
// Regression test for codex P1: byte-cap path used to return `i + 1`
|
|
510
|
+
// which could land mid-turn (assistant reply or toolResult with no user
|
|
511
|
+
// prompt above it). Fixed to snap to the most-recent user-message index.
|
|
512
|
+
// Arrange: many small turns, byte cap forces an early cut.
|
|
513
|
+
const entries: object[] = [HEADER, MODEL_CHANGE];
|
|
514
|
+
let parent: string | null = 'mc-1';
|
|
515
|
+
for (let i = 1; i <= 30; i++) {
|
|
516
|
+
entries.push(...userTurn(`t${i}`, parent));
|
|
517
|
+
parent = `t${i}a`;
|
|
518
|
+
}
|
|
519
|
+
const path = tmpJsonl(entries);
|
|
520
|
+
|
|
521
|
+
// Act: very tight byte budget so cap fires before keepRecentUserTurns reached.
|
|
522
|
+
const report = softResetSessionFile(path, { keepRecentUserTurns: 100, maxBytes: 600 });
|
|
523
|
+
|
|
524
|
+
// Assert: reset happened AND first kept entry is a user message.
|
|
525
|
+
expect(report.reset).toBe(true);
|
|
526
|
+
const trimmed = parseSessionFile(path);
|
|
527
|
+
expect(trimmed[0].type).toBe('session'); // header preserved
|
|
528
|
+
expect(trimmed[1].message?.role).toBe('user'); // first kept = user turn
|
|
529
|
+
expect(trimmed[1].parentId).toBeNull(); // re-parented
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('softResetSessionFile_NonAsciiContent_ReportedBytesMatchActualFileBytes', () => {
|
|
533
|
+
// Regression test for codex P2: trim used JSON.stringify(e).length
|
|
534
|
+
// (UTF-16 code units) but reported bytesAfter from real file bytes.
|
|
535
|
+
// After fix, both use Buffer.byteLength(..., 'utf8').
|
|
536
|
+
// Arrange: turns containing multi-byte UTF-8 (each emoji = 4 bytes,
|
|
537
|
+
// length 2 in code units — 2x discrepancy).
|
|
538
|
+
const entries: object[] = [HEADER, MODEL_CHANGE];
|
|
539
|
+
const emojis = '🚀🔥🎉✨💡'.repeat(20); // ~100 bytes per turn
|
|
540
|
+
let parent: string | null = 'mc-1';
|
|
541
|
+
for (let i = 1; i <= 20; i++) {
|
|
542
|
+
entries.push(
|
|
543
|
+
userMsg(`t${i}u`, parent, `${emojis} text-${i}`),
|
|
544
|
+
{
|
|
545
|
+
type: 'message', id: `t${i}a`, parentId: `t${i}u`,
|
|
546
|
+
timestamp: '2026-05-01T00:00:04Z',
|
|
547
|
+
message: {
|
|
548
|
+
role: 'assistant',
|
|
549
|
+
content: [{ type: 'text', text: `${emojis} reply-${i}` }],
|
|
550
|
+
api: 'bedrock-converse-stream', provider: 'amazon-bedrock', model: 'claude',
|
|
551
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
552
|
+
stopReason: 'endTurn', timestamp: 4,
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
);
|
|
556
|
+
parent = `t${i}a`;
|
|
557
|
+
}
|
|
558
|
+
const path = tmpJsonl(entries);
|
|
559
|
+
|
|
560
|
+
// Act
|
|
561
|
+
const report = softResetSessionFile(path, { keepRecentUserTurns: 100, maxBytes: 2000 });
|
|
562
|
+
|
|
563
|
+
// Assert: reported bytesAfter matches actual file bytes (true UTF-8 size).
|
|
564
|
+
expect(report.reset).toBe(true);
|
|
565
|
+
const actualBytes = readFileSync(path).length;
|
|
566
|
+
expect(report.bytesAfter).toBe(actualBytes);
|
|
567
|
+
// And we honored the cap (allow some slack for snap-to-user-boundary).
|
|
568
|
+
expect(report.bytesAfter).toBeLessThan(4000);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('softResetSessionFile_OnSingleAtomicWrite_OriginalBackupIsRecoverable', () => {
|
|
572
|
+
// Regression test for codex P2: previously trim wrote once, then
|
|
573
|
+
// repairSessionFile() wrote again with its OWN backup of the
|
|
574
|
+
// already-trimmed file. After fix, only one backup exists and it's
|
|
575
|
+
// the true original.
|
|
576
|
+
// Arrange: session with orphaned tool pair so post-cut repair fires.
|
|
577
|
+
const oldToolCall = assistantToolCall('a-old', 'mc-1', 'call-X');
|
|
578
|
+
const orphanedResult = {
|
|
579
|
+
type: 'message', id: 'tr-1', parentId: 'a-old',
|
|
580
|
+
timestamp: '2026-05-01T00:00:05Z',
|
|
581
|
+
message: { role: 'toolResult', toolCallId: 'call-X', content: 'ok', timestamp: 5 },
|
|
582
|
+
};
|
|
583
|
+
const entries: object[] = [HEADER, MODEL_CHANGE, userMsg('u-old', 'mc-1', 'old'), oldToolCall];
|
|
584
|
+
let parent: string | null = 'a-old';
|
|
585
|
+
for (let i = 1; i <= 5; i++) {
|
|
586
|
+
entries.push(...userTurn(`f${i}`, parent));
|
|
587
|
+
parent = `f${i}a`;
|
|
588
|
+
}
|
|
589
|
+
entries.splice(6, 0, orphanedResult);
|
|
590
|
+
const path = tmpJsonl(entries);
|
|
591
|
+
const originalBytes = readFileSync(path);
|
|
592
|
+
|
|
593
|
+
// Act
|
|
594
|
+
const report = softResetSessionFile(path, { keepRecentUserTurns: 3 });
|
|
595
|
+
|
|
596
|
+
// Assert: backup contents = TRUE original (pre-trim, pre-repair),
|
|
597
|
+
// not an intermediate trimmed-but-unrepaired state.
|
|
598
|
+
expect(report.reset).toBe(true);
|
|
599
|
+
expect(report.backupPath).toBeDefined();
|
|
600
|
+
const backup = readFileSync(report.backupPath!);
|
|
601
|
+
expect(backup.equals(originalBytes)).toBe(true);
|
|
602
|
+
// Final on-disk file is internally consistent.
|
|
603
|
+
expect(inspectSessionFile(path).hasOrphans).toBe(false);
|
|
604
|
+
});
|
|
605
|
+
|
|
508
606
|
it('softResetSessionFile_BytesCapHonored_StopsCutAtCap', () => {
|
|
509
607
|
// Arrange: each turn is small but we set a tiny byte cap so we cut early.
|
|
510
608
|
const entries: object[] = [HEADER, MODEL_CHANGE];
|
|
@@ -549,4 +647,46 @@ describe('isContextOverflowError', () => {
|
|
|
549
647
|
expect(isContextOverflowError(undefined)).toBe(false);
|
|
550
648
|
expect(isContextOverflowError({})).toBe(false);
|
|
551
649
|
});
|
|
650
|
+
|
|
651
|
+
it('classifies overflow when text lives in err.cause.message (wrapped SDK error)', () => {
|
|
652
|
+
// Regression test for codex P2: wrapped provider errors used to fall
|
|
653
|
+
// through to re-arming pendingCompact. After fix, cause-chain is walked.
|
|
654
|
+
const inner = new Error('prompt is too long: 212776 tokens > 200000 maximum');
|
|
655
|
+
const outer = new Error('Summarization failed');
|
|
656
|
+
(outer as { cause?: unknown }).cause = inner;
|
|
657
|
+
expect(isContextOverflowError(outer)).toBe(true);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('classifies overflow on Bedrock ValidationException with nested overflow text', () => {
|
|
661
|
+
// Regression test: Bedrock SDK can carry the useful text in nested
|
|
662
|
+
// $metadata or stringify-only fields. We only stringify-search when
|
|
663
|
+
// the error LOOKS like a 4xx validation (mirrors isToolPairingError).
|
|
664
|
+
const err = Object.assign(new Error('validation failed'), {
|
|
665
|
+
name: 'ValidationException',
|
|
666
|
+
$metadata: { httpStatusCode: 400 },
|
|
667
|
+
detail: { reason: 'prompt is too long' },
|
|
668
|
+
});
|
|
669
|
+
expect(isContextOverflowError(err)).toBe(true);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('does NOT stringify-search arbitrary errors that contain overflow keywords', () => {
|
|
673
|
+
// Negative case: gating prevents false-positives on unrelated 5xx errors
|
|
674
|
+
// whose payload happens to contain trigger phrases.
|
|
675
|
+
const err = Object.assign(new Error('internal error'), {
|
|
676
|
+
name: 'InternalServerError',
|
|
677
|
+
$metadata: { httpStatusCode: 500 },
|
|
678
|
+
diagnostics: 'log line: prompt is too long check disabled',
|
|
679
|
+
});
|
|
680
|
+
expect(isContextOverflowError(err)).toBe(false);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('does not loop forever on circular cause chains', () => {
|
|
684
|
+
// Safety: cause walk is bounded.
|
|
685
|
+
const a = new Error('outer');
|
|
686
|
+
const b = new Error('inner');
|
|
687
|
+
(a as { cause?: unknown }).cause = b;
|
|
688
|
+
(b as { cause?: unknown }).cause = a; // cycle
|
|
689
|
+
expect(() => isContextOverflowError(a)).not.toThrow();
|
|
690
|
+
expect(isContextOverflowError(a)).toBe(false);
|
|
691
|
+
});
|
|
552
692
|
});
|
|
@@ -246,44 +246,65 @@ export function inspectSessionFile(path: string): {
|
|
|
246
246
|
*
|
|
247
247
|
* @returns report describing what was repaired
|
|
248
248
|
*/
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Pure in-memory tool-pairing repair. Takes entries, returns repaired entries
|
|
251
|
+
* + a report. Does not touch the filesystem. Used directly by
|
|
252
|
+
* `softResetSessionFile` so trim + repair land as a single atomic write, and
|
|
253
|
+
* via a thin wrapper by `repairSessionFile` for on-disk repair.
|
|
254
|
+
*/
|
|
255
|
+
function repairEntriesInMemory(entries: SessionFileEntry[]): {
|
|
256
|
+
entries: SessionFileEntry[];
|
|
257
|
+
report: SessionRepairReport;
|
|
258
|
+
} {
|
|
255
259
|
const { messages } = extractMessages(entries);
|
|
256
260
|
const validation = validateToolPairing(messages);
|
|
257
261
|
|
|
258
262
|
if (validation.isValid) {
|
|
259
263
|
return {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
264
|
+
entries,
|
|
265
|
+
report: {
|
|
266
|
+
repaired: false,
|
|
267
|
+
droppedEntryIds: [],
|
|
268
|
+
droppedToolCallIds: [],
|
|
269
|
+
droppedToolResultIds: [],
|
|
270
|
+
totalEntries: entries.length,
|
|
271
|
+
},
|
|
265
272
|
};
|
|
266
273
|
}
|
|
267
274
|
|
|
268
275
|
const orphanedCalls = new Set(validation.orphanedToolCallIds);
|
|
269
276
|
const orphanedResults = new Set(validation.orphanedToolResultIds);
|
|
270
|
-
|
|
271
277
|
const { entriesToDrop, entriesToEdit } = findEntriesToDrop(entries, orphanedCalls, orphanedResults);
|
|
272
278
|
const edited = applyEntryEdits(entries, entriesToEdit);
|
|
273
279
|
const kept = reparentDroppedEntries(edited, entriesToDrop);
|
|
274
280
|
|
|
281
|
+
return {
|
|
282
|
+
entries: kept,
|
|
283
|
+
report: {
|
|
284
|
+
repaired: true,
|
|
285
|
+
droppedEntryIds: Array.from(entriesToDrop),
|
|
286
|
+
droppedToolCallIds: validation.orphanedToolCallIds,
|
|
287
|
+
droppedToolResultIds: validation.orphanedToolResultIds,
|
|
288
|
+
totalEntries: entries.length,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function repairSessionFile(path: string): SessionRepairReport {
|
|
294
|
+
if (!existsSync(path)) {
|
|
295
|
+
throw new Error(`Session file not found: ${path}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const entries = parseSessionFile(path);
|
|
299
|
+
const { entries: repaired, report } = repairEntriesInMemory(entries);
|
|
300
|
+
|
|
301
|
+
if (!report.repaired) return report;
|
|
302
|
+
|
|
275
303
|
const backupPath = backupFile(path);
|
|
276
|
-
const newContent =
|
|
304
|
+
const newContent = repaired.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
277
305
|
atomicWrite(path, newContent);
|
|
278
306
|
|
|
279
|
-
return {
|
|
280
|
-
repaired: true,
|
|
281
|
-
droppedEntryIds: Array.from(entriesToDrop),
|
|
282
|
-
droppedToolCallIds: validation.orphanedToolCallIds,
|
|
283
|
-
droppedToolResultIds: validation.orphanedToolResultIds,
|
|
284
|
-
backupPath,
|
|
285
|
-
totalEntries: entries.length,
|
|
286
|
-
};
|
|
307
|
+
return { ...report, backupPath };
|
|
287
308
|
}
|
|
288
309
|
|
|
289
310
|
// ── Soft reset (recovery from already-overflowed sessions) ──────────────
|
|
@@ -323,12 +344,19 @@ export interface SoftResetReport {
|
|
|
323
344
|
|
|
324
345
|
/**
|
|
325
346
|
* Find a safe cut index in the entries array. Walk backwards from the end
|
|
326
|
-
* looking for user message entries; the cut sits *
|
|
327
|
-
*
|
|
328
|
-
* entry to KEEP (i.e. all entries[0..cutIdx)
|
|
347
|
+
* looking for user message entries; the cut sits *at* the Nth most-recent
|
|
348
|
+
* user message we encounter (so the kept tail starts on a user turn).
|
|
349
|
+
* Returns the index of the first entry to KEEP (i.e. all entries[0..cutIdx)
|
|
350
|
+
* are dropped).
|
|
329
351
|
*
|
|
330
|
-
*
|
|
331
|
-
* the
|
|
352
|
+
* Byte-cap path: if we exceed the byte budget before reaching N user turns,
|
|
353
|
+
* we still snap the cut to the most-recent user-message boundary we've seen.
|
|
354
|
+
* That guarantees the kept tail always starts with a user message — never an
|
|
355
|
+
* orphaned assistant reply or toolResult whose user prompt was dropped.
|
|
356
|
+
*
|
|
357
|
+
* If we can't find ANY user messages, returns entries.length (drop everything
|
|
358
|
+
* but header) so the caller produces a header-only no-op session rather than
|
|
359
|
+
* a malformed tail.
|
|
332
360
|
*/
|
|
333
361
|
function findSoftResetCutIndex(
|
|
334
362
|
entries: SessionFileEntry[],
|
|
@@ -337,24 +365,32 @@ function findSoftResetCutIndex(
|
|
|
337
365
|
): { cutIdx: number; reason: string } {
|
|
338
366
|
let userTurnsSeen = 0;
|
|
339
367
|
let bytesAccumulated = 0;
|
|
368
|
+
/** Most recent user-message index we've walked through, or -1 if none yet. */
|
|
369
|
+
let lastUserIdx = -1;
|
|
340
370
|
// Scan tail-to-head, stop when we've collected enough user turns OR exceeded byte budget.
|
|
341
371
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
342
372
|
const e = entries[i];
|
|
343
|
-
bytesAccumulated += JSON.stringify(e)
|
|
373
|
+
bytesAccumulated += Buffer.byteLength(JSON.stringify(e), 'utf8') + 1; // +1 for newline
|
|
344
374
|
if (e.type === 'message' && e.message?.role === 'user') {
|
|
345
375
|
userTurnsSeen++;
|
|
376
|
+
lastUserIdx = i;
|
|
346
377
|
if (userTurnsSeen >= keepRecentUserTurns) {
|
|
347
378
|
return { cutIdx: i, reason: `kept-${userTurnsSeen}-user-turns` };
|
|
348
379
|
}
|
|
349
380
|
}
|
|
350
381
|
// Byte cap is a safety net for sessions where a single turn is enormous
|
|
351
|
-
// (e.g. one turn dumped a 200k file).
|
|
382
|
+
// (e.g. one turn dumped a 200k file). When we hit it we MUST snap the cut
|
|
383
|
+
// to the most recent user-message boundary — otherwise the kept tail could
|
|
384
|
+
// start mid-turn (assistant/toolResult with no user prompt above it), and
|
|
385
|
+
// tool-pairing repair won't fix that.
|
|
352
386
|
if (bytesAccumulated > maxBytes && userTurnsSeen > 0) {
|
|
353
|
-
return { cutIdx:
|
|
387
|
+
return { cutIdx: lastUserIdx, reason: `byte-cap-${bytesAccumulated}b` };
|
|
354
388
|
}
|
|
355
389
|
}
|
|
356
|
-
//
|
|
357
|
-
//
|
|
390
|
+
// Fewer user turns than target — treat as no-op. Soft-reset is recovery
|
|
391
|
+
// from overflow; if the session has fewer turns than our target it isn't
|
|
392
|
+
// overflowed and we shouldn't mutate it. Returning 1 means "keep everything
|
|
393
|
+
// after the header", which the caller's `cutIdx <= 1` gate maps to reset:false.
|
|
358
394
|
return { cutIdx: 1, reason: 'fewer-turns-than-target' };
|
|
359
395
|
}
|
|
360
396
|
|
|
@@ -418,24 +454,27 @@ export function softResetSessionFile(
|
|
|
418
454
|
}
|
|
419
455
|
const trimmed = [header, ...tail];
|
|
420
456
|
|
|
457
|
+
// Run tool-pair repair *in memory* on the trimmed entries before writing,
|
|
458
|
+
// so the on-disk update is a single atomic backup + atomic rename. Doing
|
|
459
|
+
// disk-write → repairSessionFile() (another disk-write) would mean a crash
|
|
460
|
+
// between the two leaves a partially-processed file AND a backup of the
|
|
461
|
+
// already-trimmed file rather than the true original.
|
|
462
|
+
const repaired = repairEntriesInMemory(trimmed);
|
|
463
|
+
|
|
421
464
|
const backupPath = backupFile(path);
|
|
422
|
-
const newContent =
|
|
465
|
+
const newContent = repaired.entries.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
423
466
|
atomicWrite(path, newContent);
|
|
424
467
|
|
|
425
|
-
|
|
426
|
-
// toolCall is now in the dropped section). Run repair to clean those up.
|
|
427
|
-
const postRepair = repairSessionFile(path);
|
|
428
|
-
|
|
429
|
-
const bytesAfter = readFileSync(path).length;
|
|
468
|
+
const bytesAfter = Buffer.byteLength(newContent, 'utf8');
|
|
430
469
|
return {
|
|
431
470
|
reset: true,
|
|
432
471
|
reason,
|
|
433
472
|
entriesBefore: entries.length,
|
|
434
|
-
entriesAfter:
|
|
473
|
+
entriesAfter: repaired.entries.length,
|
|
435
474
|
bytesBefore,
|
|
436
475
|
bytesAfter,
|
|
437
476
|
backupPath,
|
|
438
|
-
postRepair,
|
|
477
|
+
postRepair: repaired.report,
|
|
439
478
|
};
|
|
440
479
|
}
|
|
441
480
|
|
|
@@ -447,10 +486,14 @@ export function softResetSessionFile(
|
|
|
447
486
|
*
|
|
448
487
|
* Triggers soft-reset recovery in the memory lifecycle. Intentionally narrow:
|
|
449
488
|
* only matches the well-known overflow phrasings, not generic 4xx errors.
|
|
489
|
+
*
|
|
490
|
+
* Mirrors `isToolPairingError`'s nested-error handling: provider SDKs commonly
|
|
491
|
+
* wrap the useful text under `cause.message` or in serialized fields on
|
|
492
|
+
* Bedrock ValidationException. Stringify-search is gated on a 4xx / validation
|
|
493
|
+
* shape so we don't false-positive on noisy unrelated errors.
|
|
450
494
|
*/
|
|
451
495
|
export function isContextOverflowError(err: unknown): boolean {
|
|
452
496
|
if (!err) return false;
|
|
453
|
-
const msg = (err as { message?: string }).message ?? String(err);
|
|
454
497
|
const patterns = [
|
|
455
498
|
/prompt is too long/i,
|
|
456
499
|
/tokens?\s*[>>]\s*\d+\s*maximum/i,
|
|
@@ -458,7 +501,35 @@ export function isContextOverflowError(err: unknown): boolean {
|
|
|
458
501
|
/context length exceeded/i,
|
|
459
502
|
/maximum context length/i,
|
|
460
503
|
];
|
|
461
|
-
|
|
504
|
+
|
|
505
|
+
// 1. Top-level message.
|
|
506
|
+
const msg = (err as { message?: string }).message ?? String(err);
|
|
507
|
+
if (patterns.some(p => p.test(msg))) return true;
|
|
508
|
+
|
|
509
|
+
// 2. Walk the cause chain (a few hops — don't loop forever on circular).
|
|
510
|
+
let cur: unknown = (err as { cause?: unknown }).cause;
|
|
511
|
+
for (let hop = 0; hop < 5 && cur; hop++) {
|
|
512
|
+
const causeMsg = (cur as { message?: string }).message ?? String(cur);
|
|
513
|
+
if (patterns.some(p => p.test(causeMsg))) return true;
|
|
514
|
+
cur = (cur as { cause?: unknown }).cause;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 3. Bedrock ValidationException sometimes carries the overflow text in
|
|
518
|
+
// nested SDK fields. Only stringify-search when the error LOOKS like a 4xx
|
|
519
|
+
// validation error — mirrors the gating in isToolPairingError.
|
|
520
|
+
const name = (err as { name?: string }).name ?? '';
|
|
521
|
+
const httpStatus =
|
|
522
|
+
(err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
|
|
523
|
+
if (name === 'ValidationException' || httpStatus === 400) {
|
|
524
|
+
try {
|
|
525
|
+
const full = JSON.stringify(err);
|
|
526
|
+
if (patterns.some(p => p.test(full))) return true;
|
|
527
|
+
} catch {
|
|
528
|
+
/* circular structure — give up */
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return false;
|
|
462
533
|
}
|
|
463
534
|
|
|
464
535
|
/**
|