mixdog 0.9.0 → 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 (240) hide show
  1. package/package.json +10 -3
  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/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -4,7 +4,7 @@
4
4
  * Single dispatch path for the openai-oauth provider (SSE removed in
5
5
  * v0.6.117). Uses the `responses_websockets=2026-02-06` beta WebSocket
6
6
  * upgrade on chatgpt.com/backend-api/codex/responses. Per-session
7
- * connections are pooled (5 min idle TTL, up to 8 parallel sockets per
7
+ * connections are pooled (configurable idle TTL, up to 8 parallel sockets per
8
8
  * key) so subsequent tool-loop iterations can send only the incremental
9
9
  * `input` delta plus `previous_response_id`, skipping the full
10
10
  * tools/system/history prefix each turn.
@@ -33,14 +33,26 @@ import {
33
33
  traceAgentUsage,
34
34
  appendAgentTrace,
35
35
  } from '../agent-trace.mjs';
36
- import { jitterDelayMs, populateHttpStatusFromMessage } from './retry-classifier.mjs';
36
+ import {
37
+ classifyHandshakeError,
38
+ classifyMidstreamError,
39
+ createStreamSafetyStamps,
40
+ jitterDelayMs,
41
+ MIDSTREAM_RETRY_POLICY,
42
+ populateHttpStatusFromMessage,
43
+ sleepWithAbort,
44
+ } from './retry-classifier.mjs';
37
45
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
46
+ import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
38
47
  import {
39
48
  PROVIDER_RETRY_MAX_ATTEMPTS,
40
49
  PROVIDER_WS_ACQUIRE_TIMEOUT_MS,
41
- PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS,
42
50
  PROVIDER_WS_HANDSHAKE_TIMEOUT_MS,
43
51
  PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS,
52
+ PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
53
+ PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
54
+ streamStalledError,
55
+ resolveTimeoutMs,
44
56
  } from '../stall-policy.mjs';
45
57
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
46
58
 
@@ -50,17 +62,19 @@ const CODEX_WS_URL = 'wss://chatgpt.com/backend-api/codex/responses';
50
62
  const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
51
63
  const OPENAI_WS_URL = 'wss://api.openai.com/v1/responses';
52
64
  const XAI_WS_URL = 'wss://api.x.ai/v1/responses';
53
- const WS_IDLE_MS = 5 * 60_000;
65
+ const WS_IDLE_MS = resolveTimeoutMs(
66
+ 'MIXDOG_PROVIDER_WS_IDLE_MS',
67
+ 20 * 60_000,
68
+ { minMs: 60_000, maxMs: 60 * 60_000 },
69
+ );
54
70
  const WS_HANDSHAKE_TIMEOUT_MS = PROVIDER_WS_HANDSHAKE_TIMEOUT_MS;
55
71
  const WS_ACQUIRE_TIMEOUT_MS = PROVIDER_WS_ACQUIRE_TIMEOUT_MS;
56
- // Pre-stream watchdog uses the shared provider deadline so it fails before
57
- // the 5-minute session slow warning.
58
- const WS_FIRST_MEANINGFUL_MS = PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS;
59
72
  // Pre-`response.created` deadline. Once the socket is open and the
60
73
  // response.create frame is sent, a healthy server emits response.created
61
74
  // within seconds. If it stalls past this short bound the socket has wedged
62
75
  // post-upgrade with zero server events — treat it as a fast, retryable
63
- // first-byte timeout rather than waiting the longer first-meaningful window.
76
+ // first-byte timeout. This is the ONLY pre-stream watchdog; once any server
77
+ // event arrives the single inter-chunk idle timer below takes over.
64
78
  // Only this short window is shortened; the post-`response.created`
65
79
  // inter-chunk / reasoning span keeps the longer deadlines below.
66
80
  const WS_PRE_RESPONSE_CREATED_MS = (() => {
@@ -69,11 +83,22 @@ const WS_PRE_RESPONSE_CREATED_MS = (() => {
69
83
  if (Number.isFinite(n) && n > 0) return Math.min(Math.max(n, 1_000), 120_000);
70
84
  return 10_000;
71
85
  })();
72
- // Inter-chunk inactivity after first meaningful output.
86
+ // Single inter-chunk idle timer. Resets on EVERY received frame (matches codex
87
+ // responses_websocket.rs timeout(idle_timeout, ws_stream.next()) and mixdog
88
+ // anthropic-oauth resetIdleTimer on every chunk).
73
89
  const WS_INTER_CHUNK_MS = PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS;
74
- const MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT = 4;
75
- const MIDSTREAM_DEFAULT_RETRY_LIMIT = 2;
76
- const MIDSTREAM_BACKOFF_MS = [250, 1000, 2000, 4000];
90
+ // Mid-stream retry budgets + backoff now live in the shared MIDSTREAM_RETRY_POLICY
91
+ // table (retry-classifier.mjs). These aliases keep the local call sites readable
92
+ // and ensure the numbers exist in exactly ONE place.
93
+ const MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT = MIDSTREAM_RETRY_POLICY.ws.transientCloseRetries;
94
+ const MIDSTREAM_DEFAULT_RETRY_LIMIT = MIDSTREAM_RETRY_POLICY.ws.defaultRetries;
95
+ const MIDSTREAM_BACKOFF_MS = MIDSTREAM_RETRY_POLICY.ws.backoff;
96
+ // Policy object passed to the shared classifyMidstreamError for the WS path.
97
+ const WS_MIDSTREAM_POLICY = {
98
+ mode: 'ws',
99
+ transientCloseRetries: MIDSTREAM_RETRY_POLICY.ws.transientCloseRetries,
100
+ defaultRetries: MIDSTREAM_RETRY_POLICY.ws.defaultRetries,
101
+ };
77
102
 
78
103
  // Handshake retry policy. The `ws` library surfaces a bare
79
104
  // `Opening handshake has timed out` Error after handshakeTimeout; transient
@@ -1216,11 +1241,11 @@ export async function _streamResponse({
1216
1241
  logSuppressedReasoningDeltas = true,
1217
1242
  traceProvider = 'openai-oauth',
1218
1243
  _timeouts = null,
1244
+ knownToolNames = null,
1219
1245
  }) {
1220
1246
  const errLabel = _wsErrLabel(traceProvider);
1221
1247
  const socket = entry.socket;
1222
1248
  const preResponseCreatedMs = _positiveInt(_timeouts?.preResponseCreatedMs, WS_PRE_RESPONSE_CREATED_MS);
1223
- const firstMeaningfulMs = _positiveInt(_timeouts?.firstMeaningfulMs, WS_FIRST_MEANINGFUL_MS);
1224
1249
  const interChunkMs = _positiveInt(_timeouts?.interChunkMs, WS_INTER_CHUNK_MS);
1225
1250
  const _streamingStart = Date.now();
1226
1251
  let _firstDeltaEmitted = false;
@@ -1236,6 +1261,19 @@ export async function _streamResponse({
1236
1261
  const citations = [];
1237
1262
  const citationKeys = new Set();
1238
1263
  const pendingCalls = new Map();
1264
+ // Tool-work-in-flight flag: set the moment a function/custom tool call's
1265
+ // input starts streaming (before it lands in pendingCalls/toolCalls).
1266
+ // Gates partial-final SUCCESS so a stall mid tool-input never looks text-only.
1267
+ let _toolInFlight = false;
1268
+ // Fix 2: cross-path name+args dedupe. A text-leaked synthetic and an
1269
+ // identical native function_call must fire onToolCall exactly once. Every
1270
+ // dispatch site routes through emitToolCallDedupe.
1271
+ const _toolDedupe = createToolCallDedupe();
1272
+ const emitToolCallDedupe = (call) => {
1273
+ if (!_toolDedupe.shouldDispatch(call?.name, call?.arguments)) return;
1274
+ midState.emittedToolCall = true;
1275
+ try { onToolCall?.(call); } catch {}
1276
+ };
1239
1277
  // Reasoning items collected from response.output_item.done (or salvaged
1240
1278
  // from response.completed.response.output). The request still includes
1241
1279
  // `reasoning.encrypted_content` so the server keeps emitting the blobs,
@@ -1323,8 +1361,7 @@ export async function _streamResponse({
1323
1361
  const call = customToolCallFromResponseItem(item);
1324
1362
  if (!call || toolCalls.some((existing) => existing.id === call.id)) return;
1325
1363
  toolCalls.push(call);
1326
- midState.emittedToolCall = true;
1327
- try { onToolCall?.(call); } catch {}
1364
+ emitToolCallDedupe(call);
1328
1365
  };
1329
1366
  const pushToolSearchCall = (item) => {
1330
1367
  if (!item || item.type !== 'tool_search_call') return;
@@ -1337,9 +1374,60 @@ export async function _streamResponse({
1337
1374
  nativeType: 'tool_search_call',
1338
1375
  };
1339
1376
  toolCalls.push(call);
1377
+ emitToolCallDedupe(call);
1378
+ };
1379
+ // Leaked tool-call guard. The model sometimes emits a tool call as plain
1380
+ // text (XML `<invoke>`/`<function_calls>` or gpt-oss harmony
1381
+ // `<|channel|>...to=functions.NAME...<|call|>`) inside
1382
+ // `response.output_text.delta` instead of a native function_call. Route
1383
+ // text through the guard so leaked calls are suppressed from the visible
1384
+ // stream, synthesized (native `call_...` id shape), and dispatched like
1385
+ // native ones. Additive: the native function_call path is untouched.
1386
+ const leakGuard = createLeakGuard({ knownToolNames, harmony: true });
1387
+ const dispatchLeakedCall = (recovered) => {
1388
+ let args = recovered?.arguments;
1389
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
1390
+ const call = {
1391
+ id: `call_leaked_${randomBytes(8).toString('hex')}`,
1392
+ name: recovered.name,
1393
+ arguments: args,
1394
+ };
1395
+ if (!_toolDedupe.shouldDispatch(call.name, call.arguments)) return;
1396
+ toolCalls.push(call);
1340
1397
  midState.emittedToolCall = true;
1341
1398
  try { onToolCall?.(call); } catch {}
1342
1399
  };
1400
+ const relayLeakText = (delta) => {
1401
+ if (!leakGuard.enabled) {
1402
+ content += delta || '';
1403
+ if (delta && onTextDelta) {
1404
+ if (state) state.emittedText = true;
1405
+ try { onTextDelta(delta); } catch {}
1406
+ }
1407
+ return;
1408
+ }
1409
+ const { text, calls } = leakGuard.push(delta);
1410
+ if (text) {
1411
+ content += text;
1412
+ if (onTextDelta) {
1413
+ if (state) state.emittedText = true;
1414
+ try { onTextDelta(text); } catch {}
1415
+ }
1416
+ }
1417
+ for (const c of calls) dispatchLeakedCall(c);
1418
+ };
1419
+ const flushLeak = () => {
1420
+ if (!leakGuard.enabled) return;
1421
+ const { text, calls } = leakGuard.flush();
1422
+ if (text) {
1423
+ content += text;
1424
+ if (onTextDelta) {
1425
+ if (state) state.emittedText = true;
1426
+ try { onTextDelta(text); } catch {}
1427
+ }
1428
+ }
1429
+ for (const c of calls) dispatchLeakedCall(c);
1430
+ };
1343
1431
  const logReasoningDeltaSuppression = () => {
1344
1432
  if (!logSuppressedReasoningDeltas) return;
1345
1433
  const total = reasoningTextDeltaCount + reasoningSummaryTextDeltaCount + reasoningOtherDeltaCount;
@@ -1368,6 +1456,16 @@ export async function _streamResponse({
1368
1456
  let messageHandler = null;
1369
1457
  let closeHandler = null;
1370
1458
  let errorHandler = null;
1459
+ // SEMANTIC idle timer (pi per-event parity): distinct from the inter-chunk
1460
+ // timer, which resets on EVERY frame (rate_limits/metadata/keepalive keep
1461
+ // the socket "alive"). This timer resets ONLY on meaningful output deltas
1462
+ // (text/reasoning/tool args — the same events that call onStreamDelta) so a
1463
+ // stream that emits some deltas then goes silent (server keepalive frames
1464
+ // only) trips a short, named terminal StreamStalledError instead of coasting
1465
+ // to the 300s inter-chunk cap / 30-min agent watchdog.
1466
+ let semanticIdleTimer = null;
1467
+ const semanticIdleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
1468
+ const semanticIdleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED && semanticIdleMs > 0;
1371
1469
 
1372
1470
  return new Promise((resolve, reject) => {
1373
1471
  // Pre-stream watchdog: the timer fires if the server never sends a
@@ -1375,10 +1473,10 @@ export async function _streamResponse({
1375
1473
  // after our last frame. The socket is open and the response.create
1376
1474
  // frame was sent, but no server event has come back — a wedged
1377
1475
  // post-upgrade socket. Healthy servers ack within seconds, so this
1378
- // window is intentionally short (~25s). Once response.created (or
1379
- // any other meaningful event) arrives, the timer is cancelled and
1380
- // the longer inter-chunk inactivity watchdog takes over — silent
1381
- // gaps mid-reasoning (openai-oauth spending 50s+ producing reasoning
1476
+ // window is intentionally short (WS_PRE_RESPONSE_CREATED_MS, ~10s).
1477
+ // Once ANY server event arrives, resetIdle() cancels this watchdog and
1478
+ // the single inter-chunk idle timer takes over — silent gaps
1479
+ // mid-reasoning (openai-oauth spending 50s+ producing reasoning
1382
1480
  // tokens) are normal and should not abort the turn.
1383
1481
  const armPreStreamWatchdog = () => {
1384
1482
  if (idleTimer) clearTimeout(idleTimer);
@@ -1410,8 +1508,6 @@ export async function _streamResponse({
1410
1508
  }, preResponseCreatedMs);
1411
1509
  };
1412
1510
  let interChunkTimer = null;
1413
- let firstMeaningfulTimer = null;
1414
- let firstMeaningfulSeen = false;
1415
1511
  const traceWsTimeout = (event, timeoutMs) => {
1416
1512
  try {
1417
1513
  const iteration = Number(midState.iteration);
@@ -1426,7 +1522,6 @@ export async function _streamResponse({
1426
1522
  attempt_index: Number.isFinite(attemptIndex) ? attemptIndex : null,
1427
1523
  warmup: midState.warmup === true,
1428
1524
  saw_response_created: midState.sawResponseCreated === true,
1429
- first_meaningful_seen: firstMeaningfulSeen === true,
1430
1525
  };
1431
1526
  appendAgentTrace({
1432
1527
  sessionId: midState.sessionId || null,
@@ -1443,29 +1538,6 @@ export async function _streamResponse({
1443
1538
  idleTimer = null;
1444
1539
  }
1445
1540
  };
1446
- const clearFirstMeaningfulWatchdog = () => {
1447
- if (firstMeaningfulTimer) {
1448
- clearTimeout(firstMeaningfulTimer);
1449
- firstMeaningfulTimer = null;
1450
- }
1451
- };
1452
- const armFirstMeaningfulWatchdog = () => {
1453
- if (firstMeaningfulSeen || firstMeaningfulMs <= 0) return;
1454
- clearFirstMeaningfulWatchdog();
1455
- firstMeaningfulTimer = setTimeout(() => {
1456
- if (process.env.MIXDOG_DEBUG_AGENT) {
1457
- process.stderr.write(`[agent-trace] ws-timeout kind=first-meaningful afterMs=${firstMeaningfulMs}\n`);
1458
- }
1459
- traceWsTimeout('first_meaningful_timeout', firstMeaningfulMs);
1460
- const err = new Error(`WS stream: no meaningful output within ${firstMeaningfulMs}ms after response.created`);
1461
- err.wsCloseCode = 4000;
1462
- err.firstMeaningfulTimeout = true;
1463
- midState.firstMeaningfulTimeout = true;
1464
- terminalError = err;
1465
- try { socket.close(4000, 'first_meaningful_timeout'); } catch {}
1466
- finish();
1467
- }, firstMeaningfulMs);
1468
- };
1469
1541
  const resetInterChunk = () => {
1470
1542
  if (interChunkTimer) clearTimeout(interChunkTimer);
1471
1543
  interChunkTimer = setTimeout(() => {
@@ -1480,30 +1552,49 @@ export async function _streamResponse({
1480
1552
  finish();
1481
1553
  }, interChunkMs);
1482
1554
  };
1483
- const onResponseCreated = () => {
1484
- clearPreStreamWatchdog();
1485
- if (!firstMeaningfulSeen) armFirstMeaningfulWatchdog();
1486
- else resetInterChunk();
1555
+ // pi per-event idle: (re)armed only on meaningful output deltas via
1556
+ // bumpSemanticIdle(). Keepalive/metadata frames DON'T touch it, so a
1557
+ // deltas-then-silent wedge trips this short semantic window.
1558
+ const resetSemanticIdle = () => {
1559
+ if (!semanticIdleEnabled) return;
1560
+ if (semanticIdleTimer) clearTimeout(semanticIdleTimer);
1561
+ semanticIdleTimer = setTimeout(() => {
1562
+ traceWsTimeout('semantic_idle_timeout', semanticIdleMs);
1563
+ terminalError = streamStalledError('Responses WS', semanticIdleMs, { emittedToolCall: !!midState?.emittedToolCall });
1564
+ // Partial-final recovery parity: attach streamed partial state so
1565
+ // a wedged FINAL no-tool summary can be accepted as partial-final
1566
+ // success by the loop. pendingToolUse gates out mid-flight tools.
1567
+ try {
1568
+ terminalError.partialContent = content;
1569
+ terminalError.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
1570
+ terminalError.pendingToolUse = pendingCalls.size > 0
1571
+ || !!midState?.emittedToolCall
1572
+ || toolCalls.length > 0
1573
+ || _toolInFlight === true;
1574
+ terminalError.partialModel = model || undefined;
1575
+ } catch { /* best-effort enrichment */ }
1576
+ try { terminalError.wsCloseCode = 4000; } catch {}
1577
+ try { socket.close(4000, 'semantic_idle_timeout'); } catch {}
1578
+ finish();
1579
+ }, semanticIdleMs);
1580
+ try { semanticIdleTimer.unref?.(); } catch {}
1487
1581
  };
1488
- // Called on every event that carries real output tokens or tool
1489
- // progress. `response.created` is only an ACK and must not count here:
1490
- // a wedged openai-oauth stream can ACK immediately and then never produce
1491
- // text/reasoning/tool deltas, holding the prompt-cache lane for the
1492
- // full inter-chunk window.
1493
- const onMeaningfulOutput = () => {
1494
- if (!firstMeaningfulSeen) {
1495
- firstMeaningfulSeen = true;
1496
- clearPreStreamWatchdog();
1497
- clearFirstMeaningfulWatchdog();
1498
- }
1582
+ // Single idle reset called on EVERY parsed server event (matches
1583
+ // codex, which resets one idle timer on every received WS frame). Any
1584
+ // frame proves the socket is live; there is no separate "meaningful
1585
+ // output" gate. Also clears the pre-stream watchdog defensively in case
1586
+ // the first event is not response.created.
1587
+ const resetIdle = () => {
1588
+ clearPreStreamWatchdog();
1499
1589
  resetInterChunk();
1500
1590
  };
1501
- // resetIdle kept for compat; metadata frames no longer disarm pre-stream watchdog.
1502
- const resetIdle = () => { /* noop — only onMeaningfulOutput() disarms */ };
1591
+ // Meaningful-output progress bump: called by the same delta cases that
1592
+ // call onStreamDelta (text/reasoning/tool args). Arms the semantic idle.
1593
+ const bumpSemanticIdle = () => { resetSemanticIdle(); };
1503
1594
  const cleanup = () => {
1504
1595
  if (idleTimer) clearTimeout(idleTimer);
1505
- clearFirstMeaningfulWatchdog();
1506
1596
  if (interChunkTimer) { clearTimeout(interChunkTimer); interChunkTimer = null; }
1597
+ if (semanticIdleTimer) { clearTimeout(semanticIdleTimer); semanticIdleTimer = null; }
1507
1598
  if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
1508
1599
  if (messageHandler) socket.off('message', messageHandler);
1509
1600
  if (closeHandler) socket.off('close', closeHandler);
@@ -1512,6 +1603,9 @@ export async function _streamResponse({
1512
1603
  };
1513
1604
  const finish = () => {
1514
1605
  logReasoningDeltaSuppression();
1606
+ // Flush any partial-sentinel tail held back mid-stream so
1607
+ // legitimate trailing text is never lost (streamed-text path).
1608
+ flushLeak();
1515
1609
  cleanup();
1516
1610
  if (terminalError) { reject(terminalError); return; }
1517
1611
  resolve({
@@ -1519,11 +1613,17 @@ export async function _streamResponse({
1519
1613
  model,
1520
1614
  reasoningItems: reasoningItems.length ? reasoningItems : undefined,
1521
1615
  responseItems: responseItemsAdded.length ? responseItemsAdded : undefined,
1522
- toolCalls: toolCalls.length ? toolCalls : undefined,
1616
+ // Dedupe by name+args (Fix 2, array side) so an identical
1617
+ // synthetic-leaked + native pair can't run the tool twice.
1618
+ toolCalls: toolCalls.length ? dedupeToolCallList(toolCalls) : undefined,
1523
1619
  citations: citations.length ? citations : undefined,
1524
1620
  webSearchCalls: webSearchCalls.length ? webSearchCalls : undefined,
1525
1621
  usage,
1526
1622
  stopReason: stopReason || undefined,
1623
+ // P1 audit fix: mirror the HTTP/SSE fallback's truncated flag
1624
+ // for the WS path (sendViaWebSocket spreads this result
1625
+ // through to the provider caller unchanged).
1626
+ ...(stopReason === 'length' && content.length > 0 ? { truncated: true } : {}),
1527
1627
  incompleteReason: incompleteReason || undefined,
1528
1628
  responseId: responseId || undefined,
1529
1629
  serviceTier: responseServiceTier || undefined,
@@ -1532,7 +1632,10 @@ export async function _streamResponse({
1532
1632
 
1533
1633
  messageHandler = (data) => {
1534
1634
  resetIdle();
1535
- // Do NOT call onStreamDelta for every frame metadata/keepalive frames
1635
+ // resetIdle() above resets the SINGLE inter-chunk idle timer on
1636
+ // EVERY received frame (codex parity) — response.created, metadata,
1637
+ // rate_limits, and all deltas keep the socket alive. Separately, do
1638
+ // NOT call onStreamDelta for every frame — metadata/keepalive frames
1536
1639
  // must not reset the agent stall watchdog's lastStreamDeltaAt. Only
1537
1640
  // meaningful output (text delta / tool call) updates that timestamp.
1538
1641
  const text = typeof data === 'string' ? data : data.toString('utf-8');
@@ -1554,13 +1657,11 @@ export async function _streamResponse({
1554
1657
  midState.sawResponseCreated = true;
1555
1658
  if (event.response?.model) model = event.response.model;
1556
1659
  if (event.response?.id) responseId = event.response.id;
1557
- // Server ack: cancel only the pre-created watchdog. Keep
1558
- // a separate first-meaningful watchdog armed until real
1559
- // model progress arrives.
1560
- onResponseCreated();
1660
+ // Server ack (first event). resetIdle() at the top of
1661
+ // messageHandler already cleared the pre-stream watchdog and
1662
+ // armed the single idle timer — no extra bookkeeping here.
1561
1663
  break;
1562
1664
  case 'response.output_text.delta':
1563
- content += event.delta || '';
1564
1665
  try {
1565
1666
  if (!_firstDeltaEmitted) {
1566
1667
  _firstDeltaEmitted = true;
@@ -1570,6 +1671,7 @@ export async function _streamResponse({
1570
1671
  }
1571
1672
  onStreamDelta?.();
1572
1673
  } catch {}
1674
+ bumpSemanticIdle();
1573
1675
  // Live text relay (gateway): forward the raw text chunk so
1574
1676
  // the client renders first tokens before the final replay.
1575
1677
  // Tool-call/argument deltas intentionally stay off this path.
@@ -1577,11 +1679,10 @@ export async function _streamResponse({
1577
1679
  // cannot be withdrawn, so flag the attempt so a later
1578
1680
  // mid-stream/truncated failure is NOT retried (retry would
1579
1681
  // concatenate a second attempt onto rendered text).
1580
- if (event.delta && onTextDelta) {
1581
- if (state) state.emittedText = true;
1582
- try { onTextDelta(event.delta); } catch {}
1583
- }
1584
- onMeaningfulOutput();
1682
+ // Routed through the leaked-tool-call guard: appends to
1683
+ // `content`, forwards visible text via onTextDelta, and
1684
+ // recovers/dispatches any leaked known-tool call.
1685
+ relayLeakText(event.delta || '');
1585
1686
  break;
1586
1687
  case 'response.reasoning_text.delta':
1587
1688
  case 'response.reasoning_summary_text.delta':
@@ -1589,16 +1690,12 @@ export async function _streamResponse({
1589
1690
  else reasoningSummaryTextDeltaCount += 1;
1590
1691
  // Reasoning text is live model progress — refresh
1591
1692
  // lastStreamDeltaAt so stream-watchdog does not flag a
1592
- // long reasoning span as a stall. It also counts as
1593
- // liveness for the local pre-stream / inter-chunk
1594
- // watchdogs: a long reasoning span without any
1595
- // output_text delta would otherwise trip the
1596
- // first-meaningful timer and abort an otherwise healthy
1597
- // stream. Reasoning is still suppressed from user
1598
- // content (no `content +=` here) — only the watchdog
1599
- // timers are reset.
1693
+ // long reasoning span as a stall. The local WS idle timer
1694
+ // was already reset by resetIdle() at the top of
1695
+ // messageHandler. Reasoning is still suppressed from user
1696
+ // content (no `content +=` here).
1600
1697
  try { onStreamDelta?.(); } catch {}
1601
- onMeaningfulOutput();
1698
+ bumpSemanticIdle();
1602
1699
  break;
1603
1700
  case 'response.output_item.added':
1604
1701
  if (event.item?.type === 'function_call') {
@@ -1606,16 +1703,20 @@ export async function _streamResponse({
1606
1703
  name: event.item.name || '',
1607
1704
  callId: event.item.call_id || '',
1608
1705
  });
1609
- onMeaningfulOutput();
1706
+ _toolInFlight = true;
1707
+ } else if (event.item?.type === 'custom_tool_call') {
1708
+ _toolInFlight = true;
1610
1709
  }
1611
1710
  break;
1612
1711
  case 'response.function_call_arguments.delta':
1712
+ _toolInFlight = true;
1613
1713
  try { onStreamDelta?.(); } catch {}
1614
- onMeaningfulOutput();
1714
+ bumpSemanticIdle();
1615
1715
  break;
1616
1716
  case 'response.custom_tool_call_input.delta':
1717
+ _toolInFlight = true;
1617
1718
  try { onStreamDelta?.(); } catch {}
1618
- onMeaningfulOutput();
1719
+ bumpSemanticIdle();
1619
1720
  break;
1620
1721
  case 'response.function_call_arguments.done': {
1621
1722
  const itemId = event.item_id || '';
@@ -1646,8 +1747,7 @@ export async function _streamResponse({
1646
1747
  if (pending?.callId && pending?.name) {
1647
1748
  const call = { id: pending.callId, name: pending.name, arguments: args };
1648
1749
  toolCalls.push(call);
1649
- midState.emittedToolCall = true;
1650
- try { onToolCall?.(call); } catch {}
1750
+ emitToolCallDedupe(call);
1651
1751
  } else {
1652
1752
  // Synthesizing a `tc_${Date.now()}` callId here would
1653
1753
  // make the next turn fail to match the model's
@@ -1667,7 +1767,7 @@ export async function _streamResponse({
1667
1767
  });
1668
1768
  }
1669
1769
  try { onStreamDelta?.(); } catch {}
1670
- onMeaningfulOutput();
1770
+ bumpSemanticIdle();
1671
1771
  break;
1672
1772
  }
1673
1773
  case 'response.output_item.done':
@@ -1681,11 +1781,9 @@ export async function _streamResponse({
1681
1781
  if (event.item?.type === 'web_search_call') pushWebSearchCall(event.item);
1682
1782
  if (event.item?.type === 'tool_search_call') {
1683
1783
  pushToolSearchCall(event.item);
1684
- onMeaningfulOutput();
1685
1784
  }
1686
1785
  if (event.item?.type === 'custom_tool_call') {
1687
1786
  pushCustomToolCall(event.item);
1688
- onMeaningfulOutput();
1689
1787
  }
1690
1788
  break;
1691
1789
  case 'response.completed': {
@@ -1717,7 +1815,18 @@ export async function _streamResponse({
1717
1815
  if (!content && item.type === 'message') {
1718
1816
  for (const c of item.content || []) {
1719
1817
  if (c.type === 'output_text') {
1720
- content += c.text || '';
1818
+ // Completed-output fallback (no streamed
1819
+ // text). Route through the leak guard so
1820
+ // a tool call leaked only in the final
1821
+ // bundle is recovered, not surfaced as
1822
+ // visible content. final=true → full flush.
1823
+ if (leakGuard.enabled) {
1824
+ const { text, calls } = leakGuard.push(c.text || '', true);
1825
+ content += text;
1826
+ for (const lc of calls) dispatchLeakedCall(lc);
1827
+ } else {
1828
+ content += c.text || '';
1829
+ }
1721
1830
  pushOutputTextAnnotations(c);
1722
1831
  }
1723
1832
  }
@@ -1753,8 +1862,7 @@ export async function _streamResponse({
1753
1862
  if (tc.id && tc.name) {
1754
1863
  delete tc._deferred;
1755
1864
  delete tc._pendingItemId;
1756
- midState.emittedToolCall = true;
1757
- try { onToolCall?.(tc); } catch {}
1865
+ emitToolCallDedupe(tc);
1758
1866
  }
1759
1867
  }
1760
1868
  }
@@ -1891,6 +1999,13 @@ export async function _streamResponse({
1891
1999
  && event.type.startsWith('response.reasoning')
1892
2000
  && event.type.endsWith('.delta')) {
1893
2001
  reasoningOtherDeltaCount += 1;
2002
+ // These ARE live model progress (reviewer Medium): a
2003
+ // provider that emits only these reasoning variants for a
2004
+ // long span would otherwise trip the SEMANTIC idle abort.
2005
+ // Refresh both the watchdog and the semantic idle timer,
2006
+ // matching the named reasoning_text.delta case above.
2007
+ try { onStreamDelta?.(); } catch {}
2008
+ bumpSemanticIdle();
1894
2009
  }
1895
2010
  // Trace-only events (response.in_progress, etc.)
1896
2011
  break;
@@ -1997,34 +2112,12 @@ export async function _streamResponse({
1997
2112
  * 'http_5xx' (with specific status e.g. 'http_503') — server overload
1998
2113
  * null — not retryable
1999
2114
  */
2115
+ // Thin re-export wrapper: handshake classification now lives in the shared
2116
+ // retry-classifier (classifyHandshakeError). Kept here as a named export so
2117
+ // internal call sites (_acquireWithRetry) and any external importer keep
2118
+ // resolving the same symbol.
2000
2119
  export function _classifyHandshakeError(err) {
2001
- if (!err) return null;
2002
- const code = err.code || '';
2003
- const msg = String(err.message || '');
2004
- const status = Number(err.httpStatus || 0);
2005
-
2006
- // Permanent HTTP (auth / quota / not-found) short-circuits.
2007
- if (status === 401 || status === 403 || status === 404 || status === 429) {
2008
- return null;
2009
- }
2010
- // 5xx transient.
2011
- if (status >= 500 && status < 600) {
2012
- return `http_${status}`;
2013
- }
2014
-
2015
- // Node errno codes.
2016
- if (code === 'ECONNRESET') return 'reset';
2017
- if (code === 'EAI_AGAIN' || code === 'ENOTFOUND' || code === 'EAI_NODATA') return 'dns';
2018
- if (code === 'ECONNREFUSED') return 'refused';
2019
- if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT') return 'timeout';
2020
- if (code === 'EWSACQUIRETIMEOUT') return 'acquire_timeout';
2021
- if (code === 'ENETUNREACH' || code === 'EHOSTUNREACH' || code === 'EPIPE') return 'network';
2022
-
2023
- // `ws` library's handshake-timeout path: thrown as a bare Error.
2024
- if (/opening handshake has timed out/i.test(msg)) return 'timeout';
2025
- if (/socket hang up/i.test(msg)) return 'reset';
2026
-
2027
- return null;
2120
+ return classifyHandshakeError(err);
2028
2121
  }
2029
2122
 
2030
2123
  /**
@@ -2048,9 +2141,6 @@ export function _classifyHandshakeError(err) {
2048
2141
  * response.create frame sent, but the server never
2049
2142
  * emitted response.created within the short
2050
2143
  * pre-stream deadline. Fast-fail retryable.
2051
- * 'first_meaningful_timeout' — server ACKed response.created, then emitted
2052
- * no real text/reasoning/tool progress before the
2053
- * first-meaningful deadline.
2054
2144
  * 'response_failed_network' — response.failed with network_error
2055
2145
  * 'response_failed_disconnected' — response.failed with stream_disconnected
2056
2146
  *
@@ -2065,128 +2155,25 @@ export function _classifyHandshakeError(err) {
2065
2155
  * - HTTP 401 / 403 / 429 surfaced on the error
2066
2156
  * - state.attemptIndex has reached the classifier-specific retry budget
2067
2157
  */
2158
+ // Thin wrapper: the full WS mid-stream decision tree now lives in the shared
2159
+ // classifyMidstreamError (retry-classifier.mjs, policy.mode='ws'). Kept as a
2160
+ // named export so internal call sites and any external importer keep resolving
2161
+ // the same symbol. Behavior is byte-identical — the shared function is the
2162
+ // relocated original, with the per-classifier budget gating supplied by
2163
+ // WS_MIDSTREAM_POLICY (transientCloseRetries=4, defaultRetries=2).
2068
2164
  export function _classifyMidstreamError(err, state) {
2069
- if (!state) return null;
2070
- const attemptIndex = state.attemptIndex | 0;
2071
- // Already completed (shouldn't throw, but defensive).
2072
- if (state.sawCompleted) return null;
2073
- // Any tool call already surfaced to the caller — retrying would
2074
- // normally duplicate the side effect. EXCEPTION: ws_1000 truncation
2075
- // (server-side normal close after response.created, before completion)
2076
- // leaves the caller with an orphaned tool_use that the next turn cannot
2077
- // pair to a tool_result, which the provider rejects with a hard 400.
2078
- // The duplicate-side-effect risk is preferable to deterministic worker
2079
- // death, especially for detached agents that re-dispatch idempotently.
2080
- if (state.emittedToolCall) {
2081
- const _cc = Number(err?.wsCloseCode || state.wsCloseCode || 0);
2082
- if (!(_cc === 1000 && state.sawResponseCreated && !state.sawCompleted)) return null;
2083
- }
2084
- // Live-text invariant: once a non-empty text chunk has been relayed to the
2085
- // client (gateway live mode), the rendered output cannot be withdrawn and a
2086
- // retry would concatenate a second attempt. Treat every subsequent
2087
- // mid-stream/truncated failure as final — never retry.
2088
- if (state.emittedText || err?.liveTextEmitted) return null;
2089
- // Post-upgrade-no-first-event: the socket opened, our response.create
2090
- // frame was sent, but the server never emitted a single event before
2091
- // the short pre-`response.created` watchdog fired. The handshake retry
2092
- // layer only sees pre-upgrade failures and the legacy pre-stream gate
2093
- // below would deny this case (sawResponseCreated === false). Tag it
2094
- // here as a fast retryable bucket so the worker reconnects within
2095
- // seconds instead of stalling for the full first-meaningful window.
2096
- if (state.firstByteTimeout || err?.firstByteTimeout) {
2097
- return _allowMidstreamRetry('first_byte_timeout', attemptIndex);
2098
- }
2099
- if (state.firstMeaningfulTimeout || err?.firstMeaningfulTimeout) {
2100
- return _allowMidstreamRetry('first_meaningful_timeout', attemptIndex);
2101
- }
2102
- // _sendFrame failure (socket not OPEN, send callback errored, JSON
2103
- // serialize threw). Always retryable: caller will forceFresh next
2104
- // attempt so the wedged socket is dropped.
2105
- if (err?.wsSendFailed || state.wsSendFailed) {
2106
- return _allowMidstreamRetry('ws_send_failed', attemptIndex);
2107
- }
2108
- // Pre-stream failures normally belong to the handshake retry layer. BUT
2109
- // WS close 1011 / 1012 can fire after the 101 upgrade but BEFORE the
2110
- // first response.created event when the server's keepalive times out or
2111
- // the service restarts. Neither the handshake retry layer (it only sees
2112
- // pre-upgrade failures) nor the existing mid-stream gate covers this
2113
- // window, so permit bounded retry here for those two codes only.
2114
- if (!state.sawResponseCreated) {
2115
- const closeCode = Number(err?.wsCloseCode || state.wsCloseCode || 0);
2116
- if (closeCode !== 1011 && closeCode !== 1012) return null;
2117
- }
2118
- // User/caller abort — never retry.
2119
- if (state.userAbort) return null;
2120
-
2121
- if (!err) return null;
2122
- const status = Number(err?.httpStatus || 0);
2123
- if (status === 401 || status === 403 || status === 429) return null;
2124
- // Transient 5xx surfaced via populateHttpStatusFromMessage (case 'error'
2125
- // and case 'response.failed' branches sniff server-supplied text like
2126
- // "Our servers are currently overloaded" and assign httpStatus=503).
2127
- // Allow one bounded mid-stream retry on the same budget as the WS close-
2128
- // code buckets above so server-side overload no longer leaks straight
2129
- // to the caller without a single retry attempt.
2130
- if (status >= 500 && status < 600) {
2131
- return _allowMidstreamRetry(`http_${status}`, attemptIndex);
2132
- }
2133
-
2134
- const name = err?.name || '';
2135
- if (name === 'AgentStallAbortError') return _allowMidstreamRetry('agent_stall', attemptIndex);
2136
- if (name === 'StreamStalledAbortError') return _allowMidstreamRetry('stream_stalled', attemptIndex);
2137
-
2138
- // Watchdog abort surfaced via externalSignal handler → err is the reason
2139
- // itself. state.watchdogAbort captures the class name when the error
2140
- // shape was preserved but the name was stripped by some wrapper.
2141
- if (state.watchdogAbort === 'AgentStallAbortError') return _allowMidstreamRetry('agent_stall', attemptIndex);
2142
- if (state.watchdogAbort === 'StreamStalledAbortError') return _allowMidstreamRetry('stream_stalled', attemptIndex);
2143
-
2144
- // WS close codes: prefer the decorated property, fall back to state.
2145
- const closeCode = Number(err?.wsCloseCode || state.wsCloseCode || 0);
2146
- if (closeCode === 1006) return _allowMidstreamRetry('ws_1006', attemptIndex);
2147
- if (closeCode === 1011) return _allowMidstreamRetry('ws_1011', attemptIndex);
2148
- if (closeCode === 1012) return _allowMidstreamRetry('ws_1012', attemptIndex);
2149
- // Private 4xxx codes from a server/proxy are auth/policy/application closes;
2150
- // never treat them as transient. 4000 is our local pre-stream watchdog code.
2151
- if (closeCode >= 4000 && closeCode < 5000 && closeCode !== 4000) return null;
2152
- if (closeCode === 4000) return _allowMidstreamRetry('ws_4000', attemptIndex);
2153
- // Server-side normal close (1000) AFTER response.created but BEFORE
2154
- // response.completed = truncated stream; legitimate transient. The
2155
- // pre-stream gate above already rejects 1000 before sawResponseCreated
2156
- // (handshake retry layer owns that window).
2157
- if (closeCode === 1000 && state.sawResponseCreated && !state.sawCompleted) return _allowMidstreamRetry('ws_1000', attemptIndex);
2158
-
2159
- // response.failed payload mentioning network_error / stream_disconnected.
2160
- // xAI's gRPC backend periodically rotates auth context (server-side TTL)
2161
- // and surfaces "Auth context expired" as a response.failed event. The
2162
- // attemptIndex > 0 path in sendViaWebSocket forces a fresh WS handshake,
2163
- // which re-authenticates — so a single bounded retry recovers the turn
2164
- // instead of letting the worker die mid-session.
2165
- const failed = err?.responseFailed || state.responseFailedPayload;
2166
- if (failed) {
2167
- try {
2168
- const blob = JSON.stringify(failed).toLowerCase();
2169
- if (blob.includes('stream_disconnected')) return _allowMidstreamRetry('response_failed_disconnected', attemptIndex);
2170
- if (blob.includes('network_error')) return _allowMidstreamRetry('response_failed_network', attemptIndex);
2171
- if (blob.includes('auth context expired')) return _allowMidstreamRetry('response_failed_auth_expired', attemptIndex);
2172
- } catch {}
2173
- }
2174
-
2175
- // Unknown → default-deny (don't risk a second full-cost turn for an error
2176
- // class we haven't proven is transient).
2177
- return null;
2165
+ return classifyMidstreamError(err, state, WS_MIDSTREAM_POLICY);
2178
2166
  }
2179
2167
 
2168
+ // Per-classifier retry budget, used by the sendViaWebSocket loop to bound the
2169
+ // attempt count once classifyMidstreamError returns a bucket. Mirrors the
2170
+ // shared _midstreamLimitFor(ws) — the numbers come from MIDSTREAM_RETRY_POLICY.
2180
2171
  function _midstreamRetryLimit(classifier) {
2181
2172
  return classifier === 'ws_1006' || classifier === 'ws_1011'
2182
2173
  ? MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT
2183
2174
  : MIDSTREAM_DEFAULT_RETRY_LIMIT;
2184
2175
  }
2185
2176
 
2186
- function _allowMidstreamRetry(classifier, attemptIndex) {
2187
- return attemptIndex < _midstreamRetryLimit(classifier) ? classifier : null;
2188
- }
2189
-
2190
2177
  function _midstreamBackoffFor(retryNumber) {
2191
2178
  const raw = MIDSTREAM_BACKOFF_MS[Math.min(Math.max(retryNumber, 1), MIDSTREAM_BACKOFF_MS.length) - 1];
2192
2179
  return jitterDelayMs(raw);
@@ -2200,25 +2187,11 @@ function _backoffFor(attempt) {
2200
2187
 
2201
2188
  const _defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
2202
2189
 
2203
- async function _sleepWithAbort(ms, externalSignal, sleepFn = _defaultSleep) {
2204
- if (!ms) return;
2205
- if (!externalSignal) {
2206
- await sleepFn(ms);
2207
- return;
2208
- }
2209
- await new Promise((resolve, reject) => {
2210
- const t = setTimeout(() => {
2211
- externalSignal.removeEventListener('abort', onAbort);
2212
- resolve();
2213
- }, ms);
2214
- const onAbort = () => {
2215
- clearTimeout(t);
2216
- const reason = externalSignal.reason;
2217
- reject(reason instanceof Error ? reason : new Error('OpenAI OAuth WS retry backoff aborted'));
2218
- };
2219
- if (externalSignal.aborted) { onAbort(); return; }
2220
- externalSignal.addEventListener('abort', onAbort, { once: true });
2221
- });
2190
+ // Abort-aware backoff sleep shared sleepWithAbort (retry-classifier.mjs). The
2191
+ // abortMessage preserves the prior fallback text when the abort reason is not an
2192
+ // Error; _sleepFn (test seam) is threaded through as the no-signal sleep impl.
2193
+ function _sleepWithAbort(ms, externalSignal, sleepFn = _defaultSleep) {
2194
+ return sleepWithAbort(ms, externalSignal, sleepFn, 'OpenAI OAuth WS retry backoff aborted');
2222
2195
  }
2223
2196
 
2224
2197
  /**
@@ -2274,7 +2247,7 @@ export async function _acquireWithRetry({
2274
2247
  try { err.retryClassifier = classifier; } catch {}
2275
2248
  }
2276
2249
  try {
2277
- process.stderr.write(
2250
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(
2278
2251
  `[openai-oauth-ws] handshake failed after ${attempt}/${HANDSHAKE_MAX_ATTEMPTS} attempts: ${err?.message || err}\n`,
2279
2252
  );
2280
2253
  } catch {}
@@ -2283,7 +2256,7 @@ export async function _acquireWithRetry({
2283
2256
  // Schedule backoff and emit progress.
2284
2257
  const backoff = _backoffFor(attempt);
2285
2258
  try {
2286
- process.stderr.write(
2259
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(
2287
2260
  `[openai-oauth-ws] worker retry ${attempt}/${HANDSHAKE_MAX_ATTEMPTS} (transient: ${classifier}, backoff ${backoff}ms)\n`,
2288
2261
  );
2289
2262
  } catch {}
@@ -2371,6 +2344,14 @@ export async function sendViaWebSocket({
2371
2344
  const MAX_MIDSTREAM_RETRIES = MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT;
2372
2345
  let firstAttemptError = null;
2373
2346
  let firstAttemptClassifier = null;
2347
+ // Known tool names for the leaked-tool-call guard in _streamResponse.
2348
+ // Derived from the exact request body so a recovered leaked call only
2349
+ // synthesizes when it names a tool actually offered to this request.
2350
+ const knownToolNames = new Set(
2351
+ (Array.isArray(body?.tools) ? body.tools : [])
2352
+ .map((t) => (typeof t?.name === 'string' ? t.name : null))
2353
+ .filter(Boolean),
2354
+ );
2374
2355
  // Live-text invariant across attempts: once ANY attempt has relayed a
2375
2356
  // non-empty text chunk to the client, no error thrown out of this function
2376
2357
  // may omit the liveTextEmitted/unsafeToRetry markers — otherwise an
@@ -2379,23 +2360,15 @@ export async function sendViaWebSocket({
2379
2360
  // already-rendered output. A text-emitting attempt is never retry-eligible
2380
2361
  // (_classifyMidstreamError returns null on emittedText), so the surfaced
2381
2362
  // error is frequently an EARLIER attempt's firstAttemptError that never saw
2382
- // the marker; _stampLiveText re-applies it on every throw path.
2383
- let liveTextEmittedAcrossAttempts = false;
2384
- const _stampLiveText = (e) => {
2385
- if (liveTextEmittedAcrossAttempts && e) {
2386
- try { e.liveTextEmitted = true; e.unsafeToRetry = true; } catch {}
2387
- }
2388
- return e;
2389
- };
2390
- // Tool-call relay invariant: same latch pattern as live text — once a tool
2391
- // call was forwarded, surfaced errors must block HTTP fallback / reissue.
2392
- let toolEmittedAcrossAttempts = false;
2393
- const _stampTool = (e) => {
2394
- if (toolEmittedAcrossAttempts && e) {
2395
- try { e.emittedToolCall = true; e.unsafeToRetry = true; } catch {}
2396
- }
2397
- return e;
2398
- };
2363
+ // the marker; stampText re-applies it on every throw path. The latch state
2364
+ // + stamp semantics now come from the shared createStreamSafetyStamps()
2365
+ // factory (retry-classifier.mjs) identical to the former _stampLiveText /
2366
+ // _stampTool closures. markText()/markTool() set the latch (replacing the
2367
+ // liveTextEmittedAcrossAttempts / toolEmittedAcrossAttempts booleans);
2368
+ // stampText/stampTool re-apply the markers on every throw.
2369
+ const _safetyStamps = createStreamSafetyStamps();
2370
+ const _stampLiveText = _safetyStamps.stampText;
2371
+ const _stampTool = _safetyStamps.stampTool;
2399
2372
  // Server-side xAI conversation anchor preserved across mid-stream
2400
2373
  // retries. xAI keys its conversation by previous_response_id alone
2401
2374
  // (sessionToken is null for xAI in _mintSessionToken); a forceFresh
@@ -2639,6 +2612,7 @@ export async function sendViaWebSocket({
2639
2612
  logSuppressedReasoningDeltas,
2640
2613
  traceProvider,
2641
2614
  _timeouts: streamTimeouts,
2615
+ knownToolNames,
2642
2616
  });
2643
2617
  } catch (err) {
2644
2618
  // Snapshot the xAI conversation anchor BEFORE releasing the
@@ -2665,10 +2639,10 @@ export async function sendViaWebSocket({
2665
2639
  // Latch across attempts: even though THIS error is never
2666
2640
  // retry-eligible once text is out, a later/earlier surfaced
2667
2641
  // error (firstAttemptError) must still carry the marker.
2668
- liveTextEmittedAcrossAttempts = true;
2642
+ _safetyStamps.markText();
2669
2643
  }
2670
2644
  if (midState.emittedToolCall) {
2671
- toolEmittedAcrossAttempts = true;
2645
+ _safetyStamps.markTool();
2672
2646
  }
2673
2647
  _stampLiveText(err);
2674
2648
  _stampTool(err);
@@ -2685,7 +2659,7 @@ export async function sendViaWebSocket({
2685
2659
  const backoff = _midstreamBackoffFor(retryNumber);
2686
2660
  try {
2687
2661
  const line = `[openai-oauth-ws] mid-stream recovered: retry ${retryNumber}/${retryLimit} (cause: ${classifier}, backoff ${backoff}ms)\n`;
2688
- process.stderr.write(line);
2662
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(line);
2689
2663
  emittedProgress.push(line);
2690
2664
  } catch {}
2691
2665
  await _sleepWithAbort(backoff, externalSignal, _sleepFn);
@@ -2794,8 +2768,8 @@ export async function sendViaWebSocket({
2794
2768
  transport: 'websocket',
2795
2769
  ws_mode: mode,
2796
2770
  ws_pre_response_created_timeout_ms: WS_PRE_RESPONSE_CREATED_MS,
2797
- ws_first_meaningful_timeout_ms: WS_FIRST_MEANINGFUL_MS,
2798
2771
  ws_inter_chunk_timeout_ms: WS_INTER_CHUNK_MS,
2772
+ ws_idle_ms: WS_IDLE_MS,
2799
2773
  iteration_delta_tokens: deltaTokens,
2800
2774
  reused_connection: reused,
2801
2775
  requested_service_tier: requestedServiceTier,