@nghyane/arcane 0.1.29 → 0.1.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.
Files changed (50) hide show
  1. package/package.json +4 -4
  2. package/src/cli/config-cli.ts +1 -1
  3. package/src/config/settings-schema.ts +19 -27
  4. package/src/config/settings.ts +3 -4
  5. package/src/extensibility/custom-tools/types.ts +0 -12
  6. package/src/extensibility/extensions/index.ts +0 -5
  7. package/src/extensibility/extensions/runner.ts +6 -26
  8. package/src/extensibility/extensions/types.ts +1 -77
  9. package/src/extensibility/hooks/runner.ts +5 -24
  10. package/src/extensibility/hooks/types.ts +1 -77
  11. package/src/index.ts +2 -13
  12. package/src/modes/components/footer.ts +4 -11
  13. package/src/modes/components/index.ts +0 -1
  14. package/src/modes/components/status-line/segments.ts +1 -2
  15. package/src/modes/components/status-line/types.ts +0 -1
  16. package/src/modes/components/status-line.ts +0 -6
  17. package/src/modes/components/tree-selector.ts +0 -8
  18. package/src/modes/controllers/command-controller.ts +2 -98
  19. package/src/modes/controllers/event-controller.ts +46 -52
  20. package/src/modes/controllers/extension-ui-controller.ts +0 -42
  21. package/src/modes/controllers/input-controller.ts +0 -23
  22. package/src/modes/controllers/selector-controller.ts +0 -5
  23. package/src/modes/interactive-mode.ts +3 -24
  24. package/src/modes/print-mode.ts +0 -16
  25. package/src/modes/rpc/rpc-client.ts +0 -16
  26. package/src/modes/rpc/rpc-mode.ts +0 -32
  27. package/src/modes/rpc/rpc-types.ts +0 -9
  28. package/src/modes/types.ts +1 -13
  29. package/src/modes/utils/ui-helpers.ts +2 -118
  30. package/src/sdk.ts +0 -15
  31. package/src/session/agent-session.ts +89 -650
  32. package/src/session/compaction/branch-summarization.ts +5 -13
  33. package/src/session/compaction/index.ts +0 -1
  34. package/src/session/compaction/utils.ts +94 -2
  35. package/src/session/messages.ts +0 -37
  36. package/src/session/retry-utils.ts +1 -1
  37. package/src/session/session-manager.ts +8 -108
  38. package/src/session/session-types.ts +4 -25
  39. package/src/session/stats.ts +2 -39
  40. package/src/slash-commands/builtin-registry.ts +0 -11
  41. package/src/task/executor.ts +0 -8
  42. package/examples/hooks/custom-compaction.ts +0 -116
  43. package/src/modes/components/compaction-summary-message.ts +0 -59
  44. package/src/prompts/compaction/compaction-short-summary.md +0 -9
  45. package/src/prompts/compaction/compaction-summary-context.md +0 -5
  46. package/src/prompts/compaction/compaction-summary.md +0 -41
  47. package/src/prompts/compaction/compaction-turn-prefix.md +0 -17
  48. package/src/prompts/compaction/compaction-update-summary.md +0 -45
  49. package/src/session/compaction/compaction.ts +0 -864
  50. package/src/session/compaction/pruning.ts +0 -91
@@ -6,7 +6,6 @@
6
6
  * - Agent state access
7
7
  * - Event subscription with automatic session persistence
8
8
  * - Model and thinking level management
9
- * - Compaction (manual and auto)
10
9
  * - Bash execution
11
10
  * - Session switching and branching
12
11
  *
@@ -34,7 +33,7 @@ import type {
34
33
  ToolChoice,
35
34
  UsageReport,
36
35
  } from "@nghyane/arcane-ai";
37
- import { isContextOverflow, modelsAreEqual } from "@nghyane/arcane-ai";
36
+ import { isContextOverflow } from "@nghyane/arcane-ai";
38
37
  import { abortableSleep, isEnoent, logger } from "@nghyane/arcane-utils";
39
38
  import { getAgentDbPath } from "@nghyane/arcane-utils/dirs";
40
39
  import type { ModelRegistry, ModelRole } from "../config/model-registry";
