mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -15,9 +15,15 @@ import {
15
15
  classifyMidstreamError,
16
16
  MIDSTREAM_RETRY_POLICY,
17
17
  sleepWithAbort,
18
+ typedStatusFrom,
18
19
  } from './retry-classifier.mjs';
19
20
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
20
21
  import { scanLeakedToolCalls, createToolCallDedupe } from './anthropic-leaked-toolcall.mjs';
22
+ import {
23
+ NATIVE_SERVER_TOOL_CALL_BLOCK_TYPES,
24
+ NATIVE_SERVER_TOOL_RESULT_BLOCK_TYPES,
25
+ } from './lib/anthropic-native-blocks.mjs';
26
+ import { stampStreamOutcome, STREAM_TRANSPORTS, STREAM_OUTCOME_VERSION } from './lib/stream-outcome.mjs';
21
27
 
22
28
  /** Bounded mid-stream SSE retries (transient stream loss); shared with anthropic.mjs.
23
29
  * Sourced from the single shared retry-budget table (MIDSTREAM_RETRY_POLICY.sse). */
@@ -51,15 +57,32 @@ export function _midstreamSleepWithAbort(ms, signal, sleepFn) {
51
57
  return sleepWithAbort(ms, signal, sleepFn, 'Anthropic OAuth mid-stream retry backoff aborted');
52
58
  }
53
59
 
