muonroi-cli 1.7.2 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/dist/packages/agent-harness-core/src/event-filter.js +1 -0
  2. package/dist/packages/agent-harness-core/src/event-redact.js +8 -0
  3. package/dist/packages/agent-harness-core/src/mcp-server.d.ts +16 -0
  4. package/dist/packages/agent-harness-core/src/mcp-server.js +88 -10
  5. package/dist/packages/agent-harness-core/src/protocol.d.ts +15 -0
  6. package/dist/packages/agent-harness-core/src/selector.js +2 -2
  7. package/dist/packages/agent-harness-core/src/transports/sidechannel.js +1 -1
  8. package/dist/packages/agent-harness-opentui/src/agent-mode.js +1 -1
  9. package/dist/packages/agent-harness-opentui/src/install.js +1 -1
  10. package/dist/src/__test-stubs__/vitest-setup.js +4 -0
  11. package/dist/src/agent-harness/__tests__/mock-model.spec.js +54 -0
  12. package/dist/src/agent-harness/mock-model.d.ts +1 -1
  13. package/dist/src/agent-harness/mock-model.js +143 -6
  14. package/dist/src/agent-harness/test-spawn.js +3 -1
  15. package/dist/src/chat/chat-keychain.js +9 -1
  16. package/dist/src/cli/cost-forensics.js +7 -4
  17. package/dist/src/cli/keys.d.ts +2 -2
  18. package/dist/src/cli/keys.js +107 -28
  19. package/dist/src/cli/usage-report.js +2 -2
  20. package/dist/src/council/__tests__/abort-threading.test.d.ts +1 -0
  21. package/dist/src/council/__tests__/abort-threading.test.js +193 -0
  22. package/dist/src/council/__tests__/clarification-prompt.test.js +21 -1
  23. package/dist/src/council/__tests__/council-turn-length-emit.test.d.ts +1 -0
  24. package/dist/src/council/__tests__/council-turn-length-emit.test.js +130 -0
  25. package/dist/src/council/__tests__/debate-round-budget.test.d.ts +1 -0
  26. package/dist/src/council/__tests__/debate-round-budget.test.js +45 -0
  27. package/dist/src/council/__tests__/evaluator-metrics.test.js +3 -0
  28. package/dist/src/council/__tests__/post-debate-recommendation.test.d.ts +1 -0
  29. package/dist/src/council/__tests__/post-debate-recommendation.test.js +58 -0
  30. package/dist/src/council/__tests__/research-tools.test.js +4 -0
  31. package/dist/src/council/__tests__/round-tools.test.js +6 -0
  32. package/dist/src/council/clarifier-question-cap.test.d.ts +12 -0
  33. package/dist/src/council/clarifier-question-cap.test.js +73 -0
  34. package/dist/src/council/clarifier.d.ts +29 -1
  35. package/dist/src/council/clarifier.js +64 -1
  36. package/dist/src/council/debate-planner.d.ts +2 -1
  37. package/dist/src/council/debate-planner.js +5 -3
  38. package/dist/src/council/debate.d.ts +9 -0
  39. package/dist/src/council/debate.js +88 -8
  40. package/dist/src/council/index.d.ts +46 -1
  41. package/dist/src/council/index.js +125 -19
  42. package/dist/src/council/llm.js +41 -13
  43. package/dist/src/council/prompts.js +36 -1
  44. package/dist/src/council/types.d.ts +14 -1
  45. package/dist/src/ee/__tests__/export-transcripts.test.js +1 -1
  46. package/dist/src/ee/bridge.d.ts +3 -1
  47. package/dist/src/ee/bridge.js +9 -3
  48. package/dist/src/ee/bridge.test.js +1 -2
  49. package/dist/src/ee/client.js +15 -2
  50. package/dist/src/ee/recall-ledger.d.ts +20 -0
  51. package/dist/src/ee/recall-ledger.js +40 -1
  52. package/dist/src/ee/search.d.ts +25 -0
  53. package/dist/src/ee/search.js +73 -0
  54. package/dist/src/ee/types.d.ts +15 -1
  55. package/dist/src/ee/who-am-i.d.ts +41 -0
  56. package/dist/src/ee/who-am-i.js +143 -0
  57. package/dist/src/ee/who-am-i.test.d.ts +1 -0
  58. package/dist/src/ee/who-am-i.test.js +101 -0
  59. package/dist/src/flow/compaction/index.d.ts +1 -0
  60. package/dist/src/flow/compaction/index.js +4 -0
  61. package/dist/src/generated/version.d.ts +1 -1
  62. package/dist/src/generated/version.js +1 -1
  63. package/dist/src/headless/council-answers.js +1 -0
  64. package/dist/src/headless/output.d.ts +30 -1
  65. package/dist/src/headless/output.js +180 -0
  66. package/dist/src/headless/output.test.js +61 -1
  67. package/dist/src/hooks/index.js +48 -2
  68. package/dist/src/index.d.ts +2 -0
  69. package/dist/src/index.js +59 -62
  70. package/dist/src/lsp/client-normalize.test.d.ts +12 -0
  71. package/dist/src/lsp/client-normalize.test.js +34 -0
  72. package/dist/src/lsp/client.d.ts +1 -0
  73. package/dist/src/lsp/client.js +10 -2
  74. package/dist/src/lsp/manager.d.ts +1 -1
  75. package/dist/src/lsp/manager.js +13 -5
  76. package/dist/src/lsp/manager.test.js +41 -2
  77. package/dist/src/lsp/runtime.d.ts +1 -1
  78. package/dist/src/lsp/runtime.js +2 -2
  79. package/dist/src/maintain/__tests__/gh-create-pr.test.js +7 -3
  80. package/dist/src/maintain/__tests__/pr-builder.test.js +7 -3
  81. package/dist/src/mcp/__tests__/client-pool.spec.js +3 -3
  82. package/dist/src/mcp/__tests__/ee-tools.test.js +42 -14
  83. package/dist/src/mcp/__tests__/forensics-tools.test.js +3 -3
  84. package/dist/src/mcp/__tests__/lsp-tools.test.js +4 -4
  85. package/dist/src/mcp/auto-setup.js +0 -8
  86. package/dist/src/mcp/client-pool.d.ts +1 -1
  87. package/dist/src/mcp/client-pool.js +25 -9
  88. package/dist/src/mcp/ee-tools.d.ts +10 -0
  89. package/dist/src/mcp/ee-tools.js +34 -0
  90. package/dist/src/mcp/self-verify-jobs.d.ts +1 -0
  91. package/dist/src/mcp/self-verify-jobs.js +1 -3
  92. package/dist/src/mcp/self-verify-runner.js +1 -1
  93. package/dist/src/models/catalog-gemini.test.js +12 -10
  94. package/dist/src/models/catalog.json +106 -38
  95. package/dist/src/orchestrator/__tests__/auto-commit.test.d.ts +1 -0
  96. package/dist/src/orchestrator/__tests__/auto-commit.test.js +142 -0
  97. package/dist/src/orchestrator/__tests__/batch-turn-runner.test.js +3 -0
  98. package/dist/src/orchestrator/__tests__/delegations.test.d.ts +1 -0
  99. package/dist/src/orchestrator/__tests__/delegations.test.js +96 -0
  100. package/dist/src/orchestrator/__tests__/message-processor.test.js +2 -0
  101. package/dist/src/orchestrator/__tests__/prompts.test.d.ts +1 -0
  102. package/dist/src/orchestrator/__tests__/prompts.test.js +88 -0
  103. package/dist/src/orchestrator/__tests__/stream-runner.test.js +29 -0
  104. package/dist/src/orchestrator/__tests__/sub-session-delegation.test.d.ts +1 -0
  105. package/dist/src/orchestrator/__tests__/sub-session-delegation.test.js +507 -0
  106. package/dist/src/orchestrator/agent.test.js +5 -2
  107. package/dist/src/orchestrator/auto-commit.d.ts +110 -0
  108. package/dist/src/orchestrator/auto-commit.js +366 -0
  109. package/dist/src/orchestrator/batch-turn-runner.d.ts +3 -2
  110. package/dist/src/orchestrator/batch-turn-runner.js +2 -1
  111. package/dist/src/orchestrator/compaction-proposer-prompt.d.ts +8 -0
  112. package/dist/src/orchestrator/compaction-proposer-prompt.js +43 -0
  113. package/dist/src/orchestrator/compaction.d.ts +22 -0
  114. package/dist/src/orchestrator/compaction.js +42 -0
  115. package/dist/src/orchestrator/delegations.d.ts +1 -0
  116. package/dist/src/orchestrator/delegations.js +41 -0
  117. package/dist/src/orchestrator/error-utils.js +27 -2
  118. package/dist/src/orchestrator/message-processor.d.ts +23 -5
  119. package/dist/src/orchestrator/message-processor.js +226 -2573
  120. package/dist/src/orchestrator/orchestrator.d.ts +11 -2
  121. package/dist/src/orchestrator/orchestrator.js +530 -43
  122. package/dist/src/orchestrator/pending-calls.js +2 -1
  123. package/dist/src/orchestrator/preprocessor.d.ts +13 -0
  124. package/dist/src/orchestrator/preprocessor.js +146 -0
  125. package/dist/src/orchestrator/prompts.d.ts +16 -0
  126. package/dist/src/orchestrator/prompts.js +130 -67
  127. package/dist/src/orchestrator/safety-askcard.d.ts +66 -0
  128. package/dist/src/orchestrator/safety-askcard.js +74 -0
  129. package/dist/src/orchestrator/sandbox.test.js +8 -5
  130. package/dist/src/orchestrator/scope-ceiling.js +1 -1
  131. package/dist/src/orchestrator/stall-watchdog.d.ts +47 -0
  132. package/dist/src/orchestrator/stall-watchdog.js +23 -0
  133. package/dist/src/orchestrator/stall-watchdog.test.js +36 -1
  134. package/dist/src/orchestrator/stream-runner.js +52 -30
  135. package/dist/src/orchestrator/sub-agent-cap.d.ts +2 -0
  136. package/dist/src/orchestrator/sub-agent-cap.js +12 -4
  137. package/dist/src/orchestrator/sub-agent-model-tier.js +1 -1
  138. package/dist/src/orchestrator/subagent-compactor.d.ts +21 -0
  139. package/dist/src/orchestrator/subagent-compactor.js +148 -24
  140. package/dist/src/orchestrator/subagent-compactor.spec.js +249 -1
  141. package/dist/src/orchestrator/text-tool-call-detector.d.ts +9 -5
  142. package/dist/src/orchestrator/text-tool-call-detector.js +16 -12
  143. package/dist/src/orchestrator/text-tool-call-detector.test.js +66 -8
  144. package/dist/src/orchestrator/tool-args-repair.js +1 -1
  145. package/dist/src/orchestrator/tool-engine.d.ts +153 -0
  146. package/dist/src/orchestrator/tool-engine.js +3138 -0
  147. package/dist/src/orchestrator/turn-runner-deps.d.ts +3 -0
  148. package/dist/src/pil/__tests__/discovery-types.test.js +15 -4
  149. package/dist/src/pil/__tests__/discovery.test.js +89 -145
  150. package/dist/src/pil/__tests__/dual-run.test.js +2 -0
  151. package/dist/src/pil/__tests__/layer1-intent-trace.test.js +3 -0
  152. package/dist/src/pil/__tests__/layer1-intent.test.js +3 -0
  153. package/dist/src/pil/__tests__/layer16-clarity.test.js +35 -32
  154. package/dist/src/pil/__tests__/layer18-acceptance.test.js +1 -78
  155. package/dist/src/pil/__tests__/layer2_5-ponytail.test.d.ts +1 -0
  156. package/dist/src/pil/__tests__/layer2_5-ponytail.test.js +53 -0
  157. package/dist/src/pil/__tests__/layer3-ee-injection.test.js +67 -4
  158. package/dist/src/pil/__tests__/layer3-injected-chunk.test.js +14 -2
  159. package/dist/src/pil/__tests__/layer4-gsd.test.js +30 -0
  160. package/dist/src/pil/__tests__/layer5-context.test.js +39 -1
  161. package/dist/src/pil/__tests__/layer6-output.test.js +30 -2
  162. package/dist/src/pil/__tests__/llm-classify.test.js +79 -1
  163. package/dist/src/pil/__tests__/orchestrator-integration.test.js +3 -1
  164. package/dist/src/pil/__tests__/pipeline.test.js +11 -8
  165. package/dist/src/pil/__tests__/response-tools.test.js +52 -0
  166. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  167. package/dist/src/pil/agent-operating-contract.js +5 -3
  168. package/dist/src/pil/agent-operating-contract.test.js +2 -2
  169. package/dist/src/pil/cheap-model-playbook.d.ts +1 -1
  170. package/dist/src/pil/cheap-model-playbook.js +1 -1
  171. package/dist/src/pil/cheap-model-workbooks.d.ts +1 -1
  172. package/dist/src/pil/cheap-model-workbooks.js +1 -1
  173. package/dist/src/pil/config.d.ts +20 -0
  174. package/dist/src/pil/config.js +35 -0
  175. package/dist/src/pil/discovery-types.d.ts +32 -9
  176. package/dist/src/pil/discovery.d.ts +3 -3
  177. package/dist/src/pil/discovery.js +138 -244
  178. package/dist/src/pil/index.d.ts +1 -1
  179. package/dist/src/pil/index.js +1 -1
  180. package/dist/src/pil/layer1-intent.d.ts +9 -1
  181. package/dist/src/pil/layer1-intent.js +62 -7
  182. package/dist/src/pil/layer1-intent.test.js +112 -2
  183. package/dist/src/pil/layer15-context-scan.js +1 -1
  184. package/dist/src/pil/layer16-clarity.d.ts +21 -25
  185. package/dist/src/pil/layer16-clarity.js +52 -88
  186. package/dist/src/pil/layer18-acceptance.d.ts +13 -3
  187. package/dist/src/pil/layer18-acceptance.js +1 -75
  188. package/dist/src/pil/layer2-personality.js +7 -8
  189. package/dist/src/pil/layer2_5-ponytail.d.ts +2 -0
  190. package/dist/src/pil/layer2_5-ponytail.js +41 -0
  191. package/dist/src/pil/layer3-ee-injection.js +180 -14
  192. package/dist/src/pil/layer4-gsd.js +22 -8
  193. package/dist/src/pil/layer5-context.d.ts +1 -0
  194. package/dist/src/pil/layer5-context.js +26 -11
  195. package/dist/src/pil/layer6-output.js +23 -5
  196. package/dist/src/pil/llm-classify.d.ts +11 -0
  197. package/dist/src/pil/llm-classify.js +196 -3
  198. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  199. package/dist/src/pil/native-capabilities-workbook.js +4 -3
  200. package/dist/src/pil/pipeline.js +15 -1
  201. package/dist/src/pil/response-tools.d.ts +16 -0
  202. package/dist/src/pil/response-tools.js +41 -0
  203. package/dist/src/pil/schema.d.ts +8 -0
  204. package/dist/src/pil/schema.js +24 -3
  205. package/dist/src/pil/task-tier-map.js +7 -7
  206. package/dist/src/pil/types.d.ts +9 -3
  207. package/dist/src/product-loop/__tests__/discovery-interview.test.js +103 -1
  208. package/dist/src/product-loop/__tests__/gather-selectable-alts.test.js +34 -0
  209. package/dist/src/product-loop/__tests__/sprint-self-verify.test.js +12 -12
  210. package/dist/src/product-loop/discovery-interview.d.ts +20 -0
  211. package/dist/src/product-loop/discovery-interview.js +121 -7
  212. package/dist/src/product-loop/gather.js +19 -5
  213. package/dist/src/product-loop/loop-driver.js +2 -2
  214. package/dist/src/product-loop/sprint-self-verify.js +2 -2
  215. package/dist/src/providers/__tests__/reasoning-roundtrip.test.js +12 -0
  216. package/dist/src/providers/auth/__tests__/gemini-oauth.test.d.ts +1 -5
  217. package/dist/src/providers/auth/__tests__/gemini-oauth.test.js +1 -5
  218. package/dist/src/providers/auth/gcloud.d.ts +28 -0
  219. package/dist/src/providers/auth/gcloud.js +102 -0
  220. package/dist/src/providers/auth/gemini-oauth.d.ts +46 -19
  221. package/dist/src/providers/auth/gemini-oauth.js +292 -105
  222. package/dist/src/providers/auth/grok-oauth.d.ts +1 -1
  223. package/dist/src/providers/auth/grok-oauth.js +1 -1
  224. package/dist/src/providers/auth/registry.js +28 -6
  225. package/dist/src/providers/gemini.d.ts +1 -1
  226. package/dist/src/providers/gemini.js +10 -3
  227. package/dist/src/providers/keychain.d.ts +2 -2
  228. package/dist/src/providers/keychain.js +20 -2
  229. package/dist/src/providers/openai.js +5 -1
  230. package/dist/src/providers/runtime.d.ts +1 -1
  231. package/dist/src/providers/runtime.js +11 -2
  232. package/dist/src/providers/siliconflow-sse-repair.js +1 -1
  233. package/dist/src/providers/strategies/google.strategy.d.ts +9 -2
  234. package/dist/src/providers/strategies/google.strategy.js +159 -9
  235. package/dist/src/providers/strategies/thinking-mode.js +19 -6
  236. package/dist/src/providers/types.d.ts +1 -1
  237. package/dist/src/router/decide.test.js +40 -8
  238. package/dist/src/router/step-router.d.ts +20 -0
  239. package/dist/src/router/step-router.js +73 -0
  240. package/dist/src/self-qa/agentic-loop.js +30 -21
  241. package/dist/src/self-qa/orchestrator.js +5 -5
  242. package/dist/src/self-qa/scenario-planner.js +1 -1
  243. package/dist/src/self-qa/spec-emitter.js +4 -4
  244. package/dist/src/storage/__tests__/hierarchical-session.test.d.ts +1 -0
  245. package/dist/src/storage/__tests__/hierarchical-session.test.js +161 -0
  246. package/dist/src/storage/__tests__/sweep-stale-pending.test.js +2 -2
  247. package/dist/src/storage/index.d.ts +1 -1
  248. package/dist/src/storage/index.js +1 -1
  249. package/dist/src/storage/migrations.js +13 -1
  250. package/dist/src/storage/transcript-response-entry.test.js +18 -5
  251. package/dist/src/storage/transcript-view.js +7 -1
  252. package/dist/src/storage/transcript.d.ts +3 -0
  253. package/dist/src/storage/transcript.js +254 -73
  254. package/dist/src/storage/ui-interaction-log.d.ts +17 -0
  255. package/dist/src/storage/usage.js +22 -5
  256. package/dist/src/tools/__tests__/native-tools.test.js +6 -0
  257. package/dist/src/tools/bash.d.ts +36 -23
  258. package/dist/src/tools/bash.js +59 -301
  259. package/dist/src/tools/bash.test.js +2 -257
  260. package/dist/src/tools/file-tracker.d.ts +5 -0
  261. package/dist/src/tools/file-tracker.js +14 -0
  262. package/dist/src/tools/file.js +2 -0
  263. package/dist/src/tools/git-safety.d.ts +38 -1
  264. package/dist/src/tools/git-safety.js +94 -4
  265. package/dist/src/tools/git-safety.test.js +45 -1
  266. package/dist/src/tools/native-tools.d.ts +1 -1
  267. package/dist/src/tools/native-tools.js +66 -1
  268. package/dist/src/tools/registry-bash-empty-command.test.js +4 -3
  269. package/dist/src/tools/registry-git-safety.test.js +17 -6
  270. package/dist/src/tools/registry.d.ts +6 -0
  271. package/dist/src/tools/registry.js +215 -13
  272. package/dist/src/types/index.d.ts +8 -2
  273. package/dist/src/ui/app.d.ts +1 -0
  274. package/dist/src/ui/app.js +0 -0
  275. package/dist/src/ui/components/council-question-card.js +1 -0
  276. package/dist/src/ui/components/message-view.js +37 -3
  277. package/dist/src/ui/components/structured-response-view.js +7 -0
  278. package/dist/src/ui/components/task-list-panel.js +3 -12
  279. package/dist/src/ui/containers/chat-feed.d.ts +40 -0
  280. package/dist/src/ui/containers/chat-feed.js +66 -0
  281. package/dist/src/ui/containers/modals-layer.d.ts +87 -0
  282. package/dist/src/ui/containers/modals-layer.js +18 -0
  283. package/dist/src/ui/slash/__tests__/menu-parity.test.d.ts +1 -0
  284. package/dist/src/ui/slash/__tests__/menu-parity.test.js +1 -0
  285. package/dist/src/ui/slash/__tests__/ponytail.test.d.ts +1 -0
  286. package/dist/src/ui/slash/__tests__/ponytail.test.js +37 -0
  287. package/dist/src/ui/slash/expand.js +14 -1
  288. package/dist/src/ui/slash/export.js +16 -1
  289. package/dist/src/ui/slash/menu-items.js +1 -0
  290. package/dist/src/ui/slash/ponytail.d.ts +2 -0
  291. package/dist/src/ui/slash/ponytail.js +19 -0
  292. package/dist/src/ui/status-bar/store.js +0 -1
  293. package/dist/src/ui/status-bar/store.test.js +2 -2
  294. package/dist/src/ui/use-app-logic.d.ts +26 -0
  295. package/dist/src/ui/use-app-logic.js +0 -0
  296. package/dist/src/ui/utils/relaunch.js +1 -1
  297. package/dist/src/ui/utils/text.d.ts +11 -0
  298. package/dist/src/ui/utils/text.js +21 -1
  299. package/dist/src/ui/utils/text.test.js +25 -1
  300. package/dist/src/ui/utils/tools.js +3 -1
  301. package/dist/src/usage/cost-log.js +2 -2
  302. package/dist/src/usage/estimator.d.ts +14 -0
  303. package/dist/src/usage/estimator.js +22 -0
  304. package/dist/src/utils/__tests__/compaction-caps.test.d.ts +21 -0
  305. package/dist/src/utils/__tests__/compaction-caps.test.js +187 -0
  306. package/dist/src/utils/__tests__/llm-deadline-abort.test.d.ts +1 -0
  307. package/dist/src/utils/__tests__/llm-deadline-abort.test.js +33 -0
  308. package/dist/src/utils/__tests__/llm-deadline.test.js +16 -4
  309. package/dist/src/utils/__tests__/logger.test.d.ts +1 -0
  310. package/dist/src/utils/__tests__/logger.test.js +115 -0
  311. package/dist/src/utils/__tests__/settings-agent-first.test.d.ts +1 -0
  312. package/dist/src/utils/__tests__/settings-agent-first.test.js +48 -0
  313. package/dist/src/utils/install-manager.d.ts +1 -0
  314. package/dist/src/utils/install-manager.js +76 -7
  315. package/dist/src/utils/install-manager.test.js +43 -1
  316. package/dist/src/utils/llm-deadline.d.ts +9 -1
  317. package/dist/src/utils/llm-deadline.js +31 -2
  318. package/dist/src/utils/logger.d.ts +28 -0
  319. package/dist/src/utils/logger.js +171 -0
  320. package/dist/src/utils/permission-mode.d.ts +33 -0
  321. package/dist/src/utils/permission-mode.js +164 -3
  322. package/dist/src/utils/permission-mode.test.js +92 -1
  323. package/dist/src/utils/settings.d.ts +36 -32
  324. package/dist/src/utils/settings.js +151 -123
  325. package/dist/src/utils/update-checker.test.js +5 -1
  326. package/package.json +1 -1
