@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17

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 (143) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli.js +3531 -3812
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/model-discovery.d.ts +1 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/settings-schema.d.ts +12 -1
  11. package/dist/types/eval/agent-bridge.d.ts +19 -0
  12. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  13. package/dist/types/eval/js/shared/types.d.ts +1 -1
  14. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  15. package/dist/types/eval/py/executor.d.ts +1 -1
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  17. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  18. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  19. package/dist/types/hindsight/content.d.ts +2 -10
  20. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  21. package/dist/types/main.d.ts +2 -0
  22. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  25. package/dist/types/modes/components/session-selector.d.ts +25 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  30. package/dist/types/session/agent-session.d.ts +6 -6
  31. package/dist/types/session/provider-image-budget.d.ts +3 -0
  32. package/dist/types/session/session-context.d.ts +6 -5
  33. package/dist/types/task/isolation-runner.d.ts +128 -0
  34. package/dist/types/task/parallel.d.ts +4 -0
  35. package/dist/types/task/worktree.d.ts +14 -1
  36. package/dist/types/thinking.d.ts +23 -1
  37. package/dist/types/tiny/title-client.d.ts +2 -2
  38. package/dist/types/tools/eval-render.d.ts +3 -0
  39. package/dist/types/tools/eval.d.ts +11 -17
  40. package/dist/types/tools/todo.d.ts +26 -28
  41. package/dist/types/tui/output-block.d.ts +8 -0
  42. package/dist/types/utils/image-resize.d.ts +2 -0
  43. package/dist/types/utils/tools-manager.d.ts +2 -0
  44. package/dist/types/web/search/providers/exa.d.ts +2 -0
  45. package/package.json +12 -12
  46. package/scripts/build-binary.ts +18 -4
  47. package/src/auto-thinking/classifier.ts +7 -2
  48. package/src/cli/args.ts +4 -5
  49. package/src/cli/flag-tables.ts +3 -3
  50. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  51. package/src/cli/gallery-fixtures/shell.ts +15 -23
  52. package/src/cli/profile-alias.ts +38 -7
  53. package/src/cli/session-picker.ts +17 -3
  54. package/src/cli/usage-cli.ts +5 -1
  55. package/src/commands/launch.ts +3 -3
  56. package/src/config/model-discovery.ts +59 -8
  57. package/src/config/model-registry.ts +74 -3
  58. package/src/config/settings-schema.ts +13 -1
  59. package/src/discovery/omp-extension-roots.ts +1 -3
  60. package/src/edit/renderer.ts +34 -12
  61. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  62. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  63. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  64. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  65. package/src/eval/agent-bridge.ts +301 -71
  66. package/src/eval/jl/prelude.jl +32 -227
  67. package/src/eval/jl/runner.jl +38 -12
  68. package/src/eval/js/shared/helpers.ts +1 -114
  69. package/src/eval/js/shared/prelude.txt +13 -27
  70. package/src/eval/js/shared/runtime.ts +0 -6
  71. package/src/eval/js/shared/types.ts +1 -1
  72. package/src/eval/js/worker-protocol.ts +1 -1
  73. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  74. package/src/eval/py/executor.ts +1 -1
  75. package/src/eval/py/prelude.py +47 -105
  76. package/src/eval/py/runner.py +0 -6
  77. package/src/eval/rb/prelude.rb +21 -189
  78. package/src/eval/rb/runner.rb +116 -9
  79. package/src/export/html/tool-views.generated.js +29 -29
  80. package/src/extensibility/extensions/wrapper.ts +3 -2
  81. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  83. package/src/extensibility/plugins/loader.ts +71 -23
  84. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  85. package/src/extensibility/tool-event-input.ts +57 -0
  86. package/src/hindsight/content.ts +21 -12
  87. package/src/internal-urls/docs-index.generated.txt +1 -1
  88. package/src/internal-urls/local-protocol.ts +100 -53
  89. package/src/main.ts +15 -4
  90. package/src/mcp/tool-bridge.ts +27 -2
  91. package/src/mnemopi/state.ts +5 -2
  92. package/src/modes/acp/acp-event-mapper.ts +7 -2
  93. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  94. package/src/modes/components/chat-transcript-builder.ts +6 -0
  95. package/src/modes/components/custom-editor.test.ts +18 -1
  96. package/src/modes/components/custom-editor.ts +77 -45
  97. package/src/modes/components/hook-editor.ts +15 -2
  98. package/src/modes/components/plugin-settings.ts +7 -1
  99. package/src/modes/components/session-selector.ts +143 -29
  100. package/src/modes/components/settings-selector.ts +2 -2
  101. package/src/modes/components/status-line/component.ts +52 -8
  102. package/src/modes/components/status-line/segments.ts +5 -1
  103. package/src/modes/components/status-line/types.ts +1 -0
  104. package/src/modes/components/welcome.ts +12 -14
  105. package/src/modes/controllers/command-controller.ts +21 -5
  106. package/src/modes/controllers/input-controller.ts +115 -3
  107. package/src/modes/controllers/selector-controller.ts +19 -1
  108. package/src/modes/interactive-mode.ts +3 -3
  109. package/src/modes/rpc/rpc-mode.ts +6 -0
  110. package/src/modes/utils/copy-targets.ts +7 -2
  111. package/src/modes/utils/ui-helpers.ts +3 -3
  112. package/src/prompts/system/system-prompt.md +3 -3
  113. package/src/prompts/system/workflow-notice.md +3 -3
  114. package/src/prompts/tools/bash.md +16 -0
  115. package/src/prompts/tools/eval.md +19 -19
  116. package/src/prompts/tools/todo.md +1 -1
  117. package/src/sdk.ts +8 -10
  118. package/src/session/agent-session.ts +422 -97
  119. package/src/session/provider-image-budget.ts +86 -0
  120. package/src/session/session-context.ts +14 -7
  121. package/src/session/session-storage.ts +24 -2
  122. package/src/session/snapcompact-inline.ts +19 -3
  123. package/src/slash-commands/builtin-registry.ts +0 -22
  124. package/src/slash-commands/helpers/usage-report.ts +9 -1
  125. package/src/task/index.ts +61 -207
  126. package/src/task/isolation-runner.ts +354 -0
  127. package/src/task/parallel.ts +6 -1
  128. package/src/task/worktree.ts +46 -9
  129. package/src/thinking.ts +29 -2
  130. package/src/tiny/title-client.ts +75 -21
  131. package/src/tools/ask.ts +44 -38
  132. package/src/tools/bash.ts +9 -2
  133. package/src/tools/browser/tab-worker.ts +1 -1
  134. package/src/tools/eval-render.ts +34 -27
  135. package/src/tools/eval.ts +100 -103
  136. package/src/tools/index.ts +8 -1
  137. package/src/tools/read.ts +136 -60
  138. package/src/tools/todo.ts +60 -64
  139. package/src/tui/code-cell.ts +1 -1
  140. package/src/tui/output-block.ts +11 -0
  141. package/src/utils/image-resize.ts +30 -0
  142. package/src/utils/tools-manager.ts +67 -10
  143. package/src/web/search/providers/exa.ts +85 -1
