mixdog 0.9.1 → 0.9.3

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 (301) hide show
  1. package/package.json +9 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -1,28 +1,23 @@
1
1
  import { classifyResultKind } from './result-classification.mjs';
2
2
  import { executeMcpTool, isMcpTool, mcpToolHasField } from '../mcp/client.mjs';
3
- import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool } from '../tools/builtin.mjs';
3
+ import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool, isExternalAdapterTool } from '../tools/builtin.mjs';
4
4
  import { executeBashSessionTool } from '../tools/bash-session.mjs';
5
- import { executePatchTool } from '../tools/patch.mjs';
5
+ import { executePatchTool, takeApplyPatchUiDiff } from '../tools/patch.mjs';
6
6
  import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
7
- import { collectSkillsCached, loadSkillContent } from '../context/collect.mjs';
8
- import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash } from '../agent-trace.mjs';
7
+ import { normalizeToolEnvelope, makeToolEnvelope } from './tool-envelope.mjs';
8
+ import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash, appendAgentTrace } from '../agent-trace.mjs';
9
+ import { resolveSessionMaxLoopIterations } from '../agent-runtime/agent-loop-policy.mjs';
9
10
  import { isAgentOwner } from '../agent-owner.mjs';
10
11
  import { markSessionToolCall, updateSessionStage, SessionClosedError, getSessionAbortSignal, enqueuePendingMessage, bumpUsageMetricsEpoch } from './manager.mjs';
11
- import { estimateMessagesTokens, estimateRequestReserveTokens, sanitizeToolPairs } from './context-utils.mjs';
12
12
  import {
13
13
  recallFastTrackCompactMessages,
14
14
  pruneToolOutputs,
15
+ pruneToolOutputsUnanchored,
15
16
  semanticCompactMessages,
16
- compactTypeIsRecallFastTrack,
17
- compactTypeIsSemantic,
18
- normalizeCompactType,
17
+ effectiveBudget as compactEffectiveBudget,
19
18
  DEFAULT_COMPACT_TYPE,
20
- DEFAULT_COMPACTION_BUFFER_TOKENS,
21
- DEFAULT_COMPACTION_BUFFER_RATIO,
22
- DEFAULT_COMPACTION_KEEP_TOKENS,
23
- compactionBufferTokensForBoundary,
24
- normalizeCompactionBufferRatio,
25
19
  drainSessionCycle1,
20
+ countRawPendingRows,
26
21
  } from './compact.mjs';
27
22
  import { isContextOverflowError } from '../providers/retry-classifier.mjs';
28
23
  import { stripSoftWarns } from '../tool-loop-guard.mjs';
@@ -33,26 +28,15 @@ import { modelVisibleToolCompletionMessage } from '../../../shared/tool-executio
33
28
  import { createHash } from 'crypto';
34
29
  import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
35
30
 
36
- // Tool-name classification for cross-turn read dedup.
37
- // Strips the MCP prefix so direct calls and MCP-wrapped calls share the
38
- // same cache.
39
- function _stripMcpPrefix(name) {
40
- return typeof name === 'string' && name.startsWith(MCP_TOOL_PREFIX)
41
- ? name.slice(MCP_TOOL_PREFIX.length) : name;
42
- }
43
- function _isReadTool(name) {
44
- return _stripMcpPrefix(name) === 'read';
45
- }
46
- function _isMutationTool(name) {
47
- const n = _stripMcpPrefix(name);
48
- return n === 'apply_patch';
49
- }
50
- const SCOPED_CACHEABLE_TOOLS = new Set([
51
- 'code_graph',
52
- 'grep',
53
- 'list',
54
- 'glob',
55
- ]);
31
+ import {
32
+ _stripMcpPrefix,
33
+ _isReadTool,
34
+ _isMutationTool,
35
+ _isScopedCacheableTool,
36
+ _isShellTool,
37
+ _intraTurnSig,
38
+ } from './loop/tool-classify.mjs';
39
+ import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
56
40
  let codeGraphRuntimePromise = null;
57
41
  async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
58
42
  codeGraphRuntimePromise ??= import('../tools/code-graph.mjs');
@@ -60,389 +44,105 @@ async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options
60
44
  if (typeof mod.executeCodeGraphTool !== 'function') throw new Error('code_graph runtime is not available');
61
45
  return mod.executeCodeGraphTool(name, args, cwd, signal, options);
62
46
  }
63
- function _isScopedCacheableTool(name) {
64
- const n = _stripMcpPrefix(name);
65
- return SCOPED_CACHEABLE_TOOLS.has(n);
66
- }
67
- function _isShellTool(name) {
68
- const n = _stripMcpPrefix(name);
69
- return n === 'shell' || n === 'bash_session';
70
- }
71
47
 
72
48
  // classifyResultKind is imported from result-classification.mjs at the top of
73
49
  // this file; import it from there directly rather than via this module.
74
-
75
- // Canonical signature for intra-turn duplicate detection. Sorting keys
76
- // produces a stable hash regardless of arg-object key order. Anything
77
- // non-serializable falls back to String(args) — still deterministic for
78
- // the model's typical structured-arg shape.
79
- function _canonicalArgs(args) {
80
- if (args == null || typeof args !== 'object') {
81
- try { return JSON.stringify(args); } catch { return String(args); }
82
- }
83
- try {
84
- const keys = Object.keys(args).sort();
85
- const sorted = {};
86
- for (const k of keys) sorted[k] = args[k];
87
- return JSON.stringify(sorted);
88
- } catch { return String(args); }
89
- }
90
- function _intraTurnSig(name, args) {
91
- return createHash('sha256').update(`${name}:${_canonicalArgs(args)}`).digest('hex').slice(0, 16);
92
- }
93
-
94
- // Shared pre-dispatch deny — single source of truth for the remaining
95
- // control-plane / role scoping rejects. Called by BOTH the eager dispatch
96
- // path (startEagerTool) and the serial dispatch path (executeTool body).
97
- // Returns null when the call is allowed to proceed; otherwise returns the
98
- // Error string the serial path would emit. The eager caller ignores the
99
- // message body and just treats non-null as "do not start eager".
100
- //
101
- // This is NOT a permission gate — runtime permission enforcement was removed
102
- // (every tool call is trusted). What remains is architectural scoping:
103
- // agent workers are sandboxed to code/research tools. They must never reach
104
- // owner/host control surfaces: session management, the ENTIRE channels module
105
- // (Discord messaging, schedules, webhook/config, channel-bridge toggle,
106
- // command injection), or host input injection. Explicit name list (no imports)
107
- // keeps this hot-path gate dependency-free; add new owner/channel tools here.
108
- const WORKER_DENIED_TOOLS = new Set([
109
- // session control-plane — unified into the single `agent` tool
110
- // (type=spawn|send|close|list). Denying the one name blocks all worker
111
- // session control.
112
- 'agent',
113
- // channels module (owner/Discord-facing)
114
- 'reply', 'react', 'edit_message', 'download_attachment', 'fetch',
115
- 'schedule_status', 'trigger_schedule', 'schedule_control',
116
- 'activate_channel_bridge', 'reload_config', 'inject_command',
117
- // host input injection
118
- 'inject_input',
119
- ]);
120
- function _preDispatchDeny(call, toolKind, sessionRef) {
121
- const name = call?.name;
122
- if (typeof name !== 'string' || !name) return null;
123
- const _agentOwned = sessionRef?.scope?.startsWith?.('agent:')
124
- || isAgentOwner(sessionRef);
125
- const _controlPlaneTool = WORKER_DENIED_TOOLS.has(name);
126
- if (_agentOwned && _controlPlaneTool) {
127
- return `Error: control-plane tool "${name}" is Lead-only and not available to agent workers.`;
128
- }
129
- const noToolRole = sessionRef?.role === 'cycle1-agent' || sessionRef?.role === 'cycle2-agent';
130
- if (noToolRole) {
131
- return `Error: tool "${name}" is not available in role "${sessionRef.role}". Re-emit the answer as pipe-separated text per the role's output format (first character a digit, NO tool_use blocks, NO JSON, NO prose, NO apology).`;
132
- }
133
- return null;
134
- }
135
- /** Exported for smoke tests — same runtime deny as the agent loop. */
136
- export function preDispatchDenyForSession(sessionRef, call, toolKind = 'builtin') {
137
- return _preDispatchDeny(call, toolKind, sessionRef);
138
- }
139
50
  import { compressToolResult, recordToolBatch } from '../tools/result-compression.mjs';
140
51
 
141
52
 
142
- import { readFileSync as _readFileSync } from 'fs';
143
- import { fileURLToPath } from 'url';
144
- import { dirname, resolve as resolvePath, isAbsolute } from 'path';
145
- const MCP_TOOL_PREFIX = 'mcp__plugin_mixdog_mixdog__';
146
- const COMPACT_SAFETY_PERCENT = 1.00;
147
- const COMPACT_BUFFER_MAX_WINDOW_FRACTION = 0.25;
148
-
149
- function estimateMessagesTokensSafe(messages) {
150
- try { return estimateMessagesTokens(messages); }
151
- catch { return null; }
152
- }
153
-
154
- function steeringContentText(content) {
155
- if (typeof content === 'string') return content;
156
- if (Array.isArray(content)) {
157
- return content.map((part) => {
158
- if (typeof part === 'string') return part;
159
- if (part?.type === 'text') return part.text || '';
160
- if (part?.type === 'image') return '[Image]';
161
- return part?.text || '';
162
- }).filter(Boolean).join('\n');
163
- }
164
- return String(content ?? '');
165
- }
166
-
167
- function normalizeSteeringEntry(entry) {
168
- if (typeof entry === 'string') {
169
- const text = entry.trim();
170
- return text ? { content: text, text } : null;
171
- }
172
- if (!entry || typeof entry !== 'object') return null;
173
- const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : entry;
174
- const text = typeof entry.text === 'string' ? entry.text.trim() : steeringContentText(content).trim();
175
- if (Array.isArray(content)) return content.length > 0 ? { content, text } : null;
176
- if (typeof content === 'string') {
177
- const value = content.trim();
178
- return value ? { content: value, text: text || value } : null;
179
- }
180
- const fallback = steeringContentText(content).trim();
181
- return fallback ? { content: fallback, text: text || fallback } : null;
182
- }
183
-
184
- function mergeSteeringEntries(entries) {
185
- const normalized = (Array.isArray(entries) ? entries : [])
186
- .map(normalizeSteeringEntry)
187
- .filter(Boolean);
188
- if (normalized.length === 0) return null;
189
- const displayText = normalized.map((entry) => entry.text || steeringContentText(entry.content))
190
- .filter((text) => String(text || '').trim())
191
- .join('\n');
192
- if (normalized.every((entry) => typeof entry.content === 'string')) {
193
- return {
194
- content: normalized.map((entry) => entry.content).filter(Boolean).join('\n'),
195
- text: displayText,
196
- count: normalized.length,
197
- };
198
- }
199
- const parts = [];
200
- for (const entry of normalized) {
201
- if (typeof entry.content === 'string') {
202
- if (entry.content.trim()) parts.push({ type: 'text', text: entry.content });
203
- } else if (Array.isArray(entry.content)) {
204
- parts.push(...entry.content);
205
- } else {
206
- const text = steeringContentText(entry.content);
207
- if (text.trim()) parts.push({ type: 'text', text });
208
- }
209
- parts.push({ type: 'text', text: '\n' });
210
- }
211
- while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
212
- return { content: parts, text: displayText || steeringContentText(parts), count: normalized.length };
213
- }
214
-
215
- class AgentContextOverflowError extends Error {
216
- constructor({ stage, sessionId, provider, model, contextWindow, budgetTokens, reserveTokens, messageTokensEst }, cause) {
217
- const target = [provider, model].filter(Boolean).join('/') || 'target model';
218
- const causeMsg = cause && cause.message ? `: ${cause.message}` : '';
219
- super(
220
- `agent context overflow (${target}, stage=${stage || 'compact'}): ` +
221
- `latest turn cannot fit target context budget=${budgetTokens ?? 'unknown'} ` +
222
- `reserve=${reserveTokens ?? 'unknown'} contextWindow=${contextWindow ?? 'unknown'} ` +
223
- `messageTokensEst=${messageTokensEst ?? 'unknown'}${causeMsg}`,
224
- );
225
- this.name = 'AgentContextOverflowError';
226
- this.code = 'AGENT_CONTEXT_OVERFLOW';
227
- this.sessionId = sessionId || null;
228
- this.provider = provider || null;
229
- this.model = model || null;
230
- this.contextWindow = contextWindow ?? null;
231
- this.budgetTokens = budgetTokens ?? null;
232
- this.reserveTokens = reserveTokens ?? null;
233
- this.messageTokensEst = messageTokensEst ?? null;
234
- if (cause) this.cause = cause;
235
- }
236
- }
53
+ import { resolve as resolvePath, isAbsolute } from 'path';
54
+ import {
55
+ estimateMessagesTokensSafe,
56
+ compactDiagnosticError,
57
+ compactByteLength,
58
+ compactDebugLog,
59
+ } from './loop/compact-debug.mjs';
60
+ import { mergeSteeringEntries, steeringContentText } from './loop/steering.mjs';
61
+ import { agentContextOverflowError } from './loop/context-overflow.mjs';
62
+ import { positiveTokenInt } from './loop/env.mjs';
63
+ import { normalizeUsage, addUsage } from './loop/usage.mjs';
64
+ import { HIDDEN_AGENT_NAMES } from './loop/hidden-agents.mjs';
65
+ import {
66
+ resolveWorkerCompactPolicy,
67
+ compactionTelemetryPressureTokens,
68
+ compactTargetBudget,
69
+ shouldCompactForSession,
70
+ countPrunedToolOutputs,
71
+ rememberCompactTelemetry,
72
+ emitCompactEvent,
73
+ compactEventType,
74
+ } from './loop/compact-policy.mjs';
75
+ import {
76
+ isEagerDispatchable,
77
+ messagesArrayChanged,
78
+ getToolKind,
79
+ buildSkillsListResponse,
80
+ viewSkill,
81
+ normalizeHookUpdatedToolOutput,
82
+ resolveToolResultAfterHook,
83
+ parseNativeToolSearchPayload,
84
+ extractBashSessionId,
85
+ buildAgentBashSessionArgs,
86
+ formatMissingToolApprovalUiDenial,
87
+ resolvePreToolAskApproval,
88
+ approvalGranted,
89
+ approvalReason,
90
+ } from './loop/tool-helpers.mjs';
91
+ import {
92
+ compactToolCallsForHistory,
93
+ restoreToolCallBodyForId,
94
+ } from './loop/stored-tool-args.mjs';
95
+ import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
237
96
 