@@ -58,7 +57,6 @@ import type {
58
57
  MessageStartEvent,
59
58
  MessageUpdateEvent,
60
59
  SessionBeforeBranchResult,
61
- SessionBeforeCompactResult,
62
60
  SessionBeforeSwitchResult,
63
61
  SessionBeforeTreeResult,
64
62
  ToolExecutionEndEvent,
@@ -68,7 +66,7 @@ import type {
68
66
  TurnEndEvent,
69
67
  TurnStartEvent,
70
68
  } from "../extensibility/extensions";
71
- import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
69
+ import type { ContextUsage } from "../extensibility/extensions/types";
72
70
  import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
73
71
  import type { HookCommandContext } from "../extensibility/hooks/types";
74
72
  import type { Skill, SkillWarning } from "../extensibility/skills";
@@ -83,21 +81,11 @@ import { outputMeta } from "../tools/output-meta";
83
81
  import type { TodoItem } from "../tools/todo-write";
84
82
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
85
83
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
86
- import {
87
- type CompactionResult,
88
- calculateContextTokens,
89
- collectEntriesForBranchSummary,
90
- compact,
91
- generateBranchSummary,
92
- prepareCompaction,
93
- shouldCompact,
94
- } from "./compaction";
95
- import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "./compaction/pruning";
84
+ import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction";
96
85
  import type { BashExecutionMessage, CustomMessage, PythonExecutionMessage } from "./messages";
97
86
  import { ModelController } from "./model-controller";
98
87
  import { isRetryableErrorMessage, isUsageLimitErrorMessage, parseRetryAfterMs } from "./retry-utils";
99
- import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
100
- import { getLatestCompactionEntry } from "./session-manager";
88
+ import type { BranchSummaryEntry, NewSessionOptions, SessionManager } from "./session-manager";
101
89
  import type {
102
90
  AgentSessionConfig,
103
91
  AgentSessionEvent,
@@ -208,10 +196,6 @@ export class AgentSession {
208
196
  /** Messages queued to be included with the next user prompt as context ("asides"). */
209
197
  #pendingNextTurnMessages: CustomMessage[] = [];
210
198
 
211
- // Compaction state
212
- #compactionAbortController: AbortController | undefined = undefined;
213
- #autoCompactionAbortController: AbortController | undefined = undefined;
214
-
215
199
  // Branch summarization state
216
200
  #branchSummaryAbortController: AbortController | undefined = undefined;
217
201
 
@@ -228,6 +212,8 @@ export class AgentSession {
228
212
  // Todo completion reminder state
229
213
  #todoReminderCount = 0;
230
214
 
215
+ // Auto-handoff state
216
+ #contextWarningEmitted = false;
231
217
  // Verification loop state
232
218
  #verificationReminderCount = 0;
233
219
  #turnHasFileModifications = false;
@@ -290,7 +276,7 @@ export class AgentSession {
290
276
  this.agent.providerSessionState = this.#model.providerSessionState;
291
277
 
292
278
  // Always subscribe to agent events for internal handling
293
- // (session persistence, hooks, auto-compaction, retry logic)
279
+ // (session persistence, hooks, retry logic)
294
280
  this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
295
281
  }
296
282
 
@@ -332,9 +318,6 @@ export class AgentSession {
332
318
  this.#emit(event);
333
319
  }
334
320
 
335
- // Track last assistant message for auto-compaction check
336
- #lastAssistantMessage: AssistantMessage | undefined = undefined;
337
-
338
321
  /** Internal handler for agent events - shared by subscribe and reconnect */
339
322
  #handleAgentEvent = async (event: AgentEvent): Promise<void> => {
340
323
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
@@ -497,11 +480,9 @@ export class AgentSession {
497
480
  // Regular LLM message - persist as SessionMessageEntry
498
481
  this.sessionManager.appendMessage(event.message);
499
482
  }
500
- // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
483
+ // Other message types (bashExecution, branchSummary) are persisted elsewhere
501
484
 
502
- // Track assistant message for auto-compaction (checked on agent_end)
503
485
  if (event.message.role === "assistant") {
504
- this.#lastAssistantMessage = event.message;
505
486
  const assistantMsg = event.message as AssistantMessage;
506
487
  queueDeferredTtsrInjectionIfNeeded(this.#ttsr, this.agent, assistantMsg);
507
488
  if (this.#handoffAbortController) {
@@ -578,24 +559,26 @@ export class AgentSession {
578
559
  }
579
560
  }
580
561
 
581
- // Check auto-retry and auto-compaction after agent completes
582
- if (event.type === "agent_end" && this.#lastAssistantMessage) {
583
- const msg = this.#lastAssistantMessage;
584
- this.#lastAssistantMessage = undefined;
562
+ // Check auto-retry after agent completes
563
+ if (event.type === "agent_end") {
564
+ const msg = this.#findLastAssistantMessage();
565
+ if (!msg) return;
585
566
 
586
567
  if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
587
568
  this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
588
569
  return;
589
570
  }
590
571
 
572
+ // Check for auto-handoff (context overflow or high usage)
573
+ const didHandoff = await this.#checkAutoHandoff(msg);
574
+ if (didHandoff) return;
575
+
591
576
  // Check for retryable errors first (overloaded, rate limit, server errors)
592
577
  if (this.#isRetryableError(msg)) {
593
578
  const didRetry = await this.#handleRetryableError(msg);
594
- if (didRetry) return; // Retry was initiated, don't proceed to compaction
579
+ if (didRetry) return;
595
580
  }
596
581
 
597
- await this.#checkCompaction(msg);
598
-
599
582
  // Check verification (if agent modified files without verifying)
600
583
  if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
601
584
  const didRemind = await this.#checkVerification();
@@ -711,16 +694,6 @@ export class AgentSession {
711
694
  isError: event.isError ?? false,
712
695
  };
713
696
  await this.#extensionRunner.emit(extensionEvent);
714
- } else if (event.type === "auto_compaction_start") {
715
- await this.#extensionRunner.emit({ type: "auto_compaction_start", reason: event.reason });
716
- } else if (event.type === "auto_compaction_end") {
717
- await this.#extensionRunner.emit({
718
- type: "auto_compaction_end",
719
- result: event.result,
720
- aborted: event.aborted,
721
- willRetry: event.willRetry,
722
- errorMessage: event.errorMessage,
723
- });
724
697
  } else if (event.type === "auto_retry_start") {
725
698
  await this.#extensionRunner.emit({
726
699
  type: "auto_retry_start",
@@ -940,11 +913,6 @@ export class AgentSession {
940
913
  await this.setActiveToolsByName(nextActive);
941
914
  }
942
915
 
943
- /** Whether auto-compaction is currently running */
944
- get isCompacting(): boolean {
945
- return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
946
- }
947
-
948
916
  /** All messages including custom types like BashExecutionMessage */
949
917
  get messages(): AgentMessage[] {
950
918
  return this.agent.state.messages;
@@ -1143,12 +1111,6 @@ export class AgentSession {
1143
1111
  );
1144
1112
  }
1145
1113
 
1146
- // Check if we need to compact before sending (catches aborted responses)
1147
- const lastAssistant = this.#findLastAssistantMessage();
1148
- if (lastAssistant) {
1149
- await this.#checkCompaction(lastAssistant, false);
1150
- }
1151
-
1152
1114
  // Build messages array (custom messages if any, then user message)
1153
1115
  const messages: AgentMessage[] = [];
1154
1116
 
@@ -1289,12 +1251,6 @@ export class AgentSession {
1289
1251
  const result = await this.navigateTree(targetId, { summarize: options?.summarize });
1290
1252
  return { cancelled: result.cancelled };
1291
1253
  },
1292
- compact: async instructionsOrOptions => {
1293
- const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
1294
- const options =
1295
- instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
1296
- await this.compact(instructions, options);
1297
- },
1298
1254
  switchSession: async sessionPath => {
1299
1255
  const success = await this.switchSession(sessionPath);
1300
1256
  return { cancelled: !success };
@@ -1783,187 +1739,6 @@ export class AgentSession {
1783
1739
  this.settings.set("interruptMode", mode);
1784
1740
  }
1785
1741
 
1786
- // =========================================================================
1787
- // Compaction
1788
- // =========================================================================
1789
-
1790
- async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
1791
- const branchEntries = this.sessionManager.getBranch();
1792
- const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
1793
- if (result.prunedCount === 0) {
1794
- return undefined;
1795
- }
1796
-
1797
- await this.sessionManager.rewriteEntries();
1798
- const sessionContext = this.sessionManager.buildSessionContext();
1799
- this.agent.replaceMessages(sessionContext.messages);
1800
- this.#model.closeCodexProviderSessionsForHistoryRewrite();
1801
- return result;
1802
- }
1803
-
1804
- /**
1805
- * Manually compact the session context.
1806
- * Aborts current agent operation first.
1807
- * @param customInstructions Optional instructions for the compaction summary
1808
- * @param options Optional callbacks for completion/error handling
1809
- */
1810
- async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
1811
- this.#disconnectFromAgent();
1812
- await this.abort();
1813
- this.#compactionAbortController = new AbortController();
1814
-
1815
- try {
1816
- if (!this.model) {
1817
- throw new Error("No model selected");
1818
- }
1819
-
1820
- const compactionSettings = this.settings.getGroup("compaction");
1821
- const compactionModel = this.model;
1822
- const apiKey = await this.#model.registry.getApiKey(compactionModel, this.sessionId);
1823
- if (!apiKey) {
1824
- throw new Error(`No API key for ${compactionModel.provider}`);
1825
- }
1826
-
1827
- const pathEntries = this.sessionManager.getBranch();
1828
-
1829
- const preparation = prepareCompaction(pathEntries, compactionSettings);
1830
- if (!preparation) {
1831
- // Check why we can't compact
1832
- const lastEntry = pathEntries[pathEntries.length - 1];
1833
- if (lastEntry?.type === "compaction") {
1834
- throw new Error("Already compacted");
1835
- }
1836
- throw new Error("Nothing to compact (session too small)");
1837
- }
1838
-
1839
- let hookCompaction: CompactionResult | undefined;
1840
- let fromExtension = false;
1841
- let hookContext: string[] | undefined;
1842
- let hookPrompt: string | undefined;
1843
- let preserveData: Record<string, unknown> | undefined;
1844
-
1845
- if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
1846
- const result = (await this.#extensionRunner.emit({
1847
- type: "session_before_compact",
1848
- preparation,
1849
- branchEntries: pathEntries,
1850
- customInstructions,
1851
- signal: this.#compactionAbortController.signal,
1852
- })) as SessionBeforeCompactResult | undefined;
1853
-
1854
- if (result?.cancel) {
1855
- throw new Error("Compaction cancelled");
1856
- }
1857
-
1858
- if (result?.compaction) {
1859
- hookCompaction = result.compaction;
1860
- fromExtension = true;
1861
- }
1862
- }
1863
-
1864
- if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
1865
- const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
1866
- const result = (await this.#extensionRunner.emit({
1867
- type: "session.compacting",
1868
- sessionId: this.sessionId,
1869
- messages: compactMessages,
1870
- })) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
1871
-
1872
- hookContext = result?.context;
1873
- hookPrompt = result?.prompt;
1874
- preserveData = result?.preserveData;
1875
- }
1876
-
1877
- let summary: string;
1878
- let shortSummary: string | undefined;
1879
- let firstKeptEntryId: string;
1880
- let tokensBefore: number;
1881
- let details: unknown;
1882
-
1883
- if (hookCompaction) {
1884
- // Extension provided compaction content
1885
- summary = hookCompaction.summary;
1886
- shortSummary = hookCompaction.shortSummary;
1887
- firstKeptEntryId = hookCompaction.firstKeptEntryId;
1888
- tokensBefore = hookCompaction.tokensBefore;
1889
- details = hookCompaction.details;
1890
- preserveData ??= hookCompaction.preserveData;
1891
- } else {
1892
- // Generate compaction result
1893
- const result = await compact(
1894
- preparation,
1895
- compactionModel,
1896
- apiKey,
1897
- customInstructions,
1898
- this.#compactionAbortController.signal,
1899
- { promptOverride: hookPrompt, extraContext: hookContext },
1900
- );
1901
- summary = result.summary;
1902
- shortSummary = result.shortSummary;
1903
- firstKeptEntryId = result.firstKeptEntryId;
1904
- tokensBefore = result.tokensBefore;
1905
- details = result.details;
1906
- }
1907
-
1908
- if (this.#compactionAbortController.signal.aborted) {
1909
- throw new Error("Compaction cancelled");
1910
- }
1911
-
1912
- this.sessionManager.appendCompaction(
1913
- summary,
1914
- shortSummary,
1915
- firstKeptEntryId,
1916
- tokensBefore,
1917
- details,
1918
- fromExtension,
1919
- preserveData,
1920
- );
1921
- const newEntries = this.sessionManager.getEntries();
1922
- const sessionContext = this.sessionManager.buildSessionContext();
1923
- this.agent.replaceMessages(sessionContext.messages);
1924
- this.#model.closeCodexProviderSessionsForHistoryRewrite();
1925
-
1926
- // Get the saved compaction entry for the hook
1927
- const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
1928
- | CompactionEntry
1929
- | undefined;
1930
-
1931
- if (this.#extensionRunner && savedCompactionEntry) {
1932
- await this.#extensionRunner.emit({
1933
- type: "session_compact",
1934
- compactionEntry: savedCompactionEntry,
1935
- fromExtension,
1936
- });
1937
- }
1938
-
1939
- const compactionResult: CompactionResult = {
1940
- summary,
1941
- shortSummary,
1942
- firstKeptEntryId,
1943
- tokensBefore,
1944
- details,
1945
- preserveData,
1946
- };
1947
- options?.onComplete?.(compactionResult);
1948
- return compactionResult;
1949
- } catch (error) {
1950
- const err = error instanceof Error ? error : new Error(String(error));
1951
- options?.onError?.(err);
1952
- throw error;
1953
- } finally {
1954
- this.#compactionAbortController = undefined;
1955
- this.#reconnectToAgent();
1956
- }
1957
- }
1958
-
1959
- /**
1960
- * Cancel in-progress compaction (manual or auto).
1961
- */
1962
- abortCompaction(): void {
1963
- this.#compactionAbortController?.abort();
1964
- this.#autoCompactionAbortController?.abort();
1965
- }
1966
-
1967
1742
  /**
1968
1743
  * Cancel in-progress branch summarization.
1969
1744
  */