54
- function _statusForAnthropicSseError(type, message) {
55
- const kind = String(type || '').toLowerCase();
56
- const text = String(message || '').toLowerCase();
57
- if (kind.includes('overload') || text.includes('overload')) return 503;
58
- if (kind.includes('rate_limit') || text.includes('rate limit') || text.includes('quota')) return 429;
59
- if (kind.includes('authentication') || text.includes('authentication') || text.includes('unauthorized')) return 401;
60
- if (kind.includes('permission') || text.includes('forbidden')) return 403;
61
- if (kind.includes('not_found') || text.includes('not found')) return 404;
62
- if (kind.includes('invalid_request')) return 400;
60
+ // Anthropic's documented error-event `type` enumeration → HTTP equivalent.
61
+ // This is STRUCTURED evidence (an explicit wire field with a closed value set),
62
+ // never a message-text guess: an event whose only hint is prose keeps no status
63
+ // at all, so the retry/auth layers see it as unknown and terminate the turn.
64
+ const ANTHROPIC_SSE_ERROR_TYPE_STATUS = new Map([
65
+ ['invalid_request_error', 400],
66
+ ['authentication_error', 401],
67
+ ['permission_error', 403],
68
+ ['not_found_error', 404],
69
+ ['request_too_large', 413],
70
+ ['rate_limit_error', 429],
71
+ ['api_error', 500],
72
+ ['overloaded_error', 503],
73
+ ]);
74
+
75
+ function _statusForAnthropicSseError(event, payload) {
76
+ // 1) A real numeric status carried by the event/error payload.
77
+ const numeric = typedStatusFrom(payload, event);
78
+ if (numeric) return numeric;
79
+ // 2) The explicit structured error type/code (exact enumeration match).
80
+ for (const field of [payload?.type, payload?.code, event?.error?.type, event?.error?.code]) {
81
+ const key = typeof field === 'string' ? field.trim().toLowerCase() : '';
82
+ if (key && ANTHROPIC_SSE_ERROR_TYPE_STATUS.has(key)) {
83
+ return ANTHROPIC_SSE_ERROR_TYPE_STATUS.get(key);
84
+ }
85
+ }
63
86
  return 0;
64
87
  }
65
88
 
@@ -72,7 +95,7 @@ function _anthropicSseError(event) {
72
95
  err.code = 'EANTHROPIC_SSE_ERROR';
73
96
  err.providerErrorType = type;
74
97
  err.requestId = event?.request_id || event?.requestId || null;
75
- const status = _statusForAnthropicSseError(type, message);
98
+ const status = _statusForAnthropicSseError(event, payload);
76
99
  if (status) {
77
100
  err.httpStatus = status;
78
101
  err.status = status;
@@ -80,6 +103,21 @@ function _anthropicSseError(event) {
80
103
  return err;
81
104
  }
82
105
 
106
+ // Anthropic NATIVE (server-side) tool blocks. Anthropic runs these itself and
107
+ // returns both the CALL block (`server_tool_use`, streamed `input`) and its
108
+ // RESULT block (`web_search_tool_result`, …, delivered whole). They are never
109
+ // dispatched to the agent loop, so they only survive a continuation turn if
110
+ // they are captured here and replayed verbatim in original block order. The
111
+ // type sets are shared with the non-streaming normalizer so both transports
112
+ // classify identically (see lib/anthropic-native-blocks.mjs).
113
+
114
+ // Detach the provider payload from the parser's event object without altering
115
+ // any field (opaque server-tool result payloads must round-trip byte-exact).
116
+ function cloneNativeBlock(block) {
117
+ try { return structuredClone(block); }
118
+ catch { return { ...block }; }
119
+ }
120
+
83
121
  export async function parseSSEStream(response, signal, abortStream, onStreamDelta, onToolCall, state, onTextDelta, knownToolNames) {
84
122
  // onStreamDelta is a semantic-progress callback. HTTP headers and raw SSE
85
123
  // bytes prove transport health but must not refresh semantic watchdogs.
@@ -107,6 +145,58 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
107
145
  // it can be round-tripped verbatim on tool-continuation turns (required
108
146
  // back on tool_use turns; empty thinking + signature is valid).
109
147
  const thinkingBlocks = new Map();
148
+ // Ordered NATIVE (server-side) tool blocks, keyed by content_block index.
149
+ // Anthropic executes these itself — they are never dispatched to the agent
150
+ // loop as client tool calls — but they are part of the assistant turn and
151
+ // MUST be replayed verbatim, in original block order, on a continuation
152
+ // turn (pause_turn): a `web_search_tool_result` is only valid immediately
153
+ // after the `server_tool_use` block that produced it.
154
+ const nativeServerToolBlocks = new Map();
155
+ // Streamed `input` JSON for native server-tool CALL blocks (same
156
+ // input_json_delta transport as client tool_use, separate index space).
157
+ const pendingNativeToolInputs = new Map();
158
+ // In-flight tool INPUT state: a client `tool_use` (pendingToolInputs) or an
159
+ // Anthropic-native `server_tool_use` (pendingNativeToolInputs) whose
160
+ // streamed input_json has not reached content_block_stop. While either is
161
+ // non-empty the assistant turn cannot be terminal: the arguments are
162
+ // incomplete, so the call was never pushed and never dispatched.
163
+ const _toolInputInFlight = () => pendingToolInputs.size > 0 || pendingNativeToolInputs.size > 0;
164
+ // Set when a terminal frame (message_stop) arrived while input was still in
165
+ // flight — used only to word the truncated-stream failure precisely.
166
+ let sawTerminalFrameWithPendingInput = false;
167
+ // Ordered assistant content builders. Shared by the SUCCESS return and the
168
+ // truncation failure so a cut-off turn preserves exactly the same completed
169
+ // native/client blocks (verbatim, in original content_block order) that a
170
+ // successful turn would have replayed.
171
+ const _orderedThinkingBlocks = () => (thinkingBlocks.size
172
+ ? [...thinkingBlocks.entries()].sort((a, b) => a[0] - b[0]).map(([, b]) => b)
173
+ : undefined);
174
+ // A native CALL block is seeded at content_block_start and only gains its
175
+ // parsed `input` at content_block_stop, so a block whose input JSON is
176
+ // still streaming is INCOMPLETE and must never enter the replay list: it
177
+ // would be replayed with empty/partial arguments. Completed blocks only.
178
+ const _completedNativeBlocks = () => [...nativeServerToolBlocks.entries()]
179
+ .filter(([index]) => !pendingNativeToolInputs.has(index));
180
+ const _orderedAssistantBlocks = () => {
181
+ const nativeBlocks = _completedNativeBlocks();
182
+ if (!nativeBlocks.length) return undefined;
183
+ return [
184
+ ...[...thinkingBlocks.entries()],
185
+ ...[...textBlocks.entries()]
186
+ .filter(([, text]) => typeof text === 'string' && text.length > 0)
187
+ .map(([index, text]) => [index, { type: 'text', text }]),
188
+ ...nativeBlocks,
189
+ ...[...clientToolUseBlocks.entries()],
190
+ ]
191
+ .sort((a, b) => a[0] - b[0])
192
+ .map(([, block]) => block);
193
+ };
194
+ // Per-index raw text, kept only so the ordered native-block replay list
195
+ // below can interleave text exactly where the provider emitted it.
196
+ const textBlocks = new Map();
197
+ // Client tool_use blocks that were actually dispatched, kept for the same
198
+ // ordered replay list (deduped/skipped calls are intentionally absent).
199
+ const clientToolUseBlocks = new Map();
110
200
  let model = '';
111
201
  let toolCalls = [];
112
202
  let usage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, raw: null };
@@ -240,12 +330,48 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
240
330
  try {
241
331
  err.partialContent = content;
242
332
  err.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
243
- err.pendingToolUse = pendingToolInputs.size > 0;
333
+ // `_toolInputInFlight()` is the single authority for "arguments
334
+ // never finished streaming": a client tool_use OR an Anthropic
335
+ // NATIVE server_tool_use whose input JSON is still open. A mixed
336
+ // turn (dispatched client call, then an incomplete server_tool_use)
337
+ // must stay a continuation failure — promoting it would report a
338
+ // native call that never ran as part of a finished turn.
339
+ err.pendingToolUse = _toolInputInFlight();
244
340
  err.partialModel = model || undefined;
245
341
  err.partialUsage = usage;
246
342
  err.partialStopReason = stopReason || undefined;
247
343
  err.partialHasThinking = hasThinkingContent;
344
+ // A stalled continuation must carry the SAME ordered block state a
345
+ // successful turn returns: Anthropic requires the verbatim thinking
346
+ // blocks (signatures intact) back before tool_use on the next turn,
347
+ // and a native server_tool_use call is only valid immediately
348
+ // followed by its result block. Without these the recovered
349
+ // tool-call turn replays with the native/thinking state dropped.
350
+ err.partialThinkingBlocks = _orderedThinkingBlocks();
351
+ err.partialAssistantBlocks = _orderedAssistantBlocks();
248
352
  } catch { /* best-effort enrichment */ }
353
+ // Canonical stream-outcome contract. Anthropic historically attached
354
+ // ONLY the partial-state fields above, so retry/fallback/persistence
355
+ // consumers had to guess; the record makes terminal-vs-continuation,
356
+ // observed text/reasoning and tool exposure explicit and fail-closed.
357
+ try {
358
+ stampStreamOutcome(err, {
359
+ transport: STREAM_TRANSPORTS.SSE,
360
+ provider: 'anthropic',
361
+ terminalObserved: state?.sawCompleted === true,
362
+ continuation: state?.sawCompleted !== true,
363
+ textEmitted: state?.emittedText === true,
364
+ textObservedChars: content.length,
365
+ // Buffered/empty thinking blocks are not exposure; only
366
+ // relayed reasoning text is.
367
+ reasoningEmitted: state?.emittedThinking === true,
368
+ toolCallsStarted: state?.partialToolCall === true,
369
+ toolCallsComplete: toolCalls.length,
370
+ toolCallsDispatched: state?.emittedToolCall === true ? Math.max(1, toolCalls.length) : 0,
371
+ pendingToolInput: _toolInputInFlight(),
372
+ stallObserved: true,
373
+ });
374
+ } catch { /* stamping is best-effort */ }
249
375
  return err;
250
376
  };
251
377
 
@@ -407,9 +533,31 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
407
533
  inputJson: '',
408
534
  });