238
- function agentContextOverflowError({ stage, sessionId, sessionRef, model, budgetTokens, reserveTokens, messageTokensEst }, cause) {
239
- return new AgentContextOverflowError({
240
- stage,
241
- sessionId,
242
- provider: sessionRef?.provider || null,
243
- model: sessionRef?.model || model || null,
244
- contextWindow: sessionRef?.contextWindow ?? null,
245
- budgetTokens,
246
- reserveTokens,
247
- messageTokensEst,
248
- }, cause);
249
- }
97
+ // Facade re-exports: these symbols moved to split modules under ./loop/ but
98
+ // remain part of loop.mjs's public surface (imported by scripts/tests and other
99
+ // runtime modules). Re-export the already-imported local bindings so every
100
+ // existing import path keeps working (no duplicate module binding).
101
+ export {
102
+ preDispatchDenyForSession,
103
+ repairTranscriptBeforeProviderSend,
104
+ normalizeHookUpdatedToolOutput,
105
+ resolveToolResultAfterHook,
106
+ buildAgentBashSessionArgs,
107
+ formatMissingToolApprovalUiDenial,
108
+ resolvePreToolAskApproval,
109
+ approvalGranted,
110
+ approvalReason,
111
+ };
250
112
 
251
- // Cache-hit results always inline the cached body. The earlier size-gated
252
- // `[cache-hit-ref]` branch confused agents whose context did not
253
- // contain the referenced prior tool_result, triggering shell-cat detours.
254
113
  // Hard iteration ceiling for every agent loop. Reset to 0 whenever the
255
114
  // transcript is compacted (see the trim block below): a long task that keeps
256
115
  // compacting can proceed past this count, while a tight NON-compacting loop
257
116
  // still stops here and returns the accumulated transcript.
258
- const MAX_LOOP_ITERATIONS = 200;
259
117
  // Consecutive identical-AND-failing tool calls (same name+args, error result)
260
118
  // tolerated across iterations before the loop refuses to re-execute and steers
261
119
  // the model to change approach. Distinct from the hard iteration cap above:
262
120
  // this catches tight deterministic-failure loops (e.g. a command that errors
263
121
  // the same way every time) far earlier than 100 iterations.
264
122
  const REPEAT_FAIL_LIMIT = 3;
265
- const _HIDDEN_ROLES_JSON = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../defaults/hidden-roles.json');
266
- let _hiddenRolesCache = null;
267
- function _getHiddenRoles() {
268
- if (_hiddenRolesCache) return _hiddenRolesCache;
269
- try {
270
- _hiddenRolesCache = JSON.parse(_readFileSync(_HIDDEN_ROLES_JSON, 'utf8'));
271
- } catch { _hiddenRolesCache = { roles: [] }; }
272
- return _hiddenRolesCache;
273
- }
274
- // Transcript pairing guard. Anthropic 400-rejects when an assistant message
275
- // ends with tool_use blocks and the next message isn't tool results for
276
- // those exact ids. abort/timeout/error race in the loop body can leave a
277
- // dangling assistant tool_use at the tail (e.g. the structure_probe loop
278
- // running 12 deep then aborting between push-assistant and push-tool).
279
- // Strip any trailing assistant tool_use that has no matching tool result
280
- // so provider.send sees a valid transcript instead of leaking the 400 to
281
- // the user. Repair runs every iteration but is a no-op on healthy paths.
282
- function _ensureTranscriptPairing(msgs, sessionId) {
283
- // Walk backwards to find the last assistant message that emitted
284
- // tool_use, then validate that every id has a matching tool result
285
- // inside the CONTIGUOUS tool-message block immediately following it.
286
- // Earlier guard splice'd the entire tail — which silently deleted any
287
- // user prompt appended after the dangling assistant by manager.mjs:
288
- // when the guard fired with shape
289
- // [..., assistant{a,b}, tool{a}, user{new prompt}]
290
- // the splice removed user{new prompt} along with the orphan suffix.
291
- // Fix: remove only assistant + the contiguous tool block; preserve
292
- // anything past it (user / system / next assistant) untouched.
293
- let popped = 0;
294
- while (msgs.length > 0) {
295
- let lastAssistantIdx = -1;
296
- for (let i = msgs.length - 1; i >= 0; i--) {
297
- const m = msgs[i];
298
- if (m?.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
299
- lastAssistantIdx = i;
300
- break;
301
- }
302
- }
303
- if (lastAssistantIdx === -1) break;
304
- // Collect the contiguous tool messages directly after this assistant.
305
- // Anything past that block is unrelated (next user prompt, system
306
- // marker, etc.) and must survive the repair.
307
- let toolBlockEnd = lastAssistantIdx + 1;
308
- while (toolBlockEnd < msgs.length && msgs[toolBlockEnd]?.role === 'tool') {
309
- toolBlockEnd += 1;
310
- }
311
- const toolBlock = msgs.slice(lastAssistantIdx + 1, toolBlockEnd);
312
- const ids = msgs[lastAssistantIdx].toolCalls.map(c => c.id);
313
- const matched = ids.every(id => toolBlock.some(m => m.toolCallId === id));
314
- if (matched) break;
315
- const removed = toolBlockEnd - lastAssistantIdx;
316
- msgs.splice(lastAssistantIdx, removed);
317
- popped += removed;
318
- }
319
- // Second sweep — catch dangling tool results that survived the
320
- // contiguous-block splice. Anthropic strict spec requires every
321
- // tool result to sit in a contiguous block right after the
322
- // assistant whose toolCalls produced it; a `[..., assistant{a,b},
323
- // tool{a}, user, tool{b}]` shape leaves tool{b} orphaned even
324
- // after assistant + tool{a} are repaired by the loop above.
325
- // Walk back from each tool message to the nearest non-tool
326
- // ancestor; if it is not an assistant whose toolCalls include
327
- // this id, drop the orphan.
328
- for (let i = msgs.length - 1; i >= 0; i--) {
329
- const m = msgs[i];
330
- if (m?.role !== 'tool') continue;
331
- if (!m.toolCallId) {
332
- msgs.splice(i, 1);
333
- popped += 1;
334
- continue;
335
- }
336
- let prevIdx = i - 1;
337
- while (prevIdx >= 0 && msgs[prevIdx]?.role === 'tool') prevIdx--;
338
- const anchor = prevIdx >= 0 ? msgs[prevIdx] : null;
339
- const anchorOk = anchor?.role === 'assistant'
340
- && Array.isArray(anchor.toolCalls)
341
- && anchor.toolCalls.some(c => c.id === m.toolCallId);
342
- if (!anchorOk) {
343
- msgs.splice(i, 1);
344
- popped += 1;
345
- }
346
- }
347
- if (popped > 0 && sessionId) {
348
- try { process.stderr.write(`[transcript-repair] sess=${sessionId} popped=${popped} dangling assistant tool_use\n`); } catch {}
349
- }
350
- }
351
-
352
- /**
353
- * Pre-provider transcript repair for the agent loop. Reattach valid tool
354
- * results (non-destructive) before any destructive orphan pairing cleanup.
355
- * Mutates `messages` in place to preserve the session array reference.
356
- */
357
- export function repairTranscriptBeforeProviderSend(messages, sessionId = null) {
358
- if (!Array.isArray(messages)) return messages;
359
- const sanitized = sanitizeToolPairs(messages);
360
- if (sanitized !== messages) {
361
- messages.length = 0;
362
- messages.push(...sanitized);
363
- }
364
- _ensureTranscriptPairing(messages, sessionId);
365
- return messages;
366
- }
367
-
368
- // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
369
- // to execute during SSE parsing so tool work overlaps with the rest of the
370
- // stream. Writes, bash, MCP and skills stay serial after send() returns.
371
- function isEagerDispatchable(name, tools) {
372
- if (!Array.isArray(tools)) return false;
373
- const def = tools.find(t => t?.name === name);
374
- return def?.annotations?.readOnlyHint === true;
375
- }
376
- function messagesArrayChanged(before, after) {
377
- if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
378
- if (before.length !== after.length) return true;
379
- for (let i = 0; i < before.length; i += 1) {
380
- if (before[i] !== after[i]) return true;
381
- }
382
- return false;
383
- }
384
- function normalizeUsage(usage) {
385
- if (!usage) return null;
386
- const costUsd = Number(usage.costUsd);
387
- return {
388
- inputTokens: usage.inputTokens || 0,
389
- outputTokens: usage.outputTokens || 0,
390
- cachedTokens: usage.cachedTokens || 0,
391
- cacheWriteTokens: usage.cacheWriteTokens || 0,
392
- promptTokens: usage.promptTokens || 0,
393
- ...(Number.isFinite(costUsd) ? { costUsd } : {}),
394
- raw: usage.raw,
395
- };
396
- }
397
- function addUsage(total, usage) {
398
- const delta = normalizeUsage(usage);
399
- if (!delta) return total;
400
- if (!total) return { ...delta };
401
- const next = {
402
- ...total,
403
- inputTokens: (total.inputTokens || 0) + delta.inputTokens,
404
- outputTokens: (total.outputTokens || 0) + delta.outputTokens,
405
- cachedTokens: (total.cachedTokens || 0) + delta.cachedTokens,
406
- cacheWriteTokens: (total.cacheWriteTokens || 0) + delta.cacheWriteTokens,
407
- promptTokens: (total.promptTokens || 0) + delta.promptTokens,
408
- };
409
- if (delta.costUsd != null || total.costUsd != null) {
410
- next.costUsd = (total.costUsd || 0) + (delta.costUsd || 0);
411
- }
412
- return next;
413
- }
414
- function positiveTokenInt(value) {
415
- const n = Number(value);
416
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
417
- }
418
- function envFlag(name, fallback = false) {
419
- const v = process.env[name];
420
- if (v === undefined) return fallback;
421
- return !['0', 'false', 'off', 'no'].includes(String(v).trim().toLowerCase());
422
- }
423
- function envTokenInt(name) {
424
- return positiveTokenInt(process.env[name]);
425
- }
426
- function resolveSemanticCompactSetting(sessionRef, cfg = {}) {
427
- if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
428
- if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
429
- if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on') return true;
430
- // The compact type is already explicit (`semantic` by default). Honor it
431
- // directly instead of substituting another compaction path.
432
- return true;
433
- }
434
-
435
- function resolveCompactTypeSetting(_sessionRef, cfg = {}) {
436
- const configured = process.env.MIXDOG_AGENT_COMPACT_TYPE
437
- ?? process.env.MIXDOG_COMPACT_TYPE
438
- ?? cfg.type
439
- ?? cfg.compactType
440
- ?? cfg.compact_type;
441
- return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
442
- }
443
-
444
123
  async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTokens, compactPolicy, sessionId, signal }) {
445
124
  if (!sessionId) throw new Error('recall-fasttrack requires a session id');
125
+ const startedAt = Date.now();
126
+ const diagnostics = {
127
+ hydrateLimit: null,
128
+ ingestMs: null,
129
+ ingestSkipped: false,
130
+ ingestError: null,
131
+ initialDumpMs: null,
132
+ initialDumpBytes: null,
133
+ initialDumpChars: null,
134
+ initialRawPending: null,
135
+ cycle1Ms: null,
136
+ cycle1Skipped: false,
137
+ cycle1SkipReason: null,
138
+ cycle1Passes: null,
139
+ cycle1RawRemaining: null,
140
+ cycle1TextBytes: null,
141
+ cycle1Error: null,
142
+ finalRecallBytes: null,
143
+ finalRecallChars: null,
144
+ totalMs: null,
145
+ };
446
146
  const query = `session:${sessionId}:all-chunks`;
447
147
  const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
448
148
  const callerCtx = {
@@ -454,6 +154,8 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
454
154
  };
455
155
  const hydrateLimit = positiveTokenInt(sessionRef?.compaction?.recallIngestLimit)
456
156
  || Math.max(500, Math.min(5000, messages.length || 0));
157
+ diagnostics.hydrateLimit = hydrateLimit;
158
+ let t0 = Date.now();
457
159
  try {
458
160
  await executeInternalTool('memory', {
459
161
  action: 'ingest_session',
@@ -463,7 +165,11 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
463
165
  limit: hydrateLimit,
464
166
  }, callerCtx);
465
167
  } catch (err) {
168
+ diagnostics.ingestSkipped = true;
169
+ diagnostics.ingestError = compactDiagnosticError(err);
466
170
  try { process.stderr.write(`[loop] recall-fasttrack ingest skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
171
+ } finally {
172
+ diagnostics.ingestMs = Date.now() - t0;
467
173
  }
468
174
  const dumpArgs = {
469
175
  action: 'dump_session_roots',
@@ -472,10 +178,16 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
472
178
  limit: positiveTokenInt(sessionRef?.compaction?.recallChunkLimit ?? sessionRef?.compaction?.recallLimit) || hydrateLimit,
473
179
  };
474
180
  const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
181
+ t0 = Date.now();
475
182
  let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
183
+ diagnostics.initialDumpMs = Date.now() - t0;
184
+ diagnostics.initialDumpChars = String(recallText || '').length;
185
+ diagnostics.initialDumpBytes = compactByteLength(recallText);
186
+ diagnostics.initialRawPending = countRawPendingRows(recallText);
476
187
  let cycle1Text = '';
477
188
  const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
478
189
  if (hasRawRows) {
190
+ t0 = Date.now();
479
191
  try {
480
192
  // Drain this session's cycle1 in window×concurrency units until no
481
193
  // raw rows remain, so the injected root is fully chunked rather than
@@ -496,19 +208,32 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
496
208
  });
497
209
  recallText = drained.recallText;
498
210
  cycle1Text = drained.cycle1Text;
211
+ diagnostics.cycle1Passes = drained.passes;
212
+ diagnostics.cycle1RawRemaining = drained.rawRemaining;
213
+ diagnostics.cycle1TextBytes = compactByteLength(cycle1Text);
499
214
  if (drained.rawRemaining > 0) {
500
215
  try { process.stderr.write(`[loop] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId || 'unknown'})\n`); } catch {}
501
216
  }
502
217
  } catch (err) {
218
+ diagnostics.cycle1Error = compactDiagnosticError(err);
503
219
  try { process.stderr.write(`[loop] recall-fasttrack cycle1 skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
220
+ } finally {
221
+ diagnostics.cycle1Ms = Date.now() - t0;
504
222
  }
505
223
  } else {
224
+ diagnostics.cycle1Skipped = true;
225
+ diagnostics.cycle1SkipReason = 'session chunks already hydrated';
226
+ diagnostics.cycle1Passes = 0;
227
+ diagnostics.cycle1RawRemaining = 0;
506
228
  cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
507
229
  }