@@ -2092,6 +1867,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
2092
1867
  this.#followUpMessages = [];
2093
1868
  this.#pendingNextTurnMessages = [];
2094
1869
  this.#todoReminderCount = 0;
1870
+ this.#contextWarningEmitted = false;
2095
1871
 
2096
1872
  // Inject the handoff document as a custom message
2097
1873
  const handoffContent = `<handoff-context thread="${parentThreadId}">\n${handoffText}\n</handoff-context>\n\nThe above is a handoff document from thread \`${parentThreadId}\`. Use this context to continue the work seamlessly. If you need additional details not covered above, use \`read_thread("${parentThreadId}", "your specific question")\` to query the original session.`;
@@ -2107,79 +1883,6 @@ Be thorough - include exact file paths, function names, error messages, and tech
2107
1883
  }
2108
1884
  }
2109
1885
 
2110
- /**
2111
- * Check if compaction or context promotion is needed and run it.
2112
- * Called after agent_end and before prompt submission.
2113
- *
2114
- * Three cases (in order):
2115
- * 1. Overflow + promotion: promote to larger model, retry without compacting
2116
- * 2. Overflow + no promotion target: compact, auto-retry on same model
2117
- * 3. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
2118
- *
2119
- * @param assistantMessage The assistant message to check
2120
- * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
2121
- */
2122
- async #checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {
2123
- // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
2124
- if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return;
2125
- const contextWindow = this.model?.contextWindow ?? 0;
2126
- // Skip overflow check if the message came from a different model.
2127
- // This handles the case where user switched from a smaller-context model (e.g. opus)
2128
- // to a larger-context model (e.g. codex) - the overflow error from the old model
2129
- // shouldn't trigger compaction for the new model.
2130
- const sameModel =
2131
- this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;
2132
- // This handles the case where an error was kept after compaction (in the "kept" region).
2133
- // The error shouldn't trigger another compaction since we already compacted.
2134
- // Example: opus fails \u2192 switch to codex \u2192 compact \u2192 switch back to opus \u2192 opus error
2135
- // is still in context but shouldn't trigger compaction again.
2136
- const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
2137
- const errorIsFromBeforeCompaction =
2138
- compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
2139
- if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
2140
- // Remove the error message from agent state (it IS saved to session for history,
2141
- // but we don't want it in context for the retry)
2142
- const messages = this.agent.state.messages;
2143
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2144
- this.agent.replaceMessages(messages.slice(0, -1));
2145
- }
2146
-
2147
- // Try context promotion first \u2014 switch to a larger model and retry without compacting
2148
- const promoted = await this.#tryContextPromotion(assistantMessage);
2149
- if (promoted) {
2150
- // Retry on the promoted (larger) model without compacting
2151
- setTimeout(() => {
2152
- this.agent.continue().catch(() => {});
2153
- }, 100);
2154
- return;
2155
- }
2156
-
2157
- // No promotion target available \u2014 fall through to compaction
2158
- const compactionSettings = this.settings.getGroup("compaction");
2159
- if (compactionSettings.enabled) {
2160
- await this.#runAutoCompaction("overflow", true);
2161
- }
2162
- return;
2163
- }
2164
- const compactionSettings = this.settings.getGroup("compaction");
2165
- if (!compactionSettings.enabled) return;
2166
-
2167
- // Case 2: Threshold - turn succeeded but context is getting large
2168
- // Skip if this was an error (non-overflow errors don't have usage data)
2169
- if (assistantMessage.stopReason === "error") return;
2170
- const pruneResult = await this.#pruneToolOutputs();
2171
- let contextTokens = calculateContextTokens(assistantMessage.usage);
2172
- if (pruneResult) {
2173
- contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
2174
- }
2175
- if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
2176
- // Try promotion first — if a larger model is available, switch instead of compacting
2177
- const promoted = await this.#tryContextPromotion(assistantMessage);
2178
- if (!promoted) {
2179
- await this.#runAutoCompaction("threshold", false);
2180
- }
2181
- }
2182
- }
2183
1886
  /**
2184
1887
  * Check if agent stopped after modifying files without running verification.
2185
1888
  * If so, inject a reminder to verify and continue the conversation.
@@ -2319,368 +2022,104 @@ Be thorough - include exact file paths, function names, error messages, and tech
2319
2022
  this.agent.continue().catch(() => {});
2320
2023
  }
2321
2024
 
2322
- /**
2323
- * Attempt context promotion to a larger model.
2324
- * Returns true if promotion succeeded (caller should retry without compacting).
2325
- */
2326
- async #tryContextPromotion(assistantMessage: AssistantMessage): Promise<boolean> {
2327
- const promotionSettings = this.settings.getGroup("contextPromotion");
2328
- if (!promotionSettings.enabled) return false;
2329
- const currentModel = this.model;
2330
- if (!currentModel) return false;
2331
- if (assistantMessage.provider !== currentModel.provider || assistantMessage.model !== currentModel.id)
2332
- return false;
2333
- const contextWindow = currentModel.contextWindow ?? 0;
2334
- if (contextWindow <= 0) return false;
2335
- const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
2336
- if (!targetModel) return false;
2337
-
2338
- try {
2339
- await this.setModelTemporary(targetModel);
2340
- logger.debug("Context promotion switched model on overflow", {
2341
- from: `${currentModel.provider}/${currentModel.id}`,
2342
- to: `${targetModel.provider}/${targetModel.id}`,
2343
- });
2344
- return true;
2345
- } catch (error) {
2346
- logger.warn("Context promotion failed", {
2347
- from: `${currentModel.provider}/${currentModel.id}`,
2348
- to: `${targetModel.provider}/${targetModel.id}`,
2349
- error: String(error),
2350
- });
2351
- return false;
2352
- }
2353
- }
2354
-
2355
- async #resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
2356
- const availableModels = this.#model.registry.getAvailable();
2357
- if (availableModels.length === 0) return undefined;
2025
+ // =========================================================================
2026
+ // Auto-Handoff
2027
+ // =========================================================================
2358
2028
 
