@inceptionstack/roundhouse 0.5.28 → 0.5.29

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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to `@inceptionstack/roundhouse` are documented here.
4
4
 
5
+ ## [0.5.29] — 2026-05-14
6
+
7
+ ### Added
8
+ - **Soft-reset recovery for already-overflowed sessions.** When a session has grown past the model's context window, normal compact cannot recover — the summarizer prompt itself overflows and `compact()` throws `prompt is too long: N > max`. v0.5.28's threshold tuning prevents *new* sessions from hitting this; this release adds graceful recovery for sessions that already crossed the line. On context-overflow detection, the memory lifecycle calls a new `agent.softReset(threadId)` capability that trims the on-disk session jsonl to its most-recent N user turns (default 8, byte-capped at 250k), reloads the session, and queues a memory re-injection on the next turn. The agent loses verbatim message history for older turns but retains its durable context (MEMORY.md, daily front-page, soul.md). No more manual surgery on stuck sessions.
9
+ - New module exports: `softResetSessionFile()` and `isContextOverflowError()` in `src/agents/shared/session-repair.ts`. New optional `softReset?(threadId)` method on `AgentAdapter` interface (no-op when not implemented — backward-compatible). PiAdapter implements it via the existing `reloadSession` path.
10
+ - 20 new tests across `session-repair.test.ts` (file-level cut/preserve/repair semantics, error classifier) and `memory.test.ts` (lifecycle wiring — success/no-op/missing-capability/non-overflow-error/throws-during-recovery). 527 tests total.
11
+
5
12
  ## [0.5.28] — 2026-05-14
6
13
 
7
14
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.28",
3
+ "version": "0.5.29",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@ import {
28
28
 
29
29
  import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent, MessageContext } from "../../types";
30
30
  import { formatMessage, extractCustomMessage, customContentToText } from "./message-format";
31
- import { isToolPairingError, repairSessionFile } from "../shared/session-repair";
31
+ import { isToolPairingError, repairSessionFile, softResetSessionFile, type SoftResetReport } from "../shared/session-repair";
32
32
  import { SESSIONS_DIR } from "../../config";
33
33
  import { DEBUG_STREAM, threadIdToDir } from "../../util";
34
34
 
@@ -662,6 +662,58 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
662
662
  });
663
663
  },
664
664
 
665
+ /**
666
+ * Soft-reset an overflowed session: trim the on-disk jsonl to its most
667
+ * recent N user turns, then reload the session in place. Used by the
668
+ * memory-lifecycle layer when compact fails with "prompt is too long"
669
+ * — the session has grown past the model's context window and the
670
+ * summarizer prompt itself can no longer fit.
671
+ *
672
+ * Returns the soft-reset report (or null if no session for threadId).
673
+ * Behavior:
674
+ * - In-memory session: returns null (nothing to trim on disk).
675
+ * - Already-trimmed session: report.reset === false, no reload.
676
+ * - Otherwise: trims file, reloads session, returns report.
677
+ *
678
+ * On reload failure, the SessionEntry is dropped from the cache so the
679
+ * next prompt() recreates it cleanly.
680
+ */
681
+ async softReset(threadId: string): Promise<SoftResetReport | null> {
682
+ return enqueue(threadId, async () => {
683
+ const entry = sessions.get(threadId);
684
+ if (!entry) return null;
685
+ const sessionFile = entry.session.sessionFile;
686
+ if (!sessionFile) {
687
+ console.warn(`[pi-agent] softReset: ${threadId} has no on-disk session file, skipping`);
688
+ return null;
689
+ }
690
+
691
+ console.warn(`[pi-agent] softReset: trimming overflowed session ${sessionFile}`);
692
+ const report = softResetSessionFile(sessionFile);
693
+ if (!report.reset) {
694
+ console.log(`[pi-agent] softReset: nothing to trim (${report.reason})`);
695
+ return report;
696
+ }
697
+ console.warn(
698
+ `[pi-agent] softReset: ${report.entriesBefore} → ${report.entriesAfter} entries, ` +
699
+ `${report.bytesBefore} → ${report.bytesAfter} bytes (${report.reason}). Backup: ${report.backupPath}`
700
+ );
701
+
702
+ // Reload the session so pi-ai re-reads the trimmed file. Drop the
703
+ // cache entry on failure so the next prompt() recreates from scratch
704
+ // rather than running against the disposed session.
705
+ try {
706
+ const reloaded = await reloadSession(entry, sessionFile);
707
+ await entry.session.dispose();
708
+ entry.session = reloaded.session;
709
+ } catch (err) {
710
+ console.error(`[pi-agent] softReset reload failed for ${threadId}:`, (err as Error).message);
711
+ sessions.delete(threadId);
712
+ }
713
+ return report;
714
+ });
715
+ },
716
+
665
717
  async abort(threadId: string): Promise<void> {
666
718
  const entry = sessions.get(threadId);
667
719
  if (entry) {
@@ -11,6 +11,8 @@ import {
11
11
  inspectSessionFile,
12
12
  repairSessionFile,
13
13
  isToolPairingError,
14
+ softResetSessionFile,
15
+ isContextOverflowError,
14
16
  } from './session-repair';
15
17
 
16
18
  // ---------- fixtures ----------
@@ -376,3 +378,175 @@ describe('session-repair', () => {
376
378
  });
377
379
  });
