@gaunt-sloth/core 2.0.0-beta.4 → 2.0.0-beta.6

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.
Files changed (94) hide show
  1. package/dist/config/schema.d.ts +4 -0
  2. package/dist/config/schema.js +64 -2
  3. package/dist/config/schema.js.map +1 -1
  4. package/dist/config/tokenBudget.d.ts +88 -0
  5. package/dist/config/tokenBudget.js +155 -0
  6. package/dist/config/tokenBudget.js.map +1 -0
  7. package/dist/config/types.d.ts +33 -4
  8. package/dist/config/types.js.map +1 -1
  9. package/dist/config.d.ts +1 -0
  10. package/dist/config.js +5 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/core/GthAbstractAgent.d.ts +24 -0
  13. package/dist/core/GthAbstractAgent.js +37 -1
  14. package/dist/core/GthAbstractAgent.js.map +1 -1
  15. package/dist/core/GthAgentRunner.d.ts +216 -1
  16. package/dist/core/GthAgentRunner.js +424 -3
  17. package/dist/core/GthAgentRunner.js.map +1 -1
  18. package/dist/core/GthLangChainAgent.d.ts +196 -0
  19. package/dist/core/GthLangChainAgent.js +392 -2
  20. package/dist/core/GthLangChainAgent.js.map +1 -1
  21. package/dist/core/approvals/approvalRequest.d.ts +142 -0
  22. package/dist/core/approvals/approvalRequest.js +198 -3
  23. package/dist/core/approvals/approvalRequest.js.map +1 -1
  24. package/dist/core/approvals/conversationGrants.d.ts +60 -0
  25. package/dist/core/approvals/conversationGrants.js +77 -0
  26. package/dist/core/approvals/conversationGrants.js.map +1 -0
  27. package/dist/core/approvals/grants.d.ts +16 -0
  28. package/dist/core/approvals/grants.js +20 -5
  29. package/dist/core/approvals/grants.js.map +1 -1
  30. package/dist/core/compaction.d.ts +181 -0
  31. package/dist/core/compaction.js +293 -0
  32. package/dist/core/compaction.js.map +1 -0
  33. package/dist/core/compactionThreshold.d.ts +158 -0
  34. package/dist/core/compactionThreshold.js +183 -0
  35. package/dist/core/compactionThreshold.js.map +1 -0
  36. package/dist/core/contextWindow.d.ts +146 -0
  37. package/dist/core/contextWindow.js +256 -0
  38. package/dist/core/contextWindow.js.map +1 -0
  39. package/dist/core/exitOutputChannel.d.ts +51 -0
  40. package/dist/core/exitOutputChannel.js +65 -0
  41. package/dist/core/exitOutputChannel.js.map +1 -0
  42. package/dist/core/refusal.d.ts +17 -2
  43. package/dist/core/refusal.js +80 -14
  44. package/dist/core/refusal.js.map +1 -1
  45. package/dist/core/runStats.d.ts +1 -1
  46. package/dist/core/terminationNotice.d.ts +8 -0
  47. package/dist/core/terminationNotice.js +10 -4
  48. package/dist/core/terminationNotice.js.map +1 -1
  49. package/dist/core/terminationReason.d.ts +28 -0
  50. package/dist/core/terminationReason.js +27 -0
  51. package/dist/core/terminationReason.js.map +1 -1
  52. package/dist/core/types.d.ts +35 -1
  53. package/dist/core/types.js.map +1 -1
  54. package/dist/history/checkpointRetention.d.ts +279 -0
  55. package/dist/history/checkpointRetention.js +567 -0
  56. package/dist/history/checkpointRetention.js.map +1 -0
  57. package/dist/history/checkpointSaver.d.ts +93 -0
  58. package/dist/history/checkpointSaver.js +464 -0
  59. package/dist/history/checkpointSaver.js.map +1 -0
  60. package/dist/history/historyEnabled.d.ts +27 -0
  61. package/dist/history/historyEnabled.js +23 -0
  62. package/dist/history/historyEnabled.js.map +1 -0
  63. package/dist/history/historyFormat.d.ts +27 -0
  64. package/dist/history/historyFormat.js +125 -2
  65. package/dist/history/historyFormat.js.map +1 -1
  66. package/dist/history/historyStore.d.ts +61 -0
  67. package/dist/history/historyStore.js +180 -7
  68. package/dist/history/historyStore.js.map +1 -1
  69. package/dist/history/recordSession.d.ts +84 -22
  70. package/dist/history/recordSession.js +187 -12
  71. package/dist/history/recordSession.js.map +1 -1
  72. package/dist/history/sessionCheckpointer.d.ts +48 -0
  73. package/dist/history/sessionCheckpointer.js +200 -0
  74. package/dist/history/sessionCheckpointer.js.map +1 -0
  75. package/dist/index.d.ts +6 -0
  76. package/dist/index.js +8 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/providers/modelCatalog.d.ts +14 -0
  79. package/dist/providers/modelCatalog.js +4 -0
  80. package/dist/providers/modelCatalog.js.map +1 -1
  81. package/dist/providers/modelDiscovery.d.ts +3 -1
  82. package/dist/providers/modelDiscovery.js +22 -8
  83. package/dist/providers/modelDiscovery.js.map +1 -1
  84. package/dist/providers/ollama.js +3 -19
  85. package/dist/providers/ollama.js.map +1 -1
  86. package/dist/runtime/conversation.js +7 -1
  87. package/dist/runtime/conversation.js.map +1 -1
  88. package/dist/runtime/singleShot.js +6 -1
  89. package/dist/runtime/singleShot.js.map +1 -1
  90. package/dist/utils/consoleUtils.d.ts +77 -0
  91. package/dist/utils/consoleUtils.js +81 -0
  92. package/dist/utils/consoleUtils.js.map +1 -1
  93. package/package.json +2 -2
  94. package/schema/gsloth-config.schema.json +33 -14
