@superblocksteam/vite-plugin-file-sync 2.0.130-next.1 → 2.0.130-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/ai-service/agent/prompts/build-base-system-prompt.js +1 -1
  2. package/dist/ai-service/agent/prompts/build-base-system-prompt.js.map +1 -1
  3. package/dist/ai-service/agent/tool-message-utils.d.ts.map +1 -1
  4. package/dist/ai-service/agent/tool-message-utils.js +30 -0
  5. package/dist/ai-service/agent/tool-message-utils.js.map +1 -1
  6. package/dist/ai-service/agent/tools2/tools/exit-plan-mode.d.ts +11 -1
  7. package/dist/ai-service/agent/tools2/tools/exit-plan-mode.d.ts.map +1 -1
  8. package/dist/ai-service/agent/tools2/tools/exit-plan-mode.js +144 -29
  9. package/dist/ai-service/agent/tools2/tools/exit-plan-mode.js.map +1 -1
  10. package/dist/ai-service/agent/utils.d.ts.map +1 -1
  11. package/dist/ai-service/agent/utils.js +6 -1
  12. package/dist/ai-service/agent/utils.js.map +1 -1
  13. package/dist/ai-service/app-skills/helpers.d.ts.map +1 -1
  14. package/dist/ai-service/app-skills/helpers.js +17 -18
  15. package/dist/ai-service/app-skills/helpers.js.map +1 -1
  16. package/dist/ai-service/chat/chat-push-metrics.d.ts +12 -0
  17. package/dist/ai-service/chat/chat-push-metrics.d.ts.map +1 -0
  18. package/dist/ai-service/chat/chat-push-metrics.js +68 -0
  19. package/dist/ai-service/chat/chat-push-metrics.js.map +1 -0
  20. package/dist/ai-service/chat/chat-session-store.d.ts +61 -5
  21. package/dist/ai-service/chat/chat-session-store.d.ts.map +1 -1
  22. package/dist/ai-service/chat/chat-session-store.js +214 -8
  23. package/dist/ai-service/chat/chat-session-store.js.map +1 -1
  24. package/dist/ai-service/chat/transcript-budget.d.ts +108 -0
  25. package/dist/ai-service/chat/transcript-budget.d.ts.map +1 -0
  26. package/dist/ai-service/chat/transcript-budget.js +175 -0
  27. package/dist/ai-service/chat/transcript-budget.js.map +1 -0
  28. package/dist/ai-service/chat/transcript-metrics.d.ts +16 -0
  29. package/dist/ai-service/chat/transcript-metrics.d.ts.map +1 -0
  30. package/dist/ai-service/chat/transcript-metrics.js +128 -0
  31. package/dist/ai-service/chat/transcript-metrics.js.map +1 -0
  32. package/dist/ai-service/context-download.d.ts.map +1 -1
  33. package/dist/ai-service/context-download.js +48 -1
  34. package/dist/ai-service/context-download.js.map +1 -1
  35. package/dist/ai-service/context-upload.d.ts.map +1 -1
  36. package/dist/ai-service/context-upload.js +10 -0
  37. package/dist/ai-service/context-upload.js.map +1 -1
  38. package/dist/ai-service/index.d.ts.map +1 -1
  39. package/dist/ai-service/index.js +4 -0
  40. package/dist/ai-service/index.js.map +1 -1
  41. package/dist/ai-service/llm/clark-stream-telemetry-sink.d.ts +4 -0
  42. package/dist/ai-service/llm/clark-stream-telemetry-sink.d.ts.map +1 -0
  43. package/dist/ai-service/llm/clark-stream-telemetry-sink.js +105 -0
  44. package/dist/ai-service/llm/clark-stream-telemetry-sink.js.map +1 -0
  45. package/dist/ai-service/llm/client.d.ts.map +1 -1
  46. package/dist/ai-service/llm/client.js +63 -39
  47. package/dist/ai-service/llm/client.js.map +1 -1
  48. package/dist/ai-service/llm/context-v2/compaction/client-side.d.ts.map +1 -1
  49. package/dist/ai-service/llm/context-v2/compaction/client-side.js +10 -1
  50. package/dist/ai-service/llm/context-v2/compaction/client-side.js.map +1 -1
  51. package/dist/ai-service/llm/context-v2/context.d.ts +14 -0
  52. package/dist/ai-service/llm/context-v2/context.d.ts.map +1 -1
  53. package/dist/ai-service/llm/context-v2/context.js +94 -3
  54. package/dist/ai-service/llm/context-v2/context.js.map +1 -1
  55. package/dist/ai-service/llm/context-v2/storage/event-types.d.ts +10 -1
  56. package/dist/ai-service/llm/context-v2/storage/event-types.d.ts.map +1 -1
  57. package/dist/ai-service/llm/context-v2/storage/event-types.js +7 -1
  58. package/dist/ai-service/llm/context-v2/storage/event-types.js.map +1 -1
  59. package/dist/ai-service/llm/stream/config.d.ts +30 -0
  60. package/dist/ai-service/llm/stream/config.d.ts.map +1 -1
  61. package/dist/ai-service/llm/stream/config.js +34 -17
  62. package/dist/ai-service/llm/stream/config.js.map +1 -1
  63. package/dist/ai-service/llm/stream/errors.d.ts +10 -1
  64. package/dist/ai-service/llm/stream/errors.d.ts.map +1 -1
  65. package/dist/ai-service/llm/stream/errors.js +3 -1
  66. package/dist/ai-service/llm/stream/errors.js.map +1 -1
  67. package/dist/ai-service/llm/stream/idle-monitor.d.ts +34 -4
  68. package/dist/ai-service/llm/stream/idle-monitor.d.ts.map +1 -1
  69. package/dist/ai-service/llm/stream/idle-monitor.js +90 -6
  70. package/dist/ai-service/llm/stream/idle-monitor.js.map +1 -1
  71. package/dist/ai-service/llm/stream/index.d.ts +1 -1
  72. package/dist/ai-service/llm/stream/index.d.ts.map +1 -1
  73. package/dist/ai-service/llm/stream/index.js.map +1 -1
  74. package/dist/ai-service/llm/stream/managed-stream.d.ts.map +1 -1
  75. package/dist/ai-service/llm/stream/managed-stream.js +11 -0
  76. package/dist/ai-service/llm/stream/managed-stream.js.map +1 -1
  77. package/dist/ai-service/llm/stream/orchestrator.d.ts +19 -0
  78. package/dist/ai-service/llm/stream/orchestrator.d.ts.map +1 -1
  79. package/dist/ai-service/llm/stream/orchestrator.js +387 -33
  80. package/dist/ai-service/llm/stream/orchestrator.js.map +1 -1
  81. package/dist/ai-service/llm/stream/retry-engine.d.ts +18 -2
  82. package/dist/ai-service/llm/stream/retry-engine.d.ts.map +1 -1
  83. package/dist/ai-service/llm/stream/retry-engine.js +76 -3
  84. package/dist/ai-service/llm/stream/retry-engine.js.map +1 -1
  85. package/dist/ai-service/llm/stream/session.d.ts +6 -0
  86. package/dist/ai-service/llm/stream/session.d.ts.map +1 -1
  87. package/dist/ai-service/llm/stream/session.js +28 -0
  88. package/dist/ai-service/llm/stream/session.js.map +1 -1
  89. package/dist/ai-service/llm/stream/types.d.ts +1 -0
  90. package/dist/ai-service/llm/stream/types.d.ts.map +1 -1
  91. package/dist/ai-service/llm/stream/wait-state-tracker.d.ts +54 -0
  92. package/dist/ai-service/llm/stream/wait-state-tracker.d.ts.map +1 -0
  93. package/dist/ai-service/llm/stream/wait-state-tracker.js +105 -0
  94. package/dist/ai-service/llm/stream/wait-state-tracker.js.map +1 -0
  95. package/dist/ai-service/state-machine/clark-fsm.d.ts.map +1 -1
  96. package/dist/ai-service/state-machine/clark-fsm.js +13 -7
  97. package/dist/ai-service/state-machine/clark-fsm.js.map +1 -1
  98. package/dist/ai-service/state-machine/handlers/agent-planning.d.ts.map +1 -1
  99. package/dist/ai-service/state-machine/handlers/agent-planning.js +55 -43
  100. package/dist/ai-service/state-machine/handlers/agent-planning.js.map +1 -1
  101. package/dist/ai-service/state-machine/handlers/flush-pending-chat-pushes.d.ts +11 -0
  102. package/dist/ai-service/state-machine/handlers/flush-pending-chat-pushes.d.ts.map +1 -0
  103. package/dist/ai-service/state-machine/handlers/flush-pending-chat-pushes.js +11 -0
  104. package/dist/ai-service/state-machine/handlers/flush-pending-chat-pushes.js.map +1 -0
  105. package/dist/ai-service/state-machine/handlers/llm-generating.d.ts.map +1 -1
  106. package/dist/ai-service/state-machine/handlers/llm-generating.js +10 -0
  107. package/dist/ai-service/state-machine/handlers/llm-generating.js.map +1 -1
  108. package/dist/ai-service/state-machine/helpers/background-chat-record-metrics.d.ts +8 -0
  109. package/dist/ai-service/state-machine/helpers/background-chat-record-metrics.d.ts.map +1 -0
  110. package/dist/ai-service/state-machine/helpers/background-chat-record-metrics.js +52 -0
  111. package/dist/ai-service/state-machine/helpers/background-chat-record-metrics.js.map +1 -0
  112. package/dist/ai-service/state-machine/helpers/background-chat-record.d.ts +33 -0
  113. package/dist/ai-service/state-machine/helpers/background-chat-record.d.ts.map +1 -0
  114. package/dist/ai-service/state-machine/helpers/background-chat-record.js +61 -0
  115. package/dist/ai-service/state-machine/helpers/background-chat-record.js.map +1 -0
  116. package/dist/ai-service/state-machine/mocks.d.ts.map +1 -1
  117. package/dist/ai-service/state-machine/mocks.js +1 -0
  118. package/dist/ai-service/state-machine/mocks.js.map +1 -1
  119. package/dist/ai-service/tasks/peer-push.d.ts +7 -4
  120. package/dist/ai-service/tasks/peer-push.d.ts.map +1 -1
  121. package/dist/ai-service/tasks/peer-push.js +7 -4
  122. package/dist/ai-service/tasks/peer-push.js.map +1 -1
  123. package/dist/migration-templates/app-fullstack/package.json +1 -1
  124. package/dist/socket-manager.d.ts +2 -1
  125. package/dist/socket-manager.d.ts.map +1 -1
  126. package/dist/socket-manager.js +17 -6
  127. package/dist/socket-manager.js.map +1 -1
  128. package/dist/util/logger.d.ts +1 -0
  129. package/dist/util/logger.d.ts.map +1 -1
  130. package/dist/util/logger.js.map +1 -1
  131. package/package.json +8 -8
