mixdog 0.9.86 → 0.9.88

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 (180) hide show
  1. package/package.json +14 -8
  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 +5 -1
  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 +59 -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 +147 -16
  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 +144 -10
  43. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  44. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  45. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  46. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  48. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  49. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  50. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  51. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  52. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  53. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  54. package/src/runtime/agent/orchestrator/session/manager.mjs +8 -1
  55. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  56. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  57. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  58. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  59. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  60. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  61. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  62. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  63. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  64. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  65. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  66. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  67. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  68. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  69. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  70. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  71. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  72. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  73. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  75. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  76. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  77. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  79. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +2 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  81. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  82. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  83. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
  84. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  85. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +67 -3
  86. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  87. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +203 -38
  88. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -1
  89. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +73 -19
  90. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  91. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -8
  92. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  93. package/src/runtime/agent/orchestrator/tools/token-manifest.json +23 -2
  94. package/src/runtime/media/lanes.mjs +2 -2
  95. package/src/runtime/media/renditions.mjs +101 -17
  96. package/src/runtime/media/renditions.test.mjs +54 -0
  97. package/src/runtime/media/store.mjs +111 -43
  98. package/src/runtime/media/store.test.mjs +53 -11
  99. package/src/runtime/memory/index.mjs +26 -50
  100. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  101. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  102. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  103. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  104. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  105. package/src/runtime/memory/lib/http-router.mjs +4 -0
  106. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  107. package/src/runtime/memory/lib/memory-action-handlers.mjs +25 -19
  108. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  109. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  110. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  111. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  112. package/src/runtime/memory/tool-defs.mjs +22 -6
  113. package/src/runtime/shared/atomic-file.mjs +5 -1
  114. package/src/runtime/shared/child-guardian.mjs +52 -2
  115. package/src/runtime/shared/provider-api-key.mjs +22 -5
  116. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  117. package/src/session-runtime/config-helpers.mjs +6 -5
  118. package/src/session-runtime/lifecycle-api.mjs +27 -0
  119. package/src/session-runtime/media-api.mjs +31 -16
  120. package/src/session-runtime/prewarm.mjs +2 -24
  121. package/src/session-runtime/provider-auth-api.mjs +8 -0
  122. package/src/session-runtime/provider-usage.mjs +30 -5
  123. package/src/session-runtime/runtime-core.mjs +298 -494
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +372 -0
  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 +383 -0
  131. package/src/standalone/agent-tool/spawn-flow.mjs +438 -0
  132. package/src/standalone/agent-tool/tag-registry.mjs +340 -0
  133. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  134. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  135. package/src/standalone/agent-tool.mjs +115 -1166
  136. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  137. package/src/standalone/channel-daemon.mjs +8 -0
  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 +47 -3
  141. package/src/tui/App.jsx +367 -2579
  142. package/src/tui/app/app-view.jsx +504 -0
  143. package/src/tui/app/create-app-pickers.mjs +313 -0
  144. package/src/tui/app/prompt-submit.mjs +501 -0
  145. package/src/tui/app/resume-picker.mjs +7 -2
  146. package/src/tui/app/shell-layout.mjs +563 -0
  147. package/src/tui/app/slash-dispatch.mjs +13 -3
  148. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  149. package/src/tui/app/transcript-window.mjs +17 -93
  150. package/src/tui/app/usage-context-panels.mjs +288 -0
  151. package/src/tui/app/use-copy-selection.mjs +56 -0
  152. package/src/tui/app/use-global-key-input.mjs +151 -0
  153. package/src/tui/app/use-pasted-buffers.mjs +115 -0
  154. package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
  155. package/src/tui/app/use-prompt-hint.mjs +58 -0
  156. package/src/tui/app/use-prompt-queue-history.mjs +100 -0
  157. package/src/tui/app/use-terminal-chrome.mjs +72 -0
  158. package/src/tui/app/use-transcript-activity.mjs +131 -0
  159. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  160. package/src/tui/app/use-transcript-window.mjs +64 -114
  161. package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
  162. package/src/tui/components/Markdown.jsx +19 -2
  163. package/src/tui/components/Message.jsx +8 -3
  164. package/src/tui/components/ToolExecution.jsx +7 -3
  165. package/src/tui/dist/index.mjs +9889 -8999
  166. package/src/tui/engine/live-share.mjs +15 -5
  167. package/src/tui/engine/render-timing.mjs +2 -2
  168. package/src/tui/engine/session-api-ext.mjs +97 -4
  169. package/src/tui/engine/session-flow.mjs +3 -1
  170. package/src/tui/engine.mjs +2 -2
  171. package/src/tui/hooks/useSharedTick.mjs +0 -2
  172. package/src/tui/index.jsx +5 -5
  173. package/src/ui/statusline-agents.mjs +44 -11
  174. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  175. package/src/workflows/default/WORKFLOW.md +3 -3
  176. package/src/workflows/solo/WORKFLOW.md +1 -1
  177. package/src/runtime/media/index.mjs +0 -18
  178. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  179. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  180. package/src/standalone/agent-shard/shard-pool.mjs +0 -442
@@ -4,17 +4,17 @@
4
4
  * Single source of truth across every provider (openai-oauth-ws, openai-oauth,
5
5
  * anthropic-oauth, anthropic, gemini, openai-ws, openai-compat).
6
6
  *
