@animalabs/membrane 0.5.76 → 0.5.77

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.
@@ -1 +1 @@
1
- {"version":3,"file":"membrane.d.ts","sourceRoot":"","sources":["../src/membrane.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EACV,iBAAiB,EACjB,kBAAkB,EAClB,eAAe,EAEf,eAAe,EAEf,cAAc,EACd,aAAa,EACb,eAAe,EAYhB,MAAM,kBAAkB,CAAC;AAoB1B,OAAO,KAAK,EACV,cAAc,EACd,qBAAqB,EAGtB,MAAM,4BAA4B,CAAC;AAiBpC,qBAAa,QAAQ;IACnB,OAAO,CAAC,OAAO,CAAkB;IACjC,OAAO,CAAC,QAAQ,CAAC,CAAgB;IACjC,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,SAAS,CAAmB;gBAGlC,OAAO,EAAE,eAAe,EACxB,MAAM,GAAE,cAAmB;IAc7B;;OAEG;IACG,QAAQ,CACZ,OAAO,EAAE,iBAAiB,EAC1B,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,kBAAkB,CAAC;IA6E9B;;;;;;;;;;;;;;;;;OAiBG;IACG,MAAM,CACV,OAAO,EAAE,iBAAiB,EAC1B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,kBAAkB,GAAG,eAAe,CAAC;IA+BhD;;OAEG;IACH,OAAO,CAAC,eAAe;IAsBvB;;;;;;OAMG;YACW,kBAAkB;IAsfhC;;OAEG;YACW,qBAAqB;IAwOnC;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAwN9B;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAuD5B;;;;;OAKG;IACH,OAAO,CAAC,6BAA6B;IAkBrC;;;;;;;;;OASG;IACH,OAAO,CAAC,2BAA2B;IA+BnC;;;;;;;OAOG;YACW,sBAAsB;IASpC;;;OAGG;IACH,OAAO,CAAC,qBAAqB;IAoB7B;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,qBAAqB;IAIpC;;;;;;;;OAQG;IACH,OAAO,CAAC,kBAAkB;IAqB1B;;OAEG;IACH,OAAO,CAAC,gBAAgB;YA4EV,UAAU;IA+BxB,OAAO,CAAC,wBAAwB;IA8ChC;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,kCAAkC;IAoD1C,OAAO,CAAC,iBAAiB;IAqIzB,OAAO,CAAC,kBAAkB;IAkF1B,OAAO,CAAC,aAAa;IAoBrB,OAAO,CAAC,sBAAsB;IAO9B,OAAO,CAAC,cAAc;IAItB,qFAAqF;IACrF,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,gBAAgB;IAMxB,OAAO,CAAC,KAAK;IAcb;;OAEG;IACH,OAAO,CAAC,YAAY;IAcpB;;OAEG;IACH,OAAO,CAAC,oBAAoB;IA4B5B;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,cAAc,CACZ,OAAO,EAAE,iBAAiB,EAC1B,OAAO,GAAE,qBAA0B,GAClC,cAAc;IAWjB;;OAEG;YACW,mBAAmB;IAmfjC;;OAEG;YACW,sBAAsB;CAqTrC"}
1
+ {"version":3,"file":"membrane.d.ts","sourceRoot":"","sources":["../src/membrane.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EACV,iBAAiB,EACjB,kBAAkB,EAClB,eAAe,EAEf,eAAe,EAEf,cAAc,EACd,aAAa,EACb,eAAe,EAYhB,MAAM,kBAAkB,CAAC;AAqB1B,OAAO,KAAK,EACV,cAAc,EACd,qBAAqB,EAGtB,MAAM,4BAA4B,CAAC;AAiBpC,qBAAa,QAAQ;IACnB,OAAO,CAAC,OAAO,CAAkB;IACjC,OAAO,CAAC,QAAQ,CAAC,CAAgB;IACjC,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,SAAS,CAAmB;gBAGlC,OAAO,EAAE,eAAe,EACxB,MAAM,GAAE,cAAmB;IAkB7B;;OAEG;IACG,QAAQ,CACZ,OAAO,EAAE,iBAAiB,EAC1B,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,kBAAkB,CAAC;IAwF9B;;;;;;;;;;;;;;;;;OAiBG;IACG,MAAM,CACV,OAAO,EAAE,iBAAiB,EAC1B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,kBAAkB,GAAG,eAAe,CAAC;IA8FhD;;OAEG;IACH,OAAO,CAAC,eAAe;IAsBvB;;;;;;OAMG;YACW,kBAAkB;IA8kBhC;;OAEG;YACW,qBAAqB;IAwOnC;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAwN9B;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAuD5B;;;;;OAKG;IACH,OAAO,CAAC,6BAA6B;IAkBrC;;;;;;;;;OASG;IACH,OAAO,CAAC,2BAA2B;IA+BnC;;;;;;;OAOG;YACW,sBAAsB;IASpC;;;OAGG;IACH,OAAO,CAAC,qBAAqB;IAoB7B;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,qBAAqB;IAIpC;;;;;;;;OAQG;IACH,OAAO,CAAC,kBAAkB;IAqB1B;;OAEG;IACH,OAAO,CAAC,gBAAgB;YA4EV,UAAU;IA+BxB,OAAO,CAAC,wBAAwB;IA8ChC;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,kCAAkC;IAoD1C,OAAO,CAAC,iBAAiB;IAqIzB,OAAO,CAAC,kBAAkB;IAkF1B,OAAO,CAAC,aAAa;IAoBrB,OAAO,CAAC,sBAAsB;IAO9B,OAAO,CAAC,cAAc;IAItB,qFAAqF;IACrF,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,gBAAgB;IAMxB,OAAO,CAAC,KAAK;IAcb;;OAEG;IACH,OAAO,CAAC,YAAY;IAcpB;;OAEG;IACH,OAAO,CAAC,oBAAoB;IA4B5B;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,cAAc,CACZ,OAAO,EAAE,iBAAiB,EAC1B,OAAO,GAAE,qBAA0B,GAClC,cAAc;IAWjB;;OAEG;YACW,mBAAmB;IAokBjC;;OAEG;YACW,sBAAsB;CAqTrC"}
package/dist/membrane.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * A selective boundary that transforms what passes through.
5
5
  */
6
6
  import { lastCacheableBlockIndex } from './formatters/native.js';
7
- import { DEFAULT_RETRY_CONFIG, MembraneError, classifyError, } from './types/index.js';
7
+ import { DEFAULT_RETRY_CONFIG, MembraneError, classifyError, isOverloadedError, } from './types/index.js';
8
8
  import { parseToolCalls, formatToolResults, parseAccumulatedIntoBlocks, hasImageInToolResults, formatToolResultsForSplitTurn, } from './utils/tool-parser.js';
9
9
  import { AnthropicXmlFormatter } from './formatters/anthropic-xml.js';
10
10
  import { normalizeToolPairs, mergeConsecutiveRoles } from './formatters/normalize-tool-pairs.js';
@@ -24,7 +24,11 @@ export class Membrane {
24
24
  constructor(adapter, config = {}) {
25
25
  this.adapter = adapter;
26
26
  this.registry = config.registry;
27
- this.retryConfig = { ...DEFAULT_RETRY_CONFIG, ...config.retry };
27
+ this.retryConfig = {
28
+ ...DEFAULT_RETRY_CONFIG,
29
+ ...config.retry,
30
+ overloaded: { ...DEFAULT_RETRY_CONFIG.overloaded, ...config.retry?.overloaded },
31
+ };
28
32
  this.config = config;
29
33
  // Use provided formatter or default to AnthropicXmlFormatter
30
34
  this.formatter = config.formatter ?? new AnthropicXmlFormatter();
@@ -69,12 +73,22 @@ export class Membrane {
69
73
  catch (error) {
70
74
  const errorInfo = classifyError(error);
71
75
  errorInfo.rawRequest = rawRequest;
72
- // Rate limits (429) always retry up to 5 attempts regardless of config.
73
- // Other retryable errors only retry when maxRetries > 0.
76
+ // Rate limits (429) always retry up to 5 attempts regardless of
77
+ // config, and overloaded (529) always retries on its own longer
78
+ // schedule — both are transient by definition, and the default
79
+ // maxRetries of 0 would otherwise turn a capacity blip into a dead
80
+ // turn. Other retryable errors only retry when maxRetries > 0.
81
+ // overloaded.maxRetries: 0 disables the dedicated policy entirely;
82
+ // the 529 then follows the base config like any retryable server
83
+ // error (exactly the pre-policy behavior), rather than being
84
+ // silently re-promoted to the long schedule by a positive base limit.
74
85
  const isRateLimit = errorInfo.type === 'rate_limit';
86
+ const isOverloaded = isOverloadedError(errorInfo) && this.retryConfig.overloaded.maxRetries > 0;
75
87
  const effectiveMax = isRateLimit
76
88
  ? Math.max(this.retryConfig.maxRetries, 5)
77
- : this.retryConfig.maxRetries;
89
+ : isOverloaded
90
+ ? Math.max(this.retryConfig.maxRetries, this.retryConfig.overloaded.maxRetries)
91
+ : this.retryConfig.maxRetries;
78
92
  if (errorInfo.retryable && attempts < effectiveMax) {
79
93
  // Check hook for retry decision
80
94
  if (this.config.hooks?.onError) {
@@ -84,7 +98,7 @@ export class Membrane {
84
98
  }
85
99
  }
86
100
  // Wait before retry (abort-aware)
87
- const delay = this.calculateRetryDelay(attempts);
101
+ const delay = this.calculateRetryDelay(attempts, isOverloaded);
88
102
  await this.sleep(delay, options.signal);
89
103
  continue;
90
104
  }
@@ -132,11 +146,69 @@ export class Membrane {
132
146
  }
133
147
  // Determine tool mode
134
148
  const toolMode = this.resolveToolMode(request);
135
- if (toolMode === 'native' && request.tools && request.tools.length > 0) {
136
- return this.streamWithNativeTools(request, options);
137
- }
138
- else {
139
- return this.streamWithXmlTools(request, options);
149
+ const useNative = toolMode === 'native' && !!request.tools && request.tools.length > 0;
150
+ // Overloaded (529) pre-emission retry. The streaming paths have no retry
151
+ // loop of their own, so a capacity error used to kill the turn outright —
152
+ // and 529s most often arrive INSTEAD of a stream, before anything reaches
153
+ // the caller, where retrying is transparent. Once any callback has
154
+ // delivered output (tokens, blocks, usage), retrying would replay content
155
+ // the caller already consumed, so mid-stream errors still throw.
156
+ let attempts = 0;
157
+ const retryDelaysMs = [];
158
+ while (true) {
159
+ attempts++;
160
+ let emitted = false;
161
+ const mark = (fn) => fn && ((...args) => { emitted = true; return fn(...args); });
162
+ const tracked = {
163
+ ...options,
164
+ onChunk: mark(options.onChunk),
165
+ onContentBlockUpdate: mark(options.onContentBlockUpdate),
166
+ onToolCalls: mark(options.onToolCalls),
167
+ onPreToolContent: mark(options.onPreToolContent),
168
+ onUsage: mark(options.onUsage),
169
+ onBlock: mark(options.onBlock),
170
+ onResponse: mark(options.onResponse),
171
+ // onRequest fires before the send — it is not an emission.
172
+ };
173
+ try {
174
+ const result = useNative
175
+ ? await this.streamWithNativeTools(request, tracked)
176
+ : await this.streamWithXmlTools(request, tracked);
177
+ // The inner paths report attempts: 1 — they can't see this wrapper.
178
+ // A call that succeeded after N overloaded retries must not look like
179
+ // a first-attempt success in durable logs, so patch the real count
180
+ // (and the waits) into the response telemetry.
181
+ if (attempts > 1 && 'details' in result) {
182
+ result.details.timing.attempts = attempts;
183
+ result.details.timing.retryDelaysMs = retryDelaysMs;
184
+ }
185
+ return result;
186
+ }
187
+ catch (error) {
188
+ const errorInfo = classifyError(error);
189
+ // Same semantics as complete(): maxRetries bounds total attempts,
190
+ // the overloaded floor applies over the base config, and
191
+ // overloaded.maxRetries: 0 opts out of stream retries entirely
192
+ // (streaming had no retry before this policy existed).
193
+ const overloadedEnabled = this.retryConfig.overloaded.maxRetries > 0;
194
+ const maxOverloaded = Math.max(this.retryConfig.maxRetries, this.retryConfig.overloaded.maxRetries);
195
+ if (!emitted && overloadedEnabled && isOverloadedError(errorInfo) && attempts < maxOverloaded) {
196
+ // Honor the same pre-retry hook contract as complete(): hosts use
197
+ // onError for circuit-breaking, and its 'abort' decision must work
198
+ // on the streaming path too.
199
+ if (this.config.hooks?.onError) {
200
+ const decision = await this.config.hooks.onError(errorInfo, attempts);
201
+ if (decision === 'abort') {
202
+ throw error;
203
+ }
204
+ }
205
+ const delay = this.calculateRetryDelay(attempts, true);
206
+ retryDelaysMs.push(delay);
207
+ await this.sleep(delay, options.signal);
208
+ continue;
209
+ }
210
+ throw error;
211
+ }
140
212
  }
141
213
  }
142
214
  /**
@@ -216,13 +288,56 @@ export class Membrane {
216
288
  // blocks inherited from prefill context (e.g., unclosed <thinking> from other bots)
217
289
  // from blocks the model itself opened during generation
218
290
  const prefillDepths = parser.getDepths();
291
+ // Resumption spin guards (issue #39). Observed live on Ash 2026-07-26:
292
+ // each automatic resumption re-sent ~172k input tokens, streamed ~6
293
+ // output tokens, and stopped on the same (dropped) stop sequence — 43
294
+ // rounds, ~7M input tokens, zero progress, found only because a human
295
+ // noticed. Two guards, both scoped to AUTOMATIC false-positive
296
+ // resumptions — tool rounds are real caller-governed work (maxToolDepth
297
+ // / the yielding API's uncapped contract) and are never counted here:
298
+ // - stall guard: several CONSECUTIVE resumptions that each stream
299
+ // almost nothing and stop identically end the turn ('no_progress').
300
+ // One short repeated round is low progress, not proof of none — a
301
+ // stop sequence inside legitimate tool-argument text can cause a
302
+ // couple of short resumptions on the way to completing.
303
+ // - round cap: a hard bound on resumptions per turn ('round_limit'),
304
+ // the backstop for a spin that keeps technically progressing.
305
+ const MIN_ROUND_PROGRESS_CHARS = 16;
306
+ const MAX_CONSECUTIVE_STALLED_RESUMPTIONS = 3;
307
+ const RESUMPTION_WARN_ROUNDS = 5;
308
+ const maxResumptionRounds = options.maxResumptionRounds ?? 24;
309
+ let resumptionRounds = 0;
310
+ let consecutiveStalledResumptions = 0;
311
+ let enteredViaResumption = false;
312
+ let prevRoundStopSequence;
313
+ const warnLog = this.config.logger ?? console;
314
+ /** Count an automatic resumption; emits the visibility warning at the
315
+ * threshold and returns false when the cap says the turn should end. */
316
+ const registerResumptionRound = () => {
317
+ resumptionRounds++;
318
+ if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
319
+ warnLog.warn(`[membrane] automatic resumption at round ${resumptionRounds} ` +
320
+ `(${totalUsage.inputTokens} input tokens so far this turn) — ` +
321
+ `a spin shows up here before it shows up on the bill`);
322
+ }
323
+ if (resumptionRounds > maxResumptionRounds) {
324
+ warnLog.warn(`[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
325
+ `ending turn with stopReason 'round_limit'. ` +
326
+ `${totalUsage.inputTokens} input tokens spent this turn.`);
327
+ return false;
328
+ }
329
+ return true;
330
+ };
219
331
  try {
220
332
  // Tool execution loop
221
333
  while (toolDepth <= maxToolDepth) {
222
334
  // Track if we manually detected a stop sequence (API doesn't always stop)
223
335
  let detectedStopSequence = null;
224
336
  let truncatedAccumulated = null;
225
- // Track where to start checking for stop sequences (skip already-processed content)
337
+ // Track where to start checking for stop sequences (skip already-processed content).
338
+ // Also the round's progress baseline: XML we pushed ourselves at the
339
+ // end of the previous round (tool results, closing tags) sits below
340
+ // this index and doesn't count as model progress.
226
341
  const checkFromIndex = parser.getAccumulated().length;
227
342
  // Stream from provider
228
343
  const streamResult = await this.streamOnce(providerRequest, {
@@ -332,6 +447,34 @@ export class Membrane {
332
447
  }
333
448
  // Get accumulated text from parser
334
449
  const accumulated = parser.getAccumulated();
450
+ // Stall accounting (issue #39): only rounds ENTERED via automatic
451
+ // resumption can stall — tool rounds are caller-governed work and a
452
+ // real tool call is longer than the threshold anyway. A stall is a
453
+ // resumption that streamed almost nothing and stopped identically to
454
+ // the previous round; the turn ends only after several IN A ROW
455
+ // (one short repeated round is low progress, not proof of none —
456
+ // a stop sequence inside legitimate tool-argument text can cause a
457
+ // couple of short resumptions on the way to completing).
458
+ const streamedThisRound = accumulated.length - checkFromIndex;
459
+ if (enteredViaResumption &&
460
+ lastStopReason === 'stop_sequence' &&
461
+ streamedThisRound < MIN_ROUND_PROGRESS_CHARS &&
462
+ lastStopSequence === prevRoundStopSequence) {
463
+ consecutiveStalledResumptions++;
464
+ if (consecutiveStalledResumptions >= MAX_CONSECUTIVE_STALLED_RESUMPTIONS) {
465
+ warnLog.warn(`[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
466
+ `made no progress (${streamedThisRound} chars this round, stop ` +
467
+ `${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
468
+ `stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`);
469
+ lastStopReason = 'no_progress';
470
+ break;
471
+ }
472
+ }
473
+ else {
474
+ consecutiveStalledResumptions = 0;
475
+ }
476
+ prevRoundStopSequence = lastStopSequence;
477
+ enteredViaResumption = false;
335
478
  // Check for tool calls (if handler provided)
336
479
  if (onToolCalls && streamResult.stopSequence === '</function_calls>') {
337
480
  // Append the closing tag (we truncated before it, or API stopped before it)
@@ -490,7 +633,9 @@ export class Membrane {
490
633
  prefillResult.assistantPrefill = parser.getAccumulated();
491
634
  providerRequest = this.buildContinuationRequest(request, prefillResult, parser.getAccumulated());
492
635
  }
493
- // Reset parser state for new streaming iteration
636
+ // Reset parser state for new streaming iteration. Tool rounds
637
+ // are the caller's work — they count against maxToolDepth only,
638
+ // never against the resumption guards (issue #39 review).
494
639
  parser.resetForNewIteration();
495
640
  toolDepth++;
496
641
  continue;
@@ -523,6 +668,11 @@ export class Membrane {
523
668
  if (toolDepth > maxToolDepth) {
524
669
  break;
525
670
  }
671
+ if (!registerResumptionRound()) {
672
+ lastStopReason = 'round_limit';
673
+ break;
674
+ }
675
+ enteredViaResumption = true;
526
676
  prefillResult.assistantPrefill = parser.getAccumulated();
527
677
  providerRequest = this.buildContinuationRequest(request, prefillResult, parser.getAccumulated());
528
678
  // Reset parser state for new streaming iteration
@@ -1523,10 +1673,15 @@ export class Membrane {
1523
1673
  const pricing = this.resolvePricing(model);
1524
1674
  return pricing ? calculateCost(usage, pricing) : undefined;
1525
1675
  }
1526
- calculateRetryDelay(attempt) {
1527
- const { retryDelayMs, backoffMultiplier, maxRetryDelayMs } = this.retryConfig;
1528
- const delay = retryDelayMs * Math.pow(backoffMultiplier, attempt - 1);
1529
- return Math.min(delay, maxRetryDelayMs);
1676
+ calculateRetryDelay(attempt, overloaded = false) {
1677
+ const { retryDelayMs, backoffMultiplier, maxRetryDelayMs } = overloaded
1678
+ ? this.retryConfig.overloaded
1679
+ : this.retryConfig;
1680
+ const delay = Math.min(retryDelayMs * Math.pow(backoffMultiplier, attempt - 1), maxRetryDelayMs);
1681
+ // Equal jitter on the overloaded schedule only: a capacity storm is
1682
+ // exactly the case where a fleet retrying in sync re-creates the
1683
+ // stampede it's backing off from. [delay/2, delay) keeps the wait long.
1684
+ return overloaded ? Math.floor(delay / 2 + Math.random() * (delay / 2)) : delay;
1530
1685
  }
1531
1686
  attachRawRequest(error, rawRequest) {
1532
1687
  const errorInfo = classifyError(error);
@@ -1634,6 +1789,27 @@ export class Membrane {
1634
1789
  const maxToolDepth = maxToolDepthOpt === undefined || maxToolDepthOpt === -1
1635
1790
  ? Infinity
1636
1791
  : maxToolDepthOpt;
1792
+ // Resumption spin guards (issue #39). This is the path the Ash spin ran
1793
+ // on: tool depth here is unlimited BY DESIGN (the caller budgets its own
1794
+ // tool work — that contract stands untouched), and the false-positive
1795
+ // resumption path counted against that same unlimited bound — 43 rounds
1796
+ // × ~172k input tokens of zero progress. Only AUTOMATIC resumptions are
1797
+ // guarded: a stall guard (consecutive no-progress resumptions →
1798
+ // 'no_progress') and a hard resumption cap ('round_limit'). Tool rounds
1799
+ // are never counted. See streamWithXmlTools for the rationale details.
1800
+ const MIN_ROUND_PROGRESS_CHARS = 16;
1801
+ const MAX_CONSECUTIVE_STALLED_RESUMPTIONS = 3;
1802
+ const RESUMPTION_WARN_ROUNDS = 5;
1803
+ const maxResumptionRounds = options.maxResumptionRounds === undefined
1804
+ ? 24
1805
+ : options.maxResumptionRounds === -1
1806
+ ? Infinity
1807
+ : options.maxResumptionRounds;
1808
+ let resumptionRounds = 0;
1809
+ let consecutiveStalledResumptions = 0;
1810
+ let enteredViaResumption = false;
1811
+ let prevRoundStopSequence;
1812
+ const warnLog = this.config.logger ?? console;
1637
1813
  // Initialize parser from formatter for format-specific tracking
1638
1814
  const formatter = this.formatter;
1639
1815
  const parser = formatter.createStreamParser();
@@ -1673,6 +1849,23 @@ export class Membrane {
1673
1849
  // blocks inherited from prefill context (e.g., unclosed <thinking> from other bots)
1674
1850
  // from blocks the model itself opened during generation
1675
1851
  const prefillDepths = parser.getDepths();
1852
+ /** Count an automatic resumption; emits the visibility warning at the
1853
+ * threshold and returns false when the cap says the turn should end. */
1854
+ const registerResumptionRound = () => {
1855
+ resumptionRounds++;
1856
+ if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
1857
+ warnLog.warn(`[membrane] automatic resumption at round ${resumptionRounds} ` +
1858
+ `(${totalUsage.inputTokens} input tokens so far this turn) — ` +
1859
+ `a spin shows up here before it shows up on the bill`);
1860
+ }
1861
+ if (resumptionRounds > maxResumptionRounds) {
1862
+ warnLog.warn(`[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
1863
+ `ending turn with stopReason 'round_limit'. ` +
1864
+ `${totalUsage.inputTokens} input tokens spent this turn.`);
1865
+ return false;
1866
+ }
1867
+ return true;
1868
+ };
1676
1869
  try {
1677
1870
  // Tool execution loop
1678
1871
  while (toolDepth <= maxToolDepth) {
@@ -1786,6 +1979,30 @@ export class Membrane {
1786
1979
  stream.emit({ type: 'block', event: emission.event });
1787
1980
  }
1788
1981
  }
1982
+ // Stall accounting (issue #39): only rounds ENTERED via automatic
1983
+ // resumption can stall; the turn ends only after several consecutive
1984
+ // stalls. Tool rounds are never counted. Mirrors streamWithXmlTools —
1985
+ // see the detailed rationale there.
1986
+ const streamedThisRound = parser.getAccumulated().length - checkFromIndex;
1987
+ if (enteredViaResumption &&
1988
+ lastStopReason === 'stop_sequence' &&
1989
+ streamedThisRound < MIN_ROUND_PROGRESS_CHARS &&
1990
+ lastStopSequence === prevRoundStopSequence) {
1991
+ consecutiveStalledResumptions++;
1992
+ if (consecutiveStalledResumptions >= MAX_CONSECUTIVE_STALLED_RESUMPTIONS) {
1993
+ warnLog.warn(`[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
1994
+ `made no progress (${streamedThisRound} chars this round, stop ` +
1995
+ `${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
1996
+ `stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`);
1997
+ lastStopReason = 'no_progress';
1998
+ break;
1999
+ }
2000
+ }
2001
+ else {
2002
+ consecutiveStalledResumptions = 0;
2003
+ }
2004
+ prevRoundStopSequence = lastStopSequence;
2005
+ enteredViaResumption = false;
1789
2006
  // Check for tool calls
1790
2007
  if (streamResult.stopSequence === '</function_calls>') {
1791
2008
  const closeTag = '</function_calls>';
@@ -1966,6 +2183,9 @@ export class Membrane {
1966
2183
  prefillResult.assistantPrefill = parser.getAccumulated();
1967
2184
  providerRequest = this.buildContinuationRequest(request, prefillResult, parser.getAccumulated());
1968
2185
  }
2186
+ // Tool rounds are the caller's work — they count against
2187
+ // maxToolDepth only, never against the resumption guards
2188
+ // (issue #39 review: the uncapped tool-loop contract stands).
1969
2189
  parser.resetForNewIteration();
1970
2190
  toolDepth++;
1971
2191
  continue;
@@ -1993,6 +2213,11 @@ export class Membrane {
1993
2213
  if (toolDepth > maxToolDepth) {
1994
2214
  break;
1995
2215
  }
2216
+ if (!registerResumptionRound()) {
2217
+ lastStopReason = 'round_limit';
2218
+ break;
2219
+ }
2220
+ enteredViaResumption = true;
1996
2221
  prefillResult.assistantPrefill = parser.getAccumulated();
1997
2222
  providerRequest = this.buildContinuationRequest(request, prefillResult, parser.getAccumulated());
1998
2223
  parser.resetForNewIteration();