@@ -7,7 +7,7 @@ import { classifyCommand } from '#src/core/shell/arity.js';
7
7
  import { describeAbstention } from '#src/core/shell/abstention.js';
8
8
  import { normalizeCommand } from '#src/core/shell/normalize.js';
9
9
  import { ApprovalStopError, AttackHaltError, NonInteractiveEscalationError, } from '#src/core/shell/approvalStop.js';
10
- import { attachTerminationReason, classifyThrownTermination, terminationReason, terminationReasonOf, } from '#src/core/terminationReason.js';
10
+ import { attachTerminationReason, classifyThrownTermination, replaceTerminationReason, terminationPosture, terminationReason, terminationReasonOf, } from '#src/core/terminationReason.js';
11
11
  import { terminationLogLine } from '#src/core/terminationNotice.js';
12
12
  import { applyDestructiveFloor, effectivePreflightFloorFinding, isBelowDestructiveFloor, isNegotiableCall, isRaterTimeout, mapAllowMatchedVerdictToAction, mapVerdictToAction, openWorldToolFloorReason, preflightFloorFinding, RATER_DEFAULT_TIMEOUT_MS, rateShellCommand, } from '#src/core/shell/rater.js';
13
13
  import { RaterHealth } from '#src/core/shell/raterHealth.js';
@@ -33,6 +33,7 @@ import { getNewRunnableConfig } from '#src/utils/llmUtils.js';
33
33
  import { initDebugLogging, debugLog, debugLogError, debugLogObject, } from '#src/utils/debugUtils.js';
34
34
  import { updateCrashContext } from '#src/utils/crashHandler.js';
35
35
  import { setToolDisplayConfig } from '#src/core/toolDisplay.js';
36
+ import { compactMessages, conversationSize, createModelSummarizer, DEFAULT_KEEP_RECENT, } from '#src/core/compaction.js';
36
37
  /**
37
38
  * GS2-48 — how many trailing messages of the in-flight turn to hand the crash handler as the
38
39
  * transcript tail. A crash file is triage, not the full session, so only the last few messages are
@@ -228,6 +229,12 @@ export class GthAgentRunner {
228
229
  * after {@link cleanup} has already dropped the agent.
229
230
  */
230
231
  agentFinishReasons = [];
232
+ /**
233
+ * GS2-23 — how many turns are being driven right now, through either driver. Read by
234
+ * {@link compactConversation}, which refuses to rewrite the thread underneath a running turn:
235
+ * the graph would be writing checkpoints for the turn while the compaction wrote a competing one.
236
+ */
237
+ turnsInFlight = 0;
231
238
  /**
232
239
  * CFG-27 — the runtime, session-scoped approvals posture, seeded at {@link init} from
233
240
  * {@link resolveApprovals} and thereafter switchable for the session by `/approvals <rung>`.
@@ -284,6 +291,15 @@ export class GthAgentRunner {
284
291
  * both are handed to the same matcher, so a runtime refusal and a declared one are one list.
285
292
  */
286
293
  denyGrants = new ApprovalGrantStore();
