@pentoshi/clai 4.1.0 → 4.2.1

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 (266) hide show
  1. package/README.md +14 -0
  2. package/dist/agent/compaction-executor.d.ts +68 -0
  3. package/dist/agent/compaction-executor.js +343 -0
  4. package/dist/agent/compaction-executor.js.map +1 -0
  5. package/dist/agent/context-breakdown.js +2 -0
  6. package/dist/agent/context-breakdown.js.map +1 -1
  7. package/dist/agent/context-manager.d.ts +23 -9
  8. package/dist/agent/context-manager.js +72 -43
  9. package/dist/agent/context-manager.js.map +1 -1
  10. package/dist/agent/events.d.ts +3 -0
  11. package/dist/agent/loop-guard.d.ts +2 -0
  12. package/dist/agent/loop-guard.js +48 -3
  13. package/dist/agent/loop-guard.js.map +1 -1
  14. package/dist/agent/prompt-composer.js +6 -6
  15. package/dist/agent/prompt-composer.js.map +1 -1
  16. package/dist/agent/reliability-policy.d.ts +1 -0
  17. package/dist/agent/reliability-policy.js +6 -3
  18. package/dist/agent/reliability-policy.js.map +1 -1
  19. package/dist/agent/request-accounting.d.ts +97 -0
  20. package/dist/agent/request-accounting.js +207 -0
  21. package/dist/agent/request-accounting.js.map +1 -0
  22. package/dist/agent/request-budget.d.ts +2 -2
  23. package/dist/agent/request-budget.js +2 -4
  24. package/dist/agent/request-budget.js.map +1 -1
  25. package/dist/agent/runner.d.ts +8 -2
  26. package/dist/agent/runner.js +409 -178
  27. package/dist/agent/runner.js.map +1 -1
  28. package/dist/agent/session-title.d.ts +0 -26
  29. package/dist/agent/session-title.js +0 -80
  30. package/dist/agent/session-title.js.map +1 -1
  31. package/dist/agent/tool-history.d.ts +2 -2
  32. package/dist/agent/tool-history.js +19 -6
  33. package/dist/agent/tool-history.js.map +1 -1
  34. package/dist/app/adapters/agent-event-adapter.js +19 -0
  35. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  36. package/dist/app/adapters/current-agent-adapter.js +4 -0
  37. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  38. package/dist/app/commands/catalog.js +24 -0
  39. package/dist/app/commands/catalog.js.map +1 -1
  40. package/dist/app/controllers/cancel-coordinator.d.ts +51 -0
  41. package/dist/app/controllers/cancel-coordinator.js +48 -0
  42. package/dist/app/controllers/cancel-coordinator.js.map +1 -0
  43. package/dist/app/controllers/session-compact-helper.d.ts +19 -2
  44. package/dist/app/controllers/session-compact-helper.js +100 -127
  45. package/dist/app/controllers/session-compact-helper.js.map +1 -1
  46. package/dist/app/controllers/session-context-usage.d.ts +32 -15
  47. package/dist/app/controllers/session-context-usage.js +186 -51
  48. package/dist/app/controllers/session-context-usage.js.map +1 -1
  49. package/dist/app/controllers/session-controller.d.ts +20 -11
  50. package/dist/app/controllers/session-controller.js +111 -90
  51. package/dist/app/controllers/session-controller.js.map +1 -1
  52. package/dist/app/controllers/session-naming.d.ts +31 -0
  53. package/dist/app/controllers/session-naming.js +173 -0
  54. package/dist/app/controllers/session-naming.js.map +1 -0
  55. package/dist/app/controllers/session-persistence.d.ts +2 -2
  56. package/dist/app/controllers/session-persistence.js +3 -6
  57. package/dist/app/controllers/session-persistence.js.map +1 -1
  58. package/dist/app/controllers/session-turn-request.d.ts +2 -1
  59. package/dist/app/controllers/session-turn-request.js +3 -0
  60. package/dist/app/controllers/session-turn-request.js.map +1 -1
  61. package/dist/app/controllers/turn-controller.d.ts +2 -1
  62. package/dist/app/controllers/turn-controller.js +1 -0
  63. package/dist/app/controllers/turn-controller.js.map +1 -1
  64. package/dist/app/events/app-event.d.ts +9 -0
  65. package/dist/app/events/app-event.js.map +1 -1
  66. package/dist/app/ports/agent-port.d.ts +8 -1
  67. package/dist/classic/app/ClassicApp.js +2 -1
  68. package/dist/classic/app/ClassicApp.js.map +1 -1
  69. package/dist/classic/app/app-wiring.js +1 -2
  70. package/dist/classic/app/app-wiring.js.map +1 -1
  71. package/dist/classic/app/wiring-interactions.js +2 -7
  72. package/dist/classic/app/wiring-interactions.js.map +1 -1
  73. package/dist/classic/blocks/tool-lines.js +2 -1
  74. package/dist/classic/blocks/tool-lines.js.map +1 -1
  75. package/dist/classic/input/cancel-ladder.d.ts +2 -19
  76. package/dist/classic/input/cancel-ladder.js +13 -20
  77. package/dist/classic/input/cancel-ladder.js.map +1 -1
  78. package/dist/commands/providers.js +2 -0
  79. package/dist/commands/providers.js.map +1 -1
  80. package/dist/llm/adapters/anthropic-tools.d.ts +31 -14
  81. package/dist/llm/adapters/anthropic-tools.js +124 -29
  82. package/dist/llm/adapters/anthropic-tools.js.map +1 -1
  83. package/dist/llm/adapters/gemini-tools.d.ts +16 -4
  84. package/dist/llm/adapters/gemini-tools.js +117 -16
  85. package/dist/llm/adapters/gemini-tools.js.map +1 -1
  86. package/dist/llm/adapters/openai-tools.d.ts +13 -2
  87. package/dist/llm/adapters/openai-tools.js +58 -11
  88. package/dist/llm/adapters/openai-tools.js.map +1 -1
  89. package/dist/llm/agentrouter.js +68 -45
  90. package/dist/llm/agentrouter.js.map +1 -1
  91. package/dist/llm/anthropic.js +69 -36
  92. package/dist/llm/anthropic.js.map +1 -1
  93. package/dist/llm/aws-mantle.js +70 -32
  94. package/dist/llm/aws-mantle.js.map +1 -1
  95. package/dist/llm/bynara.js +26 -69
  96. package/dist/llm/bynara.js.map +1 -1
  97. package/dist/llm/capabilities.d.ts +2 -1
  98. package/dist/llm/capabilities.js +47 -0
  99. package/dist/llm/capabilities.js.map +1 -1
  100. package/dist/llm/context-snapshot.d.ts +100 -0
  101. package/dist/llm/context-snapshot.js +277 -0
  102. package/dist/llm/context-snapshot.js.map +1 -0
  103. package/dist/llm/context-windows.d.ts +10 -0
  104. package/dist/llm/context-windows.js +129 -0
  105. package/dist/llm/context-windows.js.map +1 -0
  106. package/dist/llm/custom-provider-profile.d.ts +75 -0
  107. package/dist/llm/custom-provider-profile.js +558 -0
  108. package/dist/llm/custom-provider-profile.js.map +1 -0
  109. package/dist/llm/custom-providers.d.ts +13 -1
  110. package/dist/llm/custom-providers.js +77 -25
  111. package/dist/llm/custom-providers.js.map +1 -1
  112. package/dist/llm/effort-fallback.d.ts +44 -0
  113. package/dist/llm/effort-fallback.js +101 -0
  114. package/dist/llm/effort-fallback.js.map +1 -0
  115. package/dist/llm/fireworks.js +4 -0
  116. package/dist/llm/fireworks.js.map +1 -1
  117. package/dist/llm/free.js +4 -0
  118. package/dist/llm/free.js.map +1 -1
  119. package/dist/llm/gemini.d.ts +1 -1
  120. package/dist/llm/gemini.js +89 -51
  121. package/dist/llm/gemini.js.map +1 -1
  122. package/dist/llm/groq.js +4 -0
  123. package/dist/llm/groq.js.map +1 -1
  124. package/dist/llm/hetzner.js +4 -0
  125. package/dist/llm/hetzner.js.map +1 -1
  126. package/dist/llm/http.d.ts +49 -10
  127. package/dist/llm/http.js +417 -153
  128. package/dist/llm/http.js.map +1 -1
  129. package/dist/llm/kimchi.js +4 -0
  130. package/dist/llm/kimchi.js.map +1 -1
  131. package/dist/llm/lightning.js +4 -0
  132. package/dist/llm/lightning.js.map +1 -1
  133. package/dist/llm/meta.d.ts +2 -0
  134. package/dist/llm/meta.js +811 -655
  135. package/dist/llm/meta.js.map +1 -1
  136. package/dist/llm/modal.js +4 -0
  137. package/dist/llm/modal.js.map +1 -1
  138. package/dist/llm/model-families.d.ts +9 -0
  139. package/dist/llm/model-families.js +39 -0
  140. package/dist/llm/model-families.js.map +1 -0
  141. package/dist/llm/nvidia.js +4 -0
  142. package/dist/llm/nvidia.js.map +1 -1
  143. package/dist/llm/ollama.js +44 -40
  144. package/dist/llm/ollama.js.map +1 -1
  145. package/dist/llm/openai.js +4 -0
  146. package/dist/llm/openai.js.map +1 -1
  147. package/dist/llm/openrouter.js +4 -0
  148. package/dist/llm/openrouter.js.map +1 -1
  149. package/dist/llm/operation-ledger.d.ts +60 -0
  150. package/dist/llm/operation-ledger.js +136 -0
  151. package/dist/llm/operation-ledger.js.map +1 -0
  152. package/dist/llm/operation-usage.d.ts +32 -0
  153. package/dist/llm/operation-usage.js +172 -0
  154. package/dist/llm/operation-usage.js.map +1 -0
  155. package/dist/llm/orcarouter.d.ts +7 -0
  156. package/dist/llm/orcarouter.js +166 -0
  157. package/dist/llm/orcarouter.js.map +1 -0
  158. package/dist/llm/provider-profile-layers.d.ts +7 -0
  159. package/dist/llm/provider-profile-layers.js +961 -0
  160. package/dist/llm/provider-profile-layers.js.map +1 -0
  161. package/dist/llm/provider-profile.d.ts +181 -0
  162. package/dist/llm/provider-profile.js +329 -0
  163. package/dist/llm/provider-profile.js.map +1 -0
  164. package/dist/llm/provider-profiles.d.ts +13 -0
  165. package/dist/llm/provider-profiles.js +86 -0
  166. package/dist/llm/provider-profiles.js.map +1 -0
  167. package/dist/llm/provider.d.ts +2 -0
  168. package/dist/llm/provider.js +67 -0
  169. package/dist/llm/provider.js.map +1 -1
  170. package/dist/llm/qwen-cloud.js +4 -0
  171. package/dist/llm/qwen-cloud.js.map +1 -1
  172. package/dist/llm/reasoning-artifacts.d.ts +101 -0
  173. package/dist/llm/reasoning-artifacts.js +440 -0
  174. package/dist/llm/reasoning-artifacts.js.map +1 -0
  175. package/dist/llm/request-fingerprint.d.ts +10 -0
  176. package/dist/llm/request-fingerprint.js +313 -0
  177. package/dist/llm/request-fingerprint.js.map +1 -0
  178. package/dist/llm/request-plan.d.ts +119 -0
  179. package/dist/llm/request-plan.js +279 -0
  180. package/dist/llm/request-plan.js.map +1 -0
  181. package/dist/llm/router.d.ts +19 -1
  182. package/dist/llm/router.js +447 -75
  183. package/dist/llm/router.js.map +1 -1
  184. package/dist/llm/stream-events.d.ts +47 -0
  185. package/dist/llm/stream-events.js +55 -0
  186. package/dist/llm/stream-events.js.map +1 -0
  187. package/dist/llm/stream-terminal.d.ts +30 -0
  188. package/dist/llm/stream-terminal.js +51 -0
  189. package/dist/llm/stream-terminal.js.map +1 -0
  190. package/dist/llm/token-estimate-calibration.d.ts +23 -0
  191. package/dist/llm/token-estimate-calibration.js +140 -0
  192. package/dist/llm/token-estimate-calibration.js.map +1 -0
  193. package/dist/llm/token-usage.d.ts +35 -5
  194. package/dist/llm/token-usage.js +212 -162
  195. package/dist/llm/token-usage.js.map +1 -1
  196. package/dist/llm/tokenrouter.js +8 -2
  197. package/dist/llm/tokenrouter.js.map +1 -1
  198. package/dist/modes/ask.js +11 -5
  199. package/dist/modes/ask.js.map +1 -1
  200. package/dist/noninteractive/start-noninteractive.js +31 -2
  201. package/dist/noninteractive/start-noninteractive.js.map +1 -1
  202. package/dist/noninteractive/stream-blocks.js +15 -4
  203. package/dist/noninteractive/stream-blocks.js.map +1 -1
  204. package/dist/prompts/embedded.js +1 -1
  205. package/dist/prompts/embedded.js.map +1 -1
  206. package/dist/prompts/system.agent.md +6 -0
  207. package/dist/store/config.js +5 -1
  208. package/dist/store/config.js.map +1 -1
  209. package/dist/store/history.d.ts +2 -0
  210. package/dist/store/history.js +19 -10
  211. package/dist/store/history.js.map +1 -1
  212. package/dist/store/keys.js +11 -1
  213. package/dist/store/keys.js.map +1 -1
  214. package/dist/store/plan.js +20 -14
  215. package/dist/store/plan.js.map +1 -1
  216. package/dist/tools/elevated-shell.d.ts +1 -1
  217. package/dist/tools/elevated-shell.js +24 -72
  218. package/dist/tools/elevated-shell.js.map +1 -1
  219. package/dist/tools/nmap-runner.js +30 -36
  220. package/dist/tools/nmap-runner.js.map +1 -1
  221. package/dist/tools/sudo-session.d.ts +83 -0
  222. package/dist/tools/sudo-session.js +124 -0
  223. package/dist/tools/sudo-session.js.map +1 -0
  224. package/dist/tui-v2/app/App.js +9 -24
  225. package/dist/tui-v2/app/App.js.map +1 -1
  226. package/dist/tui-v2/components/status/context-limit-chip.js +2 -2
  227. package/dist/tui-v2/components/status/context-limit-chip.js.map +1 -1
  228. package/dist/tui-v2/components/transcript/intro-card.js +2 -1
  229. package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
  230. package/dist/tui-v2/composer/composer-editor.js +2 -1
  231. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  232. package/dist/types.d.ts +156 -1
  233. package/dist/types.js +1 -0
  234. package/dist/types.js.map +1 -1
  235. package/dist/ui-core/bootstrap/composition-root.d.ts +2 -0
  236. package/dist/ui-core/bootstrap/composition-root.js +26 -3
  237. package/dist/ui-core/bootstrap/composition-root.js.map +1 -1
  238. package/dist/ui-core/commands/picker-commands.js +11 -5
  239. package/dist/ui-core/commands/picker-commands.js.map +1 -1
  240. package/dist/ui-core/commands/session-commands.js +41 -5
  241. package/dist/ui-core/commands/session-commands.js.map +1 -1
  242. package/dist/ui-core/plan/plan-lifecycle.js +3 -2
  243. package/dist/ui-core/plan/plan-lifecycle.js.map +1 -1
  244. package/dist/ui-core/rendering/context-limit.js +4 -1
  245. package/dist/ui-core/rendering/context-limit.js.map +1 -1
  246. package/dist/ui-core/rendering/duration.d.ts +2 -1
  247. package/dist/ui-core/rendering/duration.js +13 -5
  248. package/dist/ui-core/rendering/duration.js.map +1 -1
  249. package/dist/ui-core/rendering/intro-header.js +2 -2
  250. package/dist/ui-core/rendering/intro-header.js.map +1 -1
  251. package/dist/ui-core/rendering/theme.d.ts +1 -0
  252. package/dist/ui-core/rendering/theme.js +2 -0
  253. package/dist/ui-core/rendering/theme.js.map +1 -1
  254. package/dist/ui-core/state/transcript-hydrate.js +3 -3
  255. package/dist/ui-core/state/transcript-hydrate.js.map +1 -1
  256. package/dist/ui-core/state/transcript-reducer.d.ts +5 -0
  257. package/dist/ui-core/state/transcript-reducer.js +76 -3
  258. package/dist/ui-core/state/transcript-reducer.js.map +1 -1
  259. package/dist/ui-core/state/transcript-store.d.ts +10 -1
  260. package/dist/ui-core/state/transcript-store.js +22 -1
  261. package/dist/ui-core/state/transcript-store.js.map +1 -1
  262. package/dist/ui-core/state/transcript-types.d.ts +2 -0
  263. package/dist/ui-core/state/transcript-types.js.map +1 -1
  264. package/dist/version.generated.d.ts +2 -2
  265. package/dist/version.generated.js +2 -2
  266. package/package.json +6 -1