@@ -1,11 +1,71 @@
1
1
  import { getErrorMeta, } from "../../../util/logger.js";
2
2
  import { buildThinkingProviderOptions } from "../../util/llm-config-utils.js";
3
3
  import { LLMProviderError } from "../error.js";
4
+ import { getServerCompactionTextBlockUpdate } from "./compaction-text-block.js";
4
5
  import { AbortedError, RetriesExhaustedError } from "./errors.js";
5
6
  import { IdleMonitor } from "./idle-monitor.js";
6
7
  import { createManagedStream } from "./managed-stream.js";
7
8
  import { RetryEngine } from "./retry-engine.js";
8
9
  import { StreamSession } from "./session.js";
10
+ import { WaitStateTracker } from "./wait-state-tracker.js";
11
+ /**
12
+ * Tracks the in-flight provider attempt for a single execute() call so a
13
+ * superseded attempt can be cut off the instant we decide to retry.
14
+ *
15
+ * Each attempt (the initial stream and every retry) gets its own AbortController
16
+ * chained to the execute-level parent signal. The Vercel AI SDK keeps draining a
17
+ * streamText() result and auto-executing tools regardless of whether we read its
18
+ * fullStream, so abandoning the stream is not enough — the only thing that stops
19
+ * a superseded attempt is aborting its signal. The orchestrator aborts the
20
+ * current attempt at the retry decision, before the backoff delay, so the loser
21
+ * can't race the winner to run terminal tools.
22
+ */
23
+ class AttemptCoordinator {
24
+ parent;
25
+ current = null;
26
+ parentListener = null;
27
+ constructor(parent) {
28
+ this.parent = parent;
29
+ }
30
+ /**
31
+ * Begin a new attempt. Returns the attempt's abort signal and a predicate that
32
+ * is true only while this attempt is the live one and neither it nor the parent
33
+ * has been aborted. Used to fence tool execution and lifecycle callbacks so a
34
+ * superseded attempt's late, buffered events are ignored.
35
+ */
36
+ begin() {
37
+ // Drop the superseded attempt's parent listener so they don't accumulate on
38
+ // the session signal for the whole execute() lifetime.
39
+ this.detachParentListener();
40
+ const controller = new AbortController();
41
+ if (this.parent.aborted) {
42
+ controller.abort();
43
+ }
44
+ else {
45
+ const onParentAbort = () => controller.abort();
46
+ this.parentListener = onParentAbort;
47
+ this.parent.addEventListener("abort", onParentAbort, { once: true });
48
+ }
49
+ this.current = controller;
50
+ const isCurrent = () => this.current === controller && // still the most-recent begin() call
51
+ !controller.signal.aborted && // not superseded via abortCurrent()
52
+ !this.parent.aborted; // defensive: parent abort also cascades via listener
53
+ return { signal: controller.signal, isCurrent };
54
+ }
55
+ /**
56
+ * Abort the in-flight attempt. Called at the retry decision, before backoff,
57
+ * to close the window where a stale attempt could wake up and execute tools.
58
+ */
59
+ abortCurrent() {
60
+ this.current?.abort();
61
+ }
62
+ detachParentListener() {
63
+ if (this.parentListener) {
64
+ this.parent.removeEventListener("abort", this.parentListener);
65
+ this.parentListener = null;
66
+ }
67
+ }
68
+ }
9
69
  /**
10
70
  * The main coordinator for stream operations.
11
71
  *
@@ -65,7 +125,11 @@ export class StreamOrchestrator {
65
125
  applicationId: this.applicationId,
66
126
  getJwt: this.getJwt,
67
127
  });
128
+ // Start watching before any data flows so that, if the stream stalls, we
129
+ // can say what it was stuck on (the model vs. a local tool or step gap).
130
+ const waitStateTracker = new WaitStateTracker(session);
68
131
  const internalAbortController = new AbortController();
132
+ const attempts = new AttemptCoordinator(internalAbortController.signal);
69
133
  if (options.abortSignal) {
70
134
  options.abortSignal.addEventListener("abort", () => {
71
135
  internalAbortController.abort();
@@ -88,28 +152,142 @@ export class StreamOrchestrator {
88
152
  internalAbortController.signal.addEventListener("abort", () => {
89
153
  void session.events.emit("session:abort", session);
90
154
  });
91
- // Idle monitoring: aborts hung streams that stop sending chunks
155
+ // Idle monitoring: aborts hung streams that stop sending chunks. Budgets
156
+ // are resolved per attempt from the CURRENT model (session.provider.model)
157
+ // so a fallback to a different model is monitored against its own budget,
158
+ // not the initial model's frozen one. See APPS-4775.
92
159
  const idleMonitor = new IdleMonitor({
93
160
  session,
94
- maxIdleTimeMs: this.config.retry.idleTimeoutMs,
95
- firstChunkTimeoutMs: this.config.retry.firstChunkTimeoutMs,
161
+ maxIdleTimeMs: () => this.config.retry.budgetForModel(session.provider.model).idleTimeoutMs,
162
+ firstChunkTimeoutMs: () => this.config.retry.budgetForModel(session.provider.model)
163
+ .firstChunkTimeoutMs,
96
164
  onServerCompactionStart: (blockId) => {
165
+ // firstChunkTimeoutMs is re-resolved here from the current model rather
166
+ // than read from the armed timer. The model only changes on fallback
167
+ // events (never mid-stream), so at compaction-start time it matches the
168
+ // budget the first-chunk timer was actually armed with.
97
169
  this.logger.debug("[orchestrator] Server-side compaction chunk detected; holding first-chunk idle budget", {
98
170
  sessionId: session.id,
99
171
  provider: session.provider.name,
100
172
  model: session.provider.model,
101
173
  attempt: session.attempt,
102
174
  blockId,
103
- firstChunkTimeoutMs: this.config.retry.firstChunkTimeoutMs,
175
+ firstChunkTimeoutMs: this.config.retry.budgetForModel(session.provider.model).firstChunkTimeoutMs,
104
176
  });
105
177
  },
178
+ shouldPause: () => {
179
+ // Reads only plain in-memory state, so it should never throw. Guard it
180
+ // anyway and log if it does: a silent failure here would let a false
181
+ // stall through and rotate the provider — the exact bug this prevents —
182
+ // with no signal for oncall to find it. On error, return false so the
183
+ // stall still fires (failing safe toward surfacing a real stall).
184
+ try {
185
+ // Only a genuine wait on the provider's next byte should count toward
186
+ // a stall. If a tool is running or we're between steps, the gap is
187
+ // local work — not the model going silent — so pause instead of
188
+ // firing a false stall that would pointlessly rotate providers. See
189
+ // APPS-4605.
190
+ const waitState = waitStateTracker.getWaitState();
191
+ if (waitState.state === "tool" ||
192
+ waitState.state === "step-boundary") {
193
+ return true;
194
+ }
195
+ // isAwaitingUpstream() is true only while the managed stream is
196
+ // blocked waiting for the provider's next chunk. False means the
197
+ // consumer took a chunk and hasn't asked for the next one yet (e.g.
198
+ // it's busy on its own downstream call), so the silence is on the
199
+ // consumer's side, not the provider's. Require at least one chunk to
200
+ // have arrived first: before that the gap is real provider silence
201
+ // (and the flag may not be set yet because the read happens lazily),
202
+ // so it must never be mistaken for the consumer holding the stream.
203
+ //
204
+ // Trade-off: if the provider connection drops while the consumer is
205
+ // holding a chunk, the dead connection is invisible until the
206
+ // consumer pulls again, since we suppress the stall for the whole
207
+ // consumer-hold window. That is acceptable because a slow consumer is
208
+ // the intended case and the consumer (the dev server request) carries
209
+ // its own request timeout that aborts the stream if it never resumes.
210
+ return (!session.isAwaitingUpstream() && session.metrics.chunkCount > 0);
211
+ }
212
+ catch (err) {
213
+ this.logger.error("[orchestrator] shouldPause check failed", {
214
+ error: {
215
+ kind: err instanceof Error ? err.name : "unknown",
216
+ message: err instanceof Error ? err.message : String(err),
217
+ stack: err instanceof Error ? err.stack : undefined,
218
+ },
219
+ });
220
+ return false;
221
+ }
222
+ },
223
+ onPause: () => {
224
+ // One structured event at the start of each local-wait stretch (a tool
225
+ // running, a step gap, or the consumer holding the stream past the idle
226
+ // budget). No stream_stall fires for these, so without this a hung tool
227
+ // would be invisible to the stream layer until the request aborts. The
228
+ // idle monitor fires this once per stretch, so a healthy slow tool logs
229
+ // once, not once per re-arm. See APPS-4605.
230
+ const waitState = waitStateTracker.getWaitState();
231
+ const message = `[orchestrator] Local wait suppressed idle stall`;
232
+ const attributes = {
233
+ event: "stream_local_wait",
234
+ session_id: session.id,
235
+ provider: session.provider.name,
236
+ model: session.provider.model,
237
+ attempt: session.attempt,
238
+ elapsed_ms: session.getElapsedMs(),
239
+ chunks_received: session.metrics.chunkCount,
240
+ // "tool"/"step-boundary" come from the wait tracker; "consumer" is the
241
+ // remaining case (consumer holding the stream), which the tracker
242
+ // reports as "provider" since it does not track consumer-pull state.
243
+ wait_state: waitState.state === "provider" ? "consumer" : waitState.state,
244
+ };
245
+ if (waitState.state === "tool") {
246
+ attributes.tool_name = waitState.toolName;
247
+ attributes.tool_call_id = waitState.toolCallId;
248
+ attributes.pending_tool_count = waitState.pendingToolCount;
249
+ }
250
+ if (session.organizationId) {
251
+ attributes.organization_id = session.organizationId;
252
+ }
253
+ if (session.applicationId) {
254
+ attributes.application_id = session.applicationId;
255
+ }
256
+ if (this.logger.warnStructured) {
257
+ this.logger.warnStructured(message, attributes);
258
+ }
259
+ else {
260
+ this.logger.warn(message, attributes);
261
+ }
262
+ },
106
263
  onStall: (error) => {
107
264
  // budgetKind is the keepalive-vs-content discriminator: "first-chunk"
108
265
  // means the model never sent content (only silence/keep-alives) before
109
266
  // the stall; "inter-chunk" means content was flowing and then stopped.
110
- const budgetKind = error.maxIdleTimeMs === this.config.retry.firstChunkTimeoutMs
111
- ? "first-chunk"
112
- : "inter-chunk";
267
+ // It is carried on the error (set when the timer armed) because budgets
268
+ // are now per-model and can't be re-derived by numeric comparison.
269
+ const budgetKind = error.budgetKind;
270
+ const msSinceLastPull = session.getTimeSinceLastPull();
271
+ // Discriminate network-dark vs consumer-blocked (APPS-4729). The
272
+ // managed stream flips isAwaitingUpstream() around its
273
+ // `await iterator.next()`: true means the stall fired while we were
274
+ // blocked reading upstream (the wire went dark); false means the
275
+ // consumer is holding the stream without pulling (e.g. blocked on a
276
+ // downstream RPC). A boolean rather than an ms_since_last_pull
277
+ // comparison, which is unreliable near the idle-budget boundary. The
278
+ // flag read here is consistent because IdleMonitor runs this onStall
279
+ // callback synchronously, before the rejected waitForStall() propagates
280
+ // into the managed stream's catch and clears the flag (see idle-monitor).
281
+ //
282
+ // A first-chunk stall is upstream silence by definition: no chunk was
283
+ // delivered, so the consumer cannot be holding the stream. Force
284
+ // "network-dark" rather than trusting the flag — pull() is lazy, so a
285
+ // first-chunk timer can fire before the loop's first await ever set it.
286
+ // budget_kind still separates a first-chunk network-dark from an
287
+ // inter-chunk one.
288
+ const stallReason = budgetKind === "first-chunk" || session.isAwaitingUpstream()
289
+ ? "network-dark"
290
+ : "consumer-blocked";
113
291
  const message = `[orchestrator] Idle timeout exceeded, triggering retry`;
114
292
  // Emit as structured attributes (OTel log attributes -> Datadog facets)
115
293
  // so stalls are queryable by provider/model/attempt and chartable,
@@ -118,6 +296,14 @@ export class StreamOrchestrator {
118
296
  // awaiting-user.ts). budget_ms + budget_kind already capture which idle
119
297
  // budget applied and its value, so the static config constants are not
120
298
  // re-emitted per stall. See APPS-4643.
299
+ // wait_state says what the stall was stuck on: "provider" = the model
300
+ // went silent, "tool" = a local tool was still running (a slow tool,
301
+ // not the model), "step-boundary" = it was between steps.
302
+ // raw_reset_count is how many keep-alive pings arrived during this
303
+ // stretch of silence; a healthy-but-quiet model still pings, so 0 means
304
+ // no pings got through. Pairs with ms_since_last_pull (how recently the
305
+ // reader pulled).
306
+ const waitState = waitStateTracker.getWaitState();
121
307
  const attributes = {
122
308
  event: "stream_stall",
123
309
  session_id: session.id,
@@ -129,7 +315,21 @@ export class StreamOrchestrator {
129
315
  budget_ms: error.maxIdleTimeMs,
130
316
  elapsed_ms: session.getElapsedMs(),
131
317
  chunks_received: session.metrics.chunkCount,
318
+ stall_reason: stallReason,
319
+ wait_state: waitState.state,
320
+ raw_reset_count: waitStateTracker.getRawResetCount(),
132
321
  };
322
+ if (waitState.state === "tool") {
323
+ attributes.tool_name = waitState.toolName;
324
+ attributes.tool_call_id = waitState.toolCallId;
325
+ attributes.pending_tool_count = waitState.pendingToolCount;
326
+ }
327
+ // Supporting numeric signal: how long since the consumer last pulled.
328
+ // stall_reason is the primary discriminator; this aids triage. Absent
329
+ // when the consumer never made a productive pull.
330
+ if (msSinceLastPull !== undefined) {
331
+ attributes.ms_since_last_pull = msSinceLastPull;
332
+ }
133
333
  // Customer-scoped facets let on-call triage stalls by org/app during an
134
334
  // incident; only emitted when the session carries them.
135
335
  if (session.organizationId) {
@@ -146,19 +346,67 @@ export class StreamOrchestrator {
146
346
  }
147
347
  },
148
348
  });
149
- const result = await this.startStreamWithRetry(optionsWithAbort, session, retryEngine);
349
+ const result = await this.startStreamWithRetry(optionsWithAbort, session, retryEngine, attempts, waitStateTracker);
150
350
  // Start monitoring now that the stream is active
151
351
  idleMonitor.start();
152
352
  // wrapper order matters here. managed stream throws errors (error chunks), retry wrapper catches them.
153
353
  // Pass the idle monitor so it can interrupt hung streams.
154
354
  const managedStream = createManagedStream(result.fullStream, session, idleMonitor);
155
- const retryableFullStream = this.wrapWithRetry(managedStream, (r) => createManagedStream(r.fullStream, session, idleMonitor), optionsWithAbort, session, retryEngine, internalAbortController);
355
+ const retryableFullStream = this.wrapWithRetry(managedStream, (r) => createManagedStream(r.fullStream, session, idleMonitor), optionsWithAbort, session, retryEngine, internalAbortController, attempts, waitStateTracker);
356
+ // `tee()` preserves the public contract that fullStream and textStream can
357
+ // be consumed independently; the slower branch may buffer response chunks.
358
+ const [fullStream, textSourceStream] = retryableFullStream.tee();
156
359
  return {
157
360
  ...result,
158
- fullStream: retryableFullStream,
159
- textStream: result.textStream,
361
+ fullStream: this.asAsyncIterableStream(fullStream),
362
+ textStream: this.textStreamFromFullStream(this.asAsyncIterableStream(textSourceStream)),
160
363
  };
161
364
  }
365
+ asAsyncIterableStream(stream) {
366
+ const asyncIterableStream = stream;
367
+ if (asyncIterableStream[Symbol.asyncIterator]) {
368
+ return asyncIterableStream;
369
+ }
370
+ return Object.assign(stream, {
371
+ async *[Symbol.asyncIterator]() {
372
+ const reader = stream.getReader();
373
+ try {
374
+ while (true) {
375
+ const result = await reader.read();
376
+ if (result.done) {
377
+ return;
378
+ }
379
+ yield result.value;
380
+ }
381
+ }
382
+ finally {
383
+ reader.releaseLock();
384
+ }
385
+ },
386
+ });
387
+ }
388
+ textStreamFromFullStream(stream) {
389
+ const compactionTextBlockIds = new Set();
390
+ return this.asAsyncIterableStream(stream.pipeThrough(new TransformStream({
391
+ transform(chunk, controller) {
392
+ const compactionBlock = getServerCompactionTextBlockUpdate(chunk);
393
+ if (compactionBlock.kind === "start") {
394
+ compactionTextBlockIds.add(compactionBlock.id);
395
+ return;
396
+ }
397
+ if (compactionBlock.kind === "part" &&
398
+ compactionTextBlockIds.has(compactionBlock.id)) {
399
+ if (compactionBlock.done) {
400
+ compactionTextBlockIds.delete(compactionBlock.id);
401
+ }
402
+ return;
403
+ }
404
+ if (chunk.type === "text-delta") {
405
+ controller.enqueue(chunk.text);
406
+ }
407
+ },
408
+ })));
409
+ }
162
410
  /**
163
411
  * Wraps a fullStream with retry logic. When an error occurs (either thrown or
164
412
  * as an error chunk), makes a retry decision and either creates a new stream
@@ -168,7 +416,7 @@ export class StreamOrchestrator {
168
416
  * retries will silently fail (the new stream will close immediately because
169
417
  * session.isPending() returns false).
170
418
  */