409
535
  }
536
+ if (block?.type === 'text') {
537
+ // Seed the ordered text slot (a text block may open
538
+ // with a non-empty `text` before any delta).
539
+ textBlocks.set(
540
+ event.index,
541
+ (textBlocks.get(event.index) || '') + (typeof block.text === 'string' ? block.text : ''),
542
+ );
543
+ }
544
+ if (NATIVE_SERVER_TOOL_CALL_BLOCK_TYPES.has(block?.type)) {
545
+ // Verbatim seed; `input` is completed from the
546
+ // streamed input_json_delta at content_block_stop.
547
+ nativeServerToolBlocks.set(event.index, cloneNativeBlock(block));
548
+ pendingNativeToolInputs.set(event.index, '');
549
+ try { onStreamDelta?.('tool'); } catch {}
550
+ } else if (NATIVE_SERVER_TOOL_RESULT_BLOCK_TYPES.has(block?.type)) {
551
+ // Server-tool RESULT blocks arrive whole (no deltas);
552
+ // keep the payload byte-for-byte for replay.
553
+ nativeServerToolBlocks.set(event.index, cloneNativeBlock(block));
554
+ try { onStreamDelta?.('tool'); } catch {}
555
+ }
410
556
  if (block?.type === 'thinking' || block?.type === 'redacted_thinking') {
411
- if (state) state.emittedThinking = true;
412
557
  if (block.type === 'redacted_thinking') {
558
+ // Opaque redacted payload: real reasoning
559
+ // content, exposed as a block.
560
+ if (state) state.emittedThinking = true;
413
561
  // Redacted blocks round-trip EXACTLY as
414
562
  // {type:'redacted_thinking',data} — no thinking/
415
563
  // signature fields (the API rejects the extras).
@@ -442,6 +590,10 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
442
590
  // ttftMs was always null and reported as 0ms.
443
591
  if (state && !state.ttftAt) state.ttftAt = Date.now();
444
592
  if (delta?.type === 'text_delta') {
593
+ // Ordered verbatim text for native-block replay —
594
+ // independent of the leak-guard's visible-stream
595
+ // bookkeeping below (which may hold text back).
596
+ textBlocks.set(event.index, (textBlocks.get(event.index) || '') + (delta.text || ''));
445
597
  // Live text relay (gateway): forward the explicit
446
598
  // text chunk. thinking/signature/input_json deltas
447
599
  // intentionally stay off this path.
@@ -473,7 +625,14 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
473
625
  }
474
626
  }
475
627
  if (delta?.type === 'thinking_delta' || delta?.type === 'signature_delta') {
476
- if (state) state.emittedThinking = true;
628
+ // Only actual reasoning TEXT counts as exposed
629
+ // reasoning. An empty thinking block (a
630
+ // signature-only delta from a display-omitted
631
+ // model) shows the user nothing, so it must not
632
+ // deny a replay.
633
+ if (state && delta.type === 'thinking_delta' && delta.thinking) {
634
+ state.emittedThinking = true;
635
+ }
477
636
  // Extended-thinking block: provider reasoning without
478
637
  // user-visible text. Track presence so a final turn
479
638
  // that emitted ONLY thinking (no text_delta, no
@@ -501,6 +660,13 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
501
660
  }
502
661
  }