508
- return recallFastTrackCompactMessages(messages, compactBudgetTokens, {
230
+ const combinedRecallText = [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n');
231
+ diagnostics.finalRecallChars = combinedRecallText.length;
232
+ diagnostics.finalRecallBytes = compactByteLength(combinedRecallText);
233
+ const result = recallFastTrackCompactMessages(messages, compactBudgetTokens, {
509
234
  reserveTokens: compactPolicy.reserveTokens,
510
235
  force: true,
511
- recallText: [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n'),
236
+ recallText: combinedRecallText,
512
237
  query,
513
238
  querySha,
514
239
  allowEmptyRecall: true,
@@ -516,562 +241,16 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
516
241
  keepTokens: compactPolicy.keepTokens,
517
242
  preserveRecentTokens: compactPolicy.preserveRecentTokens,
518
243
  });
519
- }
520
- // Percent-named inputs (bufferPercent / bufferPct / *_BUFFER_PERCENT) carry a
521
- // PERCENT: 1 means 1% (0.01). Ratio-named inputs (bufferRatio / bufferFraction)
522
- // carry a fraction: 0.01 means 1%, and a legacy value > 1 is read as a percent
523
- // (10 -> 10%). The shared normalizeCompactionBufferRatio only handles the ratio
524
- // convention, so resolve percent-named values to a fraction first.
525
- function _resolveBufferRatioCandidate(percentInputs, ratioInputs) {
526
- for (const raw of percentInputs) {
527
- const n = Number(raw);
528
- if (Number.isFinite(n) && n > 0) return Math.min(1, n / 100);
529
- }
530
- for (const raw of ratioInputs) {
531
- const n = Number(raw);
532
- if (Number.isFinite(n) && n > 0) return n > 1 ? n / 100 : n;
533
- }
534
- return null;
535
- }
536
- function resolveCompactBufferRatio(cfg = {}) {
537
- const resolved = _resolveBufferRatioCandidate(
538
- [cfg.bufferPercent, cfg.bufferPct, process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT],
539
- [cfg.bufferRatio, cfg.bufferFraction, process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO],
540
- );
541
- return normalizeCompactionBufferRatio(resolved, DEFAULT_COMPACTION_BUFFER_RATIO);
542
- }
543
- const LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
544
- function isPersistedZeroBufferTelemetry(cfg = {}, boundaryTokens = 0) {
545
- const boundary = positiveTokenInt(boundaryTokens);
546
- if (!boundary) return false;
547
- if (envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')) return false;
548
- for (const envName of ['MIXDOG_AGENT_COMPACT_BUFFER_PERCENT', 'MIXDOG_AGENT_COMPACT_BUFFER_RATIO']) {
549
- const n = Number(process.env[envName]);
550
- if (Number.isFinite(n) && n > 0) return false;
551
- }
552
- for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
553
- const n = Number(cfg?.[key]);
554
- if (Number.isFinite(n) && n > 0) return false;
555
- }
556
- const ratio = Number(cfg?.bufferRatio);
557
- if (Number.isFinite(ratio) && ratio > 0) return false;
558
- const explicitTokens = Number(cfg?.bufferTokens ?? cfg?.buffer);
559
- if (!Number.isFinite(explicitTokens) || explicitTokens !== 0) return false;
560
- return true;
561
- }
562
- function isLegacyDefaultBufferTelemetry(cfg = {}, boundaryTokens = 0) {
563
- const boundary = positiveTokenInt(boundaryTokens);
564
- if (!boundary) return false;
565
- if (envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')) return false;
566
- for (const envName of ['MIXDOG_AGENT_COMPACT_BUFFER_PERCENT', 'MIXDOG_AGENT_COMPACT_BUFFER_RATIO']) {
567
- const n = Number(process.env[envName]);
568
- if (Number.isFinite(n) && n > 0) return false;
569
- }
570
- // Percent/fraction-named fields are operator config. The legacy default
571
- // telemetry persisted bufferTokens + bufferRatio after a check/compact pass.
572
- for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
573
- const n = Number(cfg?.[key]);
574
- if (Number.isFinite(n) && n > 0) return false;
575
- }
576
- const explicitTokens = positiveTokenInt(cfg?.bufferTokens ?? cfg?.buffer);
577
- const ratio = Number(cfg?.bufferRatio);
578
- if (!explicitTokens || !Number.isFinite(ratio) || Math.abs(ratio - LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO) > 1e-9) return false;
579
- const expectedTokens = Math.floor(boundary * LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO);
580
- const cfgBoundary = positiveTokenInt(cfg?.boundaryTokens);
581
- const cfgTrigger = positiveTokenInt(cfg?.triggerTokens);
582
- return explicitTokens === expectedTokens
583
- || (cfgBoundary === boundary && cfgTrigger > 0 && explicitTokens === Math.max(0, boundary - cfgTrigger));
584
- }
585
- function compactBufferConfigForBoundary(cfg = {}, boundaryTokens = 0) {
586
- const base = cfg || {};
587
- if (!isLegacyDefaultBufferTelemetry(base, boundaryTokens)
588
- && !isPersistedZeroBufferTelemetry(base, boundaryTokens)) {
589
- return base;
590
- }
591
- return {
592
- ...base,
593
- bufferTokens: null,
594
- buffer: null,
595
- bufferRatio: null,
596
- };
597
- }
598
- function resolveCompactBufferTokens(boundaryTokens, cfg = {}) {
599
- const boundary = positiveTokenInt(boundaryTokens);
600
- const effectiveCfg = compactBufferConfigForBoundary(cfg, boundary);
601
- const configured = positiveTokenInt(effectiveCfg.bufferTokens ?? effectiveCfg.buffer)
602
- || envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')
603
- || 0;
604
- if (!boundary) return configured || DEFAULT_COMPACTION_BUFFER_TOKENS;
605
- return compactionBufferTokensForBoundary(boundary, {
606
- explicitTokens: configured,
607
- ratio: resolveCompactBufferRatio(effectiveCfg),
608
- maxRatio: COMPACT_BUFFER_MAX_WINDOW_FRACTION,
609
- });
610
- }
611
- const COMPACT_TARGET_RATIO = 0.02;
612
- const COMPACT_TARGET_MIN_TOKENS = 4_000;
613
- const COMPACT_TARGET_MAX_TOKENS = 16_000;
614
- function resolveCompactTargetRatio(cfg = {}) {
615
- const raw = cfg.targetPercent
616
- ?? cfg.targetPct
617
- ?? cfg.targetRatio
618
- ?? cfg.targetFraction
619
- ?? process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
620
- ?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
621
- ?? COMPACT_TARGET_RATIO;
622
- const n = Number(raw);
623
- if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
624
- return n > 1 ? n / 100 : n;
625
- }
626
- function resolveCompactTargetTokens(boundaryTokens, cfg = {}) {
627
- const boundary = positiveTokenInt(boundaryTokens);
628
- if (!boundary) return null;
629
- const explicit = positiveTokenInt(cfg.targetTokens ?? cfg.target)
630
- || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_TOKENS')
631
- || envTokenInt('MIXDOG_COMPACT_TARGET_TOKENS');
632
- if (explicit) return Math.max(1, Math.min(boundary, explicit));
633
- const minTarget = Math.min(boundary, positiveTokenInt(cfg.targetMinTokens ?? cfg.minTargetTokens)
634
- || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MIN_TOKENS')
635
- || envTokenInt('MIXDOG_COMPACT_TARGET_MIN_TOKENS')
636
- || COMPACT_TARGET_MIN_TOKENS);
637
- const maxTarget = Math.min(boundary, positiveTokenInt(cfg.targetMaxTokens ?? cfg.maxTargetTokens)
638
- || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MAX_TOKENS')
639
- || envTokenInt('MIXDOG_COMPACT_TARGET_MAX_TOKENS')
640
- || COMPACT_TARGET_MAX_TOKENS);
641
- const byRatio = Math.max(1, Math.floor(boundary * resolveCompactTargetRatio(cfg)));
642
- return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
643
- }
644
- function resolveCompactKeepTokens(cfg = {}) {
645
- return positiveTokenInt(cfg.keepTokens ?? cfg.keep?.tokens ?? cfg.preserveRecentTokens)
646
- || envTokenInt('MIXDOG_AGENT_COMPACT_KEEP_TOKENS')
647
- || DEFAULT_COMPACTION_KEEP_TOKENS;
648
- }
649
- function resolveWorkerCompactPolicy(sessionRef, tools) {
650
- if (!sessionRef) return null;
651
- const cfg = sessionRef.compaction || {};
652
- const auto = cfg.auto !== false && envFlag('MIXDOG_AGENT_COMPACT_AUTO', true);
653
- if (!auto) return { auto: false };
654
- const contextWindow = positiveTokenInt(sessionRef.contextWindow ?? cfg.contextWindow);
655
- const explicitBoundary = positiveTokenInt(sessionRef.compactBoundaryTokens ?? cfg.boundaryTokens);
656
- const autoLimit = positiveTokenInt(sessionRef.autoCompactTokenLimit ?? cfg.autoCompactTokenLimit);
657
- const boundaryTokens = explicitBoundary && contextWindow
658
- ? Math.min(explicitBoundary, contextWindow)
659
- : (explicitBoundary || contextWindow || autoLimit);
660
- if (!boundaryTokens) return null;
661
- const compactBoundaryTokens = Math.max(1, Math.floor(boundaryTokens * COMPACT_SAFETY_PERCENT));
662
- // Only an explicit auto-compact limit STRICTLY BELOW the boundary acts as
663
- // the trigger. A persisted value == boundary (legacy derived full-window
664
- // autoCompactTokenLimit) would set autoTriggerTokens == boundary and
665
- // collapse/override the default trigger, so it is ignored in favor of the
666
- // default boundary trigger.
667
- const autoTriggerTokens = autoLimit && autoLimit < compactBoundaryTokens ? Math.max(1, autoLimit) : null;
668
- // Sanitized explicit limit: only a sub-boundary value is a real auto-compact
669
- // limit. Anything >= boundary is a legacy derived full-window artifact and
670
- // is reported as null so rememberCompactTelemetry does not re-persist it
671
- // back onto the session and re-collapse the buffer on the next turn.
672
- const explicitAutoCompactTokenLimit = autoTriggerTokens;
673
- const bufferTokens = autoTriggerTokens
674
- ? Math.max(0, compactBoundaryTokens - autoTriggerTokens)
675
- : resolveCompactBufferTokens(compactBoundaryTokens, cfg);
676
- const bufferRatio = compactBoundaryTokens ? (bufferTokens / compactBoundaryTokens) : resolveCompactBufferRatio(cfg);
677
- const triggerTokens = autoTriggerTokens || Math.max(1, compactBoundaryTokens - bufferTokens);
678
- const configuredReserve = positiveTokenInt(cfg.reservedTokens)
679
- || envTokenInt('MIXDOG_AGENT_COMPACT_RESERVED_TOKENS')
680
- || 0;
681
- const requestReserve = estimateRequestReserveTokens(tools);
682
- const keepTokens = resolveCompactKeepTokens(cfg);
683
- const compactType = resolveCompactTypeSetting(sessionRef, cfg);
684
- return {
685
- auto: true,
686
- type: compactType,
687
- compactType,
688
- prune: cfg.prune === true || envFlag('MIXDOG_AGENT_COMPACT_PRUNE', false),
689
- boundaryTokens: compactBoundaryTokens,
690
- triggerTokens,
691
- bufferTokens,
692
- bufferRatio,
693
- contextWindow,
694
- rawContextWindow: positiveTokenInt(sessionRef.rawContextWindow ?? cfg.rawContextWindow) || contextWindow,
695
- effectiveContextWindowPercent: Number.isFinite(Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent))
696
- ? Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent)
697
- : null,
698
- autoCompactTokenLimit: explicitAutoCompactTokenLimit,
699
- semantic: compactTypeIsSemantic(compactType) && resolveSemanticCompactSetting(sessionRef, cfg),
700
- recallFastTrack: compactTypeIsRecallFastTrack(compactType),
701
- semanticTimeoutMs: positiveTokenInt(cfg.timeoutMs) || envTokenInt('MIXDOG_AGENT_COMPACT_TIMEOUT_MS') || 30_000,
702
- tailTurns: positiveTokenInt(cfg.tailTurns) || envTokenInt('MIXDOG_AGENT_COMPACT_TAIL_TURNS') || 2,
703
- keepTokens,
704
- preserveRecentTokens: positiveTokenInt(cfg.preserveRecentTokens) || envTokenInt('MIXDOG_AGENT_COMPACT_PRESERVE_RECENT_TOKENS') || keepTokens,
705
- reserveTokens: requestReserve + configuredReserve,
706
- requestReserveTokens: requestReserve,
707
- configuredReserveTokens: configuredReserve,
708
- };
709
- }
710
- /** Transcript + request reserve only (never provider lastContextTokens). */
711
- function compactPressureTokens(messageTokensEst, policy) {
712
- if (messageTokensEst === null) return 0;
713
- return Math.max(0, messageTokensEst + (policy?.reserveTokens || 0));
714
- }
715
-
716
- /** Telemetry pressure when a reactive overflow retry forces the next compact. */
717
- function compactionTelemetryPressureTokens(messageTokensEst, policy, { reactivePending = false } = {}) {
718
- const base = compactPressureTokens(messageTokensEst, policy);
719
- if (!reactivePending) return base;
720
- const floor = positiveTokenInt(policy?.triggerTokens) || positiveTokenInt(policy?.boundaryTokens) || 0;
721
- return floor ? Math.max(base, floor) : base;
722
- }
723
- function compactTargetBudget(policy) {
724
- const boundary = positiveTokenInt(policy?.boundaryTokens);
725
- if (!boundary) return null;
726
- const reserve = Math.max(0, Number(policy?.reserveTokens) || 0);
727
- const targetEffective = resolveCompactTargetTokens(boundary, policy) || boundary;
728
- return Math.max(1, Math.min(boundary, targetEffective + reserve));
729
- }
730
- function shouldCompactForSession(messageTokensEst, policy, { forceReactive = false } = {}) {
731
- if (!policy?.auto || !policy.boundaryTokens) return false;
732
- if (forceReactive) return true;
733
- if (messageTokensEst === null) return true;
734
- return compactPressureTokens(messageTokensEst, policy) >= (policy.triggerTokens || policy.boundaryTokens);
735
- }
736
- function countPrunedToolOutputs(before, after) {
737
- if (!Array.isArray(before) || !Array.isArray(after)) return 0;
738
- let count = 0;
739
- const n = Math.min(before.length, after.length);
740
- for (let i = 0; i < n; i += 1) {
741
- if (before[i]?.role !== 'tool' || after[i]?.role !== 'tool') continue;
742
- if (before[i]?.content !== after[i]?.content && after[i]?.compactedKind === 'tool_output_prune') count += 1;
743
- }
744
- return count;
745
- }
746
- function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
747
- if (!sessionRef || !policy) return;
748
- const prev = sessionRef.compaction && typeof sessionRef.compaction === 'object'
749
- ? sessionRef.compaction
750
- : {};
751
- const changed = meta.compactChanged === true || meta.pruneCount > 0;
752
- sessionRef.compaction = {
753
- ...prev,
754
- auto: policy.auto !== false,
755
- prune: policy.prune === true,
756
- reservedTokens: policy.configuredReserveTokens || prev.reservedTokens || null,
757
- requestReserveTokens: policy.requestReserveTokens || 0,
758
- reserveTokens: policy.reserveTokens || 0,
759
- boundaryTokens: policy.boundaryTokens || null,
760
- triggerTokens: policy.triggerTokens || null,
761
- bufferTokens: policy.bufferTokens || 0,
762
- bufferRatio: policy.bufferRatio ?? prev.bufferRatio ?? null,
763
- contextWindow: policy.contextWindow || null,
764
- rawContextWindow: policy.rawContextWindow || null,
765
- effectiveContextWindowPercent: policy.effectiveContextWindowPercent ?? null,
766
- autoCompactTokenLimit: policy.autoCompactTokenLimit || null,
767
- type: policy.compactType || policy.type || DEFAULT_COMPACT_TYPE,
768
- compactType: policy.compactType || policy.type || DEFAULT_COMPACT_TYPE,
769
- semantic: policy.semantic === true ? 'auto' : false,
770
- recallFastTrack: policy.recallFastTrack === true,
771
- semanticModel: policy.semanticModel || null,
772
- semanticTimeoutMs: policy.semanticTimeoutMs || null,
773
- tailTurns: policy.tailTurns || null,
774
- keepTokens: policy.keepTokens || null,
775
- preserveRecentTokens: policy.preserveRecentTokens || null,
776
- lastCheckedAt: Date.now(),
777
- lastBeforeTokens: meta.beforeTokens ?? null,
778
- lastAfterTokens: meta.afterTokens ?? null,
779
- lastPressureTokens: meta.pressureTokens ?? null,
780
- currentEstimatedTokens: meta.pressureTokens ?? prev.currentEstimatedTokens ?? null,
781
- lastApiRequestTokens: positiveTokenInt(sessionRef?.lastContextTokens) || prev.lastApiRequestTokens || null,
782
- lastStage: meta.stage || prev.lastStage || null,
783
- lastChanged: changed,
784
- lastTrigger: meta.trigger || prev.lastTrigger || null,
785
- lastSemantic: meta.semanticCompact === true,
786
- lastSemanticError: Object.hasOwn(meta, 'semanticError')
787
- ? (meta.semanticError ?? null)
788
- : (prev.lastSemanticError ?? null),
789
- lastRecallFastTrack: meta.recallFastTrack === true,
790
- lastRecallFastTrackError: Object.hasOwn(meta, 'recallFastTrackError')
791
- ? (meta.recallFastTrackError ?? null)
792
- : (prev.lastRecallFastTrackError ?? null),
793
- lastError: Object.hasOwn(meta, 'compactError') || Object.hasOwn(meta, 'lastError')
794
- ? (meta.compactError ?? meta.lastError ?? null)
795
- : (prev.lastError ?? null),
796
- lastPruneCount: meta.pruneCount || 0,
797
- lastDurationMs: meta.durationMs != null && Number.isFinite(Number(meta.durationMs))
798
- ? Math.max(0, Math.round(Number(meta.durationMs)))
799
- : null,
800
- compactCount: (prev.compactCount || 0) + (changed ? 1 : 0),
801
- };
802
- if (changed) {
803
- const changedAt = Date.now();
804
- sessionRef.compaction.lastChangedAt = changedAt;
805
- sessionRef.compaction.lastCompactAt = changedAt;
806
- sessionRef.lastContextTokensStaleAfterCompact = true;
807
- }
808
- sessionRef.contextWindow = policy.contextWindow || sessionRef.contextWindow;
809
- sessionRef.rawContextWindow = policy.rawContextWindow || sessionRef.rawContextWindow;
810
- sessionRef.compactBoundaryTokens = policy.boundaryTokens || sessionRef.compactBoundaryTokens || null;
811
- // Persist only the sanitized (sub-boundary) explicit limit. policy.autoCompactTokenLimit
812
- // is already null for legacy derived full-window values, so a stale
813
- // boundary-sized autoCompactTokenLimit on the session is cleared here rather
814
- // than carried forward to re-collapse the buffer next turn.
815
- {
816
- const _boundary = positiveTokenInt(sessionRef.compactBoundaryTokens);
817
- const _prevLimit = positiveTokenInt(sessionRef.autoCompactTokenLimit);
818
- const _keepPrev = _prevLimit && (!_boundary || _prevLimit < _boundary) ? _prevLimit : null;
819
- sessionRef.autoCompactTokenLimit = policy.autoCompactTokenLimit || _keepPrev || null;
820
- }
821
- if (policy.effectiveContextWindowPercent !== null) {
822
- sessionRef.effectiveContextWindowPercent = policy.effectiveContextWindowPercent;
823
- }
824
- }
825
-
826
- function emitCompactEvent(opts, event = {}) {
827
- if (!opts || typeof opts.onCompactEvent !== 'function') return;
828
- try { opts.onCompactEvent({ ts: Date.now(), ...event }); }
829
- catch { /* best-effort UI/log hook */ }
830
- }
831
-
832
- function compactEventType(policy, fallback = DEFAULT_COMPACT_TYPE) {
833
- return policy?.compactType || policy?.type || fallback;
834
- }
835
- const SKILL_TOOL_NAMES = new Set(['Skill', 'skills_list', 'skill_view']);
836
- const SPECIAL_TOOL_NAMES = new Set(['bash_session', 'apply_patch', 'code_graph']);
837
- const BASH_SESSION_HEADER_RE = /\[session: ([^\]\r\n]+)\]/;
838
- const STORED_TOOL_ARG_BODY_KEY_RE = /^(?:content|old_string|new_string|patch|rewrite)$/i;
839
- const STORED_TOOL_ARG_LONG_KEY_RE = /^(?:command|script)$/i;
840
- const STORED_TOOL_ARG_BODY_LIMIT = 2_000;
841
- const STORED_TOOL_ARG_LONG_LIMIT = 8_000;
842
- const STORED_TOOL_ARG_PREVIEW_HEAD = 360;
843
- const STORED_TOOL_ARG_PREVIEW_TAIL = 160;
844
-
845
- function compactStoredToolArgString(value, key = '') {
846
- if (typeof value !== 'string') return value;
847
- const isBody = STORED_TOOL_ARG_BODY_KEY_RE.test(key);
848
- const isLong = isBody || STORED_TOOL_ARG_LONG_KEY_RE.test(key);
849
- const limit = isBody ? STORED_TOOL_ARG_BODY_LIMIT : (isLong ? STORED_TOOL_ARG_LONG_LIMIT : Infinity);
850
- if (value.length <= limit) return value;
851
- const hash = createHash('sha256').update(value).digest('hex').slice(0, 16);
852
- const head = value.slice(0, STORED_TOOL_ARG_PREVIEW_HEAD).replace(/\r\n/g, '\n');
853
- const tail = value.slice(-STORED_TOOL_ARG_PREVIEW_TAIL).replace(/\r\n/g, '\n');
854
- return `[mixdog compacted ${key || 'string'}: ${value.length} chars, sha256:${hash}]\n${head}\n... [middle omitted from stored tool-call args] ...\n${tail}`;
855
- }
856
-
857
- function compactStoredToolArgValue(value, key = '', depth = 0) {
858
- if (value === null || value === undefined) return value;
859
- if (typeof value === 'string') return compactStoredToolArgString(value, key);
860
- if (typeof value !== 'object') return value;
861
- if (depth >= 6) return Array.isArray(value) ? `[${value.length} items]` : '{...}';
862
- if (Array.isArray(value)) {
863
- return value.map((item) => compactStoredToolArgValue(item, key, depth + 1));
864
- }
865
- const out = {};
866
- for (const [k, v] of Object.entries(value)) {
867
- out[k] = compactStoredToolArgValue(v, k, depth + 1);
868
- }
869
- return out;
870
- }
871
-
872
- function compactToolCallsForHistory(calls) {
873
- if (!Array.isArray(calls)) return calls;
874
- return calls.map((call) => {
875
- if (!call || typeof call !== 'object') return call;
876
- return {
877
- ...call,
878
- arguments: compactStoredToolArgValue(call.arguments),
879
- };
880
- });
881
- }
882
-
883
- // Restore the FULL body of ONE tool call inside a history assistant message
884
- // whose toolCalls were compacted at push time. Used for a failed edit call so
885
- // the model sees the original patch/old_string on retry instead of a
886
- // `[mixdog compacted …]` placeholder it cannot act on. Must run BEFORE the
887
- // message is first transmitted so it never mutates an already-cached prefix
888
- // (the prompt cache is content-prefix matched).
889
- //
890
- // Only the compactable body/long keys (patch, old_string, new_string, content,
891
- // rewrite, command, script) are restored, and at ANY depth — compaction is
892
- // recursive (compactStoredToolArgValue), so batch shapes like edits[].old_string
893
- // or writes[].content carry nested compacted bodies too. Every other field
894
- // (e.g. `path`, which a tool may mutate in place during execution) is taken from
895
- // the compacted snapshot captured at push time, before any mutation. The
896
- // compacted args tree is built fresh by compactToolCallsForHistory and is not
897
- // shared with originalCalls, so rebuilding it here is safe.
898
- function restoreToolCallBodyForId(assistantMsg, originalCalls, callId) {
899
- if (!assistantMsg || !Array.isArray(assistantMsg.toolCalls) || !callId) return;
900
- if (!Array.isArray(originalCalls)) return;
901
- const tc = assistantMsg.toolCalls.find((t) => t && t.id === callId);
902
- const orig = originalCalls.find((c) => c && c.id === callId);
903
- if (!tc || !orig) return;
904
- if (!tc.arguments || typeof tc.arguments !== 'object'
905
- || !orig.arguments || typeof orig.arguments !== 'object') return;
906
- tc.arguments = _restoreCompactedBodies(tc.arguments, orig.arguments, '');
907
- }
908
-
909
- // Recursively rebuild a compacted args tree: replace ONLY compactable body/long
910
- // string fields (matched by key at any depth) with their full originals, and
911
- // keep every other field from the compacted snapshot. tcVal and origVal share
912
- // the same structure (compaction only shortens body strings), so the walk
913
- // descends them in parallel; a missing or non-object origVal falls back to the
914
- // compacted value rather than throwing.
915
- function _restoreCompactedBodies(tcVal, origVal, key) {
916
- if ((STORED_TOOL_ARG_BODY_KEY_RE.test(key) || STORED_TOOL_ARG_LONG_KEY_RE.test(key))
917
- && typeof origVal === 'string') {
918
- return origVal;
919
- }
920
- if (Array.isArray(tcVal) && Array.isArray(origVal)) {
921
- return tcVal.map((item, i) => _restoreCompactedBodies(item, origVal[i], key));
922
- }
923
- if (tcVal && typeof tcVal === 'object' && origVal && typeof origVal === 'object') {
924
- const out = {};
925
- for (const k of Object.keys(tcVal)) {
926
- out[k] = (k in origVal) ? _restoreCompactedBodies(tcVal[k], origVal[k], k) : tcVal[k];
927
- }
928
- return out;
929
- }
930
- return tcVal;
931
- }
932
- /**
933
- * Execute a single tool call — routes to MCP or builtin.
934
- */
935
- function getToolKind(name) {
936
- if (SKILL_TOOL_NAMES.has(name)) return 'skill';
937
- if (SPECIAL_TOOL_NAMES.has(name)) return 'builtin';
938
- if (isMcpTool(name)) return 'mcp';
939
- if (isInternalTool(name)) return 'internal';
940
- if (isBuiltinTool(name)) return 'builtin';
941
- return 'builtin';
942
- }
943
- function buildSkillsListResponse(cwd) {
944
- const skills = collectSkillsCached(cwd);
945
- const entries = skills.map(s => ({ name: s.name, description: s.description || '' }));
946
- return JSON.stringify({ skills: entries });
947
- }
948
- function viewSkill(cwd, name) {
949
- if (!name) return 'Error: skill name is required';
950
- const content = loadSkillContent(name, cwd);
951
- if (!content) return `Error: skill "${name}" not found`;
952
- return `<skill>\n<name>${String(name).replace(/[<>&]/g, (ch) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[ch]))}</name>\n${content}\n</skill>`;
953
- }
954
-
955
- /** Normalize PostToolUse hook override values (legacy MCP text envelopes only). */
956
- export function normalizeHookUpdatedToolOutput(value) {
957
- if (typeof value === 'string') return value;
958
- if (value == null) return '';
959
- if (typeof value === 'object' && Array.isArray(value.content)) {
960
- const hasNonText = value.content.some((c) => c && typeof c === 'object' && c.type && c.type !== 'text');
961
- if (hasNonText) return value;
962
- return value.content
963
- .map((c) => (c?.type === 'text' ? c.text || '' : JSON.stringify(c)))
964
- .join('\n');
965
- }
966
- return value;
967
- }
968
-
969
- export function resolveToolResultAfterHook(originalResult, hookResult) {
970
- if (!hookResult || typeof hookResult !== 'object' || hookResult.updatedToolOutput === undefined) {
971
- return originalResult;
972
- }
973
- const updated = normalizeHookUpdatedToolOutput(hookResult.updatedToolOutput);
974
- return updated === undefined ? originalResult : updated;
975
- }
976
-
977
- function parseNativeToolSearchPayload(toolName, result) {
978
- if (toolName !== 'tool_search' || typeof result !== 'string') return null;
979
- try {
980
- const parsed = JSON.parse(result);
981
- const native = parsed?.nativeToolSearch;
982
- if (!native || typeof native !== 'object') return null;
983
- const toolReferences = Array.isArray(native.toolReferences)
984
- ? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
985
- : [];
986
- const openaiTools = Array.isArray(native.openaiTools)
987
- ? native.openaiTools.filter((tool) => tool && typeof tool === 'object')
988
- : [];
989
- if (!toolReferences.length && !openaiTools.length) return null;
990
- return {
991
- toolReferences,
992
- openaiTools,
993
- summary: typeof native.summary === 'string' && native.summary
994
- ? native.summary
995
- : `Loaded deferred tools: ${toolReferences.join(', ') || openaiTools.map((tool) => tool.name).filter(Boolean).join(', ')}`,
244
+ diagnostics.totalMs = Date.now() - startedAt;
245
+ if (result && typeof result === 'object') {
246
+ result.diagnostics = {
247
+ ...(result.diagnostics || {}),
248
+ pipeline: diagnostics,
996
249
  };
997
- } catch {
998
- return null;
999
- }
1000
- }
1001
- function extractBashSessionId(result) {
1002
- if (typeof result !== 'string') return null;
1003
- const match = BASH_SESSION_HEADER_RE.exec(result);
1004
- return match ? match[1] : null;
1005
- }
1006
-
1007
- export function buildAgentBashSessionArgs(args, sessionRef) {
1008
- if (!isAgentOwner(sessionRef)) return null;
1009
- // run_in_background is a detached one-shot job, incompatible with the
1010
- // persistent bash session. Fall through to the background-job path
1011
- // (executeBuiltinTool -> startBackgroundShellJob) so the worker gets a
1012
- // task_id that task control can resolve — otherwise the persistent
1013
- // session returns a [session: ...] header and task control reports "task not found".
1014
- if (args?.run_in_background === true) return null;
1015
- const routedArgs = { ...(args || {}) };
1016
- const explicitSessionId = typeof routedArgs.session_id === 'string' && routedArgs.session_id.trim()
1017
- ? routedArgs.session_id.trim()
1018
- : null;
1019
- const wantsPersistent = routedArgs.persistent === true || !!explicitSessionId;
1020
- if (!wantsPersistent) return null;
1021
- if (!explicitSessionId && sessionRef?.implicitBashSessionId) {
1022
- routedArgs.session_id = sessionRef.implicitBashSessionId;
1023
- } else if (explicitSessionId) {
1024
- routedArgs.session_id = explicitSessionId;
1025
250
  }
1026
- delete routedArgs.persistent;
1027
- return routedArgs;
1028
- }
1029
-
1030
- export function formatMissingToolApprovalUiDenial(toolName, askReason) {
1031
- const reason = String(askReason || 'approval requested by hook').trim();
1032
- const name = String(toolName || 'tool');
1033
- return `Error: tool "${name}" denied by hook: approval required but no approval UI is available${reason ? ` (${reason})` : ''}`;
251
+ compactDebugLog('recall-fasttrack pipeline', diagnostics);
252
+ return result;
1034
253
  }
1035
-
1036
- /**
1037
- * Resolve PreToolUse `{ action: 'ask' }` against an optional approval callback.
1038
- * Returns `{ denial }` when the tool must not run; otherwise `{ approval }`.
1039
- */
1040
- export async function resolvePreToolAskApproval({
1041
- toolName,
1042
- args,
1043
- cwd,
1044
- sessionId,
1045
- toolCallId,
1046
- askReason,
1047
- toolApprovalHook,
1048
- }) {
1049
- const name = String(toolName || 'tool');
1050
- const reason = String(askReason || 'approval requested by hook').trim();
1051
- if (typeof toolApprovalHook !== 'function') {
1052
- return { denial: formatMissingToolApprovalUiDenial(name, reason) };
1053
- }
1054
- let approval;
1055
- try {
1056
- approval = await toolApprovalHook({
1057
- name,
1058
- args,
1059
- cwd,
1060
- sessionId,
1061
- toolCallId: toolCallId || null,
1062
- reason,
1063
- });
1064
- } catch (error) {
1065
- const detail = error?.message || String(error || 'approval failed');
1066
- return { denial: `Error: tool "${name}" denied by hook: ${detail}` };
1067
- }
1068
- if (!approvalGranted(approval)) {
1069
- const detail = approvalReason(approval, reason || 'not approved');
1070
- return { denial: `Error: tool "${name}" denied by hook: ${detail}` };
1071
- }
1072
- return { approval };
1073
- }
1074
-
1075
254
  function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, callerSessionId, executeOpts = {}) {
1076
255
  if (executeOpts.scopedCacheOutcome) {
1077
256
  if (sessionRef && toolCallId) {
@@ -1238,13 +417,19 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1238
417
  }
1239
418
  if (name === 'apply_patch') {
1240
419
  const patchArgs = typeof args === 'string' ? { patch: args } : args;
1241
- return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId });
420
+ return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId, toolCallId: executeOpts.toolCallId || null });
1242
421
  }