2359
- const candidate = this.#model.resolveContextPromotionTarget(currentModel, availableModels);
2360
- if (!candidate) return undefined;
2361
- if (modelsAreEqual(candidate, currentModel)) return undefined;
2362
- if (candidate.contextWindow <= contextWindow) return undefined;
2363
- const apiKey = await this.#model.registry.getApiKey(candidate, this.sessionId);
2364
- if (!apiKey) return undefined;
2365
- return candidate;
2366
- }
2367
- /**
2368
- * Internal: Run auto-compaction with events.
2369
- */
2370
- async #runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<void> {
2371
- const compactionSettings = this.settings.getGroup("compaction");
2029
+ async #checkAutoHandoff(message: AssistantMessage): Promise<boolean> {
2030
+ const handoffSettings = this.settings.getGroup("autoHandoff");
2031
+ if (!handoffSettings.enabled) return false;
2372
2032
 
2373
- await this.#emitSessionEvent({ type: "auto_compaction_start", reason });
2374
- // Properly abort and null existing controller before replacing
2375
- if (this.#autoCompactionAbortController) {
2376
- this.#autoCompactionAbortController.abort();
2033
+ // Check if this was a context overflow error
2034
+ const contextWindow = this.model?.contextWindow ?? 0;
2035
+ if (message.stopReason === "error" && isContextOverflow(message, contextWindow)) {
2036
+ return this.#handleContextOverflow();
2377
2037
  }
2378
- this.#autoCompactionAbortController = new AbortController();
2379
2038
 
2380
- try {
2381
- if (!this.model) {
2382
- await this.#emitSessionEvent({
2383
- type: "auto_compaction_end",
2384
- result: undefined,
2385
- aborted: false,
2386
- willRetry: false,
2387
- });
2388
- return;
2389
- }
2039
+ // Skip threshold-based handoff if this turn ended with an error
2040
+ // (e.g., 429 rate limit) — let retry logic handle those
2041
+ if (message.stopReason === "error") return false;
2390
2042
 