171
- wrapWithRetry(initialStream, getNewStream, options, session, retryEngine, abortController) {
419
+ wrapWithRetry(initialStream, getNewStream, options, session, retryEngine, abortController, attempts, waitStateTracker) {
172
420
  const handleRetry = this.handleRetryDecision.bind(this);
173
421
  let currentStream = initialStream;
174
422
  async function* createRetryIterator() {
@@ -184,7 +432,7 @@ export class StreamOrchestrator {
184
432
  return;
185
433
  }
186
434
  catch (error) {
187
- const retryResult = await handleRetry(error, options, session, retryEngine);
435
+ const retryResult = await handleRetry(error, options, session, retryEngine, attempts, waitStateTracker);
188
436
  if (!retryResult.shouldRetry) {
189
437
  // abort the internal signal so any in-flight stream processing stops.
190
438
  // without this, tools keep getting called in the background since the stream is not closed.
@@ -221,8 +469,8 @@ export class StreamOrchestrator {
221
469
  * Handle an error by making a retry decision. Returns either a new result to continue
222
470
  * with, or the final error to throw.
223
471
  */
224
- async handleRetryDecision(error, options, session, retryEngine) {
225
- const decision = retryEngine.getRetryDecision(error, session, options.abortSignal);
472
+ async handleRetryDecision(error, options, session, retryEngine, attempts, waitStateTracker) {
473
+ const decision = retryEngine.getRetryDecision(error, session, options.abortSignal, waitStateTracker.getWaitState());
226
474
  if (!decision.shouldRetry) {
227
475
  this.logger.warn(`[orchestrator] Not retrying: reason=${decision.reason}`, getErrorMeta(error));
228
476
  session.finalize("error", error);
@@ -231,26 +479,26 @@ export class StreamOrchestrator {
231
479
  error: this.wrapFinalError(error, decision.reason ?? "unknown", session),
232
480
  };
233
481
  }
482
+ // Supersede the failed attempt now, before the backoff delay, so its
483
+ // abandoned stream can't keep executing tools while we wait to retry.
484
+ attempts.abortCurrent();
234
485
  await session.events.emit("retry", error, session.attempt, decision.delayMs, session);
235
486
  if (decision.newProvider) {
236
- const oldProvider = session.provider;
237
- session.provider = decision.newProvider;
238
- this.logger.warn(`[orchestrator] Switching provider: ${oldProvider.name}/${oldProvider.model} -> ${decision.newProvider.name}/${decision.newProvider.model}`, getErrorMeta(error));
239
- await session.events.emit("fallback", oldProvider, decision.newProvider, this.getFallbackReason(error), session);
487
+ await this.switchProvider(session, decision.newProvider, decision.fastFallback === true, error);
240
488
  }
241
489
  session.recordRetry(decision.delayMs);
242
- this.logger.warn(`[orchestrator] Retrying in ${decision.delayMs}ms (attempt ${session.attempt})`, getErrorMeta(error));
490
+ this.logger.warn(`[orchestrator] Retrying in ${decision.delayMs}ms (attempt ${session.attempt}) reason=${decision.reason ?? "retry"}`, getErrorMeta(error));
243
491
  await this.delay(decision.delayMs, options.abortSignal);
244
- const newResult = await this.startStreamWithRetry(options, session, retryEngine);
492
+ const newResult = await this.startStreamWithRetry(options, session, retryEngine, attempts, waitStateTracker);
245
493
  return { shouldRetry: true, newResult };
246
494
  }
247
- async startStreamWithRetry(options, session, retryEngine) {
495
+ async startStreamWithRetry(options, session, retryEngine, attempts, waitStateTracker) {
248
496
  while (true) {
249
497
  try {
250
- return await this.startStream(options, session);
498
+ return await this.startStream(options, session, attempts);
251
499
  }
252
500
  catch (error) {
253
- const decision = retryEngine.getRetryDecision(error, session, options.abortSignal);
501
+ const decision = retryEngine.getRetryDecision(error, session, options.abortSignal, waitStateTracker.getWaitState());
254
502
  if (!decision.shouldRetry) {
255
503
  this.logger.error(`[orchestrator] Startup error, not retrying: reason=${decision.reason}`, {
256
504
  error: {
@@ -262,15 +510,14 @@ export class StreamOrchestrator {
262
510
  await session.events.emit("session:error", error, session);
263
511
  throw this.wrapFinalError(error, decision.reason ?? "unknown", session);
264
512
  }
513
+ // Supersede the failed attempt before backoff.
514
+ attempts.abortCurrent();
265
515
  await session.events.emit("retry", error, session.attempt, decision.delayMs, session);
266
516
  if (decision.newProvider) {
267
- const oldProvider = session.provider;
268
- session.provider = decision.newProvider;
269
- this.logger.warn(`[orchestrator] Switching provider: ${oldProvider.name}/${oldProvider.model} -> ${decision.newProvider.name}/${decision.newProvider.model}`, getErrorMeta(error));
270
- await session.events.emit("fallback", oldProvider, decision.newProvider, this.getFallbackReason(error), session);
517
+ await this.switchProvider(session, decision.newProvider, decision.fastFallback === true, error);
271
518
  }
272
519
  session.recordRetry(decision.delayMs);
273
- this.logger.info(`[orchestrator] Startup retry in ${decision.delayMs}ms (attempt ${session.attempt})`);
520
+ this.logger.info(`[orchestrator] Startup retry in ${decision.delayMs}ms (attempt ${session.attempt}) reason=${decision.reason ?? "retry"}`);
274
521
  await this.delay(decision.delayMs, options.abortSignal);
275
522
  }
276
523
  }
@@ -293,15 +540,27 @@ export class StreamOrchestrator {
293
540
  cause: error,
294
541
  });
295
542
  }
296
- async startStream(options, session) {
543
+ async startStream(options, session, attempts) {
297
544
  if (options.abortSignal?.aborted) {
298
545
  throw new AbortedError();
299
546
  }
300
547
  const providerOptions = this.applyProvider(options, session.provider, session);
301
548
  this.logger.debug(`[orchestrator] Attempt ${session.attempt} with provider=${session.provider.name} model=${session.provider.model}`);
549
+ // Each attempt runs on its own abort signal so a superseded attempt can be
550
+ // aborted without killing the winning one. `isCurrent` fences tool execution
551
+ // and lifecycle callbacks: the Vercel AI SDK can still deliver buffered tool
552
+ // calls and callbacks from a superseded attempt after abort (abort is
553
+ // cooperative), so the live-attempt check is what prevents duplicate tool
554
+ // execution and duplicate finish events.
555
+ const { signal, isCurrent } = attempts.begin();
302
556
  return this.provider.streamText({
303
557
  ...providerOptions,
558
+ abortSignal: signal,
559
+ tools: this.guardToolsForAttempt(providerOptions.tools, isCurrent),
304
560
  prepareStep: async (step) => {
561
+ if (!isCurrent()) {
562
+ throw new AbortedError();
563
+ }
305
564
  const preparedStep = (await options.prepareStep?.(step)) ?? step;
306
565
  await session.events.emit("step:start", {
307
566
  stepNumber: preparedStep.stepNumber,
@@ -310,17 +569,61 @@ export class StreamOrchestrator {
310
569
  return preparedStep;
311
570
  },
312
571
  onStepFinish: async (step) => {
572
+ if (!isCurrent()) {
573
+ return;
574
+ }
313
575
  await session.events.emit("step:finish", step, session);
314
576
  await options.onStepFinish?.(step);
315
577
  },
316
578
  onFinish: async (finalResult) => {
579
+ if (!isCurrent()) {
580
+ return;
581
+ }
317
582
  session.setTotalTokens(finalResult.totalUsage?.totalTokens ?? 0);
318
583
  await session.events.emit("stream:finish", finalResult, session);
319
584
  return options.onFinish?.(finalResult);
320
585
  },
321
- onChunk: options.onChunk,
586
+ onChunk: (event) => {
587
+ if (!isCurrent()) {
588
+ return;
589
+ }
590
+ return options.onChunk?.(event);
591
+ },
322
592
  });
323
593
  }
594
+ /**
595
+ * Wraps each tool's execute so only the live attempt can run it. The Vercel AI
596
+ * SDK auto-executes tools as their calls arrive on the provider stream, and a
597
+ * superseded attempt's stream can still deliver buffered tool calls after we
598
+ * abort it. Throwing AbortedError here ensures a terminal tool's real side
599
+ * effect (e.g. build_finalize) runs once, for the winning attempt only.
600
+ */
601
+ guardToolsForAttempt(tools, isCurrent) {
602
+ if (!tools) {
603
+ return tools;
604
+ }
605
+ const guarded = {};
606
+ for (const [name, tool] of Object.entries(tools)) {
607
+ if (typeof tool?.execute !== "function") {
608
+ guarded[name] = tool;
609
+ continue;
610
+ }
611
+ const execute = tool.execute.bind(tool);
612
+ guarded[name] = {
613
+ ...tool,
614
+ execute: async (input, executeOptions) => {
615
+ if (!isCurrent()) {
616
+ throw new AbortedError();
617
+ }
618
+ return execute(input, executeOptions);
619
+ },
620
+ };
621
+ }
622
+ // Each entry preserves the original tool (`...tool`) and only wraps
623
+ // `execute`, so the runtime shape still matches TOOLS; signal the deliberate
624
+ // widening so a reviewer doesn't read this as a checked narrowing.
625
+ return guarded;
626
+ }
324
627
  applyProvider(options, provider, session) {
325
628
  const baseOptions = this.config.fallback.enabled
326
629
  ? {
@@ -344,9 +647,22 @@ export class StreamOrchestrator {
344
647
  const thinkingRebuilt = this.rebuildThinkingOptions(baseOptions, provider);
345
648
  return {
346
649
  ...thinkingRebuilt,
650
+ // `thinkingRebuilt.headers` is already authoritative for the
651
+ // interleaved-thinking beta header. Don't re-merge `options.headers`
652
+ // here or a disabled/adaptive rebuild would re-introduce the stale beta.
347
653
  headers: {
348
- ...options.headers,
349
654
  ...thinkingRebuilt.headers,
655
+ // Stamp the per-attempt stream identity so the server proxy can join
656
+ // its egress frame counts to this stream's client-side stream_stall
657
+ // event. The value is the same session.id the stall event logs as
658
+ // `session_id`; the proxy logs it as `stream_id` (the server log
659
+ // pipeline redacts any field whose name contains "session", so the
660
+ // proxy cannot reuse the `session_id` name). That join is what lets a
661
+ // sampled stall be decided as upstream-silent (proxy wrote nothing) vs
662
+ // proxy->client drop (proxy wrote frames the client never saw). Rides
663
+ // the existing x-superblocks-* header path.
664
+ "x-superblocks-stream-id": session.id,
665
+ "x-superblocks-stream-attempt": String(session.attempt),
350
666
  },
351
667
  providerOptions: {
352
668
  ...thinkingRebuilt.providerOptions,
@@ -380,6 +696,11 @@ export class StreamOrchestrator {
380
696
  const existingBedrockFields = existingBedrock.additionalModelRequestFields ?? {};
381
697
  const { thinking: _a, effort: _b, ...anthropicRest } = existingAnthropic;
382
698
  const { thinking: _c, output_config: _d, anthropic_beta: _e, budget_tokens: _f, ...bedrockFieldsRest } = existingBedrockFields;
699
+ // `buildThinkingProviderOptions` is the sole source of the
700
+ // interleaved-thinking `anthropic-beta` HTTP header. Strip any stale value
701
+ // before applying the fresh headers so a disabled or adaptive-effort
702
+ // rebuild (both emit no header) stops advertising the beta.
703
+ const { "anthropic-beta": _staleBeta, ...headersWithoutThinkingBeta } = options.headers ?? {};
383
704
  const anthropic = fresh.anthropic
384
705
  ? { ...anthropicRest, ...fresh.anthropic }
385
706
  : anthropicRest;
@@ -399,7 +720,7 @@ export class StreamOrchestrator {
399
720
  : { ...existingBedrock, additionalModelRequestFields: {} };
400
721
  return {
401
722
  ...options,
402
- headers: { ...options.headers, ...fresh.headers },
723
+ headers: { ...headersWithoutThinkingBeta, ...fresh.headers },
403
724
  providerOptions: {
404
725
  ...existing,
405
726
  anthropic: anthropic,
@@ -415,6 +736,39 @@ export class StreamOrchestrator {
415
736
  }
416
737
  return "error";
417
738
  }
739
+ /**
740
+ * Switch the session to a fallback provider, emit the structured switch log,
741
+ * and fire the "fallback" event. `fastFallback` distinguishes the stall
742
+ * fast-fallback path (a stall left the provider early) from a normal
743
+ * provider-exhaustion fallback, surfaced as a queryable Datadog facet so the
744
+ * fast-fallback behavior is verifiable in production. Error context for the
745
+ * triggering error is carried by the retry log that follows this call.
746
+ */
747
+ async switchProvider(session, newProvider, fastFallback, error) {
748
+ const oldProvider = session.provider;
749
+ session.provider = newProvider;
750
+ const message = `[orchestrator] Switching provider: ${oldProvider.name}/${oldProvider.model} -> ` +
751
+ `${newProvider.name}/${newProvider.model}` +
752
+ (fastFallback ? " (stall fast-fallback)" : "");
753
+ const attributes = {
754
+ event: "provider_fallback",
755
+ session_id: session.id,
756
+ from_provider: oldProvider.name,
757
+ from_model: oldProvider.model,
758
+ to_provider: newProvider.name,
759
+ to_model: newProvider.model,
760
+ attempt: session.attempt,
761
+ fast_fallback: fastFallback,
762
+ error_kind: error instanceof Error ? error.name : "unknown",
763
+ };
764
+ if (this.logger.warnStructured) {
765
+ this.logger.warnStructured(message, attributes);
766
+ }
767
+ else {
768
+ this.logger.warn(message, attributes);
769
+ }
770
+ await session.events.emit("fallback", oldProvider, newProvider, this.getFallbackReason(error), session);
771
+ }
418
772
  delay(ms, abortSignal) {
419
773
  return new Promise((resolve) => {
420
774
  if (abortSignal?.aborted) {