1243
422
  if (isBuiltinTool(name)) {
1244
423
  // clientHostPid threaded for the same per-terminal job-scope reason as
1245
424
  // the bash branch above (see resolveJobOwnerHostPid).
1246
425
  return executeBuiltinTool(name, args, cwd, completionToolOpts);
1247
426
  }
427
+ if (isExternalAdapterTool(name)) {
428
+ // Foreign-CLI tool names (StrReplace/Write/bash variants) adapt to a
429
+ // native execution inside executeBuiltinTool's default: case; on a
430
+ // shape mismatch it falls back to the redirect guidance message.
431
+ return executeBuiltinTool(name, args, cwd, completionToolOpts);
432
+ }
1248
433
  return formatUnknownBuiltinToolMessage(name, args, 'tool');
1249
434
  })();
1250
435
  if (typeof afterToolHook === 'function') {
@@ -1257,7 +442,14 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1257
442
  toolCallId: executeOpts.toolCallId || null,
1258
443
  result: __result,
1259
444
  });
1260
- return resolveToolResultAfterHook(__result, hookResult);
445
+ // Envelope-aware hook override: a PostToolUse hook may override the
446
+ // model-VISIBLE tool output (the envelope's `result` / stub), but it
447
+ // must NEVER drop the `newMessages` channel. Split first, apply the
448
+ // override to `result` only, then re-wrap so newMessages survive.
449
+ const { result: __res, newMessages: __nm } = normalizeToolEnvelope(__result);
450
+ const __overridden = resolveToolResultAfterHook(__res, hookResult);
451
+ if (__nm.length) return makeToolEnvelope(__overridden, __nm);
452
+ return __overridden;
1261
453
  } catch {
1262
454
  // PostToolUse hooks are best-effort; never let one break the tool result.
1263
455
  }