2391
- const availableModels = this.#model.registry.getAvailable();
2392
- if (availableModels.length === 0) {
2393
- await this.#emitSessionEvent({
2394
- type: "auto_compaction_end",
2395
- result: undefined,
2396
- aborted: false,
2397
- willRetry: false,
2398
- });
2399
- return;
2400
- }
2401
-
2402
- const pathEntries = this.sessionManager.getBranch();
2403
-
2404
- const preparation = prepareCompaction(pathEntries, compactionSettings);
2405
- if (!preparation) {
2406
- await this.#emitSessionEvent({
2407
- type: "auto_compaction_end",
2408
- result: undefined,
2409
- aborted: false,
2410
- willRetry: false,
2411
- });
2412
- return;
2413
- }
2043
+ // Check context usage percentage
2044
+ const usage = this.getContextUsage();
2045
+ if (!usage || usage.percent == null) return false;
2414
2046
 
2415
- let hookCompaction: CompactionResult | undefined;
2416
- let fromExtension = false;
2417
- let hookContext: string[] | undefined;
2418
- let hookPrompt: string | undefined;
2419
- let preserveData: Record<string, unknown> | undefined;
2420
-
2421
- if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
2422
- const hookResult = (await this.#extensionRunner.emit({
2423
- type: "session_before_compact",
2424
- preparation,
2425
- branchEntries: pathEntries,
2426
- customInstructions: undefined,
2427
- signal: this.#autoCompactionAbortController.signal,
2428
- })) as SessionBeforeCompactResult | undefined;
2429
-
2430
- if (hookResult?.cancel) {
2431
- await this.#emitSessionEvent({
2432
- type: "auto_compaction_end",
2433
- result: undefined,
2434
- aborted: true,
2435
- willRetry: false,
2436
- });
2437
- return;
2438
- }
2439
-
2440
- if (hookResult?.compaction) {
2441
- hookCompaction = hookResult.compaction;
2442
- fromExtension = true;
2047
+ // Auto-handoff at high threshold
2048
+ if (usage.percent >= handoffSettings.handoffThreshold) {
2049
+ await this.#emitSessionEvent({ type: "auto_handoff_start", percent: usage.percent });
2050
+ try {
2051
+ const result = await this.handoff();
2052
+ await this.#emitSessionEvent({ type: "auto_handoff_end", success: !!result });
2053
+ if (result) {
2054
+ this.agent.continue().catch(() => {});
2443
2055
  }
2056
+ return !!result;
2057
+ } catch (err) {
2058
+ // handoff() failed (likely context too full to generate doc)
2059
+ // End the current handoff event before falling back to overflow handler
2060
+ await this.#emitSessionEvent({ type: "auto_handoff_end", success: false, error: String(err) });
2061
+ return this.#handleContextOverflow();
2444
2062
  }
2063
+ }
2445
2064
 
2446
- if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
2447
- const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
2448
- const result = (await this.#extensionRunner.emit({
2449
- type: "session.compacting",
2450
- sessionId: this.sessionId,
2451
- messages: compactMessages,
2452
- })) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
2453
-
2454
- hookContext = result?.context;
2455
- hookPrompt = result?.prompt;
2456
- preserveData = result?.preserveData;
2457
- }
2458
-
2459
- let summary: string;
2460
- let shortSummary: string | undefined;
2461
- let firstKeptEntryId: string;
2462
- let tokensBefore: number;
2463
- let details: unknown;
2464
-
2465
- if (hookCompaction) {
2466
- // Extension provided compaction content
2467
- summary = hookCompaction.summary;
2468
- shortSummary = hookCompaction.shortSummary;
2469
- firstKeptEntryId = hookCompaction.firstKeptEntryId;
2470
- tokensBefore = hookCompaction.tokensBefore;
2471
- details = hookCompaction.details;
2472
- preserveData ??= hookCompaction.preserveData;
2473
- } else {
2474
- const candidates = this.#model.getCompactionModelCandidates(availableModels);
2475
- const retrySettings = this.settings.getGroup("retry");
2476
- let compactResult: CompactionResult | undefined;
2477
- let lastError: unknown;
2478
-
2479
- for (const candidate of candidates) {
2480
- const apiKey = await this.#model.registry.getApiKey(candidate, this.sessionId);
2481
- if (!apiKey) continue;
2482
-
2483
- let attempt = 0;
2484
- while (true) {
2485
- try {
2486
- compactResult = await compact(
2487
- preparation,
2488
- candidate,
2489
- apiKey,
2490
- undefined,
2491
- this.#autoCompactionAbortController.signal,
2492
- { promptOverride: hookPrompt, extraContext: hookContext },
2493
- );
2494
- break;
2495
- } catch (error) {
2496
- if (this.#autoCompactionAbortController.signal.aborted) {
2497
- throw error;
2498
- }
2499
-
2500
- const message = error instanceof Error ? error.message : String(error);
2501
- const retryAfterMs = parseRetryAfterMs(message);
2502
- const shouldRetry =
2503
- retrySettings.enabled &&
2504
- attempt < retrySettings.maxRetries &&
2505
- (retryAfterMs !== undefined || isRetryableErrorMessage(message));
2506
- if (!shouldRetry) {
2507
- lastError = error;
2508
- break;
2509
- }
2510
-
2511
- const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt;
2512
- const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs;
2513
-
2514
- // If retry delay is too long (>30s), try next candidate instead of waiting
2515
- const maxAcceptableDelayMs = 30_000;
2516
- if (delayMs > maxAcceptableDelayMs) {
2517
- const hasMoreCandidates = candidates.indexOf(candidate) < candidates.length - 1;
2518
- if (hasMoreCandidates) {
2519
- logger.warn("Auto-compaction retry delay too long, trying next model", {
2520
- delayMs,
2521
- retryAfterMs,
2522
- error: message,
2523
- model: `${candidate.provider}/${candidate.id}`,
2524
- });
2525
- lastError = error;
2526
- break; // Exit retry loop, continue to next candidate
2527
- }
2528
- // No more candidates - we have to wait
2529
- }
2530
-
2531
- attempt++;
2532
- logger.warn("Auto-compaction failed, retrying", {
2533
- attempt,
2534
- maxRetries: retrySettings.maxRetries,
2535
- delayMs,
2536
- retryAfterMs,
2537
- error: message,
2538
- model: `${candidate.provider}/${candidate.id}`,
2539
- });
2540
- await abortableSleep(delayMs, this.#autoCompactionAbortController.signal);
2541
- }
2542
- }
2543
-
2544
- if (compactResult) {
2545
- break;
2546
- }
2547
- }
2065
+ // Warning at lower threshold (once per session)
2066
+ if (usage.percent >= handoffSettings.warningThreshold && !this.#contextWarningEmitted) {
2067
+ this.#contextWarningEmitted = true;
2068
+ await this.#emitSessionEvent({
2069
+ type: "context_warning",
2070
+ percent: usage.percent,
2071
+ tokens: usage.tokens ?? 0,
2072
+ contextWindow: usage.contextWindow,
2073
+ });
2074
+ }
2548
2075
 
