@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.
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +242 -17
- 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 +83 -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/membrane.ts +268 -15
- package/src/providers/bedrock.ts +97 -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/dist/membrane.d.ts.map
CHANGED
|
@@ -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;
|
|
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 = {
|
|
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
|
|
73
|
-
//
|
|
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
|
-
:
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 } =
|
|
1528
|
-
|
|
1529
|
-
|
|
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();
|