mixdog 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/package.json +9 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Anthropic "effort" (extended-thinking budget) request parameter handling.
3
+ */
4
+
5
+ export const EFFORT_LEVELS = Object.freeze(['low', 'medium', 'high', 'max']);
6
+
7
+ export const EFFORT_BETA_HEADER = 'effort-2025-11-24';
8
+
9
+ export const LEGACY_EFFORT_BUDGET = Object.freeze({
10
+ low: 1024,
11
+ medium: 4096,
12
+ high: 16384,
13
+ max: 32768,
14
+ });
15
+ const _LOGGED_EFFORT_NORMALIZATION = new Set();
16
+ const _LOGGED_UNKNOWN_EFFORT = new Set();
17
+
18
+ function normalizeModelId(model) {
19
+ return String(model || '').toLowerCase().replace(/\./g, '-');
20
+ }
21
+
22
+ function parseClaudeVersion(model) {
23
+ const m = normalizeModelId(model);
24
+ const triple = m.match(/^claude-(haiku|sonnet|opus|fable)-(\d+)-(\d+)/);
25
+ if (triple) {
26
+ return { family: triple[1], major: Number(triple[2]), minor: Number(triple[3]) };
27
+ }
28
+ const pair = m.match(/^claude-(sonnet|fable)-(\d+)(?:$|[-@])/);
29
+ if (pair) {
30
+ return { family: pair[1], major: Number(pair[2]), minor: null };
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function isLegacyAnthropicReasoningModel(model) {
36
+ const parsed = parseClaudeVersion(model);
37
+ if (!parsed) return false;
38
+ if (parsed.family === 'haiku') return true;
39
+ if (parsed.family === 'fable') return false;
40
+ if (parsed.family === 'sonnet' || parsed.family === 'opus') {
41
+ if (parsed.major < 4) return true;
42
+ if (parsed.major === 4 && parsed.minor !== null && parsed.minor < 6) return true;
43
+ return false;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ // @[MODEL LAUNCH]: extend allowlist when new Claude models ship with effort support.
49
+ export function modelSupportsEffort(model) {
50
+ if (isEnvTruthy(process.env.MIXDOG_ANTHROPIC_ALWAYS_ENABLE_EFFORT)) return true;
51
+ const m = normalizeModelId(model);
52
+ if (!m.includes('claude')) return false;
53
+ if (isLegacyAnthropicReasoningModel(model)) return false;
54
+ if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) return true;
55
+ if (m.includes('sonnet-5') || m.includes('fable-5')) return true;
56
+ if (/^claude-opus-4-(6|7|8)(?:$|[-@])/.test(m)) return true;
57
+ if (/^claude-opus-5-/.test(m)) return true;
58
+ if (m.startsWith('claude-')) return true;
59
+ return false;
60
+ }
61
+
62
+ // @[MODEL LAUNCH]: extend when new Opus models support max effort.
63
+ export function modelSupportsMaxEffort(model) {
64
+ const m = normalizeModelId(model);
65
+ if (/^claude-opus-4-(6|7|8)(?:$|[-@])/.test(m)) return true;
66
+ if (/^claude-opus-5-/.test(m)) return true;
67
+ return false;
68
+ }
69
+
70
+ function isEnvTruthy(value) {
71
+ const s = String(value ?? '').trim().toLowerCase();
72
+ return s === '1' || s === 'true' || s === 'yes' || s === 'on';
73
+ }
74
+
75
+ function logOnce(key, write) {
76
+ if (_LOGGED_EFFORT_NORMALIZATION.has(key)) return;
77
+ _LOGGED_EFFORT_NORMALIZATION.add(key);
78
+ try { write(); } catch { /* ignore */ }
79
+ }
80
+
81
+ /**
82
+ * Normalize user/config effort to API levels (low/medium/high/max).
83
+ * Maps legacy xhigh → max (Opus 4.6+) or high; clamps max on non-Opus models.
84
+ */
85
+ export function normalizeAnthropicEffortInput(raw, model, logTag = 'anthropic') {
86
+ if (raw === undefined || raw === null || raw === '') return undefined;
87
+ let level = String(raw).trim().toLowerCase();
88
+ if (level === 'extra-high' || level === 'very-high') level = 'xhigh';
89
+
90
+ if (level === 'xhigh') {
91
+ const mapped = modelSupportsMaxEffort(model) ? 'max' : 'high';
92
+ logOnce(`xhigh:${mapped}`, () => {
93
+ process.stderr.write(
94
+ `[${logTag}] effort "xhigh" normalized to "${mapped}" (official levels: ${EFFORT_LEVELS.join(', ')})\n`,
95
+ );
96
+ });
97
+ return mapped;
98
+ }
99
+
100
+ if (level === 'max' && !modelSupportsMaxEffort(model)) {
101
+ logOnce('max:high', () => {
102
+ process.stderr.write(`[${logTag}] effort "max" downgraded to "high" for model ${model}\n`);
103
+ });
104
+ return 'high';
105
+ }
106
+
107
+ if (EFFORT_LEVELS.includes(level)) return level;
108
+ return undefined;
109
+ }
110
+
111
+ export function anthropicEffortUsesOutputConfig(model, opts = {}) {
112
+ const thinkingBudgetTokens = Number(opts.thinkingBudgetTokens);
113
+ if (Number.isFinite(thinkingBudgetTokens) && thinkingBudgetTokens > 0) return false;
114
+ return modelSupportsEffort(model);
115
+ }
116
+
117
+ export function shouldIncludeEffortBeta(model, opts = {}) {
118
+ return anthropicEffortUsesOutputConfig(model, opts);
119
+ }
120
+
121
+ /**
122
+ * Apply effort / thinking to a Messages API body or SDK params object (mutates).
123
+ */
124
+ export function applyAnthropicEffortToBody(
125
+ body,
126
+ {
127
+ model,
128
+ opts = {},
129
+ maxTokens,
130
+ clampThinkingBudgetTokens,
131
+ logTag = 'anthropic',
132
+ },
133
+ ) {
134
+ if (!body || typeof body !== 'object') return;
135
+
136
+ const thinkingBudgetTokens = Number(opts.thinkingBudgetTokens);
137
+ if (Number.isFinite(thinkingBudgetTokens) && thinkingBudgetTokens > 0) {
138
+ const budgetTokens = clampThinkingBudgetTokens(thinkingBudgetTokens, maxTokens);
139
+ if (budgetTokens) {
140
+ body.thinking = { type: 'enabled', budget_tokens: budgetTokens };
141
+ }
142
+ return;
143
+ }
144
+
145
+ const normalized = normalizeAnthropicEffortInput(opts.effort, model, logTag);
146
+
147
+ if (modelSupportsEffort(model)) {
148
+ if (normalized) {
149
+ const existing = body.output_config && typeof body.output_config === 'object'
150
+ ? body.output_config
151
+ : {};
152
+ body.output_config = { ...existing, effort: normalized };
153
+ }
154
+ return;
155
+ }
156
+
157
+ if (normalized && LEGACY_EFFORT_BUDGET[normalized]) {
158
+ const budgetTokens = clampThinkingBudgetTokens(LEGACY_EFFORT_BUDGET[normalized], maxTokens);
159
+ if (budgetTokens) {
160
+ body.thinking = { type: 'enabled', budget_tokens: budgetTokens };
161
+ }
162
+ return;
163
+ }
164
+
165
+ if (opts.effort && normalized === undefined && !_LOGGED_UNKNOWN_EFFORT.has(opts.effort)) {
166
+ _LOGGED_UNKNOWN_EFFORT.add(opts.effort);
167
+ try {
168
+ process.stderr.write(
169
+ `[${logTag}] unknown effort=${opts.effort} ignored (known legacy: ${Object.keys(LEGACY_EFFORT_BUDGET).join(', ')})\n`,
170
+ );
171
+ } catch { /* ignore */ }
172
+ }
173
+ }
174
+
175
+ export function effortValuesForModel(capabilities, modelId) {
176
+ const effort = capabilities?.effort;
177
+ let levels = [...EFFORT_LEVELS];
178
+ if (!modelSupportsMaxEffort(modelId)) {
179
+ levels = levels.filter((level) => level !== 'max');
180
+ }
181
+ if (!effort) return [];
182
+ if (effort === true) return levels;
183
+ const values = levels.filter(
184
+ (level) => effort?.[level] === true || effort?.[level]?.supported === true,
185
+ );
186
+ if (values.length) return values;
187
+ return effort.supported === true ? levels : [];
188
+ }
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Leaked tool-call recovery for the shared Anthropic SSE parser.
3
+ *
4
+ * Opus (via OAuth) and, less often, the API model sometimes emit a tool
5
+ * call as plain text tags inside `text_delta` (e.g. `<function_calls>`,
6
+ * `<invoke name="...">`, `<invoke ...>`, or a stray prefix like
7
+ * `course<invoke ...>`) instead of a native `tool_use` content block. Left
8
+ * alone those tags stream to the TUI as assistant prose and the tool never
9
+ * runs.
10
+ *
11
+ * `scanLeakedToolCalls` is the boundary-gated scanner the parser feeds its
12
+ * rolling text window. Unlike hermes-agent's strip-only port (which only
13
+ * DROPS the call), this recovers a real call: it suppresses the tags from
14
+ * the visible stream AND returns a synthesizable `{ name, arguments }` when
15
+ * the leaked payload parses to a KNOWN tool. Unknown / unparseable / prose
16
+ * `<function>` mentions are flushed as ordinary text so no user-visible
17
+ * content is ever lost.
18
+ */
19
+
20
+ import { traceHash, stableTraceStringify } from './trace-utils.mjs';
21
+
22
+ // Opener strings (lowercased) used only for partial-tail hold detection —
23
+ // so a sentinel split across text_delta chunk boundaries is still caught.
24
+ const OPENERS = [
25
+ '<function_calls',
26
+ '<invoke',
27
+ '<function', // Gemma-style; name= is gated at match time
28
+ ];
29
+ // OpenAI harmony / gpt-oss channel-syntax openers. Only consulted when a
30
+ // caller opts in via `{ harmony: true }` (OpenAI-family stream parsers) so the
31
+ // Anthropic path — and its regression tests — are byte-for-byte unaffected.
32
+ const HARMONY_OPENERS = [
33
+ '<|channel|>',
34
+ '<|start|>',
35
+ ];
36
+ const LONGEST_OPENER = Math.max(
37
+ ...OPENERS.map((o) => o.length),
38
+ ...HARMONY_OPENERS.map((o) => o.length),
39
+ );
40
+
41
+ // First *definite* leaked-tool-call sentinel. The bare `<function` opener is
42
+ // boundary-gated to require a `name=` attribute so prose like
43
+ // "Use <function> in JavaScript" is never treated as a call.
44
+ // NOTE (Fix 3): a real leak ALWAYS carries the leading `<` bracket
45
+ // (`<invoke ...>`, `<invoke ...>`, `<function_calls>`); the model never
46
+ // emits a bare `antml:invoke` token without it. The old bare-`antml:` opener
47
+ // therefore only ever matched ordinary prose that mentions the literal string,
48
+ // and — because `matchCompleteBlockAt` has no bare form — it forced the scanner
49
+ // to HOLD from that token to end of stream. Removed: only bracketed forms are
50
+ // sentinels now, so prose containing "antml:invoke" mid-sentence streams
51
+ // promptly and is never recovered.
52
+ const SENTINEL_RE = /<(?:antml:)?function_calls\b|<(?:antml:)?invoke\b|<(?:antml:)?function\b[^>]*\bname\s*=/i;
53
+
54
+ // Harmony channel-syntax sentinel: the `<|channel|>` (optionally preceded by a
55
+ // `<|start|>` role token) that frames a gpt-oss tool call. Only whether it is a
56
+ // *tool* call is decided at block-match time (recipient `to=functions.NAME`).
57
+ const HARMONY_SENTINEL_RE = /<\|(?:channel|start)\|>/i;
58
+ // Terminator of a harmony message. A tool call ends with `<|call|>`; ordinary
59
+ // channel content ends with `<|end|>` / `<|return|>` — all three close a block
60
+ // so benign channel text flushes promptly instead of stalling to stream end.
61
+ const HARMONY_TERMINATOR_RE = /<\|call\|>|<\|end\|>|<\|return\|>/i;
62
+ const HARMONY_RECIPIENT_RE = /to=functions\.([A-Za-z0-9_.\-]+)/i;
63
+ const HARMONY_MESSAGE_RE = /<\|message\|>/i;
64
+
65
+ function firstSentinelIndex(s, harmony) {
66
+ const m = SENTINEL_RE.exec(s);
67
+ let idx = m ? m.index : -1;
68
+ if (harmony) {
69
+ const hm = HARMONY_SENTINEL_RE.exec(s);
70
+ if (hm && (idx === -1 || hm.index < idx)) idx = hm.index;
71
+ }
72
+ return idx;
73
+ }
74
+
75
+ function isPrefixOfAnyOpener(tail, harmony) {
76
+ const t = tail.toLowerCase();
77
+ if (OPENERS.some((o) => o.startsWith(t))) return true;
78
+ return harmony ? HARMONY_OPENERS.some((o) => o.startsWith(t)) : false;
79
+ }
80
+
81
+ // --- Markdown code-fence / inline-code tracking (Fix 1) --------------------
82
+ // A leaked tool-call tag written INSIDE a fenced code block (``` … ``` or
83
+ // ~~~ … ~~~) or an inline code span (`…`) is a documentation example, not a
84
+ // real call — it must stream as visible text, never be recovered/executed.
85
+ // Because the scanner runs on a rolling buffer, the fence state is tracked as
86
+ // a small struct threaded across chunks (a fence opened in one delta and
87
+ // closed in a later one persists). `advanceFenceState` folds a text slice into
88
+ // the state; `isInCode` reports whether a position sits inside code.
89
+ function initialFenceState() {
90
+ return { fenceChar: null, fenceLen: 0, inlineLen: 0, atLineStart: true };
91
+ }
92
+ function isInCode(s) {
93
+ return !!s && (s.fenceChar !== null || s.inlineLen > 0);
94
+ }
95
+ // Fold `text` into fence state and return the new state (input not mutated).
96
+ // Fenced blocks (``` / ~~~ at line start, length ≥ 3) persist across lines
97
+ // until a closing fence of the same char (length ≥ opener) at line start.
98
+ // Inline spans (backtick run not opening a fence) are line-local: reset at
99
+ // newline so a stray unmatched backtick in prose cannot poison later lines
100
+ // (which would wrongly suppress a genuine leaked call).
101
+ function advanceFenceState(state, text) {
102
+ let { fenceChar, fenceLen, inlineLen, atLineStart } = state || initialFenceState();
103
+ let i = 0;
104
+ while (i < text.length) {
105
+ const ch = text[i];
106
+ if (ch === '`' || ch === '~') {
107
+ let j = i;
108
+ while (j < text.length && text[j] === ch) j++;
109
+ const runLen = j - i;
110
+ if (fenceChar) {
111
+ if (ch === fenceChar && atLineStart && runLen >= fenceLen) {
112
+ fenceChar = null; fenceLen = 0;
113
+ }
114
+ } else if (inlineLen) {
115
+ if (ch === '`' && runLen === inlineLen) inlineLen = 0;
116
+ } else if (atLineStart && runLen >= 3) {
117
+ fenceChar = ch; fenceLen = runLen;
118
+ } else if (ch === '`') {
119
+ inlineLen = runLen;
120
+ }
121
+ atLineStart = false;
122
+ i = j;
123
+ continue;
124
+ }
125
+ if (ch === '\n') {
126
+ atLineStart = true;
127
+ inlineLen = 0; // inline spans do not cross lines here
128
+ i++;
129
+ continue;
130
+ }
131
+ if (atLineStart && (ch === ' ' || ch === '\t')) { i++; continue; }
132
+ atLineStart = false;
133
+ i++;
134
+ }
135
+ return { fenceChar, fenceLen, inlineLen, atLineStart };
136
+ }
137
+
138
+ // Index from which the tail of `s` could be the *start* of a sentinel that
139
+ // has not fully arrived yet. Everything before it is safe to flush now;
140
+ // from it onward must be held for the next chunk. Returns s.length when
141
+ // nothing needs holding (normal text streams promptly).
142
+ function partialTailIndex(s, harmony) {
143
+ const start = Math.max(0, s.length - LONGEST_OPENER);
144
+ for (let i = start; i < s.length; i++) {
145
+ if (isPrefixOfAnyOpener(s.slice(i), harmony)) return i;
146
+ }
147
+ return s.length;
148
+ }
149
+
150
+ // Coerce a leaked `<parameter>` value. apply_patch-style raw bodies and plain
151
+ // strings pass through verbatim; JSON-looking scalars/objects are parsed so
152
+ // the synthesized arguments match a native tool_use input shape.
153
+ function coerceValue(raw) {
154
+ const trimmed = raw.trim();
155
+ if (trimmed === '') return raw;
156
+ const first = trimmed[0];
157
+ if ('{[0123456789tfn"-'.includes(first)) {
158
+ try { return JSON.parse(trimmed); } catch { /* fall through to raw */ }
159
+ }
160
+ return raw;
161
+ }
162
+
163
+ function parseParams(text) {
164
+ const out = {};
165
+ const re = /<(?:antml:)?parameter\b[^>]*\bname\s*=\s*["']?([^"'>\s]+)["']?[^>]*>([\s\S]*?)<\/(?:antml:)?parameter\s*>/gi;
166
+ let m;
167
+ while ((m = re.exec(text)) !== null) {
168
+ out[m[1]] = coerceValue(m[2]);
169
+ }
170
+ return out;
171
+ }
172
+
173
+ function parseInvoke(text) {
174
+ const nameM = /<(?:antml:)?invoke\b[^>]*\bname\s*=\s*["']?([^"'>\s]+)["']?/i.exec(text);
175
+ if (!nameM) return null;
176
+ return { name: nameM[1], arguments: parseParams(text) };
177
+ }
178
+
179
+ // Match a COMPLETE harmony channel block starting at `idx` (which points at a
180
+ // `<|channel|>` or `<|start|>` token). Returns `{ end, call }` where `call` is
181
+ // `{ name, arguments }` when the block is a `to=functions.NAME` tool call, or
182
+ // null when it is ordinary channel content (flushed as visible text). Returns
183
+ // null (undecided → caller holds) until a terminator token has arrived.
184
+ function matchCompleteHarmonyBlockAt(s, idx) {
185
+ const rest = s.slice(idx);
186
+ if (!/^<\|(?:channel|start)\|>/i.test(rest)) return null;
187
+ const termM = HARMONY_TERMINATOR_RE.exec(rest);
188
+ if (!termM) return null;
189
+ const end = idx + termM.index + termM[0].length;
190
+ const block = rest.slice(0, termM.index);
191
+ const recM = HARMONY_RECIPIENT_RE.exec(block);
192
+ if (!recM) return { end, call: null };
193
+ const msgM = HARMONY_MESSAGE_RE.exec(block);
194
+ let args = {};
195
+ if (msgM) {
196
+ const raw = block.slice(msgM.index + msgM[0].length).trim();
197
+ if (raw) {
198
+ const coerced = coerceValue(raw);
199
+ if (coerced && typeof coerced === 'object' && !Array.isArray(coerced)) args = coerced;
200
+ }
201
+ }
202
+ return { end, call: { name: recM[1], arguments: args } };
203
+ }
204
+
205
+ // Try to match a COMPLETE leaked block starting at `idx`. Returns
206
+ // `{ end, call }` where `end` is the index in `s` just past the block and
207
+ // `call` is `{ name, arguments }` (or null when the block is well-formed XML
208
+ // but carries no parseable invoke). Returns null when the block has not
209
+ // finished arriving yet (caller holds and waits for more chunks).
210
+ function matchCompleteBlockAt(s, idx, harmony) {
211
+ const rest = s.slice(idx);
212
+
213
+ // Harmony channel syntax (opt-in). Routed first because its opener token
214
+ // (`<|...|>`) never collides with the XML `<function...>` families below.
215
+ if (harmony && /^<\|(?:channel|start)\|>/i.test(rest)) {
216
+ return matchCompleteHarmonyBlockAt(s, idx);
217
+ }
218
+
219
+ // <function_calls> ... </function_calls> wrapper
220
+ const wm = /^<(?:antml:)?function_calls\b[^>]*>/i.exec(rest);
221
+ if (wm) {
222
+ const cm = /<\/(?:antml:)?function_calls\s*>/i.exec(rest);
223
+ if (!cm) return null;
224
+ const end = idx + cm.index + cm[0].length;
225
+ const inner = rest.slice(wm[0].length, cm.index);
226
+ return { end, call: parseInvoke(inner) };
227
+ }
228
+
229
+ // <invoke ...> ... </invoke>
230
+ const im = /^<(?:antml:)?invoke\b[^>]*>/i.exec(rest);
231
+ if (im) {
232
+ const cm = /<\/(?:antml:)?invoke\s*>/i.exec(rest);
233
+ if (!cm) return null;
234
+ const blockText = rest.slice(0, cm.index + cm[0].length);
235
+ return { end: idx + blockText.length, call: parseInvoke(blockText) };
236
+ }
237
+
238
+ // <function name="..."> ... </function> (Gemma-style)
239
+ const fm = /^<(?:antml:)?function\b[^>]*\bname\s*=\s*["']?([^"'>\s]+)["']?[^>]*>/i.exec(rest);
240
+ if (fm) {
241
+ const cm = /<\/(?:antml:)?function\s*>/i.exec(rest);
242
+ if (!cm) return null;
243
+ const end = idx + cm.index + cm[0].length;
244
+ const inner = rest.slice(fm[0].length, cm.index);
245
+ return { end, call: fm[1] ? { name: fm[1], arguments: parseParams(inner) } : null };
246
+ }
247
+
248
+ // Sentinel matched (e.g. bare `antml:invoke` with no `<`) but no complete
249
+ // block form — leave undecided so the caller holds (or flushes on final).
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Scan a rolling text window for leaked tool-call syntax.
255
+ *
256
+ * @param {string} buffer Held-back text window.
257
+ * @param {object} opts
258
+ * @param {(name:string)=>boolean} opts.isKnownTool Validates the recovered
259
+ * tool name against the tools available to this request.
260
+ * @param {boolean} opts.final True on stream end: flush everything, never
261
+ * hold (legitimate text is never lost).
262
+ * @param {boolean} [opts.harmony] Opt-in (OpenAI-family callers only): also
263
+ * recognize gpt-oss/harmony `<|channel|>...to=functions.NAME...<|call|>`
264
+ * tool syntax. Off by default so the Anthropic path is unaffected.
265
+ * @param {object} [opts.fenceState] Running markdown fence/inline-code state
266
+ * from the previous chunk (Fix 1). A fence opened in an earlier delta and
267
+ * closed in a later one is respected because `fence` reflects the state
268
+ * as-of the start of `buffer`. Omit for single-shot use.
269
+ * @returns {{ emit:string, calls:Array<{name:string,arguments:object}>, rest:string, fenceState:object }}
270
+ * `emit` = text safe to forward now, `calls` = recovered known-tool
271
+ * calls to synthesize/dispatch, `rest` = text to keep buffered,
272
+ * `fenceState` = markdown state as-of the start of `rest` (thread it
273
+ * back in on the next call).
274
+ */
275
+ export function scanLeakedToolCalls(buffer, { isKnownTool, final, harmony = false, fenceState = null }) {
276
+ let emit = '';
277
+ const calls = [];
278
+ const buf = buffer;
279
+ let pos = 0;
280
+ // Fix 1: fence state advances over EVERY consumed character in raw order,
281
+ // so `isInCode` at a sentinel reflects the running markdown context —
282
+ // including a fence opened in an earlier chunk (caller threads fenceState).
283
+ let fence = fenceState || initialFenceState();
284
+ // `asText` controls both whether the slice is emitted AND whether it
285
+ // advances markdown fence state. A recovered/SUPPRESSED tool-call block
286
+ // (asText=false) is hidden from the visible stream, so it must NOT feed the
287
+ // fence tracker either (Fix 2 follow-up / reviewer Medium): an unmatched
288
+ // backtick inside the hidden XML/harmony args would otherwise leave a fence
289
+ // "open" and wrongly suppress a subsequent REAL leaked call as in-code.
290
+ // Fence state only ever reflects text the user actually sees.
291
+ const consume = (to, asText) => {
292
+ if (asText) {
293
+ fence = advanceFenceState(fence, buf.slice(pos, to));
294
+ emit += buf.slice(pos, to);
295
+ }
296
+ pos = to;
297
+ };
298
+
299
+ while (pos < buf.length) {
300
+ const rel = firstSentinelIndex(buf.slice(pos), harmony);
301
+ if (rel === -1) {
302
+ if (final) { consume(buf.length, true); }
303
+ else {
304
+ const hold = partialTailIndex(buf.slice(pos), harmony);
305
+ consume(pos + hold, true);
306
+ }
307
+ break;
308
+ }
309
+ const idx = pos + rel;
310
+ // Plain text before the sentinel always streams as visible text.
311
+ consume(idx, true);
312
+ const inCode = isInCode(fence);
313
+ const block = matchCompleteBlockAt(buf, idx, harmony);
314
+ if (!block) {
315
+ // Opener present but block not fully arrived.
316
+ if (final) { consume(buf.length, true); break; }
317
+ if (inCode) {
318
+ // Inside a code fence / inline span: this can never become a
319
+ // real recovered call — emit the opener char and keep scanning
320
+ // instead of holding it (and the rest of the doc) to stream end.
321
+ consume(idx + 1, true);
322
+ continue;
323
+ }
324
+ // Outside code: hold from the opener for the next chunk.
325
+ break;
326
+ }
327
+ const { end, call } = block;
328
+ if (!inCode && call && call.name && isKnownTool(call.name)) {
329
+ // Recovered a real, known tool call OUTSIDE any code fence:
330
+ // suppress the tags from the visible stream and synthesize it.
331
+ consume(end, false);
332
+ calls.push({ name: call.name, arguments: call.arguments || {} });
333
+ continue;
334
+ }
335
+ // In code, unknown tool, or no parseable invoke: keep the block as
336
+ // ordinary visible text and continue scanning past it.
337
+ consume(end, true);
338
+ }
339
+
340
+ return { emit, calls, rest: buf.slice(pos), fenceState: fence };
341
+ }
342
+
343
+ /**
344
+ * Stateful convenience wrapper around `scanLeakedToolCalls` for the streaming
345
+ * parsers. Holds the minimal rolling text window across chunk boundaries and
346
+ * exposes `push(delta)` / `flush()` that return the visible text safe to
347
+ * forward now plus any recovered known-tool calls. The Anthropic parser keeps
348
+ * its own inline wiring; this factory is shared by the OpenAI-family stream
349
+ * parsers so each only wires provider-specific call synthesis + dispatch.
350
+ *
351
+ * @param {object} opts
352
+ * @param {Set<string>|string[]} opts.knownToolNames Tools offered this request.
353
+ * @param {boolean} [opts.harmony] Opt-in gpt-oss/harmony channel detection.
354
+ * @returns {{ enabled:boolean, push:(delta:string, final?:boolean)=>{text:string,calls:Array<{name:string,arguments:object}>}, flush:()=>{text:string,calls:Array<{name:string,arguments:object}>} }}
355
+ */
356
+ export function createLeakGuard({ knownToolNames, harmony = false } = {}) {
357
+ const known = knownToolNames instanceof Set
358
+ ? knownToolNames
359
+ : new Set(Array.isArray(knownToolNames) ? knownToolNames : []);
360
+ const enabled = known.size > 0;
361
+ const isKnownTool = (name) => known.has(name);
362
+ let buffer = '';
363
+ // Running markdown fence/inline-code state threaded across chunks (Fix 1).
364
+ let fence = initialFenceState();
365
+ const run = (delta, final) => {
366
+ if (!enabled) return { text: delta || '', calls: [] };
367
+ buffer += delta || '';
368
+ if (!buffer && !final) return { text: '', calls: [] };
369
+ const { emit, calls, rest, fenceState } = scanLeakedToolCalls(buffer, { isKnownTool, final, harmony, fenceState: fence });
370
+ buffer = rest;
371
+ fence = fenceState;
372
+ return { text: emit, calls };
373
+ };
374
+ return {
375
+ enabled,
376
+ push: (delta, final = false) => run(delta, final),
377
+ flush: () => run('', true),
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Shared name+args fingerprint dedupe for tool calls (Fix 2). The leak guard
383
+ * dispatches a synthesized call the instant it is recovered from text; the
384
+ * native tool_use / tool_calls path can later surface the SAME call. Without a
385
+ * cross-path guard both fire and a side-effecting tool double-executes. This
386
+ * factory holds a per-stream Set of dispatched fingerprints (name + stably
387
+ * stringified args) — call `shouldDispatch(name, args)` before EITHER a
388
+ * synthetic or a native dispatch: it returns false (skip) when an identical
389
+ * fingerprint already fired. Mirrors gemini.mjs's dedupe so all providers
390
+ * behave identically.
391
+ */
392
+ export function toolCallFingerprint(name, args) {
393
+ let a = args;
394
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) a = {};
395
+ return traceHash(stableTraceStringify({ name: name || '', args: a }));
396
+ }
397
+ export function createToolCallDedupe() {
398
+ const seen = new Set();
399
+ return {
400
+ // True the first time this (name,args) fingerprint is seen; false on
401
+ // any later identical call (synthetic-then-native or vice-versa).
402
+ shouldDispatch(name, args) {
403
+ const fp = toolCallFingerprint(name, args);
404
+ if (seen.has(fp)) return false;
405
+ seen.add(fp);
406
+ return true;
407
+ },
408
+ has(name, args) { return seen.has(toolCallFingerprint(name, args)); },
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Drop duplicate tool calls from a RETURNED `toolCalls` array by name+args
414
+ * fingerprint (Fix 2, array side). Dispatch-time dedupe (`createToolCallDedupe`)
415
+ * only suppresses the second `onToolCall`; providers that also RETURN a
416
+ * `toolCalls` array (which the agent loop executes) can still carry a
417
+ * synthetic-leaked + identical-native duplicate. Run the final array through
418
+ * this so the loop never executes a side-effecting tool twice. Order is
419
+ * preserved; the FIRST occurrence of each fingerprint wins.
420
+ *
421
+ * @param {Array<{name?:string,arguments?:object}>} calls
422
+ * @returns {Array} the array with later duplicate-fingerprint calls removed.
423
+ */
424
+ export function dedupeToolCallList(calls) {
425
+ if (!Array.isArray(calls) || calls.length < 2) return calls;
426
+ const seen = new Set();
427
+ const out = [];
428
+ for (const call of calls) {
429
+ if (!call || typeof call !== 'object') { out.push(call); continue; }
430
+ const fp = toolCallFingerprint(call.name, call.arguments);
431
+ if (seen.has(fp)) continue;
432
+ seen.add(fp);
433
+ out.push(call);
434
+ }
435
+ return out.length === calls.length ? calls : out;
436
+ }
437
+
438
+ // Exposed for focused unit tests.
439
+ export const _internals = {
440
+ firstSentinelIndex,
441
+ partialTailIndex,
442
+ matchCompleteBlockAt,
443
+ parseInvoke,
444
+ };