2549
- if (!compactResult) {
2550
- if (lastError) {
2551
- throw lastError;
2552
- }
2553
- throw new Error("Compaction failed: no available model");
2554
- }
2076
+ return false;
2077
+ }
2555
2078
 
2556
- summary = compactResult.summary;
2557
- shortSummary = compactResult.shortSummary;
2558
- firstKeptEntryId = compactResult.firstKeptEntryId;
2559
- tokensBefore = compactResult.tokensBefore;
2560
- details = compactResult.details;
2561
- }
2079
+ async #handleContextOverflow(): Promise<boolean> {
2080
+ await this.#emitSessionEvent({ type: "auto_handoff_start", percent: 100 });
2081
+ try {
2082
+ // Can't prompt agent (context full), build lightweight handoff from session state
2083
+ const parentThreadId = this.sessionManager.getSessionId();
2084
+ await this.sessionManager.flush();
2085
+ await this.sessionManager.newSession({ parentSession: parentThreadId });
2086
+ this.agent.reset();
2087
+ this.agent.sessionId = this.sessionManager.getSessionId();
2088
+ this.#steeringMessages = [];
2089
+ this.#followUpMessages = [];
2090
+ this.#pendingNextTurnMessages = [];
2091
+ this.#todoReminderCount = 0;
2092
+ this.#contextWarningEmitted = false;
2562
2093
 
2563
- if (this.#autoCompactionAbortController.signal.aborted) {
2564
- await this.#emitSessionEvent({
2565
- type: "auto_compaction_end",
2566
- result: undefined,
2567
- aborted: true,
2568
- willRetry: false,
2569
- });
2570
- return;
2571
- }
2094
+ // Inject a minimal handoff message pointing back to the parent thread
2095
+ const handoffContent = `<handoff-context thread="${parentThreadId}">\nContext window reached capacity. This session was automatically created to continue the work.\n\nUse \`read_thread("${parentThreadId}", "your specific question")\` to retrieve context from the previous session as needed.\n</handoff-context>\n\nThe previous session ran out of context space. Read the parent thread to understand what was being worked on and continue.`;
2096
+ this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true);
2572
2097
 