@@ -1,6 +1,7 @@
1
- import chalk from "chalk";
2
1
  import { join } from "node:path";
3
2
  import { streamWithProvider } from "../llm/router.js";
3
+ import { operationUsageFromError } from "../llm/operation-ledger.js";
4
+ import { contextAttemptFromOperationUsage } from "../llm/context-snapshot.js";
4
5
  import { modelContextWindow } from "../llm/token-usage.js";
5
6
  import { streamAlreadyEmitted } from "../llm/stream-progress.js";
6
7
  import { classifyStreamFailure, planStreamRecovery, recordRecoveryAttempt, createStreamRecoveryState, resetStreamRecoveryState, } from "./stream-recovery.js";
@@ -41,21 +42,26 @@ import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, }
41
42
  import { getToolDefinitions, getCompactToolDefinitions, RUNNER_META_TOOL_NAMES, } from "../tools/definitions.js";
42
43
  import { elidedStubReuseMessage, findElidedStubArg, } from "./message-slim.js";
43
44
  import { appendAssistantWithTools, ensureUniqueToolCallIds, toolCallIdsInHistory, appendToolResult, assertValidToolProtocol, fillMissingToolResults, repairToolProtocol, } from "./tool-history.js";
45
+ import { legacyReasoningBlockFromArtifacts, reasoningArtifactsForPersistence, } from "../llm/reasoning-artifacts.js";
44
46
  import { compactMessagesWithSummary, shouldApplyAutoCompact, COMPACTION_MEMORY_PREFIX, PLAN_IMPLEMENT_MEMORY_PREFIX, isCompactionMemoryMessage, } from "./context-manager.js";
45
47
  import { buildContextBreakdown, contextBreakdownAuditPayload, describeDominantContextBlock, toolSchemaHash, } from "./context-breakdown.js";
48
+ import { recordRequestTokenObservation } from "../llm/token-estimate-calibration.js";
49
+ import { accountAssembledRequest, RequestOverLimitError, } from "./request-accounting.js";
46
50
  import { autoCompactTriggerTokens, dedupeToolContextOutput, freeTierGuardNotices, getReliabilityPolicy, resolveStepMaxTokens, } from "./reliability-policy.js";
47
51
  import { auditLog } from "../store/logs.js";
48
52
  import { loadProjectContext } from "../store/project.js";
49
53
  import { loadScope, isScopeActive } from "../store/scope.js";
50
54
  import { ensureProviderConfigured } from "../commands/providers.js";