@@ -1275,13 +467,6 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1275
467
  * wrapper can propagate a clean cancellation.
1276
468
  * - `onStageChange(stage)` / `onStreamDelta()` — forwarded to provider.send for heartbeats
1277
469
  */
1278
- // Source of truth: defaults/hidden-roles.json (loaded via _getHiddenRoles
1279
- // above). Build the name Set eagerly at module load so HIDDEN_ROLE_NAMES
1280
- // stays in sync with the declarative registry — no hardcoded duplicate.
1281
- const HIDDEN_ROLE_NAMES = new Set(
1282
- (_getHiddenRoles().roles || []).map((r) => r && r.name).filter((n) => typeof n === 'string' && n.length > 0)
1283
- );
1284
-
1285
470
  // Stop reasons that signal the turn was cut short mid-synthesis (token cap,
1286
471
  // provider pause). Empty content + one of these reasons means the worker
1287
472
  // was not done — re-prompt instead of accepting empty as final.
@@ -1291,22 +476,6 @@ const INCOMPLETE_STOP_REASONS = new Set([
1291
476
  'pause_turn', 'max_tokens', 'length', 'MAX_TOKENS', 'OTHER',
1292
477
  ]);
1293
478
 
1294
- export function approvalGranted(value) {
1295
- if (value === true) return true;
1296
- if (!value || typeof value !== 'object') return false;
1297
- if (value.approved === true || value.allow === true || value.allowed === true) return true;
1298
- const decision = String(value.decision || value.action || value.result || '').trim().toLowerCase();
1299
- return decision === 'approve' || decision === 'approved' || decision === 'allow' || decision === 'yes';
1300
- }
1301
-
1302
- export function approvalReason(value, fallback = '') {
1303
- if (value && typeof value === 'object') {
1304
- const reason = String(value.reason || value.message || '').trim();
1305
- if (reason) return reason;
1306
- }
1307
- return fallback;
1308
- }
1309
-
1310
479
  export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