@@ -0,0 +1,3138 @@
1
+ // MessageProcessor — extracted from orchestrator.ts as part of Phase 12.4.
2
+ //
3
+ // Owns the main streaming turn loop that lives in `Agent.processMessage`:
4
+ // - Abort wiring (external AbortContext + per-turn AbortController)
5
+ // - Trajectory + phase tracker observations on user_turn / abort
6
+ // - PIL enrichment pipeline (layers 1/3/6 — fail-open with logging)
7
+ // - ROUTE-11 per-turn model routing (decide + fallback to non-disabled
8
+ // provider via CouncilManager)
9
+ // - Vision proxy (history + current turn)
10
+ // - Auto-council gate (PIL taskType + heavy tier + role count) — routes
11
+ // into runCouncilV2 and re-enters processMessage with synthesis
12
+ // - System prompt assembly (chitchat / playwright gating + PIL suffix +
13
+ // model constraints)
14
+ // - SAMR step-aware routing (phase1 reasoning → phase2 execution)
15
+ // - Tool roundtrip loop:
16
+ // - Compaction (relax on overflow recovery, B4 top-level prepareStep)
17
+ // - Tool set assembly: builtin + MCP (smart filter for chitchat /
18
+ // browser-vocab) + PIL response tools, all wrapped with top-level
19
+ // cumulative cap (F1), cross-turn dedup (C3), read-path budget
20
+ // - ProviderOptions composition (buildTurnProviderOptions +
21
+ // taskTypeToReasoningEffort budget + thinkingType adaptive override
22
+ // + O1 shape capture)
23
+ // - streamText({...}) with prepareStep (top-level compactor +
24
+ // capability sanitizeHistory), onStepStart/Finish, onFinish
25
+ // (correlation cleanup)
26
+ // - fullStream consumer (text-delta / reasoning-delta / tool-call
27
+ // with EE PreToolUse intercept / tool-result with EE PostToolUse
28
+ // and vision-bridge / tool-error / tool-approval-request /
29
+ // error / abort)
30
+ // - Write-ahead persistence (Phase A4 tool_calls, A5 message_seq)
31
+ // - Context-overflow recovery + transient retry with exponential backoff
32
+ // - Post-turn compact + Stop / StopFailure hooks
33
+ // - Debug pipeline trace
34
+ //
35
+ // Zero behavioral changes — every method body mirrors the original
36
+ // `processMessage` (see commit history). The DI surface (`MessageProcessorDeps`)
37
+ // is the minimum proxy onto Agent state needed to reach back into Agent
38
+ // without holding a circular reference. Public `Agent.processMessage`
39
+ // signature is unchanged and continues to be the entrypoint; internally it
40
+ // constructs a `MessageProcessor` per call.
41
+ //
42
+ // Cost-leak code paths preserved here:
43
+ // - F1 (top-level cumulative cap) — wrapToolSetWithCap (top-level)
44
+ // - F1 (openai.promptCacheKey) — buildTurnProviderOptions
45
+ // - G1 (OAuth `maxOutputTokens` drop) — shouldDropParam(runtime, ...)
46
+ // - B4 (top-level prepareStep compaction) — compactSubAgentMessages
47
+ // - C3 (cross-turn dedup wrap) — wrapToolSetWithDedup
48
+ // - A4 (tool_call write-ahead) — persistToolCallWriteAhead
49
+ // - A5 (message_seq write-ahead) — persistMessageWriteAhead
50
+ // - O1 (providerOptions shape forensics) — extractProviderOptionsShape
51
+ // - siliconflow reasoning-strip — turnCaps.sanitizeHistory
52
+ import { generateText, stepCountIs, streamText } from "ai";
53
+ import { recordArtifact } from "../ee/artifact-cache.js";
54
+ import { routeFeedback } from "../ee/bridge.js";
55
+ import { getDefaultEEClient } from "../ee/intercept.js";
56
+ import { acquireMcpTools } from "../mcp/client-pool.js";
57
+ import { dropRedundantFsMcpTools, filterMcpServersByMessage } from "../mcp/smart-filter.js";
58
+ import { getModelInfo } from "../models/registry.js";
59
+ import { cheapModelShellLine, injectCheapModelPlaybook, injectCheapModelShellDirective, shouldInjectCheapModelPlaybook, } from "../pil/cheap-model-playbook.js";
60
+ import { injectCheapModelWorkbook, shouldInjectCheapModelWorkbook } from "../pil/cheap-model-workbooks.js";
61
+ import { getResponseTaskType, isResponseTool, normalizeStructuredResponseTaskType, shouldHaltOnResponseTool, } from "../pil/index.js";
62
+ import { isMetaAnalysisPrompt } from "../pil/layer6-output.js";
63
+ import { taskTypeToMaxTokens, taskTypeToReasoningEffort, taskTypeToTier } from "../pil/task-tier-map.js";
64
+ import { mentionsEcosystemScope } from "../playbook/directives.js";
65
+ import { getProviderCapabilities } from "../providers/capabilities.js";
66
+ import { bridgeMcpToolResult, listCachedImages, scrubImagePayloadsInMessages, } from "../providers/mcp-vision-bridge.js";
67
+ import { captureToolSchemas } from "../providers/patch-zod-schema.js";
68
+ import { buildTurnProviderOptions, requireRuntimeProvider, shouldDropParam, } from "../providers/runtime.js";
69
+ import { wireDebug } from "../providers/wire-debug.js";
70
+ import { reportRouteOutcome } from "../router/decide.js";
71
+ import { getNextMessageSequence, logInteraction, markMessageErrored, markToolCallErrored, persistToolCallWriteAhead, } from "../storage/index.js";
72
+ import { persistSessionExperience } from "../storage/session-experience-store.js";
73
+ import { createBuiltinTools } from "../tools/registry.js";
74
+ import { snapshotFromTodoWriteArgs } from "../tools/todo-write-snapshot.js";
75
+ import { visionToolsNeeded } from "../tools/vision-gate.js";
76
+ import { recordTurnTrace } from "../ui/slash/debug.js";
77
+ import { statusBarStore } from "../ui/status-bar/store.js";
78
+ import { appendDecisionLog } from "../usage/decision-log.js";
79
+ import { logger } from "../utils/logger.js";
80
+ import { openUrl } from "../utils/open-url.js";
81
+ import { appendAudit, toolNeedsApproval } from "../utils/permission-mode.js";
82
+ import { getAutoCouncilConfidence, getAutoCouncilMinRoles, getProviderStallRetries, getProviderStallTimeoutMs, getRoleModels, getSteerInjectionEnabled, getTopLevelCompactKeepLast, getTopLevelCompactThresholdChars, getTopLevelToolBudgetChars, isAutoCouncilEnabled, loadMcpServers, } from "../utils/settings.js";
83
+ import { resolveShell } from "../utils/shell.js";
84
+ import { relaxCompactionSettings } from "./compaction.js";
85
+ import { wrapToolSetWithDedup } from "./cross-turn-dedup.js";
86
+ import { humanizeApiError, isAuthenticationError, isContextLimitError, summarizeApiErrorForLog } from "./error-utils.js";
87
+ import { buildGroundingFootnote, findUnverifiedClaims } from "./grounding-check.js";
88
+ import { buildInterruptedTurnNote } from "./interrupted-turn.js";
89
+ import { stableCallId } from "./pending-calls.js";
90
+ import { buildMcpCapabilityBlock, MAX_LLM_CALLS_PER_TURN, } from "./prompts.js";
91
+ import { extractProviderOptionsShape } from "./provider-options-shape.js";
92
+ import { wrapToolSetWithReadBudget } from "./read-path-budget.js";
93
+ import { containsEncryptedReasoning, sanitizeModelMessages } from "./reasoning.js";
94
+ import { repairToolCallHook } from "./repair-tool-call.js";
95
+ import { buildRepetitionReminder, recordAssistantBurst, shouldInjectRepetitionReminder, } from "./repetition-detector.js";
96
+ import { classifyStreamError } from "./retry-classifier.js";
97
+ import { forcedFinalize, incSessionStep, } from "./scope-ceiling.js";
98
+ import { attachReminderToMessages, buildCheckpointReminder, buildScopeReminder, cadenceForSize, shouldInjectCeilingCrossing, shouldInjectReminder, shouldInjectSoftWarn, shouldPreWarnCompaction, } from "./scope-reminder.js";
99
+ import { formatElisionManifest, getSessionExperienceCounts, recordCompaction, recordElision, } from "./session-experience.js";
100
+ import { attemptStallRescue, pushStallToolResult } from "./stall-rescue.js";
101
+ import { createStallWatchdog, STALL_ERROR_MESSAGE, shouldContinueAfterMidLoopStall, shouldRepromptStall, stallRepromptBackoffMs, } from "./stall-watchdog.js";
102
+ import { planSteerInjection } from "./steer-inbox.js";
103
+ import { wrapToolSetWithCap } from "./sub-agent-cap.js";
104
+ import { applyAnthropicPromptCaching, compactSubAgentMessages, cumulativeMessageChars } from "./subagent-compactor.js";
105
+ import { detectTextEmittedToolCall, parseDsmlToolCalls } from "./text-tool-call-detector.js";
106
+ import { createToolLoopCapPredicate } from "./tool-loop-cap.js";
107
+ import { buildToolRepetitionAbortMessage, recordToolError as recordToolRepetitionError, recordToolSuccess as recordToolRepetitionSuccess, } from "./tool-repetition-detector.js";
108
+ /**
109
+ * F2 — approximate the char cost of the FIXED prompt envelope (system +
110
+ * tools JSON-Schema) that streamText re-sends on every step. Used to feed
111
+ * the compactor a realistic total-prompt size so it fires when billed input
112
+ * is actually large, not when only `messages[]` is.
113
+ */
114
+ function computeEnvelopeChars(system, tools) {
115
+ let n = 0;
116
+ if (typeof system === "string")
117
+ n += system.length;
118
+ else if (system && typeof system === "object") {
119
+ try {
120
+ n += JSON.stringify(system).length;
121
+ }
122
+ catch {
123
+ /* ignore — best-effort estimate */
124
+ }
125
+ }
126
+ if (tools && typeof tools === "object") {
127
+ try {
128
+ n += JSON.stringify(tools).length;
129
+ }
130
+ catch {
131
+ /* ignore */
132
+ }
133
+ }
134
+ return n;
135
+ }
136
+ import { combineAbortSignals, getFinishReason, getStepNumber, getUsage, notifyObserver, toToolCall, toToolResult, } from "./tool-utils.js";
137
+ /**
138
+ * Session-scoped cache for [EE Session Guidance] dedup. Maps sessionId to the
139
+ * sha256 prefix of the last-injected guidance content. Prevents the same block
140
+ * from being re-injected on every turn — once the model has seen a set of
141
+ * guidance entries it stays informed until new entries arrive.
142
+ */
143
+ const _injectedGuidanceSha = new Map();
144
+ /**
145
+ * MessageProcessor — extracted streaming turn loop.
146
+ *
147
+ * Lifecycle:
148
+ * const processor = new MessageProcessor(deps);
149
+ * yield* processor.run(userMessage, observer, images);
150
+ *
151
+ * Constructed per call (heap allocation is negligible against the streamText
152
+ * cost), matching the StreamRunner / CouncilManager pattern.
153
+ */
154
+ /**
155
+ * Max response-tool (`respond_*`) calls tolerated within a single turn before
156
+ * the orchestrator finalizes early with the best answer buffered so far. A
157
+ * well-behaved turn emits the response tool ONCE; a hedge-then-answer emits 2.
158
+ * Beyond that is degenerate spam (session 8d8f498268ed: 80× identical
159
+ * respond_general in one generation). Set to 3 so the legitimate ≤2 patterns
160
+ * are never cut short.
161
+ */
162
+ const RESPONSE_TOOL_SPAM_CAP = 3;
163
+ /**
164
+ * Rewrites tool-result parts in the AI SDK's final response history if the
165
+ * user manually approved a safety-blocked command. This ensures the model sees
166
+ * its own retry context accurately (as a success) rather than a repeated block
167
+ * message, avoiding infinite retry loops or hallucinated failures.
168
+ */
169
+ export function rewriteSafetyApprovedToolResults(messages) {
170
+ const _globalSafety = globalThis;
171
+ const approvedMap = _globalSafety.__muonroiSafetyApproved;
172
+ if (!approvedMap || approvedMap.size === 0) {
173
+ return messages;
174
+ }
175
+ return messages.map((m) => {
176
+ if (m.role !== "tool" || !Array.isArray(m.content))
177
+ return m;
178
+ let changed = false;
179
+ const newContent = m.content.map((part) => {
180
+ if (part.type === "tool-result" && typeof part.toolCallId === "string") {
181
+ const approved = approvedMap.get(part.toolCallId);
182
+ if (approved) {
183
+ changed = true;
184
+ return {
185
+ ...part,
186
+ isError: false,
187
+ result: `Approved (${approved.kind}): blocked command was allowed by user`,
188
+ };
189
+ }
190
+ }
191
+ return part;
192
+ });
193
+ return changed ? { ...m, content: newContent } : m;
194
+ });
195
+ }
196
+ import { stripDsmlMarkup } from "./message-processor.js";
197
+ export async function* executeToolEngine(args) {
198
+ let { deps, stepRouterPhase, phase2Runtime, runtime, modelInfo, _debugSteps, _ceilingHit, userMessage, pilCtx, turnModelId, turnProvider, _stepCeiling, userModelMessage, userEnrichedMessage, signal, observer, taskHash, provider, system, routerStore, attemptedOverflowRecovery, patternLoopForceHalt, userWriteAheadSeq, streamRetryCount, MAX_STREAM_RETRIES, subagents, systemParts, toolTurnSystem, playwrightGuidance, _hasResponseTools, _pilResponseTools, patternLoopInjectCount, agentLoopDecisionCount, MAX_AGENT_LOOP_DECISIONS, _naturalCeiling, _ceilingTaskType, _ceilingSize, textToolReSteerCount, MAX_TEXT_TOOL_RESTEER, turnStartMs, _debugOn, _debugTurnId, _pilEnrichmentDeltaSnapshot, isChitchat, } = args;
199
+ // Put all extracted code here:
200
+ let stallTriggered = false;
201
+ // Time-to-first-byte stall RE-PROMPT: some providers (observed:
202
+ // xai/grok-build-0.1) accept the request then never send the first byte —
203
+ // a single wedged socket, not a down backend, so a fresh request usually
204
+ // goes through. When the watchdog fires with ZERO chunks received this
205
+ // attempt, we re-issue the SAME request up to `maxStallRetries` times
206
+ // (loop-persistent counter). Gated on zero-chunks so it can NEVER restart a
207
+ // turn that already ran tools or emitted text — those go to the partial-
208
+ // answer rescue path instead. maxStallRetries = 0 restores legacy behaviour.
209
+ let stallRetryCount = 0;
210
+ const maxStallRetries = getProviderStallRetries();
211
+ // Mid-loop dead-socket CONTINUATION counter (loop-persistent). Distinct from
212
+ // the TTFB re-prompt above: when the watchdog fires AFTER earlier tool steps
213
+ // ran (chunksThisAttempt > 0) but the in-flight step produced zero bytes
214
+ // (chunksThisStep === 0 — observed live: xai/grok-build-0.1 wedges the socket
215
+ // on an inter-step request mid-investigation, session 247a0cea2eac), we
216
+ // append the completed steps' messages to history and re-issue streamText to
217
+ // RESUME from the stalled step. Because completed tool-calls + tool-results
218
+ // are in history, no tool is re-run and no text is duplicated — safe even
219
+ // for write tools (the TTFB re-prompt is NOT, hence its zero-chunks gate).
220
+ // Bounded by the same maxStallRetries cap so a persistently-dead provider
221
+ // still falls through to the partial-answer rescue.
222
+ let midLoopStallRetryCount = 0;
223
+ // F3c — per-turn LLM call cap: counts every streamText() invocation
224
+ // (tool round-trip, stall re-prompt, stream retry) and hard-aborts
225
+ // the turn when exceeded. Prevents the session 526a83cf22df pattern
226
+ // where 3 user messages burnt 82% of 2.44M tokens in 36 LLM calls.
227
+ let llmCallsThisTurn = 0;
228
+ // Live-queue steering: messages the user typed mid-turn are drained at a
229
+ // prepareStep boundary and accumulated here, then re-appended (deduped) to
230
+ // the messages returned for each subsequent step. Loop-persistent so they
231
+ // survive a stall-reprompt restart of streamText. NOT pushed into
232
+ // deps.messages in v1 — model-context only; the assistant response captures
233
+ // the steering effect and is persisted via appendCompletedTurn.
234
+ const pendingSteers = [];
235
+ const steerEnabled = getSteerInjectionEnabled();
236
+ // Auto-council: route to multi-model debate when EITHER
237
+ // (a) PIL classified taskType=plan|analyze with high confidence AND the
238
+ // prompt is complex enough to justify the debate cost, OR
239
+ // (b) GSD-native tier === "heavy" (wholesale / multi-step / cross-repo work).
240
+ // After the debate finishes, runCouncilV2 records synthesis on
241
+ // councilManager.lastSynthesis; we then re-enter processMessage with the synthesis
242
+ // as the next user turn so the main loop continues with full debate context.
243
+ // Skip if this is already a council continuation turn (prevent infinite recursion).
244
+ //
245
+ // Phase 5 BUG-I (session f1a2a2a547db) — the gate previously fired on
246
+ // taskType=analyze + conf≥0.85 alone, with no complexity check. Result:
247
+ // "improve test coverage cho src/X.ts" (single-file, scoreComplexity=low,
248
+ // score=2) sank 13 minutes into council debate, then halted on pattern-loop
249
+ // after sprint 1 read 6 files. The complexity gate below bypasses council
250
+ // for low-complexity analyze prompts — they get the hot-path direct exec
251
+ // and stay productive. `plan` keeps the old behaviour (architectural
252
+ // decisions deserve debate regardless of length).
253
+ const autoCouncilTypes = new Set(["plan", "analyze"]);
254
+ const councilRoles = getRoleModels();
255
+ const configuredRoleCount = Object.values(councilRoles).filter(Boolean).length;
256
+ const heavyTier = pilCtx.complexityTier === "heavy";
257
+ const autoCouncilConfidence = getAutoCouncilConfidence();
258
+ const autoCouncilMinRoles = getAutoCouncilMinRoles();
259
+ const _complexityFromTrace = pilCtx._intentTrace
260
+ ?.complexity;
261
+ const _complexityGatePassed = pilCtx.taskType === "plan" || _complexityFromTrace === undefined || _complexityFromTrace !== "low";
262
+ const taskTypeMatch = pilCtx.taskType &&
263
+ autoCouncilTypes.has(pilCtx.taskType) &&
264
+ pilCtx.confidence >= autoCouncilConfidence &&
265
+ _complexityGatePassed;
266
+ const shouldAutoCouncil = !deps.councilManager.isContinuation &&
267
+ isAutoCouncilEnabled() &&
268
+ configuredRoleCount >= autoCouncilMinRoles &&
269
+ (taskTypeMatch || heavyTier);
270
+ // Always log the auto-council decision (taken or skipped) with the gate
271
+ // values that decided it. Lets reports answer "why did this turn cost
272
+ // $0.30?" and "is the confidence floor tuned wrong for my prompts?".
273
+ const autoCouncilSkipReason = (() => {
274
+ if (deps.councilManager.isContinuation)
275
+ return "continuation-turn";
276
+ if (!isAutoCouncilEnabled())
277
+ return "feature-disabled";
278
+ if (configuredRoleCount < autoCouncilMinRoles)
279
+ return `role-count<${autoCouncilMinRoles} (have ${configuredRoleCount})`;
280
+ if (!taskTypeMatch && !heavyTier) {
281
+ if (!pilCtx.taskType || !autoCouncilTypes.has(pilCtx.taskType))
282
+ return `taskType=${pilCtx.taskType ?? "null"} not in plan|analyze`;
283
+ if (pilCtx.confidence < autoCouncilConfidence)
284
+ return `confidence<${autoCouncilConfidence} (got ${pilCtx.confidence.toFixed(2)})`;
285
+ if (!_complexityGatePassed)
286
+ return `complexity=low + taskType=${pilCtx.taskType} (analyze needs medium+; plan bypasses gate)`;
287
+ return "no-trigger";
288
+ }
289
+ return "taken";
290
+ })();
291
+ appendDecisionLog({
292
+ ts: Date.now(),
293
+ sessionId: deps.session?.id ?? null,
294
+ kind: "auto-council",
295
+ taken: shouldAutoCouncil,
296
+ reason: autoCouncilSkipReason,
297
+ meta: {
298
+ taskType: pilCtx.taskType ?? null,
299
+ confidence: pilCtx.confidence,
300
+ complexityTier: pilCtx.complexityTier ?? null,
301
+ complexityScore: _complexityFromTrace ?? null,
302
+ complexityGatePassed: _complexityGatePassed,
303
+ configuredRoleCount,
304
+ autoCouncilConfidence,
305
+ autoCouncilMinRoles,
306
+ heavyTier,
307
+ isContinuation: deps.councilManager.isContinuation,
308
+ },
309
+ }).catch(() => undefined);
310
+ if (shouldAutoCouncil) {
311
+ const reason = heavyTier
312
+ ? `complexity=heavy${pilCtx.taskType ? ` task=${pilCtx.taskType}` : ""}`
313
+ : `${pilCtx.taskType} task detected with ${(pilCtx.confidence * 100).toFixed(0)}% confidence`;
314
+ yield { type: "content", content: `\n[Auto-council triggered: ${reason}]\n` };
315
+ yield* deps.runCouncilV2(userMessage, { skipClarification: true, observer, userModelMessage });
316
+ const synthesis = deps.councilManager.lastSynthesis;
317
+ deps.councilManager.setLastSynthesis(null);
318
+ if (synthesis) {
319
+ yield { type: "content", content: "\n[Auto-continuing with council recommendations...]\n" };
320
+ deps.councilManager.setContinuation(true);
321
+ try {
322
+ yield* deps.processMessage(`Council debate completed. Synthesis:\n\n${synthesis}\n\nProceed with the recommended action items.`, observer);
323
+ }
324
+ finally {
325
+ deps.councilManager.setContinuation(false);
326
+ }
327
+ }
328
+ return;
329
+ }
330
+ if (deps.batchApi) {
331
+ try {
332
+ yield* deps.processMessageBatchTurn({
333
+ userModelMessage,
334
+ userEnrichedMessage,
335
+ observer,
336
+ provider,
337
+ subagents,
338
+ system,
339
+ runtime,
340
+ modelInfo,
341
+ signal,
342
+ });
343
+ }
344
+ finally {
345
+ if (deps.getAbortController()?.signal === signal) {
346
+ deps.setAbortController(null);
347
+ }
348
+ }
349
+ return;
350
+ }
351
+ try {
352
+ streamAttempt: while (true) {
353
+ // SAMR Phase 2: switch to fast model for tool-execution steps
354
+ if (stepRouterPhase === "phase2" && phase2Runtime) {
355
+ runtime = phase2Runtime;
356
+ modelInfo = runtime.modelInfo;
357
+ }
358
+ deps.setCompactedThisTurn(false);
359
+ let assistantText = "";
360
+ // Count of stream parts received in THIS attempt. Stays 0 only when the
361
+ // provider never sent a first byte → the safe-to-re-prompt stall case.
362
+ let chunksThisAttempt = 0;
363
+ // Count of stream parts received since the last step boundary (reset in
364
+ // prepareStep). Distinguishes a mid-loop dead socket (a single step's
365
+ // request got zero bytes while every prior step completed) from a stall
366
+ // that interrupted text mid-generation. See shouldContinueAfterMidLoopStall.
367
+ let chunksThisStep = 0;
368
+ // Decide whether a fired stall watchdog should re-prompt (re-issue the
369
+ // same request) instead of falling through to rescue/error. Returns the
370
+ // backoff ms to wait before re-issuing, or null to NOT re-prompt. Reads
371
+ // the live per-attempt locals; safe to call only when stallTriggered.
372
+ const planStallReprompt = () => {
373
+ if (!shouldRepromptStall({
374
+ stallTriggered,
375
+ stallRetryCount,
376
+ maxStallRetries,
377
+ chunksThisAttempt,
378
+ assistantTextEmpty: assistantText.trim() === "",
379
+ aborted: signal.aborted,
380
+ })) {
381
+ return null;
382
+ }
383
+ stallRetryCount++;
384
+ const backoffMs = stallRepromptBackoffMs(stallRetryCount);
385
+ try {
386
+ const _ar = globalThis.__muonroiAgentRuntime;
387
+ _ar?.emitEvent({
388
+ t: "event",
389
+ kind: "stream-retry",
390
+ attempt: stallRetryCount,
391
+ maxAttempts: maxStallRetries + 1,
392
+ errorName: "TimeoutError",
393
+ errorMessage: "provider-stall (no first byte) — re-prompting",
394
+ nextDelayMs: backoffMs,
395
+ });
396
+ _ar?.emitEvent({
397
+ t: "event",
398
+ kind: "toast",
399
+ level: "warning",
400
+ text: `Model stalled — re-prompting (attempt ${stallRetryCount}/${maxStallRetries})…`,
401
+ });
402
+ }
403
+ catch (emitErr) {
404
+ logger.error("orchestrator", "stall-reprompt telemetry failed", { error: emitErr });
405
+ }
406
+ try {
407
+ if (deps.session) {
408
+ logInteraction(deps.session.id, "stream_retry", {
409
+ data: {
410
+ attempt: stallRetryCount,
411
+ maxAttempts: maxStallRetries + 1,
412
+ errorName: "provider-stall",
413
+ errorMessage: "no first byte within stall timeout — re-prompted",
414
+ nextDelayMs: backoffMs,
415
+ },
416
+ });
417
+ }
418
+ }
419
+ catch (logErr) {
420
+ logger.error("orchestrator", "stall-reprompt log failed", { error: logErr });
421
+ }
422
+ return backoffMs;
423
+ };
424
+ // Tracks where `assistantText` was at the previous step boundary so
425
+ // `onStepFinish` can compute the text emitted within the just-finished
426
+ // step (input to the self-repetition detector).
427
+ let _assistantTextAtLastStep = 0;
428
+ let reasoningPreview = "";
429
+ let encryptedReasoningHidden = false;
430
+ let streamOk = false;
431
+ let closeMcp;
432
+ let stepNumber = -1;
433
+ const activeToolCalls = [];
434
+ // Capped digest of tool outputs gathered this attempt — fuels the
435
+ // best-effort answer rescue if the stream stalls mid-turn (see
436
+ // stall-rescue.ts). Reset per attempt; only the most recent results win.
437
+ const turnToolResults = [];
438
+ // SAMR: track whether Phase 1 produced tool calls
439
+ let phase1HadToolCalls = false;
440
+ let _pendingStructuredResponse = null;
441
+ let _pendingStructuredResponseLen = -1;
442
+ try {
443
+ const { getDatabase } = await import("../storage/db.js");
444
+ const db = getDatabase();
445
+ const row = db.prepare("SELECT parent_session_id FROM sessions WHERE id = ?").get(deps.session.id);
446
+ const isSubSession = !!row?.parent_session_id;
447
+ let contextWindow = modelInfo?.contextWindow || 0;
448
+ if (isSubSession && contextWindow > 0) {
449
+ contextWindow = Math.min(45000, contextWindow);
450
+ }
451
+ const settings = attemptedOverflowRecovery
452
+ ? relaxCompactionSettings(deps.getCompactionSettings(contextWindow))
453
+ : deps.getCompactionSettings(contextWindow);
454
+ if (contextWindow) {
455
+ await deps.compactForContext(provider, system, contextWindow, signal, settings, attemptedOverflowRecovery);
456
+ }
457
+ // Vision-tool gate: for vision-proxy (text-only) models the registry
458
+ // adds 3 image tools (~500-700 tok) on every turn. Drop them when the
459
+ // turn has no plausible image involvement. Bias is KEEP — retained on
460
+ // any image signal, attachment, cached image, or prior-tool turn.
461
+ const includeVisionTools = visionToolsNeeded({
462
+ userMessage,
463
+ messages: deps.messages,
464
+ cachedImageCount: listCachedImages().length,
465
+ priorTurnHadTools: deps.messages.some((m) => m?.role === "tool"),
466
+ });
467
+ const baseToolsRaw = createBuiltinTools(deps.bash, deps.mode, {
468
+ runTask: (request, abortSignal) => deps.runTask(request, combineAbortSignals(signal, abortSignal)),
469
+ runDelegation: (request, abortSignal) => deps.runDelegation(request, combineAbortSignals(signal, abortSignal)),
470
+ readDelegation: (id) => deps.readDelegation(id),
471
+ listDelegations: () => deps.listDelegations(),
472
+ modelId: turnModelId,
473
+ includeVisionTools,
474
+ consultParentSession: deps.consultParentSession,
475
+ });
476
+ // Top-level cumulative cap state. We accumulate the raw tool set
477
+ // (base + MCP + PIL response tools) across the assembly below,
478
+ // then apply the cap once. Tier ratios are looser than the
479
+ // sub-agent cap (50%/80%) so casual single-tool turns are not
480
+ // trimmed. See sub-agent-cap.ts.
481
+ // Chitchat: drop builtin tools too (not just MCP). A 1-word greeting
482
+ // never needs bash/read_file/edit_file/grep — those schemas alone
483
+ // cost ~1.5K input tokens on this CLI. Falls back to baseTools for
484
+ // every non-chitchat turn (PIL gates conservatively).
485
+ //
486
+ // BUG-A guard — when prior turns already issued tool_calls (their
487
+ // results still live in the messages history), DROPPING tools on a
488
+ // continuation chitchat ("tiếp tục" / "continue") causes two
489
+ // failures: (1) DeepSeek goes into native DSML markup fallback
490
+ // because it sees tool-call history but no schema (visible in
491
+ // sessions 002df4014cb4 + fc19b4daee20); (2) the agent has no way
492
+ // to actually CONTINUE the prior task — the user's clear intent.
493
+ // Detect prior-tool-context and keep the base tool set in that
494
+ // case. The 1.5K token saving for true greetings (no prior tool
495
+ // history) is preserved.
496
+ const turnCaps = getProviderCapabilities(requireRuntimeProvider(runtime));
497
+ const _priorTurnHadTools = deps.messages.some((m) => m?.role === "tool");
498
+ let rawToolSet = !turnCaps.supportsClientTools(runtime.modelInfo)
499
+ ? {}
500
+ : isChitchat && !_priorTurnHadTools
501
+ ? {}
502
+ : baseToolsRaw;
503
+ // MCP skip: chitchat / greeting inputs don't need 7 MCP servers'
504
+ // worth of tool schemas (~20K input tokens). PIL Layer 1 already
505
+ // gates this conservatively (≤10 chars + ≤2 words OR brain "none").
506
+ if (deps.mode === "agent" &&
507
+ (!isChitchat || _priorTurnHadTools) &&
508
+ turnCaps.supportsClientTools(runtime.modelInfo)) {
509
+ // Smart MCP filter: drop OPTIONAL MCP servers whose category the
510
+ // current message gives no signal for. Browser/vision servers
511
+ // (Playwright/Chrome/Figma/Canva) skip unless the message touches a
512
+ // page; docs/web servers (context7/fetch) skip unless the message
513
+ // looks like an external lookup. Each MCP contributes 8-15 tools at
514
+ // ~150 tok each, so local code work — the majority of turns — saves
515
+ // ~13K input tokens it would otherwise pay every turn. Domain
516
+ // servers (filesystem/tools/harness) always pass through. Logic is
517
+ // a pure helper (src/mcp/smart-filter.ts) so it is unit-tested.
518
+ // Override with MUONROI_DISABLE_SMART_MCP=1.
519
+ const filteredServers = filterMcpServersByMessage(loadMcpServers(), userMessage, {
520
+ disabled: process.env.MUONROI_DISABLE_SMART_MCP === "1",
521
+ });
522
+ // Ecosystem question → muonroi-docs is the authoritative source the
523
+ // agent is nudged to consult FIRST. Wait for it specifically beyond the
524
+ // normal deadline so a cold first-connect lands THIS turn instead of
525
+ // "ready next turn" (session 584ba476c07a: first ecosystem question
526
+ // missed docs while warming → agent guessed from local files).
527
+ const criticalServerIds = mentionsEcosystemScope(userMessage)
528
+ ? filteredServers.filter((s) => /(^|[-_])docs([-_]|$)/.test(s.id) && /muonroi/i.test(s.id)).map((s) => s.id)
529
+ : undefined;
530
+ // MCP non-blocking: acquireMcpTools self-bounds — it connects servers
531
+ // in parallel and returns PARTIAL results at its internal deadline
532
+ // (fast/cached servers included; slow first-connects reported in
533
+ // .errors and available next turn). Clients are POOLED across turns
534
+ // (client-pool.ts), so a server cold-spawns at most once per session
535
+ // instead of every turn. No outer race: the old race discarded the
536
+ // WHOLE bundle on timeout (Phase 1c — session f6f7881a5fae).
537
+ let mcpBundle = null;
538
+ try {
539
+ mcpBundle = await acquireMcpTools(filteredServers, {
540
+ onOAuthRequired: (_serverId, url) => {
541
+ // Server-supplied URL is untrusted — openUrl validates the
542
+ // scheme and spawns via execFile (no shell), closing the
543
+ // command-injection vector the old exec() opener had.
544
+ openUrl(url);
545
+ },
546
+ ...(criticalServerIds && criticalServerIds.length > 0 ? { criticalServerIds } : {}),
547
+ });
548
+ }
549
+ catch (err) {
550
+ logger.error("mcp", "buildMcpToolSet failed, proceeding with builtins only", { error: err });
551
+ }
552
+ if (mcpBundle) {
553
+ closeMcp = mcpBundle.close;
554
+ // Drop filesystem-MCP read/write/edit tools that duplicate the
555
+ // first-class builtin file tools. Without this, models re-read the
556
+ // SAME file via both `read_file` and `mcp_filesystem__read_text_file`
557
+ // (live grok session f5dfab0ce0ca: a 772-line file read 6×), wasting
558
+ // ~150 tok/schema PLUS re-injecting whole files into context. The
559
+ // builtins are strictly better (read-before-write, LSP, CRLF match,
560
+ // dedup/read-budget wrappers). Non-duplicate fs tools are untouched.
561
+ const _builtinToolNames = new Set(Object.keys(rawToolSet));
562
+ const { tools: _dedupedMcpTools, dropped: _droppedFsMcp } = dropRedundantFsMcpTools(mcpBundle.tools, _builtinToolNames);
563
+ rawToolSet = { ...rawToolSet, ..._dedupedMcpTools };
564
+ // muonroi-tools is THIS CLI: every tool it exposes (ee_query,
565
+ // ee_feedback, ee_health, usage_forensics, lsp_query, setup_guide,
566
+ // selfverify_*) is now a NATIVE in-process builtin (src/tools/
567
+ // native-tools.ts) — strictly better (no subprocess, no cold-start).
568
+ // If an external/legacy config still self-spawns muonroi-tools, drop
569
+ // any MCP twin whose native equivalent is present so the model never
570
+ // sees two interchangeable copies. (The CLI no longer self-spawns it
571
+ // by default — see auto-setup.ts.)
572
+ for (const key of Object.keys(rawToolSet)) {
573
+ const twin = key.match(/^mcp_muonroi-tools__(.+)$/);
574
+ if (twin && rawToolSet[twin[1]])
575
+ delete rawToolSet[key];
576
+ }
577
+ if (_droppedFsMcp.length > 0 && deps.session) {
578
+ try {
579
+ logInteraction(deps.session.id, "routing", {
580
+ model: turnModelId,
581
+ data: { droppedRedundantFsMcp: _droppedFsMcp },
582
+ });
583
+ }
584
+ catch {
585
+ /* telemetry best-effort */
586
+ }
587
+ }
588
+ if (mcpBundle.errors.length > 0) {
589
+ // A pooled server that is still cold-starting is NOT "unavailable"
590
+ // — it's warming up and will be ready next turn. Only surface
591
+ // GENUINE failures as "unavailable"; show warming servers as a
592
+ // soft, non-alarming note (and only the first time, since the
593
+ // pool connects them in the background).
594
+ const warming = mcpBundle.errors.filter((e) => /still connecting/.test(e));
595
+ const failed = mcpBundle.errors.filter((e) => !/still connecting/.test(e));
596
+ if (failed.length > 0) {
597
+ yield { type: "content", content: `MCP unavailable: ${failed.join(" | ")}\n\n` };
598
+ }
599
+ if (warming.length > 0) {
600
+ const names = warming.map((e) => e.split(":")[0]).join(", ");
601
+ yield { type: "content", content: `MCP warming up (${names}) — ready from the next turn.\n\n` };
602
+ }
603
+ }
604
+ }
605
+ }
606
+ // PIL response tools: inject structured output tool when taskType detected
607
+ if (_hasResponseTools && turnCaps.supportsClientTools(runtime.modelInfo)) {
608
+ rawToolSet = { ...rawToolSet, ..._pilResponseTools };
609
+ captureToolSchemas(_pilResponseTools);
610
+ }
611
+ // Apply the top-level cumulative cap once over the fully-assembled
612
+ // raw tool set. State is per-turn; each turn gets a fresh budget.
613
+ const topLevelCap = wrapToolSetWithCap(rawToolSet, {
614
+ maxCumulativeChars: getTopLevelToolBudgetChars(deps.maxToolRounds),
615
+ midTierRatio: 0.5,
616
+ highTierRatio: 0.8,
617
+ label: "top-level",
618
+ });
619
+ // Phase C3: layer cross-turn dedup on top of the top-level cap.
620
+ const tools = wrapToolSetWithReadBudget(wrapToolSetWithDedup(topLevelCap.tools, deps.crossTurnDedup), deps.readBudget);
621
+ captureToolSchemas(tools);
622
+ let responseToolCalled = false;
623
+ // A turn must surface exactly ONE final structured answer. Cheap
624
+ // models sometimes emit the response tool MORE THAN ONCE in a single
625
+ // step (session 9b1b39bf4dc6: grok emitted respond_general twice —
626
+ // a 278-char "I must read the code" hedge, then the 3782-char real
627
+ // answer — both in one step). Yielding each inline appends two
628
+ // stacked structured_response blocks and shows the hedge first.
629
+ // Instead we BUFFER the response-tool payloads and yield only the
630
+ // most complete one (longest serialized data) after the stream
631
+ // drains — robust to either ordering (hedge-then-answer or
632
+ // answer-then-summary).
633
+ _pendingStructuredResponse = null;
634
+ _pendingStructuredResponseLen = -1;
635
+ let _responseToolEmitCount = 0;
636
+ // G3: providerOptions assembly is owned by the capability layer
637
+ // (src/providers/capabilities.ts). buildTurnProviderOptions feeds
638
+ // sessionId in so openai.promptCacheKey is derived per turn.
639
+ // The task-type-driven anthropic.thinking budget override stays
640
+ // here because it depends on PIL task context, not provider quirks.
641
+ // biome-ignore lint/suspicious/noExplicitAny: matches RuntimeResult.providerOptions shape (any) used downstream
642
+ const baseProviderOpts = buildTurnProviderOptions(runtime, { sessionId: deps.session?.id }) ?? {};
643
+ const providerOpts = runtime.modelInfo?.reasoning && runtime.modelInfo?.provider === "anthropic"
644
+ ? {
645
+ ...baseProviderOpts,
646
+ anthropic: {
647
+ ...(baseProviderOpts.anthropic ?? {}),
648
+ thinking: {
649
+ type: "enabled",
650
+ budgetTokens: taskTypeToReasoningEffort(pilCtx.taskType) === "high"
651
+ ? 32_768
652
+ : taskTypeToReasoningEffort(pilCtx.taskType) === "medium"
653
+ ? 8_192
654
+ : 2_048,
655
+ },
656
+ },
657
+ }
658
+ : baseProviderOpts;
659
+ // Use catalog's thinkingType field instead of regex matching.
660
+ // providerOpts is loosely typed (Record<string, unknown>) after the
661
+ // g1 capability refactor — narrow with a local typed view.
662
+ const thinkingModelInfo = getModelInfo(runtime.modelId);
663
+ const providerOptsAnyView = providerOpts;
664
+ if (providerOptsAnyView.anthropic?.thinking?.type === "enabled" &&
665
+ thinkingModelInfo?.thinkingType === "adaptive") {
666
+ providerOptsAnyView.anthropic.thinking = { type: "adaptive" };
667
+ }
668
+ // OpenAI api-key path: `store: true` is seeded by OpenAIStrategy
669
+ // via factory.defaultProviderOptions (Phase 12.2-G4 migration).
670
+ // OAuth backend (ChatGPT Codex) overrides with `store: false` via
671
+ // the auth registry. Both flow through resolveModelRuntime →
672
+ // runtime.providerOptions → buildTurnProviderOptions and arrive
673
+ // here merged into providerOpts.openai.
674
+ // Top-level dropParam — shared with sub-agent path via shouldDropParam.
675
+ // See src/providers/runtime.ts for the central rule.
676
+ const dropParam = (p) => shouldDropParam(runtime, p);
677
+ // Tier-aware behavioural suffix. Cheap models (DeepSeek V4 Flash etc.)
678
+ // ignore well-worded tool descriptions but DO adopt instructions when
679
+ // surfaced in the system prompt. Smart models don't need this — gated
680
+ // by `modelInfo.tier === "fast"`. See cheap-model-playbook.ts for
681
+ // motivation + escape hatch (MUONROI_DISABLE_CHEAP_MODEL_PLAYBOOK=1).
682
+ // Fast-tier steering, front-loaded for primacy: task convergence
683
+ // workbook (anti-ramble — cuts tool-call count, the dominant
684
+ // cheap-model cost) layered UNDER the tool-use playbook so the
685
+ // CRITICAL tool rules stay at the very front. Both fixed per turn, so
686
+ // they stay inside the cached prefix.
687
+ // F3c — tool-turn: use reduced system prompt (skip
688
+ // native-capabilities + skills already shown in first call).
689
+ const activeSystem = llmCallsThisTurn > 0 && toolTurnSystem ? toolTurnSystem : system;
690
+ const systemWithWorkbook = shouldInjectCheapModelWorkbook(runtime.modelInfo)
691
+ ? injectCheapModelWorkbook(activeSystem, pilCtx.taskType)
692
+ : activeSystem;
693
+ const systemWithPlaybook = shouldInjectCheapModelPlaybook(runtime.modelInfo)
694
+ ? injectCheapModelPlaybook(systemWithWorkbook)
695
+ : systemWithWorkbook;
696
+ // A2: front-load a one-line shell/env directive for fast-tier models.
697
+ // The authoritative ENVIRONMENT block already states OS/shell/cwd in
698
+ // the prompt body, but budget models underweight non-front-loaded
699
+ // rules — so echo the correct-syntax line at the very front. Derived
700
+ // from resolveShell({}) (same source as buildEnvironmentBlock) so it
701
+ // is always accurate to the actual shell the bash tool will spawn.
702
+ // Gated to fast tier, so the claude branch below still sees `system`.
703
+ const systemWithShell = shouldInjectCheapModelPlaybook(runtime.modelInfo)
704
+ ? injectCheapModelShellDirective(systemWithPlaybook, cheapModelShellLine(resolveShell({}).kind, process.platform))
705
+ : systemWithPlaybook;
706
+ // Append the LIVE MCP tool roster so the agent calls connected MCP
707
+ // tools by their exact mcp_<server>__<tool> name instead of shelling
708
+ // out (session f6f7881a5fae). Built from the FINAL toolset for this
709
+ // iteration (post smart-filter + fs-dedup), so it never names a tool
710
+ // the model can't actually call. Dynamic per turn → must live OUTSIDE
711
+ // the cached staticPrefix; for claude it lands in the second
712
+ // (non-cached) system message via the slice below.
713
+ const mcpCapabilityBlock = buildMcpCapabilityBlock(Object.keys(tools));
714
+ const systemWithCaps = mcpCapabilityBlock ? `${systemWithShell}${mcpCapabilityBlock}` : systemWithShell;
715
+ const systemForModel = runtime.modelId.startsWith("claude")
716
+ ? [
717
+ {
718
+ role: "system",
719
+ content: systemParts.staticPrefix,
720
+ providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
721
+ },
722
+ {
723
+ role: "system",
724
+ content: systemWithCaps.slice(systemParts.staticPrefix.length),
725
+ },
726
+ ]
727
+ : systemWithCaps;
728
+ // Capture prompt-size breakdown so recordUsage can attach it to the
729
+ // cost-log entry. Without this, "system prompt is huge" is unfalsifiable.
730
+ // chars/4 ≈ tokens for English; reported as chars to keep math obvious.
731
+ const messagesChars = deps.messages.reduce((s, m) => {
732
+ const c = m.content;
733
+ if (typeof c === "string")
734
+ return s + c.length;
735
+ if (Array.isArray(c)) {
736
+ for (const part of c) {
737
+ if (typeof part.text === "string") {
738
+ s += part.text.length;
739
+ }
740
+ }
741
+ }
742
+ return s;
743
+ }, 0);
744
+ let toolsChars = 0;
745
+ let toolsCount = 0;
746
+ for (const [name, t] of Object.entries(tools)) {
747
+ toolsCount += 1;
748
+ toolsChars += name.length;
749
+ const desc = t.description;
750
+ if (typeof desc === "string")
751
+ toolsChars += desc.length;
752
+ try {
753
+ // Schemas often dominate tool size on non-Anthropic providers
754
+ // (Zod-derived JSON schemas can be 2-5K chars per tool).
755
+ const params = t.parameters ??
756
+ t.inputSchema;
757
+ if (params)
758
+ toolsChars += JSON.stringify(params).length;
759
+ }
760
+ catch {
761
+ /* best-effort */
762
+ }
763
+ }
764
+ deps.setLastPromptBreakdown({
765
+ systemChars: system.length,
766
+ staticPrefixChars: systemParts.staticPrefix.length,
767
+ dynamicSuffixChars: systemParts.dynamicSuffix.length,
768
+ playwrightGuidanceChars: playwrightGuidance.length,
769
+ messagesChars,
770
+ messagesCount: deps.messages.length,
771
+ toolsChars,
772
+ toolsCount,
773
+ });
774
+ // Task 2.6a — assign a fresh correlation ID for this top-level streamText call.
775
+ const _topCallId = crypto.randomUUID();
776
+ deps.setCurrentCallId(_topCallId);
777
+ // Capture finishReason so we can surface "round cap hit" as a visible
778
+ // toast — without this, the agent silently stops mid-flight when
779
+ // stepCountIs(maxToolRounds) fires and the user sees the TUI freeze
780
+ // (session 7dcf8fd7d6a4 hit exactly 100 rounds → looked like a crash).
781
+ let _lastFinishReason = null;
782
+ // F3b — track hard cap hit for visible toast after stream ends.
783
+ let _hardCapHit = false;
784
+ // Phase B4: compact older tool_result parts before each top-level
785
+ // step once cumulative message chars exceed the configured threshold.
786
+ // The compactor preserves system + first user verbatim and keeps the
787
+ // last N tool turns intact; older results are rewritten into short
788
+ // stubs. Symmetric to the B3 sub-agent path; reuses the same module
789
+ // with `label: "top-level"` so the stub text reflects which loop
790
+ // elided the content.
791
+ const topLevelCompactThreshold = getTopLevelCompactThresholdChars();
792
+ const topLevelCompactKeepLast = getTopLevelCompactKeepLast();
793
+ // Phase O1 — capture providerOptions SHAPE (types only) for forensics.
794
+ deps.setLastProviderOptionsShape(Object.keys(providerOpts).length > 0 ? extractProviderOptionsShape(providerOpts) : null);
795
+ // Substitute the enriched user message for the current turn so the LLM sees PIL additions,
796
+ // while leaving the DB-persisted `deps.messages` clean for future turns.
797
+ const _messagesForCall = deps.messages.map((m) => m === userModelMessage ? userEnrichedMessage : m);
798
+ if (wireDebug.enabled) {
799
+ wireDebug.logRequest({
800
+ providerId: runtime.modelInfo?.provider ?? "unknown",
801
+ modelId: runtime.modelId,
802
+ messages: _messagesForCall,
803
+ systemChars: systemForModel?.length ?? 0,
804
+ toolNames: tools ? Object.keys(tools) : undefined,
805
+ providerOptions: providerOpts,
806
+ });
807
+ }
808
+ // sanitizeHistory is identity for every provider (kept as a hook
809
+ // for future provider-specific quirks). Reasoning round-trips
810
+ // natively via @ai-sdk/openai-compatible — see
811
+ // src/providers/__tests__/reasoning-roundtrip.test.ts.
812
+ const _topMessagesForCall = applyAnthropicPromptCaching(turnCaps.sanitizeHistory(_messagesForCall), runtime.modelId);
813
+ // Closure-mutable cap for the tool-loop askcard rescue.
814
+ // Phase 1 (SAMR) skips the dynamic cap (it's a single-step path).
815
+ // Algorithm extracted to ./tool-loop-cap.ts so it can be unit-tested.
816
+ const _baseDynamicStopWhen = createToolLoopCapPredicate({
817
+ initialCap: deps.maxToolRounds,
818
+ ask: async (info) => {
819
+ if (info.kind === "pattern") {
820
+ if (patternLoopInjectCount < 1) {
821
+ patternLoopInjectCount++;
822
+ patternLoopForceHalt = true;
823
+ deps.messages.push({
824
+ role: "user",
825
+ content: `[System Warning: You have called tool '${info.toolName}' ${info.count} times with similar arguments in this turn. If you are stuck in a loop, please re-evaluate your plan, change your approach, or explain the blocker. Do not repeat the same unsuccessful tool call.]`,
826
+ });
827
+ return "stop";
828
+ }
829
+ }
830
+ // Query the agent itself to make the decision instead of showing a raw user askcard
831
+ // Active only in headless (batchApi) mode — TUI users expect the CLI to stop and wait.
832
+ if (deps.batchApi && agentLoopDecisionCount < MAX_AGENT_LOOP_DECISIONS) {
833
+ try {
834
+ const _ar = globalThis.__muonroiAgentRuntime;
835
+ _ar?.emitEvent({
836
+ t: "event",
837
+ kind: "toast",
838
+ level: "info",
839
+ text: info.kind === "pattern"
840
+ ? `phát hiện lặp tool liên tục — đang hỏi ý kiến agent có muốn tiếp tục không...`
841
+ : `đạt giới hạn số bước (${info.stepNumber} bước) — đang hỏi ý kiến agent có muốn tiếp tục không...`,
842
+ });
843
+ const loopContext = info.kind === "pattern"
844
+ ? `You have called the tool '${info.toolName}' repeatedly ${info.count} times in a row with similar inputs. This indicates you might be stuck in a tool loop.`
845
+ : `You have reached the tool execution limit of ${info.stepNumber} steps (cap: ${info.cap}).`;
846
+ const systemPrompt = `You are an AI loop-guard assistant. Your task is to evaluate the agent's progress and decide whether they should keep trying to run tools (continue) or stop and present their best answer now (stop).\n` +
847
+ `Choose 'stop' if the agent is stuck in an unproductive loop (e.g. repeated unsuccessful commands, permission errors, file reading loops without making progress), or if they already have enough information to write a final response.\n` +
848
+ `Choose 'continue' only if they are making active progress, are close to a breakthrough, or genuinely need a few more steps to verify their changes.\n\n` +
849
+ `Format your decision EXACTLY as:\n` +
850
+ `<decision>continue</decision> or <decision>stop</decision>\n` +
851
+ `followed by a brief 1-sentence explanation of your decision (in English).`;
852
+ const recentMessages = deps.messages.slice(-10);
853
+ const modelMessages = [
854
+ ...recentMessages,
855
+ {
856
+ role: "user",
857
+ content: `[System Loop Guard Context: ${loopContext}\nBased on the conversation history and recent tool executions, do you need to continue executing tools or should you stop and summarize now?]`,
858
+ },
859
+ ];
860
+ const { text: decisionText } = await generateText({
861
+ model: runtime.model,
862
+ system: systemPrompt,
863
+ messages: modelMessages,
864
+ abortSignal: signal,
865
+ });
866
+ const cleanText = decisionText.trim();
867
+ const match = cleanText.match(/<decision>(continue|stop)<\/decision>/i);
868
+ const decision = match ? match[1].toLowerCase() : "stop";
869
+ let reason = cleanText.replace(/<decision>.*?<\/decision>/gs, "").trim();
870
+ // Strip DeepSeek's raw DSML or other XML tool leaks from the toast reason
871
+ reason = reason.replace(/<[^>]+>/g, "").trim();
872
+ _ar?.emitEvent({
873
+ t: "event",
874
+ kind: "toast",
875
+ level: "info",
876
+ text: `Agent quyết định: ${decision === "continue" ? "TIẾP TỤC" : "DỪNG LẠI"} (${reason || "không có lý do"})`,
877
+ });
878
+ agentLoopDecisionCount++;
879
+ if (decision === "continue") {
880
+ return "continue";
881
+ }
882
+ else {
883
+ return "stop";
884
+ }
885
+ }
886
+ catch (err) {
887
+ logger.error("orchestrator", "loop auto-decision failed", { error: err });
888
+ }
889
+ }
890
+ return deps.askToolLoopContinue ? await deps.askToolLoopContinue(info) : "stop";
891
+ },
892
+ // Phase 5 BUG-H — thread the resolved natural ceiling down so the
893
+ // pattern askcard can pick a context-aware default action (continue
894
+ // early in the run, stop once we're past 50% of the natural budget).
895
+ naturalCeiling: _naturalCeiling,
896
+ });
897
+ // Phase 4 Plan 04 (4B) — compose per-session ceiling alongside the
898
+ // existing cap + pattern guard. Logical OR: any condition true → halt.
899
+ // Counter is per-SESSION and increments once per stopWhen invocation
900
+ // (i.e. once per finished tool step), persisting across user turns
901
+ // so a wandering 3-turn burst still trips at the matrix limit.
902
+ const _ceilingSessionId = deps.session?.id ?? "no-session";
903
+ // Phase 5 Fix 3 — capture the actual step number when the ceiling
904
+ // trips so the halt toast can report the real value, not the
905
+ // ceiling/ceiling literal that always showed e.g. "5/5" regardless
906
+ // of how many steps the turn actually ran.
907
+ const _ceilingHitAtStep = 0;
908
+ // Phase 5 Fix 5 — matrix ceiling is now a SOFT BOUNDARY, never a
909
+ // hard halt. Phase 4's hard halt was a blunt anti-wandering
910
+ // measure that also blocked legitimate multi-step work: every
911
+ // long task (improve coverage, optimize startup, refactor) ran
912
+ // out of budget mid-flight and required the user to manually
913
+ // type "tiếp tục". Wrong philosophy — "done" must be the agent's
914
+ // call, not the counter's.
915
+ //
916
+ // What replaced the hard halt:
917
+ // - Scope-reminders (4A path, prepareStep above) inject
918
+ // "[approaching ceiling]" reminder at floor(ceiling*0.7) and
919
+ // repeat at K cadence. Past the ceiling, every step gets a
920
+ // re-anchor so the model is repeatedly nudged toward closure.
921
+ // - The dynamicStopWhen no longer checks the matrix ceiling at
922
+ // all. The only halt source is `_baseDynamicStopWhen` which
923
+ // enforces `deps.maxToolRounds` as the ULTIMATE runaway safety
924
+ // net (default raised; see CLI default).
925
+ // - 4R bash repeat detector still catches the dominant wandering
926
+ // pattern (identical command twice in a row).
927
+ // - F6 synthesis still ensures any natural stream-end without
928
+ // text gets a final summary.
929
+ //
930
+ // _ceilingHit and _ceilingHitAtStep are kept for telemetry: a
931
+ // crossing event is logged for forensics, but no action is taken.
932
+ const dynamicStopWhen = (async (state) => {
933
+ // F3b — HARD cap: absolute non-bumpable ceiling per user turn.
934
+ // Fires AFTER the soft cap (maxToolRounds) has been bumped by the
935
+ // user. Prevents runaway sessions (session 526a83cf22df: 16 LLM
936
+ // calls for a single user message, 2.44M total input tokens).
937
+ if (state.steps.length > deps.hardMaxToolRounds) {
938
+ _hardCapHit = true;
939
+ return true;
940
+ }
941
+ // Terminal response tool: a `respond_*` call IS the model's final
942
+ // structured answer (its `execute` is identity — the payload lives
943
+ // in the tool-call args). `shouldHaltOnResponseTool` decides if the
944
+ // emission is terminal vs a premature "blind" announce:
945
+ // - response tool AFTER real tool work (read/grep/bash) → terminal,
946
+ // halt now (kills d95113d3be09 seq=27: 7 reads → 87× respond loop
947
+ // at call #1, no extra round-trip for the common case).
948
+ // - a single blind response (no prior investigation) → do NOT halt;
949
+ // give the model the step it announced it would use to read code
950
+ // (session e4a9d97a90: lone blind respond_general was force-stopped
951
+ // by the old halt-on-first rule and the agent never investigated).
952
+ // - a 2nd blind response with still no real work → narration loop,
953
+ // halt. In-step spam (80× in one generation) is bounded separately
954
+ // by RESPONSE_TOOL_SPAM_CAP — stopWhen only runs BETWEEN steps.
955
+ // Read from `state.steps` (the SDK's own per-step record) rather than
956
+ // the for-await consumer's `responseToolCalled` flag — stopWhen runs
957
+ // between steps and may evaluate before our consumer processed the
958
+ // tool-result part, so the flag would race.
959
+ const _steps = state.steps;
960
+ if (shouldHaltOnResponseTool(_steps))
961
+ return true;
962
+ const base = await _baseDynamicStopWhen(state);
963
+ if (base)
964
+ return true;
965
+ const next = incSessionStep(_ceilingSessionId);
966
+ // Telemetry-only: record the first time the counter crosses
967
+ // the matrix ceiling, so post-hoc queries can correlate the
968
+ // ceiling crossing with task completion outcomes. No halt.
969
+ if (next === _stepCeiling) {
970
+ try {
971
+ if (deps.session?.id) {
972
+ logInteraction(deps.session.id, "f6_synthesis", {
973
+ data: {
974
+ outcome: "ceiling_crossed_softly",
975
+ stepAtCrossing: next,
976
+ naturalCeiling: _naturalCeiling,
977
+ taskType: _ceilingTaskType,
978
+ size: _ceilingSize,
979
+ hardCapMaxToolRounds: deps.maxToolRounds,
980
+ },
981
+ });
982
+ }
983
+ }
984
+ catch {
985
+ /* telemetry only */
986
+ }
987
+ }
988
+ return false;
989
+ });
990
+ // BUG-A fix — when this turn carries an empty tool set (the
991
+ // chitchat optimization at line ~1107 drops all schemas), AI SDK
992
+ // sends `tools:[], tool_choice:undefined` to the provider. DeepSeek
993
+ // V4 Flash sees prior `tool_call`/`tool_result` parts still in the
994
+ // messages history (the previous turn used 20 tools) and the
995
+ // model stays in agent-mode — but with no schema to call, it
996
+ // falls back to its NATIVE DSML markup syntax and emits that as
997
+ // plain text. AI SDK does not parse the native format, so the
998
+ // markup leaks straight to the TUI as garbage and the turn
999
+ // produces no useful output. Setting `toolChoice:"none"` is the
1000
+ // canonical way to tell the model "you cannot call tools this
1001
+ // turn" so it emits text-only. Verified by stream_start telemetry
1002
+ // on sessions 002df4014cb4 (leak) + fc19b4daee20 (leak): both had
1003
+ // toolCount=0 + toolChoice=undefined on chitchat continuation.
1004
+ const _toolsAreEmpty = Object.keys(tools).length === 0;
1005
+ const _finalToolChoice = _toolsAreEmpty
1006
+ ? "none"
1007
+ : _hasResponseTools && turnCaps.supportsClientTools(runtime.modelInfo)
1008
+ ? "auto"
1009
+ : undefined;
1010
+ // BUG-C telemetry — record tool availability + toolChoice at the
1011
+ // call site so future regressions show up in telemetry not in TUI.
1012
+ try {
1013
+ const _toolNamesAtCall = Object.keys(tools);
1014
+ logInteraction(deps.session?.id ?? "no-session", "stream_start", {
1015
+ model: turnModelId,
1016
+ data: {
1017
+ toolCount: _toolNamesAtCall.length,
1018
+ hasBash: _toolNamesAtCall.includes("bash"),
1019
+ toolNames: _toolNamesAtCall.slice(0, 25),
1020
+ toolChoice: _finalToolChoice ?? "undefined",
1021
+ hasResponseTools: _hasResponseTools,
1022
+ supportsClientTools: turnCaps.supportsClientTools(runtime.modelInfo),
1023
+ priorTurnHadTools: _topMessagesForCall.some((m) => m?.role === "tool"),
1024
+ },
1025
+ });
1026
+ }
1027
+ catch {
1028
+ /* telemetry only */
1029
+ }
1030
+ // Silent-hang guard: abort the stream (and surface a toast in the
1031
+ // catch below) if the provider sends no chunk for too long. Re-armed
1032
+ // on every chunk via stall.pet(), so it never kills an actively
1033
+ // streaming call. Disposed when the stream ends or errors.
1034
+ stallTriggered = false;
1035
+ const stall = createStallWatchdog(getProviderStallTimeoutMs(), () => {
1036
+ stallTriggered = true;
1037
+ });
1038
+ // F3c — hard-cap LLM calls per turn before this streamText()
1039
+ if (++llmCallsThisTurn > MAX_LLM_CALLS_PER_TURN) {
1040
+ stall.dispose();
1041
+ yield {
1042
+ type: "error",
1043
+ content: `Turn aborted: reached the limit of ${MAX_LLM_CALLS_PER_TURN} LLM calls for this message. Try a narrower request or break your task into smaller steps.`,
1044
+ isAuthError: false,
1045
+ };
1046
+ yield { type: "done" };
1047
+ return;
1048
+ }
1049
+ const result = streamText({
1050
+ model: runtime.model,
1051
+ system: systemForModel,
1052
+ messages: _topMessagesForCall,
1053
+ tools,
1054
+ toolChoice: _finalToolChoice,
1055
+ stopWhen: stepRouterPhase === "phase1" ? stepCountIs(1) : dynamicStopWhen,
1056
+ maxRetries: 0,
1057
+ abortSignal: combineAbortSignals(signal, stall.signal),
1058
+ // Repair malformed tool-call JSON args before they bubble up as
1059
+ // InvalidToolInputError → tool-error → repetition-detector abort.
1060
+ // Conservative: only fixes the two observed Qwen-style defects.
1061
+ // See src/orchestrator/tool-args-repair.ts for the transforms.
1062
+ experimental_repairToolCall: repairToolCallHook,
1063
+ prepareStep: ({ stepNumber: sn, messages: stepMessages }) => {
1064
+ chunksThisStep = 0;
1065
+ if (deps.isSubSession) {
1066
+ logger.info("orchestrator", "Sub-session executing tool round", {
1067
+ stepNumber: sn,
1068
+ maxToolRounds: deps.maxToolRounds,
1069
+ });
1070
+ deps.emitSubagentStatus({
1071
+ agent: "sub-session",
1072
+ description: `Running sub-session task...`,
1073
+ detail: `[Sub-Session] Executing tool round ${sn + 1} of ${deps.maxToolRounds}...`,
1074
+ });
1075
+ }
1076
+ // --- Live-queue steering injection ---------------------------
1077
+ // Drain the UI steer queue ONCE per prepareStep call (sn >= 1),
1078
+ // accumulate into pendingSteers, and graft pendingSteers onto the
1079
+ // messages this step returns. Dedup-by-content makes re-appending
1080
+ // idempotent even if a stall-reprompt restart re-reads history.
1081
+ const withSteers = (r) => {
1082
+ // Guard the drain on !signal.aborted too: planSteerInjection
1083
+ // already refuses to inject on abort, but draining still CLEARS
1084
+ // the UI queue — so on a (programmatic) abort we must not drain,
1085
+ // or a queued-but-uninjected message is lost (spec §143).
1086
+ const _drained = sn >= 1 && steerEnabled && !signal.aborted ? (deps.drainSteerMessages?.() ?? []) : [];
1087
+ const _newSteers = planSteerInjection({
1088
+ drained: _drained,
1089
+ aborted: signal.aborted,
1090
+ enabled: steerEnabled,
1091
+ });
1092
+ if (_newSteers.length > 0) {
1093
+ pendingSteers.push(..._newSteers);
1094
+ deps.appendMidTurnMessages?.(_newSteers);
1095
+ try {
1096
+ const _ar = globalThis.__muonroiAgentRuntime;
1097
+ _ar?.emitEvent({
1098
+ t: "event",
1099
+ kind: "steer-inject",
1100
+ count: _newSteers.length,
1101
+ atStep: sn,
1102
+ runId: deps.getActiveRunId() ?? "",
1103
+ });
1104
+ }
1105
+ catch (emitErr) {
1106
+ logger.error("orchestrator", "steer-inject telemetry failed", { error: emitErr });
1107
+ }
1108
+ }
1109
+ const baseRes = (() => {
1110
+ if (pendingSteers.length === 0)
1111
+ return r;
1112
+ const _base = r.messages ?? stepMessages;
1113
+ const _existingContents = new Set(_base
1114
+ .filter((m) => m.role === "user")
1115
+ .map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))));
1116
+ const steersToAdd = pendingSteers.filter((s) => {
1117
+ const sContent = typeof s.content === "string" ? s.content : JSON.stringify(s.content);
1118
+ return !_existingContents.has(sContent);
1119
+ });
1120
+ if (steersToAdd.length === 0)
1121
+ return r;
1122
+ const insertIdx = _base.length;
1123
+ return {
1124
+ ...r,
1125
+ messages: [
1126
+ ..._base.slice(0, insertIdx),
1127
+ ...steersToAdd,
1128
+ ..._base.slice(insertIdx),
1129
+ ],
1130
+ };
1131
+ })();
1132
+ if (baseRes.messages) {
1133
+ baseRes.messages = applyAnthropicPromptCaching(baseRes.messages, runtime.modelId);
1134
+ }
1135
+ return baseRes;
1136
+ };
1137
+ const stripped = turnCaps.sanitizeHistory(stepMessages);
1138
+ // Agent-controlled veto (PRESERVE) or lighter selective keep (KEEP_TOOL_IDS) for this turn's B4 compaction.
1139
+ // PRESERVE_FULL_CONTEXT skips the compactor entirely (full history).
1140
+ // KEEP_TOOL_IDS: id1,id2 (from prior stub " (id=...) ") protects only those specific tool results
1141
+ // without the cost of a full veto. Parsed from reasoning or assistant note.
1142
+ let keepToolIds = [];
1143
+ const hasPreserve = stripped.some((m) => {
1144
+ const c = m?.content;
1145
+ const texts = [];
1146
+ if (typeof c === "string")
1147
+ texts.push(c);
1148
+ if (Array.isArray(c)) {
1149
+ for (const p of c)
1150
+ if (typeof p?.text === "string")
1151
+ texts.push(p.text);
1152
+ }
1153
+ const joined = texts.join(" ");
1154
+ if (joined.includes("PRESERVE_FULL_CONTEXT"))
1155
+ return true;
1156
+ // Idea 3: parse lighter token
1157
+ const mKeep = joined.match(/KEEP_TOOL_IDS\s*[:=]\s*([a-z0-9_, -]+)/i);
1158
+ if (mKeep) {
1159
+ keepToolIds = mKeep[1]
1160
+ .split(/[,\s]+/)
1161
+ .map((s) => s.trim())
1162
+ .filter(Boolean);
1163
+ }
1164
+ return false;
1165
+ });
1166
+ if (hasPreserve) {
1167
+ return withSteers({ messages: stripped });
1168
+ }
1169
+ // F2 — envelope = system prompt + JSON-Schema of every tool
1170
+ // re-sent on every step. Without this the threshold check
1171
+ // ignored 20-50K of fixed prompt overhead and the compactor
1172
+ // sat dormant just below its limit while billed input climbed.
1173
+ const envelopeChars = computeEnvelopeChars(systemForModel, tools);
1174
+ // G1 + G2 — feed the model's context window so the compactor
1175
+ // can pick a token-aware threshold and shrink keepLastTurns
1176
+ // when the window is approaching its ceiling.
1177
+ const contextWindowTokens = runtime.modelInfo?.contextWindow ?? 0;
1178
+ // Idea 4: fire-and-forget persist of elided tool outputs to EE (source=tool-artifact)
1179
+ // so later layer3/ee.query "tool-artifact id=xxx" or "full tool result id=..." can re-hydrate.
1180
+ // Use process-level fallbacks (prepareStep closure does not directly expose outer cwd/session in this scope).
1181
+ const _cwd = process.cwd();
1182
+ const _sess = undefined; // best-effort; EE artifact still indexable by content + meta.toolCallId
1183
+ const persistArtifact = (toolCallId, toolName, fullContent, reason) => {
1184
+ // Local-first: record the FULL output in-process so ee_query can
1185
+ // rehydrate it even if EE is down (the EE extract below caps at 8k
1186
+ // and needs the network; the cache keeps up to 200k, no network).
1187
+ recordArtifact(toolCallId, toolName, fullContent);
1188
+ // Lived-experience telemetry: count this elision so a later
1189
+ // "cảm nhận trong CLI" question answers from data, and so the
1190
+ // post-compaction note can list what it just stubbed.
1191
+ recordElision(toolCallId, toolName, fullContent.length, sn);
1192
+ try {
1193
+ getDefaultEEClient()
1194
+ .extract({
1195
+ transcript: fullContent.slice(0, 8000),
1196
+ projectPath: _cwd,
1197
+ meta: {
1198
+ source: "tool-artifact",
1199
+ toolCallId,
1200
+ toolName,
1201
+ reason,
1202
+ sessionId: _sess,
1203
+ elidedAtStep: sn,
1204
+ },
1205
+ }, AbortSignal.timeout(700))
1206
+ .catch(() => { });
1207
+ }
1208
+ catch {
1209
+ /* fail-open, no silent swallow of the decision */
1210
+ }
1211
+ };
1212
+ // T1.1 + T1.2 — reasoning models (DeepSeek V4 Flash, R1) emit 2K-5K
1213
+ // CoT tokens per turn that accumulate across the multi-step loop.
1214
+ // Strip old reasoning and compact earlier (ratio 0.3 vs 0.5) to
1215
+ // cut ~40-60% of cumulative input tokens.
1216
+ const isReasoningModel = runtime.modelInfo?.reasoning === true;
1217
+ const compacted = compactSubAgentMessages(stripped, {
1218
+ thresholdChars: topLevelCompactThreshold,
1219
+ // Rec #1 (cheap part): on meta/self-eval turns keep a couple more
1220
+ // trailing tool turns verbatim — those carry the reasoning the
1221
+ // agent is being asked to reflect on, and over-eliding them is
1222
+ // exactly what starves a self-evaluation. One boolean, no new
1223
+ // detection logic (isMetaAnalysisPrompt already gates layer3/5).
1224
+ keepLastTurns: topLevelCompactKeepLast + (isMetaAnalysisPrompt(userMessage) ? 2 : 0),
1225
+ label: "top-level",
1226
+ envelopeChars,
1227
+ contextWindowTokens,
1228
+ contextFillRatio: isReasoningModel ? 0.3 : undefined,
1229
+ keepToolIds: keepToolIds.length ? keepToolIds : undefined,
1230
+ persistArtifact,
1231
+ stripOldReasoning: isReasoningModel,
1232
+ });
1233
+ if (compacted !== stripped)
1234
+ recordCompaction(sn);
1235
+ // Pre-compaction visibility: give the agent one step of notice
1236
+ // before B4 actually rewrites history into stubs. This is the
1237
+ // advance warning that was missing — agent can now decide to
1238
+ // summarize, finish, or request preservation. Fires when we did
1239
+ // NOT compact this step (compacted === stripped, restored by the
1240
+ // compactSubAgentMessages no-op ref contract) AND the prompt is
1241
+ // approaching the threshold. Must compare CHARS (messages +
1242
+ // envelope), not stripped.length (a message count that never
1243
+ // exceeds a char-scaled threshold) — session 2b7a10219499.
1244
+ const _preWarnChars = cumulativeMessageChars(stripped) + envelopeChars;
1245
+ if (compacted === stripped && shouldPreWarnCompaction(_preWarnChars, topLevelCompactThreshold)) {
1246
+ const _cp = buildCheckpointReminder(sn, true);
1247
+ const _pre = `[pre-compaction warning at step ${sn} — next step(s) will likely rewrite older tool results to stubs (threshold ${topLevelCompactThreshold}, keepLast=${topLevelCompactKeepLast}). ${_cp} Summarize or finish if possible, or warn the user they can run the "/compact" command if they want a clean compressed history.]`;
1248
+ return withSteers({ messages: attachReminderToMessages(stripped, _pre) });
1249
+ }
1250
+ // Phase 4A — scope reminder injection (REQ-005).
1251
+ // Cadence K = 3/5/8 for small/medium/large. Soft-warn fires
1252
+ // ONCE per session at floor(ceiling*0.7). Reminder lives in
1253
+ // the tool_result/system channel so B3/B4 compaction cannot
1254
+ // strip it (system-prompt path is unsafe at high step counts).
1255
+ // Ceiling reuses the 4B (task_type × size) matrix result
1256
+ // resolved above (`_stepCeiling`, `_ceilingTaskType`,
1257
+ // `_ceilingSize`, `_ceilingSessionId`) so the reminder and the
1258
+ // halt boundary agree on the same number.
1259
+ const _scopeSize = _ceilingSize;
1260
+ const _scopeK = cadenceForSize(_scopeSize);
1261
+ const _scopeCeiling = Math.max(1, _stepCeiling ?? deps.maxToolRounds ?? 30);
1262
+ const _scopeStep = sn;
1263
+ const _shouldRemind = shouldInjectReminder(_scopeStep, _scopeK);
1264
+ const _shouldWarn = shouldInjectSoftWarn(_scopeStep, _scopeCeiling, _ceilingSessionId);
1265
+ // Phase 5 Fix 5 (revised) — past the natural matrix ceiling the
1266
+ // orchestrator emits a STRONG re-anchor reminder, but only when
1267
+ // (a) crossing the ceiling for the first time (one-shot), OR
1268
+ // (b) hitting a normal cadence step (multiple of K).
1269
+ // Original Phase 5 Fix 5 fired on EVERY step past ceiling, which
1270
+ // on long-running sessions (e.g. step 77 / ceiling 6 in session
1271
+ // 1f29e238a816) produced 70+ redundant reminders that bloated
1272
+ // the tool_result channel and forced the model into a "YES still
1273
+ // on scope" loop on every tool call.
1274
+ const _pastNaturalCeiling = _scopeStep > _naturalCeiling;
1275
+ const _justCrossedCeiling = shouldInjectCeilingCrossing(_scopeStep, _naturalCeiling, _ceilingSessionId);
1276
+ const _pastCeilingAtCadence = _pastNaturalCeiling && _shouldRemind;
1277
+ // Fix #8 — self-repetition one-shot. Fires when the assistant
1278
+ // has opened the last 3 streamText steps with the same 4-word
1279
+ // phrase (e.g. "YES still on scope" — session 1f29e238a816
1280
+ // emitted 15 such bursts past ceiling). Reminder is attached
1281
+ // alongside (and before) any scope reminder so the model sees
1282
+ // the behavioural correction first.
1283
+ const _shouldRepeatReminder = shouldInjectRepetitionReminder(_ceilingSessionId);
1284
+ if (_shouldRemind || _shouldWarn || _justCrossedCeiling || _shouldRepeatReminder) {
1285
+ const _baseReminder = buildScopeReminder({
1286
+ step: _scopeStep,
1287
+ ceiling: _scopeCeiling,
1288
+ taskType: _ceilingTaskType,
1289
+ size: _scopeSize,
1290
+ originalPrompt: userMessage,
1291
+ });
1292
+ // Strong "past natural budget" prefix only applies when we
1293
+ // ACTUALLY want the model to consider wrapping up — i.e. on
1294
+ // the crossing event or at a cadence step past ceiling, not
1295
+ // on every silent step in between.
1296
+ const _useStrong = _justCrossedCeiling || _pastCeilingAtCadence;
1297
+ const _scopePart = _shouldRemind || _shouldWarn || _justCrossedCeiling
1298
+ ? _useStrong
1299
+ ? `[past natural budget — step ${_scopeStep}/${_naturalCeiling}] If task is COMPLETE, emit final answer NOW. If you need to keep working in this long session, suggest that the user run the "/compact" slash command to compress the conversation history before continuing. Otherwise, simplify the next step. ${_baseReminder}`
1300
+ : _shouldWarn
1301
+ ? `[approaching ceiling] ${_baseReminder}`
1302
+ : _baseReminder
1303
+ : null;
1304
+ const _reminder = _shouldRepeatReminder
1305
+ ? _scopePart
1306
+ ? `${buildRepetitionReminder(_ceilingSessionId)}\n${_scopePart}`
1307
+ : buildRepetitionReminder(_ceilingSessionId)
1308
+ : _scopePart;
1309
+ const withReminder = attachReminderToMessages(compacted, _reminder);
1310
+ return withSteers({ messages: withReminder });
1311
+ }
1312
+ if (compacted === stripped && stripped === stepMessages)
1313
+ return withSteers({});
1314
+ // Self-awareness note: tell the model compaction happened so it
1315
+ // knows earlier context was elided and can adjust its behavior.
1316
+ // Enhanced per EE anti-mù plan (docs/ee-anti-mu-compaction-plan.md Phase 2): include proactive
1317
+ // "task finished?", "compacted yet?", "EE checkpoint" so agent can self-assess and avoid mù
1318
+ // even when the top-level summary is not in its immediate focus (sub-agents, long loops).
1319
+ const _compactNote = compacted !== stripped
1320
+ ? (() => {
1321
+ // Rec #2: turn the generic "high-value elided? use ee_query"
1322
+ // prose into a concrete, actionable manifest of what was just
1323
+ // stubbed (id/tool/size) — sourced from the elisions recorded
1324
+ // by persistArtifact above — so the rehydrate round-trip is
1325
+ // informed, not blind.
1326
+ const _m = formatElisionManifest();
1327
+ return `[context compacted at step ${sn} — older or low-value tool results rewritten to stubs to fit budget. High-value evidence (file reads, bash, your previous responses) is kept verbatim. ${buildCheckpointReminder(sn, true)}${_m ? ` ${_m}` : ""}]`;
1328
+ })()
1329
+ : null;
1330
+ if (_compactNote) {
1331
+ return withSteers({ messages: attachReminderToMessages(compacted, _compactNote) });
1332
+ }
1333
+ return withSteers({ messages: compacted });
1334
+ },
1335
+ ...(dropParam("temperature") ? {} : { temperature: 0.7 }),
1336
+ ...(dropParam("maxOutputTokens") ? {} : { maxOutputTokens: taskTypeToMaxTokens(pilCtx.taskType) }),
1337
+ ...(Object.keys(providerOpts).length > 0 ? { providerOptions: providerOpts } : {}),
1338
+ experimental_onStepStart: (event) => {
1339
+ stepNumber = getStepNumber(event, stepNumber + 1);
1340
+ notifyObserver(observer?.onStepStart, {
1341
+ stepNumber,
1342
+ timestamp: Date.now(),
1343
+ });
1344
+ },
1345
+ onStepFinish: (event) => {
1346
+ const currentStep = getStepNumber(event, Math.max(stepNumber, 0));
1347
+ stepNumber = Math.max(stepNumber, currentStep);
1348
+ const stepUsage = getUsage(event);
1349
+ notifyObserver(observer?.onStepFinish, {
1350
+ stepNumber: currentStep,
1351
+ timestamp: Date.now(),
1352
+ finishReason: getFinishReason(event),
1353
+ usage: stepUsage,
1354
+ });
1355
+ // Realtime status bar update per step
1356
+ if (stepUsage.inputTokens || stepUsage.outputTokens) {
1357
+ // O1 — thread THIS turn's providerOptions shape per step so every
1358
+ // step event records it (not just step 1) and an interleaved task
1359
+ // can't overwrite it. Mirrors the gate used for the call itself.
1360
+ deps.recordUsage(stepUsage, "message", runtime.modelId, Object.keys(providerOpts).length > 0 ? extractProviderOptionsShape(providerOpts) : null);
1361
+ }
1362
+ // Fix #8 — feed the assistant text emitted in this step into
1363
+ // the self-repetition detector. The slice covers everything
1364
+ // appended to `assistantText` since the previous step boundary;
1365
+ // a step with no text (pure tool call) records as empty, which
1366
+ // recordAssistantBurst treats as a no-op so the current run is
1367
+ // preserved across tool interludes.
1368
+ const _stepText = assistantText.slice(_assistantTextAtLastStep);
1369
+ _assistantTextAtLastStep = assistantText.length;
1370
+ recordAssistantBurst(_ceilingSessionId, _stepText);
1371
+ },
1372
+ onFinish: ({ finishReason }) => {
1373
+ _lastFinishReason = finishReason ?? null;
1374
+ // Task 2.6b — emit llm-done (agent-mode only).
1375
+ try {
1376
+ const _ar = globalThis.__muonroiAgentRuntime;
1377
+ _ar?.emitEvent({
1378
+ t: "event",
1379
+ kind: "llm-done",
1380
+ correlationId: _topCallId,
1381
+ totalChars: assistantText.length,
1382
+ finishReason: finishReason ?? "stop",
1383
+ });
1384
+ }
1385
+ catch (err) {
1386
+ logger.error("orchestrator", "failed to emit llm-done", { error: err });
1387
+ }
1388
+ deps.setCurrentCallId("");
1389
+ // Rec #1 persisted forensics: onFinish fires once per top-level turn,
1390
+ // so flush this session's cumulative experience counts here. Readers
1391
+ // take the latest row per session, so the last turn's row is the
1392
+ // session total. No-ops on missing id / all-zero. Fail-open.
1393
+ try {
1394
+ persistSessionExperience(deps.session?.id ?? null, getSessionExperienceCounts());
1395
+ }
1396
+ catch (err) {
1397
+ logger.error("orchestrator", "persistSessionExperience failed", { error: err });
1398
+ }
1399
+ },
1400
+ });
1401
+ let _topTokenIndex = 0;
1402
+ const _wireProviderIdTop = runtime.modelInfo?.provider ?? "unknown";
1403
+ for await (const part of result.fullStream) {
1404
+ stall.pet(); // chunk arrived — reset the stall watchdog
1405
+ // Count only real content parts. The watchdog abort itself surfaces
1406
+ // as an "abort" part — counting it would defeat the TTFB-stall gate
1407
+ // (a frozen-before-first-byte stall yields ONLY the abort part).
1408
+ if (part.type !== "abort") {
1409
+ chunksThisAttempt++;
1410
+ chunksThisStep++;
1411
+ }
1412
+ if (signal.aborted) {
1413
+ yield { type: "content", content: "\n\n[Cancelled]" };
1414
+ break;
1415
+ }
1416
+ if (wireDebug.enabled) {
1417
+ wireDebug.logChunk(_wireProviderIdTop, String(part.type ?? "unknown"), {
1418
+ hasText: typeof part.text === "string"
1419
+ ? part.text.length
1420
+ : undefined,
1421
+ hasReasoning: typeof part.reasoning === "string"
1422
+ ? part.reasoning.length
1423
+ : undefined,
1424
+ });
1425
+ if (part.type === "error") {
1426
+ wireDebug.logError(_wireProviderIdTop, part.error);
1427
+ }
1428
+ }
1429
+ switch (part.type) {
1430
+ case "text-delta":
1431
+ assistantText += part.text;
1432
+ // Task 2.6b — emit llm-token (agent-mode only; high-volume, default-off per Phase 4).
1433
+ try {
1434
+ const _ar = globalThis.__muonroiAgentRuntime;
1435
+ _ar?.emitEvent({
1436
+ t: "event",
1437
+ kind: "llm-token",
1438
+ correlationId: _topCallId,
1439
+ delta: part.text,
1440
+ tokenIndex: _topTokenIndex++,
1441
+ });
1442
+ }
1443
+ catch {
1444
+ /* best-effort */
1445
+ }
1446
+ yield { type: "content", content: part.text };
1447
+ break;
1448
+ case "reasoning-delta":
1449
+ reasoningPreview = `${reasoningPreview}${part.text}`.slice(-256);
1450
+ if (containsEncryptedReasoning(reasoningPreview)) {
1451
+ if (!encryptedReasoningHidden) {
1452
+ encryptedReasoningHidden = true;
1453
+ yield { type: "reasoning", content: "[Encrypted reasoning hidden]" };
1454
+ }
1455
+ break;
1456
+ }
1457
+ // P0 native observation: accumulate reasoning for intent context.
1458
+ deps.appendTurnAssistantReasoning(part.text);
1459
+ yield { type: "reasoning", content: part.text };
1460
+ break;
1461
+ case "tool-call": {
1462
+ const tc = toToolCall(part);
1463
+ activeToolCalls.push(tc);
1464
+ // SAMR: track that Phase 1 produced tool calls → transition to Phase 2
1465
+ if (stepRouterPhase === "phase1")
1466
+ phase1HadToolCalls = true;
1467
+ // Response tool = the terminal final answer (identity execute;
1468
+ // the payload lives in the call args). Buffer it (longest-wins)
1469
+ // straight from the args and gate UI/DB/exec spam: cheap models
1470
+ // sometimes emit the response tool MANY times in ONE generation
1471
+ // (session 8d8f498268ed: 80× identical respond_general hedge in
1472
+ // one step). stopWhen only halts BETWEEN steps, so it can't stop
1473
+ // an in-step spam — this does. Surface only the first indicator;
1474
+ // if the model spams past the cap, finalize NOW with the
1475
+ // buffered answer instead of streaming out the degenerate step.
1476
+ if (isResponseTool(tc.function.name)) {
1477
+ _responseToolEmitCount += 1;
1478
+ try {
1479
+ const _payload = JSON.parse(tc.function.arguments || "{}");
1480
+ const _len = JSON.stringify(_payload).length;
1481
+ if (_len > _pendingStructuredResponseLen) {
1482
+ _pendingStructuredResponseLen = _len;
1483
+ _pendingStructuredResponse = {
1484
+ taskType: getResponseTaskType(tc.function.name) ?? tc.function.name,
1485
+ data: _payload,
1486
+ };
1487
+ }
1488
+ }
1489
+ catch {
1490
+ /* keep the prior buffered payload */
1491
+ }
1492
+ responseToolCalled = true;
1493
+ // Only the first response-tool call gets a UI indicator.
1494
+ if (_responseToolEmitCount === 1) {
1495
+ yield { type: "tool_calls", toolCalls: [tc] };
1496
+ }
1497
+ if (_responseToolEmitCount >= RESPONSE_TOOL_SPAM_CAP && _pendingStructuredResponse) {
1498
+ if (deps.session) {
1499
+ try {
1500
+ logInteraction(deps.session.id, "f6_synthesis", {
1501
+ eventSubtype: "response_tool_spam_abort",
1502
+ data: { emitted: _responseToolEmitCount, keptChars: _pendingStructuredResponseLen },
1503
+ });
1504
+ }
1505
+ catch {
1506
+ /* telemetry best-effort */
1507
+ }
1508
+ }
1509
+ // Persist a clean turn (user + the single buffered answer)
1510
+ // so history stays usable; the spam is dropped. Mirrors the
1511
+ // tool-repetition abort: yield + done + return (do NOT await
1512
+ // result.response — the stream is still spewing calls).
1513
+ const _data = _pendingStructuredResponse.data;
1514
+ const _answerText = typeof _data.response === "string"
1515
+ ? _data.response
1516
+ : JSON.stringify(_pendingStructuredResponse.data);
1517
+ try {
1518
+ deps.appendCompletedTurn(userModelMessage, [
1519
+ { role: "assistant", content: _answerText },
1520
+ ]);
1521
+ }
1522
+ catch (persistErr) {
1523
+ console.error(`[message-processor] response-tool-spam persist failed: ${persistErr?.message}`);
1524
+ }
1525
+ yield {
1526
+ type: "structured_response",
1527
+ structuredResponse: _pendingStructuredResponse,
1528
+ };
1529
+ yield { type: "done" };
1530
+ return;
1531
+ }
1532
+ break; // response tools skip write-ahead/hooks/normal tool_calls yield
1533
+ }
1534
+ // EE PreToolUse hook: fire intercept before tool execution.
1535
+ {
1536
+ const turnAssistantReasoning = deps.getTurnAssistantReasoning();
1537
+ const intentContext = {
1538
+ ...(turnAssistantReasoning ? { assistantReasoningExcerpt: turnAssistantReasoning.slice(-200) } : {}),
1539
+ ...(deps.priorWarningIdsInSession?.size > 0
1540
+ ? {
1541
+ priorWarningIdsInSession: Array.from(deps.priorWarningIdsInSession).slice(-20),
1542
+ }
1543
+ : {}),
1544
+ ...(pilCtx.gsdPhase ? { gsdPhase: pilCtx.gsdPhase } : {}),
1545
+ ...(userMessage.slice(0, 200) ? { userGoalExcerpt: userMessage.slice(0, 200) } : {}),
1546
+ };
1547
+ const preInput = {
1548
+ hook_event_name: "PreToolUse",
1549
+ tool_name: tc.function.name,
1550
+ tool_input: JSON.parse(tc.function.arguments || "{}"),
1551
+ session_id: deps.session?.id,
1552
+ cwd: deps.bash.getCwd(),
1553
+ ...(Object.keys(intentContext).length > 0 ? { intent_context: intentContext } : {}),
1554
+ };
1555
+ const preResult = await deps.fireHook(preInput, signal).catch(() => ({
1556
+ blocked: false,
1557
+ blockingErrors: [],
1558
+ preventContinuation: false,
1559
+ additionalContexts: [],
1560
+ results: [],
1561
+ eeMatches: [],
1562
+ }));
1563
+ for (const ctx of preResult.additionalContexts ?? []) {
1564
+ yield { type: "content", content: `${ctx}\n` };
1565
+ }
1566
+ // Store structured EE matches for session guidance injection on next turn.
1567
+ for (const m of preResult.eeMatches ?? []) {
1568
+ deps.sessionEEGuidance.set(m.id, {
1569
+ toolName: m.toolName,
1570
+ message: m.message,
1571
+ why: m.why,
1572
+ confidence: m.confidence,
1573
+ });
1574
+ // Cap at 30 entries — oldest first, trim when exceeded.
1575
+ if (deps.sessionEEGuidance.size > 30) {
1576
+ const firstKey = deps.sessionEEGuidance.keys().next().value;
1577
+ if (firstKey !== undefined)
1578
+ deps.sessionEEGuidance.delete(firstKey);
1579
+ }
1580
+ }
1581
+ // P0 native observation: track which principle IDs surfaced
1582
+ // this turn so the next intercept can dedup server-side.
1583
+ try {
1584
+ const { getLastSurfacedState } = await import("../ee/intercept.js");
1585
+ const { surfacedIds } = getLastSurfacedState();
1586
+ for (const id of surfacedIds)
1587
+ deps.priorWarningIdsInSession.add(id);
1588
+ // Cap memory: keep only most-recent 100 IDs.
1589
+ if (deps.priorWarningIdsInSession.size > 100) {
1590
+ const arr = Array.from(deps.priorWarningIdsInSession);
1591
+ deps.setPriorWarningIdsInSession(new Set(arr.slice(-100)));
1592
+ }
1593
+ }
1594
+ catch {
1595
+ /* fail-open */
1596
+ }
1597
+ }
1598
+ // Pitfall 9: log the pending call so reconcile() can recover any
1599
+ // staged .tmp files if the process is killed before tool-result.
1600
+ if (deps.pendingCalls) {
1601
+ const turnId = deps.session?.id ?? "anon";
1602
+ const callId = stableCallId(turnId, tc.function.name, tc.function.arguments);
1603
+ // Phase 0: predictStagedPaths = [] for all tools (refined in Phase 1).
1604
+ void deps.pendingCalls.begin({ call_id: callId, tool_name: tc.function.name }).catch(() => { });
1605
+ // Attach callId to the ToolCall so tool-result can end it.
1606
+ tc._pendingCallId = callId;
1607
+ }
1608
+ // Phase A4: write-ahead persistence — insert a pending row into
1609
+ // tool_calls BEFORE executing the tool. If the stream throws
1610
+ // mid-call (e.g. provider 5xx, abort, network drop), this row
1611
+ // remains as `pending` so `usage forensics` can show the args
1612
+ // the model passed. The post-stream appendMessages() path
1613
+ // (INSERT OR IGNORE + UPDATE) will finalize this row to
1614
+ // `completed` once the turn settles normally.
1615
+ if (deps.sessionStore && deps.session) {
1616
+ // Predicted assistant seq: user message + assistant message
1617
+ // are appended atomically by appendCompletedTurn().
1618
+ // getNextMessageSequence() returns the seq the user message
1619
+ // will get; the assistant message is the next one after.
1620
+ let predictedSeq = -1;
1621
+ try {
1622
+ predictedSeq = getNextMessageSequence(deps.session.id) + 1;
1623
+ }
1624
+ catch {
1625
+ /* fail-open — leave predictedSeq=-1; post-stream UPDATE corrects it */
1626
+ }
1627
+ persistToolCallWriteAhead(deps.session.id, predictedSeq, tc.id, tc.function.name, tc.function.arguments || "{}");
1628
+ }
1629
+ notifyObserver(observer?.onToolStart, {
1630
+ toolCall: tc,
1631
+ timestamp: Date.now(),
1632
+ });
1633
+ // Interaction log: tool call start
1634
+ try {
1635
+ if (deps.session) {
1636
+ logInteraction(deps.session.id, "tool_call", {
1637
+ eventSubtype: tc.function.name,
1638
+ data: {
1639
+ toolCallId: tc.id,
1640
+ argsPreview: tc.function.arguments.slice(0, 200),
1641
+ },
1642
+ });
1643
+ }
1644
+ }
1645
+ catch {
1646
+ /* fail-open */
1647
+ }
1648
+ yield { type: "tool_calls", toolCalls: [tc] };
1649
+ break;
1650
+ }
1651
+ case "tool-result": {
1652
+ const tc = {
1653
+ id: part.toolCallId,
1654
+ type: "function",
1655
+ function: { name: part.toolName, arguments: JSON.stringify(part.input ?? {}) },
1656
+ };
1657
+ let tr = toToolResult(part.output);
1658
+ // Vision Bridge: proxy image-bearing tool results for text-only models (any tool, not just MCP)
1659
+ try {
1660
+ const bridgeResult = await bridgeMcpToolResult(part.toolName, tr.output, turnModelId, signal, part.toolCallId);
1661
+ if (bridgeResult.proxied) {
1662
+ tr = {
1663
+ ...tr,
1664
+ output: typeof bridgeResult.output === "string"
1665
+ ? bridgeResult.output
1666
+ : JSON.stringify(bridgeResult.output),
1667
+ };
1668
+ yield { type: "content", content: `[Vision Bridge: image → text for ${turnModelId}]\n` };
1669
+ }
1670
+ }
1671
+ catch (err) {
1672
+ console.error("[Agent:visionBridge] failed to process image for tool result", err);
1673
+ }
1674
+ // Safety-block intercept: when bash.execute returns a
1675
+ // "BLOCKED (...):" error, surface an askcard to the user via
1676
+ // deps.askSafetyOverride. If allowed, rewrite the output as a
1677
+ // success so the model knows the command was approved (it may
1678
+ // retry on the next turn). The approved command is stored in
1679
+ // the global __muonroiSafetyApproved map so registry.ts's
1680
+ // bash.execute bypasses the block on the retry.
1681
+ const _outputText = [tr.output, tr.error].filter((x) => typeof x === "string").join("\n");
1682
+ const _blockMatch = _outputText.match(/^BLOCKED \(([^)]+)\):\s*(.*)/);
1683
+ if (_blockMatch && part.toolName === "bash") {
1684
+ const _blockKind = _blockMatch[1];
1685
+ const _blockReason = _blockMatch[2];
1686
+ const _command = typeof part.input === "object" && part.input != null
1687
+ ? String(part.input.command ?? "")
1688
+ : "";
1689
+ // empty-bash: auto-block without askcard.
1690
+ // The BLOCKED error from registry.ts already steers the agent
1691
+ // to self-correct. Showing an askcard adds user friction for
1692
+ // no benefit (empty bash calls are never intentional) and
1693
+ // wastes a turn of user interaction per strike.
1694
+ if (_blockKind === "empty-bash") {
1695
+ tr = { ...tr, success: false, error: _outputText, output: _outputText };
1696
+ }
1697
+ else {
1698
+ const _verdict = deps.askSafetyOverride
1699
+ ? await deps.askSafetyOverride({
1700
+ kind: _blockKind,
1701
+ toolName: part.toolName,
1702
+ blockedItem: _command,
1703
+ reason: _blockReason,
1704
+ source: "bash.execute",
1705
+ })
1706
+ : { action: "block" };
1707
+ if (_verdict.action === "allow-once" || _verdict.action === "allow-session") {
1708
+ // Store approval so registry.ts can bypass the block on retry.
1709
+ const _globalSafety = globalThis;
1710
+ if (!_globalSafety.__muonroiSafetyApproved) {
1711
+ _globalSafety.__muonroiSafetyApproved = new Map();
1712
+ }
1713
+ _globalSafety.__muonroiSafetyApproved.set(part.toolCallId, {
1714
+ kind: _verdict.action === "allow-session" ? "session" : "once",
1715
+ command: _command,
1716
+ });
1717
+ // Rewrite tool result as success so the stream continues
1718
+ // without an error. The model will see "Approved: ..." and
1719
+ // may retry the tool call on the next turn, at which point
1720
+ // registry.ts will see the approval and actually run it.
1721
+ tr = { ...tr, success: true, output: `Approved (${_verdict.action}): ${_blockReason}` };
1722
+ yield {
1723
+ type: "content",
1724
+ content: `[User approved blocked command: ${_blockKind} — ${_verdict.action}]\n`,
1725
+ };
1726
+ }
1727
+ if (_verdict.action === "block") {
1728
+ tr = { ...tr, success: false, error: _outputText, output: _outputText };
1729
+ }
1730
+ }
1731
+ }
1732
+ // Capture into the stall-rescue digest before any further
1733
+ // processing — if the stream stalls after this, these outputs
1734
+ // are all we have to synthesize a final answer from.
1735
+ pushStallToolResult(turnToolResults, part.toolName, typeof tr.output === "string" ? tr.output : JSON.stringify(tr.output));
1736
+ // Pitfall 9: settle the pending call log entry.
1737
+ if (deps.pendingCalls) {
1738
+ const pending = activeToolCalls.find((t) => t.id === part.toolCallId);
1739
+ const callId = pending?._pendingCallId;
1740
+ if (callId) {
1741
+ const endStatus = signal.aborted ? "aborted" : "settled";
1742
+ void deps.pendingCalls.end(callId, endStatus).catch(() => { });
1743
+ }
1744
+ }
1745
+ // EE PostToolUse hook: fire-and-forget after tool execution.
1746
+ {
1747
+ const postInput = {
1748
+ hook_event_name: "PostToolUse",
1749
+ tool_name: part.toolName,
1750
+ tool_input: part.input ?? {},
1751
+ tool_output: typeof tr.output === "string"
1752
+ ? { text: tr.output }
1753
+ : (tr.output ?? {}),
1754
+ session_id: deps.session?.id,
1755
+ cwd: deps.bash.getCwd(),
1756
+ };
1757
+ await deps.fireHook(postInput, signal).catch((err) => {
1758
+ console.error("[Agent:PostToolUse hook] failed", err);
1759
+ });
1760
+ }
1761
+ // Response tool: yield as structured_response instead of tool_result.
1762
+ // AI SDK v5 wraps tool outputs as `{type:"json", value:{...}}`; unwrap
1763
+ // to expose the schema-shaped payload to the UI renderer.
1764
+ if (isResponseTool(part.toolName)) {
1765
+ responseToolCalled = true;
1766
+ // Payload was already buffered (longest-wins) from the
1767
+ // tool-CALL args above; re-buffer from the executed result as
1768
+ // a fallback (unwraps the AI-SDK `{type:"json",value}` shape).
1769
+ // Counting + the spam cap live in the tool-call branch.
1770
+ const taskType = getResponseTaskType(part.toolName);
1771
+ const rawOutput = part.output;
1772
+ const unwrapped = rawOutput && typeof rawOutput === "object" && rawOutput.type === "json"
1773
+ ? (rawOutput.value ?? {})
1774
+ : (rawOutput ?? {});
1775
+ const _len = JSON.stringify(unwrapped ?? {}).length;
1776
+ if (_len > _pendingStructuredResponseLen) {
1777
+ _pendingStructuredResponseLen = _len;
1778
+ _pendingStructuredResponse = {
1779
+ taskType: taskType ?? part.toolName,
1780
+ data: unwrapped,
1781
+ };
1782
+ }
1783
+ notifyObserver(observer?.onToolFinish, { toolCall: tc, toolResult: tr, timestamp: Date.now() });
1784
+ break;
1785
+ }
1786
+ notifyObserver(observer?.onToolFinish, {
1787
+ toolCall: tc,
1788
+ toolResult: tr,
1789
+ timestamp: Date.now(),
1790
+ });
1791
+ // Interaction log: tool result.
1792
+ // Phase 5 BUG-J — for edit/write/update tools, persist the
1793
+ // structured diff (file_path, +N/-M counts, isNew flag, and
1794
+ // a bounded patch preview) so forensics queries can audit
1795
+ // what actually changed in each turn without re-reading
1796
+ // git history. Earlier the log only had the summary string
1797
+ // ("Edited X (+1 -1)") — the patch text was lost.
1798
+ try {
1799
+ if (deps.session) {
1800
+ const outputPreview = typeof tr.output === "string" ? tr.output.slice(0, 200) : JSON.stringify(tr.output).slice(0, 200);
1801
+ const _trWithDiff = tr;
1802
+ const diffMeta = _trWithDiff.diff &&
1803
+ (tc.function.name === "edit_file" ||
1804
+ tc.function.name === "write_file" ||
1805
+ tc.function.name === "update_file")
1806
+ ? {
1807
+ filePath: _trWithDiff.diff.filePath,
1808
+ additions: _trWithDiff.diff.additions,
1809
+ removals: _trWithDiff.diff.removals,
1810
+ isNew: _trWithDiff.diff.isNew,
1811
+ // Cap at 4000 chars — enough to inspect small/medium
1812
+ // edits without ballooning the SQLite row. Large
1813
+ // refactors get truncated with a tail marker so
1814
+ // readers know the patch is partial.
1815
+ patchPreview: _trWithDiff.diff.patch.length > 4000
1816
+ ? `${_trWithDiff.diff.patch.slice(0, 4000)}\n…[truncated]`
1817
+ : _trWithDiff.diff.patch,
1818
+ }
1819
+ : undefined;
1820
+ logInteraction(deps.session.id, "tool_result", {
1821
+ eventSubtype: tc.function.name,
1822
+ data: { success: tr.success, outputPreview, ...(diffMeta ? { diff: diffMeta } : {}) },
1823
+ });
1824
+ }
1825
+ }
1826
+ catch {
1827
+ /* fail-open */
1828
+ }
1829
+ yield { type: "tool_result", toolCall: tc, toolResult: tr };
1830
+ // Reset tool-repetition counter on any non-error result. A
1831
+ // successful call between two failures of the same shape is
1832
+ // progress and should not accumulate toward the abort gate.
1833
+ if (tr.success) {
1834
+ recordToolRepetitionSuccess(deps.session?.id ?? null);
1835
+ }
1836
+ // todo_write side-effect: surface the task list to the UI via a
1837
+ // dedicated chunk so the sticky checklist panel can re-render
1838
+ // without parsing tool args itself. Skipped when the snapshot
1839
+ // doesn't parse (malformed args) so the UI is never poisoned.
1840
+ if (tc.function.name === "todo_write" && tr.success) {
1841
+ const snap = snapshotFromTodoWriteArgs(tc.function.arguments);
1842
+ if (snap)
1843
+ yield { type: "task_list_update", taskListSnapshot: snap };
1844
+ }
1845
+ break;
1846
+ }
1847
+ case "tool-error": {
1848
+ // AI SDK emits this when tool execution throws/aborts before
1849
+ // producing a tool-result. Without this branch, the tool_call
1850
+ // log row has no matching tool_result and the EE judge never
1851
+ // sees the failure → silent ~1.6% pairing leak in prod DB.
1852
+ const errPart = part;
1853
+ const tc = {
1854
+ id: errPart.toolCallId,
1855
+ type: "function",
1856
+ function: { name: errPart.toolName, arguments: JSON.stringify(errPart.input ?? {}) },
1857
+ };
1858
+ const errMsg = errPart.error instanceof Error
1859
+ ? errPart.error.message
1860
+ : typeof errPart.error === "string"
1861
+ ? errPart.error
1862
+ : JSON.stringify(errPart.error);
1863
+ const tr = { success: false, output: `[tool-error] ${errMsg}` };
1864
+ // A respond_* (response) tool that ERRORED still carries the
1865
+ // model's terminal answer in its call args — for response tools
1866
+ // execution is identity, so an execution failure is usually a
1867
+ // post-processing issue and the payload is intact. Recover it so
1868
+ // the turn is not left empty (textLength:0) and the answer is not
1869
+ // swallowed on turn-finalize. Mirrors the tool-call / tool-result
1870
+ // buffering above.
1871
+ if (isResponseTool(errPart.toolName)) {
1872
+ if (!_pendingStructuredResponse && errPart.input && typeof errPart.input === "object") {
1873
+ const data = errPart.input;
1874
+ const _len = JSON.stringify(data).length;
1875
+ if (_len > _pendingStructuredResponseLen) {
1876
+ _pendingStructuredResponseLen = _len;
1877
+ _pendingStructuredResponse = {
1878
+ taskType: getResponseTaskType(errPart.toolName) ?? errPart.toolName,
1879
+ data,
1880
+ };
1881
+ }
1882
+ }
1883
+ if (!_pendingStructuredResponse) {
1884
+ // Nothing recoverable (args never parsed). Do NOT suppress F6
1885
+ // synthesis — let it produce a visible prose answer instead
1886
+ // of an empty turn.
1887
+ responseToolCalled = false;
1888
+ }
1889
+ }
1890
+ // Settle pending-call ledger so we don't leak stale .tmp files.
1891
+ if (deps.pendingCalls) {
1892
+ const pending = activeToolCalls.find((t) => t.id === errPart.toolCallId);
1893
+ const callId = pending?._pendingCallId;
1894
+ if (callId)
1895
+ void deps.pendingCalls.end(callId, "settled").catch(() => { });
1896
+ }
1897
+ // Phase A4: mark the write-ahead tool_calls row as `errored`.
1898
+ // The post-stream appendMessages() path does NOT see tool-error
1899
+ // parts in the assistant message content (the SDK doesn't emit
1900
+ // them there), so without this explicit update the row would
1901
+ // remain `pending` after a clean tool failure.
1902
+ if (deps.session) {
1903
+ markToolCallErrored(deps.session.id, errPart.toolCallId, errMsg);
1904
+ }
1905
+ // Fire PostToolUseFailure so EE judge can record IGNORED outcome.
1906
+ {
1907
+ const failInput = {
1908
+ hook_event_name: "PostToolUseFailure",
1909
+ tool_name: errPart.toolName,
1910
+ tool_input: errPart.input ?? {},
1911
+ error: errMsg,
1912
+ session_id: deps.session?.id,
1913
+ cwd: deps.bash.getCwd(),
1914
+ };
1915
+ await deps.fireHook(failInput, signal).catch(() => { });
1916
+ }
1917
+ try {
1918
+ if (deps.session) {
1919
+ logInteraction(deps.session.id, "tool_result", {
1920
+ eventSubtype: errPart.toolName,
1921
+ data: { success: false, error: errMsg.slice(0, 500), reason: "tool-error" },
1922
+ });
1923
+ }
1924
+ }
1925
+ catch (logErr) {
1926
+ console.error(`[message-processor] interaction-log tool_result failed: ${logErr?.message}`);
1927
+ }
1928
+ notifyObserver(observer?.onToolFinish, { toolCall: tc, toolResult: tr, timestamp: Date.now() });
1929
+ yield { type: "tool_result", toolCall: tc, toolResult: tr };
1930
+ // Tool-call perseveration guard. After N consecutive identical
1931
+ // (toolName, args, error) triples, abort the streaming loop
1932
+ // before TPM rate limits do (session 080fe2fcbf24).
1933
+ const repetition = recordToolRepetitionError(deps.session?.id ?? null, errPart.toolName, errPart.input, errMsg);
1934
+ if (repetition.shouldAbort) {
1935
+ const abortMsg = buildToolRepetitionAbortMessage(errPart.toolName, repetition.runLength, errMsg);
1936
+ try {
1937
+ if (deps.session) {
1938
+ logInteraction(deps.session.id, "error", {
1939
+ eventSubtype: "tool_repetition_abort",
1940
+ data: {
1941
+ toolName: errPart.toolName,
1942
+ runLength: repetition.runLength,
1943
+ errorPreview: errMsg.slice(0, 200),
1944
+ },
1945
+ });
1946
+ }
1947
+ }
1948
+ catch (logErr) {
1949
+ console.error(`[message-processor] interaction-log tool_repetition_abort failed: ${logErr?.message}`);
1950
+ }
1951
+ notifyObserver(observer?.onError, { message: abortMsg, timestamp: Date.now() });
1952
+ yield { type: "error", content: abortMsg, isAuthError: false };
1953
+ yield { type: "done" };
1954
+ return;
1955
+ }
1956
+ break;
1957
+ }
1958
+ case "tool-approval-request": {
1959
+ const approvalPart = part;
1960
+ const toolCallId = approvalPart.toolCall?.toolCallId ?? "";
1961
+ const pendingTc = activeToolCalls.find((tc) => tc.id === toolCallId);
1962
+ const tcForChunk = pendingTc ?? {
1963
+ id: toolCallId,
1964
+ type: "function",
1965
+ function: {
1966
+ name: approvalPart.toolCall?.toolName ?? "paid_request",
1967
+ arguments: JSON.stringify(approvalPart.toolCall?.input ?? {}),
1968
+ },
1969
+ };
1970
+ // Payment pre-check disabled — Stripe billing pending.
1971
+ const paymentPrecheck = undefined;
1972
+ // Plan 03-01: check permission mode before yielding approval request to UI.
1973
+ // auto-edit auto-approves file ops; yolo auto-approves everything.
1974
+ const toolName = approvalPart.toolCall?.toolName ?? "";
1975
+ const input = approvalPart.toolCall?.input ?? {};
1976
+ const context = toolName === "bash"
1977
+ ? { command: String(input.command ?? "") }
1978
+ : toolName === "write_file" ||
1979
+ toolName === "edit_file" ||
1980
+ toolName === "read_file" ||
1981
+ toolName === "grep"
1982
+ ? { path: String(input.path ?? input.file_path ?? "") }
1983
+ : undefined;
1984
+ if (!toolNeedsApproval(toolName, deps.permissionMode, context)) {
1985
+ // Auto-approve: respond directly without surfacing to UI.
1986
+ deps.respondToToolApproval(approvalPart.approvalId, true);
1987
+ appendAudit({
1988
+ kind: deps.permissionMode === "yolo" ? "yolo-override" : "permission-override",
1989
+ tool: toolName,
1990
+ mode: deps.permissionMode,
1991
+ context,
1992
+ ts: Date.now(),
1993
+ });
1994
+ break;
1995
+ }
1996
+ yield {
1997
+ type: "tool_approval_request",
1998
+ approvalId: approvalPart.approvalId,
1999
+ toolCall: tcForChunk,
2000
+ paymentPrecheck,
2001
+ };
2002
+ break;
2003
+ }
2004
+ case "error": {
2005
+ // F6b: a TRANSIENT error delivered as a stream PART (e.g. a provider
2006
+ // 5xx / dropped socket surfaced mid-loop after a tool step) otherwise
2007
+ // BYPASSES the thrown-error transient-retry path below — the turn would
2008
+ // surface the error and stop instead of retrying a flaky transient
2009
+ // (observed live: SiliconFlow 500 error-part ended a multi-step turn).
2010
+ // Route it through the SAME bounded budget (shared streamRetryCount /
2011
+ // MAX_STREAM_RETRIES) and the same no-content guard, so an error-part
2012
+ // and a thrown error behave identically. (A 2026-06-20 council debate
2013
+ // independently endorsed bounded auto-retry for exactly this case.)
2014
+ {
2015
+ const { transient: _partTransient } = classifyStreamError(part.error);
2016
+ if (_partTransient &&
2017
+ !assistantText.trim() &&
2018
+ streamRetryCount < MAX_STREAM_RETRIES &&
2019
+ !signal.aborted &&
2020
+ !stallTriggered) {
2021
+ streamRetryCount++;
2022
+ // Exponential backoff: 500 → 8000 ms with ±25% jitter (mirrors the
2023
+ // thrown-error path).
2024
+ const baseMs = 500;
2025
+ const expMs = Math.min(baseMs * 4 ** (streamRetryCount - 1), 8_000);
2026
+ const spread = expMs * 0.25;
2027
+ const nextDelayMs = Math.round(expMs + (Math.random() * 2 - 1) * spread);
2028
+ const errName = part.error instanceof Error ? part.error.name : "Error";
2029
+ const errMsg = part.error instanceof Error ? part.error.message : String(part.error);
2030
+ try {
2031
+ const _ar = globalThis.__muonroiAgentRuntime;
2032
+ _ar?.emitEvent({
2033
+ t: "event",
2034
+ kind: "stream-retry",
2035
+ attempt: streamRetryCount,
2036
+ maxAttempts: MAX_STREAM_RETRIES + 1,
2037
+ errorName: errName,
2038
+ errorMessage: errMsg,
2039
+ nextDelayMs,
2040
+ });
2041
+ }
2042
+ catch {
2043
+ /* best-effort telemetry */
2044
+ }
2045
+ try {
2046
+ if (deps.session) {
2047
+ logInteraction(deps.session.id, "stream_retry", {
2048
+ data: {
2049
+ attempt: streamRetryCount,
2050
+ maxAttempts: MAX_STREAM_RETRIES + 1,
2051
+ errorName: errName,
2052
+ errorMessage: errMsg.slice(0, 200),
2053
+ nextDelayMs,
2054
+ source: "error-part",
2055
+ },
2056
+ });
2057
+ }
2058
+ }
2059
+ catch (logErr) {
2060
+ console.error(`[message-processor] error-part retry log failed: ${logErr?.message}`);
2061
+ }
2062
+ await new Promise((resolve) => setTimeout(resolve, nextDelayMs));
2063
+ if (!signal.aborted) {
2064
+ continue streamAttempt;
2065
+ }
2066
+ }
2067
+ }
2068
+ // F6b-2: a TRANSIENT error-part that arrives AFTER content/tool steps
2069
+ // (F6b's no-content guard above skipped it — assistantText/tools already
2070
+ // flowed). Try to graft the COMPLETED steps onto history and re-issue, so
2071
+ // a flaky 5xx after a tool step RESUMES instead of ending the turn (the
2072
+ // observed Cycle-1 "fail liên tục" case: read big file → finish-step → 500).
2073
+ // SAFE by construction: if result.response can't yield the completed steps
2074
+ // (it rejects on a hard stream error), _appended stays 0 and we fall through
2075
+ // to surface the error — identical to prior behavior. No tool re-run: the
2076
+ // completed steps are grafted onto history, not replayed. Shares the
2077
+ // mid-loop recovery budget (midLoopStallRetryCount / maxStallRetries).
2078
+ {
2079
+ const { transient: _contTransient } = classifyStreamError(part.error);
2080
+ if (_contTransient && midLoopStallRetryCount < maxStallRetries && !signal.aborted && !stallTriggered) {
2081
+ let _appended = 0;
2082
+ try {
2083
+ const _resp = (await Promise.race([
2084
+ result.response,
2085
+ new Promise((_r, rej) => setTimeout(() => rej(new Error("response-timeout")), 3_000)),
2086
+ ]));
2087
+ const _gen = sanitizeModelMessages(scrubImagePayloadsInMessages(_resp.messages));
2088
+ for (const _m of _gen) {
2089
+ deps.messages.push(_m);
2090
+ _appended++;
2091
+ }
2092
+ }
2093
+ catch (respErr) {
2094
+ console.error(`[message-processor] error-part continuation: completed steps unavailable: ${respErr?.message}`);
2095
+ }
2096
+ if (_appended > 0) {
2097
+ midLoopStallRetryCount++;
2098
+ const _contBackoff = stallRepromptBackoffMs(midLoopStallRetryCount);
2099
+ const _errName = part.error instanceof Error ? part.error.name : "Error";
2100
+ const _errMsg = part.error instanceof Error ? part.error.message : String(part.error);
2101
+ try {
2102
+ const _ar = globalThis.__muonroiAgentRuntime;
2103
+ _ar?.emitEvent({
2104
+ t: "event",
2105
+ kind: "stream-retry",
2106
+ attempt: midLoopStallRetryCount,
2107
+ maxAttempts: maxStallRetries + 1,
2108
+ errorName: _errName,
2109
+ errorMessage: "transient error-part after tool step — resuming from completed steps",
2110
+ nextDelayMs: _contBackoff,
2111
+ });
2112
+ _ar?.emitEvent({
2113
+ t: "event",
2114
+ kind: "toast",
2115
+ level: "warning",
2116
+ text: `Transient error mid-task — resuming (attempt ${midLoopStallRetryCount}/${maxStallRetries})…`,
2117
+ });
2118
+ }
2119
+ catch {
2120
+ /* best-effort telemetry */
2121
+ }
2122
+ try {
2123
+ if (deps.session) {
2124
+ logInteraction(deps.session.id, "stream_retry", {
2125
+ data: {
2126
+ attempt: midLoopStallRetryCount,
2127
+ maxAttempts: maxStallRetries + 1,
2128
+ errorName: _errName,
2129
+ errorMessage: `${_errMsg.slice(0, 160)} — resumed from completed steps`,
2130
+ appendedMessages: _appended,
2131
+ nextDelayMs: _contBackoff,
2132
+ source: "error-part-continuation",
2133
+ },
2134
+ });
2135
+ }
2136
+ }
2137
+ catch (logErr) {
2138
+ console.error(`[message-processor] error-part continuation log failed: ${logErr?.message}`);
2139
+ }
2140
+ await new Promise((resolve) => setTimeout(resolve, _contBackoff));
2141
+ if (!signal.aborted) {
2142
+ continue streamAttempt;
2143
+ }
2144
+ }
2145
+ }
2146
+ }
2147
+ const authError = isAuthenticationError(part.error);
2148
+ const friendly = humanizeApiError(part.error, {
2149
+ modelId: runtime.modelId,
2150
+ providerId: runtime.modelInfo?.provider,
2151
+ });
2152
+ const forensics = summarizeApiErrorForLog(part.error);
2153
+ notifyObserver(observer?.onError, {
2154
+ message: friendly,
2155
+ timestamp: Date.now(),
2156
+ });
2157
+ // Interaction log: error + forensics envelope so opaque
2158
+ // provider 4xx ("parameter is invalid" / unknown 400s) leave
2159
+ // an actionable wire-level trace without needing a repro.
2160
+ try {
2161
+ if (deps.session) {
2162
+ logInteraction(deps.session.id, "error", {
2163
+ eventSubtype: authError ? "auth" : "api",
2164
+ data: {
2165
+ message: friendly.slice(0, 200),
2166
+ ...(forensics ? { forensics } : {}),
2167
+ },
2168
+ });
2169
+ }
2170
+ }
2171
+ catch (logErr) {
2172
+ console.error(`[message-processor] interaction-log error failed: ${logErr?.message}`);
2173
+ }
2174
+ yield {
2175
+ type: "error",
2176
+ content: friendly,
2177
+ isAuthError: authError,
2178
+ };
2179
+ break;
2180
+ }
2181
+ case "abort":
2182
+ // A stall-watchdog abort arrives here as an "abort" stream part
2183
+ // (the SDK surfaces it as a part, not a throw). Distinguish it
2184
+ // from a genuine user cancel — which is caught at the top of the
2185
+ // loop via `signal.aborted` — and surface it as a visible error
2186
+ // instead of a benign "[Cancelled]" so a hung provider no longer
2187
+ // looks like a silent freeze.
2188
+ if (stallTriggered) {
2189
+ // Time-to-first-byte stall (no real chunk this attempt): the
2190
+ // socket wedged before any output — re-issue the SAME request
2191
+ // rather than giving up. Bounded by maxStallRetries; never
2192
+ // fires once tools ran or text flowed (planStallReprompt gate).
2193
+ const _stallBackoff = planStallReprompt();
2194
+ if (_stallBackoff != null) {
2195
+ stall.dispose();
2196
+ await new Promise((r) => setTimeout(r, _stallBackoff));
2197
+ if (!signal.aborted) {
2198
+ stallTriggered = false;
2199
+ continue streamAttempt;
2200
+ }
2201
+ }
2202
+ // Mid-loop dead-socket CONTINUATION (distinct from the TTFB
2203
+ // re-prompt above): the watchdog fired AFTER earlier steps ran
2204
+ // (chunksThisAttempt > 0) but the in-flight step produced zero
2205
+ // bytes (chunksThisStep === 0) — a single wedged inter-step
2206
+ // socket (xai/grok-build-0.1 mid-investigation, session
2207
+ // 247a0cea2eac), not a down backend. The TTFB re-prompt can't
2208
+ // recover this (its zero-chunks gate refuses, since restarting
2209
+ // the whole request would re-run the tools that already ran).
2210
+ // Instead, append the COMPLETED steps' generated messages
2211
+ // (assistant tool-calls + their tool-results) to history and
2212
+ // re-issue streamText: with the results already in context, no
2213
+ // tool re-runs and no text duplicates, so this is safe even for
2214
+ // write/commit tools. Falls through to the rescue path if the
2215
+ // completed steps can't be recovered.
2216
+ if (shouldContinueAfterMidLoopStall({
2217
+ stallTriggered,
2218
+ chunksThisAttempt,
2219
+ chunksThisStep,
2220
+ retryCount: midLoopStallRetryCount,
2221
+ maxRetries: maxStallRetries,
2222
+ aborted: signal.aborted,
2223
+ })) {
2224
+ let _appended = 0;
2225
+ try {
2226
+ // result.response settles fast here (the stream was already
2227
+ // aborted via stall.signal). Race a short timeout so a
2228
+ // doubly-wedged provider can't re-hang the recovery itself.
2229
+ const _resp = (await Promise.race([
2230
+ result.response,
2231
+ new Promise((_r, rej) => setTimeout(() => rej(new Error("response-timeout")), 3_000)),
2232
+ ]));
2233
+ const _gen = sanitizeModelMessages(scrubImagePayloadsInMessages(_resp.messages));
2234
+ for (const _m of _gen) {
2235
+ deps.messages.push(_m);
2236
+ _appended++;
2237
+ }
2238
+ }
2239
+ catch (respErr) {
2240
+ console.error(`[message-processor] mid-loop stall continuation: completed steps unavailable: ${respErr?.message}`);
2241
+ }
2242
+ // A re-issue with zero preserved steps would restart from the
2243
+ // original prompt and re-run every tool — only continue when
2244
+ // the completed steps were actually grafted onto history.
2245
+ if (_appended > 0) {
2246
+ midLoopStallRetryCount++;
2247
+ const _midBackoff = stallRepromptBackoffMs(midLoopStallRetryCount);
2248
+ try {
2249
+ const _ar = globalThis.__muonroiAgentRuntime;
2250
+ _ar?.emitEvent({
2251
+ t: "event",
2252
+ kind: "stream-retry",
2253
+ attempt: midLoopStallRetryCount,
2254
+ maxAttempts: maxStallRetries + 1,
2255
+ errorName: "TimeoutError",
2256
+ errorMessage: "provider-stall (mid-loop) — resuming from completed steps",
2257
+ nextDelayMs: _midBackoff,
2258
+ });
2259
+ _ar?.emitEvent({
2260
+ t: "event",
2261
+ kind: "toast",
2262
+ level: "warning",
2263
+ text: `Model stalled mid-task — resuming (attempt ${midLoopStallRetryCount}/${maxStallRetries})…`,
2264
+ });
2265
+ }
2266
+ catch (emitErr) {
2267
+ console.error(`[message-processor] mid-loop continuation telemetry failed: ${emitErr?.message}`);
2268
+ }
2269
+ try {
2270
+ if (deps.session) {
2271
+ logInteraction(deps.session.id, "stream_retry", {
2272
+ data: {
2273
+ attempt: midLoopStallRetryCount,
2274
+ maxAttempts: maxStallRetries + 1,
2275
+ errorName: "provider-stall-midloop",
2276
+ errorMessage: "no byte on inter-step request — resumed from completed steps",
2277
+ appendedMessages: _appended,
2278
+ nextDelayMs: _midBackoff,
2279
+ },
2280
+ });
2281
+ }
2282
+ }
2283
+ catch (logErr) {
2284
+ console.error(`[message-processor] mid-loop continuation log failed: ${logErr?.message}`);
2285
+ }
2286
+ stall.dispose();
2287
+ await new Promise((r) => setTimeout(r, _midBackoff));
2288
+ if (!signal.aborted) {
2289
+ stallTriggered = false;
2290
+ continue streamAttempt;
2291
+ }
2292
+ }
2293
+ }
2294
+ stall.dispose();
2295
+ // A response tool already produced the terminal structured
2296
+ // answer (buffered from its call args) before the provider
2297
+ // stalled on a LATER step. Surface it and finish cleanly —
2298
+ // never bury the model's actual answer behind a "not
2299
+ // responding" error. Root cause of the "respond_* indicator
2300
+ // shows but no answer block renders" report: this stall-abort
2301
+ // path returned before the post-loop structured_response yield,
2302
+ // dropping the captured answer. A response tool is terminal,
2303
+ // so there is nothing to rescue — just emit what we have.
2304
+ if (_pendingStructuredResponse) {
2305
+ if (!streamOk) {
2306
+ try {
2307
+ const _d = _pendingStructuredResponse.data;
2308
+ const _ans = typeof _d.response === "string" ? _d.response : JSON.stringify(_pendingStructuredResponse.data);
2309
+ deps.appendCompletedTurn(userModelMessage, [
2310
+ { role: "assistant", content: _ans },
2311
+ ]);
2312
+ streamOk = true;
2313
+ }
2314
+ catch (persistErr) {
2315
+ console.error(`[message-processor] stall+response-tool persist failed: ${persistErr?.message}`);
2316
+ }
2317
+ }
2318
+ yield {
2319
+ type: "structured_response",
2320
+ structuredResponse: _pendingStructuredResponse,
2321
+ };
2322
+ yield { type: "done" };
2323
+ return;
2324
+ }
2325
+ // Best-effort answer rescue: a turn that already ran tools but
2326
+ // stalled before the final synthesis would otherwise return
2327
+ // ONLY "Model not responding", discarding all that work (live
2328
+ // obs 2026-06-04, deepseek session 734e65cffdf6: 67 tool calls
2329
+ // → user got nothing). Make ONE guarded forced-finalize call
2330
+ // over the gathered tool outputs. forcedFinalize has its own
2331
+ // stall timeout, so a still-dead provider just falls through.
2332
+ let _rescued = null;
2333
+ if (turnToolResults.length > 0) {
2334
+ try {
2335
+ const _userText = typeof userModelMessage?.content === "string"
2336
+ ? userModelMessage.content
2337
+ : JSON.stringify(userModelMessage?.content ?? "");
2338
+ _rescued = await attemptStallRescue({
2339
+ baseMessages: _topMessagesForCall,
2340
+ userText: _userText.slice(0, 4000),
2341
+ toolResults: turnToolResults,
2342
+ system: typeof systemForModel === "string" ? systemForModel : undefined,
2343
+ finalize: (a) => forcedFinalize({ model: runtime.model, messages: a.messages, system: a.system }),
2344
+ });
2345
+ }
2346
+ catch {
2347
+ _rescued = null;
2348
+ }
2349
+ try {
2350
+ if (deps.session) {
2351
+ logInteraction(deps.session.id, "stall_rescue", {
2352
+ data: {
2353
+ outcome: _rescued ? "rescued" : "no_text",
2354
+ toolResultCount: turnToolResults.length,
2355
+ chars: _rescued?.length ?? 0,
2356
+ },
2357
+ });
2358
+ }
2359
+ }
2360
+ catch {
2361
+ /* telemetry is best-effort */
2362
+ }
2363
+ }
2364
+ if (_rescued) {
2365
+ assistantText += (assistantText ? "\n\n" : "") + _rescued;
2366
+ yield { type: "content", content: _rescued };
2367
+ }
2368
+ // Persist a record of the interrupted turn BEFORE returning so
2369
+ // the next turn is not amnesiac. Previously this returned with
2370
+ // nothing persisted → the next turn saw "no previous turn" and
2371
+ // redid the work, orphaning any edits the stalled turn applied
2372
+ // (live obs 2026-06-04, deepseek-v4-flash). When rescued, the
2373
+ // note now carries the synthesized answer too (assistantText).
2374
+ // Best-effort: never let persistence failure block surfacing.
2375
+ if (!streamOk) {
2376
+ try {
2377
+ const _stallNote = buildInterruptedTurnNote(assistantText, activeToolCalls.map((c) => c.function.name));
2378
+ deps.appendCompletedTurn(userModelMessage, [
2379
+ { role: "assistant", content: _stallNote },
2380
+ ]);
2381
+ streamOk = true;
2382
+ }
2383
+ catch {
2384
+ /* best-effort — surface the stall regardless */
2385
+ }
2386
+ }
2387
+ if (_rescued) {
2388
+ // Recovered a best-effort answer from partial data — surface
2389
+ // a soft notice instead of the scary "not responding" error.
2390
+ yield {
2391
+ type: "content",
2392
+ content: "\n\n[Note: the model connection stalled; the answer above is a best-effort synthesis " +
2393
+ "from the tool results gathered before the stall and may be incomplete.]",
2394
+ };
2395
+ yield { type: "done" };
2396
+ return;
2397
+ }
2398
+ notifyObserver(observer?.onError, { message: STALL_ERROR_MESSAGE, timestamp: Date.now() });
2399
+ yield { type: "error", content: STALL_ERROR_MESSAGE, isAuthError: false };
2400
+ yield { type: "done" };
2401
+ return;
2402
+ }
2403
+ yield { type: "content", content: "\n\n[Cancelled]" };
2404
+ break;
2405
+ }
2406
+ }
2407
+ stall.dispose(); // stream drained normally — stop the stall watchdog
2408
+ // ─── SAMR Phase 1 → Phase 2 transition ─────────────────────────
2409
+ // Phase 1 (premium model) produced tool calls but the SDK stopped
2410
+ // before executing them (stopWhen: stepCountIs(1)). Append the
2411
+ // assistant message to deps.messages and restart the loop with
2412
+ // the fast execution model. Phase 2's streamText call will see
2413
+ // the pending tool calls and execute them automatically.
2414
+ //
2415
+ // EXCEPT when Phase 1 emitted a response tool: a `respond_*` call IS
2416
+ // the terminal structured answer (identity execute), not work to hand
2417
+ // to Phase 2. Transitioning here would (a) skip the structured_response
2418
+ // yield below — the answer never reaches the TUI — and (b) append a
2419
+ // dangling assistant tool-call WITHOUT its tool-result (only assistant
2420
+ // msgs are pushed), corrupting Phase 2's history. Fall through instead
2421
+ // so the buffered answer is yielded + persisted on this turn.
2422
+ if (stepRouterPhase === "phase1" && phase1HadToolCalls && !responseToolCalled) {
2423
+ try {
2424
+ const phase1Response = await result.response;
2425
+ // Append only new messages (assistant message with tool calls)
2426
+ const newMsgs = phase1Response.messages.slice(deps.messages.length);
2427
+ for (const msg of newMsgs) {
2428
+ if (msg.role === "assistant") {
2429
+ deps.messages.push(msg);
2430
+ }
2431
+ }
2432
+ }
2433
+ catch {
2434
+ // If response extraction fails, fall through to normal completion
2435
+ }
2436
+ stepRouterPhase = "phase2";
2437
+ continue; // Re-enter while loop with Phase 2 (fast) model
2438
+ }
2439
+ // Surface the single most-complete response-tool answer buffered
2440
+ // during the stream (see _pendingStructuredResponse). Yielding here —
2441
+ // once, after the stream drained and after the Phase 1 transition —
2442
+ // collapses any duplicate response-tool emissions in the turn into a
2443
+ // single structured_response block for the UI.
2444
+ if (_pendingStructuredResponse) {
2445
+ // Schema-mismatch normalization: when the model calls a typed
2446
+ // respond_<task> (e.g. respond_analyze) but sends a payload shaped
2447
+ // like respond_general ({ response: "..." } without schema-specific
2448
+ // fields like `findings`), the TUI typed renderer renders an empty
2449
+ // box. Normalize taskType to 'general' so the plain-markdown
2450
+ // renderer is used. See normalizeStructuredResponseTaskType() docs.
2451
+ // Session 48d22fe436f6: respond_analyze({ response: "..." }) →
2452
+ // analyze renderer showed empty findings list → answer swallowed.
2453
+ const _normalizedType = normalizeStructuredResponseTaskType(_pendingStructuredResponse.taskType, _pendingStructuredResponse.data);
2454
+ if (_normalizedType !== _pendingStructuredResponse.taskType) {
2455
+ _pendingStructuredResponse = { taskType: _normalizedType, data: _pendingStructuredResponse.data };
2456
+ }
2457
+ yield {
2458
+ type: "structured_response",
2459
+ structuredResponse: _pendingStructuredResponse,
2460
+ };
2461
+ // Ensure DB has the answer as assistant row even if later _finalMessages
2462
+ // or buildChatEntries path drops the tool-call-only assistant (sanitize,
2463
+ // response-tool terminal, vision bridge, sub-delegate). Prevents TUI
2464
+ // "flashed then vanished" on finalizeActiveTurn + getChatEntries().
2465
+ if (deps.session) {
2466
+ try {
2467
+ const _d = _pendingStructuredResponse.data;
2468
+ const _ans = typeof _d.response === "string" ? _d.response : JSON.stringify(_pendingStructuredResponse.data);
2469
+ deps.appendCompletedTurn(userModelMessage, [{ role: "assistant", content: _ans }]);
2470
+ }
2471
+ catch { }
2472
+ }
2473
+ if (_responseToolEmitCount > 1 && deps.session) {
2474
+ try {
2475
+ logInteraction(deps.session.id, "f6_synthesis", {
2476
+ eventSubtype: "response_tool_deduped",
2477
+ data: { emitted: _responseToolEmitCount, keptChars: _pendingStructuredResponseLen },
2478
+ });
2479
+ }
2480
+ catch {
2481
+ /* telemetry best-effort */
2482
+ }
2483
+ }
2484
+ }
2485
+ if (signal.aborted) {
2486
+ if (_pendingStructuredResponse) {
2487
+ try {
2488
+ const _d = _pendingStructuredResponse.data;
2489
+ const _ans = typeof _d.response === "string" ? _d.response : JSON.stringify(_pendingStructuredResponse.data);
2490
+ deps.appendCompletedTurn(userModelMessage, [{ role: "assistant", content: _ans }]);
2491
+ }
2492
+ catch { }
2493
+ }
2494
+ deps.discardAbortedTurn(userModelMessage);
2495
+ yield { type: "done" };
2496
+ return;
2497
+ }
2498
+ try {
2499
+ const response = await result.response;
2500
+ if (!signal.aborted) {
2501
+ // Scrub oversized base64 image payloads from tool-result parts
2502
+ // BEFORE persisting. The vision bridge above only modified the
2503
+ // transient `tr` shown to the user — `response.messages` from
2504
+ // the AI SDK still carries the full base64 (e.g. Playwright
2505
+ // screenshot, ~1.5MB). Persisting that lets it accumulate and
2506
+ // overflow the model's context on subsequent turns.
2507
+ const scrubbed = rewriteSafetyApprovedToolResults(scrubImagePayloadsInMessages(response.messages));
2508
+ // Phase 5 F6 — synthesis step when stream ended without a final
2509
+ // text response. Cheap models (DeepSeek V4 Flash) frequently
2510
+ // emit only tool-calls in their last step and stop, leaving the
2511
+ // user staring at "Here's the summary:..." truncation that
2512
+ // required a manual "tiếp tục" turn-2 to coax out. Detect that
2513
+ // shape and inject ONE forcedFinalize call (same path as 4B
2514
+ // ceiling-hit) so the answer arrives on turn 1.
2515
+ //
2516
+ // Skip when 4B ceiling already triggered its own forcedFinalize
2517
+ // below — running both would double-bill and duplicate text.
2518
+ let _f6SynthesisText = null;
2519
+ const _f6LastMsg = scrubbed[scrubbed.length - 1];
2520
+ const _f6LastRole = _f6LastMsg?.role ?? "none";
2521
+ let _f6Outcome = "skip_ceiling";
2522
+ let _f6Elapsed = 0;
2523
+ let _f6ChunkChars = 0;
2524
+ let _f6Error = null;
2525
+ // A response tool already produced the final structured answer —
2526
+ // F6 synthesis would duplicate it as prose. Skip entirely. With
2527
+ // the stopWhen terminal-halt above, the turn now ends right after
2528
+ // the response tool (last scrubbed message is the response
2529
+ // tool-result, role "tool"), which would otherwise trip the
2530
+ // _needsSynthesis "ended on a tool" branch and double-respond.
2531
+ if (responseToolCalled) {
2532
+ _f6Outcome = "skip_response_tool";
2533
+ }
2534
+ else if (!_ceilingHit) {
2535
+ const _needsSynthesis = (() => {
2536
+ if (!_f6LastMsg)
2537
+ return false;
2538
+ if (_f6LastMsg.role === "tool")
2539
+ return true;
2540
+ if (_f6LastMsg.role !== "assistant")
2541
+ return false;
2542
+ const _c = _f6LastMsg.content;
2543
+ if (typeof _c === "string")
2544
+ return !_c.trim();
2545
+ if (!Array.isArray(_c))
2546
+ return false;
2547
+ return !_c.some((p) => p && p.type === "text" && typeof p.text === "string" && p.text.trim().length > 0);
2548
+ })();
2549
+ if (!_needsSynthesis) {
2550
+ _f6Outcome = "skip_has_text";
2551
+ }
2552
+ else {
2553
+ const _f6Start = Date.now();
2554
+ try {
2555
+ const _ff = await forcedFinalize({
2556
+ model: runtime.model,
2557
+ messages: _topMessagesForCall,
2558
+ system: typeof systemForModel === "string" ? systemForModel : undefined,
2559
+ });
2560
+ _f6Elapsed = Date.now() - _f6Start;
2561
+ _f6ChunkChars = (_ff.text ?? "").length;
2562
+ if (_ff.text.trim()) {
2563
+ _f6SynthesisText = _ff.text;
2564
+ assistantText += _ff.text;
2565
+ yield { type: "content", content: _ff.text };
2566
+ _f6Outcome = "fired_text";
2567
+ }
2568
+ else {
2569
+ _f6Outcome = "fired_empty";
2570
+ }
2571
+ }
2572
+ catch (_err) {
2573
+ _f6Elapsed = Date.now() - _f6Start;
2574
+ _f6Outcome = "error";
2575
+ _f6Error = _err?.message?.slice(0, 200) ?? String(_err).slice(0, 200);
2576
+ }
2577
+ }
2578
+ }
2579
+ try {
2580
+ if (deps.session) {
2581
+ logInteraction(deps.session.id, "f6_synthesis", {
2582
+ data: {
2583
+ outcome: _f6Outcome,
2584
+ lastMsgRole: _f6LastRole,
2585
+ elapsedMs: _f6Elapsed,
2586
+ chars: _f6ChunkChars,
2587
+ error: _f6Error,
2588
+ ceilingHit: _ceilingHit,
2589
+ scrubbedLen: scrubbed.length,
2590
+ },
2591
+ });
2592
+ }
2593
+ }
2594
+ catch {
2595
+ /* telemetry is best-effort */
2596
+ }
2597
+ // Summary-phase grounding check (Agent Operating Contract, runtime
2598
+ // half). Soft-flag counts / file:line refs in the final synthesis
2599
+ // that don't appear in this turn's tool outputs — possible
2600
+ // hallucination. Never blocks: emits a grounding-flag event + a
2601
+ // warn toast + an inline advisory footnote. Only runs when the
2602
+ // turn actually produced tool output (a corpus to ground against)
2603
+ // and is not chitchat. See grounding-check.ts.
2604
+ if (process.env.MUONROI_DISABLE_GROUNDING_CHECK !== "1" && !isChitchat && assistantText.trim().length > 0) {
2605
+ try {
2606
+ const _gParts = [];
2607
+ let _gHadTool = false;
2608
+ for (const _gm of scrubbed) {
2609
+ if (!_gm || _gm.role === "assistant")
2610
+ continue;
2611
+ if (_gm.role === "tool")
2612
+ _gHadTool = true;
2613
+ const _gc = _gm.content;
2614
+ _gParts.push(typeof _gc === "string" ? _gc : JSON.stringify(_gc));
2615
+ }
2616
+ if (_gHadTool) {
2617
+ const _claims = findUnverifiedClaims(assistantText, _gParts.join("\n"));
2618
+ if (_claims.length > 0) {
2619
+ const _footnote = buildGroundingFootnote(_claims);
2620
+ assistantText += _footnote;
2621
+ yield { type: "content", content: _footnote };
2622
+ const _gar = globalThis.__muonroiAgentRuntime;
2623
+ const _claimTexts = _claims.map((c) => c.text);
2624
+ _gar?.emitEvent({
2625
+ t: "event",
2626
+ kind: "grounding-flag",
2627
+ claims: _claimTexts,
2628
+ count: _claims.length,
2629
+ ts: Date.now(),
2630
+ });
2631
+ _gar?.emitEvent({
2632
+ t: "event",
2633
+ kind: "toast",
2634
+ level: "warn",
2635
+ text: `grounding: ${_claims.length} unverified claim(s) — ${_claimTexts.join(", ")}`,
2636
+ });
2637
+ if (deps.session) {
2638
+ try {
2639
+ logInteraction(deps.session.id, "grounding_flag", {
2640
+ data: { claims: _claimTexts, count: _claims.length },
2641
+ });
2642
+ }
2643
+ catch {
2644
+ /* telemetry is best-effort */
2645
+ }
2646
+ }
2647
+ }
2648
+ }
2649
+ }
2650
+ catch {
2651
+ /* grounding check is best-effort — never break finalize */
2652
+ }
2653
+ }
2654
+ const _finalMessages = sanitizeModelMessages(scrubbed);
2655
+ if (_f6SynthesisText !== null) {
2656
+ _finalMessages.push({
2657
+ role: "assistant",
2658
+ content: _f6SynthesisText,
2659
+ });
2660
+ }
2661
+ deps.appendCompletedTurn(userModelMessage, _finalMessages);
2662
+ streamOk = true;
2663
+ }
2664
+ }
2665
+ catch (responseError) {
2666
+ if (!attemptedOverflowRecovery && !assistantText.trim() && modelInfo && isContextLimitError(responseError)) {
2667
+ attemptedOverflowRecovery = true;
2668
+ continue;
2669
+ }
2670
+ }
2671
+ if (signal.aborted) {
2672
+ deps.discardAbortedTurn(userModelMessage);
2673
+ yield { type: "done" };
2674
+ return;
2675
+ }
2676
+ if (patternLoopForceHalt) {
2677
+ patternLoopForceHalt = false;
2678
+ try {
2679
+ const _ar = globalThis.__muonroiAgentRuntime;
2680
+ _ar?.emitEvent({
2681
+ t: "event",
2682
+ kind: "toast",
2683
+ level: "info",
2684
+ text: "vòng lặp tool được phát hiện — đang tự động tiêm prompt nhắc nhở để agent tự sửa đổi",
2685
+ });
2686
+ }
2687
+ catch {
2688
+ /* best-effort */
2689
+ }
2690
+ stall.dispose();
2691
+ await closeMcp?.().catch(() => { });
2692
+ continue;
2693
+ }
2694
+ // Phase 5 Fix 5 — the Phase 4 4B forced-finalize-on-ceiling-hit
2695
+ // block lived here. With the matrix ceiling no longer halting the
2696
+ // stream (it's pure telemetry now), _ceilingHit can never be true
2697
+ // and this branch is dead. F6 synthesis above already covers the
2698
+ // "stream ended with no final text" case for both natural model
2699
+ // termination AND maxToolRounds halt. Keeping the comment as a
2700
+ // breadcrumb for future archaeology.
2701
+ if (!streamOk && assistantText.trim()) {
2702
+ deps.appendCompletedTurn(userModelMessage, [{ role: "assistant", content: stripDsmlMarkup(assistantText) }]);
2703
+ }
2704
+ // Fallback: model responded in text despite tool_choice=required
2705
+ // Attempt JSON extraction from assistant text → yield as structured_response
2706
+ if (_hasResponseTools && !responseToolCalled && pilCtx.taskType && assistantText.trim()) {
2707
+ try {
2708
+ const jsonMatch = assistantText.match(/\{[\s\S]*\}/);
2709
+ if (jsonMatch) {
2710
+ const parsed = JSON.parse(jsonMatch[0]);
2711
+ if (Object.keys(parsed).length > 0) {
2712
+ responseToolCalled = true;
2713
+ yield {
2714
+ type: "structured_response",
2715
+ structuredResponse: {
2716
+ taskType: pilCtx.taskType,
2717
+ data: parsed,
2718
+ },
2719
+ };
2720
+ }
2721
+ }
2722
+ }
2723
+ catch {
2724
+ // JSON parse failed — leave as text-fallback
2725
+ }
2726
+ }
2727
+ // Track PIL output mode for /optimize metrics
2728
+ {
2729
+ const { setLastOutputMode } = await import("../pil/store.js");
2730
+ if (!_hasResponseTools)
2731
+ setLastOutputMode("conversational");
2732
+ else if (responseToolCalled)
2733
+ setLastOutputMode("structured");
2734
+ else
2735
+ setLastOutputMode("text-fallback");
2736
+ }
2737
+ // ROUTE-11: Fire routeFeedback after turn completes (success path).
2738
+ // Must come AFTER posttool calls (posttool fires during tool-result processing above).
2739
+ // Fire-and-forget — no await. Skipped when taskHash is null (bridge absent).
2740
+ {
2741
+ const turnDuration = Date.now() - turnStartMs;
2742
+ if (taskHash) {
2743
+ const tier = taskTypeToTier(pilCtx.taskType);
2744
+ void routeFeedback(taskHash, tier, runtime.modelId, "success", // Phase 6: all normal completions = 'success'
2745
+ 0, // retryCount: 0 for first attempt
2746
+ turnDuration);
2747
+ }
2748
+ // HTTP path: also report via router store taskHash (covers warm/cold EE routes)
2749
+ const storeHash = routerStore.getState().taskHash;
2750
+ if (storeHash) {
2751
+ reportRouteOutcome(storeHash, "success", turnDuration);
2752
+ }
2753
+ }
2754
+ // Detect a tool call emitted as plain TEXT (wrong dialect) in the final
2755
+ // assistant answer — the action never ran, so the turn would otherwise
2756
+ // end silently with broken/half-done work (live: deepseek session
2757
+ // 905d564dbde4 emitted `<read_file>` as text after a destructive edit).
2758
+ // Detect regardless of how many real tool calls already succeeded: the
2759
+ // common failure is the model doing a few real tools, then emitting the
2760
+ // NEXT call as text and stopping (live deepseek-native, full-fix CLI: 2
2761
+ // real read_file calls, then `<read_file><path>` as text → silent stop).
2762
+ // An earlier `activeToolCalls.length === 0` guard suppressed exactly
2763
+ // that case. Detector precision (structural invocation shape, not a bare
2764
+ // mention) guards against false-firing on a normal final answer.
2765
+ const _textToolCall = detectTextEmittedToolCall(assistantText);
2766
+ // Interaction log: agent response complete
2767
+ try {
2768
+ if (deps.session) {
2769
+ const sb = statusBarStore.getState();
2770
+ const turnDurationMs = Date.now() - turnStartMs;
2771
+ // BUG-A telemetry — detect raw DeepSeek native tool-call markup
2772
+ // leaking into assistant text. Signature is `<||DSML||` (the
2773
+ // fullwidth vertical bars are NOT pipes, they're U+FF5C).
2774
+ const _dsmlSig = "||DSML||";
2775
+ const _dsmlMatches = assistantText.includes(_dsmlSig);
2776
+ const _codeBlockBash = /```\s*bash\b/i.test(assistantText);
2777
+ logInteraction(deps.session.id, "agent_response", {
2778
+ model: turnModelId,
2779
+ inputTokens: sb.in_tokens,
2780
+ outputTokens: sb.out_tokens,
2781
+ durationMs: turnDurationMs,
2782
+ data: {
2783
+ textLength: assistantText.length,
2784
+ toolCallCount: activeToolCalls.length,
2785
+ compacted: deps.getCompactedThisTurn(),
2786
+ dsmlLeak: _dsmlMatches,
2787
+ bashCodeBlock: _codeBlockBash,
2788
+ textToolXmlLeak: _textToolCall.detected,
2789
+ textToolXmlTool: _textToolCall.tool,
2790
+ },
2791
+ });
2792
+ }
2793
+ }
2794
+ catch {
2795
+ /* fail-open */
2796
+ }
2797
+ // F3b — surface hard-cap stop (absolute ceiling, cannot be bumped).
2798
+ if (_hardCapHit) {
2799
+ yield {
2800
+ type: "content",
2801
+ content: `\n\n[Hard limit reached: Agent hit the absolute step ceiling of ${deps.hardMaxToolRounds} steps. ` +
2802
+ `This turn cannot continue — too many LLM round-trips. ` +
2803
+ `Start a new turn or break your task into smaller steps.]\n`,
2804
+ };
2805
+ }
2806
+ // Surface the round-cap stop so the user knows why the agent halted
2807
+ // (session 7dcf8fd7d6a4 hit stepCountIs(100) silently, looked like a
2808
+ // crash). AI SDK reports finishReason='tool-calls' when the step cap
2809
+ // fires with tool calls still pending — distinct from 'stop' (model
2810
+ // chose to end). We only warn when stepNumber ≥ cap so a model that
2811
+ // legitimately terminates mid-tool-call (rare) doesn't get a false
2812
+ // warning.
2813
+ if (_lastFinishReason === "tool-calls" && stepNumber >= deps.maxToolRounds - 1) {
2814
+ yield {
2815
+ type: "content",
2816
+ content: `\n\n[Stopped: Agent paused execution to prevent runaway loops (hit step cap of ${deps.maxToolRounds}). ` +
2817
+ `If you want to continue the current task, simply reply with "continue" or "tiếp tục". ` +
2818
+ `For long tasks, we strongly recommend running the \`/compact\` command first to free up context memory before continuing.]\n`,
2819
+ };
2820
+ }
2821
+ // Tool-call-as-text leak: the model wrote a tool invocation as plain
2822
+ // text (wrong dialect) and made NO real tool call, so the action never
2823
+ // ran. Auto-recover ONCE: append a corrective message and re-run the
2824
+ // turn so the model can invoke the tool properly. The just-finished
2825
+ // (text-only) turn is already persisted above — the model sees its own
2826
+ // mistake plus the correction. Mirrors the proven phase-switch re-entry
2827
+ // (it also pushes to deps.messages then `continue`s); bounded by
2828
+ // MAX_TEXT_TOOL_RESTEER so a persistently-degrading model can't loop.
2829
+ if (_textToolCall.detected && streamOk && textToolReSteerCount < MAX_TEXT_TOOL_RESTEER) {
2830
+ textToolReSteerCount++;
2831
+ // Recover the model's INTENT from the leaked markup (DeepSeek-native
2832
+ // DSML carries the tool + args) so the corrective restates the exact
2833
+ // call — far more effective than a generic "use the tool" nudge.
2834
+ const _parsedCalls = parseDsmlToolCalls(assistantText);
2835
+ const _intent = _parsedCalls.length > 0
2836
+ ? ` You appear to have intended: ${_parsedCalls
2837
+ .map((c) => `${c.name}(${Object.entries(c.args)
2838
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
2839
+ .join(", ")})`)
2840
+ .join("; ")}. Make those exact call(s) via the tool interface now.`
2841
+ : "";
2842
+ deps.messages.push({
2843
+ role: "user",
2844
+ content: `Your previous reply wrote a \`${_textToolCall.tool}\` tool call as XML/text. That is NOT how tools are invoked here — ` +
2845
+ "writing tool calls as text does nothing, so the action did not run. " +
2846
+ "Use the actual tool-calling interface (function/tool calls) to perform the action now. " +
2847
+ "Do NOT output XML tags like <read_file>, <write_to_file>, <execute_command>, or <tool_call> (or DSML markup) as text." +
2848
+ _intent,
2849
+ });
2850
+ if (deps.session) {
2851
+ try {
2852
+ logInteraction(deps.session.id, "text_tool_resteer", {
2853
+ model: turnModelId,
2854
+ data: { tool: _textToolCall.tool, attempt: textToolReSteerCount },
2855
+ });
2856
+ }
2857
+ catch {
2858
+ /* telemetry best-effort */
2859
+ }
2860
+ }
2861
+ {
2862
+ const _gar = globalThis.__muonroiAgentRuntime;
2863
+ _gar?.emitEvent({
2864
+ t: "event",
2865
+ kind: "toast",
2866
+ level: "info",
2867
+ text: `model wrote a ${_textToolCall.tool} tool call as text — re-steering to use the tool interface`,
2868
+ });
2869
+ }
2870
+ await closeMcp?.().catch(() => { });
2871
+ continue;
2872
+ }
2873
+ // Re-steer exhausted: inject DSML intent as a user message so the
2874
+ // model re-issues the same calls via the real tool interface.
2875
+ if (_textToolCall.detected) {
2876
+ const _parsedDsml = assistantText ? parseDsmlToolCalls(assistantText) : [];
2877
+ if (_parsedDsml.length > 0 && textToolReSteerCount < MAX_TEXT_TOOL_RESTEER) {
2878
+ textToolReSteerCount++;
2879
+ const _intentStr = _parsedDsml
2880
+ .map((c) => `${c.name}(${Object.entries(c.args)
2881
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
2882
+ .join(", ")})`)
2883
+ .join("; ");
2884
+ deps.messages.push({
2885
+ role: "user",
2886
+ content: `[DSML intent: ${_intentStr} — re-issue via the tool interface now.]`,
2887
+ });
2888
+ await closeMcp?.().catch(() => { });
2889
+ continue;
2890
+ }
2891
+ // No parseable DSML — surface the dead-end warning.
2892
+ yield {
2893
+ type: "content",
2894
+ content: `\n\n[⚠ The model wrote a \`${_textToolCall.tool}\` tool call as TEXT instead of invoking the tool, ` +
2895
+ "so that action did NOT run and this turn made no real progress. " +
2896
+ "Re-run the request (optionally with a more capable model) — the tool interface was not used.]\n",
2897
+ };
2898
+ const _gar2 = globalThis.__muonroiAgentRuntime;
2899
+ _gar2?.emitEvent({
2900
+ t: "event",
2901
+ kind: "toast",
2902
+ level: "warn",
2903
+ text: `model emitted a ${_textToolCall.tool} tool call as text — action not executed`,
2904
+ });
2905
+ }
2906
+ const stopInput = {
2907
+ hook_event_name: "Stop",
2908
+ session_id: deps.session?.id,
2909
+ cwd: deps.bash.getCwd(),
2910
+ };
2911
+ await deps.fireHook(stopInput, signal).catch(() => { });
2912
+ // Debug trace: emit pipeline summary
2913
+ if (_debugOn) {
2914
+ const sb = statusBarStore.getState();
2915
+ const defaultInfo = getModelInfo(deps.modelId);
2916
+ const usedInfo = getModelInfo(turnModelId);
2917
+ const routerSaved = defaultInfo && usedInfo && defaultInfo.outputPrice > usedInfo.outputPrice
2918
+ ? (sb.out_tokens * (defaultInfo.outputPrice - usedInfo.outputPrice)) / 1_000_000
2919
+ : 0;
2920
+ const cacheSaved = sb.cache_read_tokens > 0 && defaultInfo
2921
+ ? (sb.cache_read_tokens *
2922
+ (defaultInfo.inputPrice - (defaultInfo.cachedInputPrice ?? defaultInfo.inputPrice * 0.1))) /
2923
+ 1_000_000
2924
+ : 0;
2925
+ const trace = {
2926
+ turn_id: _debugTurnId,
2927
+ timestamp: turnStartMs,
2928
+ raw_prompt: userMessage,
2929
+ steps: _debugSteps,
2930
+ model_requested: deps.modelId,
2931
+ model_used: turnModelId,
2932
+ routed: turnModelId !== deps.modelId,
2933
+ input_tokens: sb.in_tokens,
2934
+ output_tokens: sb.out_tokens,
2935
+ cache_read_tokens: sb.cache_read_tokens,
2936
+ cost_usd: sb.session_usd,
2937
+ estimated_savings: {
2938
+ pil_tokens_saved: _pilEnrichmentDeltaSnapshot > 0 ? _pilEnrichmentDeltaSnapshot : 0,
2939
+ cache_tokens_saved: sb.cache_read_tokens,
2940
+ router_cost_saved_usd: routerSaved,
2941
+ total_tokens_saved: (_pilEnrichmentDeltaSnapshot > 0 ? _pilEnrichmentDeltaSnapshot : 0) + sb.cache_read_tokens,
2942
+ total_cost_saved_usd: routerSaved + cacheSaved,
2943
+ },
2944
+ };
2945
+ recordTurnTrace(trace);
2946
+ const traceLines = [];
2947
+ traceLines.push("\n┌─ Pipeline Trace ─────────────────────────");
2948
+ for (const step of _debugSteps) {
2949
+ const dur = step.duration_ms < 1 ? "<1ms" : `${step.duration_ms}ms`;
2950
+ const saved = step.tokens_saved ? ` (saved ~${step.tokens_saved} tok)` : "";
2951
+ traceLines.push(`│ ▸ ${step.name} [${dur}]${saved}`);
2952
+ traceLines.push(`│ ${step.output_summary}`);
2953
+ }
2954
+ const routeLabel = trace.routed ? `${trace.model_requested}→${trace.model_used}` : trace.model_used;
2955
+ traceLines.push(`│ Model: ${routeLabel} | ↑${sb.in_tokens} ↓${sb.out_tokens} | $${sb.session_usd.toFixed(4)}`);
2956
+ if (trace.estimated_savings.total_cost_saved_usd > 0) {
2957
+ traceLines.push(`│ Savings: ~${trace.estimated_savings.total_tokens_saved} tok, ~$${trace.estimated_savings.total_cost_saved_usd.toFixed(4)}`);
2958
+ }
2959
+ traceLines.push("└──────────────────────────────────────────\n");
2960
+ yield { type: "content", content: traceLines.join("\n") };
2961
+ }
2962
+ // Yield done FIRST so the UI releases isProcessing immediately.
2963
+ // postTurnCompact is a background optimization that must not block
2964
+ // the composer — without this, DeepSeek's 5-30s compaction call
2965
+ // keeps the input in loading state and queued messages hang.
2966
+ //
2967
+ // CRITICAL: postTurnCompact MUST be fire-and-forget (void, NOT await).
2968
+ // Even though yield-done happens first, the for-await loop in
2969
+ // use-app-logic.tsx does NOT exit until the generator returns.
2970
+ // An awaited postTurnCompact blocks the generator's return, which
2971
+ // blocks the for-await loop, which blocks finalizeActiveTurn.
2972
+ if (modelInfo?.contextWindow) {
2973
+ void deps.postTurnCompact(provider, system, modelInfo.contextWindow, signal).catch(() => { });
2974
+ }
2975
+ yield { type: "done" };
2976
+ return;
2977
+ }
2978
+ catch (err) {
2979
+ if (signal.aborted) {
2980
+ deps.discardAbortedTurn(userModelMessage);
2981
+ // ROUTE-11: Fire routeFeedback for cancelled turns (abort path).
2982
+ // Fire-and-forget — no await. Skipped when taskHash is null.
2983
+ {
2984
+ const turnDuration = Date.now() - turnStartMs;
2985
+ if (taskHash) {
2986
+ const tier = taskTypeToTier(pilCtx.taskType);
2987
+ void routeFeedback(taskHash, tier, runtime.modelId, "cancelled", 0, turnDuration);
2988
+ }
2989
+ const storeHash = routerStore.getState().taskHash;
2990
+ if (storeHash) {
2991
+ reportRouteOutcome(storeHash, "cancelled", turnDuration);
2992
+ }
2993
+ }
2994
+ yield { type: "content", content: "\n\n[Cancelled]" };
2995
+ yield { type: "done" };
2996
+ return;
2997
+ }
2998
+ if (!attemptedOverflowRecovery && !assistantText.trim() && modelInfo && isContextLimitError(err)) {
2999
+ attemptedOverflowRecovery = true;
3000
+ continue;
3001
+ }
3002
+ // Stall surfaced as a throw (rather than an "abort" stream part):
3003
+ // apply the SAME time-to-first-byte re-prompt as the abort-part path.
3004
+ // The watchdog already fired (stallTriggered) so its timer is spent —
3005
+ // no dispose needed; the next attempt arms a fresh watchdog.
3006
+ if (stallTriggered) {
3007
+ const _stallBackoff = planStallReprompt();
3008
+ if (_stallBackoff != null) {
3009
+ await new Promise((r) => setTimeout(r, _stallBackoff));
3010
+ if (!signal.aborted) {
3011
+ stallTriggered = false;
3012
+ continue;
3013
+ }
3014
+ }
3015
+ }
3016
+ // Transient network/server error retry — up to MAX_STREAM_RETRIES extra attempts.
3017
+ // Only retry when no content has flowed yet (assistantText empty) to avoid
3018
+ // partial-output corruption. Honour the abort signal between retries.
3019
+ // Skip retry on a stall abort: the provider is unresponsive, so a retry
3020
+ // just burns another full stall timeout of silence — surface it instead.
3021
+ if (!assistantText.trim() && streamRetryCount < MAX_STREAM_RETRIES && !signal.aborted && !stallTriggered) {
3022
+ const { transient } = classifyStreamError(err);
3023
+ if (transient) {
3024
+ streamRetryCount++;
3025
+ // Exponential backoff: 500 → 2000 ms with ±25% jitter
3026
+ const baseMs = 500;
3027
+ const expMs = Math.min(baseMs * 4 ** (streamRetryCount - 1), 8_000);
3028
+ const spread = expMs * 0.25;
3029
+ const nextDelayMs = Math.round(expMs + (Math.random() * 2 - 1) * spread);
3030
+ const errorName = err instanceof Error ? err.name : "Error";
3031
+ const errorMessage = err instanceof Error ? err.message : String(err);
3032
+ // Emit harness telemetry event
3033
+ try {
3034
+ const _ar = globalThis.__muonroiAgentRuntime;
3035
+ _ar?.emitEvent({
3036
+ t: "event",
3037
+ kind: "stream-retry",
3038
+ attempt: streamRetryCount,
3039
+ maxAttempts: MAX_STREAM_RETRIES + 1,
3040
+ errorName,
3041
+ errorMessage,
3042
+ nextDelayMs,
3043
+ });
3044
+ }
3045
+ catch {
3046
+ /* best-effort */
3047
+ }
3048
+ try {
3049
+ if (deps.session) {
3050
+ logInteraction(deps.session.id, "stream_retry", {
3051
+ data: {
3052
+ attempt: streamRetryCount,
3053
+ maxAttempts: MAX_STREAM_RETRIES + 1,
3054
+ errorName,
3055
+ errorMessage: errorMessage.slice(0, 200),
3056
+ nextDelayMs,
3057
+ },
3058
+ });
3059
+ }
3060
+ }
3061
+ catch {
3062
+ /* fail-open */
3063
+ }
3064
+ await new Promise((resolve) => setTimeout(resolve, nextDelayMs));
3065
+ if (!signal.aborted) {
3066
+ continue;
3067
+ }
3068
+ }
3069
+ }
3070
+ const authError = isAuthenticationError(err);
3071
+ // Stall aborts carry an opaque DOMException; show the clear stall
3072
+ // message instead of the raw abort reason.
3073
+ const friendly = stallTriggered
3074
+ ? STALL_ERROR_MESSAGE
3075
+ : humanizeApiError(err, { modelId: runtime.modelId, providerId: runtime.modelInfo?.provider });
3076
+ notifyObserver(observer?.onError, {
3077
+ message: friendly,
3078
+ timestamp: Date.now(),
3079
+ });
3080
+ yield {
3081
+ type: "error",
3082
+ content: friendly,
3083
+ isAuthError: authError,
3084
+ };
3085
+ if (_pendingStructuredResponse) {
3086
+ const _d = _pendingStructuredResponse.data;
3087
+ const _ans = typeof _d.response === "string" ? _d.response : JSON.stringify(_pendingStructuredResponse.data);
3088
+ deps.appendCompletedTurn(userModelMessage, [{ role: "assistant", content: _ans }]);
3089
+ }
3090
+ else if (assistantText.trim()) {
3091
+ deps.appendCompletedTurn(userModelMessage, [{ role: "assistant", content: stripDsmlMarkup(assistantText) }]);
3092
+ }
3093
+ else if (deps.session && userWriteAheadSeq != null) {
3094
+ markMessageErrored(deps.session.id, userWriteAheadSeq);
3095
+ }
3096
+ // ROUTE-11: Fire routeFeedback for failed turns (error path).
3097
+ // Must come AFTER posttool calls. Fire-and-forget — no await.
3098
+ {
3099
+ const turnDuration = Date.now() - turnStartMs;
3100
+ if (taskHash) {
3101
+ const tier = taskTypeToTier(pilCtx.taskType);
3102
+ void routeFeedback(taskHash, tier, runtime.modelId, "fail", 0, turnDuration);
3103
+ }
3104
+ const storeHash = routerStore.getState().taskHash;
3105
+ if (storeHash) {
3106
+ reportRouteOutcome(storeHash, "fail", turnDuration);
3107
+ }
3108
+ }
3109
+ const stopFailureInput = {
3110
+ hook_event_name: "StopFailure",
3111
+ error: friendly,
3112
+ session_id: deps.session?.id,
3113
+ cwd: deps.bash.getCwd(),
3114
+ };
3115
+ await deps.fireHook(stopFailureInput, signal).catch(() => { });
3116
+ // Yield done FIRST — same rationale as the success path above.
3117
+ // postTurnCompact fire-and-forget so generator returns immediately.
3118
+ if (modelInfo?.contextWindow) {
3119
+ void deps.postTurnCompact(provider, system, modelInfo.contextWindow, signal).catch(() => { });
3120
+ }
3121
+ yield { type: "done" };
3122
+ return;
3123
+ }
3124
+ finally {
3125
+ await closeMcp?.().catch(() => { });
3126
+ }
3127
+ }
3128
+ }
3129
+ catch (err) {
3130
+ throw err;
3131
+ }
3132
+ finally {
3133
+ if (deps.isSubSession) {
3134
+ deps.emitSubagentStatus(null);
3135
+ }
3136
+ }
3137
+ }
3138
+ //# sourceMappingURL=tool-engine.js.map