7
- * Goal: when a provider returns a transient server-side condition we should
8
- * retry; when it returns a deterministic refusal (auth, permission, quota)
9
- * we should fail fast. Mid-stream WS events (server-supplied error / response
10
- * .failed messages) historically lost their HTTP status because the message
11
- * was wrapped without classification — that left "Our servers are currently
12
- * overloaded" indistinguishable from a permanent failure to the retry layer.
7
+ * Goal: when a provider returns a TYPED transient server-side condition we
8
+ * should retry; when it returns a deterministic refusal (auth, permission,
9
+ * quota) we should fail fast. Evidence is structural only — HTTP status,
10
+ * Node errno, SDK error type, WS close code, or a typed field on a wire event.
11
+ * Error MESSAGE TEXT is never parsed into a status/transience/auth verdict:
12
+ * an untyped failure stays 'unknown' and is surfaced, not retried.
13
13
  *
14
14
  * Usage:
15
- * import { classifyError, populateHttpStatusFromMessage } from './retry-classifier.mjs'
15
+ * import { classifyError, typedStatusFrom } from './retry-classifier.mjs'
16
16
  * const kind = classifyError(err) // 'auth' | 'permanent' | 'transient' | 'unknown'
17
- * populateHttpStatusFromMessage(err) // mutates err.httpStatus if message hints at one
17
+ * typedStatusFrom(event.response?.error, event) // structured status, or 0
18
18
  */
19
19
 
