mixdog 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/package.json +9 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -1,12 +1,28 @@
1
1
  import {
2
2
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
3
- PROVIDER_SSE_IDLE_TIMEOUT_MS,
4
3
  PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
4
+ PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
5
+ streamStalledError,
5
6
  createTimeoutSignal,
6
7
  providerTimeoutError,
7
8
  } from '../stall-policy.mjs';
8
9
  import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
9
10
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
11
+ import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
12
+ import { randomBytes } from 'crypto';
13
+
14
+ // Synthesize a native-shaped OpenAI tool call from a recovered leaked call.
15
+ // Matches the `call_...` id scheme the native Responses/Chat paths use so the
16
+ // dispatch loop and any downstream tool_result reference line up.
17
+ function synthLeakedOpenAICall(recovered) {
18
+ let args = recovered?.arguments;
19
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
20
+ return {
21
+ id: `call_leaked_${randomBytes(8).toString('hex')}`,
22
+ name: recovered.name,
23
+ arguments: args,
24
+ };
25
+ }
10
26
 
11
27
  function truncatedCompatStreamError(label, detail) {
12
28
  return Object.assign(
@@ -15,8 +31,7 @@ function truncatedCompatStreamError(label, detail) {
15
31
  );
16
32
  }
17
33
 
18
- // Invalid-tool-args marker. Native-provider convergence (openai-oauth /
19
- // opencode): completed-but-malformed tool_call arguments JSON must NOT throw
34
+ // Invalid-tool-args marker: completed-but-malformed tool_call arguments JSON must NOT throw
20
35
  // (kills the turn) NOR be silently swallowed to `{}`. Instead the parse
21
36
  // failure is carried as data on the tool call's `arguments` slot so the
22
37
  // dispatch loop can turn it into an is_error tool_result and let the model
@@ -33,8 +48,7 @@ export function isInvalidToolArgsMarker(value) {
33
48
  return !!value && typeof value === 'object' && value.__invalidToolArgs === true;
34
49
  }
35
50
  /** Model-facing tool_result text for a tool call whose arguments failed to
36
- * parse. Mirrors opencode `The arguments provided to the tool are invalid` and
37
- * `failed to parse function arguments` — instructs an in-turn retry. */
51
+ * parse; instructs the model to retry with valid JSON in the same turn. */
38
52
  export function formatInvalidToolArgsResult(call) {
39
53
  const name = call?.name || 'tool';
40
54
  const detail = call?.arguments?.__parseError || 'arguments were not valid JSON';
@@ -69,9 +83,9 @@ export function parseCompletedToolCallArgumentsJson(raw, label, meta) {
69
83
  // Invariant: a completion/finish signal was observed for this tool call
70
84
  // (finish_reason present, or a per-call/response "done" event fired), so
71
85
  // the arguments are NOT mid-stream-truncated — they are complete but
72
- // malformed. Native convergence: return an invalid-args MARKER (not a
73
- // throw) so the dispatch loop feeds the parse error back to the model as
74
- // a tool_result and the model self-corrects in the same turn. Only an
86
+ // malformed. Return an invalid-args MARKER (not a throw) so the
87
+ // dispatch loop feeds the parse error back to the model as a
88
+ // tool_result and the model self-corrects in the same turn. Only an
75
89
  // unfinished stream (no finishReason) stays the retryable truncation
76
90
  // case — that transient behavior is deliberately preserved.
77
91
  if (meta?.finishReason) {
@@ -87,17 +101,29 @@ function firstByteCompatStreamError(label) {
87
101
  return err;
88
102
  }
89
103
 
90
- async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, idleLabel } = {}) {
104
+ async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, idleLabel, emittedToolCall } = {}) {
91
105
  let idleTimer = null;
92
106
  let idleReject = null;
93
107
  let idleTimedOut = false;
108
+ // Double-dispatch guard (reviewer High): if a tool call was already emitted
109
+ // this stream, a stall must be unsafe-to-retry so withRetry() won't replay
110
+ // the turn and re-run the side-effecting tool. `emittedToolCall` may be a
111
+ // boolean or a getter evaluated at abort time (state mutates mid-stream).
112
+ const didEmitToolCall = () => {
113
+ try { return typeof emittedToolCall === 'function' ? !!emittedToolCall() : !!emittedToolCall; }
114
+ catch { return false; }
115
+ };
94
116
  const armIdle = () => {
95
117
  if (!idleEnabled || !(idleMs > 0)) return;
96
118
  if (idleTimer) clearTimeout(idleTimer);
97
119
  idleTimer = setTimeout(() => {
98
120
  idleTimedOut = true;
99
- const e = providerTimeoutError(idleLabel || 'compat SSE idle', idleMs);
100
- e.code = 'ETIMEDOUT';
121
+ // SEMANTIC idle abort: this timer is (re)armed only around waiting
122
+ // for the NEXT stream event, so keepalive/comment frames the SDK
123
+ // filters out cannot keep it alive. Throw the named terminal
124
+ // StreamStalledError so the retry-classifier treats it as a stream
125
+ // failure (owner gets notified) rather than a user cancel.
126
+ const e = streamStalledError(idleLabel || 'compat SSE', idleMs, { emittedToolCall: didEmitToolCall() });
101
127
  if (idleReject) {
102
128
  const r = idleReject;
103
129
  idleReject = null;
@@ -143,7 +169,7 @@ async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, id
143
169
  return result;
144
170
  } catch (err) {
145
171
  if (idleTimer) clearTimeout(idleTimer);
146
- if (idleTimedOut) throw providerTimeoutError(idleLabel || 'compat SSE idle', idleMs);
172
+ if (idleTimedOut) throw streamStalledError(idleLabel || 'compat SSE', idleMs, { emittedToolCall: didEmitToolCall() });
147
173
  throw err;
148
174
  }
149
175
  }
@@ -213,6 +239,15 @@ function emitCompatToolCallOnce(state, call, onToolCall) {
213
239
  const key = `id:${call.id}`;
214
240
  if (!state.emittedToolCallKeys) state.emittedToolCallKeys = new Set();
215
241
  if (state.emittedToolCallKeys.has(key)) return false;
242
+ // Fix 2: cross-path name+args dedupe. A synthesized text-leaked call and an
243
+ // identical native tool_call must fire onToolCall exactly once. state._toolDedupe
244
+ // is created per stream; when absent (older callers) behavior is unchanged.
245
+ if (state._toolDedupe && !state._toolDedupe.shouldDispatch(call.name, call.arguments)) {
246
+ // Still mark the id as emitted so later id-frames for the same native
247
+ // call don't retry, but do NOT invoke onToolCall (already dispatched).
248
+ state.emittedToolCallKeys.add(key);
249
+ return false;
250
+ }
216
251
  state.emittedToolCallKeys.add(key);
217
252
  state.emittedToolCall = true;
218
253
  const { _pendingItemId, ...cleanCall } = call;
@@ -257,11 +292,14 @@ function isMaxOutputIncompleteReason(reason) {
257
292
  return /^(?:max_output_tokens|max_tokens|length|output_token_limit)$/i.test(String(reason || '').trim());
258
293
  }
259
294
 
260
- export async function consumeCompatChatCompletionStream(stream, { signal, label, onStreamDelta, onToolCall, onTextDelta, parseToolCalls } = {}) {
295
+ export async function consumeCompatChatCompletionStream(stream, { signal, label, onStreamDelta, onToolCall, onTextDelta, parseToolCalls, knownToolNames } = {}) {
261
296
  const iterator = stream[Symbol.asyncIterator]();
262
297
  const firstByteTimeout = createTimeoutSignal(signal, PROVIDER_FIRST_BYTE_TIMEOUT_MS, `${label} first byte`);
263
298
  const idleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED;
264
- const idleMs = PROVIDER_SSE_IDLE_TIMEOUT_MS;
299
+ // Per-event (last-event-relative) SEMANTIC idle: nextAsyncWithWatchdog arms
300
+ // the timer only while awaiting the NEXT stream event, so a stream that
301
+ // emits some deltas then goes silent trips it within the window.
302
+ const idleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
265
303
  let sawFirstEvent = false;
266
304
  let content = '';
267
305
  let reasoningContent = '';
@@ -276,6 +314,46 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
276
314
  let rawUsage = null;
277
315
  const toolAcc = new Map();
278
316
  const toolBucketState = { _orderSeq: 0, _nextAnonId: 0, _lastAnonKey: null };
317
+ // Fix 2: one dedupe per stream, shared by the synthetic leaked-call
318
+ // dispatch and every native emit so an identical (name,args) fires once.
319
+ const _toolDedupe = createToolCallDedupe();
320
+ // Leaked tool-call guard: the model sometimes emits a tool call as plain
321
+ // text (XML `<invoke>`/`<function_calls>` or gpt-oss harmony
322
+ // `<|channel|>...to=functions.NAME...<|call|>`) inside `delta.content`
323
+ // instead of a native `tool_calls` delta. Route content through the guard
324
+ // so leaked calls are suppressed from visible text, synthesized, and
325
+ // dispatched like native calls. Additive: the native tool_calls path is
326
+ // untouched. Harmony detection is opt-in here (gpt-oss compat backends).
327
+ const leakGuard = createLeakGuard({ knownToolNames, harmony: true });
328
+ const dispatchLeakedCall = (recovered) => {
329
+ const call = synthLeakedOpenAICall(recovered);
330
+ const emitState = { emittedToolCallKeys: new Set(), _toolDedupe };
331
+ emitCompatToolCallOnce(emitState, call, onToolCall);
332
+ return call;
333
+ };
334
+ const leakedCalls = [];
335
+ const relayText = (delta) => {
336
+ const { text, calls } = leakGuard.push(delta);
337
+ if (text) {
338
+ content += text;
339
+ if (onTextDelta) {
340
+ emittedText = true;
341
+ try { onTextDelta(text); } catch {}
342
+ }
343
+ }
344
+ for (const c of calls) leakedCalls.push(dispatchLeakedCall(c));
345
+ };
346
+ const flushLeak = () => {
347
+ const { text, calls } = leakGuard.flush();
348
+ if (text) {
349
+ content += text;
350
+ if (onTextDelta) {
351
+ emittedText = true;
352
+ try { onTextDelta(text); } catch {}
353
+ }
354
+ }
355
+ for (const c of calls) leakedCalls.push(dispatchLeakedCall(c));
356
+ };
279
357
  try {
280
358
  while (true) {
281
359
  const { value: chunk, done } = await nextAsyncWithWatchdog(iterator, {
@@ -285,6 +363,9 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
285
363
  idleMs,
286
364
  idleEnabled: sawFirstEvent && idleEnabled,
287
365
  idleLabel: `${label} SSE idle`,
366
+ // A stall after a tool call has already been dispatched (native
367
+ // or recovered-leaked) must be unsafe-to-retry (no double-run).
368
+ emittedToolCall: () => leakedCalls.length > 0 || toolAcc.size > 0,
288
369
  });
289
370
  if (done) break;
290
371
  if (!sawFirstEvent) {
@@ -296,12 +377,18 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
296
377
  if (chunk?.model) model = chunk.model;
297
378
  const choice = chunk?.choices?.[0];
298
379
  if (choice?.delta?.content) {
299
- content += choice.delta.content;
300
- // Live text relay (gateway): explicit assistant text delta.
380
+ // Live text relay (gateway): explicit assistant text delta,
381
+ // routed through the leaked-tool-call guard (which appends to
382
+ // `content`, forwards visible text, and recovers leaked calls).
301
383
  // reasoning_content + tool_calls deltas stay off this path.
302
- if (onTextDelta) {
303
- emittedText = true;
304
- try { onTextDelta(choice.delta.content); } catch {}
384
+ if (leakGuard.enabled) {
385
+ relayText(choice.delta.content);
386
+ } else {
387
+ content += choice.delta.content;
388
+ if (onTextDelta) {
389
+ emittedText = true;
390
+ try { onTextDelta(choice.delta.content); } catch {}
391
+ }
305
392
  }
306
393
  }
307
394
  if (typeof choice?.delta?.reasoning_content === 'string') {
@@ -311,9 +398,23 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
311
398
  if (choice?.finish_reason) stopReason = choice.finish_reason;
312
399
  if (chunk?.usage) rawUsage = chunk.usage;
313
400
  }
401
+ // Flush any partial-sentinel tail held back mid-stream so legitimate
402
+ // trailing text is never lost.
403
+ if (leakGuard.enabled) flushLeak();
314
404
  } catch (err) {
315
405
  // Any mid-stream failure after live text was relayed is non-retryable.
316
406
  if (emittedText) throw markErrorLiveTextEmitted(err);
407
+ // Partial-final recovery: on a mid-stream stall, attach the
408
+ // streamed partial state so the loop can accept a wedged FINAL no-tool
409
+ // summary as partial-final success. pendingToolUse gates out any
410
+ // in-flight/emitted tool call.
411
+ if (err?.streamStalled === true) {
412
+ try {
413
+ err.partialContent = content;
414
+ err.pendingToolUse = toolAcc.size > 0 || leakedCalls.length > 0;
415
+ err.partialModel = model || undefined;
416
+ } catch { /* best-effort */ }
417
+ }
317
418
  throw err;
318
419
  } finally {
319
420
  firstByteTimeout.cleanup();
@@ -356,9 +457,18 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
356
457
  throw err;
357
458
  }
358
459
  if (Array.isArray(toolCalls) && toolCalls.length) {
359
- const emitState = { emittedToolCallKeys: new Set() };
460
+ const emitState = { emittedToolCallKeys: new Set(), _toolDedupe };
360
461
  for (const call of toolCalls) emitCompatToolCallOnce(emitState, call, onToolCall);
361
462
  }
463
+ // Fold recovered leaked calls into the returned toolCalls so the dispatch
464
+ // loop treats them exactly like native ones. They were already emitted via
465
+ // onToolCall in relayText/flushLeak, so no re-dispatch here. Dedupe the
466
+ // final array by name+args (Fix 2, array side): a synthetic leaked call and
467
+ // an identical native tool_call must not both remain, else the loop runs
468
+ // the side-effecting tool twice.
469
+ if (leakedCalls.length) {
470
+ toolCalls = dedupeToolCallList([...(Array.isArray(toolCalls) ? toolCalls : []), ...leakedCalls]);
471
+ }
362
472
  return {
363
473
  response,
364
474
  model,
@@ -370,18 +480,21 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
370
480
  };
371
481
  }
372
482
 
373
- function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta }) {
483
+ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta, relayLeakText }) {
374
484
  if (!event || typeof event.type !== 'string') return;
375
485
  const pushToolSearchCall = (item) => {
376
486
  if (!item || item.type !== 'tool_search_call') return;
377
487
  const callId = item.call_id || item.id || '';
378
488
  if (!callId || state.toolCalls.some((call) => call.id === callId)) return;
489
+ const _tsArgs = item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments)
490
+ ? item.arguments
491
+ : parseCompletedToolCallArgumentsJson(item.arguments || '{}', label, { id: callId, name: 'tool_search', finishReason: 'done' });
379
492
  const call = {
380
493
  id: callId,
381
494
  name: 'tool_search',
382
- arguments: item.arguments && typeof item.arguments === 'object'
383
- ? item.arguments
384
- : parseCompletedToolCallArgumentsJson(item.arguments || '{}', label, { id: callId, name: 'tool_search', finishReason: 'done' }),
495
+ // Schema is a plain object ({query,select,limit}); an array must
496
+ // never pass through as args.
497
+ arguments: (_tsArgs && typeof _tsArgs === 'object' && !Array.isArray(_tsArgs)) ? _tsArgs : {},
385
498
  nativeType: 'tool_search_call',
386
499
  };
387
500
  state.toolCalls.push(call);
@@ -399,12 +512,17 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
399
512
  if (event.response?.id) state.responseId = event.response.id;
400
513
  break;
401
514
  case 'response.output_text.delta':
402
- state.content += event.delta || '';
403
515
  state.sawOutput = true;
404
516
  try { onStreamDelta?.(); } catch {}
405
- if (event.delta && onTextDelta) {
406
- state.emittedText = true;
407
- try { onTextDelta(event.delta); } catch {}
517
+ // Route assistant text through the leaked-tool-call guard (appends
518
+ // to state.content, forwards visible text, recovers leaked calls).
519
+ if (relayLeakText) relayLeakText(event.delta || '');
520
+ else {
521
+ state.content += event.delta || '';
522
+ if (event.delta && onTextDelta) {
523
+ state.emittedText = true;
524
+ try { onTextDelta(event.delta); } catch {}
525
+ }
408
526
  }
409
527
  break;
410
528
  case 'response.output_item.added':
@@ -413,13 +531,29 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
413
531
  name: event.item.name || '',
414
532
  callId: event.item.call_id || '',
415
533
  });
534
+ state.toolInFlight = true;
535
+ } else if (event.item?.type === 'custom_tool_call') {
536
+ state.toolInFlight = true;
537
+ } else if (event.item?.type === 'tool_search_call') {
538
+ // Mark tool_search in-flight at item-added time, same as
539
+ // function_call/custom_tool_call above, so the stall-recovery
540
+ // pendingToolUse gate never drops a mid-flight tool_search
541
+ // before response.output_item.done pushes it.
542
+ state.toolInFlight = true;
416
543
  }
417
544
  try { onStreamDelta?.(); } catch {}
418
545
  break;
419
546
  case 'response.function_call_arguments.delta':
547
+ // A tool call's args are streaming — mark tool work in-flight so a
548
+ // mid-args stall is NEVER accepted as a text-only partial-final.
549
+ state.toolInFlight = true;
420
550
  try { onStreamDelta?.(); } catch {}
421
551
  break;
422
552
  case 'response.custom_tool_call_input.delta':
553
+ // Custom-tool input streams before output_item.done records the call
554
+ // in pendingCalls; flag it so a mid-input stall gates out partial-
555
+ // final success (otherwise a tool-bearing turn looks text-only).
556
+ state.toolInFlight = true;
423
557
  try { onStreamDelta?.(); } catch {}
424
558
  break;
425
559
  case 'response.function_call_arguments.done': {
@@ -555,11 +689,13 @@ export async function consumeCompatResponsesStream(stream, {
555
689
  onTextDelta,
556
690
  parseResponsesToolCalls,
557
691
  responseOutputText,
692
+ knownToolNames,
558
693
  } = {}) {
559
694
  const iterator = stream[Symbol.asyncIterator]();
560
695
  const firstByteTimeout = createTimeoutSignal(signal, PROVIDER_FIRST_BYTE_TIMEOUT_MS, `${label} first byte`);
561
696
  const idleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED;
562
- const idleMs = PROVIDER_SSE_IDLE_TIMEOUT_MS;
697
+ // Per-event (last-event-relative) SEMANTIC idle — see the Chat path note.
698
+ const idleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
563
699
  const state = {
564
700
  content: '',
565
701
  model: '',
@@ -572,13 +708,51 @@ export async function consumeCompatResponsesStream(stream, {
572
708
  completed: false,
573
709
  completedResponse: null,
574
710
  sawOutput: false,
711
+ // Fix 2: cross-path name+args dedupe shared by synthetic leaked-call
712
+ // dispatch and every native emit in this Responses stream.
713
+ _toolDedupe: createToolCallDedupe(),
575
714
  // Gateway live-text relay invariant: set once a non-empty text chunk
576
715
  // has been forwarded. A later failure is non-retryable (rendered text
577
716
  // cannot be withdrawn; a retry would concatenate attempts).
578
717
  emittedText: false,
579
718
  };
580
719
  let sawFirstEvent = false;
581
- const deps = { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta };
720
+ // Leaked tool-call guard for the Responses text stream. Same recovery as
721
+ // the Chat path: leaked XML/harmony tool syntax in `output_text.delta` is
722
+ // suppressed from visible text, synthesized, and dispatched like native.
723
+ const leakGuard = createLeakGuard({ knownToolNames, harmony: true });
724
+ const leakedCalls = [];
725
+ const dispatchLeakedCall = (recovered) => {
726
+ const call = synthLeakedOpenAICall(recovered);
727
+ emitCompatToolCallOnce(state, call, onToolCall);
728
+ leakedCalls.push(call);
729
+ };
730
+ const relayLeakText = leakGuard.enabled
731
+ ? (delta) => {
732
+ const { text, calls } = leakGuard.push(delta);
733
+ if (text) {
734
+ state.content += text;
735
+ if (onTextDelta) {
736
+ state.emittedText = true;
737
+ try { onTextDelta(text); } catch {}
738
+ }
739
+ }
740
+ for (const c of calls) dispatchLeakedCall(c);
741
+ }
742
+ : null;
743
+ const flushLeak = () => {
744
+ if (!leakGuard.enabled) return;
745
+ const { text, calls } = leakGuard.flush();
746
+ if (text) {
747
+ state.content += text;
748
+ if (onTextDelta) {
749
+ state.emittedText = true;
750
+ try { onTextDelta(text); } catch {}
751
+ }
752
+ }
753
+ for (const c of calls) dispatchLeakedCall(c);
754
+ };
755
+ const deps = { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta, relayLeakText };
582
756
  try {
583
757
  while (true) {
584
758
  const { value: event, done } = await nextAsyncWithWatchdog(iterator, {
@@ -586,6 +760,9 @@ export async function consumeCompatResponsesStream(stream, {
586
760
  idleMs,
587
761
  idleEnabled: sawFirstEvent && idleEnabled,
588
762
  idleLabel: `${label} SSE idle`,
763
+ // Unsafe-to-retry once any tool call (native or recovered-leaked)
764
+ // has been emitted this stream — avoid a double side-effect.
765
+ emittedToolCall: () => state.emittedToolCall || leakedCalls.length > 0,
589
766
  });
590
767
  if (done) break;
591
768
  if (!sawFirstEvent) {
@@ -594,7 +771,21 @@ export async function consumeCompatResponsesStream(stream, {
594
771
  }
595
772
  handleCompatResponsesStreamEvent(event, state, deps);
596
773
  }
774
+ flushLeak();
597
775
  } catch (err) {
776
+ // Partial-final recovery: attach streamed partial state so a
777
+ // wedged FINAL no-tool summary can be accepted as partial-final success.
778
+ if (err?.streamStalled === true) {
779
+ try {
780
+ err.partialContent = state.content || '';
781
+ err.pendingToolUse = state.emittedToolCall === true
782
+ || leakedCalls.length > 0
783
+ || (state.pendingCalls && state.pendingCalls.size > 0)
784
+ || (Array.isArray(state.toolCalls) && state.toolCalls.length > 0)
785
+ || state.toolInFlight === true;
786
+ err.partialModel = state.model || undefined;
787
+ } catch { /* best-effort */ }
788
+ }
598
789
  throw markUnsafeRetryIfToolEmitted(err, state);
599
790
  } finally {
600
791
  firstByteTimeout.cleanup();
@@ -618,9 +809,14 @@ export async function consumeCompatResponsesStream(stream, {
618
809
  output_text: state.content,
619
810
  output: [],
620
811
  };
621
- const toolCalls = state.toolCalls.length
812
+ let toolCalls = state.toolCalls.length
622
813
  ? state.toolCalls.map(({ _pendingItemId, ...t }) => t)
623
814
  : parseResponsesToolCalls(response, label);
815
+ // Fold recovered leaked calls in (already emitted via onToolCall above).
816
+ // Dedupe by name+args so an identical native+synthetic pair can't run twice.
817
+ if (leakedCalls.length) {
818
+ toolCalls = dedupeToolCallList([...(Array.isArray(toolCalls) ? toolCalls : []), ...leakedCalls]);
819
+ }
624
820
  return {
625
821
  response,
626
822
  content: state.content || responseOutputText(response),
@@ -1,7 +1,8 @@
1
- import OpenAI from 'openai';
1
+ import { createRequire } from 'node:module';
2
2
  import { createHash } from 'crypto';
3
3
  import { loadConfig } from '../config.mjs';
4
4
  import { shouldFallbackTransport, withRetry } from './retry-classifier.mjs';
5
+ import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
5
6
  import { sendViaWebSocket } from './openai-oauth-ws.mjs';
6
7
  import {
7
8
  consumeCompatChatCompletionStream,
@@ -32,6 +33,17 @@ import {
32
33
  customToolCallFromResponseItem,
33
34
  } from './custom-tool-wire.mjs';
34
35
  import { OPENAI_COMPAT_PRESETS } from './openai-compat-presets.mjs';
36
+
37
+ const requireOpenAI = createRequire(import.meta.url);
38
+ let _OpenAI = null;
39
+
40
+ function loadOpenAI() {
41
+ if (!_OpenAI) {
42
+ const mod = requireOpenAI('openai');
43
+ _OpenAI = mod.default || mod.OpenAI || mod;
44
+ }
45
+ return _OpenAI;
46
+ }
35
47
  export { OPENAI_COMPAT_PRESETS } from './openai-compat-presets.mjs';
36
48
  const PRESETS = OPENAI_COMPAT_PRESETS;
37
49
  const MODEL_LIST_TIMEOUT_MS = resolveTimeoutMs(
@@ -970,6 +982,25 @@ function nativeResponsesTools(opts) {
970
982
  ? opts.nativeTools.filter(t => t && typeof t === 'object')
971
983
  : [];
972
984
  }
985
+ // Known tool-name sets for the leaked-tool-call guard, derived from the exact
986
+ // request body so a recovered leaked call is only synthesized when it names a
987
+ // tool the model was actually offered. Chat tools nest the name under
988
+ // `function.name`; Responses tools carry a top-level `name`.
989
+ function knownToolNamesFromOpenAITools(tools) {
990
+ return new Set(
991
+ (Array.isArray(tools) ? tools : [])
992
+ .map((t) => (typeof t?.function?.name === 'string' ? t.function.name
993
+ : typeof t?.name === 'string' ? t.name : null))
994
+ .filter(Boolean),
995
+ );
996
+ }
997
+ function knownToolNamesFromResponsesTools(tools) {
998
+ return new Set(
999
+ (Array.isArray(tools) ? tools : [])
1000
+ .map((t) => (typeof t?.name === 'string' ? t.name : null))
1001
+ .filter(Boolean),
1002
+ );
1003
+ }
973
1004
  export function parseToolCalls(choice, label) {
974
1005
  const calls = choice.message?.tool_calls;
975
1006
  if (!calls?.length)
@@ -1001,12 +1032,15 @@ export function parseResponsesToolCalls(response, label) {
1001
1032
  const call = customToolCallFromResponseItem(item);
1002
1033
  if (call) out.push(call);
1003
1034
  } else if (item?.type === 'tool_search_call') {
1035
+ const _tsArgs = item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments)
1036
+ ? item.arguments
1037
+ : parseCompletedToolCallArgumentsJson(item.arguments || '{}', label, { id: item.call_id || item.id, name: 'tool_search', finishReason });
1004
1038
  out.push({
1005
1039
  id: item.call_id || item.id,
1006
1040
  name: 'tool_search',
1007
- arguments: item.arguments && typeof item.arguments === 'object'
1008
- ? item.arguments
1009
- : parseCompletedToolCallArgumentsJson(item.arguments || '{}', label, { id: item.call_id || item.id, name: 'tool_search', finishReason }),
1041
+ // Schema is a plain object ({query,select,limit}); an array
1042
+ // (parsed JSON or passthrough) must never pass through as args.
1043
+ arguments: (_tsArgs && typeof _tsArgs === 'object' && !Array.isArray(_tsArgs)) ? _tsArgs : {},
1010
1044
  nativeType: 'tool_search_call',
1011
1045
  });
1012
1046
  }
@@ -1198,10 +1232,20 @@ export class OpenAICompatProvider {
1198
1232
  // extraHeaders behave exactly as before.
1199
1233
  this.defaultHeaders = { ...(preset?.extraHeaders || {}), ...(config.extraHeaders || {}) };
1200
1234
  this.defaultModel = preset?.defaultModel || 'default';
1201
- this.client = new OpenAI({
1235
+ this.client = new (loadOpenAI())({
1202
1236
  baseURL,
1203
1237
  apiKey,
1204
1238
  defaultHeaders: this.defaultHeaders,
1239
+ // The SDK's own retry loop (default 2) would nest underneath our
1240
+ // withRetry wrapper and multiply tail latency on a transient
1241
+ // backend. We own retry/backoff via withRetry, so disable the SDK's.
1242
+ maxRetries: 0,
1243
+ // Force the shared long-keepalive undici dispatcher to be installed
1244
+ // globally (setGlobalDispatcher) so the SDK's global fetch rides a
1245
+ // warm socket pool instead of Node's short-keepalive default. The
1246
+ // return value is undefined once installed globally; the option is
1247
+ // a harmless no-op then.
1248
+ fetchOptions: { dispatcher: getLlmDispatcher() },
1205
1249
  });
1206
1250
  }
1207
1251
  reloadApiKey() {
@@ -1216,10 +1260,12 @@ export class OpenAICompatProvider {
1216
1260
  this.baseURL = baseURL;
1217
1261
  this.apiKey = newKey;
1218
1262
  this.defaultHeaders = { ...(preset?.extraHeaders || {}), ...(this.config.extraHeaders || {}) };
1219
- this.client = new OpenAI({
1263
+ this.client = new (loadOpenAI())({
1220
1264
  baseURL,
1221
1265
  apiKey: newKey,
1222
1266
  defaultHeaders: this.defaultHeaders,
1267
+ maxRetries: 0,
1268
+ fetchOptions: { dispatcher: getLlmDispatcher() },
1223
1269
  });
1224
1270
  }
1225
1271
  } catch { /* best effort */ }
@@ -1239,6 +1285,10 @@ export class OpenAICompatProvider {
1239
1285
  async _doSend(messages, model, tools, sendOpts) {
1240
1286
  const useModel = model || this.defaultModel;
1241
1287
  const opts = sendOpts || {};
1288
+ // Re-warm a kept-alive socket to the provider origin before the turn so
1289
+ // the request hot path lands on a live socket instead of paying a cold
1290
+ // TLS handshake after an idle gap. Fire-and-forget; never awaited.
1291
+ preconnect(this.baseURL);
1242
1292
  if (this.name === 'xai' && useXaiResponsesApi(opts, this.config)) {
1243
1293
  if (useXaiResponsesWebSocket(opts, this.config)) {
1244
1294
  try {
@@ -1339,6 +1389,11 @@ export class OpenAICompatProvider {
1339
1389
  ({ signal: openSignal }) => this.client.chat.completions.create(params, { signal: openSignal }),
1340
1390
  {
1341
1391
  signal: attemptSignal,
1392
+ // Single attempt: this inner wrapper exists only to
1393
+ // apply the first-byte per-attempt timeout. Retry is
1394
+ // owned by the outer withRetry — nesting retry loops
1395
+ // here multiplied tail latency (5x5).
1396
+ maxAttempts: 1,
1342
1397
  perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
1343
1398
  perAttemptLabel: `${this.name} first byte`,
1344
1399
  },
@@ -1351,6 +1406,10 @@ export class OpenAICompatProvider {
1351
1406
  onToolCall: opts.onToolCall,
1352
1407
  onTextDelta: opts.onTextDelta,
1353
1408
  parseToolCalls,
1409
+ // Known tool names for the leaked-tool-call guard:
1410
+ // recovered leaked calls only synthesize when they name
1411
+ // a tool actually offered to this request.
1412
+ knownToolNames: knownToolNamesFromOpenAITools(params.tools),
1354
1413
  });
1355
1414
  },
1356
1415
  {
@@ -1434,6 +1493,13 @@ export class OpenAICompatProvider {
1434
1493
  model: response.model || useModel,
1435
1494
  toolCalls,
1436
1495
  stopReason,
1496
+ // P1 audit fix: a text-only completion that hit finish_reason=
1497
+ // 'length' (no tool calls, so not thrown above as
1498
+ // ProviderIncompleteError) previously returned as an ordinary
1499
+ // success with no signal that the content is a mid-sentence
1500
+ // cutoff. Flag it so loop.mjs can surface a one-line warning
1501
+ // instead of silently treating a truncated answer as complete.
1502
+ ...(stopReason === 'length' && (assembled.content || '').length > 0 ? { truncated: true } : {}),
1437
1503
  ...(reasoningContent ? { reasoningContent } : {}),
1438
1504
  usage: response.usage ? (() => {
1439
1505
  const input = response.usage.prompt_tokens ?? response.usage.input_tokens ?? 0;
@@ -1516,6 +1582,9 @@ export class OpenAICompatProvider {
1516
1582
  ({ signal: openSignal }) => this.client.responses.create(params, { signal: openSignal }),
1517
1583
  {
1518
1584
  signal: attemptSignal,
1585
+ // Single attempt: first-byte timeout only; retry
1586
+ // is owned by the outer withRetry (see chat path).
1587
+ maxAttempts: 1,
1519
1588
  perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
1520
1589
  perAttemptLabel: 'xai responses first byte',
1521
1590
  },
@@ -1529,6 +1598,7 @@ export class OpenAICompatProvider {
1529
1598
  onTextDelta: opts.onTextDelta,
1530
1599
  parseResponsesToolCalls,
1531
1600
  responseOutputText,
1601
+ knownToolNames: knownToolNamesFromResponsesTools(params.tools),
1532
1602
  });
1533
1603
  },
1534
1604
  {
@@ -1598,6 +1668,10 @@ export class OpenAICompatProvider {
1598
1668
  model: response.model || useModel,
1599
1669
  toolCalls,
1600
1670
  stopReason: streamed.stopReason || null,
1671
+ // P1 audit fix: mirror the chat-completions truncated flag for
1672
+ // the xAI Responses HTTP path — a max-output cutoff with real
1673
+ // content must not look identical to a clean stop.
1674
+ ...(streamed.stopReason === 'length' && (streamed.content || '').length > 0 ? { truncated: true } : {}),
1601
1675
  citations: searchSources.citations.length ? searchSources.citations : undefined,
1602
1676
  webSearchCalls: searchSources.webSearchCalls.length ? searchSources.webSearchCalls : undefined,
1603
1677
  providerState: {
@@ -1753,6 +1827,9 @@ export class OpenAICompatProvider {
1753
1827
  model: result.model || useModel,
1754
1828
  toolCalls: result.toolCalls,
1755
1829
  stopReason: result.stopReason || null,
1830
+ // P1 audit fix: same truncated signal as the HTTP path (see
1831
+ // _doSendXaiResponses above) for the WebSocket transport.
1832
+ ...(result.stopReason === 'length' && (result.content || '').length > 0 ? { truncated: true } : {}),
1756
1833
  providerState: {
1757
1834
  ...(opts.providerState || {}),
1758
1835
  xaiResponses: {