@animalabs/membrane 0.5.76 → 0.5.78

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.
package/src/membrane.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  DEFAULT_RETRY_CONFIG,
32
32
  MembraneError,
33
33
  classifyError,
34
+ isOverloadedError,
34
35
  isTextContent,
35
36
  isAbortedResponse,
36
37
  } from './types/index.js';
@@ -80,7 +81,11 @@ export class Membrane {
80
81
  ) {
81
82
  this.adapter = adapter;
82
83
  this.registry = config.registry;
83
- this.retryConfig = { ...DEFAULT_RETRY_CONFIG, ...config.retry };
84
+ this.retryConfig = {
85
+ ...DEFAULT_RETRY_CONFIG,
86
+ ...config.retry,
87
+ overloaded: { ...DEFAULT_RETRY_CONFIG.overloaded, ...config.retry?.overloaded },
88
+ };
84
89
  this.config = config;
85
90
  // Use provided formatter or default to AnthropicXmlFormatter
86
91
  this.formatter = config.formatter ?? new AnthropicXmlFormatter();
@@ -100,6 +105,10 @@ export class Membrane {
100
105
  const startTime = Date.now();
101
106
  let attempts = 0;
102
107
  let rawRequest: unknown;
108
+ // Counted separately from `attempts` (the transport-error budget): a
109
+ // refusal is a successful HTTP call with an unwanted verdict, and letting
110
+ // it consume error retries would couple two unrelated budgets.
111
+ let refusalRetriesUsed = 0;
103
112
 
104
113
  while (true) {
105
114
  attempts++;
@@ -135,6 +144,19 @@ export class Membrane {
135
144
  rawRequest
136
145
  );
137
146
 
147
+ // Re-issue a content-policy refusal (opt-in, default off). Safe here
148
+ // in a way the streaming paths are not: nothing has reached the
149
+ // caller yet, so the abandoned attempt leaves no trace to retract.
150
+ // Deliberately BEFORE afterResponse — a hook that logs or transforms
151
+ // should see the attempt that actually stands, not the discarded one.
152
+ if (
153
+ response.stopReason === 'refusal' &&
154
+ refusalRetriesUsed < Math.max(0, options.refusalRetries ?? 0)
155
+ ) {
156
+ refusalRetriesUsed++;
157
+ continue;
158
+ }
159
+
138
160
  // Call afterResponse hook
139
161
  if (this.config.hooks?.afterResponse) {
140
162
  return await this.config.hooks.afterResponse(response, providerResponse.raw);
@@ -146,12 +168,23 @@ export class Membrane {
146
168
  const errorInfo = classifyError(error);
147
169
  errorInfo.rawRequest = rawRequest;
148
170
 
149
- // Rate limits (429) always retry up to 5 attempts regardless of config.
150
- // Other retryable errors only retry when maxRetries > 0.
171
+ // Rate limits (429) always retry up to 5 attempts regardless of
172
+ // config, and overloaded (529) always retries on its own longer
173
+ // schedule — both are transient by definition, and the default
174
+ // maxRetries of 0 would otherwise turn a capacity blip into a dead
175
+ // turn. Other retryable errors only retry when maxRetries > 0.
176
+ // overloaded.maxRetries: 0 disables the dedicated policy entirely;
177
+ // the 529 then follows the base config like any retryable server
178
+ // error (exactly the pre-policy behavior), rather than being
179
+ // silently re-promoted to the long schedule by a positive base limit.
151
180
  const isRateLimit = errorInfo.type === 'rate_limit';
181
+ const isOverloaded =
182
+ isOverloadedError(errorInfo) && this.retryConfig.overloaded.maxRetries > 0;
152
183
  const effectiveMax = isRateLimit
153
184
  ? Math.max(this.retryConfig.maxRetries, 5)
154
- : this.retryConfig.maxRetries;
185
+ : isOverloaded
186
+ ? Math.max(this.retryConfig.maxRetries, this.retryConfig.overloaded.maxRetries)
187
+ : this.retryConfig.maxRetries;
155
188
 
156
189
  if (errorInfo.retryable && attempts < effectiveMax) {
157
190
  // Check hook for retry decision
@@ -163,7 +196,7 @@ export class Membrane {
163
196
  }
164
197
 
165
198
  // Wait before retry (abort-aware)
166
- const delay = this.calculateRetryDelay(attempts);
199
+ const delay = this.calculateRetryDelay(attempts, isOverloaded);
167
200
  await this.sleep(delay, options.signal);
168
201
  continue;
169
202
  }
@@ -217,11 +250,74 @@ export class Membrane {
217
250
 
218
251
  // Determine tool mode
219
252
  const toolMode = this.resolveToolMode(request);
253
+ const useNative = toolMode === 'native' && !!request.tools && request.tools.length > 0;
254
+
255
+ // Overloaded (529) pre-emission retry. The streaming paths have no retry
256
+ // loop of their own, so a capacity error used to kill the turn outright —
257
+ // and 529s most often arrive INSTEAD of a stream, before anything reaches
258
+ // the caller, where retrying is transparent. Once any callback has
259
+ // delivered output (tokens, blocks, usage), retrying would replay content
260
+ // the caller already consumed, so mid-stream errors still throw.
261
+ let attempts = 0;
262
+ const retryDelaysMs: number[] = [];
263
+ while (true) {
264
+ attempts++;
265
+ let emitted = false;
266
+ const mark = <A extends unknown[], R>(fn?: (...args: A) => R) =>
267
+ fn && ((...args: A): R => { emitted = true; return fn(...args); });
268
+ const tracked: StreamOptions = {
269
+ ...options,
270
+ onChunk: mark(options.onChunk),
271
+ onContentBlockUpdate: mark(options.onContentBlockUpdate),
272
+ onToolCalls: mark(options.onToolCalls),
273
+ onPreToolContent: mark(options.onPreToolContent),
274
+ onUsage: mark(options.onUsage),
275
+ onBlock: mark(options.onBlock),
276
+ onResponse: mark(options.onResponse),
277
+ // onRequest fires before the send — it is not an emission.
278
+ };
220
279
 
221
- if (toolMode === 'native' && request.tools && request.tools.length > 0) {
222
- return this.streamWithNativeTools(request, options);
223
- } else {
224
- return this.streamWithXmlTools(request, options);
280
+ try {
281
+ const result = useNative
282
+ ? await this.streamWithNativeTools(request, tracked)
283
+ : await this.streamWithXmlTools(request, tracked);
284
+ // The inner paths report attempts: 1 — they can't see this wrapper.
285
+ // A call that succeeded after N overloaded retries must not look like
286
+ // a first-attempt success in durable logs, so patch the real count
287
+ // (and the waits) into the response telemetry.
288
+ if (attempts > 1 && 'details' in result) {
289
+ result.details.timing.attempts = attempts;
290
+ result.details.timing.retryDelaysMs = retryDelaysMs;
291
+ }
292
+ return result;
293
+ } catch (error) {
294
+ const errorInfo = classifyError(error);
295
+ // Same semantics as complete(): maxRetries bounds total attempts,
296
+ // the overloaded floor applies over the base config, and
297
+ // overloaded.maxRetries: 0 opts out of stream retries entirely
298
+ // (streaming had no retry before this policy existed).
299
+ const overloadedEnabled = this.retryConfig.overloaded.maxRetries > 0;
300
+ const maxOverloaded = Math.max(
301
+ this.retryConfig.maxRetries,
302
+ this.retryConfig.overloaded.maxRetries
303
+ );
304
+ if (!emitted && overloadedEnabled && isOverloadedError(errorInfo) && attempts < maxOverloaded) {
305
+ // Honor the same pre-retry hook contract as complete(): hosts use
306
+ // onError for circuit-breaking, and its 'abort' decision must work
307
+ // on the streaming path too.
308
+ if (this.config.hooks?.onError) {
309
+ const decision = await this.config.hooks.onError(errorInfo, attempts);
310
+ if (decision === 'abort') {
311
+ throw error;
312
+ }
313
+ }
314
+ const delay = this.calculateRetryDelay(attempts, true);
315
+ retryDelaysMs.push(delay);
316
+ await this.sleep(delay, options.signal);
317
+ continue;
318
+ }
319
+ throw error;
320
+ }
225
321
  }
226
322
  }
227
323
 
@@ -330,6 +426,52 @@ export class Membrane {
330
426
  // from blocks the model itself opened during generation
331
427
  const prefillDepths = parser.getDepths();
332
428
 
429
+ // Resumption spin guards (issue #39). Observed live on Ash 2026-07-26:
430
+ // each automatic resumption re-sent ~172k input tokens, streamed ~6
431
+ // output tokens, and stopped on the same (dropped) stop sequence — 43
432
+ // rounds, ~7M input tokens, zero progress, found only because a human
433
+ // noticed. Two guards, both scoped to AUTOMATIC false-positive
434
+ // resumptions — tool rounds are real caller-governed work (maxToolDepth
435
+ // / the yielding API's uncapped contract) and are never counted here:
436
+ // - stall guard: several CONSECUTIVE resumptions that each stream
437
+ // almost nothing and stop identically end the turn ('no_progress').
438
+ // One short repeated round is low progress, not proof of none — a
439
+ // stop sequence inside legitimate tool-argument text can cause a
440
+ // couple of short resumptions on the way to completing.
441
+ // - round cap: a hard bound on resumptions per turn ('round_limit'),
442
+ // the backstop for a spin that keeps technically progressing.
443
+ const MIN_ROUND_PROGRESS_CHARS = 16;
444
+ const MAX_CONSECUTIVE_STALLED_RESUMPTIONS = 3;
445
+ const RESUMPTION_WARN_ROUNDS = 5;
446
+ const maxResumptionRounds = options.maxResumptionRounds ?? 24;
447
+ let resumptionRounds = 0;
448
+ let consecutiveStalledResumptions = 0;
449
+ let enteredViaResumption = false;
450
+ let prevRoundStopSequence: string | undefined;
451
+ const warnLog = this.config.logger ?? console;
452
+
453
+ /** Count an automatic resumption; emits the visibility warning at the
454
+ * threshold and returns false when the cap says the turn should end. */
455
+ const registerResumptionRound = (): boolean => {
456
+ resumptionRounds++;
457
+ if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
458
+ warnLog.warn(
459
+ `[membrane] automatic resumption at round ${resumptionRounds} ` +
460
+ `(${totalUsage.inputTokens} input tokens so far this turn) — ` +
461
+ `a spin shows up here before it shows up on the bill`
462
+ );
463
+ }
464
+ if (resumptionRounds > maxResumptionRounds) {
465
+ warnLog.warn(
466
+ `[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
467
+ `ending turn with stopReason 'round_limit'. ` +
468
+ `${totalUsage.inputTokens} input tokens spent this turn.`
469
+ );
470
+ return false;
471
+ }
472
+ return true;
473
+ };
474
+
333
475
  try {
334
476
  // Tool execution loop
335
477
  while (toolDepth <= maxToolDepth) {
@@ -338,7 +480,10 @@ export class Membrane {
338
480
  let detectedStopSequence: string | null = null;
339
481
  let truncatedAccumulated: string | null = null;
340
482
 
341
- // Track where to start checking for stop sequences (skip already-processed content)
483
+ // Track where to start checking for stop sequences (skip already-processed content).
484
+ // Also the round's progress baseline: XML we pushed ourselves at the
485
+ // end of the previous round (tool results, closing tags) sits below
486
+ // this index and doesn't count as model progress.
342
487
  const checkFromIndex = parser.getAccumulated().length;
343
488
 
344
489
  // Stream from provider
@@ -465,6 +610,38 @@ export class Membrane {
465
610
  // Get accumulated text from parser
466
611
  const accumulated = parser.getAccumulated();
467
612
 
613
+ // Stall accounting (issue #39): only rounds ENTERED via automatic
614
+ // resumption can stall — tool rounds are caller-governed work and a
615
+ // real tool call is longer than the threshold anyway. A stall is a
616
+ // resumption that streamed almost nothing and stopped identically to
617
+ // the previous round; the turn ends only after several IN A ROW
618
+ // (one short repeated round is low progress, not proof of none —
619
+ // a stop sequence inside legitimate tool-argument text can cause a
620
+ // couple of short resumptions on the way to completing).
621
+ const streamedThisRound = accumulated.length - checkFromIndex;
622
+ if (
623
+ enteredViaResumption &&
624
+ lastStopReason === 'stop_sequence' &&
625
+ streamedThisRound < MIN_ROUND_PROGRESS_CHARS &&
626
+ lastStopSequence === prevRoundStopSequence
627
+ ) {
628
+ consecutiveStalledResumptions++;
629
+ if (consecutiveStalledResumptions >= MAX_CONSECUTIVE_STALLED_RESUMPTIONS) {
630
+ warnLog.warn(
631
+ `[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
632
+ `made no progress (${streamedThisRound} chars this round, stop ` +
633
+ `${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
634
+ `stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`
635
+ );
636
+ lastStopReason = 'no_progress';
637
+ break;
638
+ }
639
+ } else {
640
+ consecutiveStalledResumptions = 0;
641
+ }
642
+ prevRoundStopSequence = lastStopSequence;
643
+ enteredViaResumption = false;
644
+
468
645
  // Check for tool calls (if handler provided)
469
646
  if (onToolCalls && streamResult.stopSequence === '</function_calls>') {
470
647
  // Append the closing tag (we truncated before it, or API stopped before it)
@@ -655,7 +832,9 @@ export class Membrane {
655
832
  );
656
833
  }
657
834
 
658
- // Reset parser state for new streaming iteration
835
+ // Reset parser state for new streaming iteration. Tool rounds
836
+ // are the caller's work — they count against maxToolDepth only,
837
+ // never against the resumption guards (issue #39 review).
659
838
  parser.resetForNewIteration();
660
839
  toolDepth++;
661
840
  continue;
@@ -692,6 +871,11 @@ export class Membrane {
692
871
  if (toolDepth > maxToolDepth) {
693
872
  break;
694
873
  }
874
+ if (!registerResumptionRound()) {
875
+ lastStopReason = 'round_limit';
876
+ break;
877
+ }
878
+ enteredViaResumption = true;
695
879
  prefillResult.assistantPrefill = parser.getAccumulated();
696
880
  providerRequest = this.buildContinuationRequest(
697
881
  request,
@@ -1514,6 +1698,23 @@ export class Membrane {
1514
1698
  * separate `streamOnceWithoutHook` so the bypass is intentional.
1515
1699
  */
1516
1700
  normalizedRequest: NormalizedRequest;
1701
+ /**
1702
+ * Re-issue this attempt when the provider ends it with
1703
+ * `stop_reason: 'refusal'` (see RetryingEvent). Default 0 = off, so
1704
+ * every existing caller keeps byte-identical behaviour.
1705
+ */
1706
+ refusalRetries?: number;
1707
+ /**
1708
+ * REQUIRED to enable streaming retries. Called immediately before a
1709
+ * re-issue so the caller can discard the abandoned attempt: reset its
1710
+ * accumulators and tell its own consumer to drop what it emitted.
1711
+ *
1712
+ * Without it a retry would silently concatenate two attempts, so a
1713
+ * caller that does not pass this simply does not get retries — an
1714
+ * unaware consumer can never be corrupted by enabling the option
1715
+ * somewhere upstream.
1716
+ */
1717
+ onRetrying?: (info: { attempt: number; maxAttempts: number; category?: string }) => void;
1517
1718
  }
1518
1719
  ) {
1519
1720
  // Strip `normalizedRequest` before forwarding to the adapter — it's
@@ -1521,9 +1722,21 @@ export class Membrane {
1521
1722
  // compatibility won't catch the excess field (checked only on object
1522
1723
  // literals, not on variables). Leaving it in would silently leak the
1523
1724
  // normalized form into every adapter's options.
1524
- const { normalizedRequest, ...adapterOptions } = options;
1725
+ const { normalizedRequest, refusalRetries, onRetrying, ...adapterOptions } = options;
1525
1726
  const finalRequest = (await this.applyBeforeRequestHook(normalizedRequest, request)) as typeof request;
1526
- return await this.adapter.stream(finalRequest, callbacks, adapterOptions);
1727
+
1728
+ // Retries are only safe when the caller can discard the abandoned
1729
+ // attempt, so they require BOTH a budget and an onRetrying hook.
1730
+ const maxAttempts = onRetrying ? Math.max(0, refusalRetries ?? 0) : 0;
1731
+ let retried = 0;
1732
+ while (true) {
1733
+ const result = await this.adapter.stream(finalRequest, callbacks, adapterOptions);
1734
+ if (result.stopReason !== 'refusal' || retried >= maxAttempts) return result;
1735
+ retried++;
1736
+ const category = (result.raw as { response?: { stop_details?: { category?: string } } } | undefined)
1737
+ ?.response?.stop_details?.category;
1738
+ onRetrying!({ attempt: retried, maxAttempts, category });
1739
+ }
1527
1740
  }
1528
1741
 
1529
1742
  private buildContinuationRequest(
@@ -1893,10 +2106,15 @@ export class Membrane {
1893
2106
  return pricing ? calculateCost(usage, pricing) : undefined;
1894
2107
  }
1895
2108
 
1896
- private calculateRetryDelay(attempt: number): number {
1897
- const { retryDelayMs, backoffMultiplier, maxRetryDelayMs } = this.retryConfig;
1898
- const delay = retryDelayMs * Math.pow(backoffMultiplier, attempt - 1);
1899
- return Math.min(delay, maxRetryDelayMs);
2109
+ private calculateRetryDelay(attempt: number, overloaded = false): number {
2110
+ const { retryDelayMs, backoffMultiplier, maxRetryDelayMs } = overloaded
2111
+ ? this.retryConfig.overloaded
2112
+ : this.retryConfig;
2113
+ const delay = Math.min(retryDelayMs * Math.pow(backoffMultiplier, attempt - 1), maxRetryDelayMs);
2114
+ // Equal jitter on the overloaded schedule only: a capacity storm is
2115
+ // exactly the case where a fleet retrying in sync re-creates the
2116
+ // stampede it's backing off from. [delay/2, delay) keeps the wait long.
2117
+ return overloaded ? Math.floor(delay / 2 + Math.random() * (delay / 2)) : delay;
1900
2118
  }
1901
2119
 
1902
2120
  private attachRawRequest(error: unknown, rawRequest: unknown): Error {
@@ -2000,6 +2218,18 @@ export class Membrane {
2000
2218
  ): YieldingStream {
2001
2219
  const toolMode = this.resolveToolMode(request);
2002
2220
 
2221
+ // refusalRetries is implemented on the native path only. The XML path
2222
+ // accumulates into a streaming parser carrying prefill context and
2223
+ // resumption depths; rolling that back mid-turn is a separate problem,
2224
+ // and a partial implementation would corrupt the turn instead of
2225
+ // retrying it. Fail LOUD and OFF rather than silently mis-retrying.
2226
+ if (toolMode !== 'native' && (options.refusalRetries ?? 0) > 0) {
2227
+ (this.config.logger ?? console).warn(
2228
+ '[membrane] refusalRetries is ignored in XML tool mode ' +
2229
+ '(native-only for now) — the turn will surface the refusal as before.',
2230
+ );
2231
+ }
2232
+
2003
2233
  // Create the yielding stream with the appropriate inference runner
2004
2234
  const runInference = toolMode === 'native'
2005
2235
  ? (stream: YieldingStreamImpl) => this.runNativeToolsYielding(request, options, stream)
@@ -2033,6 +2263,29 @@ export class Membrane {
2033
2263
  ? Infinity
2034
2264
  : maxToolDepthOpt;
2035
2265
 
2266
+ // Resumption spin guards (issue #39). This is the path the Ash spin ran
2267
+ // on: tool depth here is unlimited BY DESIGN (the caller budgets its own
2268
+ // tool work — that contract stands untouched), and the false-positive
2269
+ // resumption path counted against that same unlimited bound — 43 rounds
2270
+ // × ~172k input tokens of zero progress. Only AUTOMATIC resumptions are
2271
+ // guarded: a stall guard (consecutive no-progress resumptions →
2272
+ // 'no_progress') and a hard resumption cap ('round_limit'). Tool rounds
2273
+ // are never counted. See streamWithXmlTools for the rationale details.
2274
+ const MIN_ROUND_PROGRESS_CHARS = 16;
2275
+ const MAX_CONSECUTIVE_STALLED_RESUMPTIONS = 3;
2276
+ const RESUMPTION_WARN_ROUNDS = 5;
2277
+ const maxResumptionRounds =
2278
+ options.maxResumptionRounds === undefined
2279
+ ? 24
2280
+ : options.maxResumptionRounds === -1
2281
+ ? Infinity
2282
+ : options.maxResumptionRounds;
2283
+ let resumptionRounds = 0;
2284
+ let consecutiveStalledResumptions = 0;
2285
+ let enteredViaResumption = false;
2286
+ let prevRoundStopSequence: string | undefined;
2287
+ const warnLog = this.config.logger ?? console;
2288
+
2036
2289
  // Initialize parser from formatter for format-specific tracking
2037
2290
  const formatter = this.formatter;
2038
2291
  const parser = formatter.createStreamParser();
@@ -2078,6 +2331,28 @@ export class Membrane {
2078
2331
  // from blocks the model itself opened during generation
2079
2332
  const prefillDepths = parser.getDepths();
2080
2333
 
2334
+ /** Count an automatic resumption; emits the visibility warning at the
2335
+ * threshold and returns false when the cap says the turn should end. */
2336
+ const registerResumptionRound = (): boolean => {
2337
+ resumptionRounds++;
2338
+ if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
2339
+ warnLog.warn(
2340
+ `[membrane] automatic resumption at round ${resumptionRounds} ` +
2341
+ `(${totalUsage.inputTokens} input tokens so far this turn) — ` +
2342
+ `a spin shows up here before it shows up on the bill`
2343
+ );
2344
+ }
2345
+ if (resumptionRounds > maxResumptionRounds) {
2346
+ warnLog.warn(
2347
+ `[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
2348
+ `ending turn with stopReason 'round_limit'. ` +
2349
+ `${totalUsage.inputTokens} input tokens spent this turn.`
2350
+ );
2351
+ return false;
2352
+ }
2353
+ return true;
2354
+ };
2355
+
2081
2356
  try {
2082
2357
  // Tool execution loop
2083
2358
  while (toolDepth <= maxToolDepth) {
@@ -2206,6 +2481,34 @@ export class Membrane {
2206
2481
  }
2207
2482
  }
2208
2483
 
2484
+ // Stall accounting (issue #39): only rounds ENTERED via automatic
2485
+ // resumption can stall; the turn ends only after several consecutive
2486
+ // stalls. Tool rounds are never counted. Mirrors streamWithXmlTools —
2487
+ // see the detailed rationale there.
2488
+ const streamedThisRound = parser.getAccumulated().length - checkFromIndex;
2489
+ if (
2490
+ enteredViaResumption &&
2491
+ lastStopReason === 'stop_sequence' &&
2492
+ streamedThisRound < MIN_ROUND_PROGRESS_CHARS &&
2493
+ lastStopSequence === prevRoundStopSequence
2494
+ ) {
2495
+ consecutiveStalledResumptions++;
2496
+ if (consecutiveStalledResumptions >= MAX_CONSECUTIVE_STALLED_RESUMPTIONS) {
2497
+ warnLog.warn(
2498
+ `[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
2499
+ `made no progress (${streamedThisRound} chars this round, stop ` +
2500
+ `${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
2501
+ `stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`
2502
+ );
2503
+ lastStopReason = 'no_progress';
2504
+ break;
2505
+ }
2506
+ } else {
2507
+ consecutiveStalledResumptions = 0;
2508
+ }
2509
+ prevRoundStopSequence = lastStopSequence;
2510
+ enteredViaResumption = false;
2511
+
2209
2512
  // Check for tool calls
2210
2513
  if (streamResult.stopSequence === '</function_calls>') {
2211
2514
  const closeTag = '</function_calls>';
@@ -2422,6 +2725,9 @@ export class Membrane {
2422
2725
  );
2423
2726
  }
2424
2727
 
2728
+ // Tool rounds are the caller's work — they count against
2729
+ // maxToolDepth only, never against the resumption guards
2730
+ // (issue #39 review: the uncapped tool-loop contract stands).
2425
2731
  parser.resetForNewIteration();
2426
2732
  toolDepth++;
2427
2733
  continue;
@@ -2453,6 +2759,11 @@ export class Membrane {
2453
2759
  if (toolDepth > maxToolDepth) {
2454
2760
  break;
2455
2761
  }
2762
+ if (!registerResumptionRound()) {
2763
+ lastStopReason = 'round_limit';
2764
+ break;
2765
+ }
2766
+ enteredViaResumption = true;
2456
2767
  prefillResult.assistantPrefill = parser.getAccumulated();
2457
2768
  providerRequest = this.buildContinuationRequest(
2458
2769
  request,
@@ -2571,6 +2882,9 @@ export class Membrane {
2571
2882
  // Stream from provider
2572
2883
  let textAccumulated = '';
2573
2884
  let blockIndex = 0;
2885
+ // Where this attempt starts inside the tool-loop-spanning buffer, so
2886
+ // a refusal retry can roll back exactly this attempt's contribution.
2887
+ const allTextBefore = allTextAccumulated.length;
2574
2888
  // Track block-type from the provider's content_block_start signal so
2575
2889
  // every token chunk is tagged with the membrane block it belongs to.
2576
2890
  // Without this, thinking_delta chunks get mislabelled as 'text' and
@@ -2649,6 +2963,25 @@ export class Membrane {
2649
2963
  idleTimeoutMs: options.idleTimeoutMs,
2650
2964
  normalizedRequest: request,
2651
2965
  onRequest: (req: unknown) => { rawRequest = req; },
2966
+ refusalRetries: options.refusalRetries,
2967
+ // Discard the refused attempt: roll the accumulators back to
2968
+ // where this attempt began and tell the consumer to drop what it
2969
+ // already received. `allTextAccumulated` spans the whole tool
2970
+ // loop, so it is truncated rather than cleared.
2971
+ onRetrying: (info) => {
2972
+ allTextAccumulated = allTextAccumulated.slice(0, allTextBefore);
2973
+ textAccumulated = '';
2974
+ blockIndex = 0;
2975
+ currentBlockType = 'text';
2976
+ seenBlockIndices.clear();
2977
+ stream.emit({
2978
+ type: 'retrying',
2979
+ attempt: info.attempt,
2980
+ maxAttempts: info.maxAttempts,
2981
+ reason: 'refusal',
2982
+ ...(info.category ? { category: info.category } : {}),
2983
+ });
2984
+ },
2652
2985
  }
2653
2986
  );
2654
2987