@oh-my-pi/pi-coding-agent 16.1.23 → 16.2.0

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 (254) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/cli.js +3817 -3735
  3. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +1 -0
  4. package/dist/types/advisor/emission-guard.d.ts +73 -0
  5. package/dist/types/advisor/index.d.ts +1 -0
  6. package/dist/types/advisor/runtime.d.ts +7 -0
  7. package/dist/types/advisor/watchdog.d.ts +2 -0
  8. package/dist/types/cli/flag-tables.d.ts +1 -0
  9. package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
  10. package/dist/types/collab/display-name.d.ts +3 -0
  11. package/dist/types/collab/host.d.ts +0 -2
  12. package/dist/types/commands/worktree.d.ts +0 -3
  13. package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
  14. package/dist/types/config/model-discovery.d.ts +6 -1
  15. package/dist/types/config/model-registry.d.ts +6 -2
  16. package/dist/types/config/model-resolver.d.ts +12 -0
  17. package/dist/types/config/models-config-schema.d.ts +29 -2
  18. package/dist/types/config/models-config.d.ts +22 -1
  19. package/dist/types/config/settings-schema.d.ts +91 -21
  20. package/dist/types/config/settings.d.ts +2 -0
  21. package/dist/types/dap/config.d.ts +1 -1
  22. package/dist/types/edit/hashline/filesystem.d.ts +4 -2
  23. package/dist/types/edit/renderer.d.ts +4 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -17
  25. package/dist/types/extensibility/hooks/types.d.ts +10 -10
  26. package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
  27. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
  28. package/dist/types/internal-urls/index.d.ts +1 -0
  29. package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
  30. package/dist/types/internal-urls/router.d.ts +1 -1
  31. package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
  32. package/dist/types/internal-urls/types.d.ts +19 -2
  33. package/dist/types/irc/bus.d.ts +6 -0
  34. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
  35. package/dist/types/modes/components/custom-editor.d.ts +7 -0
  36. package/dist/types/modes/components/move-overlay.d.ts +23 -0
  37. package/dist/types/modes/components/plugin-selector.d.ts +2 -1
  38. package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
  39. package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
  40. package/dist/types/modes/components/settings-defs.d.ts +4 -1
  41. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  42. package/dist/types/modes/components/show-images-selector.d.ts +2 -1
  43. package/dist/types/modes/components/status-line/component.d.ts +2 -0
  44. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  45. package/dist/types/modes/components/theme-selector.d.ts +2 -1
  46. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  47. package/dist/types/modes/controllers/command-controller.d.ts +10 -1
  48. package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
  49. package/dist/types/modes/interactive-mode.d.ts +10 -1
  50. package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
  51. package/dist/types/modes/running-subagent-badge.d.ts +6 -0
  52. package/dist/types/modes/theme/theme.d.ts +1 -1
  53. package/dist/types/modes/types.d.ts +9 -1
  54. package/dist/types/sdk.d.ts +2 -2
  55. package/dist/types/session/agent-session.d.ts +2 -1
  56. package/dist/types/session/session-manager.d.ts +13 -0
  57. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  58. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
  59. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
  60. package/dist/types/ssh/connection-manager.d.ts +2 -0
  61. package/dist/types/ssh/file-transfer.d.ts +79 -0
  62. package/dist/types/ssh/utils.d.ts +6 -0
  63. package/dist/types/system-prompt.d.ts +3 -0
  64. package/dist/types/task/executor.d.ts +16 -0
  65. package/dist/types/tiny/text.d.ts +1 -1
  66. package/dist/types/tools/builtin-names.d.ts +5 -1
  67. package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
  68. package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
  69. package/dist/types/tools/index.d.ts +3 -3
  70. package/dist/types/tools/path-utils.d.ts +29 -0
  71. package/dist/types/tools/read.d.ts +2 -2
  72. package/dist/types/tools/render-utils.d.ts +8 -0
  73. package/dist/types/tools/renderers.d.ts +11 -0
  74. package/dist/types/tools/ssh.d.ts +2 -0
  75. package/dist/types/tools/todo.d.ts +0 -16
  76. package/dist/types/tools/write.d.ts +2 -2
  77. package/dist/types/utils/active-repo-context.d.ts +8 -0
  78. package/dist/types/utils/image-resize.d.ts +1 -0
  79. package/dist/types/utils/markit-cache.d.ts +23 -0
  80. package/dist/types/utils/markit.d.ts +5 -1
  81. package/dist/types/utils/prompt-path.d.ts +1 -0
  82. package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
  83. package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
  84. package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
  85. package/dist/types/web/search/providers/xai.d.ts +13 -0
  86. package/dist/types/web/search/types.d.ts +18 -2
  87. package/package.json +30 -15
  88. package/scripts/build-binary.ts +9 -9
  89. package/scripts/bundle-dist.ts +2 -2
  90. package/src/advisor/__tests__/advisor.test.ts +40 -4
  91. package/src/advisor/__tests__/emission-guard.test.ts +147 -0
  92. package/src/advisor/advise-tool.ts +1 -1
  93. package/src/advisor/emission-guard.ts +172 -0
  94. package/src/advisor/index.ts +1 -0
  95. package/src/advisor/runtime.ts +11 -0
  96. package/src/advisor/watchdog.ts +12 -1
  97. package/src/cli/args.ts +5 -2
  98. package/src/cli/auth-broker-cli.ts +17 -0
  99. package/src/cli/config-cli.ts +4 -0
  100. package/src/cli/flag-tables.ts +7 -4
  101. package/src/cli/gallery-cli.ts +14 -2
  102. package/src/cli/gallery-fixtures/edit.ts +60 -0
  103. package/src/cli/gallery-fixtures/fs.ts +17 -17
  104. package/src/cli/gallery-fixtures/search.ts +4 -4
  105. package/src/cli/usage-cli.ts +20 -1
  106. package/src/cli/web-search-cli.ts +1 -1
  107. package/src/collab/display-name.ts +13 -0
  108. package/src/collab/guest.ts +5 -1
  109. package/src/collab/host.ts +2 -13
  110. package/src/commands/worktree.ts +6 -0
  111. package/src/config/inline-tool-descriptors-mode.ts +27 -0
  112. package/src/config/model-discovery.ts +77 -8
  113. package/src/config/model-registry.ts +89 -11
  114. package/src/config/model-resolver.ts +39 -1
  115. package/src/config/models-config-schema.ts +41 -5
  116. package/src/config/models-config.ts +4 -1
  117. package/src/config/settings-schema.ts +85 -27
  118. package/src/config/settings.ts +172 -1
  119. package/src/cursor.ts +1 -1
  120. package/src/dap/config.ts +152 -8
  121. package/src/dap/session.ts +1 -1
  122. package/src/discovery/helpers.ts +3 -1
  123. package/src/edit/hashline/diff.ts +14 -3
  124. package/src/edit/hashline/execute.ts +42 -6
  125. package/src/edit/hashline/filesystem.ts +38 -2
  126. package/src/edit/index.ts +1 -0
  127. package/src/edit/modes/patch.ts +11 -1
  128. package/src/edit/renderer.ts +140 -13
  129. package/src/eval/__tests__/agent-bridge.test.ts +101 -0
  130. package/src/eval/agent-bridge.ts +26 -3
  131. package/src/eval/js/tool-bridge.ts +2 -2
  132. package/src/export/html/index.ts +1 -1
  133. package/src/export/html/template.js +3 -1
  134. package/src/export/html/tool-views.generated.js +20 -20
  135. package/src/extensibility/extensions/types.ts +22 -22
  136. package/src/extensibility/hooks/types.ts +11 -11
  137. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +8 -3
  138. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +25 -10
  139. package/src/extensibility/plugins/marketplace/manager.ts +22 -0
  140. package/src/extensibility/plugins/marketplace/types.ts +1 -0
  141. package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
  142. package/src/internal-urls/docs-index.generated.txt +2 -2
  143. package/src/internal-urls/docs-index.ts +2 -3
  144. package/src/internal-urls/index.ts +1 -0
  145. package/src/internal-urls/registry-helpers.ts +19 -4
  146. package/src/internal-urls/router.ts +5 -3
  147. package/src/internal-urls/ssh-protocol.ts +367 -0
  148. package/src/internal-urls/types.ts +19 -2
  149. package/src/irc/bus.ts +11 -3
  150. package/src/mcp/tool-bridge.ts +32 -2
  151. package/src/memories/index.ts +1 -1
  152. package/src/modes/acp/acp-event-mapper.ts +2 -2
  153. package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
  154. package/src/modes/components/agent-hub.ts +3 -0
  155. package/src/modes/components/agent-transcript-viewer.ts +9 -7
  156. package/src/modes/components/custom-editor.ts +90 -5
  157. package/src/modes/components/move-overlay.ts +282 -0
  158. package/src/modes/components/plan-review-overlay.ts +35 -35
  159. package/src/modes/components/plugin-selector.ts +6 -1
  160. package/src/modes/components/queue-mode-selector.ts +6 -1
  161. package/src/modes/components/select-list-mouse-routing.ts +35 -0
  162. package/src/modes/components/session-selector.ts +11 -10
  163. package/src/modes/components/settings-defs.ts +14 -1
  164. package/src/modes/components/settings-selector.ts +196 -29
  165. package/src/modes/components/show-images-selector.ts +6 -1
  166. package/src/modes/components/status-line/component.ts +108 -28
  167. package/src/modes/components/status-line/segments.ts +5 -1
  168. package/src/modes/components/status-line/types.ts +2 -0
  169. package/src/modes/components/theme-selector.ts +6 -1
  170. package/src/modes/components/thinking-selector.ts +6 -1
  171. package/src/modes/components/tool-execution.ts +25 -9
  172. package/src/modes/components/tree-selector.ts +5 -5
  173. package/src/modes/controllers/command-controller.ts +140 -47
  174. package/src/modes/controllers/event-controller.ts +59 -3
  175. package/src/modes/controllers/input-controller.ts +68 -2
  176. package/src/modes/controllers/selector-controller.ts +5 -2
  177. package/src/modes/controllers/streaming-reveal.ts +17 -0
  178. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  179. package/src/modes/interactive-mode.ts +116 -50
  180. package/src/modes/internal-url-autocomplete.ts +17 -2
  181. package/src/modes/prompt-action-autocomplete.ts +3 -1
  182. package/src/modes/running-subagent-badge.ts +13 -0
  183. package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
  184. package/src/modes/setup-wizard/scenes/providers.ts +2 -1
  185. package/src/modes/setup-wizard/scenes/theme.ts +6 -12
  186. package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
  187. package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
  188. package/src/modes/theme/theme.ts +9 -1
  189. package/src/modes/types.ts +9 -1
  190. package/src/modes/utils/interactive-context-helpers.ts +1 -1
  191. package/src/priority.json +15 -0
  192. package/src/prompts/advisor/active-repo-watchdog.md +6 -0
  193. package/src/prompts/advisor/system.md +21 -7
  194. package/src/prompts/agents/designer.md +1 -1
  195. package/src/prompts/agents/explore.md +1 -1
  196. package/src/prompts/agents/librarian.md +2 -2
  197. package/src/prompts/agents/plan.md +2 -2
  198. package/src/prompts/agents/reviewer.md +1 -1
  199. package/src/prompts/agents/task.md +2 -2
  200. package/src/prompts/system/active-repo-context.md +4 -0
  201. package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
  202. package/src/prompts/system/plan-mode-active.md +3 -3
  203. package/src/prompts/system/project-prompt.md +2 -2
  204. package/src/prompts/system/system-prompt.md +6 -6
  205. package/src/prompts/tools/bash.md +2 -2
  206. package/src/prompts/tools/checkpoint.md +1 -1
  207. package/src/prompts/tools/{find.md → glob.md} +1 -1
  208. package/src/prompts/tools/{search.md → grep.md} +3 -3
  209. package/src/prompts/tools/read.md +4 -2
  210. package/src/sdk.ts +55 -25
  211. package/src/session/agent-session.ts +508 -132
  212. package/src/session/session-history-format.ts +2 -2
  213. package/src/session/session-manager.ts +46 -0
  214. package/src/slash-commands/builtin-registry.ts +138 -20
  215. package/src/slash-commands/helpers/usage-report.ts +23 -2
  216. package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
  217. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
  218. package/src/ssh/connection-manager.ts +12 -15
  219. package/src/ssh/file-transfer.ts +209 -0
  220. package/src/ssh/utils.ts +24 -0
  221. package/src/system-prompt.ts +56 -28
  222. package/src/task/executor.ts +96 -36
  223. package/src/task/index.ts +3 -3
  224. package/src/task/render.ts +14 -13
  225. package/src/task/worktree.ts +38 -1
  226. package/src/tiny/text.ts +49 -4
  227. package/src/tiny/worker.ts +3 -3
  228. package/src/tools/bash.ts +2 -2
  229. package/src/tools/builtin-names.ts +26 -2
  230. package/src/tools/{find.ts → glob.ts} +48 -42
  231. package/src/tools/{search.ts → grep.ts} +311 -129
  232. package/src/tools/index.ts +13 -14
  233. package/src/tools/irc.ts +6 -2
  234. package/src/tools/path-utils.ts +64 -1
  235. package/src/tools/read.ts +35 -6
  236. package/src/tools/render-utils.ts +14 -0
  237. package/src/tools/renderers.ts +15 -4
  238. package/src/tools/ssh.ts +17 -2
  239. package/src/tools/todo.ts +4 -26
  240. package/src/tools/write.ts +25 -9
  241. package/src/utils/active-repo-context.ts +143 -0
  242. package/src/utils/image-resize.ts +88 -7
  243. package/src/utils/lang-from-path.ts +3 -3
  244. package/src/utils/markit-cache.ts +166 -0
  245. package/src/utils/markit.ts +86 -18
  246. package/src/utils/prompt-path.ts +3 -0
  247. package/src/utils/title-generator.ts +1 -1
  248. package/src/web/search/provider.ts +54 -34
  249. package/src/web/search/providers/duckduckgo.ts +140 -0
  250. package/src/web/search/providers/firecrawl.ts +144 -0
  251. package/src/web/search/providers/tinyfish.ts +159 -0
  252. package/src/web/search/providers/xai.ts +292 -0
  253. package/src/web/search/providers/zai.ts +142 -56
  254. package/src/web/search/types.ts +6 -2