1311
480
  let iterations = 0;
1312
481
  let toolCallsTotal = 0;
@@ -1315,6 +484,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1315
484
  let response;
1316
485
  let contractNudges = 0;
1317
486
  let contextOverflowRetryUsed = false;
487
+ // Set when the hard iteration-cap break below fires. Consumed at the final
488
+ // return to tag terminationReason='iteration_cap' so a worker that exhausts
489
+ // the loop without a final answer surfaces to Lead as an explicit error
490
+ // instead of a silent empty "completed".
491
+ let terminatedByCap = false;
1318
492
  // Set when a provider context-overflow refusal triggers the in-turn
1319
493
  // reactive compact retry below; consumed by the next pre-send compact pass
1320
494
  // so its telemetry/events carry trigger:'reactive' (distinct from the
@@ -1323,7 +497,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1323
497
  const opts = sendOpts || {};
1324
498
  const sessionId = opts.sessionId || null;
1325
499
  const signal = opts.signal || null;
1326
- const sessionRole = opts.session?.role;
500
+ const sessionAgent = opts.session?.agent;
1327
501
  const forcedFirstTool = opts.forcedFirstTool ?? null;
1328
502
  const forcedFirstToolDef = forcedFirstTool
1329
503
  ? tools.find(tool => tool?.name === forcedFirstTool)
@@ -1396,9 +570,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1396
570
  });
1397
571
  return true;
1398
572
  };
1399
- const maxLoopIterations = Number.isFinite(sessionRef?.maxLoopIterations)
1400
- ? sessionRef.maxLoopIterations
1401
- : MAX_LOOP_ITERATIONS;
573
+ const maxLoopIterations = resolveSessionMaxLoopIterations(sessionRef);
1402
574
  // Tool execution must use the session cwd even when the caller omitted the
1403
575
  // legacy positional cwd argument. Agent workers always carry their cwd on
1404
576
  // sessionRef; falling through to pwd()/process.cwd() resolves relatives
@@ -1408,6 +580,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1408
580
  throwIfAborted();
1409
581
  if (iterations >= maxLoopIterations) {
1410
582
  process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
583
+ terminatedByCap = true;
1411
584
  break;
1412
585
  }
1413
586
  // Drain queued steering/prompts BEFORE the
@@ -1460,6 +633,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1460
633
  // them apart, then clear the one-shot flag.
1461
634
  const compactTrigger = reactiveOverflowRetryPending ? 'reactive' : 'auto';
1462
635
  reactiveOverflowRetryPending = false;
636
+ // PreCompact: bridge to the standard hook bus before compaction
637
+ // runs. session-property hook (manager/loop have no bus access).
638
+ // { trigger } normalized to 'auto'|'manual'. Best-effort.
639
+ {
640
+ const _preCompactHook = typeof opts.preCompactHook === 'function'
641
+ ? opts.preCompactHook
642
+ : sessionRef?.preCompactHook;
643
+ if (typeof _preCompactHook === 'function') {
644
+ try { await _preCompactHook({ sessionId, cwd, trigger: compactTrigger === 'manual' ? 'manual' : 'auto' }); }
645
+ catch { /* best-effort: PreCompact hook must never break compaction */ }
646
+ }
647
+ }
1463
648
  rememberCompactTelemetry(sessionRef, compactPolicy, {
1464
649
  stage: 'compacting',
1465
650
  beforeTokens: messageTokensEst,
@@ -1570,6 +755,51 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1570
755
  }
1571
756
  summaryChanged = messagesArrayChanged(compactInputMessages, compacted);
1572
757
  } catch (compactErr) {
758
+ // Anchor-independent prune safety net. When SEMANTIC compact
759
+ // throws (e.g. a degenerate single-turn transcript, or a
760
+ // summary that cannot fit), attempt one non-LLM prune that
761
+ // needs no user anchor: middle-truncate the oldest oversized
762
+ // tool_result bodies until the transcript fits the budget.
763
+ // If it shrinks the transcript we continue with that result
764
+ // instead of escalating to overflow. Structure/pairing is
765
+ // preserved (only string content shrinks) and the result is
766
+ // re-reconciled inside the helper.
767
+ //
768
+ // GATED to the non-recall path: a recall-fasttrack failure
769
+ // must NOT be silently recovered by this prune (that would
770
+ // change the type-2 path's contract by shipping a pruned
771
+ // transcript with no recall output). When recallFastTrackError
772
+ // is set the fallback is skipped and the original overflow
773
+ // escalation runs unchanged.
774
+ if (!recallFastTrackError) {
775
+ try {
776
+ // Accept only if the pruned transcript fits the SAME
777
+ // effective budget the prune targets (compactBudgetTokens
778
+ // minus the request reserve) — comparing against the raw
779
+ // compactBudgetTokens would accept a result with no
780
+ // reserve headroom and overflow on the very next send.
781
+ const acceptThreshold = compactEffectiveBudget(compactBudgetTokens, {
782
+ reserveTokens: compactPolicy.reserveTokens,
783
+ });
784
+ const salvaged = pruneToolOutputsUnanchored(messages, compactBudgetTokens, {
785
+ reserveTokens: compactPolicy.reserveTokens,
786
+ });
787
+ if (messagesArrayChanged(messages, salvaged)
788
+ && estimateMessagesTokensSafe(salvaged) <= acceptThreshold) {
789
+ compacted = salvaged;
790
+ pruneCount = countPrunedToolOutputs(messages, salvaged);
791
+ summaryChanged = true;
792
+ }
793
+ } catch { /* fall through to overflow escalation */ }
794
+ }
795
+ if (compacted !== undefined) {
796
+ try {
797
+ process.stderr.write(
798
+ `[loop] compact fallback prune recovered (sess=${sessionId || 'unknown'}): ` +
799
+ `${compactErr?.message || compactErr}\n`,
800
+ );
801
+ } catch { /* best-effort */ }
802
+ } else {
1573
803
  const compactFailMsg = compactErr && compactErr.message ? compactErr.message : String(compactErr);
1574
804
  const semanticFailMsg = semanticCompactError?.message || null;
1575
805
  const recallFailMsg = recallFastTrackError?.message || null;
@@ -1593,6 +823,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1593
823
  iteration: iterations + 1,
1594
824
  stage: 'pre_send',
1595
825
  trigger: compactTrigger,
826
+ compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
1596
827
  prune_count: pruneCount,
1597
828
  compact_changed: false,
1598
829
  input_prefix_hash: messagePrefixHash(messages),
@@ -1602,13 +833,23 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1602
833
  after_bytes: getBeforeBytes(),
1603
834
  context_window: compactPolicy.contextWindow,
1604
835
  budget_tokens: compactPolicy.boundaryTokens,
836
+ boundary_tokens: compactPolicy.boundaryTokens,
1605
837
  target_budget_tokens: compactBudgetTokens,
1606
838
  reserve_tokens: compactPolicy.reserveTokens,
839
+ pressure_tokens: pressureTokens,
840
+ trigger_tokens: compactPolicy.triggerTokens,
1607
841
  message_tokens_est: messageTokensEst,
842
+ duration_ms: Date.now() - compactStartedAt,
1608
843
  provider: sessionRef.provider,
1609
844
  model: sessionRef.model || model,
1610
845
  error: compactFailMsg,
1611
846
  error_code: compactFailCode,
847
+ details: {
848
+ semantic: semanticCompactResult?.diagnostics || null,
849
+ recallFastTrack: recallFastTrackResult?.diagnostics || null,
850
+ semanticError: semanticFailMsg,
851
+ recallFastTrackError: recallFailMsg,
852
+ },
1612
853
  });
1613
854
  emitCompactEvent(opts, {
1614
855
  sessionId,
@@ -1640,6 +881,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1640
881
  reserveTokens: compactPolicy.reserveTokens,
1641
882
  messageTokensEst,
1642
883
  }, compactErr);
884
+ }
1643
885
  }
1644
886
  try { opts.onStageChange?.('requesting'); } catch { /* best-effort */ }
1645
887
  const compactChanged = messagesArrayChanged(messages, compacted);
@@ -1684,6 +926,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1684
926
  iteration: iterations + 1,
1685
927
  stage: 'pre_send',
1686
928
  trigger: compactTrigger,
929
+ compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
1687
930
  prune_count: pruneCount,
1688
931
  compact_changed: compactChanged || summaryChanged,
1689
932
  input_prefix_hash: messagePrefixHash(messages),
@@ -1693,11 +936,19 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1693
936
  after_bytes: afterBytes,
1694
937
  context_window: compactPolicy.contextWindow,
1695
938
  budget_tokens: compactPolicy.boundaryTokens,
939
+ boundary_tokens: compactPolicy.boundaryTokens,
1696
940
  target_budget_tokens: compactBudgetTokens,
1697
941
  reserve_tokens: compactPolicy.reserveTokens,