@@ -44,6 +44,7 @@ import {
44
44
  CompactionCancelledError,
45
45
  type CompactionPreparation,
46
46
  type CompactionResult,
47
+ type CompactionSettings,
47
48
  calculateContextTokens,
48
49
  calculatePromptTokens,
49
50
  collectEntriesForBranchSummary,
@@ -55,8 +56,9 @@ import {
55
56
  effectiveReserveTokens,
56
57
  estimateTokens,
57
58
  generateBranchSummary,
58
- generateHandoff,
59
+ generateHandoffFromContext,
59
60
  prepareCompaction,
61
+ renderHandoffPrompt,
60
62
  resolveThresholdTokens,
61
63
  type SessionEntry,
62
64
  type SessionMessageEntry,
@@ -103,7 +105,7 @@ import {
103
105
  resolveServiceTier,
104
106
  streamSimple,
105
107
  } from "@oh-my-pi/pi-ai";
106
- import { stripToolDescriptions, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
108
+ import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
107
109
  import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
108
110
  import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
109
111
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
@@ -302,7 +304,7 @@ import {
302
304
  stripImagesFromMessage,
303
305
  USER_INTERRUPT_LABEL,
304
306
  } from "./messages";
305
- import type { SessionContext } from "./session-context";
307
+ import type { BuildSessionContextOptions, SessionContext } from "./session-context";
306
308
  import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-context";
307
309
  import { formatSessionDumpText } from "./session-dump-format";
308
310
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
@@ -1364,6 +1366,13 @@ export class AgentSession {
1364
1366
  #pendingRewindReport: string | undefined = undefined;
1365
1367
  #rewoundToolResultIds = new Set<string>();
1366
1368
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1369
+ /**
1370
+ * Sticky across an in-flight prompt run: a successful `yield` makes the run
1371
+ * terminal for execution purposes, so any trailing empty/aborted assistant
1372
+ * stop must NOT trigger empty-stop/unexpected-stop/compaction continuations.
1373
+ * Cleared in `#promptWithMessage` so the next prompt evaluates cleanly.
1374
+ */
1375
+ #yieldTerminationPending = false;
1367
1376
  #providerSessionState = new Map<string, ProviderSessionState>();
1368
1377
  #hindsightSessionState: HindsightSessionState | undefined = undefined;
1369
1378
  readonly rawSseDebugBuffer: RawSseDebugBuffer;
@@ -1619,6 +1628,7 @@ export class AgentSession {
1619
1628
  await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
1620
1629
  }
1621
1630
  }
1631
+ await this.#maintainContextMidRun(messages, signal);
1622
1632
  });
1623
1633
  this.yieldQueue = new YieldQueue({
1624
1634
  isStreaming: () => this.isStreaming,
@@ -2552,6 +2562,7 @@ export class AgentSession {
2552
2562
  }
2553
2563
  if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
2554
2564
  this.#lastSuccessfulYieldToolCallId = event.toolCallId;
2565
+ this.#yieldTerminationPending = true;
2555
2566
  }
2556
2567
 
2557
2568
  // TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
@@ -2777,10 +2788,32 @@ export class AgentSession {
2777
2788
  this.#lastAssistantMessage = undefined;
2778
2789
  if (!msg) {
2779
2790
  this.#lastSuccessfulYieldToolCallId = undefined;
2791
+ logger.debug("agent_end maintenance routing", {
2792
+ reason: "no-assistant-message",
2793
+ goalModeEnabled: this.#goalModeState?.enabled === true,
2794
+ goalStatus: this.#goalModeState?.goal.status,
2795
+ });
2780
2796
  await emitAgentEndNotification();
2781
2797
  return;
2782
2798
  }
2783
2799
 
2800
+ const maintenanceRoute = (route: string, extra?: Record<string, unknown>) => {
2801
+ logger.debug("agent_end maintenance routing", {
2802
+ route,
2803
+ stopReason: msg.stopReason,
2804
+ provider: msg.provider,
2805
+ model: msg.model,
2806
+ contentBlocks: msg.content.length,
2807
+ hasToolCalls: msg.content.some(content => content.type === "toolCall"),
2808
+ hasText: msg.content.some(content => content.type === "text"),
2809
+ goalModeEnabled: this.#goalModeState?.enabled === true,
2810
+ goalStatus: this.#goalModeState?.goal.status,
2811
+ successfulYield: this.#assistantEndedWithSuccessfulYield(msg),
2812
+ ...extra,
2813
+ });
2814
+ };
2815
+ maintenanceRoute("entered");
2816
+
2784
2817
  // Invalidate GitHub Copilot credentials on auth failure so stale tokens
2785
2818
  // aren't reused on the next request
2786
2819
  if (
@@ -2794,27 +2827,70 @@ export class AgentSession {
2794
2827
  if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
2795
2828
  this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
2796
2829
  this.#lastSuccessfulYieldToolCallId = undefined;
2830
+ maintenanceRoute("skip-post-turn-maintenance");
2797
2831
  await emitAgentEndNotification();
2798
2832
  return;
2799
2833
  }
2800
2834
 
2801
- if (this.#assistantEndedWithSuccessfulYield(msg)) {
2835
+ const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
2836
+ const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg);
2837
+ // A successful `yield` in this run is terminal for execution purposes.
2838
+ // Suppress empty-stop retry, unexpected-stop retry, queued-message drain,
2839
+ // and compaction-driven continuations for the rest of this prompt cycle:
2840
+ // the executor consumed the yield as the terminal result, so a trailing
2841
+ // empty/aborted assistant stop must NOT revive the agent loop. The
2842
+ // `#yieldTerminationPending` sticky flag clears on the next `prompt()`.
2843
+ if (yieldOnThisMessage || this.#yieldTerminationPending) {
2802
2844
  this.#lastSuccessfulYieldToolCallId = undefined;
2803
- if (this.#goalModeState?.enabled && this.#goalModeState.goal.status === "active") {
2845
+ if (yieldOnThisMessage && activeGoal) {
2846
+ maintenanceRoute("successful-yield-active-goal-checkCompaction");
2804
2847
  const compactionTask = this.#checkCompaction(msg);
2805
2848
  this.#trackPostPromptTask(compactionTask);
2806
2849
  await compactionTask;
2850
+ } else if (yieldOnThisMessage) {
2851
+ maintenanceRoute("successful-yield-no-active-goal");
2852
+ } else {
2853
+ maintenanceRoute("post-yield-trailing-stop-suppressed");
2807
2854
  }
2808
2855
  await emitAgentEndNotification();
2809
2856
  return;
2810
2857
  }
2811
2858
  this.#lastSuccessfulYieldToolCallId = undefined;
2812
2859
 
2860
+ // Empty-stop cleanup MUST run before any compaction continuation: an
2861
+ // empty toolUse stop must be stripped from active context + session
2862
+ // history before we schedule another turn, otherwise the next
2863
+ // Anthropic turn carries a tool_use block with no matching
2864
+ // tool_result and corrupts message history. The handler also
2865
+ // schedules its own retry, so a real empty stop never needs the
2866
+ // active-goal threshold pre-empt below.
2813
2867
  if (await this.#handleEmptyAssistantStop(msg)) {
2868
+ maintenanceRoute("empty-stop-handled");
2814
2869
  await emitAgentEndNotification();
2815
2870
  return;
2816
2871
  }
2872
+
2873
+ let compactionResult = COMPACTION_CHECK_NONE;
2874
+ let checkedCompaction = false;
2875
+ if (activeGoal) {
2876
+ maintenanceRoute("active-goal-pre-empt-checkCompaction");
2877
+ const compactionTask = this.#checkCompaction(msg);
2878
+ this.#trackPostPromptTask(compactionTask);
2879
+ compactionResult = await compactionTask;
2880
+ checkedCompaction = true;
2881
+ if (compactionResult.deferredHandoff || compactionResult.continuationScheduled) {
2882
+ maintenanceRoute("active-goal-pre-empt-continuation-scheduled", {
2883
+ deferredHandoff: compactionResult.deferredHandoff,
2884
+ continuationScheduled: compactionResult.continuationScheduled,
2885
+ });
2886
+ this.#resolveRetry();
2887
+ await emitAgentEndNotification();
2888
+ return;
2889
+ }
2890
+ }
2891
+
2817
2892
  if (await this.#handleUnexpectedAssistantStop(msg)) {
2893
+ maintenanceRoute("unexpected-stop-handled");
2818
2894
  await emitAgentEndNotification();
2819
2895
  return;
2820
2896
  }
@@ -2854,9 +2930,12 @@ export class AgentSession {
2854
2930
  }
2855
2931
  this.#resolveRetry();
2856
2932
 
2857
- const compactionTask = this.#checkCompaction(msg);
2858
- this.#trackPostPromptTask(compactionTask);
2859
- const compactionResult = await compactionTask;
2933
+ if (!checkedCompaction) {
2934
+ maintenanceRoute("bottom-checkCompaction");
2935
+ const compactionTask = this.#checkCompaction(msg);
2936
+ this.#trackPostPromptTask(compactionTask);
2937
+ compactionResult = await compactionTask;
2938
+ }
2860
2939
  // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
2861
2940
  const hasToolCalls = msg.content.some(content => content.type === "toolCall");
2862
2941
  if (hasToolCalls) {
@@ -5227,13 +5306,21 @@ export class AgentSession {
5227
5306
  }
5228
5307
 
5229
5308
  /**
5230
- * Full-history transcript for TUI display: every path entry in
5231
- * chronological order with compactions rendered inline at the point they
5232
- * fired (instead of replacing prior history). Display-only — NEVER feed
5233
- * the result to `agent.replaceMessages` or a provider.
5309
+ * Transcript for TUI display. Full history is kept for export/resume-style
5310
+ * callers; live chat can collapse compacted history to keep the hot render
5311
+ * surface bounded. Display-only — NEVER feed the result to
5312
+ * `agent.replaceMessages` or a provider.
5234
5313
  */
5235
- buildTranscriptSessionContext(): SessionContext {
5236
- return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator);
5314
+ buildTranscriptSessionContext(
5315
+ options?: Pick<BuildSessionContextOptions, "collapseCompactedHistory">,
5316
+ ): SessionContext {
5317
+ return deobfuscateSessionContext(
5318
+ this.sessionManager.buildSessionContext({
5319
+ transcript: true,
5320
+ collapseCompactedHistory: options?.collapseCompactedHistory,
5321
+ }),
5322
+ this.#obfuscator,
5323
+ );
5237
5324
  }
5238
5325
 
5239
5326
  #obfuscateTextForProvider(text: string | undefined): string | undefined {
@@ -5939,6 +6026,10 @@ export class AgentSession {
5939
6026
  this.#todoReminderAwaitingProgress = false;
5940
6027
  this.#emptyStopRetryCount = 0;
5941
6028
  this.#unexpectedStopRetryCount = 0;
6029
+ // A new prompt cycle starts: drop any sticky yield-termination from the
6030
+ // previous run so empty-stop / unexpected-stop / compaction maintenance
6031
+ // can evaluate this turn normally.
6032
+ this.#yieldTerminationPending = false;
5942
6033
 
5943
6034
  await this.#maybeRestoreRetryFallbackPrimary();
5944
6035
 
@@ -6967,20 +7058,22 @@ export class AgentSession {
6967
7058
  throw new Error(`No API key for ${model.provider}/${model.id}`);
6968
7059
  }
6969
7060
 
7061
+ const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
7062
+
6970
7063
  this.#clearActiveRetryFallback();
6971
- this.#setModelWithProviderSessionReset(model);
6972
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
7064
+ this.#setModelWithProviderSessionReset(targetModel);
7065
+ this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
6973
7066
  if (options?.persist) {
6974
7067
  this.settings.setModelRole(
6975
7068
  role,
6976
- this.#formatRoleModelValue(role, model, options.selector, options.thinkingLevel),
7069
+ this.#formatRoleModelValue(role, targetModel, options.selector, options.thinkingLevel),
6977
7070
  );
6978
7071
  }
6979
- this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
7072
+ this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
6980
7073
 
6981
7074
  // Re-apply thinking for the newly selected model. Prefer the model's
6982
7075
  // configured defaultLevel; otherwise preserve the current level (or auto).
6983
- this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
7076
+ this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
6984
7077
  await this.#syncAfterModelChange(previousEditMode);
6985
7078
  }
6986
7079
 
@@ -7001,20 +7094,22 @@ export class AgentSession {
7001
7094
  throw new Error(`No API key for ${model.provider}/${model.id}`);
7002
7095
  }
7003
7096
 
7097
+ const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
7098
+
7004
7099
  this.#clearActiveRetryFallback();
7005
- this.#setModelWithProviderSessionReset(model);
7100
+ this.#setModelWithProviderSessionReset(targetModel);
7006
7101
  this.sessionManager.appendModelChange(
7007
- `${model.provider}/${model.id}`,
7102
+ `${targetModel.provider}/${targetModel.id}`,
7008
7103
  options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
7009
7104
  );
7010
- this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
7105
+ this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
7011
7106
 
7012
7107
  // Apply explicit thinking level if given; otherwise prefer the model's
7013
7108
  // configured defaultLevel; otherwise re-clamp the current level (or auto).
7014
7109
  if (thinkingLevel !== undefined) {
7015
7110
  this.setThinkingLevel(thinkingLevel);
7016
7111
  } else {
7017
- this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
7112
+ this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
7018
7113
  }
7019
7114
  await this.#syncAfterModelChange(previousEditMode);
7020
7115
  }
@@ -7302,6 +7397,10 @@ export class AgentSession {
7302
7397
  async #applyAutoThinkingLevel(promptText: string, generation: number): Promise<void> {
7303
7398
  const model = this.model;
7304
7399
  if (!model?.reasoning) return;
7400
+ // Models with reasoning but no controllable effort surface (devin-agent
7401
+ // Cascade routes effort via sibling model ids, not a wire param) have
7402
+ // nothing to pick — skip classification rather than discard its result.
7403
+ if (getSupportedEfforts(model).length === 0) return;
7305
7404
 
7306
7405
  let resolved: Effort | undefined;
7307
7406
  if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
@@ -7798,31 +7897,45 @@ export class AgentSession {
7798
7897
  let tokensBefore: number;
7799
7898
  let details: unknown;
7800
7899
 
7801
- // Snapcompact runs locally first; if its frame archive plus the kept
7802
- // history still overflows the model window, fall back to an LLM summary
7803
- // (far cheaper than ~FRAME_TOKEN_ESTIMATE per frame).
7900
+ // Snapcompact runs locally first. The frame cap is sized from the live
7901
+ // model window via #computeSnapcompactMaxFrames so the post-render context
7902
+ // fits without the warning loop (issue #3247). Zero-frame budget → skip
7903
+ // snapcompact and take the summarizer path immediately.
7804
7904
  let snapcompactResult: snapcompact.CompactionResult | undefined;
7805
7905
  if (snapcompactReady) {
7806
- snapcompactResult = await snapcompact.compact(preparation, {
7807
- convertToLlm,
7808
- model: this.model,
7809
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
7810
- });
7811
- const ctxWindow = this.model?.contextWindow ?? 0;
7812
- const budget =
7813
- ctxWindow > 0
7814
- ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
7815
- : Number.POSITIVE_INFINITY;
7816
- if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
7817
- logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
7906
+ const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings);
7907
+ if (maxFrames < 1) {
7908
+ logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
7818
7909
  model: this.model?.id,
7819
7910
  });
7820
7911
  this.emitNotice(
7821
7912
  "warning",
7822
- "snapcompact could not bring the context under the limit — using an LLM summary instead",
7913
+ "snapcompact: kept history alone exceeds the context budget — using an LLM summary instead",
7823
7914
  "compaction",
7824
7915
  );
7825
- snapcompactResult = undefined;
7916
+ } else {
7917
+ snapcompactResult = await snapcompact.compact(preparation, {
7918
+ convertToLlm,
7919
+ model: this.model,
7920
+ shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
7921
+ maxFrames,
7922
+ });
7923
+ const ctxWindow = this.model?.contextWindow ?? 0;
7924
+ const budget =
7925
+ ctxWindow > 0
7926
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
7927
+ : Number.POSITIVE_INFINITY;
7928
+ if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
7929
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing; falling back", {
7930
+ model: this.model?.id,
7931
+ });
7932
+ this.emitNotice(
7933
+ "warning",
7934
+ "snapcompact could not bring the context under the limit — using an LLM summary instead",
7935
+ "compaction",
7936
+ );
7937
+ snapcompactResult = undefined;
7938
+ }
7826
7939
  }
7827
7940
  }
7828
7941
 
@@ -8049,27 +8162,60 @@ export class AgentSession {
8049
8162
  throw new Error(`No API key for ${model.provider}`);
8050
8163
  }
8051
8164
 
8052
- const rawHandoffText = await generateHandoff(
8053
- this.agent.state.messages,
8054
- model,
8055
- this.#modelRegistry.resolver(model, this.sessionId),
8165
+ // Build the handoff request through the SAME pipeline a live turn uses
8166
+ // (`runEphemeralTurn` / `/btw` share it) so the oneshot reads the
8167
+ // provider prompt cache the main turn populated instead of cold-missing
8168
+ // the whole prefix: identical system prompt, normalized tools, and
8169
+ // transform-/obfuscation-matched message history via
8170
+ // `convertMessagesToLlm` + `buildSideRequestContext`, plus the live turn's
8171
+ // effective provider cache key with a unique side `sessionId` so
8172
+ // OpenAI/Codex append-only state never mixes with the live turn.
8173
+ const cacheSessionId = this.sessionId;
8174
+ // The loop sends `promptCacheKey` (providerPromptCacheKey) and falls back to
8175
+ // the provider session id; providers route on `promptCacheKey ?? sessionId`.
8176
+ // Both can diverge from this.sessionId (tan/subagent/shared sessions), so
8177
+ // mirror exactly what the live turn populated the cache under.
8178
+ const handoffPromptCacheKey = this.agent.promptCacheKey ?? this.agent.sessionId;
8179
+ const handoffPromptText = renderHandoffPrompt(this.#obfuscateTextForProvider(customInstructions));
8180
+ const handoffSnapshot: AgentMessage[] = [
8181
+ ...this.agent.state.messages,
8056
8182
  {
8057
- systemPrompt: this.#baseSystemPrompt,
8058
- tools: this.#pruneToolDescriptions
8059
- ? stripToolDescriptions(this.agent.state.tools)
8060
- : this.agent.state.tools,
8061
- customInstructions: this.#obfuscateTextForProvider(customInstructions),
8062
- convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
8183
+ role: "user",
8184
+ content: [{ type: "text", text: handoffPromptText }],
8185
+ attribution: "agent",
8186
+ timestamp: Date.now(),
8187
+ },
8188
+ ];
8189
+ const handoffLlmMessages = await this.convertMessagesToLlm(handoffSnapshot, handoffSignal);
8190
+ // Base system prompt, not a per-turn `before_agent_start` hook override —
8191
+ // the handoff seeds a fresh session and must not carry prompt-specific
8192
+ // hook state. Matches the prompt the old handoff path sent.
8193
+ const handoffContext = await this.agent.buildSideRequestContext(handoffLlmMessages, this.#baseSystemPrompt);
8194
+ const handoffStreamOptions = this.prepareSimpleStreamOptions(
8195
+ {
8196
+ apiKey: this.#modelRegistry.resolver(model, cacheSessionId),
8197
+ sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
8198
+ promptCacheKey: handoffPromptCacheKey,
8199
+ preferWebsockets: false,
8200
+ serviceTier: this.#effectiveServiceTier(model),
8201
+ hideThinkingSummary: this.agent.hideThinkingSummary,
8063
8202
  initiatorOverride: "agent",
8064
- metadata: this.agent.metadataForProvider(model.provider),
8203
+ signal: handoffSignal,
8204
+ },
8205
+ model.provider,
8206
+ );
8207
+ const rawHandoffText = await generateHandoffFromContext(
8208
+ obfuscateProviderContext(this.#obfuscator, handoffContext),
8209
+ model,
8210
+ {
8211
+ streamOptions: handoffStreamOptions,
8065
8212
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
8066
- // Honor the user's /model thinking selection on the handoff
8067
- // path. Clamped per-model inside generateHandoff via
8068
- // resolveCompactionEffort so unsupported-effort models don't
8069
- // trip requireSupportedEffort.
8213
+ // Honor the user's /model thinking selection on the handoff path.
8214
+ // Clamped per-model inside generateHandoffFromContext via
8215
+ // resolveCompactionEffort so unsupported-effort models don't trip
8216
+ // requireSupportedEffort.
8070
8217
  thinkingLevel: this.thinkingLevel,
8071
8218
  },
8072
- handoffSignal,
8073
8219
  );
8074
8220
  const handoffText = this.#deobfuscateFromProvider(rawHandoffText);
8075
8221
 
@@ -8213,6 +8359,58 @@ export class AgentSession {
8213
8359
  });
8214
8360
  }
8215
8361
 
8362
+ /**
8363
+ * Compact active `/goal` runs that never settle to `agent_end`.
8364
+ *
8365
+ * Long autonomous goals can keep producing tool calls inside one agent run.
8366
+ * The post-turn `agent_end` threshold check never fires in that shape, so
8367
+ * context can grow until provider overflow. `onTurnEnd` is the safe boundary:
8368
+ * tool results for the just-finished turn are already paired in
8369
+ * `activeMessages`, the live array the agent loop reads before its next
8370
+ * model call. Run maintenance here and splice the compacted state back into
8371
+ * that array, mirroring [`AgentSession.#applyRewind`].
8372
+ */
8373
+ async #maintainContextMidRun(activeMessages: AgentMessage[], signal?: AbortSignal): Promise<void> {
8374
+ if (signal?.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) return;
8375
+ if (!(this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active")) return;
8376
+
8377
+ const model = this.model;
8378
+ const contextWindow = model?.contextWindow ?? 0;
8379
+ if (contextWindow <= 0) return;
8380
+
8381
+ const compactionSettings = this.settings.getGroup("compaction");
8382
+ if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
8383
+
8384
+ const lastAssistant = [...activeMessages]
8385
+ .reverse()
8386
+ .find((message): message is AssistantMessage => message.role === "assistant");
8387
+ if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return;
8388
+
8389
+ const billedContextTokens = calculateContextTokens(lastAssistant.usage);
8390
+ const storedContextTokens = this.#estimateStoredContextTokens();
8391
+ const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
8392
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
8393
+
8394
+ const messagesBefore = activeMessages.length;
8395
+ await this.#runAutoCompaction("threshold", false, false, false, {
8396
+ autoContinue: false,
8397
+ suppressContinuation: true,
8398
+ triggerContextTokens: contextTokens,
8399
+ });
8400
+
8401
+ if (signal?.aborted) return;
8402
+ const compactedMessages = this.agent.state.messages;
8403
+ if (compactedMessages !== activeMessages) {
8404
+ activeMessages.splice(0, activeMessages.length, ...compactedMessages);
8405
+ }
8406
+ logger.debug("Mid-run goal compaction ran between tool-call turns", {
8407
+ contextTokens,
8408
+ contextWindow,
8409
+ strategy: compactionSettings.strategy,
8410
+ messagesBefore,
8411
+ messagesAfter: activeMessages.length,
8412
+ });
8413
+ }
8216
8414
  /**
8217
8415
  * Check if context maintenance or promotion is needed and run it.
8218
8416
  * Called after agent_end and before prompt submission.
@@ -8338,28 +8536,58 @@ export class AgentSession {
8338
8536
  // Skip if this was an error (non-overflow errors don't have usage data)
8339
8537
  if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
8340
8538
  const pruneResult = await this.#pruneToolOutputs();
8341
- let contextTokens = calculateContextTokens(assistantMessage.usage);
8342
- if (supersedeResult) {
8343
- contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved);
8344
- }
8345
- if (pruneResult) {
8346
- contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
8347
- }
8348
- // Floor by the real stored-conversation estimate so a payload-shrinking
8349
- // before_provider_request hook (e.g. a compression extension such as
8350
- // Headroom) can't deflate the provider-reported usage below the true
8351
- // history size and skip the threshold. The estimate runs after the prune
8352
- // passes above, so it reflects the post-prune message set.
8353
- contextTokens = compactionContextTokens(contextTokens, this.#estimateStoredContextTokens());
8354
- if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
8539
+ const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0);
8540
+ const assistantUsageContextTokens = calculateContextTokens(assistantMessage.usage);
8541
+ const storedContextTokens = this.#estimateStoredContextTokens();
8542
+ // Pruning frees bytes for the NEXT prompt; it does not change the size of
8543
+ // the prompt the LLM just billed for. Earlier revisions subtracted the
8544
+ // per-turn supersede/prune `tokensSaved` from the threshold input, which
8545
+ // let a long-running `/goal` session sit above `compaction.thresholdTokens`
8546
+ // indefinitely whenever per-turn pruning saved enough to drop the
8547
+ // post-prune estimate below the user-configured trigger the visible
8548
+ // context (anchored to the same provider billing) still showed >threshold,
8549
+ // but `shouldCompact` no-op'd (#3174). Anchor the initial trigger on the
8550
+ // last turn's billed context tokens, floored by the post-prune
8551
+ // stored-conversation estimate so a payload-compression hook still can't
8552
+ // deflate the trigger.
8553
+ const contextTokens = compactionContextTokens(assistantUsageContextTokens, storedContextTokens);
8554
+ const postMaintenanceContextTokens = compactionContextTokens(
8555
+ Math.max(0, assistantUsageContextTokens - maintenanceTokensFreed),
8556
+ storedContextTokens,
8557
+ );
8558
+ const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
8559
+ const shouldThresholdCompact = shouldCompact(contextTokens, contextWindow, compactionSettings);
8560
+ logger.debug("Auto-compaction threshold decision", {
8561
+ phase: "post-agent-end",
8562
+ goalModeEnabled: this.#goalModeState?.enabled === true,
8563
+ goalStatus: this.#goalModeState?.goal.status,
8564
+ stopReason: assistantMessage.stopReason,
8565
+ sameModel: sameModel === true,
8566
+ contextWindow,
8567
+ strategy: compactionSettings.strategy,
8568
+ thresholdTokens,
8569
+ assistantUsageContextTokens,
8570
+ storedContextTokens,
8571
+ resolvedContextTokens: contextTokens,
8572
+ postMaintenanceContextTokens,
8573
+ maintenanceTokensFreed,
8574
+ shouldCompact: shouldThresholdCompact,
8575
+ contextPromotionEnabled: this.settings.get("contextPromotion.enabled") === true,
8576
+ });
8577
+ if (shouldThresholdCompact) {
8355
8578
  // Try promotion first — if a larger model is available, switch instead of compacting
8356
8579
  const promoted = await this.#tryContextPromotion(assistantMessage);
8357
8580
  if (!promoted) {
8358
8581
  return await this.#runAutoCompaction("threshold", false, false, allowDefer, {
8359
8582
  autoContinue,
8360
- triggerContextTokens: contextTokens,
8583
+ triggerContextTokens: postMaintenanceContextTokens,
8361
8584
  });
8362
8585
  }
8586
+ logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
8587
+ contextTokens,
8588
+ contextWindow,
8589
+ model: `${assistantMessage.provider}/${assistantMessage.model}`,
8590
+ });
8363
8591
  }
8364
8592
  return COMPACTION_CHECK_NONE;
8365
8593
  }
@@ -9438,6 +9666,82 @@ export class AgentSession {
9438
9666
  return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
9439
9667
  }
9440
9668
 
9669
+ /**
9670
+ * Cap on snapcompact frames the post-compaction context can carry without
9671
+ * busting the model window. Mirrors the per-frame token charge used by the
9672
+ * projection ({@link snapcompact.FRAME_TOKEN_ESTIMATE}, the conservative
9673
+ * high-res Anthropic ceiling), so picking `maxFrames` from this helper makes
9674
+ * {@link #projectSnapcompactContextTokens} succeed by construction.
9675
+ *
9676
+ * Skip vs. cap use different reserves on purpose. The **skip** decision
9677
+ * (return `0`) trips only when kept-recent plus non-message tokens already
9678
+ * eat the entire `ctxWindow − reserve` envelope: at that point no archive
9679
+ * shape — frame-bearing or text-only — can fit, and the caller MUST
9680
+ * shortcut to the LLM summarizer instead of re-running snapcompact to
9681
+ * re-emit the "could not bring the context under the limit" warning every
9682
+ * threshold tick. The **cap** calculation subtracts a shape-aware reserve
9683
+ * (`2 × geometry(shape).capacity` chars worth of text edges, billed at the
9684
+ * tiktoken cl100k baseline, plus a 2k summary-template allowance) sized
9685
+ * from the same `shape` snapcompact will use, so the projection still
9686
+ * passes once frames land — but it MUST NOT gate the skip decision, since
9687
+ * a frame-less archive (`text.length <= 2 * edgeCap` short-circuit in
9688
+ * `planArchive`) typically costs only a few hundred tokens of summary
9689
+ * lead and would fit under residual headroom far smaller than the cap
9690
+ * reserve (chatgpt-codex reviews on #3249).
9691
+ *
9692
+ * Returns `1` when the frame charge would overflow but the text-only path
9693
+ * still has room: snapcompact's planner picks the frame-less layout
9694
+ * automatically when the discarded text fits in the edges, so giving it
9695
+ * the minimum cap lets it succeed instead of being skipped outright.
9696
+ *
9697
+ * Without this cap, the bundled `MAX_FRAMES_DEFAULT = 80` × 5024 tokens =
9698
+ * ~402k frame-token projection always overflows any sub-1M-token window
9699
+ * (issue #3247).
9700
+ */
9701
+ #computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number {
9702
+ const ctxWindow = this.model?.contextWindow ?? 0;
9703
+ if (ctxWindow <= 0) return snapcompact.MAX_FRAMES_DEFAULT;
9704
+ const reserve = effectiveReserveTokens(ctxWindow, settings);
9705
+ let baseTokens = computeNonMessageTokens(this);
9706
+ for (const message of preparation.recentMessages) {
9707
+ baseTokens += estimateTokens(message);
9708
+ }
9709
+ const totalBudget = ctxWindow - reserve;
9710
+ // Skip iff there is no headroom whatsoever; a text-only archive costs
9711
+ // far less than the cap reserve below, so any positive residual is
9712
+ // worth attempting and the projection guard catches actual overflow.
9713
+ if (baseTokens >= totalBudget) return 0;
9714
+ // Cap reserve mirrors what `estimateTokens(summaryMessage)` will charge
9715
+ // when frames > 0: `countTokens(summaryTemplate ‖ textHead ‖ textTail)`
9716
+ // plus `numFrames × FRAME_TOKEN_ESTIMATE`. Resolve the shape this
9717
+ // snapcompact pass will actually use (matches the `shape` argument
9718
+ // passed to `snapcompact.compact` in the auto and manual paths) so the
9719
+ // text-edge cost reflects the live frame geometry rather than a fixed
9720
+ // approximation. Reviewer (chatgpt-codex on #3249): a 4k reserve
9721
+ // undersized the ~7k text-edge cost on the default Anthropic
9722
+ // 11on16-bw shape, so the projection then rejected the `maxFrames`
9723
+ // the cap had picked and the warning loop reappeared.
9724
+ //
9725
+ // - `textHead` and `textTail` each consume up to `geometry.capacity`
9726
+ // chars when frames > 0 (one HQ-capacity page per edge: see
9727
+ // `TEXT_EDGE_PAGES = 1` in `planArchive`), so 2 × capacity chars
9728
+ // total. Per-shape capacity: Anthropic 11on16-bw ~13.9k, Opus
9729
+ // 1932px ~21k, Gemini 8on22-bw 2048px ~23.8k, OpenAI 1568px ~13.9k.
9730
+ // - tiktoken cl100k ≈ 4 chars/token on ASCII (verified empirically
9731
+ // for prose, code, and JSON); a 1.15 multiplier absorbs tokenizer
9732
+ // drift on denser content (e.g. dense JSON / tool-result blobs).
9733
+ // - Summary template (intro + FILES section + grid notes) bills
9734
+ // ~2k tokens for typical sessions.
9735
+ const shape = snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape"));
9736
+ const edgeCap = snapcompact.geometry(shape).capacity;
9737
+ const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4);
9738
+ const SUMMARY_TEMPLATE_TOKENS = 2000;
9739
+ const capReserve = textEdgeTokens + SUMMARY_TEMPLATE_TOKENS;
9740
+ const frameBudget = totalBudget - baseTokens - capReserve;
9741
+ if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1;
9742
+ return Math.min(Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), snapcompact.MAX_FRAMES_DEFAULT);
9743
+ }
9744
+
9441
9745
  /**
9442
9746
  * Project the post-compaction context size of a snapcompact result: kept
9443
9747
  * recent messages + the summary message with its re-attached frames + the
@@ -9483,13 +9787,15 @@ export class AgentSession {
9483
9787
  willRetry: boolean,
9484
9788
  deferred = false,
9485
9789
  allowDefer = true,
9486
- options: { autoContinue?: boolean; triggerContextTokens?: number } = {},
9790
+ options: { autoContinue?: boolean; triggerContextTokens?: number; suppressContinuation?: boolean } = {},
9487
9791
  ): Promise<CompactionCheckResult> {
9488
9792
  const compactionSettings = this.settings.getGroup("compaction");
9489
9793
  if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
9490
9794
  if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
9491
9795
  const generation = this.#promptGeneration;
9492
- const shouldAutoContinue = options.autoContinue !== false && compactionSettings.autoContinue !== false;
9796
+ const suppressContinuation = options.suppressContinuation === true;
9797
+ const shouldAutoContinue =
9798
+ !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
9493
9799
  // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
9494
9800
  // reclaims nothing we fall through to the summary-compaction body below so
9495
9801
  // the oversized input still gets resolved.
@@ -9500,6 +9806,7 @@ export class AgentSession {
9500
9806
  generation,
9501
9807
  shouldAutoContinue,
9502
9808
  options.triggerContextTokens,
9809
+ suppressContinuation,
9503
9810
  );
9504
9811
  if (outcome !== "fallback") return outcome;
9505
9812
  }
@@ -9683,24 +9990,20 @@ export class AgentSession {
9683
9990
  let tokensBefore: number;
9684
9991
  let details: unknown;
9685
9992
 
9686
- // Snapcompact runs locally first; if its frame archive plus the kept
9687
- // history still overflows the model window (frames default to
9688
- // MAX_FRAMES_DEFAULT and cost ~FRAME_TOKEN_ESTIMATE each), an LLM
9689
- // summary is far cheaper downgrade to context-full and take the
9690
- // summarizer path.
9993
+ // Snapcompact runs locally first. The post-compaction context = kept-recent
9994
+ // + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
9995
+ // per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
9996
+ // live window so we don't run snapcompact just to overflow and fall back
9997
+ // every threshold tick. Kept-recent already over budget → skip snapcompact
9998
+ // outright (a single frame won't fit). Otherwise the projection below is
9999
+ // only a defensive guard for summary-text drift.
9691
10000
  let snapcompactResult: snapcompact.CompactionResult | undefined;
9692
10001
  if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
9693
10002
  const text = snapcompact.serializeConversation(
9694
10003
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
9695
10004
  );
9696
10005
  const renderScan = snapcompact.scanRenderability(text);
9697
- if (renderScan.isSafe) {
9698
- snapcompactResult = await snapcompact.compact(preparation, {
9699
- convertToLlm,
9700
- model: this.model,
9701
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
9702
- });
9703
- } else {
10006
+ if (!renderScan.isSafe) {
9704
10007
  logger.warn("Snapcompact disabled: high non-ASCII rate detected; falling back to an LLM summary", {
9705
10008
  model: this.model?.id,
9706
10009
  unrenderableRatio: renderScan.unrenderableRatio,
@@ -9711,6 +10014,26 @@ export class AgentSession {
9711
10014
  "compaction",
9712
10015
  );
9713
10016
  action = "context-full";
10017
+ } else {
10018
+ const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
10019
+ if (maxFrames < 1) {
10020
+ logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
10021
+ model: this.model?.id,
10022
+ });
10023
+ this.emitNotice(
10024
+ "warning",
10025
+ "snapcompact: kept history alone exceeds the context budget — using an LLM summary instead",
10026
+ "compaction",
10027
+ );
10028
+ action = "context-full";
10029
+ } else {
10030
+ snapcompactResult = await snapcompact.compact(preparation, {
10031
+ convertToLlm,
10032
+ model: this.model,
10033
+ shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
10034
+ maxFrames,
10035
+ });
10036
+ }
9714
10037
  }
9715
10038
 
9716
10039
  if (snapcompactResult) {
@@ -9721,7 +10044,7 @@ export class AgentSession {
9721
10044
  : Number.POSITIVE_INFINITY;
9722
10045
  const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
9723
10046
  if (projected > budget) {
9724
- logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
10047
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing; falling back", {
9725
10048
  model: this.model?.id,
9726
10049
  projected,
9727
10050
  budget,
@@ -9953,7 +10276,7 @@ export class AgentSession {
9953
10276
 
9954
10277
  this.#scheduleAgentContinue({ delayMs: 100, generation });
9955
10278
  continuationScheduled = true;
9956
- } else if (this.agent.hasQueuedMessages()) {
10279
+ } else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
9957
10280
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
9958
10281
  // Kick the loop so queued messages are actually delivered.
9959
10282
  this.#scheduleAgentContinue({
@@ -10013,6 +10336,7 @@ export class AgentSession {
10013
10336
  generation: number,
10014
10337
  autoContinue: boolean,
10015
10338
  triggerContextTokens?: number,
10339
+ suppressContinuation = false,
10016
10340
  ): Promise<CompactionCheckResult | "fallback"> {
10017
10341
  const action = "shake";
10018
10342
  this.#autoCompactionAbortController?.abort();
@@ -10043,15 +10367,16 @@ export class AgentSession {
10043
10367
  // situation actually resolves; "idle" is exempt because its 60s+ timer
10044
10368
  // re-checks usage before re-firing and cannot dead-loop on its own.
10045
10369
  //
10046
- // #2275: the post-shake check MUST be anchored on the same metric that
10047
- // triggered compaction. The local estimator (`#estimatePendingPromptTokens`)
10048
- // undercounts thinking-signature payloads, so on thinking-heavy sessions it
10049
- // reads well below the provider-reported usage that fired the threshold.
10050
- // When that estimate slips under the threshold, the fallback never fires
10051
- // and the auto-continue prompt re-injects every turn. Prefer the trigger's
10052
- // own `contextTokens` (provider-anchored) when the caller supplies it, and
10053
- // add hysteresis (80% recovery band) so we don't oscillate at the boundary
10054
- // while shake keeps reclaiming a trickle of the previous turn's output.
10370
+ // #2275: the post-shake check MUST stay provider-anchored when caller
10371
+ // usage and local estimates diverge. The local estimator undercounts
10372
+ // thinking-signature payloads, so thinking-heavy sessions can read well
10373
+ // below the provider usage that fired the threshold. Prefer the caller's
10374
+ // context figure when supplied, then subtract shake's own savings and add
10375
+ // hysteresis (80% recovery band) so we don't oscillate at the boundary.
10376
+ // Threshold callers pass the provider-billed trigger after accounting for
10377
+ // any supersede/drop-useless pruning that already rewrote the next prompt;
10378
+ // without that pre-shake savings, shake can fall through to context-full
10379
+ // even though the post-prune history is already inside the recovery band.
10055
10380
  const contextWindow = this.model?.contextWindow ?? 0;
10056
10381
  const compactionSettings = this.settings.getGroup("compaction");
10057
10382
  let stillOverThreshold = false;
@@ -10111,7 +10436,7 @@ export class AgentSession {
10111
10436
  }
10112
10437
  this.#scheduleAgentContinue({ delayMs: 100, generation });
10113
10438
  continuationScheduled = true;
10114
- } else if (this.agent.hasQueuedMessages()) {
10439
+ } else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
10115
10440
  this.#scheduleAgentContinue({
10116
10441
  delayMs: 100,
10117
10442
  generation,