@@ -20,6 +20,7 @@ import { scheduler } from "node:timers/promises";
20
20
  import { isPromise } from "node:util/types";
21
21
 
22
22
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
23
+ import { Patch } from "@oh-my-pi/hashline";
23
24
  import {
24
25
  type AfterToolCallContext,
25
26
  type AfterToolCallResult,
@@ -78,6 +79,7 @@ import {
78
79
  import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
79
80
  import type {
80
81
  AssistantMessage,
82
+ AssistantMessageEvent,
81
83
  ImageContent,
82
84
  Message,
83
85
  MessageAttribution,
@@ -107,7 +109,11 @@ import {
107
109
  streamSimple,
108
110
  } from "@oh-my-pi/pi-ai";
109
111
  import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
110
- import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
112
+ import {
113
+ GeminiHeaderRunDetector,
114
+ isGeminiThinkingModel,
115
+ THINKING_LOOP_ERROR_MARKER,
116
+ } from "@oh-my-pi/pi-ai/utils/thinking-loop";
111
117
  import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
112
118
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
113
119
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
@@ -130,6 +136,7 @@ import * as snapcompact from "@oh-my-pi/snapcompact";
130
136
  import {
131
137
  AdviseTool,
132
138
  type AdvisorAgent,
139
+ AdvisorEmissionGuard,
133
140
  type AdvisorMessageDetails,
134
141
  type AdvisorNote,
135
142
  AdvisorRuntime,
@@ -155,15 +162,15 @@ import {
155
162
  getModelMatchPreferences,
156
163
  parseModelString,
157
164
  type ResolvedModelRoleValue,
165
+ resolveAdvisorRoleSelection,
158
166
  resolveModelOverride,
159
167
  resolveModelRoleValue,
160
- resolveRoleSelection,
161
168
  } from "../config/model-resolver";
162
169
  import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
163
170
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
164
171
  import { resolveServiceTierSetting } from "../config/service-tier";
165
172
  import type { Settings, SkillsSettings } from "../config/settings";
166
- import { getDefault, onAppendOnlyModeChanged } from "../config/settings";
173
+ import { getDefault, onAppendOnlyModeChanged, validateProviderMaxInFlightRequests } from "../config/settings";
167
174
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
168
175
  import { loadCapability } from "../discovery";
169
176
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
@@ -228,6 +235,7 @@ import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type:
228
235
  import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" };
229
236
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
230
237
  import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
238
+ import geminiToolReminderTemplate from "../prompts/system/gemini-tool-call-reminder.md" with { type: "text" };
231
239
  import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
232
240
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
233
241
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
@@ -269,6 +277,7 @@ import {
269
277
  selectDiscoverableToolNamesByServer,
270
278
  } from "../tool-discovery/tool-index";
271
279
  import { assertEditableFile } from "../tools/auto-generated-guard";
280
+ import { normalizeToolNames } from "../tools/builtin-names";
272
281
  import type { CheckpointState } from "../tools/checkpoint";
273
282
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
274
283
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
@@ -312,7 +321,7 @@ import { formatSessionDumpText } from "./session-dump-format";
312
321
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
313
322
  import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
314
323
  import { formatSessionHistoryMarkdown } from "./session-history-format";
315
- import type { SessionManager } from "./session-manager";
324
+ import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager";
316
325
  import type { ShakeMode, ShakeResult } from "./shake-types";
317
326
  import { ToolChoiceQueue } from "./tool-choice-queue";
318
327
  import { classifyUnexpectedStop, isUnexpectedStopCandidate } from "./unexpected-stop-classifier";
@@ -320,6 +329,12 @@ import { YieldQueue } from "./yield-queue";
320
329
 
321
330
  const SESSION_STOP_CONTINUATION_CAP = 8;
322
331
 
332
+ /** Abort reason for the Gemini reasoning-header runaway interrupt. Surfaced on the
333
+ * discarded assistant turn only; never reaches the model. */
334
+ const GEMINI_HEADER_INTERRUPT_REASON = "Interrupted: emit a tool call instead of more planning";
335
+ /** `customType` for the hidden tool-call reminder injected after the interrupt. */
336
+ const GEMINI_TOOL_REMINDER_TYPE = "gemini-tool-call-reminder";
337
+
323
338
  // A side-channel assistant response is signed for the hidden prompt/history that
324
339
  // produced it. If we persist that response under a different user turn, native
325
340
  // replay anchors become invalid; keep only visible, non-cryptographic content.
@@ -383,6 +398,7 @@ const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
383
398
  type CompactionCheckResult = Readonly<{
384
399
  deferredHandoff: boolean;
385
400
  continuationScheduled: boolean;
401
+ automaticContinuationBlocked?: boolean;
386
402
  }>;
387
403
 
388
404
  const COMPACTION_CHECK_NONE: CompactionCheckResult = {
@@ -397,6 +413,11 @@ const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = {
397
413
  deferredHandoff: false,
398
414
  continuationScheduled: true,
399
415
  };
416
+ const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = {
417
+ deferredHandoff: false,
418
+ continuationScheduled: false,
419
+ automaticContinuationBlocked: true,
420
+ };
400
421
 
401
422
  /**
402
423
  * Per-turn prune cache window. A tool result whose all-message suffix exceeds
@@ -419,14 +440,17 @@ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "la
419
440
 
420
441
  const RETRY_BACKOFF_JITTER_RATIO = 0.25;
421
442
  /**
422
- * Hysteresis band for the post-shake "did we actually create headroom?" check.
423
- * Shake counts as having resolved threshold pressure only when residual context
424
- * lands at or below `SHAKE_RECOVERY_BAND × threshold`. Re-checking against the
425
- * raw threshold lets shake keep reclaiming a trickle of the previous turn's
426
- * output and land just under the line every turn, sustaining the auto-continue
427
- * dead loop reported in #2275.
443
+ * Hysteresis band for the post-maintenance "did we actually create headroom?"
444
+ * check shared by the shake tail and the context-full / snapcompact tail. A
445
+ * pass counts as having resolved threshold pressure only when residual context
446
+ * lands at or below `COMPACTION_RECOVERY_BAND × threshold`. Re-checking against
447
+ * the raw threshold lets a pass keep reclaiming a trickle of the previous
448
+ * turn's output and land just under the line every turn, sustaining the
449
+ * auto-continue dead loop reported in #2275; the same band stops the
450
+ * context-full / snapcompact tail from re-firing on a history whose single
451
+ * most-recent kept turn already exceeds the threshold (the snapcompact thrash).
428
452
  */
429
- const SHAKE_RECOVERY_BAND = 0.8;
453
+ const COMPACTION_RECOVERY_BAND = 0.8;
430
454
 
431
455
  function calculateRetryBackoffDelayMs(baseDelayMs: number, attempt: number): number {
432
456
  const cappedDelayMs = Math.min(Math.max(0, baseDelayMs) * 2 ** Math.max(0, attempt - 1), RETRY_BACKOFF_MAX_DELAY_MS);
@@ -929,6 +953,15 @@ function getEditDestructiveIntent(args: unknown): { kind: "delete" | "move"; pat
929
953
 
930
954
  const input = getStringProperty(a, "input");
931
955
  if (input) {
956
+ try {
957
+ const patch = Patch.parse(input);
958
+ for (const section of patch.sections) {
959
+ if (section.fileOp?.kind === "rem") return { kind: "delete", paths: [section.path] };
960
+ if (section.fileOp?.kind === "move") return { kind: "move", paths: [section.path, section.fileOp.dest] };
961
+ }
962
+ } catch {
963
+ // Not a hashline patch — fall through to apply_patch parsing.
964
+ }
932
965
  try {
933
966
  const entries = expandApplyPatchToEntries({ input });
934
967
  const deleteEntry = entries.find(entry => entry.op === "delete");
@@ -1172,6 +1205,11 @@ export class AgentSession {
1172
1205
  #advisorAutoResumeSuppressed = false;
1173
1206
  #advisorPrimaryTurnsCompleted = 0;
1174
1207
  #advisorInterruptImmuneTurnStart: number | undefined;
1208
+ /** Dedupe + per-update rate-limit + content-free-phrase filter applied to
1209
+ * every accepted advisor `advise()` call. Owned by the session because the
1210
+ * session is what routes accepted notes back to the primary transcript.
1211
+ * Reset on advisor reset (compaction, session switch, `/new`). */
1212
+ readonly #advisorEmissionGuard = new AdvisorEmissionGuard();
1175
1213
  #planModeState: PlanModeState | undefined;
1176
1214
  #goalModeState: GoalModeState | undefined;
1177
1215
  #goalRuntime: GoalRuntime;
@@ -1197,6 +1235,8 @@ export class AgentSession {
1197
1235
  #allowAcpAgentInitiatedTurns = false;
1198
1236
  /** Per-session memory of allow_always / reject_always decisions for gated tools. */
1199
1237
  #acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
1238
+ /** Session file created by this session's `/move`; removed on dispose if it stayed empty. */
1239
+ #movedFromEmptySessionFile?: string;
1200
1240
 
1201
1241
  // Compaction state
1202
1242
  #compactionAbortController: AbortController | undefined = undefined;
@@ -1352,6 +1392,12 @@ export class AgentSession {
1352
1392
  #streamingEditPrecheckedToolCallIds = new Set<string>();
1353
1393
 
1354
1394
  #streamingEditFileCache = new Map<string, string>();
1395
+
1396
+ /** Active Gemini reasoning-header runaway detector for the current block.
1397
+ * (Re)created on each `thinking_start` when the guard applies (see
1398
+ * `#geminiHeaderGuardActive`); undefined for non-Gemini models or when the
1399
+ * guard is off. Fed thinking deltas in the assistant-message interceptor. */
1400
+ #geminiHeaderDetector: GeminiHeaderRunDetector | undefined;
1355
1401
  #promptInFlightCount = 0;
1356
1402
  #abortInProgress = false;
1357
1403
  // Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
@@ -1716,6 +1762,7 @@ export class AgentSession {
1716
1762
  };
1717
1763
  this.#preCacheStreamingEditFile(event);
1718
1764
  this.#maybeAbortStreamingEdit(event);
1765
+ this.#maybeInterruptGeminiHeaderRunaway(message, assistantMessageEvent);
1719
1766
  });
1720
1767
  // Per-tool TTSR reminders are folded into the matched tool's result via this hook.
1721
1768
  this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx);
@@ -1817,6 +1864,7 @@ export class AgentSession {
1817
1864
  this.#advisorAgentUnsubscribe = undefined;
1818
1865
  this.#advisorRuntime?.reset();
1819
1866
  this.#advisorAdviseTool?.resetDeliveredNotes();
1867
+ this.#advisorEmissionGuard.reset();
1820
1868
  this.#attachAdvisorRecorderFeed();
1821
1869
  this.#advisorPrimaryTurnsCompleted = 0;
1822
1870
  this.#advisorInterruptImmuneTurnStart = undefined;
@@ -1834,8 +1882,7 @@ export class AgentSession {
1834
1882
  if (!this.#advisorEnabled) return false;
1835
1883
  if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
1836
1884
 
1837
- const advisorSel = resolveRoleSelection(
1838
- ["advisor"],
1885
+ const advisorSel = resolveAdvisorRoleSelection(
1839
1886
  this.settings,
1840
1887
  this.#modelRegistry.getAvailable(),
1841
1888
  this.#modelRegistry,
@@ -1856,7 +1903,17 @@ export class AgentSession {
1856
1903
  // since steering an active run auto-resumes nothing; parking it there would
1857
1904
  // strand the advice and dump the backlog as one burst at the next prompt. A
1858
1905
  // plain nit always rides the non-interrupting YieldQueue aside.
1906
+ // Apply the per-session emission policy (one-advise-per-update gate,
1907
+ // exact-text dedupe, content-free phrase filter) before any routing.
1908
+ // Suppression here means the advisor model called `advise()` but the call
1909
+ // is dropped silently — the model still sees `Recorded.` from the tool, so
1910
+ // telling it "suppressed" doesn't tempt it into rephrasing the same useless
1911
+ // note to bypass the dedupe.
1859
1912
  const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
1913
+ if (!this.#advisorEmissionGuard.accept(note)) {
1914
+ logger.debug("advisor advice suppressed by emission guard", { severity });
1915
+ return;
1916
+ }
1860
1917
  const interrupting = isInterruptingSeverity(severity);
1861
1918
  const channel = resolveAdvisorDeliveryChannel({
1862
1919
  severity,
@@ -1980,6 +2037,7 @@ export class AgentSession {
1980
2037
  enqueueAdvice,
1981
2038
  maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
1982
2039
  obfuscator: this.#obfuscator,
2040
+ beginAdvisorUpdate: () => this.#advisorEmissionGuard.beginUpdate(),
1983
2041
  });
1984
2042
  if (seedToCurrent) {
1985
2043
  this.#advisorRuntime.seedTo(this.agent.state.messages.length);
@@ -2049,8 +2107,7 @@ export class AgentSession {
2049
2107
  const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
2050
2108
  if (!targetModel) return false;
2051
2109
 
2052
- const advisorSel = resolveRoleSelection(
2053
- ["advisor"],
2110
+ const advisorSel = resolveAdvisorRoleSelection(
2054
2111
  this.settings,
2055
2112
  this.#modelRegistry.getAvailable(),
2056
2113
  this.#modelRegistry,
@@ -2665,6 +2722,7 @@ export class AgentSession {
2665
2722
  if (this.#sessionMessageAlreadyPersisted(message)) return;
2666
2723
  if (message.role === "assistant") {
2667
2724
  const assistantMsg = message as AssistantMessage;
2725
+ if (this.#isClassifierRefusal(assistantMsg)) return;
2668
2726
  if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
2669
2727
  assistantMsg.contextSnapshot = {
2670
2728
  promptTokens: calculatePromptTokens(assistantMsg.usage),
@@ -3095,10 +3153,15 @@ export class AgentSession {
3095
3153
  this.#trackPostPromptTask(compactionTask);
3096
3154
  compactionResult = await compactionTask;
3097
3155
  checkedCompaction = true;
3098
- if (compactionResult.deferredHandoff || compactionResult.continuationScheduled) {
3099
- maintenanceRoute("active-goal-pre-empt-continuation-scheduled", {
3156
+ if (
3157
+ compactionResult.deferredHandoff ||
3158
+ compactionResult.continuationScheduled ||
3159
+ compactionResult.automaticContinuationBlocked
3160
+ ) {
3161
+ maintenanceRoute("active-goal-pre-empt-compaction-handled", {
3100
3162
  deferredHandoff: compactionResult.deferredHandoff,
3101
3163
  continuationScheduled: compactionResult.continuationScheduled,
3164
+ automaticContinuationBlocked: compactionResult.automaticContinuationBlocked === true,
3102
3165
  });
3103
3166
  this.#resolveRetry();
3104
3167
  await emitAgentEndNotification();
@@ -3137,7 +3200,6 @@ export class AgentSession {
3137
3200
  return;
3138
3201
  }
3139
3202
  }
3140
- // Check for retryable errors first (overloaded, rate limit, server errors)
3141
3203
  if (this.#isRetryableError(msg)) {
3142
3204
  const didRetry = await this.#handleRetryableError(msg);
3143
3205
  if (didRetry) {
@@ -3145,6 +3207,14 @@ export class AgentSession {
3145
3207
  return;
3146
3208
  }
3147
3209
  }
3210
+ // Classifier refusals are persisted-skipped above; also prune the trailing
3211
+ // stub from active context so the next turn's prompt does not replay it.
3212
+ // Fall through to the standard error tail so `session_stop` hooks (block,
3213
+ // continue, telemetry) still fire — matching the pre-fix flow for
3214
+ // `stopReason === "error"`.
3215
+ if (this.#isClassifierRefusal(msg)) {
3216
+ this.#removeAssistantMessageFromActiveContext(msg);
3217
+ }
3148
3218
  this.#resolveRetry();
3149
3219
 
3150
3220
  if (!checkedCompaction) {
@@ -3159,11 +3229,15 @@ export class AgentSession {
3159
3229
  await emitAgentEndNotification();
3160
3230
  return;
3161
3231
  }
3162
- // When compaction queued recovery, skip the rewind/todo/session_stop passes:
3163
- // any reminder or hook continuation we append here would race the handoff,
3164
- // retry, auto-continue prompt, or queued-message drain that already owns the
3165
- // next turn.
3166
- if (compactionResult.deferredHandoff || compactionResult.continuationScheduled) {
3232
+ // When compaction queued recovery or hit a deliberate dead-end, skip the
3233
+ // rewind/todo/session_stop passes: any reminder or hook continuation we append
3234
+ // here would race the handoff, retry, auto-continue prompt, queued-message
3235
+ // drain, or the explicit pause that is preventing a compaction loop.
3236
+ if (
3237
+ compactionResult.deferredHandoff ||
3238
+ compactionResult.continuationScheduled ||
3239
+ compactionResult.automaticContinuationBlocked
3240
+ ) {
3167
3241
  await emitAgentEndNotification();
3168
3242
  return;
3169
3243
  }
@@ -3178,7 +3252,7 @@ export class AgentSession {
3178
3252
  return;
3179
3253
  }
3180
3254
  }
3181
- await this.#emitSessionStopEvent(settledMessages);
3255
+ await this.#emitSessionStopEvent(settledMessages, msg);
3182
3256
  await emitAgentEndNotification();
3183
3257
  }
3184
3258
  };
@@ -3849,6 +3923,100 @@ export class AgentSession {
3849
3923
  this.#streamingEditFileCache.clear();
3850
3924
  }
3851
3925
 
3926
+ /**
3927
+ * Whether the Gemini header-runaway guard applies to the current model: the loop
3928
+ * guard is on (settings + `PI_NO_THINKING_LOOP_GUARD`), the tool-call reminder is
3929
+ * enabled, and the active model is a Gemini thinking model.
3930
+ */
3931
+ #geminiHeaderGuardActive(): boolean {
3932
+ const model = this.model;
3933
+ return (
3934
+ process.env.PI_NO_THINKING_LOOP_GUARD !== "1" &&
3935
+ this.settings.get("model.loopGuard.enabled") === true &&
3936
+ this.settings.get("model.loopGuard.toolCallReminder") === true &&
3937
+ model !== undefined &&
3938
+ isGeminiThinkingModel(model)
3939
+ );
3940
+ }
3941
+
3942
+ /**
3943
+ * Feed streamed assistant events to the Gemini header-runaway detector. Each
3944
+ * reasoning block (`thinking_start`) re-arms a fresh detector when the guard
3945
+ * applies; thinking deltas accumulate thought-summary headers; assistant prose
3946
+ * or a tool call ends the run. On the threshold hit, interrupts the stream (see
3947
+ * {@link #interruptGeminiHeaderRunaway}). Runs synchronously inside the
3948
+ * assistant-message interceptor so the abort lands before more budget burns.
3949
+ * Armed on `thinking_start` (not `turn_start`, which the agent loop skips for the
3950
+ * first turn) so the very first reasoning block is guarded too.
3951
+ */
3952
+ #maybeInterruptGeminiHeaderRunaway(message: AssistantMessage, event: AssistantMessageEvent): void {
3953
+ if (event.type === "thinking_start") {
3954
+ this.#geminiHeaderDetector = this.#geminiHeaderGuardActive() ? new GeminiHeaderRunDetector() : undefined;
3955
+ return;
3956
+ }
3957
+ const detector = this.#geminiHeaderDetector;
3958
+ if (!detector) return;
3959
+ if (event.type === "thinking_delta") {
3960
+ if (detector.push(event.delta)) this.#interruptGeminiHeaderRunaway(detector.count, message.timestamp);
3961
+ return;
3962
+ }
3963
+ // Leaving the reasoning channel ends the run: the consecutive-header count
3964
+ // only matters within one uninterrupted stretch of reasoning.
3965
+ if (event.type === "text_start" || event.type === "toolcall_start") {
3966
+ detector.reset();
3967
+ }
3968
+ }
3969
+
3970
+ /**
3971
+ * Interrupt a Gemini reasoning stream that has emitted too many consecutive
3972
+ * planning headers without calling a tool. Aborts the live turn, discards the
3973
+ * stalled reasoning-only turn (so its partial, loop-fueling thinking is neither
3974
+ * replayed nor reloaded), injects a hidden tool-call reminder, and continues.
3975
+ * `targetTimestamp` identifies the turn being aborted so the post-prompt task
3976
+ * can drop exactly it.
3977
+ */
3978
+ #interruptGeminiHeaderRunaway(headerCount: number, targetTimestamp: number): void {
3979
+ logger.warn("Gemini reasoning-header runaway; interrupting to require a tool call", {
3980
+ model: this.model?.id,
3981
+ provider: this.model?.provider,
3982
+ headers: headerCount,
3983
+ });
3984
+ this.emitNotice(
3985
+ "warning",
3986
+ `Interrupted ${headerCount} planning headers with no tool call; reminded the model to issue one.`,
3987
+ "loop-guard",
3988
+ );
3989
+ this.agent.abort(GEMINI_HEADER_INTERRUPT_REASON);
3990
+ const generation = this.#promptGeneration;
3991
+ this.#schedulePostPromptTask(async signal => {
3992
+ if (signal.aborted || this.#isDisposed || this.#promptGeneration !== generation) return;
3993
+ // Let the aborted stream finish unwinding so continue() doesn't race it.
3994
+ await this.agent.waitForIdle();
3995
+ if (signal.aborted || this.#isDisposed || this.#promptGeneration !== generation) return;
3996
+ const aborted = this.agent.state.messages.findLast(
3997
+ (m): m is AssistantMessage => m.role === "assistant" && m.timestamp === targetTimestamp,
3998
+ );
3999
+ if (aborted) this.#discardAssistantTurn(aborted);
4000
+ const content = prompt.render(geminiToolReminderTemplate, { count: headerCount });
4001
+ const details = { headers: headerCount };
4002
+ this.agent.appendMessage({
4003
+ role: "custom",
4004
+ customType: GEMINI_TOOL_REMINDER_TYPE,
4005
+ content,
4006
+ display: false,
4007
+ details,
4008
+ attribution: "agent",
4009
+ timestamp: Date.now(),
4010
+ });
4011
+ this.sessionManager.appendCustomMessageEntry(GEMINI_TOOL_REMINDER_TYPE, content, false, details, "agent");
4012
+ try {
4013
+ await this.agent.continue();
4014
+ } catch (err) {
4015
+ logger.warn("gemini tool-call reminder continue failed", { error: String(err) });
4016
+ }
4017
+ });
4018
+ }
4019
+
3852
4020
  #getStreamingEditToolCall(event: AgentEvent):
3853
4021
  | {
3854
4022
  toolCall: ToolCall;
@@ -4169,13 +4337,16 @@ export class AgentSession {
4169
4337
  await this.#extensionRunner?.emit({ type: "agent_end", messages });
4170
4338
  }
4171
4339
 
4172
- async #emitSessionStopEvent(messages: AgentMessage[]): Promise<void> {
4340
+ async #emitSessionStopEvent(
4341
+ messages: AgentMessage[],
4342
+ lastAssistantMessage = this.getLastAssistantMessage(),
4343
+ ): Promise<void> {
4173
4344
  if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) return;
4174
4345
  const generation = this.#promptGeneration;
4175
4346
  const result = await this.#extensionRunner.emitSessionStop({
4176
4347
  messages,
4177
4348
  turn_id: Math.max(0, this.#turnIndex - 1),
4178
- last_assistant_message: this.getLastAssistantMessage(),
4349
+ last_assistant_message: lastAssistantMessage,
4179
4350
  session_id: this.sessionId,
4180
4351
  session_file: this.sessionFile,
4181
4352
  stop_hook_active: this.#sessionStopHookActive,
@@ -4470,6 +4641,10 @@ export class AgentSession {
4470
4641
  return this.#isDisposed;
4471
4642
  }
4472
4643
 
4644
+ markMovedFromEmptySessionFile(sessionFile: string): void {
4645
+ this.#movedFromEmptySessionFile = path.resolve(sessionFile);
4646
+ }
4647
+
4473
4648
  /**
4474
4649
  * Synchronously mark the session as disposing so new work is rejected
4475
4650
  * immediately: eval starts throw, queued asides are dropped, and the
@@ -4543,6 +4718,9 @@ export class AgentSession {
4543
4718
  await disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId);
4544
4719
  await shutdownTinyTitleClient();
4545
4720
  this.#releasePowerAssertion();
4721
+ // Clean up an empty session created by this session's /move so it doesn't accumulate.
4722
+ await cleanupEmptyMoveSession(this.sessionManager, this.#movedFromEmptySessionFile);
4723
+ this.#movedFromEmptySessionFile = undefined;
4546
4724
  await this.sessionManager.close();
4547
4725
  // beginDispose() stopped the advisor and captured its recorder close; await
4548
4726
  // it so the final advisor turn is flushed before the process may exit.
@@ -5106,7 +5284,7 @@ export class AgentSession {
5106
5284
  toolNames: string[],
5107
5285
  options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
5108
5286
  ): Promise<void> {
5109
- toolNames = [...new Set(toolNames.map(name => name.toLowerCase()))];
5287
+ toolNames = normalizeToolNames(toolNames);
5110
5288
  const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
5111
5289
  const tools: AgentTool[] = [];
5112
5290
  const validToolNames: string[] = [];
@@ -5633,6 +5811,9 @@ export class AgentSession {
5633
5811
  ...options,
5634
5812
  ...(openrouterVariant !== undefined && { openrouterVariant }),
5635
5813
  ...(antigravityEndpointMode !== undefined && { antigravityEndpointMode }),
5814
+ maxInFlightRequests: validateProviderMaxInFlightRequests(
5815
+ options.maxInFlightRequests ?? this.settings.get("providers.maxInFlightRequests"),
5816
+ ),
5636
5817
  loopGuard: {
5637
5818
  enabled: this.settings.get("model.loopGuard.enabled"),
5638
5819
  checkAssistantContent: this.settings.get("model.loopGuard.checkAssistantContent"),
@@ -8089,16 +8270,25 @@ export class AgentSession {
8089
8270
  const effectiveSettings = compactMode
8090
8271
  ? { ...compactionSettings, ...compactMode.overrides }
8091
8272
  : compactionSettings;
8092
- if (compactMode?.requiresRemote) {
8093
- const remoteReady =
8094
- Boolean(effectiveSettings.remoteEndpoint) || shouldUseOpenAiRemoteCompaction(this.model);
8095
- if (!remoteReady) {
8096
- this.emitNotice(
8097
- "warning",
8098
- `remote compaction is unavailable for ${this.model.id} (no remote endpoint configured) — using a local summary instead`,
8099
- "compaction",
8100
- );
8101
- }
8273
+ // /compact remote demands provider-native compaction. When no remote
8274
+ // endpoint is configured (one would override per-model gating in
8275
+ // compact()), drop fallback candidates that aren't remote-capable so the
8276
+ // engine never silently runs a local summary on a configured-but-non-
8277
+ // remote compactionModel. If filtering empties the chain, warn and fall
8278
+ // back to the full chain so the operation still completes.
8279
+ const availableModels = this.#modelRegistry.getAvailable();
8280
+ const requireProviderRemote = Boolean(compactMode?.requiresRemote && !effectiveSettings.remoteEndpoint);
8281
+ let compactionCandidates = this.#getCompactionModelCandidates(
8282
+ availableModels,
8283
+ requireProviderRemote ? shouldUseOpenAiRemoteCompaction : undefined,
8284
+ );
8285
+ if (requireProviderRemote && compactionCandidates.length === 0) {
8286
+ this.emitNotice(
8287
+ "warning",
8288
+ `remote compaction is unavailable for ${this.model.id} (no remote endpoint configured and no provider-native remote-capable model in the fallback chain) — using a local summary instead`,
8289
+ "compaction",
8290
+ );
8291
+ compactionCandidates = this.#getCompactionModelCandidates(availableModels);
8102
8292
  }
8103
8293
  const pathEntries = this.sessionManager.getBranch();
8104
8294
  const preparation = prepareCompaction(pathEntries, effectiveSettings);
@@ -8137,27 +8327,32 @@ export class AgentSession {
8137
8327
  const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
8138
8328
 
8139
8329
  // Strategy honored on manual /compact too. Custom instructions imply a
8140
- // directed LLM summary; a text-only model cannot read the frames back —
8141
- // both take the summarizer path (the latter loudly).
8330
+ // directed LLM summary; a text-only model cannot read snapcompact frames.
8331
+ // When snapcompact itself was requested, fail locally instead of silently
8332
+ // converting the "no LLM call" path into a provider-backed summary.
8142
8333
  const wantsSnapcompact =
8143
8334
  compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions;
8144
- let snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
8145
- if (wantsSnapcompact && !snapcompactReady) {
8335
+ const snapcompactReady = wantsSnapcompact;
8336
+ if (wantsSnapcompact && !this.model.input.includes("image")) {
8146
8337
  this.emitNotice(
8147
8338
  "warning",
8148
- `snapcompact needs a vision-capable model (${this.model.id} is text-only) — using an LLM summary instead`,
8339
+ `snapcompact needs a vision-capable model (${this.model.id} is text-only)`,
8149
8340
  "compaction",
8150
8341
  );
8342
+ throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`);
8151
8343
  } else if (snapcompactReady) {
8152
8344
  const text = snapcompact.serializeConversation(convertToLlm(preparation.messagesToSummarize));
8153
8345
  const renderScan = snapcompact.scanRenderability(text);
8154
8346
  if (!renderScan.isSafe) {
8347
+ const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
8155
8348
  this.emitNotice(
8156
8349
  "warning",
8157
- `snapcompact disabled: high non-ASCII rate detected (${(renderScan.unrenderableRatio * 100).toFixed(1)}%). Falling back to an LLM summary to prevent data loss.`,
8350
+ `snapcompact disabled: high non-ASCII rate detected (${percent}%). No LLM fallback was attempted.`,
8158
8351
  "compaction",
8159
8352
  );
8160
- snapcompactReady = false;
8353
+ throw new Error(
8354
+ `snapcompact cannot render this conversation locally: high non-ASCII rate detected (${percent}%).`,
8355
+ );
8161
8356
  }
8162
8357
  }
8163
8358
 
@@ -8169,8 +8364,8 @@ export class AgentSession {
8169
8364
 
8170
8365
  // Snapcompact runs locally first. The frame cap is sized from the live
8171
8366
  // model window via #computeSnapcompactMaxFrames so the post-render context
8172
- // fits without the warning loop (issue #3247). Zero-frame budget skip
8173
- // snapcompact and take the summarizer path immediately.
8367
+ // fits without the warning loop (issue #3247). Zero-frame budget now fails
8368
+ // the snapcompact request locally rather than falling back to an LLM call.
8174
8369
  let snapcompactResult: snapcompact.CompactionResult | undefined;
8175
8370
  if (snapcompactReady) {
8176
8371
  const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings);
@@ -8180,9 +8375,10 @@ export class AgentSession {
8180
8375
  });
8181
8376
  this.emitNotice(
8182
8377
  "warning",
8183
- "snapcompact: kept history alone exceeds the context budget using an LLM summary instead",
8378
+ "snapcompact: kept history alone exceeds the context budget. No LLM fallback was attempted.",
8184
8379
  "compaction",
8185
8380
  );
8381
+ throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
8186
8382
  } else {
8187
8383
  snapcompactResult = await snapcompact.compact(preparation, {
8188
8384
  convertToLlm,
@@ -8196,15 +8392,15 @@ export class AgentSession {
8196
8392
  ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
8197
8393
  : Number.POSITIVE_INFINITY;
8198
8394
  if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
8199
- logger.warn("Snapcompact still overflows the window after frame-budget sizing; falling back", {
8395
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
8200
8396
  model: this.model?.id,
8201
8397
  });
8202
8398
  this.emitNotice(
8203
8399
  "warning",
8204
- "snapcompact could not bring the context under the limit using an LLM summary instead",
8400
+ "snapcompact could not bring the context under the limit. No LLM fallback was attempted.",
8205
8401
  "compaction",
8206
8402
  );
8207
- snapcompactResult = undefined;
8403
+ throw new Error("snapcompact could not bring the context under the limit locally.");
8208
8404
  }
8209
8405
  }
8210
8406
  }
@@ -8246,6 +8442,7 @@ export class AgentSession {
8246
8442
  remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
8247
8443
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
8248
8444
  },
8445
+ compactionCandidates,
8249
8446
  );
8250
8447
  summary = result.summary;
8251
8448
  shortSummary = result.shortSummary;
@@ -8679,6 +8876,21 @@ export class AgentSession {
8679
8876
  const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
8680
8877
  if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
8681
8878
 
8879
+ // Promote to a larger-context sibling before compacting, mirroring the
8880
+ // pre-prompt (#runPrePromptCompactionIfNeeded) and post-turn threshold
8881
+ // (#checkCompaction) paths. Without this, a long mid-turn tool loop that
8882
+ // crosses the threshold compacts the history (and can hit the no-progress
8883
+ // dead-end on a single oversized turn) on a model that should have just
8884
+ // been promoted to a larger window instead.
8885
+ if (await this.#promoteContextModel()) {
8886
+ logger.debug("Mid-run context promotion avoided compaction", {
8887
+ contextTokens,
8888
+ contextWindow,
8889
+ from: `${model?.provider}/${model?.id}`,
8890
+ });
8891
+ return;
8892
+ }
8893
+
8682
8894
  const messagesBefore = activeMessages.length;
8683
8895
  await this.#runAutoCompaction("threshold", false, false, false, {
8684
8896
  autoContinue: false,
@@ -8827,7 +9039,20 @@ export class AgentSession {
8827
9039
  if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
8828
9040
  const pruneResult = await this.#pruneToolOutputs();
8829
9041
  const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0);
8830
- const assistantUsageContextTokens = calculateContextTokens(assistantMessage.usage);
9042
+ // `errorIsFromBeforeCompaction` (computed above) is the general
9043
+ // "this assistant message predates the latest compaction" predicate here,
9044
+ // not just an error-specific one; alias it locally so the threshold intent
9045
+ // reads clearly (#3412 review).
9046
+ const assistantPredatesCompaction = errorIsFromBeforeCompaction;
9047
+ // An assistant that predates the latest compaction carries stale, pre-rewrite
9048
+ // `usage`: the scheduled auto-continue re-enters this check with the kept
9049
+ // assistant (#promptWithMessage → #checkCompaction), and its old high prompt
9050
+ // count would re-trip the threshold on a freshly compacted history. Drop the
9051
+ // stale provider number for those messages and let the live stored estimate
9052
+ // (the floor applied below) drive the decision instead.
9053
+ const assistantUsageContextTokens = assistantPredatesCompaction
9054
+ ? 0
9055
+ : calculateContextTokens(assistantMessage.usage);
8831
9056
  const storedContextTokens = this.#estimateStoredContextTokens();
8832
9057
  // Pruning frees bytes for the NEXT prompt; it does not change the size of
8833
9058
  // the prompt the LLM just billed for. Earlier revisions subtracted the
@@ -8931,11 +9156,11 @@ export class AgentSession {
8931
9156
  // Tool-use orphans corrupt Anthropic message history (tool_result without
8932
9157
  // matching tool_use). Always remove them even when the retry cap is hit.
8933
9158
  if (assistantMessage.stopReason === "toolUse") {
8934
- this.#removeEmptyStopFromActiveContext(assistantMessage);
9159
+ this.#discardAssistantTurn(assistantMessage);
8935
9160
  }
8936
9161
  return false;
8937
9162
  }
8938
- this.#removeEmptyStopFromActiveContext(assistantMessage);
9163
+ this.#discardAssistantTurn(assistantMessage);
8939
9164
  this.agent.appendMessage({
8940
9165
  role: "developer",
8941
9166
  content: [{ type: "text", text: this.#emptyStopRetryReminder() }],
@@ -9042,7 +9267,7 @@ export class AgentSession {
9042
9267
  });
9043
9268
  }
9044
9269
 
9045
- #removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
9270
+ #removeAssistantMessageFromActiveContext(assistantMessage: AssistantMessage): void {
9046
9271
  const messages = this.agent.state.messages;
9047
9272
  const lastMessage = messages[messages.length - 1];
9048
9273
  if (
@@ -9051,8 +9276,19 @@ export class AgentSession {
9051
9276
  ) {
9052
9277
  this.agent.replaceMessages(messages.slice(0, -1));
9053
9278
  }
9279
+ }
9054
9280
 
9055
- const emptyStopEntry = this.sessionManager
9281
+ /**
9282
+ * Drop an assistant turn from BOTH the live agent context and the persisted
9283
+ * session branch (reparenting the leaf to the turn's parent), so a discarded
9284
+ * turn does not resurface on reload. Used for empty/reasoning-only stops and
9285
+ * the Gemini header-runaway interrupt, which must not replay a partial,
9286
+ * loop-fueling thinking block.
9287
+ */
9288
+ #discardAssistantTurn(assistantMessage: AssistantMessage): void {
9289
+ this.#removeAssistantMessageFromActiveContext(assistantMessage);
9290
+
9291
+ const branchEntry = this.sessionManager
9056
9292
  .getBranch()
9057
9293
  .slice()
9058
9294
  .reverse()
@@ -9062,13 +9298,13 @@ export class AgentSession {
9062
9298
  entry.message.role === "assistant" &&
9063
9299
  this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
9064
9300
  );
9065
- if (!emptyStopEntry) {
9301
+ if (!branchEntry) {
9066
9302
  return;
9067
9303
  }
9068
- if (emptyStopEntry.parentId === null) {
9304
+ if (branchEntry.parentId === null) {
9069
9305
  this.sessionManager.resetLeaf();
9070
9306
  } else {
9071
- this.sessionManager.branch(emptyStopEntry.parentId);
9307
+ this.sessionManager.branch(branchEntry.parentId);
9072
9308
  }
9073
9309
  }
9074
9310
 
@@ -9774,11 +10010,15 @@ export class AgentSession {
9774
10010
  });
9775
10011
  return formatModelSelectorValue(modelKey, thinkingLevel);
9776
10012
  }
9777
- #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
9778
- const configuredTarget = currentModel.contextPromotionTarget?.trim();
9779
- if (!configuredTarget) return undefined;
10013
+ #resolveConfiguredModelTarget(
10014
+ configuredTarget: string | undefined,
10015
+ currentModel: Model,
10016
+ availableModels: Model[],
10017
+ ): Model | undefined {
10018
+ const trimmedTarget = configuredTarget?.trim();
10019
+ if (!trimmedTarget) return undefined;
9780
10020
 
9781
- const parsed = parseModelString(configuredTarget, {
10021
+ const parsed = parseModelString(trimmedTarget, {
9782
10022
  allowMaxAlias: true,
9783
10023
  isLiteralModelId: (provider, id) =>
9784
10024
  availableModels.some(model => model.provider === provider && model.id === id),
@@ -9788,7 +10028,15 @@ export class AgentSession {
9788
10028
  if (explicitModel) return explicitModel;
9789
10029
  }
9790
10030
 
9791
- return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
10031
+ return availableModels.find(m => m.provider === currentModel.provider && m.id === trimmedTarget);
10032
+ }
10033
+
10034
+ #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
10035
+ return this.#resolveConfiguredModelTarget(currentModel.contextPromotionTarget, currentModel, availableModels);
10036
+ }
10037
+
10038
+ #resolveCompactionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
10039
+ return this.#resolveConfiguredModelTarget(currentModel.compactionModel, currentModel, availableModels);
9792
10040
  }
9793
10041
 
9794
10042
  #resolveRoleModelFull(
@@ -9813,11 +10061,15 @@ export class AgentSession {
9813
10061
  });
9814
10062
  }
9815
10063
 
9816
- #getCompactionModelCandidates(availableModels: Model[]): Model[] {
9817
- return this.#resolveCompactionModelCandidates(this.model, availableModels);
10064
+ #getCompactionModelCandidates(availableModels: Model[], filter?: (model: Model) => boolean): Model[] {
10065
+ return this.#resolveCompactionModelCandidates(this.model, availableModels, filter);
9818
10066
  }
9819
10067
 
9820
- #resolveCompactionModelCandidates(preferredModel: Model | null | undefined, availableModels: Model[]): Model[] {
10068
+ #resolveCompactionModelCandidates(
10069
+ preferredModel: Model | null | undefined,
10070
+ availableModels: Model[],
10071
+ filter?: (model: Model) => boolean,
10072
+ ): Model[] {
9821
10073
  const candidates: Model[] = [];
9822
10074
  const seen = new Set<string>();
9823
10075
 
@@ -9826,9 +10078,16 @@ export class AgentSession {
9826
10078
  const key = this.#getModelKey(model);
9827
10079
  if (seen.has(key)) return;
9828
10080
  seen.add(key);
10081
+ // `seen` still tracks rejected models so the largest-context fallback
10082
+ // scan below doesn't reintroduce them; the filter just suppresses
10083
+ // inclusion in this caller's candidate chain.
10084
+ if (filter && !filter(model)) return;
9829
10085
  candidates.push(model);
9830
10086
  };
9831
10087
 
10088
+ if (preferredModel) {
10089
+ addCandidate(this.#resolveCompactionConfiguredTarget(preferredModel, availableModels));
10090
+ }
9832
10091
  addCandidate(preferredModel ?? undefined);
9833
10092
  for (const role of MODEL_ROLE_IDS) {
9834
10093
  addCandidate(this.#resolveRoleModelFull(role, availableModels, preferredModel ?? undefined).model);
@@ -9875,8 +10134,10 @@ export class AgentSession {
9875
10134
  customInstructions: string | undefined,
9876
10135
  signal: AbortSignal,
9877
10136
  options?: SummaryOptions,
10137
+ precomputedCandidates?: Model[],
9878
10138
  ): Promise<CompactionResult> {
9879
- const candidates = this.#getCompactionModelCandidates(this.#modelRegistry.getAvailable());
10139
+ const candidates =
10140
+ precomputedCandidates ?? this.#getCompactionModelCandidates(this.#modelRegistry.getAvailable());
9880
10141
  const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
9881
10142
 
9882
10143
  for (const candidate of candidates) {
@@ -10073,6 +10334,80 @@ export class AgentSession {
10073
10334
  return tokens;
10074
10335
  }
10075
10336
 
10337
+ /**
10338
+ * Post-maintenance progress check for the context-full / snapcompact tail.
10339
+ *
10340
+ * After `appendCompaction` rewrote history and `replaceMessages` swapped in the
10341
+ * compacted context, measure the residual context off the live message set and
10342
+ * decide whether maintenance actually created headroom. Mirrors the shake
10343
+ * recovery-band logic (#2275): a session whose single most-recent turn already
10344
+ * blows the threshold cannot be reduced by compaction (findCutPoint keeps that
10345
+ * turn verbatim), so re-firing on the next agent_end just thrashes. We only
10346
+ * report progress when residual context lands at or below
10347
+ * `COMPACTION_RECOVERY_BAND × threshold` — a band that sits strictly under the
10348
+ * compaction threshold, so reaching it guarantees the next turn cannot
10349
+ * re-trip threshold compaction.
10350
+ *
10351
+ * When the model/window is unknown we cannot evaluate the band, so we
10352
+ * optimistically allow the continuation (preserving prior behavior).
10353
+ */
10354
+ #compactionCreatedHeadroom(): boolean {
10355
+ const contextWindow = this.model?.contextWindow ?? 0;
10356
+ if (contextWindow <= 0) return true;
10357
+ const compactionSettings = this.settings.getGroup("compaction");
10358
+ const residualTokens = compactionContextTokens(
10359
+ this.getContextUsage({ contextWindow })?.tokens ?? 0,
10360
+ this.#estimateStoredContextTokens(),
10361
+ );
10362
+ const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
10363
+ const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
10364
+ // Residual at/below the band is authoritative headroom: the band sits
10365
+ // strictly under the compaction threshold, so the next turn cannot
10366
+ // re-trip threshold compaction regardless of how little this pass shaved.
10367
+ // Don't add a secondary "smaller than the trigger" guard — when stale/
10368
+ // tool-output pruning already dropped context under the band before this
10369
+ // pass, the trigger is itself sub-band, and requiring a strict reduction
10370
+ // would suppress a valid continuation and emit a false no-progress warning
10371
+ // even though compaction left the session safe.
10372
+ return residualTokens <= recoveryBand;
10373
+ }
10374
+
10375
+ /**
10376
+ * Retry-side counterpart to {@link #compactionCreatedHeadroom}. An
10377
+ * overflow/incomplete recovery only needs the rebuilt prompt to *fit* the
10378
+ * window again — it does not have to land under the compaction threshold, let
10379
+ * alone the stricter `COMPACTION_RECOVERY_BAND × threshold` hysteresis the
10380
+ * auto-continue thrash guard uses. Reusing the band here turned recoverable
10381
+ * overflows into manual dead-ends: a 200k-window prompt compacted from
10382
+ * overflow down to ~150k is comfortably retryable, but sits above
10383
+ * `0.8 × 170k = 136k` and was wrongly refused (PR #3412 review).
10384
+ *
10385
+ * Measures residual context against the usable budget (`contextWindow - reserve`).
10386
+ * The default absolute reserve can exceed bundled small-context windows, so
10387
+ * the retry path clamps that reserve back to the proportional 15% default
10388
+ * before comparing; otherwise a prompt that fits the model window would still
10389
+ * see a negative budget and dead-end. Callers MUST invoke this AFTER dropping
10390
+ * the failed assistant from `this.messages`, so the just-failed turn (which
10391
+ * the retry prompt will not include) is excluded from the estimate.
10392
+ *
10393
+ * When the model/window is unknown we cannot evaluate the budget, so we
10394
+ * optimistically allow the retry (preserving prior behavior).
10395
+ */
10396
+ #compactionCreatedRetryFit(): boolean {
10397
+ const contextWindow = this.model?.contextWindow ?? 0;
10398
+ if (contextWindow <= 0) return true;
10399
+ const compactionSettings = this.settings.getGroup("compaction");
10400
+ const residualTokens = compactionContextTokens(
10401
+ this.getContextUsage({ contextWindow })?.tokens ?? 0,
10402
+ this.#estimateStoredContextTokens(),
10403
+ );
10404
+ const reserveTokens = effectiveReserveTokens(contextWindow, compactionSettings);
10405
+ const defaultReserveTokens = Math.floor(contextWindow * 0.15);
10406
+ const fitReserveTokens = Math.min(reserveTokens, defaultReserveTokens);
10407
+ const fitBudget = Math.max(0, contextWindow - fitReserveTokens);
10408
+ return residualTokens <= fitBudget;
10409
+ }
10410
+
10076
10411
  /**
10077
10412
  * Internal: Run auto-compaction with events.
10078
10413
  *
@@ -10146,26 +10481,14 @@ export class AgentSession {
10146
10481
  // "overflow" forces context-full because the input itself is broken — a handoff
10147
10482
  // LLM call would hit the same overflow. "incomplete" is an output-side problem,
10148
10483
  // so a handoff request on the existing context is still viable. Snapcompact is
10149
- // safe for every reason (it makes no LLM call at all) but requires a vision
10150
- // model to be worth anything — fall back to context-full otherwise.
10484
+ // a local-only strategy: if it cannot run, report the local blocker instead of
10485
+ // silently swapping in a provider-backed summary.
10151
10486
  let action: "context-full" | "handoff" | "snapcompact" =
10152
- compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
10153
- ? "handoff"
10154
- : "context-full";
10155
- if (compactionSettings.strategy === "snapcompact") {
10156
- if (this.model?.input.includes("image")) {
10157
- action = "snapcompact";
10158
- } else {
10159
- logger.warn("Snapcompact compaction requires a vision-capable model; falling back to context-full", {
10160
- model: this.model?.id,
10161
- });
10162
- this.emitNotice(
10163
- "warning",
10164
- `snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only) — using an LLM summary instead`,
10165
- "compaction",
10166
- );
10167
- }
10168
- }
10487
+ compactionSettings.strategy === "snapcompact"
10488
+ ? "snapcompact"
10489
+ : compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
10490
+ ? "handoff"
10491
+ : "context-full";
10169
10492
  // Abort any older auto-compaction before installing this run's controller.
10170
10493
  this.#autoCompactionAbortController?.abort();
10171
10494
  const autoCompactionAbortController = new AbortController();
@@ -10306,48 +10629,59 @@ export class AgentSession {
10306
10629
  // Snapcompact runs locally first. The post-compaction context = kept-recent
10307
10630
  // + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
10308
10631
  // per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
10309
- // live window so we don't run snapcompact just to overflow and fall back
10310
- // every threshold tick. Kept-recent already over budget skip snapcompact
10311
- // outright (a single frame won't fit). Otherwise the projection below is
10312
- // only a defensive guard for summary-text drift.
10632
+ // live window so we don't run snapcompact just to overflow every threshold
10633
+ // tick. Any local blocker fails the snapcompact maintenance pass rather
10634
+ // than falling back to a provider-backed LLM summary.
10313
10635
  let snapcompactResult: snapcompact.CompactionResult | undefined;
10314
10636
  if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
10637
+ if (!this.model?.input.includes("image")) {
10638
+ logger.warn("Snapcompact compaction requires a vision-capable model", {
10639
+ model: this.model?.id,
10640
+ });
10641
+ this.emitNotice(
10642
+ "warning",
10643
+ `snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only). No LLM fallback was attempted.`,
10644
+ "compaction",
10645
+ );
10646
+ throw new Error(`snapcompact cannot run locally: ${this.model?.id ?? "unknown"} is text-only.`);
10647
+ }
10315
10648
  const text = snapcompact.serializeConversation(
10316
10649
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
10317
10650
  );
10318
10651
  const renderScan = snapcompact.scanRenderability(text);
10319
10652
  if (!renderScan.isSafe) {
10320
- logger.warn("Snapcompact disabled: high non-ASCII rate detected; falling back to an LLM summary", {
10653
+ const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
10654
+ logger.warn("Snapcompact disabled: high non-ASCII rate detected", {
10321
10655
  model: this.model?.id,
10322
10656
  unrenderableRatio: renderScan.unrenderableRatio,
10323
10657
  });
10324
10658
  this.emitNotice(
10325
10659
  "warning",
10326
- `snapcompact disabled: high non-ASCII rate detected (${(renderScan.unrenderableRatio * 100).toFixed(1)}%). Falling back to an LLM summary to prevent data loss.`,
10660
+ `snapcompact disabled: high non-ASCII rate detected (${percent}%). No LLM fallback was attempted.`,
10327
10661
  "compaction",
10328
10662
  );
10329
- action = "context-full";
10330
- } else {
10331
- const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
10332
- if (maxFrames < 1) {
10333
- logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
10334
- model: this.model?.id,
10335
- });
10336
- this.emitNotice(
10337
- "warning",
10338
- "snapcompact: kept history alone exceeds the context budget — using an LLM summary instead",
10339
- "compaction",
10340
- );
10341
- action = "context-full";
10342
- } else {
10343
- snapcompactResult = await snapcompact.compact(preparation, {
10344
- convertToLlm,
10345
- model: this.model,
10346
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
10347
- maxFrames,
10348
- });
10349
- }
10663
+ throw new Error(
10664
+ `snapcompact cannot render this conversation locally: high non-ASCII rate detected (${percent}%).`,
10665
+ );
10350
10666
  }
10667
+ const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
10668
+ if (maxFrames < 1) {
10669
+ logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
10670
+ model: this.model?.id,
10671
+ });
10672
+ this.emitNotice(
10673
+ "warning",
10674
+ "snapcompact: kept history alone exceeds the context budget. No LLM fallback was attempted.",
10675
+ "compaction",
10676
+ );
10677
+ throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
10678
+ }
10679
+ snapcompactResult = await snapcompact.compact(preparation, {
10680
+ convertToLlm,
10681
+ model: this.model,
10682
+ shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
10683
+ maxFrames,
10684
+ });
10351
10685
 
10352
10686
  if (snapcompactResult) {
10353
10687
  const ctxWindow = this.model?.contextWindow ?? 0;
@@ -10357,18 +10691,17 @@ export class AgentSession {
10357
10691
  : Number.POSITIVE_INFINITY;
10358
10692
  const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
10359
10693
  if (projected > budget) {
10360
- logger.warn("Snapcompact still overflows the window after frame-budget sizing; falling back", {
10694
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
10361
10695
  model: this.model?.id,
10362
10696
  projected,
10363
10697
  budget,
10364
10698
  });
10365
10699
  this.emitNotice(
10366
10700
  "warning",
10367
- "snapcompact could not bring the context under the limit using an LLM summary instead",
10701
+ "snapcompact could not bring the context under the limit. No LLM fallback was attempted.",
10368
10702
  "compaction",
10369
10703
  );
10370
- action = "context-full";
10371
- snapcompactResult = undefined;
10704
+ throw new Error("snapcompact could not bring the context under the limit locally.");
10372
10705
  }
10373
10706
  }
10374
10707
  }
@@ -10564,11 +10897,21 @@ export class AgentSession {
10564
10897
  };
10565
10898
  await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
10566
10899
 
10900
+ // Post-maintenance progress guard. Snapcompact can project over budget and
10901
+ // fall back to a context-full summary; the summarizer keeps `keepRecentTokens`
10902
+ // of recent history verbatim and findCutPoint can only cut at turn
10903
+ // boundaries (never tool results), so a single oversized recent turn (e.g. a
10904
+ // huge tool result) leaves the rewritten context still above threshold.
10905
+ // Scheduling the continuation regardless means the next agent_end re-enters
10906
+ // #checkCompaction over the same oversized tail and re-fires forever. The
10907
+ // retry and the threshold auto-continue use different progress tests (a
10908
+ // recoverable overflow only has to fit; the auto-continue thrash needs the
10909
+ // stricter recovery band), so each branch evaluates its own below.
10567
10910
  let continuationScheduled = false;
10568
- if (!willRetry && reason !== "idle" && shouldAutoContinue) {
10569
- this.#scheduleAutoContinuePrompt(generation);
10570
- continuationScheduled = true;
10571
- }
10911
+ // A non-idle pass that wanted to continue (retry or auto-continue) but freed
10912
+ // too little for that path to proceed is a dead-end: warn once so the user
10913
+ // understands why maintenance paused instead of silently looping.
10914
+ let noProgressDeadEnd = false;
10572
10915
 
10573
10916
  if (willRetry) {
10574
10917
  const messages = this.agent.state.messages;
@@ -10587,11 +10930,38 @@ export class AgentSession {
10587
10930
  }
10588
10931
  }
10589
10932
 
10590
- this.#scheduleAgentContinue({ delayMs: 100, generation });
10591
- continuationScheduled = true;
10592
- } else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
10933
+ // Retry only needs the rebuilt prompt to fit the window again — measured
10934
+ // AFTER the drop above so the just-failed turn (which the retry prompt
10935
+ // won't include) is excluded. Reusing the auto-continue recovery band
10936
+ // here turned recoverable overflows into manual dead-ends (#3412 review),
10937
+ // so use the looser fit budget.
10938
+ if (this.#compactionCreatedRetryFit()) {
10939
+ this.#scheduleAgentContinue({ delayMs: 100, generation });
10940
+ continuationScheduled = true;
10941
+ } else {
10942
+ noProgressDeadEnd = true;
10943
+ }
10944
+ } else if (reason !== "idle") {
10945
+ // Mirror the shake recovery-band check: only auto-continue when compaction
10946
+ // landed residual context under `COMPACTION_RECOVERY_BAND × threshold`.
10947
+ // Re-firing on a history that still sits just over the line is the
10948
+ // snapcompact thrash, so require genuine headroom, not a bare fit. Even
10949
+ // when auto-continue is disabled, a no-headroom threshold pass must still
10950
+ // block later automatic continuations (todo reminders/session_stop hooks)
10951
+ // from re-entering the same oversized context.
10952
+ if (this.#compactionCreatedHeadroom()) {
10953
+ if (shouldAutoContinue) {
10954
+ this.#scheduleAutoContinuePrompt(generation);
10955
+ continuationScheduled = true;
10956
+ }
10957
+ } else {
10958
+ noProgressDeadEnd = true;
10959
+ }
10960
+ }
10961
+ if (!continuationScheduled && !suppressContinuation && this.agent.hasQueuedMessages()) {
10593
10962
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
10594
- // Kick the loop so queued messages are actually delivered.
10963
+ // Kick the loop so queued messages are actually delivered. This remains separate
10964
+ // from the no-progress warning: pausing maintenance must not strand user input.
10595
10965
  this.#scheduleAgentContinue({
10596
10966
  delayMs: 100,
10597
10967
  generation,
@@ -10599,7 +10969,16 @@ export class AgentSession {
10599
10969
  });
10600
10970
  continuationScheduled = true;
10601
10971
  }
10602
- return continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE;
10972
+
10973
+ if (noProgressDeadEnd) {
10974
+ this.emitNotice(
10975
+ "warning",
10976
+ "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; shrink it (e.g. clear large tool output) or switch to a larger-context model.",
10977
+ "compaction",
10978
+ );
10979
+ }
10980
+ if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION;
10981
+ return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE;
10603
10982
  } catch (error) {
10604
10983
  if (autoCompactionSignal.aborted) {
10605
10984
  await this.#emitSessionEvent({
@@ -10697,7 +11076,7 @@ export class AgentSession {
10697
11076
  if (typeof triggerContextTokens === "number" && Number.isFinite(triggerContextTokens)) {
10698
11077
  const correctedTokens = Math.max(0, triggerContextTokens - result.tokensFreed);
10699
11078
  const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
10700
- const recoveryBand = Math.floor(thresholdTokens * SHAKE_RECOVERY_BAND);
11079
+ const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
10701
11080
  stillOverThreshold = correctedTokens > recoveryBand;
10702
11081
  } else {
10703
11082
  const postShakeTokens = this.getContextUsage({ contextWindow })?.tokens ?? 0;
@@ -11464,11 +11843,8 @@ export class AgentSession {
11464
11843
  errorMessage,
11465
11844
  });
11466
11845
 
11467
- // Remove error message from agent state (keep in session for history)
11468
- const messages = this.agent.state.messages;
11469
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
11470
- this.agent.replaceMessages(messages.slice(0, -1));
11471
- }
11846
+ // Remove the failed assistant message from active context before retrying.
11847
+ this.#removeAssistantMessageFromActiveContext(message);
11472
11848
 
11473
11849
  // Wait with exponential backoff (abortable).
11474
11850
  const retryAbortController = new AbortController();