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