@animalabs/membrane 0.5.75 → 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.
- package/dist/formatters/native.d.ts.map +1 -1
- package/dist/formatters/native.js +13 -0
- package/dist/formatters/native.js.map +1 -1
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +282 -25
- package/dist/membrane.js.map +1 -1
- package/dist/providers/bedrock.d.ts +17 -0
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +95 -13
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/types/config.d.ts +31 -1
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +8 -0
- package/dist/types/config.js.map +1 -1
- package/dist/types/errors.d.ts +12 -0
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/errors.js +19 -0
- package/dist/types/errors.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/response.d.ts +1 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/response.js.map +1 -1
- package/dist/types/streaming.d.ts +10 -0
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/types/yielding-stream.d.ts +12 -0
- package/dist/types/yielding-stream.d.ts.map +1 -1
- package/dist/types/yielding-stream.js.map +1 -1
- package/package.json +1 -1
- package/src/formatters/native.ts +12 -0
- package/src/membrane.ts +308 -23
- package/src/providers/bedrock.ts +109 -13
- package/src/types/config.ts +48 -4
- package/src/types/errors.ts +18 -0
- package/src/types/index.ts +1 -0
- package/src/types/response.ts +5 -1
- package/src/types/streaming.ts +11 -0
- package/src/types/yielding-stream.ts +13 -0
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 = {
|
|
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();
|
|
@@ -146,12 +151,23 @@ export class Membrane {
|
|
|
146
151
|
const errorInfo = classifyError(error);
|
|
147
152
|
errorInfo.rawRequest = rawRequest;
|
|
148
153
|
|
|
149
|
-
// Rate limits (429) always retry up to 5 attempts regardless of
|
|
150
|
-
//
|
|
154
|
+
// Rate limits (429) always retry up to 5 attempts regardless of
|
|
155
|
+
// config, and overloaded (529) always retries on its own longer
|
|
156
|
+
// schedule — both are transient by definition, and the default
|
|
157
|
+
// maxRetries of 0 would otherwise turn a capacity blip into a dead
|
|
158
|
+
// turn. Other retryable errors only retry when maxRetries > 0.
|
|
159
|
+
// overloaded.maxRetries: 0 disables the dedicated policy entirely;
|
|
160
|
+
// the 529 then follows the base config like any retryable server
|
|
161
|
+
// error (exactly the pre-policy behavior), rather than being
|
|
162
|
+
// silently re-promoted to the long schedule by a positive base limit.
|
|
151
163
|
const isRateLimit = errorInfo.type === 'rate_limit';
|
|
164
|
+
const isOverloaded =
|
|
165
|
+
isOverloadedError(errorInfo) && this.retryConfig.overloaded.maxRetries > 0;
|
|
152
166
|
const effectiveMax = isRateLimit
|
|
153
167
|
? Math.max(this.retryConfig.maxRetries, 5)
|
|
154
|
-
:
|
|
168
|
+
: isOverloaded
|
|
169
|
+
? Math.max(this.retryConfig.maxRetries, this.retryConfig.overloaded.maxRetries)
|
|
170
|
+
: this.retryConfig.maxRetries;
|
|
155
171
|
|
|
156
172
|
if (errorInfo.retryable && attempts < effectiveMax) {
|
|
157
173
|
// Check hook for retry decision
|
|
@@ -163,7 +179,7 @@ export class Membrane {
|
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
// Wait before retry (abort-aware)
|
|
166
|
-
const delay = this.calculateRetryDelay(attempts);
|
|
182
|
+
const delay = this.calculateRetryDelay(attempts, isOverloaded);
|
|
167
183
|
await this.sleep(delay, options.signal);
|
|
168
184
|
continue;
|
|
169
185
|
}
|
|
@@ -217,11 +233,74 @@ export class Membrane {
|
|
|
217
233
|
|
|
218
234
|
// Determine tool mode
|
|
219
235
|
const toolMode = this.resolveToolMode(request);
|
|
236
|
+
const useNative = toolMode === 'native' && !!request.tools && request.tools.length > 0;
|
|
237
|
+
|
|
238
|
+
// Overloaded (529) pre-emission retry. The streaming paths have no retry
|
|
239
|
+
// loop of their own, so a capacity error used to kill the turn outright —
|
|
240
|
+
// and 529s most often arrive INSTEAD of a stream, before anything reaches
|
|
241
|
+
// the caller, where retrying is transparent. Once any callback has
|
|
242
|
+
// delivered output (tokens, blocks, usage), retrying would replay content
|
|
243
|
+
// the caller already consumed, so mid-stream errors still throw.
|
|
244
|
+
let attempts = 0;
|
|
245
|
+
const retryDelaysMs: number[] = [];
|
|
246
|
+
while (true) {
|
|
247
|
+
attempts++;
|
|
248
|
+
let emitted = false;
|
|
249
|
+
const mark = <A extends unknown[], R>(fn?: (...args: A) => R) =>
|
|
250
|
+
fn && ((...args: A): R => { emitted = true; return fn(...args); });
|
|
251
|
+
const tracked: StreamOptions = {
|
|
252
|
+
...options,
|
|
253
|
+
onChunk: mark(options.onChunk),
|
|
254
|
+
onContentBlockUpdate: mark(options.onContentBlockUpdate),
|
|
255
|
+
onToolCalls: mark(options.onToolCalls),
|
|
256
|
+
onPreToolContent: mark(options.onPreToolContent),
|
|
257
|
+
onUsage: mark(options.onUsage),
|
|
258
|
+
onBlock: mark(options.onBlock),
|
|
259
|
+
onResponse: mark(options.onResponse),
|
|
260
|
+
// onRequest fires before the send — it is not an emission.
|
|
261
|
+
};
|
|
220
262
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
263
|
+
try {
|
|
264
|
+
const result = useNative
|
|
265
|
+
? await this.streamWithNativeTools(request, tracked)
|
|
266
|
+
: await this.streamWithXmlTools(request, tracked);
|
|
267
|
+
// The inner paths report attempts: 1 — they can't see this wrapper.
|
|
268
|
+
// A call that succeeded after N overloaded retries must not look like
|
|
269
|
+
// a first-attempt success in durable logs, so patch the real count
|
|
270
|
+
// (and the waits) into the response telemetry.
|
|
271
|
+
if (attempts > 1 && 'details' in result) {
|
|
272
|
+
result.details.timing.attempts = attempts;
|
|
273
|
+
result.details.timing.retryDelaysMs = retryDelaysMs;
|
|
274
|
+
}
|
|
275
|
+
return result;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
const errorInfo = classifyError(error);
|
|
278
|
+
// Same semantics as complete(): maxRetries bounds total attempts,
|
|
279
|
+
// the overloaded floor applies over the base config, and
|
|
280
|
+
// overloaded.maxRetries: 0 opts out of stream retries entirely
|
|
281
|
+
// (streaming had no retry before this policy existed).
|
|
282
|
+
const overloadedEnabled = this.retryConfig.overloaded.maxRetries > 0;
|
|
283
|
+
const maxOverloaded = Math.max(
|
|
284
|
+
this.retryConfig.maxRetries,
|
|
285
|
+
this.retryConfig.overloaded.maxRetries
|
|
286
|
+
);
|
|
287
|
+
if (!emitted && overloadedEnabled && isOverloadedError(errorInfo) && attempts < maxOverloaded) {
|
|
288
|
+
// Honor the same pre-retry hook contract as complete(): hosts use
|
|
289
|
+
// onError for circuit-breaking, and its 'abort' decision must work
|
|
290
|
+
// on the streaming path too.
|
|
291
|
+
if (this.config.hooks?.onError) {
|
|
292
|
+
const decision = await this.config.hooks.onError(errorInfo, attempts);
|
|
293
|
+
if (decision === 'abort') {
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const delay = this.calculateRetryDelay(attempts, true);
|
|
298
|
+
retryDelaysMs.push(delay);
|
|
299
|
+
await this.sleep(delay, options.signal);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
225
304
|
}
|
|
226
305
|
}
|
|
227
306
|
|
|
@@ -330,6 +409,52 @@ export class Membrane {
|
|
|
330
409
|
// from blocks the model itself opened during generation
|
|
331
410
|
const prefillDepths = parser.getDepths();
|
|
332
411
|
|
|
412
|
+
// Resumption spin guards (issue #39). Observed live on Ash 2026-07-26:
|
|
413
|
+
// each automatic resumption re-sent ~172k input tokens, streamed ~6
|
|
414
|
+
// output tokens, and stopped on the same (dropped) stop sequence — 43
|
|
415
|
+
// rounds, ~7M input tokens, zero progress, found only because a human
|
|
416
|
+
// noticed. Two guards, both scoped to AUTOMATIC false-positive
|
|
417
|
+
// resumptions — tool rounds are real caller-governed work (maxToolDepth
|
|
418
|
+
// / the yielding API's uncapped contract) and are never counted here:
|
|
419
|
+
// - stall guard: several CONSECUTIVE resumptions that each stream
|
|
420
|
+
// almost nothing and stop identically end the turn ('no_progress').
|
|
421
|
+
// One short repeated round is low progress, not proof of none — a
|
|
422
|
+
// stop sequence inside legitimate tool-argument text can cause a
|
|
423
|
+
// couple of short resumptions on the way to completing.
|
|
424
|
+
// - round cap: a hard bound on resumptions per turn ('round_limit'),
|
|
425
|
+
// the backstop for a spin that keeps technically progressing.
|
|
426
|
+
const MIN_ROUND_PROGRESS_CHARS = 16;
|
|
427
|
+
const MAX_CONSECUTIVE_STALLED_RESUMPTIONS = 3;
|
|
428
|
+
const RESUMPTION_WARN_ROUNDS = 5;
|
|
429
|
+
const maxResumptionRounds = options.maxResumptionRounds ?? 24;
|
|
430
|
+
let resumptionRounds = 0;
|
|
431
|
+
let consecutiveStalledResumptions = 0;
|
|
432
|
+
let enteredViaResumption = false;
|
|
433
|
+
let prevRoundStopSequence: string | undefined;
|
|
434
|
+
const warnLog = this.config.logger ?? console;
|
|
435
|
+
|
|
436
|
+
/** Count an automatic resumption; emits the visibility warning at the
|
|
437
|
+
* threshold and returns false when the cap says the turn should end. */
|
|
438
|
+
const registerResumptionRound = (): boolean => {
|
|
439
|
+
resumptionRounds++;
|
|
440
|
+
if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
|
|
441
|
+
warnLog.warn(
|
|
442
|
+
`[membrane] automatic resumption at round ${resumptionRounds} ` +
|
|
443
|
+
`(${totalUsage.inputTokens} input tokens so far this turn) — ` +
|
|
444
|
+
`a spin shows up here before it shows up on the bill`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
if (resumptionRounds > maxResumptionRounds) {
|
|
448
|
+
warnLog.warn(
|
|
449
|
+
`[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
|
|
450
|
+
`ending turn with stopReason 'round_limit'. ` +
|
|
451
|
+
`${totalUsage.inputTokens} input tokens spent this turn.`
|
|
452
|
+
);
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
return true;
|
|
456
|
+
};
|
|
457
|
+
|
|
333
458
|
try {
|
|
334
459
|
// Tool execution loop
|
|
335
460
|
while (toolDepth <= maxToolDepth) {
|
|
@@ -338,7 +463,10 @@ export class Membrane {
|
|
|
338
463
|
let detectedStopSequence: string | null = null;
|
|
339
464
|
let truncatedAccumulated: string | null = null;
|
|
340
465
|
|
|
341
|
-
// Track where to start checking for stop sequences (skip already-processed content)
|
|
466
|
+
// Track where to start checking for stop sequences (skip already-processed content).
|
|
467
|
+
// Also the round's progress baseline: XML we pushed ourselves at the
|
|
468
|
+
// end of the previous round (tool results, closing tags) sits below
|
|
469
|
+
// this index and doesn't count as model progress.
|
|
342
470
|
const checkFromIndex = parser.getAccumulated().length;
|
|
343
471
|
|
|
344
472
|
// Stream from provider
|
|
@@ -465,6 +593,38 @@ export class Membrane {
|
|
|
465
593
|
// Get accumulated text from parser
|
|
466
594
|
const accumulated = parser.getAccumulated();
|
|
467
595
|
|
|
596
|
+
// Stall accounting (issue #39): only rounds ENTERED via automatic
|
|
597
|
+
// resumption can stall — tool rounds are caller-governed work and a
|
|
598
|
+
// real tool call is longer than the threshold anyway. A stall is a
|
|
599
|
+
// resumption that streamed almost nothing and stopped identically to
|
|
600
|
+
// the previous round; the turn ends only after several IN A ROW
|
|
601
|
+
// (one short repeated round is low progress, not proof of none —
|
|
602
|
+
// a stop sequence inside legitimate tool-argument text can cause a
|
|
603
|
+
// couple of short resumptions on the way to completing).
|
|
604
|
+
const streamedThisRound = accumulated.length - checkFromIndex;
|
|
605
|
+
if (
|
|
606
|
+
enteredViaResumption &&
|
|
607
|
+
lastStopReason === 'stop_sequence' &&
|
|
608
|
+
streamedThisRound < MIN_ROUND_PROGRESS_CHARS &&
|
|
609
|
+
lastStopSequence === prevRoundStopSequence
|
|
610
|
+
) {
|
|
611
|
+
consecutiveStalledResumptions++;
|
|
612
|
+
if (consecutiveStalledResumptions >= MAX_CONSECUTIVE_STALLED_RESUMPTIONS) {
|
|
613
|
+
warnLog.warn(
|
|
614
|
+
`[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
|
|
615
|
+
`made no progress (${streamedThisRound} chars this round, stop ` +
|
|
616
|
+
`${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
|
|
617
|
+
`stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`
|
|
618
|
+
);
|
|
619
|
+
lastStopReason = 'no_progress';
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
} else {
|
|
623
|
+
consecutiveStalledResumptions = 0;
|
|
624
|
+
}
|
|
625
|
+
prevRoundStopSequence = lastStopSequence;
|
|
626
|
+
enteredViaResumption = false;
|
|
627
|
+
|
|
468
628
|
// Check for tool calls (if handler provided)
|
|
469
629
|
if (onToolCalls && streamResult.stopSequence === '</function_calls>') {
|
|
470
630
|
// Append the closing tag (we truncated before it, or API stopped before it)
|
|
@@ -476,8 +636,11 @@ export class Membrane {
|
|
|
476
636
|
|
|
477
637
|
if (parsed && parsed.calls.length > 0) {
|
|
478
638
|
// Notify about pre-tool content
|
|
479
|
-
|
|
480
|
-
|
|
639
|
+
// Slice the seeded prefill off: beforeText starts with the whole
|
|
640
|
+
// flattened document in XML mode (see ToolContext note below).
|
|
641
|
+
const preToolNew = parsed.beforeText.slice(initialPrefillLength);
|
|
642
|
+
if (onPreToolContent && preToolNew.trim()) {
|
|
643
|
+
await onPreToolContent(preToolNew);
|
|
481
644
|
}
|
|
482
645
|
|
|
483
646
|
// Emit block events for each tool call
|
|
@@ -504,13 +667,24 @@ export class Membrane {
|
|
|
504
667
|
// Track the tool calls
|
|
505
668
|
executedToolCalls.push(...parsed.calls);
|
|
506
669
|
|
|
507
|
-
// Execute tools
|
|
670
|
+
// Execute tools.
|
|
671
|
+
// preamble/accumulated must expose the MODEL'S text only. The
|
|
672
|
+
// parser is seeded with the entire assistant prefill (the whole
|
|
673
|
+
// flattened document in XML mode), so parsed.beforeText starts
|
|
674
|
+
// with it — consumers that persist the preamble as "what the
|
|
675
|
+
// agent said this round" would otherwise write the full document
|
|
676
|
+
// back into the store as an assistant message (observed on Ash,
|
|
677
|
+
// 2026-07-26: a died-mid-rounds turn flushed a ~720k-char
|
|
678
|
+
// document echo into her message store as 62 sharded messages).
|
|
679
|
+
// The turn-END path already slices (newContent =
|
|
680
|
+
// fullAccumulated.slice(initialPrefillLength)); the tool-round
|
|
681
|
+
// path must match it.
|
|
508
682
|
const context: ToolContext = {
|
|
509
683
|
rawText: parsed.fullMatch,
|
|
510
|
-
preamble: parsed.beforeText,
|
|
684
|
+
preamble: parsed.beforeText.slice(initialPrefillLength),
|
|
511
685
|
depth: toolDepth,
|
|
512
686
|
previousResults: executedToolResults,
|
|
513
|
-
accumulated: parser.getAccumulated(),
|
|
687
|
+
accumulated: parser.getAccumulated().slice(initialPrefillLength),
|
|
514
688
|
};
|
|
515
689
|
|
|
516
690
|
const results = await onToolCalls(parsed.calls, context);
|
|
@@ -641,7 +815,9 @@ export class Membrane {
|
|
|
641
815
|
);
|
|
642
816
|
}
|
|
643
817
|
|
|
644
|
-
// Reset parser state for new streaming iteration
|
|
818
|
+
// Reset parser state for new streaming iteration. Tool rounds
|
|
819
|
+
// are the caller's work — they count against maxToolDepth only,
|
|
820
|
+
// never against the resumption guards (issue #39 review).
|
|
645
821
|
parser.resetForNewIteration();
|
|
646
822
|
toolDepth++;
|
|
647
823
|
continue;
|
|
@@ -678,6 +854,11 @@ export class Membrane {
|
|
|
678
854
|
if (toolDepth > maxToolDepth) {
|
|
679
855
|
break;
|
|
680
856
|
}
|
|
857
|
+
if (!registerResumptionRound()) {
|
|
858
|
+
lastStopReason = 'round_limit';
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
enteredViaResumption = true;
|
|
681
862
|
prefillResult.assistantPrefill = parser.getAccumulated();
|
|
682
863
|
providerRequest = this.buildContinuationRequest(
|
|
683
864
|
request,
|
|
@@ -1033,6 +1214,13 @@ export class Membrane {
|
|
|
1033
1214
|
const textBlock: Record<string, unknown> = { type: 'text', text };
|
|
1034
1215
|
if ((block as any).cache_control) {
|
|
1035
1216
|
textBlock.cache_control = (block as any).cache_control;
|
|
1217
|
+
// A block-level passthrough occupies one of the 4 breakpoint slots
|
|
1218
|
+
// exactly like a marked message — count it, so the tools/system
|
|
1219
|
+
// fallback below doesn't stack more on top. (Imported/seeded
|
|
1220
|
+
// conversations can carry stale request-time cache_control on
|
|
1221
|
+
// stored blocks — first seen wedging Sill 2026-07-25: 3 cm markers
|
|
1222
|
+
// + 2 stale Arc-export blocks = 5 → hard 400 on every inference.)
|
|
1223
|
+
messageBreakpoints++;
|
|
1036
1224
|
}
|
|
1037
1225
|
content.push(textBlock);
|
|
1038
1226
|
} else if (block.type === 'tool_use') {
|
|
@@ -1872,10 +2060,15 @@ export class Membrane {
|
|
|
1872
2060
|
return pricing ? calculateCost(usage, pricing) : undefined;
|
|
1873
2061
|
}
|
|
1874
2062
|
|
|
1875
|
-
private calculateRetryDelay(attempt: number): number {
|
|
1876
|
-
const { retryDelayMs, backoffMultiplier, maxRetryDelayMs } =
|
|
1877
|
-
|
|
1878
|
-
|
|
2063
|
+
private calculateRetryDelay(attempt: number, overloaded = false): number {
|
|
2064
|
+
const { retryDelayMs, backoffMultiplier, maxRetryDelayMs } = overloaded
|
|
2065
|
+
? this.retryConfig.overloaded
|
|
2066
|
+
: this.retryConfig;
|
|
2067
|
+
const delay = Math.min(retryDelayMs * Math.pow(backoffMultiplier, attempt - 1), maxRetryDelayMs);
|
|
2068
|
+
// Equal jitter on the overloaded schedule only: a capacity storm is
|
|
2069
|
+
// exactly the case where a fleet retrying in sync re-creates the
|
|
2070
|
+
// stampede it's backing off from. [delay/2, delay) keeps the wait long.
|
|
2071
|
+
return overloaded ? Math.floor(delay / 2 + Math.random() * (delay / 2)) : delay;
|
|
1879
2072
|
}
|
|
1880
2073
|
|
|
1881
2074
|
private attachRawRequest(error: unknown, rawRequest: unknown): Error {
|
|
@@ -2012,6 +2205,29 @@ export class Membrane {
|
|
|
2012
2205
|
? Infinity
|
|
2013
2206
|
: maxToolDepthOpt;
|
|
2014
2207
|
|
|
2208
|
+
// Resumption spin guards (issue #39). This is the path the Ash spin ran
|
|
2209
|
+
// on: tool depth here is unlimited BY DESIGN (the caller budgets its own
|
|
2210
|
+
// tool work — that contract stands untouched), and the false-positive
|
|
2211
|
+
// resumption path counted against that same unlimited bound — 43 rounds
|
|
2212
|
+
// × ~172k input tokens of zero progress. Only AUTOMATIC resumptions are
|
|
2213
|
+
// guarded: a stall guard (consecutive no-progress resumptions →
|
|
2214
|
+
// 'no_progress') and a hard resumption cap ('round_limit'). Tool rounds
|
|
2215
|
+
// are never counted. See streamWithXmlTools for the rationale details.
|
|
2216
|
+
const MIN_ROUND_PROGRESS_CHARS = 16;
|
|
2217
|
+
const MAX_CONSECUTIVE_STALLED_RESUMPTIONS = 3;
|
|
2218
|
+
const RESUMPTION_WARN_ROUNDS = 5;
|
|
2219
|
+
const maxResumptionRounds =
|
|
2220
|
+
options.maxResumptionRounds === undefined
|
|
2221
|
+
? 24
|
|
2222
|
+
: options.maxResumptionRounds === -1
|
|
2223
|
+
? Infinity
|
|
2224
|
+
: options.maxResumptionRounds;
|
|
2225
|
+
let resumptionRounds = 0;
|
|
2226
|
+
let consecutiveStalledResumptions = 0;
|
|
2227
|
+
let enteredViaResumption = false;
|
|
2228
|
+
let prevRoundStopSequence: string | undefined;
|
|
2229
|
+
const warnLog = this.config.logger ?? console;
|
|
2230
|
+
|
|
2015
2231
|
// Initialize parser from formatter for format-specific tracking
|
|
2016
2232
|
const formatter = this.formatter;
|
|
2017
2233
|
const parser = formatter.createStreamParser();
|
|
@@ -2057,6 +2273,28 @@ export class Membrane {
|
|
|
2057
2273
|
// from blocks the model itself opened during generation
|
|
2058
2274
|
const prefillDepths = parser.getDepths();
|
|
2059
2275
|
|
|
2276
|
+
/** Count an automatic resumption; emits the visibility warning at the
|
|
2277
|
+
* threshold and returns false when the cap says the turn should end. */
|
|
2278
|
+
const registerResumptionRound = (): boolean => {
|
|
2279
|
+
resumptionRounds++;
|
|
2280
|
+
if (resumptionRounds === RESUMPTION_WARN_ROUNDS) {
|
|
2281
|
+
warnLog.warn(
|
|
2282
|
+
`[membrane] automatic resumption at round ${resumptionRounds} ` +
|
|
2283
|
+
`(${totalUsage.inputTokens} input tokens so far this turn) — ` +
|
|
2284
|
+
`a spin shows up here before it shows up on the bill`
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
if (resumptionRounds > maxResumptionRounds) {
|
|
2288
|
+
warnLog.warn(
|
|
2289
|
+
`[membrane] automatic resumption cap (${maxResumptionRounds}) reached — ` +
|
|
2290
|
+
`ending turn with stopReason 'round_limit'. ` +
|
|
2291
|
+
`${totalUsage.inputTokens} input tokens spent this turn.`
|
|
2292
|
+
);
|
|
2293
|
+
return false;
|
|
2294
|
+
}
|
|
2295
|
+
return true;
|
|
2296
|
+
};
|
|
2297
|
+
|
|
2060
2298
|
try {
|
|
2061
2299
|
// Tool execution loop
|
|
2062
2300
|
while (toolDepth <= maxToolDepth) {
|
|
@@ -2185,6 +2423,34 @@ export class Membrane {
|
|
|
2185
2423
|
}
|
|
2186
2424
|
}
|
|
2187
2425
|
|
|
2426
|
+
// Stall accounting (issue #39): only rounds ENTERED via automatic
|
|
2427
|
+
// resumption can stall; the turn ends only after several consecutive
|
|
2428
|
+
// stalls. Tool rounds are never counted. Mirrors streamWithXmlTools —
|
|
2429
|
+
// see the detailed rationale there.
|
|
2430
|
+
const streamedThisRound = parser.getAccumulated().length - checkFromIndex;
|
|
2431
|
+
if (
|
|
2432
|
+
enteredViaResumption &&
|
|
2433
|
+
lastStopReason === 'stop_sequence' &&
|
|
2434
|
+
streamedThisRound < MIN_ROUND_PROGRESS_CHARS &&
|
|
2435
|
+
lastStopSequence === prevRoundStopSequence
|
|
2436
|
+
) {
|
|
2437
|
+
consecutiveStalledResumptions++;
|
|
2438
|
+
if (consecutiveStalledResumptions >= MAX_CONSECUTIVE_STALLED_RESUMPTIONS) {
|
|
2439
|
+
warnLog.warn(
|
|
2440
|
+
`[membrane] ${consecutiveStalledResumptions} consecutive automatic resumptions ` +
|
|
2441
|
+
`made no progress (${streamedThisRound} chars this round, stop ` +
|
|
2442
|
+
`${JSON.stringify(lastStopSequence ?? null)} repeated) — ending turn with ` +
|
|
2443
|
+
`stopReason 'no_progress'. ${totalUsage.inputTokens} input tokens spent this turn.`
|
|
2444
|
+
);
|
|
2445
|
+
lastStopReason = 'no_progress';
|
|
2446
|
+
break;
|
|
2447
|
+
}
|
|
2448
|
+
} else {
|
|
2449
|
+
consecutiveStalledResumptions = 0;
|
|
2450
|
+
}
|
|
2451
|
+
prevRoundStopSequence = lastStopSequence;
|
|
2452
|
+
enteredViaResumption = false;
|
|
2453
|
+
|
|
2188
2454
|
// Check for tool calls
|
|
2189
2455
|
if (streamResult.stopSequence === '</function_calls>') {
|
|
2190
2456
|
const closeTag = '</function_calls>';
|
|
@@ -2225,13 +2491,24 @@ export class Membrane {
|
|
|
2225
2491
|
// Track the tool calls
|
|
2226
2492
|
executedToolCalls.push(...parsed.calls);
|
|
2227
2493
|
|
|
2228
|
-
// Build tool context
|
|
2494
|
+
// Build tool context.
|
|
2495
|
+
// preamble/accumulated must expose the MODEL'S text only: the
|
|
2496
|
+
// parser was seeded with the entire assistant prefill (the whole
|
|
2497
|
+
// flattened document in XML mode), so parsed.beforeText starts
|
|
2498
|
+
// with it. Consumers persist the preamble as "what the agent said
|
|
2499
|
+
// this round" — unsliced, a turn that dies mid-rounds flushes the
|
|
2500
|
+
// full document back into the agent's store as its own message
|
|
2501
|
+
// (observed on Ash 2026-07-26: ~720k-char document echo persisted
|
|
2502
|
+
// as 62 sharded assistant messages, doubling her store and
|
|
2503
|
+
// wedging every subsequent compile). The turn-END path already
|
|
2504
|
+
// slices (newContent = fullAccumulated.slice(initialPrefillLength));
|
|
2505
|
+
// the tool-round path must match it.
|
|
2229
2506
|
const context: ToolContext = {
|
|
2230
2507
|
rawText: parsed.fullMatch,
|
|
2231
|
-
preamble: parsed.beforeText,
|
|
2508
|
+
preamble: parsed.beforeText.slice(initialPrefillLength),
|
|
2232
2509
|
depth: toolDepth,
|
|
2233
2510
|
previousResults: executedToolResults,
|
|
2234
|
-
accumulated: parser.getAccumulated(),
|
|
2511
|
+
accumulated: parser.getAccumulated().slice(initialPrefillLength),
|
|
2235
2512
|
};
|
|
2236
2513
|
|
|
2237
2514
|
// Yield control for tool execution
|
|
@@ -2390,6 +2667,9 @@ export class Membrane {
|
|
|
2390
2667
|
);
|
|
2391
2668
|
}
|
|
2392
2669
|
|
|
2670
|
+
// Tool rounds are the caller's work — they count against
|
|
2671
|
+
// maxToolDepth only, never against the resumption guards
|
|
2672
|
+
// (issue #39 review: the uncapped tool-loop contract stands).
|
|
2393
2673
|
parser.resetForNewIteration();
|
|
2394
2674
|
toolDepth++;
|
|
2395
2675
|
continue;
|
|
@@ -2421,6 +2701,11 @@ export class Membrane {
|
|
|
2421
2701
|
if (toolDepth > maxToolDepth) {
|
|
2422
2702
|
break;
|
|
2423
2703
|
}
|
|
2704
|
+
if (!registerResumptionRound()) {
|
|
2705
|
+
lastStopReason = 'round_limit';
|
|
2706
|
+
break;
|
|
2707
|
+
}
|
|
2708
|
+
enteredViaResumption = true;
|
|
2424
2709
|
prefillResult.assistantPrefill = parser.getAccumulated();
|
|
2425
2710
|
providerRequest = this.buildContinuationRequest(
|
|
2426
2711
|
request,
|