@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/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +301 -19
- 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 +86 -15
- 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 +24 -0
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/types/yielding-stream.d.ts +57 -1
- 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/membrane.ts +350 -17
- package/src/providers/bedrock.ts +100 -15
- 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 +26 -0
- package/src/types/yielding-stream.ts +60 -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();
|
|
@@ -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
|
|
150
|
-
//
|
|
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
|
-
:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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 } =
|
|
1898
|
-
|
|
1899
|
-
|
|
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
|
|