2573
- this.sessionManager.appendCompaction(
2574
- summary,
2575
- shortSummary,
2576
- firstKeptEntryId,
2577
- tokensBefore,
2578
- details,
2579
- fromExtension,
2580
- preserveData,
2581
- );
2582
- const newEntries = this.sessionManager.getEntries();
2098
+ // Rebuild agent messages from session
2583
2099
  const sessionContext = this.sessionManager.buildSessionContext();
2584
2100
  this.agent.replaceMessages(sessionContext.messages);
2585
- this.#model.closeCodexProviderSessionsForHistoryRewrite();
2586
-
2587
- // Get the saved compaction entry for the hook
2588
- const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
2589
- | CompactionEntry
2590
- | undefined;
2591
-
2592
- if (this.#extensionRunner && savedCompactionEntry) {
2593
- await this.#extensionRunner.emit({
2594
- type: "session_compact",
2595
- compactionEntry: savedCompactionEntry,
2596
- fromExtension,
2597
- });
2598
- }
2599
-
2600
- const result: CompactionResult = {
2601
- summary,
2602
- shortSummary,
2603
- firstKeptEntryId,
2604
- tokensBefore,
2605
- details,
2606
- preserveData,
2607
- };
2608
- await this.#emitSessionEvent({ type: "auto_compaction_end", result, aborted: false, willRetry });
2609
-
2610
- if (!willRetry && compactionSettings.autoContinue !== false) {
2611
- await this.prompt("Continue if you have next steps.", {
2612
- expandPromptTemplates: false,
2613
- synthetic: true,
2614
- });
2615
- }
2616
-
2617
- if (willRetry) {
2618
- const messages = this.agent.state.messages;
2619
- const lastMsg = messages[messages.length - 1];
2620
- if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
2621
- this.agent.replaceMessages(messages.slice(0, -1));
2622
- }
2623
2101
 