942
+ pressure_tokens: pressureTokens,
943
+ trigger_tokens: compactPolicy.triggerTokens,
1698
944
  message_tokens_est: messageTokensEst,
945
+ duration_ms: compactDurationMs,
1699
946
  provider: sessionRef.provider,
1700
947
  model: sessionRef.model || model,
948
+ details: {
949
+ semantic: semanticCompactResult?.diagnostics || null,
950
+ recallFastTrack: recallFastTrackResult?.diagnostics || null,
951
+ },
1701
952
  });
1702
953
  emitCompactEvent(opts, {
1703
954
  sessionId,
@@ -1721,6 +972,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1721
972
  durationMs: compactDurationMs,
1722
973
  });
1723
974
  }
975
+ // PostCompact: bridge to the standard hook bus after compaction
976
+ // completes. session-property hook; { trigger } 'auto'|'manual'.
977
+ {
978
+ const _postCompactHook = typeof opts.postCompactHook === 'function'
979
+ ? opts.postCompactHook
980
+ : sessionRef?.postCompactHook;
981
+ if (typeof _postCompactHook === 'function') {
982
+ try { await _postCompactHook({ sessionId, cwd, trigger: compactTrigger === 'manual' ? 'manual' : 'auto' }); }
983
+ catch { /* best-effort: PostCompact hook must never break the loop */ }
984
+ }
985
+ }
1724
986
  }
1725
987
  const nextIteration = iterations + 1;
1726
988
  opts.iteration = nextIteration;
@@ -1775,12 +1037,12 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1775
1037
  // Shared pre-dispatch deny: identical predicate runs in the
1776
1038
  // serial path below. If any role/permission guard would reject
1777
1039
  // this call there, never start it eagerly here.
1778
- if (_preDispatchDeny(call, toolKind, sessionRef) !== null) return null;
1040
+ if (preDispatchDenyForSession(sessionRef, call, toolKind) !== null) return null;
1779
1041
  const entry = { startedAt: Date.now(), endedAt: null, mutationEpoch: _mutationEpoch };
1780
1042
  _eagerInFlightSigs.set(_sig, call.id);
1781
1043
  entry.promise = (async () => {
1782
1044
  try {
1783
- return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval }) };
1045
+ return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: nextIteration }) };
1784
1046
  } catch (error) {
1785
1047
  return { ok: false, error };
1786
1048
  }
@@ -1810,10 +1072,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1810
1072
  // calls (they return null above), so those `continue`-before-
1811
1073
  // execution stub paths can never early-notify (contract #5).
1812
1074
  try {
1075
+ // UI-only: surface the model-VISIBLE result (envelope
1076
+ // stub for envelope returns), never the envelope object
1077
+ // or its injected newMessages body — no [object Object],
1078
+ // no full skill body in the tool card.
1079
+ const _earlyVisible = settled && settled.ok
1080
+ ? normalizeToolEnvelope(settled.value).result
1081
+ : null;
1813
1082
  const _earlyContent = settled && settled.ok
1814
- ? (typeof settled.value === 'string'
1815
- ? settled.value
1816
- : (settled.value == null ? '' : String(settled.value)))
1083
+ ? (typeof _earlyVisible === 'string'
1084
+ ? _earlyVisible
1085
+ : (_earlyVisible == null ? '' : String(_earlyVisible)))
1817
1086
  : `Error: ${settled && settled.error instanceof Error ? settled.error.message : String(settled && settled.error)}`;
1818
1087
  opts.onToolResult?.({
1819
1088
  role: 'tool',
@@ -1870,6 +1139,88 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1870
1139
  try {
1871
1140
  response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
1872
1141
  } catch (sendErr) {
1142
+ // Partial-final recovery (owner-notify fix): the recurring "worker
1143
+ // finished but the task hung / no result delivered" case is a FINAL,
1144
+ // no-tool summary stream that wedges (ping-only) AFTER all real tool
1145
+ // work completed in earlier iterations. The provider attaches its
1146
+ // partial stream state to the StreamStalledError. When the stall
1147
+ // carries streamed assistant text, has NO pending tool_use, and did
1148
+ // NOT emit a tool call this iteration, accept the partial as a
1149
+ // successful terminal response (deliver the summary we have) instead
1150
+ // of throwing — which would strand/notify-as-failure a turn whose
1151
+ // work actually succeeded. A stall WITH a pending/emitted tool call
1152
+ // is NOT recoverable (a tool whose input never completed must never
1153
+ // look done) and falls through to the normal error path.
1154
+ if (
1155
+ sendErr?.streamStalled === true
1156
+ && sendErr.pendingToolUse !== true
1157
+ && sendErr.unsafeToRetry !== true
1158
+ && typeof sendErr.partialContent === 'string'
1159
+ && sendErr.partialContent.trim().length > 0
1160
+ && !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
1161
+ ) {
1162
+ try {
1163
+ process.stderr.write(
1164
+ `[loop] final stream stalled with partial text (sess=${sessionId || 'unknown'} `
1165
+ + `iter=${nextIteration} len=${sendErr.partialContent.length}); `
1166
+ + `accepting as partial-final success\n`,
1167
+ );
1168
+ } catch { /* best-effort */ }
1169
+ response = {
1170
+ content: sendErr.partialContent,
1171
+ model: sendErr.partialModel || model,
1172
+ toolCalls: undefined,
1173
+ usage: sendErr.partialUsage || undefined,
1174
+ stopReason: sendErr.partialStopReason || 'end_turn',
1175
+ hasThinkingContent: sendErr.partialHasThinking === true,
1176
+ partialFinal: true,
1177
+ };
1178
+ } else
1179
+ // Partial tool-call recovery (agent-hang fix): a stream that stalls
1180
+ // AFTER fully-parsed tool calls were emitted used to lose the whole
1181
+ // turn — unsafeToRetry blocks the mid-stream replay (correct: a
1182
+ // replay would re-run side-effecting tools) and the old code threw,
1183
+ // discarding tool work that had ALREADY completed via eager dispatch.
1184
+ // But the parsed calls are complete (pendingToolUse false ⇒ no
1185
+ // half-streamed tool input), so instead of replaying the request we
1186
+ // accept the partial as a normal tool-call turn and fall through to
1187
+ // the standard execution path: eager-dispatched (read-only) calls
1188
+ // resolve from the pending map without re-running, side-effecting
1189
+ // calls were never started during streaming and execute exactly
1190
+ // once. providerState stays undefined so the next iteration resends
1191
+ // a full frame on a fresh stream.
1192
+ if (
1193
+ sendErr?.streamStalled === true
1194
+ && sendErr.pendingToolUse !== true
1195
+ && Array.isArray(sendErr.partialToolCalls)
1196
+ && sendErr.partialToolCalls.length > 0
1197
+ ) {
1198
+ try {
1199
+ process.stderr.write(
1200
+ `[loop] stream stalled after ${sendErr.partialToolCalls.length} complete tool call(s) `
1201
+ + `(sess=${sessionId || 'unknown'} iter=${nextIteration}); `
1202
+ + `recovering as tool-call turn instead of failing\n`,
1203
+ );
1204
+ } catch { /* best-effort */ }
1205
+ try {
1206
+ appendAgentTrace({
1207
+ kind: 'stall_tool_recovery',
1208
+ sessionId: sessionId || null,
1209
+ iteration: nextIteration,
1210
+ toolCalls: sendErr.partialToolCalls.length,
1211
+ partialContentLen: typeof sendErr.partialContent === 'string' ? sendErr.partialContent.length : 0,
1212
+ });
1213
+ } catch { /* best-effort */ }
1214
+ response = {
1215
+ content: typeof sendErr.partialContent === 'string' ? sendErr.partialContent : '',
1216
+ model: sendErr.partialModel || model,
1217
+ toolCalls: sendErr.partialToolCalls.slice(),
1218
+ usage: sendErr.partialUsage || undefined,
1219
+ stopReason: 'tool_use',
1220
+ hasThinkingContent: sendErr.partialHasThinking === true,
1221
+ partialToolRecovery: true,
1222
+ };
1223
+ } else
1873
1224
  // Context-window-exceeded is a deterministic refusal from the API.
1874
1225
  // Recover context overflow reactively by compacting and retrying
1875
1226
  // in the same active turn. MixDog's proactive estimator can miss a
@@ -1922,13 +1273,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1922
1273
  // the stateless contract for providers that don't use continuation).
1923
1274
  providerState = response?.providerState ?? undefined;
1924
1275
  iterations = nextIteration;
1925
- traceAgentLoop({
1926
- sessionId,
1927
- iteration: iterations,
1928
- sendMs: Date.now() - sendStartedAt,
1929
- messageCount: Array.isArray(messages) ? messages.length : 0,
1930
- bodyBytesEst: estimateProviderPayloadBytes(messages, model, sendTools),
1931
- });
1276
+ // Payload byte estimate serializes the FULL messages+tools array —
1277
+ // only pay that cost when verbose loop tracing is actually enabled
1278
+ // (traceAgentLoop is a no-op otherwise).
1279
+ if (process.env.MIXDOG_AGENT_TRACE_VERBOSE === '1') {
1280
+ traceAgentLoop({
1281
+ sessionId,
1282
+ iteration: iterations,
1283
+ sendMs: Date.now() - sendStartedAt,
1284
+ messageCount: Array.isArray(messages) ? messages.length : 0,
1285
+ bodyBytesEst: estimateProviderPayloadBytes(messages, model, sendTools),
1286
+ });
1287
+ }
1932
1288
  // Accumulate usage across iterations — every billable slot, not just
1933
1289
  // input/output. Anthropic cache_read/cache_write typically stay 0 on
1934
1290
  // the first iteration and surge on later ones (warm prefix reuse),
@@ -1948,6 +1304,37 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1948
1304
  // Provider may have returned despite an abort (SDKs that don't honour
1949
1305
  // signal) — bail before processing any of its output.
1950
1306
  throwIfAborted();
1307
+ // P1 audit fix (Step4): a text-only turn truncated by the provider's
1308
+ // max-output limit (response.truncated, set by the provider layer
1309
+ // when stopReason==='length' AND content is non-empty) used to look
1310
+ // identical to a clean completion — the model's answer could be
1311
+ // silently cut mid-sentence with zero signal to the operator. Surface
1312
+ // it as a one-line stderr warning + trace event WITHOUT failing the
1313
+ // turn (the partial content is still usable and the loop's own
1314
+ // isIncompleteStop nudge below already re-prompts when content is
1315
+ // empty).
1316
+ if (response?.truncated === true) {
1317
+ try {
1318
+ process.stderr.write(
1319
+ `[loop] provider output truncated at max-output limit (sess=${sessionId || 'unknown'} `
1320
+ + `iter=${iterations} stopReason=${response.stopReason ?? response.stop_reason ?? 'length'} `
1321
+ + `contentLen=${typeof response.content === 'string' ? response.content.length : 0}); `
1322
+ + `answer may be cut off mid-sentence.\n`,
1323
+ );
1324
+ } catch { /* best-effort */ }
1325
+ try {
1326
+ appendAgentTrace({
1327
+ sessionId,
1328
+ iteration: iterations,
1329
+ kind: 'output_truncated',
1330
+ payload: {
1331
+ stop_reason: response.stopReason ?? response.stop_reason ?? 'length',
1332
+ content_len: typeof response.content === 'string' ? response.content.length : 0,
1333
+ agent: sessionAgent || null,
1334
+ },
1335
+ });
1336
+ } catch { /* best-effort */ }
1337
+ }
1951
1338
  // Incremental metric persistence (fix A): push per-iteration token delta
1952
1339
  // immediately so watchdog / agent type=list sees live totals mid-turn.
1953
1340
  if (sessionId && opts.onUsageDelta && response.usage) {
@@ -1994,7 +1381,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1994
1381
  // reminders waste turns and fragment the working context, so
1995
1382
  // the second empty turn is accepted as terminal.
1996
1383
  const hasContent = typeof response.content === 'string' && response.content.trim().length > 0;
1997
- const isHidden = HIDDEN_ROLE_NAMES.has(sessionRole);
1384
+ const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
1998
1385
  const stopReason = response.stopReason ?? response.stop_reason ?? null;
1999
1386
  const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
2000
1387
  // A user/schedule notification can arrive while provider.send() is
@@ -2091,6 +1478,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2091
1478
  }
2092
1479
  // R15: per-turn scalar read-count Map. Lifetime = this turn's tool-call batch.
2093
1480
  // Declared between the duplicate-detection block and the for-loop so it resets
1481
+ // Per-batch buffer for the general `newMessages` tool-result channel.
1482
+ // A tool MAY return a `{ __toolEnvelope, result, newMessages }` envelope;
1483
+ // its newMessages (e.g. the Skill SKILL.md body as a role:'user' message)
1484
+ // are collected here across EVERY call in this assistant turn and flushed
1485
+ // ONCE, AFTER the batch's last tool_result is pushed — never interleaved
1486
+ // between two tool results of the same multi-tool turn (which would put a
1487
+ // user message between tool(A) and tool(B) and break provider pairing).
1488
+ const _batchNewMessages = [];
2094
1489
  for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
2095
1490
  const call = calls[callIndex];
2096
1491
  if (isBuiltinTool(call.name)) {
@@ -2131,8 +1526,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2131
1526
  let toolStartedAt;
2132
1527
  let toolEndedAt;
2133
1528
  const toolKind = getToolKind(call.name);
2134
- // Cross-turn read dedup. Mirrors a reference agent's
2135
- // fileReadCache.ts: if the path's stat tuple (mtime/size/ino/dev)
1529
+ // Cross-turn read dedup: if the path's stat tuple (mtime/size/ino/dev)
2136
1530
  // is unchanged since a prior read in THIS session, return the cached
2137
1531
  // body instead of executing. Both scalar and array/object-array path
2138
1532
  // forms are cached — keyed by (abs, offset, limit, mode, n) per entry.
@@ -2216,18 +1610,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2216
1610
  // Runtime pre-dispatch deny. Schema profiles may hide
2217
1611
  // tools for routing efficiency, but this remains the
2218
1612
  // control-plane boundary for any tool_use that still
2219
- // reaches the loop. _preDispatchDeny is the SHARED helper
1613
+ // reaches the loop. preDispatchDenyForSession is the SHARED helper
2220
1614
  // used by both the eager dispatch path (startEagerTool)
2221
1615
  // and this serial path — keeps the agent-owned control-
2222
1616
  // plane reject and no-tool role guards consistent across
2223
1617
  // both paths.
2224
- const _denyMsg = _preDispatchDeny(call, toolKind, sessionRef);
1618
+ const _denyMsg = preDispatchDenyForSession(sessionRef, call, toolKind);
2225
1619
  if (_denyMsg !== null) {
2226
1620
  result = _denyMsg;
2227
1621
  toolEndedAt = Date.now();
2228
1622
  _resultKind = 'error';
2229
1623
  } else {
2230
- result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval });
1624
+ result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations });
2231
1625
  toolEndedAt = Date.now();
2232
1626
  // Boundary: tool-return string convention → structural kind.
2233
1627
  // The only prefix check in this codebase; downstream layers
@@ -2249,6 +1643,48 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2249
1643
  result = `Error: ${err instanceof Error ? err.message : String(err)}`;
2250
1644
  _resultKind = 'error';
2251
1645
  }
1646
+ // CENTRAL ENVELOPE NORMALIZE (general newMessages channel).
1647
+ // executeTool (serial + eager) and cache/error paths above all
1648
+ // funnel into `result`. Split ONCE here: downstream post-processing
1649
+ // (classifyResultKind / maybeOffloadToolResult / compressToolResult /
1650
+ // traceAgentTool / cache writes / messages.push) sees ONLY the
1651
+ // model-visible `result`; the `newMessages` ride a per-batch buffer
1652
+ // flushed after the batch's last tool_result (never interleaved).
1653
+ {
1654
+ const _env = normalizeToolEnvelope(result);
1655
+ result = _env.result;
1656
+ if (_env.newMessages.length) _batchNewMessages.push(..._env.newMessages);
1657
+ }
1658
+ // Bounded-map cleanup: a scoped-cache outcome recorded for this call.id
1659
+ // (via _scopedCacheOutcomeForCall) is only ever consumed/deleted on the
1660
+ // success path below (_executeOk && _resultKind==='normal'). A failed or
1661
+ // errored call would otherwise leak its entry in
1662
+ // sessionRef._scopedCacheOutcomeByCallId forever — reclaim it here.
1663
+ if (sessionRef?._scopedCacheOutcomeByCallId && call?.id && (!_executeOk || _resultKind === 'error')) {
1664
+ sessionRef._scopedCacheOutcomeByCallId.delete(call.id);
1665
+ }
1666
+ // PostToolUseFailure: a tool that resolved to a failure (thrown-error
1667
+ // path -> `Error:` string, or an is_error result classified as
1668
+ // 'error') fires the optional session failure hook. Same shape as
1669
+ // afterToolHook; `result` carries the error text. Best-effort — a
1670
+ // hook error must never wedge the tool loop.
1671
+ if (!_executeOk || _resultKind === 'error') {
1672
+ const _afterToolFailureHook = typeof opts.afterToolFailureHook === 'function'
1673
+ ? opts.afterToolFailureHook
1674
+ : sessionRef?.afterToolFailureHook;
1675
+ if (typeof _afterToolFailureHook === 'function') {
1676
+ try {
1677
+ await _afterToolFailureHook({
1678
+ name: call.name,
1679
+ args: call.arguments,
1680
+ cwd,
1681
+ sessionId,
1682
+ toolCallId: call.id,
1683
+ result: typeof result === 'string' ? result : String(result ?? ''),
1684
+ });
1685
+ } catch { /* best-effort: PostToolUseFailure hook must never break the loop */ }
1686
+ }
1687
+ }
2252
1688
  // Update the cross-iteration repeat-failure guard with this call's
2253
1689
  // outcome: bump the consecutive-failure count for an identical
2254
1690
  // signature, or clear it the moment the same call succeeds.
@@ -2400,7 +1836,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2400
1836
  toolKind,
2401
1837
  toolMs: toolEndedAt - toolStartedAt,
2402
1838
  toolArgs: call.arguments,
2403
- role: sessionRef?.role || null,
1839
+ agent: sessionRef?.agent || null,
2404
1840
  model: sessionRef?.model || null,
2405
1841
  resultKind: _resultKind,
2406
1842
  resultText: result,
@@ -2432,12 +1868,22 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2432
1868
  setReadCached({ sessionId, args: call.arguments, cwd, content: result, toolUseId: call.id });
2433
1869
  }