294
+ /**
295
+ * GS2-20 — told whenever the session-scoped contents of {@link sessionGrants} or
296
+ * {@link denyGrants} change, so the session can write them against its conversation and a resume
297
+ * can restore them. Set via {@link setSessionGrantsListener}; `null` means nobody records them,
298
+ * which is where every surface without a conversation row (ACP, AG-UI, a single-shot run) stays.
299
+ * Deliberately NOT fired by {@link resumeConversation}: what it installs was read from the store
300
+ * a moment ago, and writing it straight back would be a no-op with a disk write in it.
301
+ */
302
+ sessionGrantsListener = null;
287
303
  /**
288
304
  * The persisted (`always`) grant store, loaded lazily on first use from
289
305
  * `.gsloth/.gsloth-settings/shell-allowlist.json`. Null until a gated call actually needs it, and
@@ -560,6 +576,9 @@ export class GthAgentRunner {
560
576
  const removedFromFile = target.origin === 'persisted'
561
577
  ? (this.getPersistedDenials()?.remove(target.entry) ?? false)
562
578
  : false;
579
+ // GS2-20 — a lifted session refusal must leave the conversation's record too, or a resume
580
+ // would put back the very refusal the person just removed.
581
+ this.notifySessionGrantsChanged();
563
582
  const key = renderApprovalEntryObject(target.entry);
564
583
  return {
565
584
  outcome: 'lifted',
@@ -612,6 +631,106 @@ export class GthAgentRunner {
612
631
  }
613
632
  return grants;
614
633
  }
634
+ /**
635
+ * GS2-20 — **the grants that belong to the conversation**: every `session`-scoped entry in the
636
+ * two runtime stores, deep-copied on the way out for the reason {@link getGrants} gives.
637
+ *
638
+ * `always` entries are deliberately left out. They mirror the project's allow-list / deny-list
639
+ * files, which every run reads for itself; recording them against the conversation as well would
640
+ * make one decision two records that can disagree, and a resume that restored a lifted one would
641
+ * resurrect a refusal the person had already removed. What is here is exactly what a fresh
642
+ * process would otherwise have lost.
643
+ */
644
+ getSessionScopedGrants() {
645
+ const copy = (grant) => ({
646
+ ...grant,
647
+ entry: copyApprovalEntry(grant.entry),
648
+ ...(grant.annotations ? { annotations: { ...grant.annotations } } : {}),
649
+ });
650
+ return {
651
+ allow: this.sessionGrants
652
+ .list()
653
+ .filter((grant) => grant.scope === 'session')
654
+ .map(copy),
655
+ deny: this.denyGrants
656
+ .list()
657
+ .filter((grant) => grant.scope === 'session')
658
+ .map(copy),
659
+ };
660
+ }
661
+ /**
662
+ * GS2-20 — hear about every change to the conversation's grants ({@link getSessionScopedGrants}),
663
+ * after it has landed. The session records them against its conversation row, which is how a
664
+ * grant outlives the process and reaches a resume. Pass `null` to stop listening.
665
+ */
666
+ setSessionGrantsListener(listener) {
667
+ this.sessionGrantsListener = listener;
668
+ }
669
+ /** Fire the listener; a listener that throws must not turn a recorded grant into a failed turn. */
670
+ notifySessionGrantsChanged() {
671
+ try {
672
+ this.sessionGrantsListener?.();
673
+ }
674
+ catch {
675
+ /* fail-soft: recording a grant is a side benefit, never the critical path of the gate */
676
+ }
677
+ }
678
+ /**
679
+ * GS2-20 — **re-enter a stored conversation: its thread, and its grants.** The ONE seam both
680
+ * `--resume <id>` (at boot, after {@link init}) and `/resume <id>` (mid-session) go through, so
681
+ * the two spellings cannot come to mean different things.
682
+ *
683
+ * The thread half is {@link resetThread}'s rotation pointed at a stored id instead of a fresh
684
+ * one: the durable checkpointer is one database serving any thread, so re-entering a conversation
685
+ * needs no new saver, only `configurable.thread_id` naming the thread whose checkpoint holds its
686
+ * state. The negotiation window and the rater's noted clarifications go with the old thread for
687
+ * the reason {@link resetThread} gives — they are context of the conversation being LEFT.
688
+ *
689
+ * The grants half replaces the session-scoped grants with the conversation's own. Replaces, not
690
+ * adds: a grant's lifetime is the conversation, so what the session granted while it was in
691
+ * another conversation does not follow it here — that stays recorded against the conversation it
692
+ * was made in. The `always` mirrors stay, because the project files they mirror are still in
693
+ * force. Every restored grant is stamped `session` whatever the document said, since that is the
694
+ * only scope this seam is handed and the only one the stores should hold for it.
695
+ *
696
+ * The listener is NOT fired: what was installed is what the store already holds.
697
+ *
698
+ * **Refuses while a turn is running, and refuses a graph suspended on a pending tool approval**
699
+ * — the same two refusals as {@link compactConversation}, for the same reason: the runnable
700
+ * config is what the in-flight work resumes through. Measured without the guard: a mid-turn
701
+ * call returned normally, the turn's approval `Command({ resume })` then went to the OTHER
702
+ * thread, the gated tool never ran, the turn died as "Model returned an empty response after
703
+ * tool execution" — a failure blamed on the model — and the thread being resumed INTO received
704
+ * a checkpoint from the turn being left. Neither shipped surface can reach this (`/resume` is
705
+ * idle-only on both), so this is the seam refusing on its own behalf, for its next caller.
706
+ */
707
+ async resumeConversation(target) {
708
+ if (!this.agent || !this.config || !this.runConfig) {
709
+ throw new Error('AgentRunner not initialized. Call init() first.');
710
+ }
711
+ if (this.turnsInFlight > 0) {
712
+ throw new Error('A turn is still running; wait for it to finish before resuming another conversation.');
713
+ }
714
+ const pendingApprovals = (await this.agent.getPendingToolInterrupts?.(this.runConfig)) ?? [];
715
+ if (pendingApprovals.length > 0) {
716
+ throw new Error('A tool approval is still pending; answer it before resuming another conversation.');
717
+ }
718
+ this.rotateThread(target.threadId);
719
+ for (const grant of this.sessionGrants.list()) {
720
+ if (grant.scope === 'session')
721
+ this.sessionGrants.remove(grant.entry);
722
+ }
723
+ for (const grant of this.denyGrants.list()) {
724
+ if (grant.scope === 'session')
725
+ this.denyGrants.remove(grant.entry);
726
+ }
727
+ for (const grant of target.grants.allow) {
728
+ this.sessionGrants.add({ ...grant, scope: 'session' });
729
+ }
730
+ for (const grant of target.grants.deny) {
731
+ this.denyGrants.add({ ...grant, scope: 'session' });
732
+ }
733
+ }
615
734
  /**
616
735
  * §4.7.1 — **which of each server's annotation hints this session believes**, for display.
617
736
  *
@@ -792,6 +911,16 @@ export class GthAgentRunner {
792
911
  initDebugLogging(configIn.debugLog ?? false);
793
912
  debugLog(`Initializing GthAgentRunner with command: ${command || 'default'}`);
794
913
  this.runConfig = getNewRunnableConfig();
914
+ // GS2-20 — drive a caller-supplied thread when there is one, so the durable checkpointer writes
915
+ // under the id the session has already recorded against its conversation (and, on a resume,
916
+ // reads back the state stored there). Overlaid on the minted config rather than replacing it, so
917
+ // the recursion limit and anything else `getNewRunnableConfig` sets survive.
918
+ if (options?.threadId) {
919
+ this.runConfig = {
920
+ ...this.runConfig,
921
+ configurable: { ...this.runConfig.configurable, thread_id: options.threadId },
922
+ };
923
+ }
795
924
  debugLogObject('Runnable Config', this.runConfig);
796
925
  this.warnIfSubagentsCannotBeHonored(configIn, command ?? options?.owningCommand);
797
926
  this.agent = this.agentFactory(this.statusUpdate, this.resolvers);
@@ -850,6 +979,25 @@ export class GthAgentRunner {
850
979
  this.negotiation.noteUserMessages(humanMessageTexts(messages));
851
980
  debugLog('Processing messages...');
852
981
  debugLogObject('Input Messages', messages);
982
+ return this.runTurn(messages, 0);
983
+ }
984
+ /**
985
+ * The turn itself, separated from the per-turn bookkeeping above so [[EXT-160]] can run it twice.
986
+ *
987
+ * `attempt` is 0 for the turn the user asked for and 1 for the single retry that follows a
988
+ * compaction; nothing else calls this. The retry passes an EMPTY message list, because the user's
989
+ * message is already in the graph's state — the input step commits before the model step throws
990
+ * (measured), so re-sending it would append a second copy of the same turn.
991
+ *
992
+ * The preamble is deliberately NOT repeated on the retry: resetting the analytics tally, ending
993
+ * the negotiation and re-recording the crash transcript are things a NEW user turn does, and a
994
+ * retry is the same turn being attempted again.
995
+ */
996
+ async runTurn(messages, attempt) {
997
+ if (!this.agent || !this.config || !this.runConfig) {
998
+ throw new Error('AgentRunner not initialized. Call init() first.');
999
+ }
1000
+ this.turnsInFlight++;
853
1001
  try {
854
1002
  // Decision: Use streaming or non-streaming based on config
855
1003
  if (this.config.streamOutput) {
@@ -951,6 +1099,18 @@ export class GthAgentRunner {
951
1099
  this.noteApprovalStop('runner.turn-approval-stop', error);
952
1100
  throw error;
953
1101
  }
1102
+ // [[EXT-160]] — **the reactive seam: catch, classify, compact, retry once.**
1103
+ //
1104
+ // Here rather than in the streaming branch's inner `catch` because BOTH paths reach this one
1105
+ // and only one of them has an inner catch — and because the streaming path does not always
1106
+ // use it: an overflow raised while the stream is being CREATED (`await this.agent.stream(…)`,
1107
+ // above the inner `try`) lands here too, which is exactly what the local measurement showed.
1108
+ // One seam, both paths, once each.
1109
+ const retryAfterCompaction = await this.handleContextOverflow(error, attempt);
1110
+ if (retryAfterCompaction) {
1111
+ const answer = await this.runTurn([], attempt + 1);
1112
+ return answer;
1113
+ }
954
1114
  // Handle agent invocation errors
955
1115
  debugLogError('Agent processing', error);
956
1116
  // [[EXT-159]] — the OUTER of two nested wrappers. On the streaming path the inner one has
@@ -969,6 +1129,7 @@ export class GthAgentRunner {
969
1129
  // the one place the panel outlives its turn. Display-only, and a no-op on today's readline
970
1130
  // surface, which appends to scrollback and implements no `end`.
971
1131
  this.clearNegotiationDisplay();
1132
+ this.turnsInFlight--;
972
1133
  }
973
1134
  }
974
1135
  /**
@@ -2566,6 +2727,8 @@ export class GthAgentRunner {
2566
2727
  // refusal in force for this run even when the file cannot be written, and the display
2567
2728
  // de-duplicates by entry identity.
2568
2729
  this.denyGrants.add({ entry, grantedAt, scope: landed });
2730
+ // GS2-20 — after the record, never before it: the listener reads the stores.
2731
+ this.notifySessionGrantsChanged();
2569
2732
  return landed;
2570
2733
  }
2571
2734
  /**
@@ -2602,6 +2765,8 @@ export class GthAgentRunner {
2602
2765
  const saved = persisted?.add({ ...grant, grantedAt, scope: 'always' }) ?? false;
2603
2766
  const grantScope = saved ? 'always' : 'session';
2604
2767
  this.sessionGrants.add({ ...grant, grantedAt, scope: grantScope });
2768
+ // GS2-20 — after the record, never before it: the listener reads the stores.
2769
+ this.notifySessionGrantsChanged();
2605
2770
  return grantScope;
2606
2771
  }
2607
2772
  /**
@@ -2660,6 +2825,9 @@ export class GthAgentRunner {
2660
2825
  // place would silently swallow the human's re-approval of the same tool.
2661
2826
  this.sessionGrants.remove(entry);
2662
2827
  persisted?.remove(entry);
2828
+ // GS2-20 — and out of the conversation's record, so a resume cannot bring back a grant the
2829
+ // tool has since weakened out from under.
2830
+ this.notifySessionGrantsChanged();
2663
2831
  this.statusUpdate(StatusLevel.WARNING, describeWeakenedGrant(entry, weakened, held.annotations, effective));
2664
2832
  }
2665
2833
  }
@@ -2706,6 +2874,7 @@ export class GthAgentRunner {
2706
2874
  this.negotiation.noteUserMessages(humanMessageTexts(messages));
2707
2875
  debugLog('Processing messages (event stream)...');
2708
2876
  debugLogObject('Input Messages', messages);
2877
+ this.turnsInFlight++;
2709
2878
  try {
2710
2879
  // [[TUI-C100]] — **a tool call that never produced a result is closed here, and nowhere
2711
2880
  // earlier.** `processEventStream` ends a call when its own result arrives and otherwise
@@ -2823,6 +2992,7 @@ export class GthAgentRunner {
2823
2992
  // speak for it, and without this the turn would end with no reason at all — the state that
2824
2993
  // must mean "a site we missed".
2825
2994
  this.noteTermination(terminationReason('runner.events-abandoned', 'control', 'abandoned'));
2995
+ this.turnsInFlight--;
2826
2996
  }
2827
2997
  }
2828
2998
  /**
@@ -3033,7 +3203,7 @@ export class GthAgentRunner {
3033
3203
  }
3034
3204
  /**
3035
3205
  * GS2-16 — the analytics harvested from the just-finished turn (token usage + invoked tools),
3036
- * to thread into the opt-in history recorder. Reads live from the agent when one is present,
3206
+ * to thread into the local history recorder. Reads live from the agent when one is present,
3037
3207
  * otherwise the snapshot captured at {@link cleanup} (the single-shot path reads post-cleanup).
3038
3208
  * Never throws.
3039
3209
  */
@@ -3053,6 +3223,13 @@ export class GthAgentRunner {
3053
3223
  * of any checkpointer-specific delete API, mirroring how `init()` mints the initial config.
3054
3224
  */
3055
3225
  resetThread() {
3226
+ this.rotateThread();
3227
+ }
3228
+ /**
3229
+ * Move the runner onto another thread: a fresh one (`/clear`) or a stored one (a resume — see
3230
+ * {@link resumeConversation}). Everything that is context of the thread being left goes with it.
3231
+ */
3232
+ rotateThread(threadId) {
3056
3233
  // [[EXT-29]] §5.1 — the negotiation goes with the thread, user messages included. The rater's
3057
3234
  // last-5 window is conversation context; leaving it behind a `/clear` would quote the user's
3058
3235
  // previous conversation into a rating made after they asked for it to be forgotten.
@@ -3062,9 +3239,253 @@ export class GthAgentRunner {
3062
3239
  // sentence above is the whole argument for dropping them: they would decide how rows are drawn
3063
3240
  // in a conversation the user has just asked to start fresh.
3064
3241
  this.clearRaterClarifications();
3065
- this.runConfig = getNewRunnableConfig();
3242
+ const minted = getNewRunnableConfig();
3243
+ // GS2-20 — overlaid on the minted config exactly as `init` overlays a caller's thread, so the
3244
+ // recursion limit and anything else `getNewRunnableConfig` sets survive the switch.
3245
+ this.runConfig = threadId
3246
+ ? { ...minted, configurable: { ...minted.configurable, thread_id: threadId } }
3247
+ : minted;
3066
3248
  debugLogObject('Reset Runnable Config', this.runConfig);
3067
3249
  }
3250
+ /**
3251
+ * GS2-23 — **fold the older conversation into a summary, in the live graph.** The idle,
3252
+ * user-invoked seam: what `/compact` calls between turns.
3253
+ *
3254
+ * It is not the seam for the involuntary paths. EXT-160's compact-and-retry runs inside the
3255
+ * driver's `try`, where a turn is in flight and this method refuses; that path composes
3256
+ * `compactMessages` with `replaceGraphMessages` (or the agent's `replaceConversationMessages`)
3257
+ * from inside the turn, or adds a guard-free internal when it needs one, rather than calling this.
3258
+ *
3259
+ * Reads the thread's messages from the graph, runs the shared `compactMessages` with the
3260
+ * summariser bound to the session model, and writes the replacement back through the graph's own
3261
+ * state update — so the compacted history is checkpointed and a later resume loads it compacted.
3262
+ * `after` is read back from the graph rather than computed, so the report describes what the
3263
+ * graph actually holds.
3264
+ *
3265
+ * Refuses while a turn is running (the two drivers count themselves in and out). Refuses a graph
3266
+ * suspended on a pending tool approval — idle, but not between turns: the state write lands on
3267
+ * such a graph and erases the interrupt payload (measured: one pending interrupt becomes none,
3268
+ * `next` preserved), so the approval could never be answered. A graph a THROWN turn left behind
3269
+ * is not refused: its checkpoint ends on the pending human turn with no interrupt, the write
3270
+ * lands, and the next turn runs from the compacted state. Invariant (c) is relative there — the
3271
+ * mechanism never creates a trailing assistant turn and the pending human stays last; having the
3272
+ * next human turn present before the model is invoked again is the caller's job. Does nothing on
3273
+ * a conversation no longer than the kept tail: `changed: false`, nothing written.
3274
+ */
3275
+ /**
3276
+ * EXT-161 — the preventive compaction threshold in force, with its provenance, or `undefined`
3277
+ * when this session has no resolved model to derive one from.
3278
+ *
3279
+ * Read by `/status` and by the bare `/autocompact`. Asynchronous because resolving a context
3280
+ * window can reach the models.dev cache (and, on a cold cache, the network) — memoised behind
3281
+ * the agent's one window resolution, so calling it per command costs nothing after the first.
3282
+ *
3283
+ * **Never throws.** A status line that could take a session down would be worse than a missing
3284
+ * one, and every source under it already degrades to "unknown" rather than failing.
3285
+ */
3286
+ async getAutocompactStatus() {
3287
+ try {
3288
+ return await this.agent?.autocompact?.status();
3289
+ }
3290
+ catch {
3291
+ return undefined;
3292
+ }
3293
+ }
3294
+ /**
3295
+ * EXT-161 — move the preventive compaction threshold for the rest of this session.
3296
+ *
3297
+ * Returns the status that LANDED, so the surface's notice describes what is actually in force
3298
+ * rather than what was asked for — the same discipline `/approvals` and `/compact` follow.
3299
+ * `undefined` when there is no resolved model, in which case nothing was changed.
3300
+ *
3301
+ * Takes an already-parsed budget: the grammar is the shared parser's, and a second entry point
3302
+ * accepting text would be a second place it could drift.
3303
+ */
3304
+ async setAutocompactThreshold(budget) {
3305
+ const controller = this.agent?.autocompact;
3306
+ if (!controller)
3307
+ return undefined;
3308
+ controller.setSessionBudget(budget);
3309
+ return this.getAutocompactStatus();
3310
+ }
3311
+ async compactConversation(options = {}) {
3312
+ if (!this.agent || !this.config || !this.runConfig) {
3313
+ throw new Error('AgentRunner not initialized. Call init() first.');
3314
+ }
3315
+ if (this.turnsInFlight > 0) {
3316
+ throw new Error('A turn is still running; wait for it to finish before compacting.');
3317
+ }
3318
+ const agent = this.agent;
3319
+ if (!agent.getConversationMessages || !agent.replaceConversationMessages) {
3320
+ throw new Error('This agent does not expose its conversation state, so it cannot be compacted.');
3321
+ }
3322
+ const runConfig = this.runConfig;
3323
+ const pendingApprovals = (await agent.getPendingToolInterrupts?.(runConfig)) ?? [];
3324
+ if (pendingApprovals.length > 0) {
3325
+ throw new Error('A tool approval is still pending; answer it before compacting.');
3326
+ }
3327
+ return this.applyCompaction(options);
3328
+ }
3329
+ /**
3330
+ * [[EXT-160]] — the read-compact-write itself, with **no turn-state guards**: the shared internal
3331
+ * behind both the idle `/compact` above and the involuntary compact-and-retry inside a turn.
3332
+ *
3333
+ * It exists because {@link compactConversation}'s guards are exactly wrong for the involuntary
3334
+ * case. That method refuses while `turnsInFlight > 0`, and the overflow seam runs inside the
3335
+ * driver's `catch`, where the turn it is recovering is still counted in — so calling the public
3336
+ * method from there throws every time. The alternative was for the seam to compose
3337
+ * `compactMessages` with `replaceConversationMessages` itself, which is the same six steps written
3338
+ * twice: two places to keep the summariser, the keep-recent default, the read-back and the
3339
+ * `changed: false` shape in agreement. One implementation with the guards on the caller that needs
3340
+ * them is the version that cannot drift.
3341
+ *
3342
+ * **The pending-approval guard is dropped here too, and that is deliberate rather than an
3343
+ * oversight.** `/compact` refuses under a pending approval because the user is mid-decision about
3344
+ * a tool call, and folding the conversation under them would rewrite the history that decision is
3345
+ * being made against. The involuntary path cannot be in that position: it runs from the `catch`
3346
+ * of a model call that threw, and a model call that threw produced no tool call, so there is no
3347
+ * new interrupt to answer. An approval raised EARLIER in the same turn has already been resolved
3348
+ * — the driver resolves interrupts in a loop before the model is asked again — so by the time an
3349
+ * overflow can be caught, there is nothing pending for the guard to protect.
3350
+ */
3351
+ async applyCompaction(options = {}) {
3352
+ if (!this.agent || !this.config || !this.runConfig) {
3353
+ throw new Error('AgentRunner not initialized. Call init() first.');
3354
+ }
3355
+ const agent = this.agent;
3356
+ if (!agent.getConversationMessages || !agent.replaceConversationMessages) {
3357
+ throw new Error('This agent does not expose its conversation state, so it cannot be compacted.');
3358
+ }
3359
+ const runConfig = this.runConfig;
3360
+ const keepRecent = options.keepRecent ?? DEFAULT_KEEP_RECENT;
3361
+ const messages = await agent.getConversationMessages(runConfig);
3362
+ const before = conversationSize(messages);
3363
+ const result = await compactMessages({
3364
+ messages,
3365
+ summarize: createModelSummarizer(this.config.llm),
3366
+ keepRecent,
3367
+ ...(options.focus !== undefined ? { focus: options.focus } : {}),
3368
+ });
3369
+ if (!result.changed) {
3370
+ return {
3371
+ changed: false,
3372
+ removedCount: 0,
3373
+ keptCount: messages.length,
3374
+ keepRecent,
3375
+ summaryText: '',
3376
+ before,
3377
+ after: before,
3378
+ };
3379
+ }
3380
+ await agent.replaceConversationMessages(runConfig, result.messages);
3381
+ const after = conversationSize(await agent.getConversationMessages(runConfig));
3382
+ debugLog(`Compacted the conversation: ${result.removedCount} folded, ${result.keptCount} kept, ` +
3383
+ `${before.messages}→${after.messages} messages, ${before.characters}→${after.characters} chars`);
3384
+ return {
3385
+ changed: true,
3386
+ removedCount: result.removedCount,
3387
+ keptCount: result.keptCount,
3388
+ keepRecent,
3389
+ summaryText: result.summaryText,
3390
+ before,
3391
+ after,
3392
+ };
3393
+ }
3394
+ /**
3395
+ * [[EXT-160]] — **decide what a thrown turn's context overflow means, and act on it once.**
3396
+ *
3397
+ * Returns `true` when the conversation was made smaller and the turn is worth attempting again;
3398
+ * `false` for everything else, including every failure that is not an overflow at all, in which
3399
+ * case the caller's existing error path runs untouched.
3400
+ *
3401
+ * **The predicate is the taxonomy's, never a private one.** The category comes from the reason an
3402
+ * inner site already attached, or failing that from `classifyThrownTermination`, and the decision
3403
+ * is `remedy === 'reduce-context'` read out of the one POSTURE table. That is what makes this the
3404
+ * same fact [[EXT-159]] surfaces rather than a second opinion about it — and it is why an
3405
+ * `output_truncated` turn is not compacted here: the answer was cut off against the output cap,
3406
+ * its remedy is `change-request`, and folding the history would not add a single token of room to
3407
+ * the part that ran out. `context_overflow` is also the one category whose posture separates the
3408
+ * two facts this method depends on: retrying the SAME prompt is hopeless (`retryableAsIs: false`,
3409
+ * which is what `ContextOverflowError.getRetryable()` says too) while retrying a SMALLER one is
3410
+ * the whole move (`retryableAfterRemedy: true`).
3411
+ *
3412
+ * **One retry, and the reasons a compaction can decline.** A second overflow after the history has
3413
+ * already been folded is not worth a second fold — the tail it just kept is what the next
3414
+ * compaction would have to eat — so `attempt > 0` terminates at its own site. So does a compaction
3415
+ * that had nothing to fold, could not get a summary, or found an agent with no conversation state:
3416
+ * each is "the automatic remedy was tried and had nothing to give", which is a different fact from
3417
+ * "the model said no" and deserves to be said in its own words.
3418
+ *
3419
+ * The original overflow error is what surfaces in every declining branch. A compaction that throws
3420
+ * has its own failure logged and dropped rather than re-thrown, because replacing a diagnosis the
3421
+ * whole node exists to preserve with a summariser's stack trace buries the one useful thing the
3422
+ * turn produced.
3423
+ */
3424
+ async handleContextOverflow(error, attempt) {
3425
+ const category = terminationReasonOf(error)?.category ?? classifyThrownTermination(error).category;
3426
+ if (terminationPosture(category).remedy !== 'reduce-context')
3427
+ return false;
3428
+ if (attempt > 0) {
3429
+ this.overrideTerminationReason(error, terminationReason('runner.overflow-compact-exhausted', 'exception', {
3430
+ category: 'context_overflow',
3431
+ detail: 'overflowed again after compaction',
3432
+ }));
3433
+ this.statusUpdate(StatusLevel.WARNING, 'The context overflowed again after compacting, so this turn was ended. Start a new ' +
3434
+ 'conversation, or narrow what this turn is asking for.');
3435
+ return false;
3436
+ }
3437
+ // An agent that exposes no conversation state cannot be compacted at ALL, which is a different
3438
+ // fact from a compaction that ran and had nothing to give — and only the second is something
3439
+ // this seam knows. So nothing is overridden here: the wrapper's own classification is the
3440
+ // truest thing anyone has, and claiming the remedy was tried would be false.
3441
+ const agent = this.agent;
3442
+ if (!agent?.getConversationMessages || !agent?.replaceConversationMessages) {
3443
+ debugLog('Context overflow: this agent exposes no conversation state, so it cannot be compacted.');
3444
+ return false;
3445
+ }
3446
+ let compaction;
3447
+ try {
3448
+ compaction = await this.applyCompaction();
3449
+ }
3450
+ catch (compactionError) {
3451
+ debugLogError('Compacting after a context overflow', compactionError);
3452
+ compaction = { changed: false };
3453
+ }
3454
+ if (!compaction.changed) {
3455
+ this.overrideTerminationReason(error, terminationReason('runner.overflow-compact', 'exception', {
3456
+ category: 'context_overflow',
3457
+ detail: 'nothing left to compact',
3458
+ }));
3459
+ this.statusUpdate(StatusLevel.WARNING, 'The context overflowed and there was nothing left to compact, so this turn was ended. ' +
3460
+ 'Start a new conversation, or narrow what this turn is asking for.');
3461
+ return false;
3462
+ }
3463
+ this.statusUpdate(StatusLevel.INFO, `The context overflowed, so ${compaction.removedCount} earlier messages were folded into a ` +
3464
+ `summary (${compaction.before.messages}→${compaction.after.messages} messages). Retrying.`);
3465
+ // The retry is a fresh attempt and owes its own reason: the FULL reset, so the failed attempt's
3466
+ // provider finish reasons go with it rather than being read later as the retry's.
3467
+ this.resetTerminationReason();
3468
+ return true;
3469
+ }
3470
+ /**
3471
+ * [[EXT-160]] — record a reason that OVERRIDES what an inner site already said, on both carriers.
3472
+ *
3473
+ * {@link noteTermination} is first-write-wins and {@link classifyThrownAt} inherits, which is
3474
+ * right for the nested wrappers they serve: the inner site saw the failure first. The overflow
3475
+ * seam is the one site that legitimately knows better — it has watched the same turn overflow
3476
+ * twice, or watched the remedy come back empty, and the wrapper that classified the throw saw
3477
+ * neither. Both carriers move together so the runner's field and the error can never disagree.
3478
+ */
3479
+ overrideTerminationReason(error, reason) {
3480
+ try {
3481
+ this.terminationReason = reason;
3482
+ replaceTerminationReason(error, reason);
3483
+ debugLog(terminationLogLine(reason));
3484
+ }
3485
+ catch {
3486
+ /* fail-soft: classification must never affect a run */
3487
+ }
3488
+ }
3068
3489
  async cleanup() {
3069
3490
  debugLog('Cleaning up GthAgentRunner...');
3070
3491
  // GS2-16: snapshot the agent's run stats BEFORE nulling it, so a post-cleanup reader