2624
- setTimeout(() => {
2625
- this.agent.continue().catch(() => {});
2626
- }, 100);
2627
- } else if (this.agent.hasQueuedMessages()) {
2628
- // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
2629
- // Kick the loop so queued messages are actually delivered.
2630
- setTimeout(() => {
2631
- this.agent.continue().catch(() => {});
2632
- }, 100);
2633
- }
2634
- } catch (error) {
2635
- if (this.#autoCompactionAbortController?.signal.aborted) {
2636
- await this.#emitSessionEvent({
2637
- type: "auto_compaction_end",
2638
- result: undefined,
2639
- aborted: true,
2640
- willRetry: false,
2641
- });
2642
- return;
2643
- }
2644
- const errorMessage = error instanceof Error ? error.message : "compaction failed";
2645
- await this.#emitSessionEvent({
2646
- type: "auto_compaction_end",
2647
- result: undefined,
2648
- aborted: false,
2649
- willRetry: false,
2650
- errorMessage:
2651
- reason === "overflow"
2652
- ? `Context overflow recovery failed: ${errorMessage}`
2653
- : `Auto-compaction failed: ${errorMessage}`,
2654
- });
2655
- } finally {
2656
- this.#autoCompactionAbortController = undefined;
2102
+ await this.#emitSessionEvent({ type: "auto_handoff_end", success: true });
2103
+ this.agent.continue().catch(() => {});
2104
+ return true;
2105
+ } catch (err) {
2106
+ logger.error("Auto-handoff overflow fallback failed", { error: String(err) });
2107
+ await this.#emitSessionEvent({ type: "auto_handoff_end", success: false, error: String(err) });
2108
+ return false;
2657
2109
  }
2658
2110
  }
2659
-
2660
- /**
2661
- * Toggle auto-compaction setting.
2662
- */
2663
- setAutoCompactionEnabled(enabled: boolean): void {
2664
- this.settings.set("compaction.enabled", enabled);
2665
- }
2666
-
2667
- /** Whether auto-compaction is enabled */
2668
- get autoCompactionEnabled(): boolean {
2669
- return this.settings.get("compaction.enabled");
2670
- }
2671
-
2672
2111
  // =========================================================================
2673
2112
  // Auto-Retry
2674
2113
  // =========================================================================
2675
2114
 
2676
2115
  /**
2677
2116
  * Check if an error is retryable (overloaded, rate limit, server errors).
2678
- * Context overflow errors are NOT retryable (handled by compaction instead).
2117
+ * Context overflow errors are NOT retryable.
2679
2118
  */
2680
2119
  #isRetryableError(message: AssistantMessage): boolean {
2681
2120
  if (message.stopReason !== "error" || !message.errorMessage) return false;
2682
2121
 
2683
- // Context overflow is handled by compaction, not retry
2122
+ // Context overflow is not retryable
2684
2123
  const contextWindow = this.model?.contextWindow ?? 0;
2685
2124
  if (isContextOverflow(message, contextWindow)) return false;
2686
2125
 
@@ -3397,7 +2836,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3397
2836
  }
3398
2837
 
3399
2838
  getContextUsage(): ContextUsage | undefined {
3400
- return sessionStats.getContextUsage(this.model, this.messages, this.sessionManager);
2839
+ return sessionStats.getContextUsage(this.model, this.messages);
3401
2840
  }
3402
2841
 
3403
2842
  async fetchUsageReports(): Promise<UsageReport[] | null> {