51
- import { createThinkingStreamParser, rememberThinkingFromText, stripThinking, } from "../ui/thinking.js";
52
- import { hasReasoningMarker, REASONING_CLOSE, REASONING_OPEN, } from "../llm/reasoning-marker.js";
55
+ import { createThinkingStreamParser, rememberThinking, stripThinking, } from "../ui/thinking.js";
56
+ import { hasReasoningMarker } from "../llm/reasoning-marker.js";
53
57
  import { safeCwd } from "../os/cwd.js";
54
58
  import { analyzeTask, formatTaskAnalysisHint, isNarrowExplicitNmapOperation, } from "./task-analyzer.js";
55
59
  import { computeMaxIterations, computeStepBudget } from "./step-budget.js";
56
60
  import { isScratchOnlyWrite } from "./scratch-write.js";
57
61
  import { buildDurableEnvelope, WorkLedger, } from "./durable-envelope.js";
58
- import { buildCompactionRetryPrompt, compactionSinglePassInputBudget, COMPACTION_SYSTEM_PROMPT, COMPACTION_MAX_COMPLETION_TOKENS, COMPACTION_MAP_MAX_COMPLETION_TOKENS, isCompactionCompletionTruncated, looksLikeIncompleteCompactionSummary, looksLikeTranscriptReplay, normalizeCompactionSummary, } from "./compaction-summary.js";
62
+ import { buildDirectCompactionPrompt, compactionSinglePassInputBudget, COMPACTION_SYSTEM_PROMPT, COMPACTION_MAX_COMPLETION_TOKENS, COMPACTION_MAP_MAX_COMPLETION_TOKENS, } from "./compaction-summary.js";
63
+ import { executeCompactionSummary, planCompactionReplay, } from "./compaction-executor.js";
64
+ import { isOperationPolicyError, OperationLedger, singleAdmissionOperationPolicy, } from "../llm/operation-ledger.js";
59
65
  import { maybeAppendPlanModeReminder, PLAN_REMINDER_TOAST, } from "./plan-mode-reminders.js";
60
66
  import { LoopGuard } from "./loop-guard.js";
61
67
  import { appendInterruptedReasoning, interruptedReasoningBrief, isMeaningfulResumptionYield, } from "./interrupted-reasoning.js";
@@ -63,7 +69,7 @@ import { CompactionAttemptLedger, compactionAttemptKey, } from "./compaction-att
63
69
  import { loadPlan, mutatePlan, markTask, appendPlanTask, readyPlanTasks, foregroundRemaining, responderOpenTasks, isPlanTerminal, isPlanSuccessful, } from "../store/plan.js";
64
70
  import { stat } from "node:fs/promises";
65
71
  import { isOutsideWorkingDirectory, resolveFsToolPath, } from "../tools/fs.js";