503
662
  if (delta?.type === 'input_json_delta') {
663
+ if (pendingNativeToolInputs.has(event.index)) {
664
+ pendingNativeToolInputs.set(
665
+ event.index,
666
+ pendingNativeToolInputs.get(event.index) + (delta.partial_json || ''),
667
+ );
668
+ try { onStreamDelta?.('tool'); } catch {}
669
+ }
504
670
  if (state) state.partialToolCall = true;
505
671
  const pending = pendingToolInputs.get(event.index);
506
672
  if (pending) {
@@ -511,6 +677,25 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
511
677
  }
512
678
 
513
679
  if (event.type === 'content_block_stop') {
680
+ if (pendingNativeToolInputs.has(event.index)) {
681
+ const rawInput = pendingNativeToolInputs.get(event.index);
682
+ pendingNativeToolInputs.delete(event.index);
683
+ const nativeBlock = nativeServerToolBlocks.get(event.index);
684
+ if (nativeBlock) {
685
+ let input = nativeBlock.input && typeof nativeBlock.input === 'object'
686
+ ? nativeBlock.input
687
+ : {};
688
+ if (rawInput) {
689
+ try { input = JSON.parse(rawInput); }
690
+ catch (parseErr) {
691
+ process.stderr.write(`[anthropic-oauth] native server-tool input JSON.parse failed (type=${nativeBlock.type}, id=${nativeBlock.id || ''}): ${parseErr?.message || parseErr}\n`);
692
+ input = {};
693
+ }
694
+ }
695
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) input = {};
696
+ nativeBlock.input = input;
697
+ }
698
+ }
514
699
  const pending = pendingToolInputs.get(event.index);
515
700
  if (pending) {
516
701
  // Bare JSON.parse threw straight up into the
@@ -558,6 +743,15 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
558
743
  // dispatch (the marker path is unaffected).
559
744
  if (_toolDedupe.shouldDispatch(call.name, call.arguments)) {
560
745
  toolCalls.push(call);
746
+ // Ordered replay copy of the dispatched call —
747
+ // skipped/deduped calls stay out so a replayed
748
+ // turn never carries an orphan tool_use.
749
+ clientToolUseBlocks.set(event.index, {
750
+ type: 'tool_use',
751
+ id: call.id,
752
+ name: call.name,
753
+ input: call.arguments,
754
+ });
561
755
  if (state) state.emittedToolCall = true;
562
756
  // Eager dispatch: let the loop start this tool
563
757
  // before message_stop arrives. The loop keys
@@ -588,13 +782,43 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
588
782
  usage.outputTokens = event.usage.output_tokens || 0;
589
783
  usage.raw = { ...(usage.raw || {}), ...event.usage };
590
784
  }
591
- if (stopReason === 'tool_use' && toolCalls.length > 0 && pendingToolInputs.size === 0) {
785
+ // A terminal stop_reason while ANY tool input is still
786
+ // streaming ends the turn truncated, immediately: the
787
+ // model declared it stopped sampling, so the pending
788
+ // tool arguments can never complete. Do not wait for
789
+ // message_stop/EOF (which may never arrive, or would
790
+ // arrive as ping-only wedge) — enter the canonical
791
+ // truncation failure now, with zero dispatch for the
792
+ // incomplete call.
793
+ if (event.delta?.stop_reason && _toolInputInFlight()) {
794
+ sawTerminalFrameWithPendingInput = true;
795
+ break streamLoop;
796
+ }
797
+ // Early terminal on a tool_use stop_reason is only valid
798
+ // when NO tool input is still streaming — client
799
+ // (pendingToolInputs) or Anthropic-native server tool
800
+ // (pendingNativeToolInputs). An in-flight input means the
801
+ // arguments never completed, so the turn is truncated,
802
+ // not finished (the guard after the loop owns it).
803
+ if (stopReason === 'tool_use' && toolCalls.length > 0 && !_toolInputInFlight()) {
592
804
  if (state) state.sawCompleted = true;
593
805
  break streamLoop;
594
806
  }
595
807
  }
596
808
  if (event.type === 'message_stop') {
597
- if (state) state.sawCompleted = true;
809
+ // A terminal frame does NOT finish a turn whose tool
810
+ // input is still incomplete. `message_stop` while a
811
+ // tool_use/server_tool_use input_json is mid-flight means
812
+ // the model's arguments were cut off: nothing was
813
+ // dispatched for that call and no partial success may be
814
+ // promoted. Leave sawCompleted false and fall through to
815
+ // the truncated-stream guard below, which throws the
816
+ // canonical TruncatedStreamError (pendingToolUse:true).
817
+ if (_toolInputInFlight()) {
818
+ sawTerminalFrameWithPendingInput = true;
819
+ } else if (state) {
820
+ state.sawCompleted = true;
821
+ }
598
822
  // Anthropic streams can keep emitting `:ping` keepalive
599
823
  // frames after `message_stop`; if we wait for EOF the
600
824
  // outer reader.read() loop hangs indefinitely. Break
@@ -630,10 +854,12 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
630
854
  // Throw a typed truncated-stream error so the loop can decide whether
631
855
  // to retry, surface, or escalate instead of accepting the partial.
632
856
  if (state?.sawMessageStart && !state?.sawCompleted) {
633
- const pendingToolUse = pendingToolInputs.size > 0;
857
+ const pendingToolUse = _toolInputInFlight();
634
858
  const err = Object.assign(
635
859
  new Error(
636
- `Anthropic OAuth SSE stream truncated: message_start without message_stop`
860
+ (sawTerminalFrameWithPendingInput
861
+ ? `Anthropic OAuth SSE stream truncated: terminal frame with incomplete tool input`
862
+ : `Anthropic OAuth SSE stream truncated: message_start without message_stop`)
637
863
  + (pendingToolUse ? ` (pending tool_use input)` : ''),
638
864
  ),
639
865
  {
@@ -644,6 +870,43 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
644
870
  stopReason,
645
871
  },
646
872
  );
873
+ // Completed client tool calls / native server-tool blocks captured
874
+ // before the cut-off ride on the error so the interrupted-turn
875
+ // persistence path keeps them; the INCOMPLETE call is absent (it was
876
+ // never pushed and never dispatched). `partialAssistantBlocks` is
877
+ // the SAME ordered native/client block list the success path
878
+ // returns as `assistantBlocks` — a native server_tool_use call and
879
+ // its result block are only valid in original order, so truncation
880
+ // must not drop the completed ones.
881
+ try {
882
+ err.partialContent = content;
883
+ err.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
884
+ err.partialModel = model || undefined;
885
+ err.partialUsage = usage;
886
+ err.partialHasThinking = hasThinkingContent;
887
+ err.partialThinkingBlocks = _orderedThinkingBlocks();
888
+ err.partialAssistantBlocks = _orderedAssistantBlocks();
889
+ err.partialStopReason = stopReason || undefined;
890
+ } catch { /* best-effort enrichment */ }
891
+ // Truncation is a continuation, never a terminal turn. A pending
892
+ // tool_use input alone stays replay-safe (nothing was dispatched);
893
+ // exposed text/reasoning or a complete tool call is not.
894
+ try {
895
+ stampStreamOutcome(err, {
896
+ transport: STREAM_TRANSPORTS.SSE,
897
+ provider: 'anthropic',
898
+ terminalObserved: false,
899
+ continuation: true,
900
+ truncatedStream: true,
901
+ textEmitted: state?.emittedText === true,
902
+ textObservedChars: content.length,
903
+ reasoningEmitted: state?.emittedThinking === true,
904
+ toolCallsStarted: state?.partialToolCall === true && toolCalls.length > 0,
905
+ toolCallsComplete: toolCalls.length,
906
+ toolCallsDispatched: state?.emittedToolCall === true ? Math.max(1, toolCalls.length) : 0,
907
+ pendingToolInput: pendingToolUse,
908
+ });
909
+ } catch { /* stamping is best-effort */ }
647
910
  throw err;
648
911
  }
649
912
 
@@ -660,11 +923,14 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
660
923
  // signature) for round-tripping on tool-continuation turns. Emitted
661
924
  // in content_block index order. Empty thinking + signature is a
662
925
  // valid block (display-omitted models) and is kept intact.
663
- thinkingBlocks: thinkingBlocks.size
664
- ? [...thinkingBlocks.entries()]
665
- .sort((a, b) => a[0] - b[0])
666
- .map(([, b]) => b)
667
- : undefined,
926
+ thinkingBlocks: _orderedThinkingBlocks(),
927
+ // Complete ordered assistant content, emitted ONLY when the turn
928
+ // used Anthropic native server tools. Those blocks cannot be
929
+ // reconstructed from content/toolCalls/thinkingBlocks, so the loop
930
+ // replays this list verbatim (see toAnthropicMessages'
931
+ // assistantBlocks branch). Absent for every ordinary turn, which
932
+ // keeps the existing text/thinking/tool_use lowering untouched.
933
+ assistantBlocks: _orderedAssistantBlocks(),
668
934
  };
669
935
  } finally {
670
936
  if (idleTimer) clearTimeout(idleTimer);
@@ -697,3 +963,36 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
697
963
  export function _classifyMidstreamError(err, state) {
698
964
  return classifyMidstreamError(err, state, SSE_MIDSTREAM_POLICY);
699
965
  }
966
+
967
+ /**
968
+ * Wrapper-level canonical stamp for the Anthropic providers (API key + OAuth).
969
+ *
970
+ * The PARSER is the authority on what the stream produced: it knows the
971
+ * difference between "a tool_use block started" (coarse
972
+ * `midState.partialToolCall`, set on the first input_json_delta) and "a tool
973
+ * input never completed and was never dispatched" (`pendingToolInput`, which
974
+ * stays replay-safe). Merging the coarse mid-state flag over a stamped parser
975
+ * verdict would downgrade an idempotent truncation into a non-replayable one.
976
+ *
977
+ * So: when the error already carries an authoritative parser record, only
978
+ * genuinely NEW exposure evidence observed by the wrapper is merged (relayed
979
+ * text, exposed thinking, dispatched tool call). Otherwise (transport/SDK
980
+ * errors that never reached the parser) the full mid-state is stamped.
981
+ */
982
+ export function stampAnthropicStreamOutcome(err, midState, { provider = 'anthropic' } = {}) {
983
+ const hasParserVerdict = err?.streamOutcome?.version === STREAM_OUTCOME_VERSION;
984
+ if (hasParserVerdict) {
985
+ return stampStreamOutcome(err, {
986
+ transport: STREAM_TRANSPORTS.SSE,
987
+ provider,
988
+ textEmitted: midState?.emittedText === true,
989
+ reasoningEmitted: midState?.emittedThinking === true,
990
+ toolCallsDispatched: midState?.emittedToolCall === true ? 1 : 0,
991
+ });
992
+ }
993
+ return stampStreamOutcome(err, midState || {}, {
994
+ transport: STREAM_TRANSPORTS.SSE,
995
+ provider,
996
+ continuation: midState?.sawCompleted !== true,
997
+ });
998
+ }
@@ -13,6 +13,7 @@ import {
13
13
  withRetry,
14
14
  retryAfterMsFromError,
15
15
  } from './retry-classifier.mjs';
16
+ import { readStreamOutcome } from './lib/stream-outcome.mjs';
16
17
  import { traceAgentUsage } from '../agent-trace.mjs';
17
18
  import {
18
19
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
@@ -24,6 +25,7 @@ import {
24
25
  ANTHROPIC_MAX_MIDSTREAM_RETRIES,
25
26
  parseSSEStream,
26
27
  _classifyMidstreamError,
28
+ stampAnthropicStreamOutcome,
27
29
  } from './anthropic-sse.mjs';
28
30
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
29
31
  import {
@@ -108,15 +110,11 @@ export class AnthropicProvider {
108
110
  return await this._doSend(messages, model, tools, sendOpts);
109
111
  } catch (err) {
110
112
  const status = Number(err?.status || err?.httpStatus || err?.response?.status || 0);
111
- const outputWasExposed = err?.liveTextEmitted === true
112
- || err?.emittedText === true
113
- || err?.emittedToolCall === true
114
- || err?.toolCallEmitted === true
115
- || err?.partialToolCall === true
116
- || err?.emittedThinking === true
117
- || err?.unsafeToRetry === true;
118
- if (status === 401
119
- && !outputWasExposed) {
113
+ // Auth recovery re-issues the whole turn, so it is a REPLAY: a
114
+ // typed 401 is re-sent once after reloading the key, unless the
115
+ // failure already exposed output or dispatched a tool call.
116
+ const replayPermitted = readStreamOutcome(err).replaySafe === true;
117
+ if (status === 401 && replayPermitted) {
120
118
  process.stderr.write(`[provider] Auth error, re-reading provider authentication...\n`);
121
119
  this.reloadApiKey();
122
120
  return await this._doSend(messages, model, tools, sendOpts);
@@ -297,6 +295,16 @@ export class AnthropicProvider {
297
295
  thinkingBlocks: Array.isArray(parseResult.thinkingBlocks) && parseResult.thinkingBlocks.length
298
296
  ? parseResult.thinkingBlocks
299
297
  : undefined,
298
+ // Ordered NATIVE server-tool blocks (server_tool_use +
299
+ // *_tool_result) exist ONLY in this verbatim list — they are
300
+ // never dispatched as client tool calls and cannot be rebuilt
301
+ // from content/toolCalls/thinkingBlocks. Dropping them here
302
+ // broke `pause_turn` continuations on the API-key path (the
303
+ // OAuth provider returns the parse result as-is). Absent for
304
+ // ordinary turns, so nothing else changes.
305
+ assistantBlocks: Array.isArray(parseResult.assistantBlocks) && parseResult.assistantBlocks.length
306
+ ? parseResult.assistantBlocks
307
+ : undefined,
300
308
  usage: {
301
309
  inputTokens: input,
302
310
  outputTokens: output,
@@ -511,6 +519,14 @@ export class AnthropicProvider {
511
519
  _fallbackTriggered: true,
512
520
  });
513
521
  }
522
+ // Canonical stream-outcome contract (same record as the
523
+ // OAuth SSE / WS / HTTP-SSE transports). The parser verdict
524
+ // wins over coarse midState flags; new wrapper-observed
525
+ // exposure is still merged. Aliases preserved.
526
+ let _outcome = null;
527
+ try {
528
+ _outcome = stampAnthropicStreamOutcome(err, midState, { provider: 'anthropic' });
529
+ } catch { /* stamping is best-effort */ }
514
530
  // Acknowledged reset semantics let the owner tombstone this
515
531
  // attempt before the full request is restarted non-streaming.
516
532
  // Without that acknowledgement, recoverNonStreaming stamps
@@ -518,13 +534,14 @@ export class AnthropicProvider {
518
534
  if (midState.emittedText) {
519
535
  return await recoverNonStreaming(midState, err, streamController);
520
536
  }
521
- if (midState.emittedToolCall || midState.partialToolCall || midState.emittedThinking) {
522
- try {
523
- err.emittedToolCall = !!midState.emittedToolCall;
524
- err.partialToolCall = !!midState.partialToolCall;
525
- err.emittedThinking = !!midState.emittedThinking;
526
- err.unsafeToRetry = true;
527
- } catch {}
537
+ // Real exposure (dispatched tool, exposed thinking, or a
538
+ // parser-recorded started/complete call) is a replay
539
+ // boundary. The canonical merge above already recorded it
540
+ // AND wrote the compatibility aliases; writing coarse
541
+ // midState flags here would DOWNGRADE the parser's
542
+ // authoritative verdict that an incomplete, never
543
+ // dispatched tool input is still replay-safe.
544
+ if (_outcome?.replayUnsafe === true) {
528
545
  try { streamController.abort?.(err); } catch {}
529
546
  throw err;
530
547
  }
@@ -546,9 +563,7 @@ export class AnthropicProvider {
546
563
  }
547
564
  if (classifyError(err) === 'transient'
548
565
  && !midState.sawMessageStart
549
- && !midState.emittedToolCall
550
- && !midState.partialToolCall
551
- && !midState.emittedThinking
566
+ && _outcome?.replayUnsafe !== true
552
567
  && attemptIndex < MAX_MIDSTREAM_RETRIES) {
553
568
  firstAttemptError = err;
554
569
  firstAttemptClassifier = err?.providerErrorType || 'sse_transient';
@@ -563,9 +578,7 @@ export class AnthropicProvider {
563
578
  }
564
579
  if ((err?.truncatedStream === true || err?.code === 'TRUNCATED_STREAM')
565
580
  && classifyError(err) === 'transient'
566
- && !midState.emittedToolCall
567
- && !midState.partialToolCall
568
- && !midState.emittedThinking
581
+ && _outcome?.replayUnsafe !== true
569
582
  && attemptIndex < MAX_MIDSTREAM_RETRIES) {
570
583
  firstAttemptError = err;
571
584
  firstAttemptClassifier = 'truncated_stream';