378
380
  });
381
+
382
+ // ============================================================
383
+ // softResetSessionFile
384
+ // ============================================================
385
+
386
+ describe('softResetSessionFile', () => {
387
+ function userTurn(idPrefix: string, parentId: string | null) {
388
+ // A user turn = user msg + assistant text reply (no tool calls, so cuts
389
+ // are clean; tool-pairing edge cases are covered by repair tests).
390
+ return [
391
+ userMsg(`${idPrefix}u`, parentId, `text-${idPrefix}`),
392
+ {
393
+ type: 'message',
394
+ id: `${idPrefix}a`,
395
+ parentId: `${idPrefix}u`,
396
+ timestamp: '2026-05-01T00:00:04Z',
397
+ message: {
398
+ role: 'assistant',
399
+ content: [{ type: 'text', text: `reply-${idPrefix}` }],
400
+ api: 'bedrock-converse-stream',
401
+ provider: 'amazon-bedrock',
402
+ model: 'claude',
403
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
404
+ stopReason: 'endTurn',
405
+ timestamp: 4,
406
+ },
407
+ },
408
+ ];
409
+ }
410
+
411
+ it('softResetSessionFile_OnSessionWithMoreTurnsThanTarget_KeepsHeaderAndRecentTurns', () => {
412
+ // Arrange: 10 user turns, target keepRecentUserTurns=3.
413
+ const entries: object[] = [HEADER, MODEL_CHANGE];
414
+ let parent: string | null = 'mc-1';
415
+ for (let i = 1; i <= 10; i++) {
416
+ const turn = userTurn(`t${i}`, parent);
417
+ entries.push(...turn);
418
+ parent = `t${i}a`;
419
+ }
420
+ const path = tmpJsonl(entries);
421
+
422
+ // Act
423
+ const report = softResetSessionFile(path, { keepRecentUserTurns: 3 });
424
+
425
+ // Assert: report indicates reset, file shrunk, header preserved, last 3 user msgs present.
426
+ expect(report.reset).toBe(true);
427
+ expect(report.entriesAfter).toBeLessThan(report.entriesBefore);
428
+ expect(report.bytesAfter).toBeLessThan(report.bytesBefore);
429
+ expect(report.backupPath).toBeDefined();
430
+ expect(existsSync(report.backupPath!)).toBe(true);
431
+
432
+ const trimmed = parseSessionFile(path);
433
+ // Header always preserved.
434
+ expect(trimmed[0].type).toBe('session');
435
+ // Last 3 user turns present.
436
+ const userIds = trimmed.filter(e => e.message?.role === 'user').map(e => e.id);
437
+ expect(userIds).toEqual(['t8u', 't9u', 't10u']);
438
+ // First kept entry's parentId reset to null (no dangling pointer).
439
+ const firstAfterHeader = trimmed[1];
440
+ expect(firstAfterHeader.parentId).toBeNull();
441
+ });
442
+
443
+ it('softResetSessionFile_OnSessionSmallerThanTarget_ReturnsResetFalseAndDoesNotMutate', () => {
444
+ // Arrange: 2 user turns, target keepRecentUserTurns=8.
445
+ const entries: object[] = [HEADER, MODEL_CHANGE, ...userTurn('a', 'mc-1'), ...userTurn('b', 'aa')];
446
+ const path = tmpJsonl(entries);
447
+ const before = readFileSync(path, 'utf8');
448
+
449
+ // Act
450
+ const report = softResetSessionFile(path, { keepRecentUserTurns: 8 });
451
+
452
+ // Assert: no reset, file untouched, no backup.
453
+ expect(report.reset).toBe(false);
454
+ expect(report.backupPath).toBeUndefined();
455
+ expect(readFileSync(path, 'utf8')).toBe(before);
456
+ });
457
+
458
+ it('softResetSessionFile_OnTinySession_ReturnsResetFalseWithReason', () => {
459
+ // Arrange: only header.
460
+ const path = tmpJsonl([HEADER]);
461
+
462
+ // Act
463
+ const report = softResetSessionFile(path);
464
+
465
+ // Assert
466
+ expect(report.reset).toBe(false);
467
+ expect(report.reason).toContain('too-small');
468
+ });
469
+
470
+ it('softResetSessionFile_OnSessionWithOrphanedToolPairsAfterCut_AlsoRunsRepair', () => {
471
+ // Arrange: a session where the tail contains a toolResult whose toolCall
472
+ // sits in the older (dropped) section. After the cut the toolResult is
473
+ // orphaned — soft-reset must clean it up via the post-cut repair.
474
+ const oldToolCall = assistantToolCall('a-old', 'mc-1', 'call-X');
475
+ const orphanedResult = {
476
+ type: 'message',
477
+ id: 'tr-1',
478
+ parentId: 'a-old',
479
+ timestamp: '2026-05-01T00:00:05Z',
480
+ message: { role: 'toolResult', toolCallId: 'call-X', content: 'ok', timestamp: 5 },
481
+ };
482
+ const entries: object[] = [HEADER, MODEL_CHANGE, userMsg('u-old', 'mc-1', 'old'), oldToolCall];
483
+ let parent: string | null = 'a-old';
484
+ // Push 5 fresh turns so the cut leaves us in tail.
485
+ for (let i = 1; i <= 5; i++) {
486
+ entries.push(...userTurn(`f${i}`, parent));
487
+ parent = `f${i}a`;
488
+ }
489
+ // Insert the orphaned result mid-tail (kept by cut, but call is dropped).
490
+ entries.splice(6, 0, orphanedResult);
491
+ const path = tmpJsonl(entries);
492
+
493
+ // Act
494
+ const report = softResetSessionFile(path, { keepRecentUserTurns: 3 });
495
+
496
+ // Assert: reset succeeded AND post-cut repair fired.
497
+ expect(report.reset).toBe(true);
498
+ expect(report.postRepair).toBeDefined();
499
+ // Final file is internally consistent (no orphans).
500
+ expect(inspectSessionFile(path).hasOrphans).toBe(false);
501
+ });
502
+
503
+ it('softResetSessionFile_OnNonexistentFile_Throws', () => {
504
+ // Arrange/Act/Assert: documents the precondition.
505
+ expect(() => softResetSessionFile('/nonexistent/path.jsonl')).toThrow(/not found/);
506
+ });
507
+
508
+ it('softResetSessionFile_BytesCapHonored_StopsCutAtCap', () => {
509
+ // Arrange: each turn is small but we set a tiny byte cap so we cut early.
510
+ const entries: object[] = [HEADER, MODEL_CHANGE];
511
+ let parent: string | null = 'mc-1';
512
+ for (let i = 1; i <= 20; i++) {
513
+ entries.push(...userTurn(`t${i}`, parent));
514
+ parent = `t${i}a`;
515
+ }
516
+ const path = tmpJsonl(entries);
517
+
518
+ // Act
519
+ const report = softResetSessionFile(path, { keepRecentUserTurns: 100, maxBytes: 800 });
520
+
521
+ // Assert: reset triggered by byte cap (we asked for 100 turns we don't have,
522
+ // but byte cap kicks in first).
523
+ expect(report.reset).toBe(true);
524
+ expect(report.reason).toMatch(/byte-cap|fewer-turns/);
525
+ expect(report.bytesAfter).toBeLessThan(report.bytesBefore);
526
+ });
527
+ });
528
+
529
+ // ============================================================
530
+ // isContextOverflowError
531
+ // ============================================================
532
+
533
+ describe('isContextOverflowError', () => {
534
+ it.each([
535
+ ['prompt is too long: 212776 tokens > 200000 maximum', true],
536
+ ['Validation error: input is too long', true],
537
+ ['context length exceeded for this model', true],
538
+ ['maximum context length reached', true],
539
+ ['tokens > 200000 maximum', true],
540
+ ['toolUse without toolResult', false], // pairing error — different recovery
541
+ ['random network failure', false],
542
+ ['', false],
543
+ ])('classifies %p as overflow=%p', (msg, expected) => {
544
+ expect(isContextOverflowError(new Error(msg))).toBe(expected);
545
+ });
546
+
547
+ it('returns false for null/undefined/non-Error inputs', () => {
548
+ expect(isContextOverflowError(null)).toBe(false);
549
+ expect(isContextOverflowError(undefined)).toBe(false);
550
+ expect(isContextOverflowError({})).toBe(false);
551
+ });
552
+ });
@@ -286,6 +286,181 @@ export function repairSessionFile(path: string): SessionRepairReport {
286
286
  };