66
- import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, salvageTruncatedWriteFromNative, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, localHttpProbeIsFailure, localHttpProbeIsSuccess, buildWorkflowDirective, narrowNmapOperationDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
72
+ import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, salvageTruncatedWriteFromNative, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, localHttpProbeIsFailure, localHttpProbeIsSuccess, buildWorkflowDirective, narrowNmapOperationDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, looksLikePromptLeak, } from "./tool-call-parser.js";
67
73
  import { createSessionPolicy, isPreApprovalAllowedTool, isPlanModeAllowedShellCommand, isPlanModeAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
68
74
  import { saveToolOutput, formatToolContext, } from "./tool-output-formatting.js";
69
75
  import { codingSessionFromContext, isProtocolPlaceholderOutput, } from "./progress-pause-policy.js";
@@ -91,9 +97,6 @@ import { createTurnOutcome, normalizeTurnOutcomeInput, renderTurnOutcome, } from
91
97
  import { beginEngagementAction, finishEngagementAction, recordEngagementCheckpoint, reconcileEngagementJob, openEngagement, saveEngagement, } from "../store/engagement.js";
92
98
  export * from "./tool-call-parser.js";
93
99
  export { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, shouldEnableImageOcr, } from "./session-policy.js";
94
- export function styleToolChatter(call, text) {
95
- return shouldDimToolChatter(call) ? chalk.dim(text) : text;
96
- }
97
100
  /**
98
101
  * A foreground task waits for a responder child only when the plan
99
102
  * Declares that dependency. Report titles carry no scheduling meaning: any
@@ -160,7 +163,6 @@ export async function runAgentTurn(prompt, options = {}) {
160
163
  ? options.mode
161
164
  : "agent";
162
165
  const isPlanMode = agentMode === "plan";
163
- const writesDirectly = !options.onEvent;
164
166
  const emit = (event) => options.onEvent?.(event);
165
167
  // Whether the CURRENT model iteration has already committed its visible
166
168
  // prose to the transcript with an `assistant-message` event. The recovery
@@ -246,15 +248,13 @@ export async function runAgentTurn(prompt, options = {}) {
246
248
  ...(options?.replace ? { replace: true } : {}),
247
249
  });
248
250
  };
249
- const writeToolCall = (id, call, rendered) => {
251
+ const writeToolCall = (id, call) => {
250
252
  emit({
251
253
  type: "tool-call",
252
254
  id,
253
255
  name: call.name,
254
256
  argsDisplay: formatToolArgs(call),
255
257
  });
256
- if (writesDirectly)
257
- process.stdout.write(rendered);
258
258
  };
259
259
  const writePlanUpdate = (plan) => {
260
260
  emit({ type: "plan-update", plan });
@@ -305,6 +305,7 @@ export async function runAgentTurn(prompt, options = {}) {
305
305
  summary,
306
306
  beforeTokens,
307
307
  afterTokens,
308
+ contextScope: "assembled-request",
308
309
  });
309
310
  };
310
311
  const writeCompactionFailed = (id, message, retainedTokens) => {
@@ -835,22 +836,39 @@ export async function runAgentTurn(prompt, options = {}) {
835
836
  }
836
837
  return text;
837
838
  };
838
- const pushAssistantHistory = (content) => {
839
+ const pushAssistantHistory = (content, reasoning) => {
839
840
  const cleaned = sanitizeAssistantText(hasReasoningMarker(content) ? stripThinking(content).visible : content);
840
841
  if (!visibleCommitted) {
841
842
  const prose = recoveryProse(cleaned);
842
843
  if (prose)
843
844
  writeAssistantMessage(prose);
844
845
  }
846
+ const persistedArtifacts = reasoningArtifactsForPersistence({
847
+ artifacts: reasoning?.reasoningArtifacts,
848
+ hasToolCalls: false,
849
+ });
850
+ const reasoningBlock = persistedArtifacts
851
+ ? legacyReasoningBlockFromArtifacts(persistedArtifacts)
852
+ : reasoning?.reasoningArtifacts
853
+ ? undefined
854
+ : reasoning?.reasoningBlock;
845
855
  messages.push({
846
856
  role: "assistant",
847
857
  content: cleaned.trim()
848
858
  ? cleaned
849
859
  : "[No visible assistant response was produced.]",
860
+ ...(reasoningBlock?.text || reasoningBlock?.items?.length
861
+ ? { reasoningBlock }
862
+ : {}),
863
+ ...(persistedArtifacts ? { reasoningArtifacts: persistedArtifacts } : {}),
850
864
  });
851
865
  };
852
866
  const loopGuard = new LoopGuard();
853
867
  let lastExactPromptTokens = 0;
868
+ // Uncalibrated estimate for the request currently in flight. Paired with the
869
+ // provider's reported prompt size below so the estimator learns this route's
870
+ // bias instead of permanently over-reporting it.
871
+ let dispatchedRawRequestTokens = 0;
854
872
  let consecutiveSynthesizedRounds = 0;
855
873
  const engagementPolicy = new EngagementPolicyEngine();
856
874
  const probeStateKey = (call) => {
@@ -1137,6 +1155,7 @@ export async function runAgentTurn(prompt, options = {}) {
1137
1155
  let stepMaxTokens = 0;
1138
1156
  let nextToolEventId = 0;
1139
1157
  const alreadyPrintedIds = new Set();
1158
+ const executedWireOccurrences = new Map();
1140
1159
  const promptMutex = {
1141
1160
  promise: Promise.resolve(),
1142
1161
  async acquire() {
@@ -1198,8 +1217,7 @@ export async function runAgentTurn(prompt, options = {}) {
1198
1217
  let call = normalizeToolCall(rawCall);
1199
1218
  const emitVisibleSyntheticReceipt = (result, summary) => {
1200
1219
  if (!alreadyPrintedIds.has(toolEventId)) {
1201
- writeToolCall(toolEventId, call, styleToolChatter(call, chalk.cyan(` ▶ ${call.name}`) +
1202
- chalk.gray(` ${formatToolArgs(call)}`)) + "\n");
1220
+ writeToolCall(toolEventId, call);
1203
1221
  alreadyPrintedIds.add(toolEventId);
1204
1222
  }
1205
1223
  emit({ type: "tool-start", id: toolEventId });
@@ -1267,8 +1285,7 @@ export async function runAgentTurn(prompt, options = {}) {
1267
1285
  // no execution, until the model re-issues the identical batch to confirm.
1268
1286
  if (call.name === "task.update" && batchRemindCalls.has(rawCall)) {
1269
1287
  if (!alreadyPrintedIds.has(toolEventId)) {
1270
- writeToolCall(toolEventId, call, styleToolChatter(call, chalk.cyan(` ▶ ${call.name}`) +
1271
- chalk.gray(` ${formatToolArgs(call)}`)) + "\n");
1288
+ writeToolCall(toolEventId, call);
1272
1289
  alreadyPrintedIds.add(toolEventId);
1273
1290
  }
1274
1291
  const result = { ok: false, output: batchReminderNote, exitCode: 1 };
@@ -1385,8 +1402,7 @@ export async function runAgentTurn(prompt, options = {}) {
1385
1402
  unreadResponderNotificationIds.delete(responderWakeNotificationId);
1386
1403
  }
1387
1404
  if (!alreadyPrintedIds.has(toolEventId)) {
1388
- writeToolCall(toolEventId, call, styleToolChatter(call, chalk.cyan(` ▶ ${call.name}`) +
1389
- chalk.gray(` ${formatToolArgs(call)}`)) + "\n");
1405
+ writeToolCall(toolEventId, call);
1390
1406
  alreadyPrintedIds.add(toolEventId);
1391
1407
  }
1392
1408
  const result = {
@@ -1446,8 +1462,7 @@ export async function runAgentTurn(prompt, options = {}) {
1446
1462
  if (!gate.ok) {
1447
1463
  writeNotice("warn", gate.reason);
1448
1464
  if (!alreadyPrintedIds.has(toolEventId)) {
1449
- writeToolCall(toolEventId, call, styleToolChatter(call, chalk.cyan(` ▶ ${call.name}`) +
1450
- chalk.gray(` ${formatToolArgs(call)}`)) + "\n");
1465
+ writeToolCall(toolEventId, call);
1451
1466
  alreadyPrintedIds.add(toolEventId);
1452
1467
  }
1453
1468
  const result = {
@@ -1523,8 +1538,7 @@ export async function runAgentTurn(prompt, options = {}) {
1523
1538
  pendingSessionStatePlan = planResult.plan;
1524
1539
  }
1525
1540
  if (!alreadyPrintedIds.has(toolEventId)) {
1526
- writeToolCall(toolEventId, call, styleToolChatter(call, chalk.cyan(` ▶ ${call.name}`) +
1527
- chalk.gray(` ${formatToolArgs(call)}`)) + "\n");
1541
+ writeToolCall(toolEventId, call);
1528
1542
  alreadyPrintedIds.add(toolEventId);
1529
1543
  }
1530
1544
  if (planResult.reminder && planResult.toast) {
@@ -1541,8 +1555,6 @@ export async function runAgentTurn(prompt, options = {}) {
1541
1555
  pendingSessionStatePlan = null;
1542
1556
  removePlanContextMessage(messages);
1543
1557
  emit({ type: "plan-cleared", sessionId: session.sessionId });
1544
- if (writesDirectly)
1545
- process.stdout.write(planResult.display);
1546
1558
  }
1547
1559
  const result = { ok: planResult.ok, output: planResult.modelNote };
1548
1560
  emitToolResult(toolEventId, result, planResult.modelNote);
@@ -1588,8 +1600,7 @@ export async function runAgentTurn(prompt, options = {}) {
1588
1600
  `Accept the plan (y/i or /implement) to switch to agent and execute.`;
1589
1601
  writeNotice("warn", reason);
1590
1602
  if (!alreadyPrintedIds.has(toolEventId)) {
1591
- writeToolCall(toolEventId, call, styleToolChatter(call, chalk.cyan(` ▶ ${call.name}`) +
1592
- chalk.gray(` ${formatToolArgs(call)}`)) + "\n");
1603
+ writeToolCall(toolEventId, call);
1593
1604
  alreadyPrintedIds.add(toolEventId);
1594
1605
  }
1595
1606
  const result = { ok: false, output: reason, exitCode: 1 };
@@ -1692,8 +1703,7 @@ export async function runAgentTurn(prompt, options = {}) {
1692
1703
  : `Scaffold was not run: the existing target${target ? ` at ${target}` : ""} is incomplete. Inspect and repair it before completing the scaffold task; do not retry the scaffolder into this non-empty directory.`;
1693
1704
  writeNotice("info", message);
1694
1705
  if (!alreadyPrintedIds.has(toolEventId)) {
1695
- writeToolCall(toolEventId, call, styleToolChatter(call, chalk.cyan(` ▶ ${call.name}`) +
1696
- chalk.gray(` ${formatToolArgs(call)}`)) + "\n");
1706
+ writeToolCall(toolEventId, call);
1697
1707
  alreadyPrintedIds.add(toolEventId);
1698
1708
  }
1699
1709
  const result = { ok: true, output: message, exitCode: 0 };
@@ -1708,8 +1718,7 @@ export async function runAgentTurn(prompt, options = {}) {
1708
1718
  }
1709
1719
  }
1710
1720
  if (!alreadyPrintedIds.has(toolEventId)) {
1711
- writeToolCall(toolEventId, call, styleToolChatter(call, chalk.cyan(` ▶ ${call.name}`) +
1712
- chalk.gray(` ${formatToolArgs(call)}`)) + "\n");
1721
+ writeToolCall(toolEventId, call);
1713
1722
  alreadyPrintedIds.add(toolEventId);
1714
1723
  }
1715
1724
  const scopeTarget = safeScopeTargetForToolCall(call);
@@ -2678,6 +2687,20 @@ export async function runAgentTurn(prompt, options = {}) {
2678
2687
  let lastCompactionMsgCount = 0;
2679
2688
  const compactionAttempts = new CompactionAttemptLedger();
2680
2689
  let activeCompactionId;
2690
+ let activeCompactionLedger;
2691
+ /**
2692
+ * The last successful main request exactly as dispatched. A compaction
2693
+ * that replays it (plus the messages appended since) keeps the entire
2694
+ * prior prompt as a strict prefix, so APC providers serve the compaction
2695
+ * request from cache instead of re-billing the whole context.
2696
+ */
2697
+ let lastSuccessfulRequestSnapshot = options.previousSuccessfulRequest;
2698
+ /**
2699
+ * Per-attempt replay decision made by maybeAutoCompact and read by
2700
+ * summarizeForCompaction. When undefined the legacy transcript-rendered
2701
+ * requests are used (no snapshot yet, or the replay would not fit).
2702
+ */
2703
+ let compactionReplaySnapshot;
2681
2704
  /** E5: identical tool bodies within this turn → pointer instead of re-append. */
2682
2705
  const toolResultHashes = new Map();
2683
2706
  /** E4: consecutive free-tier stream failures this turn. */
@@ -2697,109 +2720,59 @@ export async function runAgentTurn(prompt, options = {}) {
2697
2720
  const compactionTools = sourceMessages
2698
2721
  ? selectToolDefs(nativeToolsActive, useCompactSystemPrompt)
2699
2722
  : undefined;
2700
- const request = {
2723
+ // Cache-preserving replay: resend the last successful request verbatim
2724
+ // (same provider, model, tools, sampling and reasoning settings) with
2725
+ // only the new tail and the compaction instruction appended. Anything
2726
+ // else — a different system prompt, dropped tool schemas, a re-rendered
2727
+ // transcript — changes the first bytes of the prompt and throws away the
2728
+ // whole cached prefix.
2729
+ const replay = compactionReplaySnapshot;
2730
+ return executeCompactionSummary({
2701
2731
  provider,
2702
2732
  model,
2703
- messages: sourceMessages
2704
- ? [
2705
- ...sourceMessages,
2706
- { role: "user", content: summaryPrompt },
2707
- ]
2708
- : [
2709
- { role: "system", content: COMPACTION_SYSTEM_PROMPT },
2710
- { role: "user", content: summaryPrompt },
2711
- ],
2712
- temperature: 0.1,
2733
+ systemContent: COMPACTION_SYSTEM_PROMPT,
2734
+ prompt: summaryPrompt,
2713
2735
  maxTokens,
2714
- thinking: { enabled: false, effort: "none" },
2715
2736
  signal: options.signal,
2716
- allowModelFallback: true,
2717
- ...(compactionTools?.length
2737
+ ...(replay
2718
2738
  ? {
2719
- tools: compactionTools,
2720
- toolChoice: "none",
2739
+ baseRequest: replay,
2740
+ history: messages,
2741
+ ...(currentContextLimitTokens() !== undefined
2742
+ ? { contextLimitTokens: currentContextLimitTokens() }
2743
+ : {}),
2721
2744
  }
2745
+ : {
2746
+ ...(sourceMessages ? { sourceMessages } : {}),
2747
+ ...(compactionTools?.length ? { tools: compactionTools } : {}),
2748
+ }),
2749
+ ...(activeCompactionLedger
2750
+ ? { operation: activeCompactionLedger }
2722
2751
  : {}),
2723
- };
2724
- const runAttempt = async (attemptRequest, replace = false) => {
2725
- if (compactionId && replace) {
2726
- writeCompactionDelta(compactionId, "", true);
2727
- }
2728
- const parser = createThinkingStreamParser((text) => {
2729
- if (compactionId)
2730
- writeCompactionDelta(compactionId, text);
2731
- }, undefined, { remember: false });
2732
- const result = await streamWithProvider(attemptRequest, (token) => parser.push(token), { onStatus: () => undefined, maxRetries: 0 });
2733
- parser.finish();
2734
- return result;
2735
- };
2736
- const first = await runAttempt(request);
2737
- let visible = normalizeCompactionSummary(stripThinking(first.text).visible);
2738
- let retryReason;
2739
- if (isCompactionCompletionTruncated(first, maxTokens)) {
2740
- retryReason = "truncated";
2741
- }
2742
- else if (!visible) {
2743
- retryReason = "reasoning-only";
2744
- }
2745
- else if (looksLikeTranscriptReplay(visible)) {
2746
- retryReason = "replayed";
2747
- }
2748
- else if (looksLikeIncompleteCompactionSummary(visible)) {
2749
- retryReason = "incomplete";
2750
- }
2751
- if (retryReason) {
2752
- const retry = await runAttempt({
2753
- ...request,
2754
- messages: sourceMessages
2755
- ? [
2756
- ...sourceMessages,
2757
- {
2758
- role: "user",
2759
- content: buildCompactionRetryPrompt(summaryPrompt, retryReason),
2760
- },
2761
- ]
2762
- : [
2763
- {
2764
- role: "system",
2765
- content: `${COMPACTION_SYSTEM_PROMPT}\nReturn only a complete continuation-memory summary. Do not include analysis, reasoning, or <think> tags.`,
2766
- },
2767
- {
2768
- role: "user",
2769
- content: buildCompactionRetryPrompt(summaryPrompt, retryReason),
2770
- },
2771
- ],
2772
- temperature: 0,
2773
- maxTokens,
2774
- thinking: { enabled: false, effort: "none" },
2775
- allowModelFallback: true,
2776
- }, true);
2777
- if (isCompactionCompletionTruncated(retry, maxTokens)) {
2778
- throw new Error("compaction failed: model hit the summary output limit twice — original context retained");
2779
- }
2780
- visible = normalizeCompactionSummary(stripThinking(retry.text).visible);
2781
- if (!visible) {
2782
- throw new Error("compaction failed: model returned an empty summary");
2783
- }
2784
- if (looksLikeTranscriptReplay(visible)) {
2785
- throw new Error("compaction failed: model replayed the transcript twice — original context retained");
2786
- }
2787
- if (looksLikeIncompleteCompactionSummary(visible)) {
2788
- throw new Error("compaction failed: model returned an incomplete summary twice — original context retained");
2789
- }
2790
- }
2791
- return visible;
2752
+ qualityRetry: false,
2753
+ retryOnServerError: true,
2754
+ stream: true,
2755
+ onToken: compactionId
2756
+ ? (text, replace) => writeCompactionDelta(compactionId, text, replace)
2757
+ : undefined,
2758
+ });
2792
2759
  };
2793
2760
  /**
2794
- * Estimate the complete next model request, including attached native-tool
2795
- * schemas. This must match the request-context accounting used by the UI
2796
- * and audit trail: large schemas can otherwise push an actual request past
2797
- * the compaction threshold while message-only accounting says it is safe.
2761
+ * Estimate the complete next model request through the one serialized-
2762
+ * request accounting service, including attached native-tool schemas and
2763
+ * reasoning replay payloads. The same service owns the final pre-dispatch
2764
+ * fit check, so the trigger path and the dispatch path cannot disagree.
2798
2765
  */
2799
2766
  const estimateNextRequestTokens = (contextMessages) => {
2800
2767
  const { native } = resolveNativeTools(provider, model);
2801
2768
  const nextTools = selectToolDefs(native, useCompactSystemPrompt);
2802
- return buildContextBreakdown(contextMessages, nextTools).estimatedTotalTokens;
2769
+ return accountAssembledRequest({
2770
+ provider,
2771
+ model,
2772
+ messages: contextMessages,
2773
+ stream: true,
2774
+ ...(nextTools?.length ? { tools: nextTools } : {}),
2775
+ }).accounting.requestTokens;
2803
2776
  };
2804
2777
  /**
2805
2778
  * Canonical state that must survive compaction verbatim. Built from the
@@ -2851,7 +2824,8 @@ export async function runAgentTurn(prompt, options = {}) {
2851
2824
  });
2852
2825
  }
2853
2826
  async function maybeAutoCompact(reason, force = false) {
2854
- const beforeTokens = Math.max(estimateNextRequestTokens(messages), lastExactPromptTokens);
2827
+ const beforeRequestTokens = estimateNextRequestTokens(messages);
2828
+ const beforeTokens = Math.max(beforeRequestTokens, lastExactPromptTokens);
2855
2829
  const contextLimitTokens = currentContextLimitTokens();
2856
2830
  const compactTrigger = autoCompactTriggerTokens(getReliabilityPolicy(), {
2857
2831
  provider,
@@ -2879,6 +2853,34 @@ export async function runAgentTurn(prompt, options = {}) {
2879
2853
  return;
2880
2854
  const compactionId = `compact-${randomUUID().slice(0, 12)}`;
2881
2855
  activeCompactionId = compactionId;
2856
+ // Admissions: the pinned dispatch plus the executor's bounded error
2857
+ // retry. Still a hard cap — every admission re-sends the full prompt.
2858
+ const compactionLedger = new OperationLedger(singleAdmissionOperationPolicy("compaction", 3));
2859
+ activeCompactionLedger = compactionLedger;
2860
+ // Plan the cache-preserving replay up front: resend the last successful
2861
+ // request with the tail + instruction appended. When it fits, the direct
2862
+ // single pass is forced (the raw estimate gate would otherwise reject a
2863
+ // request that fits fine); when it does not, compaction falls back to
2864
+ // the legacy transcript-rendered requests so it still succeeds.
2865
+ const replaySnapshot = lastSuccessfulRequestSnapshot;
2866
+ const replayPlan = replaySnapshot
2867
+ ? planCompactionReplay({
2868
+ baseRequest: replaySnapshot,
2869
+ history: messages,
2870
+ prompt: buildDirectCompactionPrompt({
2871
+ ...(durableEnvelope ? { durableState: durableEnvelope } : {}),
2872
+ }),
2873
+ maxTokens: COMPACTION_MAX_COMPLETION_TOKENS,
2874
+ ...(contextLimitTokens !== undefined
2875
+ ? { contextLimitTokens }
2876
+ : { contextLimitTokens: modelContextWindow(model, provider) }),
2877
+ stream: true,
2878
+ })
2879
+ : undefined;
2880
+ compactionReplaySnapshot =
2881
+ replayPlan && !replayPlan.accounting.overLimit
2882
+ ? replaySnapshot
2883
+ : undefined;
2882
2884
  writeCompactionStarted(compactionId, beforeTokens);
2883
2885
  try {
2884
2886
  const compactionTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
@@ -2886,6 +2888,10 @@ export async function runAgentTurn(prompt, options = {}) {
2886
2888
  const result = await compactMessagesWithSummary(messages, summarizeForCompaction, {
2887
2889
  budgetTokens: 0,
2888
2890
  keepRecent: AUTO_COMPACT_KEEP_RECENT,
2891
+ singleAdmission: true,
2892
+ ...(compactionReplaySnapshot
2893
+ ? { forceDirectSinglePass: true }
2894
+ : {}),
2889
2895
  singlePassInputBudgetTokens: Math.max(0, compactionSinglePassInputBudget(contextLimitTokens ?? modelContextWindow(model, provider)) - compactionSchemaTokens),
2890
2896
  ...(durableEnvelope ? { durableEnvelope } : {}),
2891
2897
  });
@@ -2917,18 +2923,48 @@ export async function runAgentTurn(prompt, options = {}) {
2917
2923
  writeCompactionFailed(compactionId, `Summary remained over the context limit; largest block: ${dominant}.`, beforeTokens);
2918
2924
  return;
2919
2925
  }
2920
- messages.splice(0, messages.length, ...result.messages);
2921
- compactionAttempts.recordSuccess(attemptKey);
2922
- loopGuard.resetReadOnly();
2923
- lastExactPromptTokens = 0;
2924
- // Token stats use the same complete request estimate as the trigger.
2925
- const compactedTokens = estimateNextRequestTokens(messages);
2926
- // Re-inject the live plan so the model keeps full plan awareness even
2927
- // after older turns (which carried the plan context) were summarized.
2926
+ // Re-inject the live plan on a candidate copy first so the commit is
2927
+ // validated against the complete next request, not the bare summary.
2928
2928
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
2929
+ const candidateMessages = [...result.messages];
2929
2930
  if (livePlan) {
2930
- upsertPlanContextMessage(messages, planContextMessage(livePlan, session.planApproved.value));
2931
+ upsertPlanContextMessage(candidateMessages, planContextMessage(livePlan, session.planApproved.value));
2932
+ }
2933
+ if (contextLimitTokens !== undefined) {
2934
+ const finalFit = accountAssembledRequest({
2935
+ provider,
2936
+ model,
2937
+ messages: candidateMessages,
2938
+ stream: true,
2939
+ ...(selectToolDefs(nativeToolsActive, useCompactSystemPrompt)?.length
2940
+ ? {
2941
+ tools: selectToolDefs(nativeToolsActive, useCompactSystemPrompt),
2942
+ }
2943
+ : {}),
2944
+ contextLimitTokens,
2945
+ });
2946
+ if (finalFit.accounting.overLimit) {
2947
+ const dominant = describeDominantContextBlock(candidateMessages);
2948
+ compactionAttempts.recordFailure(attemptKey);
2949
+ await auditLog("agent.compact.overflow", {
2950
+ reason,
2951
+ candidateTokens: finalFit.accounting.requestTokens,
2952
+ safeLimit: finalFit.accounting.limit.effectiveSafeTokens,
2953
+ trigger: compactTrigger,
2954
+ dominant,
2955
+ });
2956
+ writeNotice("warn", `compacted request would still exceed the effective safe context limit (~${finalFit.accounting.requestTokens.toLocaleString()} > ~${(finalFit.accounting.limit.effectiveSafeTokens ?? 0).toLocaleString()} tokens) — largest block: ${dominant}; run /compact or trim large outputs`);
2957
+ writeCompactionFailed(compactionId, `Compacted request would not fit the effective safe context limit; largest block: ${dominant}.`, beforeTokens);
2958
+ return;
2959
+ }
2931
2960
  }
2961
+ messages.splice(0, messages.length, ...candidateMessages);
2962
+ compactionAttempts.recordSuccess(attemptKey);
2963
+ loopGuard.resetReadOnly();
2964
+ lastExactPromptTokens = 0;
2965
+ // The snapshot predates the rewrite: replaying it would resurrect the
2966
+ // pre-compaction history. The next successful request re-seeds it.
2967
+ lastSuccessfulRequestSnapshot = undefined;
2932
2968
  // Re-inject live SESSION STATE after compaction (older flags survive).
2933
2969
  refreshSessionState(livePlan);
2934
2970
  lastCompactionMsgCount = messages.length;
@@ -2938,6 +2974,8 @@ export async function runAgentTurn(prompt, options = {}) {
2938
2974
  newLength: messages.length,
2939
2975
  estimatedTokens: afterTokens,
2940
2976
  reason,
2977
+ strategy: result.strategy ?? "single",
2978
+ compactionAdmissions: compactionLedger.snapshot().attempts.length,
2941
2979
  });
2942
2980
  const insertedSummary = messages.find((m) => isCompactionMemoryMessage(m))?.content ?? "";
2943
2981
  const summaryText = insertedSummary.startsWith(`${PLAN_IMPLEMENT_MEMORY_PREFIX}\n\n`)
@@ -2950,11 +2988,15 @@ export async function runAgentTurn(prompt, options = {}) {
2950
2988
  // Report the final assembled request, including live plan and session
2951
2989
  // state reinjection, so the card and the next provider request agree.
2952
2990
  writeCompactionCompleted(compactionId, summaryText, beforeTokens, afterTokens);
2953
- writeNotice("info", `context auto-compacted to fit the window (~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens)`);
2991
+ writeNotice("info", `context auto-compacted to fit the window (~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens)${result.strategy === "emergency_prefix_slice" ? " — oldest slice only (lower confidence); run /compact for a full summary" : ""}`);
2954
2992
  }
2955
2993
  catch (error) {
2956
2994
  const message = error instanceof Error ? error.message : String(error);
2957
- writeCompactionFailed(compactionId, /aborted/i.test(message) ? "Compaction was cancelled." : message, beforeTokens);
2995
+ writeCompactionFailed(compactionId, /aborted/i.test(message)
2996
+ ? "Compaction was cancelled."
2997
+ : isOperationPolicyError(error)
2998
+ ? "Compaction is limited to one pinned request (plus its bounded retry) and none completed; the original context was retained."
2999
+ : message, beforeTokens);
2958
3000
  if (error instanceof Error &&
2959
3001
  (error.name === "AbortError" || error.message.includes("aborted"))) {
2960
3002
  throw error;
@@ -2965,6 +3007,10 @@ export async function runAgentTurn(prompt, options = {}) {
2965
3007
  finally {
2966
3008
  if (activeCompactionId === compactionId)
2967
3009
  activeCompactionId = undefined;
3010
+ if (activeCompactionLedger === compactionLedger) {
3011
+ activeCompactionLedger = undefined;
3012
+ }
3013
+ compactionReplaySnapshot = undefined;
2968
3014
  }
2969
3015
  }
2970
3016
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
@@ -2997,6 +3043,8 @@ export async function runAgentTurn(prompt, options = {}) {
2997
3043
  let emittedThinkingStatus = false;
2998
3044
  let generatedTokens = 0;
2999
3045
  let accumulatedText = "";
3046
+ let streamedReasoningText = "";
3047
+ let typedReasoningOpen = false;
3000
3048
  const callIds = [];
3001
3049
  let streamedCallsCount = 0;
3002
3050
  // A model can think silently for minutes. Without a heartbeat the UI
@@ -3044,11 +3092,6 @@ export async function runAgentTurn(prompt, options = {}) {
3044
3092
  const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
3045
3093
  toolsAttached = Boolean(turnTools?.length);
3046
3094
  const contextBreakdown = buildContextBreakdown(messages, toolsAttached ? turnTools : undefined);
3047
- emit({
3048
- type: "context-estimate",
3049
- estimatedTokens: contextBreakdown.estimatedTotalTokens,
3050
- model,
3051
- });
3052
3095
  // E4: advisory only — never blocks free-tier users.
3053
3096
  if (!freeTierLargeContextWarned) {
3054
3097
  const notices = freeTierGuardNotices({
@@ -3084,6 +3127,7 @@ export async function runAgentTurn(prompt, options = {}) {
3084
3127
  toolsAttached,
3085
3128
  recoveryNudge: retryWithoutThinking,
3086
3129
  truncationDepth: truncatedBudgetRounds,
3130
+ thinkingEnabled: Boolean(config.thinking?.enabled) && !retryWithoutThinking,
3087
3131
  }),
3088
3132
  });
3089
3133
  // Resume / mid-turn abort can leave orphan tool rows or a user
@@ -3099,8 +3143,44 @@ export async function runAgentTurn(prompt, options = {}) {
3099
3143
  toolsAttached,
3100
3144
  recoveryNudge: retryWithoutThinking,
3101
3145
  truncationDepth: truncatedBudgetRounds,
3146
+ thinkingEnabled: Boolean(config.thinking?.enabled) && !retryWithoutThinking,
3102
3147
  });
3103
3148
  try {
3149
+ // MR-007: the fit verdict is taken on the final assembled request —
3150
+ // after protocol repair and every live-state reinjection — and a
3151
+ // request that cannot fit the effective safe limit never dispatches.
3152
+ const finalAccounting = accountAssembledRequest({
3153
+ provider,
3154
+ model,
3155
+ messages,
3156
+ stream: true,
3157
+ ...(toolsAttached && turnTools?.length
3158
+ ? { tools: turnTools, toolChoice: "auto", parallelToolCalls: true }
3159
+ : {}),
3160
+ ...(contextLimitTokens !== undefined ? { contextLimitTokens } : {}),
3161
+ }).accounting;
3162
+ dispatchedRawRequestTokens = finalAccounting.rawRequestTokens;
3163
+ // The chip reports the request that is actually about to be sent, from
3164
+ // the same accounting the fit gate and the compaction card use, so the
3165
+ // three can never disagree.
3166
+ emit({
3167
+ type: "context-estimate",
3168
+ estimatedTokens: finalAccounting.requestTokens,
3169
+ model,
3170
+ });
3171
+ if (finalAccounting.overLimit) {
3172
+ await auditLog("agent.request.over-limit-blocked", {
3173
+ provider,
3174
+ model,
3175
+ estimatedTokens: finalAccounting.requestTokens,
3176
+ effectiveSafeTokens: finalAccounting.limit.effectiveSafeTokens,
3177
+ limitSource: finalAccounting.limit.source,
3178
+ reservedOutputTokens: finalAccounting.limit.reservedOutputTokens,
3179
+ safetyMarginTokens: finalAccounting.limit.safetyMarginTokens,
3180
+ });
3181
+ writeNotice("warn", `estimated request (~${finalAccounting.requestTokens.toLocaleString()} tokens) exceeds the model's safe context window (~${finalAccounting.limit.effectiveSafeTokens?.toLocaleString()} tokens) — run /compact, trim large outputs, or raise the session context limit`);
3182
+ throw new RequestOverLimitError(`estimated request (~${finalAccounting.requestTokens.toLocaleString()} tokens) exceeds the effective safe context limit (~${finalAccounting.limit.effectiveSafeTokens?.toLocaleString()} tokens); dispatch blocked`);
3183
+ }
3104
3184
  if (responderDelivery &&
3105
3185
  !jobManager.markDeliveryStarted(responderDelivery.id, session.sessionId)) {
3106
3186
  jobManager.releaseResponderNotificationClaim(responderDelivery.id);
@@ -3155,35 +3235,59 @@ export async function runAgentTurn(prompt, options = {}) {
3155
3235
  deferredToolCalls.push({
3156
3236
  eventId,
3157
3237
  call,
3158
- rendered: "",
3159
3238
  shown: true,
3160
3239
  });
3161
- writeToolCall(eventId, call, styleToolChatter(call, chalk.cyan(` ▶ ${call.name}`) +
3162
- chalk.gray(` ${formatToolArgs(call)}`)) + "\n");
3240
+ writeToolCall(eventId, call);
3163
3241
  emit({ type: "status", text: call.name });
3164
3242
  streamedCallsCount += 1;
3165
3243
  }
3166
3244
  }
3167
3245
  }
3246
+ if (typedReasoningOpen) {
3247
+ typedReasoningOpen = false;
3248
+ inThinking = false;
3249
+ generatedTokens = 0;
3250
+ }
3168
3251
  if (!sawReasoning &&
3169
- (token.includes(REASONING_OPEN) ||
3170
- /^\s*<think(?:ing)?\b/i.test(accumulatedText))) {
3252
+ /^\s*<think(?:ing)?\b/i.test(accumulatedText)) {
3171
3253
  sawReasoning = true;
3172
3254
  inThinking = true;
3173
3255
  emit({ type: "status", text: "thinking" });
3174
3256
  }
3175
- if (token.includes(REASONING_CLOSE) ||
3176
- (inThinking && /<\/think(?:ing)?>/i.test(token))) {
3257
+ if (inThinking && /<\/think(?:ing)?>/i.test(token)) {
3177
3258
  inThinking = false;
3178
3259
  generatedTokens = 0;
3179
3260
  }
3180
- }, (status) => {
3181
- writeStatus(status);
3182
- // Toast only on key *switch* after a failure — never on sticky
3183
- // "using" or retry countdown ticks (those stay in composer status).
3184
- if (/^switching /i.test(status.trim())) {
3185
- writeNotice("warn", status.trim());
3186
- }
3261
+ }, {
3262
+ onStatus: (status) => {
3263
+ writeStatus(status);
3264
+ // Toast only on key *switch* after a failure never on sticky
3265
+ // "using" or retry countdown ticks (those stay in composer status).
3266
+ if (/^switching /i.test(status.trim())) {
3267
+ writeNotice("warn", status.trim());
3268
+ }
3269
+ },
3270
+ onStreamEvent: (event) => {
3271
+ if (event.type !== "reasoning_delta")
3272
+ return;
3273
+ streamedReasoningText += event.text;
3274
+ typedReasoningOpen = true;
3275
+ sawReasoning = true;
3276
+ inThinking = true;
3277
+ generatedTokens += 1;
3278
+ if (!emittedThinkingStatus) {
3279
+ emittedThinkingStatus = true;
3280
+ emit({ type: "status", text: "thinking" });
3281
+ }
3282
+ emit({ type: "thinking-delta", text: event.text });
3283
+ },
3284
+ // Capture every successful dispatch locally as well: an
3285
+ // auto-compaction later in this turn replays the exact request
3286
+ // so the whole prior prompt stays a cached prefix.
3287
+ onSuccessfulRequest: (snapshot) => {
3288
+ lastSuccessfulRequestSnapshot = snapshot;
3289
+ options.onSuccessfulRequest?.(snapshot);
3290
+ },
3187
3291
  });
3188
3292
  freeTierConsecutiveFailures = 0;
3189
3293
  // Stream succeeded → the failure episode is over. Reset the recovery
@@ -3198,6 +3302,10 @@ export async function runAgentTurn(prompt, options = {}) {
3198
3302
  // User cancelled (double-Esc) — never try to recover, just stop.
3199
3303
  if (options.signal?.aborted)
3200
3304
  throw streamError;
3305
+ // A blocked over-limit request is a policy stop, not a route
3306
+ // failure — retrying it would just re-bill the same doomed prefix.
3307
+ if (streamError instanceof RequestOverLimitError)
3308
+ throw streamError;
3201
3309
  // E4: track free-tier failures for advisory notices (never blocks).
3202
3310
  freeTierConsecutiveFailures += 1;
3203
3311
  if (!freeTierAdvisoryShown) {
@@ -3220,8 +3328,32 @@ export async function runAgentTurn(prompt, options = {}) {
3220
3328
  // We only rethrow (stop the turn) in the worst case: every approach
3221
3329
  // for that failure class is exhausted or the total budget is spent.
3222
3330
  const failureKind = classifyStreamFailure(streamError);
3331
+ const failedOperationUsage = operationUsageFromError(streamError);
3332
+ const failedUsage = failedOperationUsage?.aggregate.usage;
3333
+ const failedAttempt = failedOperationUsage?.attempts.at(-1);
3334
+ if (failedUsage && failedAttempt) {
3335
+ emit({
3336
+ type: "token-usage",
3337
+ usage: failedUsage,
3338
+ model: failedAttempt.model,
3339
+ provider: failedAttempt.provider,
3340
+ });
3341
+ }
3223
3342
  const partialStream = streamAlreadyEmitted(streamError) || accumulatedText.length > 0;
3224
- const partial = rememberThinkingFromText(accumulatedText);
3343
+ const partialSplit = stripThinking(accumulatedText);
3344
+ const partialThinkContent = [
3345
+ streamedReasoningText.trim(),
3346
+ partialSplit.thinkContent,
3347
+ ]
3348
+ .filter(Boolean)
3349
+ .join("\n\n");
3350
+ if (partialThinkContent)
3351
+ rememberThinking(partialThinkContent);
3352
+ const partial = {
3353
+ visible: partialSplit.visible,
3354
+ hasThinking: partialThinkContent.length > 0,
3355
+ thinkContent: partialThinkContent,
3356
+ };
3225
3357
  const rawPartialVisible = partialStream
3226
3358
  ? textBeforeToolCall(stripThinking(collapseRepeatedText(accumulatedText)).visible)
3227
3359
  : "";
@@ -3346,12 +3478,20 @@ export async function runAgentTurn(prompt, options = {}) {
3346
3478
  if (completion.usage) {
3347
3479
  if (completion.usage.exact && completion.usage.promptTokens > 0) {
3348
3480
  lastExactPromptTokens = completion.usage.promptTokens;
3481
+ recordRequestTokenObservation({
3482
+ provider: completion.provider,
3483
+ model: completion.model,
3484
+ estimatedRequestTokens: dispatchedRawRequestTokens,
3485
+ actualPromptTokens: completion.usage.promptTokens,
3486
+ });
3349
3487
  }
3488
+ const attempt = contextAttemptFromOperationUsage(completion.operationUsage);
3350
3489
  emit({
3351
3490
  type: "token-usage",
3352
3491
  usage: completion.usage,
3353
3492
  model: completion.model,
3354
3493
  provider: completion.provider,
3494
+ ...(attempt.kind === "generation" ? { attempt } : {}),
3355
3495
  });
3356
3496
  // Cache telemetry: without read/create counts there is no way to tell
3357
3497
  // whether the stable prefix is actually being reused.
@@ -3378,7 +3518,21 @@ export async function runAgentTurn(prompt, options = {}) {
3378
3518
  // fallback dropped tools, treat as text mode for this turn's parse.
3379
3519
  const usedNativeProtocol = Boolean(completion.toolCalls?.length) ||
3380
3520
  (toolsAttached && !isTextOnlyModel(provider, model));
3381
- const assistantTextResult = rememberThinkingFromText(completion.text);
3521
+ const completionSplit = stripThinking(completion.text);
3522
+ const completionThinkContent = [
3523
+ streamedReasoningText.trim() ||
3524
+ (completion.reasoningBlock?.text ?? "").trim(),
3525
+ completionSplit.thinkContent,
3526
+ ]
3527
+ .filter(Boolean)
3528
+ .join("\n\n");
3529
+ if (completionThinkContent)
3530
+ rememberThinking(completionThinkContent);
3531
+ const assistantTextResult = {
3532
+ visible: completionSplit.visible,
3533
+ hasThinking: completionThinkContent.length > 0,
3534
+ thinkContent: completionThinkContent,
3535
+ };
3382
3536
  const continuedVisible = trimExactContinuationOverlap(interruptedVisible, assistantTextResult.visible);
3383
3537
  canonicalAssistantVisible = interruptedVisible + continuedVisible;
3384
3538
  assistantText = {
@@ -3402,7 +3556,7 @@ export async function runAgentTurn(prompt, options = {}) {
3402
3556
  });
3403
3557
  }
3404
3558
  }
3405
- pushAssistantHistory(historyText);
3559
+ pushAssistantHistory(historyText, completion);
3406
3560
  interruptedVisible = "";
3407
3561
  interruptedReasoning = "";
3408
3562
  lowYieldResumptions = 0;
@@ -3425,7 +3579,6 @@ export async function runAgentTurn(prompt, options = {}) {
3425
3579
  deferredToolCalls.push({
3426
3580
  eventId,
3427
3581
  call: normalized,
3428
- rendered: "",
3429
3582
  shown: false,
3430
3583
  });
3431
3584
  }
@@ -3449,7 +3602,6 @@ export async function runAgentTurn(prompt, options = {}) {
3449
3602
  const entry = {
3450
3603
  eventId,
3451
3604
  call: normalized,
3452
- rendered: "",
3453
3605
  shown: existing?.shown ?? false,
3454
3606
  };
3455
3607
  if (existing)
@@ -3524,7 +3676,7 @@ export async function runAgentTurn(prompt, options = {}) {
3524
3676
  appendAssistantWithTools(messages, assistantText.visible, salvageHistoryCalls, completion.reasoningBlock ??
3525
3677
  (assistantText.hasThinking && assistantText.thinkContent
3526
3678
  ? { text: assistantText.thinkContent }
3527
- : undefined));
3679
+ : undefined), completion.reasoningArtifacts);
3528
3680
  for (const tc of salvageHistoryCalls) {
3529
3681
  appendToolResult(messages, tc.id, tc.id === salvagedCallId
3530
3682
  ? `Tool ${tc.name} result (exit=0, ok=true):\nSalvaged partial write: ${lineCount} lines to ${salvaged.path}`
@@ -3607,6 +3759,26 @@ export async function runAgentTurn(prompt, options = {}) {
3607
3759
  "Finish briefly: emit the next tool call, or the final answer if the task is complete."));
3608
3760
  continue;
3609
3761
  }
3762
+ if (hitOutputLimit &&
3763
+ !truncatedRoundText.trim() &&
3764
+ assistantText.hasThinking &&
3765
+ truncatedBudgetRounds < 4) {
3766
+ truncatedBudgetRounds += 1;
3767
+ interruptedReasoning = appendInterruptedReasoning(interruptedReasoning, assistantText.thinkContent);
3768
+ const preservedBudgetReasoning = interruptedReasoning;
3769
+ writeNotice("warn", "reasoning used the whole output budget — preserving it and widening the budget");
3770
+ commitAssistantRetry(assistantText.visible);
3771
+ interruptedReasoning = preservedBudgetReasoning;
3772
+ messages.push(recoveryUserMessage([
3773
+ "Your previous response spent the entire output budget on reasoning and was cut off before any visible answer. " +
3774
+ "Do not restart the analysis — your conclusions so far are preserved below. " +
3775
+ "Wrap up the reasoning now and emit the next tool call or the final answer directly.",
3776
+ interruptedReasoningBrief(interruptedReasoning),
3777
+ ]
3778
+ .filter((part) => Boolean(part))
3779
+ .join("\n\n")));
3780
+ continue;
3781
+ }
3610
3782
  const incompleteNativeStream = nativeToolCalls.length === 0 &&
3611
3783
  streamedNativeCallNames.size > 0;
3612
3784
  if (incompleteNativeStream) {
@@ -3622,16 +3794,22 @@ export async function runAgentTurn(prompt, options = {}) {
3622
3794
  emptyVisibleRetries += 1;
3623
3795
  if (emptyVisibleRetries <= 3) {
3624
3796
  if (assistantText.hasThinking) {
3625
- writeNotice("warn", "model produced only thinking — nudging it to take action");
3797
+ writeNotice("warn", "model produced only thinking — preserving the reasoning and nudging it to act");
3626
3798
  }
3627
3799
  else {
3628
3800
  writeNotice("warn", "model returned an empty response — nudging it to answer");
3629
3801
  }
3630
- if (assistantText.hasThinking)
3802
+ if (assistantText.hasThinking) {
3803
+ interruptedReasoning = appendInterruptedReasoning(interruptedReasoning, assistantText.thinkContent);
3804
+ }
3805
+ const preservedReasoning = interruptedReasoning;
3806
+ if (assistantText.hasThinking && emptyVisibleRetries >= 2) {
3631
3807
  retryWithoutThinking = true;
3808
+ }
3632
3809
  commitAssistantRetry(assistantText.visible);
3810
+ interruptedReasoning = preservedReasoning;
3633
3811
  // Keep nudges SHORT — cheap models lose the key instruction in long text.
3634
- const buildNudge = incompleteNativeStream
3812
+ const baseNudge = incompleteNativeStream
3635
3813
  ? "Your native tool call was incomplete, so nothing ran. Use exactly one complete fenced ```tool block now; do not repeat the incomplete native call."
3636
3814
  : isPlanMode && !activePlan
3637
3815
  ? toolsAttached
@@ -3642,6 +3820,17 @@ export async function runAgentTurn(prompt, options = {}) {
3642
3820
  ? "No visible output. " + toolNudge(true)
3643
3821
  : "No visible output. Emit a ```tool block or give your final answer. " +
3644
3822
  "Do NOT hide tool calls in <think> tags — put them in the visible response.";
3823
+ const reasoningBrief = assistantText.hasThinking
3824
+ ? interruptedReasoningBrief(interruptedReasoning)
3825
+ : undefined;
3826
+ const buildNudge = reasoningBrief
3827
+ ? "Your previous response contained only reasoning and no visible answer or tool call. " +
3828
+ "Do not restart the analysis — your reasoning so far is preserved below. " +
3829
+ "Build on it and act now: emit the next tool call or the final answer.\n\n" +
3830
+ reasoningBrief +
3831
+ "\n\n" +
3832
+ baseNudge
3833
+ : baseNudge;
3645
3834
  messages.push(recoveryUserMessage(buildNudge));
3646
3835
  continue;
3647
3836
  }
@@ -3653,6 +3842,7 @@ export async function runAgentTurn(prompt, options = {}) {
3653
3842
  emptyVisibleRetries = 0;
3654
3843
  truncatedBudgetRounds = 0;
3655
3844
  retryWithoutThinking = false;
3845
+ interruptedReasoning = "";
3656
3846
  }
3657
3847
  let bareArgsOnly = false;
3658
3848
  recoveredFromBareJson = false;
@@ -4010,7 +4200,7 @@ export async function runAgentTurn(prompt, options = {}) {
4010
4200
  },
4011
4201
  }
4012
4202
  : normalizeToolCall({ name: tc.name, args: tc.args });
4013
- return { index, id: tc.id, call, native: tc };
4203
+ return { index, id: tc.id, call, native: tc, wireId: tc.id };
4014
4204
  });
4015
4205
  }
4016
4206
  else {
@@ -4129,8 +4319,7 @@ export async function runAgentTurn(prompt, options = {}) {
4129
4319
  for (const { b, resultReason, result } of suppressedResults) {
4130
4320
  const queued = deferredToolCalls[b.index];
4131
4321
  const eventId = queued?.eventId ?? `tool-${++nextToolEventId}`;
4132
- writeToolCall(eventId, b.call, styleToolChatter(b.call, chalk.cyan(` ▶ ${b.call.name}`) +
4133
- chalk.gray(` ${formatToolArgs(b.call)}`)) + "\n");
4322
+ writeToolCall(eventId, b.call);
4134
4323
  alreadyPrintedIds.add(eventId);
4135
4324
  emit({ type: "tool-start", id: eventId });
4136
4325
  const output = resultReason.endsWith("\n")
@@ -4150,7 +4339,7 @@ export async function runAgentTurn(prompt, options = {}) {
4150
4339
  appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls, completion.reasoningBlock ??
4151
4340
  (assistantText.hasThinking && assistantText.thinkContent
4152
4341
  ? { text: assistantText.thinkContent }
4153
- : undefined));
4342
+ : undefined), completion.reasoningArtifacts);
4154
4343
  for (const { b, resultReason } of suppressedResults) {
4155
4344
  appendToolResult(messages, b.id, deniedContent(b, resultReason), b.call.name, false);
4156
4345
  }
@@ -4160,7 +4349,7 @@ export async function runAgentTurn(prompt, options = {}) {
4160
4349
  bound
4161
4350
  .map((b) => `\`\`\`tool\n${JSON.stringify(b.call)}\n\`\`\``)
4162
4351
  .join("\n\n");
4163
- pushAssistantHistory(standardizedContent);
4352
+ pushAssistantHistory(standardizedContent, completion);
4164
4353
  for (const { b, resultReason } of suppressedResults) {
4165
4354
  messages.push({ role: "tool", content: deniedContent(b, resultReason) });
4166
4355
  }
@@ -4213,31 +4402,24 @@ export async function runAgentTurn(prompt, options = {}) {
4213
4402
  formatToolArgs(finalCall) !== formatToolArgs(deferred.call));
4214
4403
  if (stale) {
4215
4404
  deferred.call = finalCall;
4216
- const refreshedLine = chalk.cyan(` ▶ ${finalCall.name}`) +
4217
- chalk.gray(` ${formatToolArgs(finalCall)}`);
4218
- deferred.rendered =
4219
- styleToolChatter(finalCall, refreshedLine) + "\n";
4220
4405
  }
4221
- if (!deferred.shown) {
4222
- writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
4406
+ if (!deferred.shown || stale) {
4407
+ writeToolCall(deferred.eventId, deferred.call);
4223
4408
  deferred.shown = true;
4224
4409
  }
4225
- else if (stale) {
4226
- writeToolCall(deferred.eventId, deferred.call, "");
4227
- }
4228
4410
  }
4229
4411
  if (historyNativeCalls.length) {
4230
4412
  appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls, completion.reasoningBlock ??
4231
4413
  (assistantText.hasThinking && assistantText.thinkContent
4232
4414
  ? { text: assistantText.thinkContent }
4233
- : undefined));
4415
+ : undefined), completion.reasoningArtifacts);
4234
4416
  }
4235
4417
  else {
4236
4418
  const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
4237
4419
  allCalls
4238
4420
  .map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
4239
4421
  .join("\n\n");
4240
- pushAssistantHistory(standardizedContent);
4422
+ pushAssistantHistory(standardizedContent, completion);
4241
4423
  }
4242
4424
  const scopeForBatch = await loadScope().catch(() => undefined);
4243
4425
  const isParallelSafe = (c) => {
@@ -4543,6 +4725,43 @@ export async function runAgentTurn(prompt, options = {}) {
4543
4725
  session.pendingTaskBatch.value = undefined;
4544
4726
  }
4545
4727
  }
4728
+ const replayExecutedOccurrence = (bc, uiId) => {
4729
+ const prior = bc.wireId
4730
+ ? executedWireOccurrences.get(bc.wireId)
4731
+ : undefined;
4732
+ if (!prior)
4733
+ return undefined;
4734
+ const notice = "This exact provider tool call already executed this turn. " +
4735
+ "The earlier result is replayed below; the tool did not run again.";
4736
+ const output = `${notice}\n\n${prior.contextOutput}`;
4737
+ const result = { ...prior.result, output, suppressedRepeat: true };
4738
+ if (!alreadyPrintedIds.has(uiId)) {
4739
+ writeToolCall(uiId, bc.call);
4740
+ alreadyPrintedIds.add(uiId);
4741
+ emit({ type: "tool-start", id: uiId });
4742
+ }
4743
+ writeToolOutput(uiId, output.endsWith("\n") ? output : `${output}\n`);
4744
+ emitToolResult(uiId, result, output);
4745
+ return {
4746
+ call: bc.call,
4747
+ result,
4748
+ contextOutput: output,
4749
+ ok: prior.ok,
4750
+ suppressedRepeat: true,
4751
+ };
4752
+ };
4753
+ const rememberExecutedOccurrence = (bc, res) => {
4754
+ if (!bc.wireId || executedWireOccurrences.has(bc.wireId))
4755
+ return;
4756
+ if (!res.ok || res.aborted || res.suppressedRepeat)
4757
+ return;
4758
+ executedWireOccurrences.set(bc.wireId, {
4759
+ call: res.call,
4760
+ result: res.result,
4761
+ contextOutput: res.contextOutput,
4762
+ ok: res.ok,
4763
+ });
4764
+ };
4546
4765
  const groups = groupToolCallsForExecution(allCalls, isParallelSafe, PARALLEL_LIMIT);
4547
4766
  for (const group of groups) {
4548
4767
  if (aborted || awaitingPlanApproval)
@@ -4556,8 +4775,14 @@ export async function runAgentTurn(prompt, options = {}) {
4556
4775
  callIds[bc.index] = `tool-${++nextToolEventId}`;
4557
4776
  }
4558
4777
  const id = callIds[bc.index];
4778
+ const replayed = replayExecutedOccurrence(bc, id);
4779
+ if (replayed) {
4780
+ recordResult(bc, replayed);
4781
+ continue;
4782
+ }
4559
4783
  const res = await executeSingleTool(call, id, options.signal || new AbortController().signal);
4560
4784
  recordResult(bc, res);
4785
+ rememberExecutedOccurrence(bc, res);
4561
4786
  }
4562
4787
  else {
4563
4788
  // Concurrent group — BoundCall via Map; record in document order.
@@ -4573,9 +4798,15 @@ export async function runAgentTurn(prompt, options = {}) {
4573
4798
  groupBound.push(bc);
4574
4799
  uiIds.push(callIds[bc.index]);
4575
4800
  }
4576
- const results = await Promise.all(groupBound.map((bc, k) => executeSingleTool(bc.call, uiIds[k], options.signal || new AbortController().signal)));
4801
+ const results = await Promise.all(groupBound.map((bc, k) => {
4802
+ const replayed = replayExecutedOccurrence(bc, uiIds[k]);
4803
+ if (replayed)
4804
+ return replayed;
4805
+ return executeSingleTool(bc.call, uiIds[k], options.signal || new AbortController().signal);
4806
+ }));
4577
4807
  for (let k = 0; k < results.length; k += 1) {
4578
4808
  recordResult(groupBound[k], results[k]);
4809
+ rememberExecutedOccurrence(groupBound[k], results[k]);
4579
4810
  }
4580
4811
  }
4581
4812
  }