20
20
  import {
@@ -24,6 +24,9 @@ import {
24
24
  PROVIDER_RETRY_MAX_ATTEMPTS,
25
25
  createTimeoutSignal,
26
26
  } from '../stall-policy.mjs'
27
+ import { readStreamOutcome } from './lib/stream-outcome.mjs'
28
+
29
+ export { readStreamOutcome, stampStreamOutcome, isReplaySafe, isReplayUnsafe, canPromoteToSuccess, hasObservedOutput, hasDispatchedToolCalls, STREAM_TRANSPORTS } from './lib/stream-outcome.mjs'
27
30
 
28
31
  // HTTP statuses considered transient — safe to retry with backoff.
29
32
  // 408 — request timeout
@@ -39,47 +42,23 @@ const TRANSIENT_STATUSES = new Set([408, 409])
39
42
  const AUTH_STATUSES = new Set([401, 403])
40
43
  const PERMANENT_STATUSES = new Set([400, 404, 405, 410, 415, 422])
41
44
 
42
- // Server-message text patterns. Used when a WS / SSE event carries an error
43
- // payload but no explicit status code — we sniff the text and assign the most
44
- // likely HTTP equivalent so the retry layer can use the same rules.
45
- const MESSAGE_PATTERNS = [
46
- // OpenAI OAuth/API sometimes surfaces generic backend failures only as
47
- // message text plus a request ID, with no HTTP status on the WS event.
48
- { regex: /(?:an error occurred while processing your request|please include the request id)/i, status: 503 },
49
- // Overload / transient 5xx — server is asking us to back off. The `\b`
50
- // anchor is intentionally OMITTED on the trailing side of `overload` so
51
- // "overloaded" / "overloading" both match (inflected forms are common in
52
- // server error text).
53
- { regex: /(?:overload(?:ed|ing)?|temporarily unavailable|try again later|service unavailable|bad gateway|gateway timeout)/i, status: 503 },
54
- // Explicit 5xx mention.
55
- { regex: /\b(?:5\d\d|http 5\d\d)\b/i, status: 503 },
56
- // Rate limit / quota — same retry posture as 429 but treat as permanent
57
- // by classifyError because per-call retry won't change the answer (the
58
- // window must elapse). Providers that want time-bounded retry should
59
- // honor Retry-After on the original response, not loop here.
60
- { regex: /(?:rate[_ -]?limit|quota|too many requests|resource exhausted|insufficient_quota|quota_exceeded)/i, status: 429 },
61
- // Auth — never retryable from our side.
62
- { regex: /\b(?:unauthorized|unauthorised|authentication|not authenticated|token expired|access token|invalid api key)\b/i, status: 401 },
63
- { regex: /\b(?:forbidden|permission denied|policy violation)\b/i, status: 403 },
64
- ]
45
+ // Structured status fields a provider error / wire event may carry. A value is
46
+ // accepted only when it is a real numeric HTTP status; string codes
47
+ // ('server_error', 'forbidden', ...) and free text are ignored, so nothing is
48
+ // ever synthesized from a message.
49
+ const TYPED_STATUS_KEYS = ['httpStatus', 'http_status', 'status', 'statusCode', 'status_code', 'code']
65
50
 
66
51
  /**
67
- * Inspect `err.message` (or the explicit `msg` argument) and, if the text
68
- * matches one of the known transient/permanent patterns, set `err.httpStatus`
69
- * to the corresponding code. No-op when httpStatus is already set.
70
- *
71
- * Returns the resolved httpStatus (existing or newly assigned), or 0 when
72
- * nothing matched.
52
+ * Read the first TYPED HTTP status carried by any of `sources` (error object,
53
+ * wire event, event.response.error payload, ...). Returns 0 when none of them
54
+ * declares one. Never inspects message text.
73
55
  */
74
- export function populateHttpStatusFromMessage(err, msg = null) {
75
- if (!err || typeof err !== 'object') return 0
76
- if (Number(err.httpStatus) > 0) return Number(err.httpStatus)
77
- const text = String(msg ?? err.message ?? '')
78
- if (!text) return 0
79
- for (const { regex, status } of MESSAGE_PATTERNS) {
80
- if (regex.test(text)) {
81
- err.httpStatus = status
82
- return status
56
+ export function typedStatusFrom(...sources) {
57
+ for (const source of sources) {
58
+ if (!source || typeof source !== 'object') continue
59
+ for (const key of TYPED_STATUS_KEYS) {
60
+ const n = Number(source[key])
61
+ if (Number.isFinite(n) && n >= 100 && n <= 599) return Math.floor(n)
83
62
  }
84
63
  }
85
64
  return 0
@@ -98,12 +77,13 @@ export function populateHttpStatusFromMessage(err, msg = null) {
98
77
  */
99
78
  export function classifyError(err) {
100
79
  if (!err) return 'unknown'
101
- // Once a streamed tool call has been surfaced to the loop, retrying the same
102
- // provider turn can double-execute that tool. Providers mark these stream
103
- // failures as unsafe so the shared retry wrapper fails fast.
104
- if (err.emittedToolCall === true || err.toolCallEmitted === true
105
- || err.partialToolCall === true || err.emittedThinking === true
106
- || err.unsafeToRetry === true) return 'permanent'
80
+ // Canonical stream-outcome contract owns replay safety: exposed
81
+ // text/reasoning or a dispatched/ambiguous complete tool call makes
82
+ // re-issuing the same turn unsafe (double side effect / output
83
+ // concatenation) → 'permanent'. Everything else stays classifiable by the
84
+ // typed rules below; an unknown/untyped failure ends as 'unknown', never a
85
+ // blanket retry.
86
+ if (readStreamOutcome(err).replayUnsafe === true) return 'permanent'
107
87
  // Cancellation is a caller decision, never a transport symptom. Anthropic's
108
88
  // APIUserAbortError inherits Error without overriding `name`, so recognize
109
89
  // only exact SDK constructor/type markers (plus standard AbortError markers)
@@ -112,7 +92,7 @@ export function classifyError(err) {
112
92
  if (chain.some(isExplicitUserAbortError)) return 'permanent'
113
93
 
114
94
  // Current typed HTTP status outranks stale stream/connection annotations.
115
- const status = Number(err.httpStatus || err.status || err.response?.status || 0) || populateHttpStatusFromMessage(err)
95
+ const status = Number(err.httpStatus || err.status || err.response?.status || 0) || 0
116
96
  if (AUTH_STATUSES.has(status)) return 'auth'
117
97
  if (status === 429) return 'permanent'
118
98
  if (PERMANENT_STATUSES.has(status)
@@ -129,14 +109,10 @@ export function classifyError(err) {
129
109
  // transient: we can retry the same request and may succeed.
130
110
  if (chain.some((item) => TRANSIENT_ERROR_CODES.has(String(item?.code || '')))) return 'transient'
131
111
  // The Anthropic SDK uses APIConnectionError for transport failures which
132
- // may not carry a Node errno. Native fetch commonly wraps the errno in
133
- // cause.code, or exposes only TypeError("fetch failed").
134
- const name = String(err.name || '')
135
- const message = String(err.message || '')
136
- if (name === 'APIConnectionError'
137
- || (name === 'TypeError' && /fetch failed|network error/i.test(message))) {
138
- return 'transient'
139
- }
112
+ // may not carry a Node errno. Native fetch wraps its errno in cause.code,
113
+ // which the bounded chain check above already covers — a bare
114
+ // TypeError('fetch failed') with no errno stays 'unknown'.
115
+ if (String(err.name || '') === 'APIConnectionError') return 'transient'
140
116
 
141
117
  return 'unknown'
142
118
  }
@@ -261,8 +237,6 @@ function isPermanentQuotaError(err) {
261
237
  for (const item of boundedCauseChain(err)) {
262
238
  const codes = [item?.code, item?.error?.code]
263
239
  if (codes.some((code) => permanentCodes.has(String(code || '').toLowerCase()))) return true
264
- const text = `${String(item?.message || '')} ${String(item?.error?.message || '')}`
265
- if (/insufficient_quota|quota[_ -]?exceeded|resource exhausted/i.test(text)) return true
266
240
  }
267
241
  return false
268
242
  }
@@ -389,12 +363,13 @@ export function classifyMidstreamError(err, signals, policy = {}) {
389
363
 
390
364
  // WebSocket classification consumes the provider's stream-state signals.
391
365
  function _classifyMidstreamWs(err, state, attemptIndex, policy) {
392
- if (state.sawCompleted) return null
393
- // Once a tool call has been dispatched, no transport outcome is replay-safe.
394
- // This includes a nominal close-1000 before response.completed: the tool may
395
- // already be executing, so retry/fallback could duplicate its side effect.
396
- if (state.emittedToolCall) return null
397
- if (state.emittedText || err?.liveTextEmitted) return null
366
+ // Terminal + replay gate. A mid-stream retry re-issues the turn, so it is
367
+ // denied once visible output was relayed or a tool call was dispatched
368
+ // (including a nominal close-1000 before response.completed: the tool may be
369
+ // executing). Everything below is the typed transient classification.
370
+ const outcome = readStreamOutcome(err, state)
371
+ if (outcome.terminalObserved) return null
372
+ if (outcome.replaySafe !== true) return null
398
373
  if (err?.wsFrameTooLarge || state.wsFrameTooLarge) {
399
374
  return _allowMidstream('ws_frame_too_large', attemptIndex, policy)
400
375
  }
@@ -418,8 +393,7 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
418
393
  if (name === 'StreamStalledAbortError' || name === 'StreamStalledError'
419
394
  || err?.code === 'ESTREAMSTALL' || err?.streamStalled === true
420
395
  || state.watchdogAbort === 'StreamStalledAbortError') {
421
- // A stall AFTER a tool emit is unsafe to replay (double side-effect).
422
- if (err?.unsafeToRetry === true) return null
396
+ // A stall after any exposure was already denied by the outcome gate.
423
397
  return _allowMidstream('stream_stalled', attemptIndex, policy)
424
398
  }
425
399
  if (closeCode === 4000) return _allowMidstream('ws_4000', attemptIndex, policy)
@@ -444,9 +418,6 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
444
418
  const name = err?.name || ''
445
419
  if (name === 'AgentStallAbortError') return _allowMidstream('agent_stall', attemptIndex, policy)
446
420
  if (name === 'StreamStalledAbortError' || name === 'StreamStalledError' || err?.code === 'ESTREAMSTALL' || err?.streamStalled === true) {
447
- // A stall that fired AFTER a tool call was emitted is unsafe to replay
448
- // (double side-effect); surface it as terminal so the turn is not retried.
449
- if (err?.unsafeToRetry === true) return null
450
421
  return _allowMidstream('stream_stalled', attemptIndex, policy)
451
422
  }
452
423
 
@@ -463,23 +434,42 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
463
434
 
464
435
  const failed = err?.responseFailed || state.responseFailedPayload
465
436
  if (failed) {
466
- try {
467
- const blob = JSON.stringify(failed).toLowerCase()
468
- if (blob.includes('stream_disconnected')) return _allowMidstream('response_failed_disconnected', attemptIndex, policy)
469
- if (blob.includes('network_error')) return _allowMidstream('response_failed_network', attemptIndex, policy)
470
- if (blob.includes('auth context expired')) return _allowMidstream('response_failed_auth_expired', attemptIndex, policy)
471
- } catch {}
437
+ // STRUCTURED evidence only: the failure's own numeric status and its
438
+ // explicit error code/type field. The payload is never stringified and
439
+ // searched — a message/body that merely CONTAINS "network_error",
440
+ // "stream_disconnected" or "auth context expired" is not a typed retry
441
+ // signal and leaves the turn terminal.
442
+ const detail = failed?.response?.error || failed?.error || failed
443
+ const failedStatus = typedStatusFrom(detail, failed?.response, failed)
444
+ if (failedStatus >= 500 && failedStatus < 600) {
445
+ return _allowMidstream(`http_${failedStatus}`, attemptIndex, policy)
446
+ }
447
+ for (const field of [detail?.code, detail?.type]) {
448
+ const key = typeof field === 'string' ? field.trim().toLowerCase() : ''
449
+ const classifier = RESPONSE_FAILED_CODE_CLASSIFIERS.get(key)
450
+ if (classifier) return _allowMidstream(classifier, attemptIndex, policy)
451
+ }
472
452
  }
473
453
 
474
454
  return null
475
455
  }
476
456
 
457
+ // Explicit `response.failed` error codes/types that describe a transport-level
458
+ // interruption (Codex retries these); every other code is terminal.
459
+ const RESPONSE_FAILED_CODE_CLASSIFIERS = new Map([
460
+ ['stream_disconnected', 'response_failed_disconnected'],
461
+ ['network_error', 'response_failed_network'],
462
+ ['auth_context_expired', 'response_failed_auth_expired'],
463
+ ['auth_expired', 'response_failed_auth_expired'],
464
+ ])
465
+
477
466
  // SSE classification consumes the provider's stream-state signals.
478
467
  function _classifyMidstreamSse(err, state, attemptIndex, policy) {
479
468
  if (attemptIndex >= policy.defaultRetries) return null
480
- if (state.sawCompleted) return null
469
+ const outcome = readStreamOutcome(err, state)
470
+ if (outcome.terminalObserved) return null
481
471
  if (state.userAbort) return null
482
- if (state.emittedText || state.emittedToolCall || state.partialToolCall || state.emittedThinking) return null
472
+ if (outcome.replaySafe !== true) return null
483
473
 
484
474
  if (!err) return null
485
475
  const status = Number(err?.httpStatus || err?.status || err?.response?.status || 0)
@@ -490,9 +480,6 @@ function _classifyMidstreamSse(err, state, attemptIndex, policy) {
490
480
  const name = err?.name || ''
491
481
  if (name === 'AgentStallAbortError') return 'agent_stall'
492
482
  if (name === 'StreamStalledAbortError' || name === 'StreamStalledError' || err?.code === 'ESTREAMSTALL' || err?.streamStalled === true) {
493
- // A stall AFTER a tool emit is unsafe to replay (double side-effect) →
494
- // terminal (null), no mid-stream retry. Otherwise route to stream_stalled.
495
- if (err?.unsafeToRetry === true) return null
496
483
  return 'stream_stalled'
497
484
  }
498
485
  if (state.watchdogAbort === 'AgentStallAbortError') return 'agent_stall'
@@ -503,10 +490,6 @@ function _classifyMidstreamSse(err, state, attemptIndex, policy) {
503
490
  if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT') return 'timeout'
504
491
  if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'EAI_NODATA') return 'dns'
505
492
 
506
- const msg = String(err?.message || '').toLowerCase()
507
- if (msg.includes('stream timed out after') && msg.includes('of inactivity')) return 'sse_idle_timeout'
508
- if (msg.includes('body stream') && msg.includes('terminated')) return 'stream_terminated'
509
- if (msg.includes('fetch failed')) return 'fetch_failed'
510
493
  if (classifyError(err) === 'transient') return 'connection'
511
494
 
512
495
  return null
@@ -530,23 +513,25 @@ const TRANSPORT_FALLBACK_ERRNO = new Set([
530
513
  export function shouldFallbackTransport(err, { signal, enabled = true } = {}) {
531
514
  if (!enabled) return false
532
515
  if (signal?.aborted) return false
533
- if (err?.liveTextEmitted === true) return false
534
- if (err?.emittedToolCall === true || err?.unsafeToRetry === true) return false
516
+ // Transport fallback re-issues the request on another transport: it is a
517
+ // replay, so the exposure deny applies. Eligibility itself stays typed
518
+ // (status / errno / classifier), matching Codex's WS→HTTPS switch.
519
+ if (readStreamOutcome(err).replaySafe !== true) return false
535
520
  const status = Number(err?.httpStatus || err?.status || 0)
536
- // 401 is auth recovery, never transport fallback. Codex treats other
537
- // unexpected handshake statuses as retryable transport errors; 426 is the
538
- // explicit immediate WS→HTTPS switch.
521
+ // 401 is auth recovery, never transport fallback. 426 is the explicit
522
+ // immediate WS→HTTPS switch; every other status must be TYPED transient
523
+ // (408/409/5xx) — an arbitrary nonzero status is not fallback evidence.
539
524
  if (status === 401) return false
540
525
  if (status === 426) return true
541
- if (status > 0) return true
526
+ if (TRANSIENT_STATUSES.has(status) || (status >= 500 && status < 600)) return true
527
+ if (status > 0) return false
542
528
  const code = String(err?.code || '')
543
529
  if (TRANSPORT_FALLBACK_ERRNO.has(code)) return true
544
530
  const classifier = String(err?.retryClassifier || err?.midstreamClassifier || '')
545
531
  if (TRANSPORT_FALLBACK_CLASSIFIERS.has(classifier)) return true
546
532
  if (/^http_5\d\d$/.test(classifier)) return true
547
533
  if (err?.firstByteTimeout) return true
548
- const msg = String(err?.message || '')
549
- return /opening handshake has timed out|socket hang up|acquire timed out|no first server event|no meaningful output/i.test(msg)
534
+ return false
550
535
  }
551
536
 
552
537
  // C) Stream-safety stamp latches. Mirrors openai-oauth-ws's _stampLiveText /
@@ -645,12 +630,17 @@ function _sleepChunkWithAbort(ms, signal, sleepFn, abortMessage) {
645
630
  export function classifyHandshakeError(err, { retry429 = true } = {}) {
646
631
  if (!err) return null
647
632
  const code = err.code || ''
648
- const msg = String(err.message || '')
649
633
  const status = Number(err.httpStatus || 0)
650
634
 
651
635
  if (status === 401 || status === 426 || (status === 429 && !retry429)) return null
652
636
  if (status > 0) {
653
- return `http_${status}`
637
+ // Typed transient handshake statuses only. A 403/404/4xx upgrade refusal
638
+ // is a deterministic decision: spending the retry budget on it cannot
639
+ // change the answer.
640
+ if (status === 429 || TRANSIENT_STATUSES.has(status) || (status >= 500 && status < 600)) {
641
+ return `http_${status}`
642
+ }
643
+ return null
654
644
  }
655
645
 
656
646
  if (code === 'ECONNRESET') return 'reset'
@@ -660,9 +650,6 @@ export function classifyHandshakeError(err, { retry429 = true } = {}) {
660
650
  if (code === 'EWSACQUIRETIMEOUT') return 'acquire_timeout'
661
651
  if (code === 'ENETUNREACH' || code === 'EHOSTUNREACH' || code === 'EPIPE') return 'network'
662
652
 
663
- if (/opening handshake has timed out/i.test(msg)) return 'timeout'
664
- if (/socket hang up/i.test(msg)) return 'reset'
665
-
666
653
  return null
667
654
  }
668
655
 
@@ -677,9 +664,8 @@ export function classifyHandshakeError(err, { retry429 = true } = {}) {
677
664
  * the provider's own responsibility via its native abort plumbing.
678
665
  * - Uses classifyError() to decide retry. 'transient' → retry,
679
666
  * 'auth' / 'permanent' / 'unknown' → throw immediately.
680
- * - populateHttpStatusFromMessage(err) is called on every caught error so
681
- * server-text errors (e.g. "Our servers are currently overloaded")
682
- * resolve to httpStatus before classification.
667
+ * - Classification is typed-only: an error with no status/errno/SDK type is
668
+ * 'unknown' and is surfaced immediately instead of being replayed.
683
669
  *
684
670
  * Returns whatever `fn()` resolves to. Throws the last error if every retry
685
671
  * is exhausted, or the first error if it's classified non-transient.
@@ -736,16 +722,13 @@ export async function withRetry(fn, opts = {}) {
736
722
  throw reason instanceof Error ? reason : new Error('withRetry: aborted')
737
723
  }
738
724
  lastErr = caught
739
- populateHttpStatusFromMessage(caught)
740
725
  const status = Number(caught?.httpStatus || caught?.status || caught?.response?.status || 0)
741
726
  const kind = classifyError(caught)
742
- const outputWasExposed = caught?.liveTextEmitted === true
743
- || caught?.emittedText === true
744
- || caught?.emittedToolCall === true
745
- || caught?.toolCallEmitted === true
746
- || caught?.partialToolCall === true
747
- || caught?.emittedThinking === true
748
- if (outputWasExposed || caught?.unsafeToRetry === true) throw caught
727
+ // Hard replay boundary: a retry RE-ISSUES the request, so it is denied
728
+ // once visible output was relayed or a tool call was dispatched. Whether
729
+ // an eligible failure is actually retried remains the typed question
730
+ // resolved by classifyError()/status below.
731
+ if (readStreamOutcome(caught).replaySafe !== true) throw caught
749
732
  // Claude Code treats x-should-retry:false as an explicit server veto
750
733
  // (except an internal-only 5xx override that Mixdog does not have).
751
734
  // Keep this ahead of status defaults, including the request-local 429 path.
@@ -50,6 +50,7 @@ import {
50
50
  crossTurnDedupStub,
51
51
  ITERATION_CAP_REFUSAL_STUB,
52
52
  } from './loop/completion-guards.mjs';
53
+ import { STOP_HOOK_SOURCE, createToolFailureStopHook } from './loop/stop-hooks.mjs';
53
54
  import { isEditProgressTool } from './loop/completion-guards.mjs';
54
55
  import { agentContextOverflowError } from './loop/context-overflow.mjs';
55
56
  import { positiveTokenInt } from './loop/env.mjs';
@@ -83,7 +84,12 @@ import {
83
84
  restoreToolCallBodyForId,
84
85
  } from './loop/stored-tool-args.mjs';
85
86
  import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
86
- import { classifyTerminationReason, INCOMPLETE_STOP_REASONS, isOutputLimitStopReason } from './loop/termination.mjs';
87
+ import {
88
+ classifyTerminationReason,
89
+ INCOMPLETE_STOP_REASONS,
90
+ isOutputLimitStopReason,
91
+ providerContinuationSignal,
92
+ } from './loop/termination.mjs';
87
93
  import { createSteeringLadder } from './loop/steering-ladder.mjs';
88
94
  import { runPreSendCompactPass } from './pre-send-compact.mjs';
89
95
  import { createEagerDispatcher } from './eager-dispatch.mjs';
@@ -210,14 +216,26 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
210
216
  // Reasoning/thinking deltas, tool calls, and the final answer are kept.
211
217
  const suppressMidTurnText = isAgentOwner(sessionRef);
212
218
  if (suppressMidTurnText) opts.onTextDelta = undefined;
219
+ // ---- Codex turn stop hook (refs/codex core/src/session/turn.rs:372-404) --
220
+ // A no-tool assistant message is TERMINAL. Only a structured provider
221
+ // follow-up signal (end_turn=false / pause_turn), pending input, tool
222
+ // calls/results, or a stop hook that blocks with a continuation prompt keep
223
+ // sampling alive. The single hook below is structural: after an unresolved
224
+ // real tool failure it blocks the first terminal message ONCE, records the
225
+ // continuation prompt, and then stays inactive for the rest of the turn.
226
+ const _toolFailureStopHook = createToolFailureStopHook();
213
227
  const pushToolResultMessage = (message) => {
214
228
  messages.push(message);
229
+ _toolFailureStopHook.observeToolResult(message);
215
230
  try { opts.onToolResult?.(message); } catch {}
216
231
  };
217
232
  const drainSteeringIntoMessages = (stage = 'mid-turn', options = {}) => {
218
233
  if (typeof opts.drainSteering !== 'function') return false;
219
234
  let steerMsgs = [];
220
- try { steerMsgs = opts.drainSteering(sessionId, options) || []; }
235
+ // The stage rides along so the host can scope which queues a drain may
236
+ // consume (mid-turn tool-batch boundary vs the terminal pending-input
237
+ // check that precedes the stop hooks).
238
+ try { steerMsgs = opts.drainSteering(sessionId, { ...options, stage }) || []; }
221
239
  catch { steerMsgs = []; }
222
240
  const mergedMessages = [];
223
241
  for (const entry of Array.isArray(steerMsgs) ? steerMsgs : []) {
@@ -288,13 +306,34 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
288
306
  const providerMetadata = resp.providerMetadata && typeof resp.providerMetadata === 'object'
289
307
  ? resp.providerMetadata
290
308
  : null;
291
- if (!content && !reasoningContent && !reasoningItems && !thinkingBlocks) return false;
309
+ // Anthropic native server-tool turns (web search / code execution /
310
+ // native MCP) carry `server_tool_use` + `*_tool_result` blocks that
311
+ // exist ONLY in this ordered verbatim list — they cannot be rebuilt
312
+ // from content/thinkingBlocks/toolCalls, and dropping them breaks the
313
+ // resumed turn (a result block is only valid right after its call
314
+ // block). Attach them so Anthropic lowering replays the turn as-is.
315
+ // Guarded on no client tool calls: this committer only handles
316
+ // continuation/intermediate turns. A MIXED turn (native blocks +
317
+ // client tool_use) is committed by the tool-call branch below, which
318
+ // keeps both the compacted toolCalls and these blocks.
319
+ const assistantBlocks = Array.isArray(resp.assistantBlocks)
320
+ && resp.assistantBlocks.length
321
+ && !resp.toolCalls?.length
322
+ ? resp.assistantBlocks
323
+ : null;
324
+ // A native-only turn (server tool blocks, no flattened text) is real
325
+ // assistant output and must stay committable — not treated as empty.
326
+ if (!content && !reasoningContent && !reasoningItems && !thinkingBlocks && !assistantBlocks) return false;
292
327
  const message = attachAssistantTranscriptMetadata({
293
328
  role: 'assistant',
294
329
  content,
330
+ // assistantBlocks already contains the thinking blocks verbatim in
331
+ // stream order, so thinkingBlocks is redundant (and would be
332
+ // double-counted by the context estimator) when it is present.
333
+ ...(assistantBlocks ? { assistantBlocks } : {}),
295
334
  // Anthropic adaptive-thinking signatures must be replayed verbatim
296
335
  // before the continuation turn, just like tool-call trajectories.
297
- ...(thinkingBlocks ? { thinkingBlocks } : {}),
336
+ ...(thinkingBlocks && !assistantBlocks ? { thinkingBlocks } : {}),
298
337
  ...(reasoningItems ? { reasoningItems } : {}),
299
338
  ...(reasoningContent ? { reasoningContent } : {}),
300
339
  ...(providerMetadata ? { providerMetadata } : {}),
@@ -334,6 +373,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
334
373
  let _refusalRetryUsed = false;
335
374
  let _maxOutputRecoveryCount = 0;
336
375
  const _maxOutputContentParts = [];
376
+ // Count of structured provider continuation signals honored this turn
377
+ // (endTurn === false / stopReason === 'pause_turn'). Diagnostic only; the
378
+ // hard iteration cap remains the sole bound on how long a provider may
379
+ // keep declaring "not done" inside one user turn.
380
+ let _providerContinuationCount = 0;
337
381
  // Claude Code parity: queued prompt/task notifications are attached after a
338
382
  // tool batch, before the continuation provider send. Normal batches drain
339
383
  // up to 'next'; a Sleep-like tool grants a 'later' flush.
@@ -760,6 +804,42 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
760
804
  });
761
805
  continue;
762
806
  }
807
+ // Structured provider continuation signal. The provider declared
808
+ // the assistant turn unfinished, so this text is mid-turn output,
809
+ // not a final answer: commit it EXACTLY ONCE to history/UI (single
810
+ // pushIntermediateAssistantResponse → onAssistantMessageCommitted)
811
+ // and immediately resume sampling in the same user turn. Output
812
+ // limit stops keep the bounded max-output recovery ladder above and
813
+ // refusals keep the bounded refusal retry; both own their own
814
+ // continuation semantics. No lexical/progress-text heuristic.
815
+ const continuationSignal = !isOutputLimitStop && stopReason !== 'refusal'
816
+ ? providerContinuationSignal(response)
817
+ : null;
818
+ if (continuationSignal && pushIntermediateAssistantResponse(response)) {
819
+ if (hasContent && !suppressMidTurnText) {
820
+ try { opts.onAssistantText?.(response.content); } catch { /* best-effort */ }
821
+ }
822
+ _providerContinuationCount += 1;
823
+ _emptyNudgeStreak = 0;
824
+ try {
825
+ appendAgentTrace({
826
+ sessionId,
827
+ iteration: iterations,
828
+ kind: 'provider_continuation',
829
+ payload: {
830
+ signal: continuationSignal,
831
+ stop_reason: stopReason,
832
+ count: _providerContinuationCount,
833
+ content_len: typeof response.content === 'string' ? response.content.length : 0,
834
+ },
835
+ agent: sessionAgent || null,
836
+ });
837
+ } catch { /* best-effort */ }
838
+ continue;
839
+ }
840
+ // A continuation signal with nothing committable (no text, no
841
+ // reasoning) falls through to the bounded empty-turn handling
842
+ // below: re-sending an unchanged transcript would livelock.
763
843
  if (!hasContent && !isHidden) {
764
844
  _emptyNudgeStreak += 1;
765
845
  if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
@@ -774,14 +854,49 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
774
854
  if (isIncompleteStop) {
775
855
  nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit your final handoff (fragments, file:line) with your synthesis so far, or call more tools to finish.`;
776
856
  } else {
777
- // Vary the nudge per attempt — a byte-identical repeat
778
- // reinforces the empty-completion pattern it is trying to
779
- // break (the request context stays effectively constant).
780
857
  nudgeMsg = `[mixdog-runtime] Your previous response was empty (no handoff text and no tool call) — attempt ${_emptyNudgeStreak}/${EMPTY_NUDGE_MAX}. Either emit your final handoff text now, or continue with tool calls. Do not return an empty turn.`;
781
858
  }
782
859
  messages.push({ role: 'user', content: nudgeMsg });
783
860
  continue;
784
861
  }
862
+ // Codex `has_pending_input` (turn.rs:304-318): queued user input is
863
+ // folded into needs_follow_up and evaluated BEFORE the stop hooks,
864
+ // so real steering always wins over a synthetic continuation prompt.
865
+ // Commit the terminal text first (beforeAppend), then resume.
866
+ if (!_capFinalToolsDisabled && drainSteeringIntoMessages('terminal', {
867
+ maxPriority: 'next',
868
+ beforeAppend: () => { pushIntermediateAssistantResponse(response); },
869
+ })) {
870
+ _emptyNudgeStreak = 0;
871
+ continue;
872
+ }
873
+ // Codex parity (turn.rs:372-404): this no-tool message ends the turn
874
+ // unless a stop hook blocks it. The unresolved-tool-failure hook may
875
+ // block exactly once — commit the assistant text, record the
876
+ // structural continuation prompt, resume sampling. Skipped on the
877
+ // hard-cap final turn (tool use is already forbidden) and for hidden
878
+ // roles, whose own role rules define a text-only terminal contract.
879
+ if (!_capFinalToolsDisabled && !isHidden) {
880
+ const _hookPrompt = _toolFailureStopHook.takeContinuationPrompt();
881
+ if (_hookPrompt) {
882
+ pushIntermediateAssistantResponse(response);
883
+ messages.push({
884
+ role: 'user',
885
+ content: _hookPrompt,
886
+ meta: { source: STOP_HOOK_SOURCE, tool: _toolFailureStopHook.lastFailedTool || null },
887
+ });
888
+ try {
889
+ appendAgentTrace({
890
+ sessionId,
891
+ iteration: iterations,
892
+ kind: 'steer',
893
+ payload: { tag: 'tool_failure_stop_hook', tool: _toolFailureStopHook.lastFailedTool || null },
894
+ agent: sessionAgent || null,
895
+ });
896
+ } catch { /* best-effort */ }
897
+ continue;
898
+ }
899
+ }
785
900
  if (_maxOutputContentParts.length > 0) {
786
901
  const terminalSegment = typeof response.content === 'string' ? response.content : '';
787
902
  response = {
@@ -824,12 +939,27 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
824
939
  // turn). Blank it so it never accumulates as input tokens.
825
940
  content: suppressMidTurnText ? '' : (response.content || ''),
826
941
  toolCalls: compactToolCallsForHistory(calls),
942
+ // MIXED Anthropic turn (native server tools + client tool_use):
943
+ // the ordered `server_tool_use` / `*_tool_result` blocks exist ONLY
944
+ // in this verbatim list and are order-bound (a result block is
945
+ // valid only right after its call block), so they must ride the
946
+ // tool-call history message too. toolCalls above stays authoritative
947
+ // for execution/recovery/interruption; both Anthropic lowerers
948
+ // prefer assistantBlocks verbatim (which already contains the
949
+ // text/thinking/tool_use blocks), so nothing is emitted twice and
950
+ // non-Anthropic providers simply ignore the field.
951
+ ...(Array.isArray(response.assistantBlocks) && response.assistantBlocks.length
952
+ ? { assistantBlocks: response.assistantBlocks }
953
+ : {}),
827
954
  // Anthropic adaptive thinking: prior-turn thinking blocks must be
828
955
  // returned verbatim (signature intact; empty thinking allowed) and
829
956
  // are REQUIRED back before tool_use blocks on tool-continuation
830
957
  // turns. Store them so toAnthropicMessages can build assistantBlocks
831
- // = [...thinking, tool_use...]. Other providers ignore this field.
958
+ // = [...thinking, tool_use...]. Skipped when assistantBlocks is
959
+ // present: those blocks already carry the thinking verbatim, and a
960
+ // second copy would double-count in the context estimator.
832
961
  ...(Array.isArray(response.thinkingBlocks) && response.thinkingBlocks.length
962
+ && !(Array.isArray(response.assistantBlocks) && response.assistantBlocks.length)
833
963
  ? { thinkingBlocks: response.thinkingBlocks }
834
964
  : {}),
835
965
  ...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
@@ -844,6 +974,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
844
974
  }, opts);
845
975
  messages.push(_assistantTurnMsg);
846
976
  try { opts.onAssistantMessageCommitted?.(_assistantTurnMsg); } catch {}
977
+ const _callsToExecute = calls;
847
978
  // Hard-cap final turn: tools are disabled but the model still emitted
848
979
  // tool calls. Do NOT execute them — push a refusal stub for each.
849
980
  if (_capFinalToolsDisabled) {
@@ -860,7 +991,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
860
991
  }
861
992
  try { opts.onToolPhaseStarted?.(); } catch {}
862
993
  ({ dedupStubTotal: _dedupStubTotal, editCount: _editCount } = await processToolBatch({
863
- calls, messages, tools, cwd, sessionId, sessionRef, signal, opts,
994
+ calls: _callsToExecute, messages, tools, cwd, sessionId, sessionRef, signal, opts,
864
995
  iterations, assistantTurnMsg: _assistantTurnMsg,
865
996
  pending: eager.pending, epoch: eager.epoch, startEagerRun: eager.startEagerRun,
866
997
  crossTurnCalls: _crossTurnCalls, crossTurnCap: _CROSS_TURN_CAP,
@@ -869,8 +1000,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
869
1000
  pushToolResultMessage, throwIfAborted,
870
1001
  repeatFailLimit: REPEAT_FAIL_LIMIT,
871
1002
  }));
1003
+ // Settle the stop hook on the batch that actually executed.
1004
+ _toolFailureStopHook.endBatch(_callsToExecute);
872
1005
  _toolBatchJustCompleted = true;
873
- _lastToolBatchHadSleep = calls.some(isSleepLikeToolCall);
1006
+ _lastToolBatchHadSleep = _callsToExecute.some(isSleepLikeToolCall);
874
1007
  }
875
1008
  // Classify WHY the loop ended so agent-tool can promote an empty/abnormal
876
1009
  // finish to an explicit Lead-facing error instead of a silent empty
@@ -891,5 +1024,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
891
1024
  providerStateUpdated,
892
1025
  terminationReason,
893
1026
  maxLoopIterations,
1027
+ providerContinuations: _providerContinuationCount,
894
1028
  };
895
1029
  }