2434
1870
  }
1871
+ // UI-only: apply_patch stashes the standard unified diff keyed
1872
+ // by tool_use id (never in the model-visible result). Attach it
1873
+ // here as a side-channel field so the TUI's expanded (ctrl+o)
1874
+ // raw view renders a colored +/- diff. The provider lowering
1875
+ // (anthropic/openai/etc.) never reads `uiDiff`, so the model
1876
+ // sees only `content` (the compact summary) — no token bloat.
1877
+ const _applyPatchUiDiff = _stripMcpPrefix(call.name) === 'apply_patch'
1878
+ ? takeApplyPatchUiDiff(call.id)
1879
+ : null;
2435
1880
  pushToolResultMessage({
2436
1881
  role: 'tool',
2437
1882
  content: result,
2438
1883
  toolCallId: call.id,
2439
1884
  toolKind: _resultKind,
2440
1885
  ...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
1886
+ ...(_applyPatchUiDiff ? { uiDiff: _applyPatchUiDiff } : {}),
2441
1887
  });
2442
1888
  } catch (postErr) {
2443
1889
  _postProcessOk = false;
@@ -2453,7 +1899,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2453
1899
  toolKind,
2454
1900
  toolMs: toolEndedAt && toolStartedAt ? toolEndedAt - toolStartedAt : null,
2455
1901
  toolArgs: call.arguments,
2456
- role: sessionRef?.role || null,
1902
+ agent: sessionRef?.agent || null,
2457
1903
  model: sessionRef?.model || null,
2458
1904
  cwd,
2459
1905
  resultText: _postMsg,
@@ -2477,6 +1923,44 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2477
1923
  // discard the rest of the batch and skip the next provider.send.
2478
1924
  throwIfAborted();
2479
1925
  }
1926
+ // Flush the per-batch newMessages channel. All tool_results for this
1927
+ // assistant turn are now pushed; appending the injected role:'user'
1928
+ // messages here (AFTER the last tool_result, BEFORE the next provider
1929
+ // send) keeps provider pairing valid — no user message is interleaved
1930
+ // between tool(A) and tool(B). pre-send repairTranscriptBeforeProviderSend
1931
+ // normalizes any residual ordering. The injected messages carry their
1932
+ // own meta flag (e.g. meta:'skill') so compaction's latest-human-prompt
1933
+ // selection does not mistake them for the user's request.
1934
+ for (const _nm of _batchNewMessages) {
1935
+ if (!_nm || _nm.role !== 'user' || typeof _nm.content !== 'string' || !_nm.content) continue;
1936
+ messages.push({ role: 'user', content: _nm.content, ...(_nm.meta ? { meta: _nm.meta } : {}) });
1937
+ }
1938
+ // PostToolBatch: the full parallel batch of tool calls for this
1939
+ // assistant turn has resolved and all tool_results are pushed. Fire the
1940
+ // optional session hook before the next model call. No matcher event.
1941
+ // Block support: if the hook returns blocked===true, inject its reason
1942
+ // as a system-note user message for the next send (natural mechanism —
1943
+ // same channel the newMessages flush just used). Best-effort otherwise.
1944
+ {
1945
+ const _afterToolBatchHook = typeof opts.afterToolBatchHook === 'function'
1946
+ ? opts.afterToolBatchHook
1947
+ : sessionRef?.afterToolBatchHook;
1948
+ if (typeof _afterToolBatchHook === 'function' && calls.length > 0) {
1949
+ try {
1950
+ const _batchDecision = await _afterToolBatchHook({
1951
+ sessionId,
1952
+ cwd,
1953
+ toolCount: calls.length,
1954
+ });
1955
+ if (_batchDecision?.blocked === true) {
1956
+ const _reason = String(_batchDecision.reason || 'PostToolBatch hook blocked continuation').trim();
1957
+ if (_reason) {
1958
+ messages.push({ role: 'user', content: `<system-reminder>\n${_reason}\n</system-reminder>`, meta: 'hook' });
1959
+ }
1960
+ }
1961
+ } catch { /* best-effort: PostToolBatch hook must never break the loop */ }
1962
+ }
1963
+ }
2480
1964
  // Mid-turn steering is drained at the next loop's pre-send point,
2481
1965
  // AFTER any auto-compact pass. Draining here would put the steering
2482
1966
  // user turn after the fresh tool results before compaction runs; then
@@ -2485,6 +1969,33 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2485
1969
  // About to re-send with tool results — transition back to connecting for the next turn.
2486
1970
  if (sessionId) updateSessionStage(sessionId, 'connecting');
2487
1971
  }
1972
+ // Classify WHY the loop ended so agent-tool can promote an empty/abnormal
1973
+ // finish to an explicit Lead-facing error instead of a silent empty
1974
+ // "completed". Determine "has content" exactly the way the no-tool-call
1975
+ // branch above does (trimmed string content, or any reasoning content).
1976
+ const _finalHasContent = (typeof response?.content === 'string' && response.content.trim().length > 0)
1977
+ || (typeof response?.reasoningContent === 'string' && response.reasoningContent.trim().length > 0);
1978
+ const _finalStopReason = response?.stopReason ?? response?.stop_reason ?? null;
1979
+ const _finalIncompleteStop = _finalStopReason && INCOMPLETE_STOP_REASONS.has(_finalStopReason);
1980
+ const _finalIsHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
1981
+ let terminationReason;
1982
+ if (terminatedByCap) {
1983
+ // Real problem regardless of hidden/public: the loop never terminated
1984
+ // on its own contract.
1985
+ terminationReason = 'iteration_cap';
1986
+ } else if (!_finalHasContent && _finalIncompleteStop) {
1987
+ // Cut short mid-synthesis (token cap / provider pause). Real problem
1988
+ // for hidden agents too.
1989
+ terminationReason = 'truncated';
1990
+ } else if (!_finalHasContent && !_finalIsHidden) {
1991
+ // Empty terminal turn. Only public agents violate their contract by
1992
+ // finishing empty — hidden agents (explorer/cycle/…) legitimately emit
1993
+ // text-only/empty terminal turns per their own role contract, so leave
1994
+ // terminationReason undefined for them.
1995
+ terminationReason = 'empty';
1996
+ } else {
1997
+ terminationReason = undefined;
1998
+ }
2488
1999
  return {
2489
2000
  ...response,
2490
2001
  usage: lastUsage || response.usage,
@@ -2493,5 +2004,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2493
2004
  iterations,
2494
2005
  toolCallsTotal,
2495
2006
  providerState,
2007
+ terminationReason,
2008
+ maxLoopIterations,
2496
2009
  };
2497
2010
  }