287
287
  }
288
288
 
289
+ // ── Soft reset (recovery from already-overflowed sessions) ──────────────
290
+
291
+ /**
292
+ * When a session has grown past the model's context window, normal compact
293
+ * cannot recover — the summarizer prompt itself overflows. Soft reset trims
294
+ * the session jsonl on disk to its most-recent N user turns, drops everything
295
+ * older, and re-runs the tool-pairing repair so what's left is internally
296
+ * consistent.
297
+ *
298
+ * Trade-off: loses fidelity for older turns. The roundhouse memory layer
299
+ * (MEMORY.md, daily front-page) re-injects on the next turn, so the agent
300
+ * still has its durable context — just not the verbatim message history.
301
+ *
302
+ * Conservative defaults aim for ~30–40% of a 200k window so the next compact
303
+ * has ample room to summarize.
304
+ */
305
+ export interface SoftResetOptions {
306
+ /** Keep at most this many user turns from the tail (default: 8). */
307
+ keepRecentUserTurns?: number;
308
+ /** Hard cap on jsonl bytes after trim (default: 250_000 ≈ 60–80k tokens). */
309
+ maxBytes?: number;
310
+ }
311
+
312
+ export interface SoftResetReport {
313
+ reset: boolean;
314
+ reason: string;
315
+ entriesBefore: number;
316
+ entriesAfter: number;
317
+ bytesBefore: number;
318
+ bytesAfter: number;
319
+ backupPath?: string;
320
+ /** Tool-pairing repair report on the trimmed file (orphans created by the cut). */
321
+ postRepair?: SessionRepairReport;
322
+ }
323
+
324
+ /**
325
+ * Find a safe cut index in the entries array. Walk backwards from the end
326
+ * looking for user message entries; the cut sits *just before* the Nth
327
+ * most-recent user message we encounter. Returns the index of the first
328
+ * entry to KEEP (i.e. all entries[0..cutIdx) are dropped).
329
+ *
330
+ * If we can't find enough user messages, returns 1 to keep everything except
331
+ * the session header (which we preserve separately).
332
+ */
333
+ function findSoftResetCutIndex(
334
+ entries: SessionFileEntry[],
335
+ keepRecentUserTurns: number,
336
+ maxBytes: number,
337
+ ): { cutIdx: number; reason: string } {
338
+ let userTurnsSeen = 0;
339
+ let bytesAccumulated = 0;
340
+ // Scan tail-to-head, stop when we've collected enough user turns OR exceeded byte budget.
341
+ for (let i = entries.length - 1; i >= 0; i--) {
342
+ const e = entries[i];
343
+ bytesAccumulated += JSON.stringify(e).length + 1; // +1 for newline
344
+ if (e.type === 'message' && e.message?.role === 'user') {
345
+ userTurnsSeen++;
346
+ if (userTurnsSeen >= keepRecentUserTurns) {
347
+ return { cutIdx: i, reason: `kept-${userTurnsSeen}-user-turns` };
348
+ }
349
+ }
350
+ // Byte cap is a safety net for sessions where a single turn is enormous
351
+ // (e.g. one turn dumped a 200k file). Stop once we'd exceed the cap.
352
+ if (bytesAccumulated > maxBytes && userTurnsSeen > 0) {
353
+ return { cutIdx: i + 1, reason: `byte-cap-${bytesAccumulated}b` };
354
+ }
355
+ }
356
+ // Not enough user turns in the file — keep everything except header.
357
+ // (Header is always at index 0 and is preserved by the writer separately.)
358
+ return { cutIdx: 1, reason: 'fewer-turns-than-target' };
359
+ }
360
+
361
+ /**
362
+ * Soft-reset a pi-ai session jsonl: keep the most-recent N user turns + their
363
+ * surrounding messages, drop everything older. Always preserves the session
364
+ * header (entries[0]). Re-parents the first kept entry to null so the tree
365
+ * remains valid. Re-runs tool-pairing repair on the trimmed file because
366
+ * the cut likely orphaned some toolCall/toolResult pairs.
367
+ *
368
+ * Atomic + backup: same safety pattern as repairSessionFile.
369
+ *
370
+ * @returns report describing what was reset, or `{reset:false}` if nothing to do.
371
+ */
372
+ export function softResetSessionFile(
373
+ path: string,
374
+ options: SoftResetOptions = {},
375
+ ): SoftResetReport {
376
+ if (!existsSync(path)) {
377
+ throw new Error(`Session file not found: ${path}`);
378
+ }
379
+
380
+ const keepRecentUserTurns = options.keepRecentUserTurns ?? 8;
381
+ const maxBytes = options.maxBytes ?? 250_000;
382
+
383
+ const entries = parseSessionFile(path);
384
+ const bytesBefore = readFileSync(path).length;
385
+
386
+ // Need at least header + a couple of messages to be worth resetting.
387
+ if (entries.length < 4) {
388
+ return {
389
+ reset: false,
390
+ reason: 'session-too-small',
391
+ entriesBefore: entries.length,
392
+ entriesAfter: entries.length,
393
+ bytesBefore,
394
+ bytesAfter: bytesBefore,
395
+ };
396
+ }
397
+
398
+ const { cutIdx, reason } = findSoftResetCutIndex(entries, keepRecentUserTurns, maxBytes);
399
+
400
+ // No-op if cut is already at the start (nothing to drop besides header).
401
+ if (cutIdx <= 1) {
402
+ return {
403
+ reset: false,
404
+ reason: `cut-at-start (${reason})`,
405
+ entriesBefore: entries.length,
406
+ entriesAfter: entries.length,
407
+ bytesBefore,
408
+ bytesAfter: bytesBefore,
409
+ };
410
+ }
411
+
412
+ // Build trimmed entries: header + tail.
413
+ // Re-parent the first kept tail entry to null so the tree root is intact.
414
+ const header = entries[0];
415
+ const tail = entries.slice(cutIdx);
416
+ if (tail.length > 0 && tail[0].parentId !== undefined) {
417
+ tail[0] = { ...tail[0], parentId: null };
418
+ }
419
+ const trimmed = [header, ...tail];
420
+
421
+ const backupPath = backupFile(path);
422
+ const newContent = trimmed.map(e => JSON.stringify(e)).join('\n') + '\n';
423
+ atomicWrite(path, newContent);
424
+
425
+ // The cut may have orphaned tool pairs (e.g. toolResult kept but its
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;
430
+ return {
431
+ reset: true,
432
+ reason,
433
+ entriesBefore: entries.length,
434
+ entriesAfter: trimmed.length - postRepair.droppedEntryIds.length,
435
+ bytesBefore,
436
+ bytesAfter,
437
+ backupPath,
438
+ postRepair,
439
+ };
440
+ }
441
+
442
+ // ── Error classifiers ────────────────────────────────────────────────────
443
+
444
+ /**
445
+ * Detect whether an error from pi-ai / the model provider indicates the
446
+ * session has grown past the model's context window (input > max).
447
+ *
448
+ * Triggers soft-reset recovery in the memory lifecycle. Intentionally narrow:
449
+ * only matches the well-known overflow phrasings, not generic 4xx errors.
450
+ */
451
+ export function isContextOverflowError(err: unknown): boolean {
452
+ if (!err) return false;
453
+ const msg = (err as { message?: string }).message ?? String(err);
454
+ const patterns = [
455
+ /prompt is too long/i,
456
+ /tokens?\s*[>>]\s*\d+\s*maximum/i,
457
+ /input is too long/i,
458
+ /context length exceeded/i,
459
+ /maximum context length/i,
460
+ ];
461
+ return patterns.some(p => p.test(msg));
462
+ }
463
+
289
464
  /**
290
465
  * Detect whether an error from pi-ai / the model provider indicates a
291
466
  * tool-pairing mismatch that can be recovered by session repair.
@@ -16,6 +16,7 @@ import { shouldInjectMemory, classifyContextPressure, isSoftFlushOnCooldown } fr
16
16
  import { buildMemoryInjection, injectMemoryIntoMessage } from "./inject";
17
17
  import { buildFlushPrompt } from "./prompts";
18
18
  import { bootstrapMemoryFiles } from "./bootstrap";
19
+ import { isContextOverflowError } from "../agents/shared/session-repair";
19
20
  import { appendFile, mkdir } from "node:fs/promises";
20
21
  import { join } from "node:path";
21
22
  import { homedir } from "node:os";
@@ -359,6 +360,35 @@ export async function flushMemoryThenCompact(
359
360
  } catch (err) {
360
361
  const errMsg = (err as Error).message;
361
362
  console.error(`[memory] flush+compact failed for ${threadId}:`, errMsg);
363
+
364
+ // Recovery path: when the session has grown past the model's context
365
+ // window, the summarizer prompt itself overflows and compact() throws
366
+ // "prompt is too long". Threshold tuning prevents *new* sessions from
367
+ // hitting this, but does nothing for sessions already past the line.
368
+ // Trim the on-disk session jsonl to its most recent N user turns and
369
+ // mark the next turn for a fresh memory injection. We do NOT retry
370
+ // compact inline — that would extend the thread lock for another long
371
+ // operation. The trimmed session is small enough that the next user
372
+ // turn proceeds normally; any soft pressure from injected memory will
373
+ // trigger a regular compact later.
374
+ let softResetAttempted = false;
375
+ let softResetSucceeded = false;
376
+ if (isContextOverflowError(err) && agent.softReset) {
377
+ softResetAttempted = true;
378
+ try {
379
+ await onProgress?.("♻️ Session overflowed — soft-resetting to recent turns...");
380
+ const report = await agent.softReset(threadId);
381
+ if (report?.reset) {
382
+ softResetSucceeded = true;
383
+ console.warn(`[memory] soft-reset recovered ${threadId} from overflow`);
384
+ } else {
385
+ console.warn(`[memory] soft-reset returned no-op for ${threadId} (${(report as { reason?: string } | null)?.reason ?? "unknown"})`);
386
+ }
387
+ } catch (resetErr) {
388
+ console.error(`[memory] soft-reset failed for ${threadId}:`, (resetErr as Error).message);
389
+ }
390
+ }
391
+
362
392
  appendCompactLog({
363
393
  threadId,
364
394
  level,
@@ -371,11 +401,22 @@ export async function flushMemoryThenCompact(
371
401
  totalMs: Date.now() - t0,
372
402
  model: flushModel ?? "default",
373
403
  status: "failed",
374
- error: errMsg.slice(0, 500),
404
+ error: (softResetAttempted
405
+ ? `${softResetSucceeded ? "soft-reset-recovered" : "soft-reset-failed"}: ${errMsg}`
406
+ : errMsg).slice(0, 500),
375
407
  });
376
- // Mark pending so we retry on next turn. Reuse the state we already loaded.
408
+
377
409
  try {
378
- stateBeforeCompact.pendingCompact = effectiveLevel;
410
+ if (softResetSucceeded) {
411
+ // Soft reset cleared the overflow. Mark the next turn for memory
412
+ // re-injection so the agent has its durable context, and clear the
413
+ // pendingCompact flag — there's nothing left to compact now.
414
+ stateBeforeCompact.forceInjectReason = "after-soft-reset";
415
+ stateBeforeCompact.pendingCompact = undefined;
416
+ } else {
417
+ // Re-arm pendingCompact so the next turn retries.
418
+ stateBeforeCompact.pendingCompact = effectiveLevel;
419
+ }
379
420
  await saveThreadMemoryState(threadId, stateBeforeCompact);
380
421
  } catch {}
381
422
  return null;
@@ -56,7 +56,7 @@ export interface ThreadMemoryState {
56
56
  /** Local date when memory was last injected (detects day boundary) */
