mixdog 0.9.1 → 0.9.2

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 (214) hide show
  1. package/package.json +8 -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/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. 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(
@@ -87,17 +103,29 @@ function firstByteCompatStreamError(label) {
87
103
  return err;
88
104
  }
89
105
 
90
- async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, idleLabel } = {}) {
106
+ async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, idleLabel, emittedToolCall } = {}) {
91
107
  let idleTimer = null;
92
108
  let idleReject = null;
93
109
  let idleTimedOut = false;
110
+ // Double-dispatch guard (reviewer High): if a tool call was already emitted
111
+ // this stream, a stall must be unsafe-to-retry so withRetry() won't replay
112
+ // the turn and re-run the side-effecting tool. `emittedToolCall` may be a
113
+ // boolean or a getter evaluated at abort time (state mutates mid-stream).
114
+ const didEmitToolCall = () => {
115
+ try { return typeof emittedToolCall === 'function' ? !!emittedToolCall() : !!emittedToolCall; }
116
+ catch { return false; }
117
+ };
94
118
  const armIdle = () => {
95
119
  if (!idleEnabled || !(idleMs > 0)) return;
96
120
  if (idleTimer) clearTimeout(idleTimer);
97
121
  idleTimer = setTimeout(() => {
98
122
  idleTimedOut = true;
99
- const e = providerTimeoutError(idleLabel || 'compat SSE idle', idleMs);
100
- e.code = 'ETIMEDOUT';
123
+ // SEMANTIC idle abort: this timer is (re)armed only around waiting
124
+ // for the NEXT stream event, so keepalive/comment frames the SDK
125
+ // filters out cannot keep it alive. Throw the named terminal
126
+ // StreamStalledError so the retry-classifier treats it as a stream
127
+ // failure (owner gets notified) rather than a user cancel.
128
+ const e = streamStalledError(idleLabel || 'compat SSE', idleMs, { emittedToolCall: didEmitToolCall() });
101
129
  if (idleReject) {
102
130
  const r = idleReject;
103
131
  idleReject = null;
@@ -143,7 +171,7 @@ async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, id
143
171
  return result;
144
172
  } catch (err) {
145
173
  if (idleTimer) clearTimeout(idleTimer);
146
- if (idleTimedOut) throw providerTimeoutError(idleLabel || 'compat SSE idle', idleMs);
174
+ if (idleTimedOut) throw streamStalledError(idleLabel || 'compat SSE', idleMs, { emittedToolCall: didEmitToolCall() });
147
175
  throw err;
148
176
  }
149
177
  }
@@ -213,6 +241,15 @@ function emitCompatToolCallOnce(state, call, onToolCall) {
213
241
  const key = `id:${call.id}`;
214
242
  if (!state.emittedToolCallKeys) state.emittedToolCallKeys = new Set();
215
243
  if (state.emittedToolCallKeys.has(key)) return false;
244
+ // Fix 2: cross-path name+args dedupe. A synthesized text-leaked call and an
245
+ // identical native tool_call must fire onToolCall exactly once. state._toolDedupe
246
+ // is created per stream; when absent (older callers) behavior is unchanged.
247
+ if (state._toolDedupe && !state._toolDedupe.shouldDispatch(call.name, call.arguments)) {
248
+ // Still mark the id as emitted so later id-frames for the same native
249
+ // call don't retry, but do NOT invoke onToolCall (already dispatched).
250
+ state.emittedToolCallKeys.add(key);
251
+ return false;
252
+ }
216
253
  state.emittedToolCallKeys.add(key);
217
254
  state.emittedToolCall = true;
218
255
  const { _pendingItemId, ...cleanCall } = call;
@@ -257,11 +294,14 @@ function isMaxOutputIncompleteReason(reason) {
257
294
  return /^(?:max_output_tokens|max_tokens|length|output_token_limit)$/i.test(String(reason || '').trim());
258
295
  }
259
296
 
260
- export async function consumeCompatChatCompletionStream(stream, { signal, label, onStreamDelta, onToolCall, onTextDelta, parseToolCalls } = {}) {
297
+ export async function consumeCompatChatCompletionStream(stream, { signal, label, onStreamDelta, onToolCall, onTextDelta, parseToolCalls, knownToolNames } = {}) {
261
298
  const iterator = stream[Symbol.asyncIterator]();
262
299
  const firstByteTimeout = createTimeoutSignal(signal, PROVIDER_FIRST_BYTE_TIMEOUT_MS, `${label} first byte`);
263
300
  const idleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED;
264
- const idleMs = PROVIDER_SSE_IDLE_TIMEOUT_MS;
301
+ // Per-event (last-event-relative) SEMANTIC idle: nextAsyncWithWatchdog arms
302
+ // the timer only while awaiting the NEXT stream event, so a stream that
303
+ // emits some deltas then goes silent trips it within the window.
304
+ const idleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
265
305
  let sawFirstEvent = false;
266
306
  let content = '';
267
307
  let reasoningContent = '';
@@ -276,6 +316,46 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
276
316
  let rawUsage = null;
277
317
  const toolAcc = new Map();
278
318
  const toolBucketState = { _orderSeq: 0, _nextAnonId: 0, _lastAnonKey: null };
319
+ // Fix 2: one dedupe per stream, shared by the synthetic leaked-call
320
+ // dispatch and every native emit so an identical (name,args) fires once.
321
+ const _toolDedupe = createToolCallDedupe();
322
+ // Leaked tool-call guard: the model sometimes emits a tool call as plain
323
+ // text (XML `<invoke>`/`<function_calls>` or gpt-oss harmony
324
+ // `<|channel|>...to=functions.NAME...<|call|>`) inside `delta.content`
325
+ // instead of a native `tool_calls` delta. Route content through the guard
326
+ // so leaked calls are suppressed from visible text, synthesized, and
327
+ // dispatched like native calls. Additive: the native tool_calls path is
328
+ // untouched. Harmony detection is opt-in here (gpt-oss compat backends).
329
+ const leakGuard = createLeakGuard({ knownToolNames, harmony: true });
330
+ const dispatchLeakedCall = (recovered) => {
331
+ const call = synthLeakedOpenAICall(recovered);
332
+ const emitState = { emittedToolCallKeys: new Set(), _toolDedupe };
333
+ emitCompatToolCallOnce(emitState, call, onToolCall);
334
+ return call;
335
+ };
336
+ const leakedCalls = [];
337
+ const relayText = (delta) => {
338
+ const { text, calls } = leakGuard.push(delta);
339
+ if (text) {
340
+ content += text;
341
+ if (onTextDelta) {
342
+ emittedText = true;
343
+ try { onTextDelta(text); } catch {}
344
+ }
345
+ }
346
+ for (const c of calls) leakedCalls.push(dispatchLeakedCall(c));
347
+ };
348
+ const flushLeak = () => {
349
+ const { text, calls } = leakGuard.flush();
350
+ if (text) {
351
+ content += text;
352
+ if (onTextDelta) {
353
+ emittedText = true;
354
+ try { onTextDelta(text); } catch {}
355
+ }
356
+ }
357
+ for (const c of calls) leakedCalls.push(dispatchLeakedCall(c));
358
+ };
279
359
  try {
280
360
  while (true) {
281
361
  const { value: chunk, done } = await nextAsyncWithWatchdog(iterator, {
@@ -285,6 +365,9 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
285
365
  idleMs,
286
366
  idleEnabled: sawFirstEvent && idleEnabled,
287
367
  idleLabel: `${label} SSE idle`,
368
+ // A stall after a tool call has already been dispatched (native
369
+ // or recovered-leaked) must be unsafe-to-retry (no double-run).
370
+ emittedToolCall: () => leakedCalls.length > 0 || toolAcc.size > 0,
288
371
  });
289
372
  if (done) break;
290
373
  if (!sawFirstEvent) {
@@ -296,12 +379,18 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
296
379
  if (chunk?.model) model = chunk.model;
297
380
  const choice = chunk?.choices?.[0];
298
381
  if (choice?.delta?.content) {
299
- content += choice.delta.content;
300
- // Live text relay (gateway): explicit assistant text delta.
382
+ // Live text relay (gateway): explicit assistant text delta,
383
+ // routed through the leaked-tool-call guard (which appends to
384
+ // `content`, forwards visible text, and recovers leaked calls).
301
385
  // reasoning_content + tool_calls deltas stay off this path.
302
- if (onTextDelta) {
303
- emittedText = true;
304
- try { onTextDelta(choice.delta.content); } catch {}
386
+ if (leakGuard.enabled) {
387
+ relayText(choice.delta.content);
388
+ } else {
389
+ content += choice.delta.content;
390
+ if (onTextDelta) {
391
+ emittedText = true;
392
+ try { onTextDelta(choice.delta.content); } catch {}
393
+ }
305
394
  }
306
395
  }
307
396
  if (typeof choice?.delta?.reasoning_content === 'string') {
@@ -311,9 +400,23 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
311
400
  if (choice?.finish_reason) stopReason = choice.finish_reason;
312
401
  if (chunk?.usage) rawUsage = chunk.usage;
313
402
  }
403
+ // Flush any partial-sentinel tail held back mid-stream so legitimate
404
+ // trailing text is never lost.
405
+ if (leakGuard.enabled) flushLeak();
314
406
  } catch (err) {
315
407
  // Any mid-stream failure after live text was relayed is non-retryable.
316
408
  if (emittedText) throw markErrorLiveTextEmitted(err);
409
+ // Partial-final recovery parity: on a mid-stream stall, attach the
410
+ // streamed partial state so the loop can accept a wedged FINAL no-tool
411
+ // summary as partial-final success. pendingToolUse gates out any
412
+ // in-flight/emitted tool call.
413
+ if (err?.streamStalled === true) {
414
+ try {
415
+ err.partialContent = content;
416
+ err.pendingToolUse = toolAcc.size > 0 || leakedCalls.length > 0;
417
+ err.partialModel = model || undefined;
418
+ } catch { /* best-effort */ }
419
+ }
317
420
  throw err;
318
421
  } finally {
319
422
  firstByteTimeout.cleanup();
@@ -356,9 +459,18 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
356
459
  throw err;
357
460
  }
358
461
  if (Array.isArray(toolCalls) && toolCalls.length) {
359
- const emitState = { emittedToolCallKeys: new Set() };
462
+ const emitState = { emittedToolCallKeys: new Set(), _toolDedupe };
360
463
  for (const call of toolCalls) emitCompatToolCallOnce(emitState, call, onToolCall);
361
464
  }
465
+ // Fold recovered leaked calls into the returned toolCalls so the dispatch
466
+ // loop treats them exactly like native ones. They were already emitted via
467
+ // onToolCall in relayText/flushLeak, so no re-dispatch here. Dedupe the
468
+ // final array by name+args (Fix 2, array side): a synthetic leaked call and
469
+ // an identical native tool_call must not both remain, else the loop runs
470
+ // the side-effecting tool twice.
471
+ if (leakedCalls.length) {
472
+ toolCalls = dedupeToolCallList([...(Array.isArray(toolCalls) ? toolCalls : []), ...leakedCalls]);
473
+ }
362
474
  return {
363
475
  response,
364
476
  model,
@@ -370,7 +482,7 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
370
482
  };
371
483
  }
372
484
 
373
- function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta }) {
485
+ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta, relayLeakText }) {
374
486
  if (!event || typeof event.type !== 'string') return;
375
487
  const pushToolSearchCall = (item) => {
376
488
  if (!item || item.type !== 'tool_search_call') return;
@@ -399,12 +511,17 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
399
511
  if (event.response?.id) state.responseId = event.response.id;
400
512
  break;
401
513
  case 'response.output_text.delta':
402
- state.content += event.delta || '';
403
514
  state.sawOutput = true;
404
515
  try { onStreamDelta?.(); } catch {}
405
- if (event.delta && onTextDelta) {
406
- state.emittedText = true;
407
- try { onTextDelta(event.delta); } catch {}
516
+ // Route assistant text through the leaked-tool-call guard (appends
517
+ // to state.content, forwards visible text, recovers leaked calls).
518
+ if (relayLeakText) relayLeakText(event.delta || '');
519
+ else {
520
+ state.content += event.delta || '';
521
+ if (event.delta && onTextDelta) {
522
+ state.emittedText = true;
523
+ try { onTextDelta(event.delta); } catch {}
524
+ }
408
525
  }
409
526
  break;
410
527
  case 'response.output_item.added':
@@ -413,13 +530,23 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
413
530
  name: event.item.name || '',
414
531
  callId: event.item.call_id || '',
415
532
  });
533
+ state.toolInFlight = true;
534
+ } else if (event.item?.type === 'custom_tool_call') {
535
+ state.toolInFlight = true;
416
536
  }
417
537
  try { onStreamDelta?.(); } catch {}
418
538
  break;
419
539
  case 'response.function_call_arguments.delta':
540
+ // A tool call's args are streaming — mark tool work in-flight so a
541
+ // mid-args stall is NEVER accepted as a text-only partial-final.
542
+ state.toolInFlight = true;
420
543
  try { onStreamDelta?.(); } catch {}
421
544
  break;
422
545
  case 'response.custom_tool_call_input.delta':
546
+ // Custom-tool input streams before output_item.done records the call
547
+ // in pendingCalls; flag it so a mid-input stall gates out partial-
548
+ // final success (otherwise a tool-bearing turn looks text-only).
549
+ state.toolInFlight = true;
423
550
  try { onStreamDelta?.(); } catch {}
424
551
  break;
425
552
  case 'response.function_call_arguments.done': {
@@ -555,11 +682,13 @@ export async function consumeCompatResponsesStream(stream, {
555
682
  onTextDelta,
556
683
  parseResponsesToolCalls,
557
684
  responseOutputText,
685
+ knownToolNames,
558
686
  } = {}) {
559
687
  const iterator = stream[Symbol.asyncIterator]();
560
688
  const firstByteTimeout = createTimeoutSignal(signal, PROVIDER_FIRST_BYTE_TIMEOUT_MS, `${label} first byte`);
561
689
  const idleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED;
562
- const idleMs = PROVIDER_SSE_IDLE_TIMEOUT_MS;
690
+ // Per-event (last-event-relative) SEMANTIC idle — see the Chat path note.
691
+ const idleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
563
692
  const state = {
564
693
  content: '',
565
694
  model: '',
@@ -572,13 +701,51 @@ export async function consumeCompatResponsesStream(stream, {
572
701
  completed: false,
573
702
  completedResponse: null,
574
703
  sawOutput: false,
704
+ // Fix 2: cross-path name+args dedupe shared by synthetic leaked-call
705
+ // dispatch and every native emit in this Responses stream.
706
+ _toolDedupe: createToolCallDedupe(),
575
707
  // Gateway live-text relay invariant: set once a non-empty text chunk
576
708
  // has been forwarded. A later failure is non-retryable (rendered text
577
709
  // cannot be withdrawn; a retry would concatenate attempts).
578
710
  emittedText: false,
579
711
  };
580
712
  let sawFirstEvent = false;
581
- const deps = { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta };
713
+ // Leaked tool-call guard for the Responses text stream. Same recovery as
714
+ // the Chat path: leaked XML/harmony tool syntax in `output_text.delta` is
715
+ // suppressed from visible text, synthesized, and dispatched like native.
716
+ const leakGuard = createLeakGuard({ knownToolNames, harmony: true });
717
+ const leakedCalls = [];
718
+ const dispatchLeakedCall = (recovered) => {
719
+ const call = synthLeakedOpenAICall(recovered);
720
+ emitCompatToolCallOnce(state, call, onToolCall);
721
+ leakedCalls.push(call);
722
+ };
723
+ const relayLeakText = leakGuard.enabled
724
+ ? (delta) => {
725
+ const { text, calls } = leakGuard.push(delta);
726
+ if (text) {
727
+ state.content += text;
728
+ if (onTextDelta) {
729
+ state.emittedText = true;
730
+ try { onTextDelta(text); } catch {}
731
+ }
732
+ }
733
+ for (const c of calls) dispatchLeakedCall(c);
734
+ }
735
+ : null;
736
+ const flushLeak = () => {
737
+ if (!leakGuard.enabled) return;
738
+ const { text, calls } = leakGuard.flush();
739
+ if (text) {
740
+ state.content += text;
741
+ if (onTextDelta) {
742
+ state.emittedText = true;
743
+ try { onTextDelta(text); } catch {}
744
+ }
745
+ }
746
+ for (const c of calls) dispatchLeakedCall(c);
747
+ };
748
+ const deps = { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta, relayLeakText };
582
749
  try {
583
750
  while (true) {
584
751
  const { value: event, done } = await nextAsyncWithWatchdog(iterator, {
@@ -586,6 +753,9 @@ export async function consumeCompatResponsesStream(stream, {
586
753
  idleMs,
587
754
  idleEnabled: sawFirstEvent && idleEnabled,
588
755
  idleLabel: `${label} SSE idle`,
756
+ // Unsafe-to-retry once any tool call (native or recovered-leaked)
757
+ // has been emitted this stream — avoid a double side-effect.
758
+ emittedToolCall: () => state.emittedToolCall || leakedCalls.length > 0,
589
759
  });
590
760
  if (done) break;
591
761
  if (!sawFirstEvent) {
@@ -594,7 +764,21 @@ export async function consumeCompatResponsesStream(stream, {
594
764
  }
595
765
  handleCompatResponsesStreamEvent(event, state, deps);
596
766
  }
767
+ flushLeak();
597
768
  } catch (err) {
769
+ // Partial-final recovery parity: attach streamed partial state so a
770
+ // wedged FINAL no-tool summary can be accepted as partial-final success.
771
+ if (err?.streamStalled === true) {
772
+ try {
773
+ err.partialContent = state.content || '';
774
+ err.pendingToolUse = state.emittedToolCall === true
775
+ || leakedCalls.length > 0
776
+ || (state.pendingCalls && state.pendingCalls.size > 0)
777
+ || (Array.isArray(state.toolCalls) && state.toolCalls.length > 0)
778
+ || state.toolInFlight === true;
779
+ err.partialModel = state.model || undefined;
780
+ } catch { /* best-effort */ }
781
+ }
598
782
  throw markUnsafeRetryIfToolEmitted(err, state);
599
783
  } finally {
600
784
  firstByteTimeout.cleanup();
@@ -618,9 +802,14 @@ export async function consumeCompatResponsesStream(stream, {
618
802
  output_text: state.content,
619
803
  output: [],
620
804
  };
621
- const toolCalls = state.toolCalls.length
805
+ let toolCalls = state.toolCalls.length
622
806
  ? state.toolCalls.map(({ _pendingItemId, ...t }) => t)
623
807
  : parseResponsesToolCalls(response, label);
808
+ // Fold recovered leaked calls in (already emitted via onToolCall above).
809
+ // Dedupe by name+args so an identical native+synthetic pair can't run twice.
810
+ if (leakedCalls.length) {
811
+ toolCalls = dedupeToolCallList([...(Array.isArray(toolCalls) ? toolCalls : []), ...leakedCalls]);
812
+ }
624
813
  return {
625
814
  response,
626
815
  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)
@@ -1198,10 +1229,20 @@ export class OpenAICompatProvider {
1198
1229
  // extraHeaders behave exactly as before.
1199
1230
  this.defaultHeaders = { ...(preset?.extraHeaders || {}), ...(config.extraHeaders || {}) };
1200
1231
  this.defaultModel = preset?.defaultModel || 'default';
1201
- this.client = new OpenAI({
1232
+ this.client = new (loadOpenAI())({
1202
1233
  baseURL,
1203
1234
  apiKey,
1204
1235
  defaultHeaders: this.defaultHeaders,
1236
+ // The SDK's own retry loop (default 2) would nest underneath our
1237
+ // withRetry wrapper and multiply tail latency on a transient
1238
+ // backend. We own retry/backoff via withRetry, so disable the SDK's.
1239
+ maxRetries: 0,
1240
+ // Force the shared long-keepalive undici dispatcher to be installed
1241
+ // globally (setGlobalDispatcher) so the SDK's global fetch rides a
1242
+ // warm socket pool instead of Node's short-keepalive default. The
1243
+ // return value is undefined once installed globally; the option is
1244
+ // a harmless no-op then.
1245
+ fetchOptions: { dispatcher: getLlmDispatcher() },
1205
1246
  });
1206
1247
  }
1207
1248
  reloadApiKey() {
@@ -1216,10 +1257,12 @@ export class OpenAICompatProvider {
1216
1257
  this.baseURL = baseURL;
1217
1258
  this.apiKey = newKey;
1218
1259
  this.defaultHeaders = { ...(preset?.extraHeaders || {}), ...(this.config.extraHeaders || {}) };
1219
- this.client = new OpenAI({
1260
+ this.client = new (loadOpenAI())({
1220
1261
  baseURL,
1221
1262
  apiKey: newKey,
1222
1263
  defaultHeaders: this.defaultHeaders,
1264
+ maxRetries: 0,
1265
+ fetchOptions: { dispatcher: getLlmDispatcher() },
1223
1266
  });
1224
1267
  }
1225
1268
  } catch { /* best effort */ }
@@ -1239,6 +1282,11 @@ export class OpenAICompatProvider {
1239
1282
  async _doSend(messages, model, tools, sendOpts) {
1240
1283
  const useModel = model || this.defaultModel;
1241
1284
  const opts = sendOpts || {};
1285
+ // Re-warm a kept-alive socket to the provider origin before the turn so
1286
+ // the request hot path lands on a live socket instead of paying a cold
1287
+ // TLS handshake after an idle gap. Fire-and-forget; never awaited. This
1288
+ // mirrors anthropic-oauth's send()-start preconnect.
1289
+ preconnect(this.baseURL);
1242
1290
  if (this.name === 'xai' && useXaiResponsesApi(opts, this.config)) {
1243
1291
  if (useXaiResponsesWebSocket(opts, this.config)) {
1244
1292
  try {
@@ -1339,6 +1387,11 @@ export class OpenAICompatProvider {
1339
1387
  ({ signal: openSignal }) => this.client.chat.completions.create(params, { signal: openSignal }),
1340
1388
  {
1341
1389
  signal: attemptSignal,
1390
+ // Single attempt: this inner wrapper exists only to
1391
+ // apply the first-byte per-attempt timeout. Retry is
1392
+ // owned by the outer withRetry — nesting retry loops
1393
+ // here multiplied tail latency (5x5).
1394
+ maxAttempts: 1,
1342
1395
  perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
1343
1396
  perAttemptLabel: `${this.name} first byte`,
1344
1397
  },
@@ -1351,6 +1404,10 @@ export class OpenAICompatProvider {
1351
1404
  onToolCall: opts.onToolCall,
1352
1405
  onTextDelta: opts.onTextDelta,
1353
1406
  parseToolCalls,
1407
+ // Known tool names for the leaked-tool-call guard:
1408
+ // recovered leaked calls only synthesize when they name
1409
+ // a tool actually offered to this request.
1410
+ knownToolNames: knownToolNamesFromOpenAITools(params.tools),
1354
1411
  });
1355
1412
  },
1356
1413
  {
@@ -1434,6 +1491,13 @@ export class OpenAICompatProvider {
1434
1491
  model: response.model || useModel,
1435
1492
  toolCalls,
1436
1493
  stopReason,
1494
+ // P1 audit fix: a text-only completion that hit finish_reason=
1495
+ // 'length' (no tool calls, so not thrown above as
1496
+ // ProviderIncompleteError) previously returned as an ordinary
1497
+ // success with no signal that the content is a mid-sentence
1498
+ // cutoff. Flag it so loop.mjs can surface a one-line warning
1499
+ // instead of silently treating a truncated answer as complete.
1500
+ ...(stopReason === 'length' && (assembled.content || '').length > 0 ? { truncated: true } : {}),
1437
1501
  ...(reasoningContent ? { reasoningContent } : {}),
1438
1502
  usage: response.usage ? (() => {
1439
1503
  const input = response.usage.prompt_tokens ?? response.usage.input_tokens ?? 0;
@@ -1516,6 +1580,9 @@ export class OpenAICompatProvider {
1516
1580
  ({ signal: openSignal }) => this.client.responses.create(params, { signal: openSignal }),
1517
1581
  {
1518
1582
  signal: attemptSignal,
1583
+ // Single attempt: first-byte timeout only; retry
1584
+ // is owned by the outer withRetry (see chat path).
1585
+ maxAttempts: 1,
1519
1586
  perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
1520
1587
  perAttemptLabel: 'xai responses first byte',
1521
1588
  },
@@ -1529,6 +1596,7 @@ export class OpenAICompatProvider {
1529
1596
  onTextDelta: opts.onTextDelta,
1530
1597
  parseResponsesToolCalls,
1531
1598
  responseOutputText,
1599
+ knownToolNames: knownToolNamesFromResponsesTools(params.tools),
1532
1600
  });
1533
1601
  },
1534
1602
  {
@@ -1598,6 +1666,10 @@ export class OpenAICompatProvider {
1598
1666
  model: response.model || useModel,
1599
1667
  toolCalls,
1600
1668
  stopReason: streamed.stopReason || null,
1669
+ // P1 audit fix: mirror the chat-completions truncated flag for
1670
+ // the xAI Responses HTTP path — a max-output cutoff with real
1671
+ // content must not look identical to a clean stop.
1672
+ ...(streamed.stopReason === 'length' && (streamed.content || '').length > 0 ? { truncated: true } : {}),
1601
1673
  citations: searchSources.citations.length ? searchSources.citations : undefined,
1602
1674
  webSearchCalls: searchSources.webSearchCalls.length ? searchSources.webSearchCalls : undefined,
1603
1675
  providerState: {
@@ -1753,6 +1825,9 @@ export class OpenAICompatProvider {
1753
1825
  model: result.model || useModel,
1754
1826
  toolCalls: result.toolCalls,
1755
1827
  stopReason: result.stopReason || null,
1828
+ // P1 audit fix: same truncated signal as the HTTP path (see
1829
+ // _doSendXaiResponses above) for the WebSocket transport.
1830
+ ...(result.stopReason === 'length' && (result.content || '').length > 0 ? { truncated: true } : {}),
1756
1831
  providerState: {
1757
1832
  ...(opts.providerState || {}),
1758
1833
  xaiResponses: {