57
57
  lastSeenLocalDate?: string;
58
58
  /** Force re-injection on next turn */
59
- forceInjectReason?: "new-session" | "after-compact" | "manual";
59
+ forceInjectReason?: "new-session" | "after-compact" | "after-soft-reset" | "manual";
60
60
  /** When last compaction happened */
61
61
  lastCompactAt?: string;
62
62
  /** Pending compaction level (from interrupted flush) */
package/src/types.ts CHANGED
@@ -122,6 +122,17 @@ export interface AgentAdapter {
122
122
  /** Compact with a specific model. */
123
123
  compactWithModel?(threadId: string, modelId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null>;
124
124
 
125
+ /**
126
+ * Soft-reset an overflowed session by trimming on-disk history to the
127
+ * most-recent few turns. Called by memory lifecycle when compact() fails
128
+ * because the session itself is too large for the model's context window.
129
+ *
130
+ * Returns a report describing what was trimmed (shape is adapter-specific
131
+ * but always has `reset: boolean`), or null if not applicable.
132
+ * Adapters without on-disk sessions (in-memory only) should return null.
133
+ */
134
+ softReset?(threadId: string): Promise<{ reset: boolean } | null>;
135
+
125
136
  /** Abort the current agent run for a thread. */
126
137
  abort?(threadId: string): Promise<void>;
127
138