@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.
@@ -49,6 +49,16 @@ export interface BedrockAdapterConfig {
49
49
 
50
50
  /** Anthropic API version header (defaults to 2023-06-01) */
51
51
  anthropicVersion?: string;
52
+
53
+ /**
54
+ * Endpoint override (no trailing slash), e.g. an inference gateway leg
55
+ * like `https://gate.animalabs.ai/bedrock/apse1`. Defaults to
56
+ * `https://bedrock-runtime.{region}.amazonaws.com`. When pointing at a
57
+ * gateway, set accessKeyId to the gate token — the gateway reads it from
58
+ * the SigV4 Credential field, discards the client signature, and re-signs
59
+ * with real AWS creds that never leave the gateway box.
60
+ */
61
+ baseURL?: string;
52
62
  }
53
63
 
54
64
  // ============================================================================
@@ -120,9 +130,25 @@ interface BedrockStreamEvent {
120
130
  usage?: {
121
131
  input_tokens: number;
122
132
  output_tokens: number;
133
+ cache_creation_input_tokens?: number | null;
134
+ cache_read_input_tokens?: number | null;
123
135
  };
124
136
  }
125
137
 
138
+ /**
139
+ * Bedrock accepts `cache_control: { type: 'ephemeral' }` but rejects the
140
+ * direct-API `ttl` extension ("cache_control.ttl: Extra inputs are not
141
+ * permitted"). Drop the ttl, keep the marker — the cache still works, at
142
+ * Bedrock's fixed default TTL.
143
+ */
144
+ function stripCacheTtl<T extends Record<string, any>>(block: T): T {
145
+ if (block?.cache_control && typeof block.cache_control === 'object' && 'ttl' in block.cache_control) {
146
+ const { ttl, ...cacheControl } = block.cache_control;
147
+ return { ...block, cache_control: cacheControl };
148
+ }
149
+ return block;
150
+ }
151
+
126
152
  // ============================================================================
127
153
  // AWS Signature V4 Implementation
128
154
  // ============================================================================
@@ -252,6 +278,7 @@ export class BedrockAdapter implements ProviderAdapter {
252
278
  private region: string;
253
279
  private defaultMaxTokens: number;
254
280
  private anthropicVersion: string;
281
+ private baseURL?: string;
255
282
 
256
283
  constructor(config: BedrockAdapterConfig = {}) {
257
284
  this.accessKeyId = config.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID ?? '';
@@ -260,6 +287,7 @@ export class BedrockAdapter implements ProviderAdapter {
260
287
  this.region = config.region ?? process.env.AWS_REGION ?? 'us-west-2';
261
288
  this.defaultMaxTokens = config.defaultMaxTokens ?? 4096;
262
289
  this.anthropicVersion = config.anthropicVersion ?? 'bedrock-2023-05-31';
290
+ this.baseURL = config.baseURL?.replace(/\/$/, '');
263
291
 
264
292
  if (!this.accessKeyId || !this.secretAccessKey) {
265
293
  throw new Error('AWS credentials required: accessKeyId and secretAccessKey');
@@ -271,6 +299,18 @@ export class BedrockAdapter implements ProviderAdapter {
271
299
  return modelId.includes('claude') || modelId.startsWith('anthropic.');
272
300
  }
273
301
 
302
+ /**
303
+ * Cross-region inference-profile prefix for this adapter's region.
304
+ * Claude 4-era models on Bedrock reject on-demand invocation of the
305
+ * direct id ("Invocation ... with on-demand throughput isn't supported")
306
+ * and require the profile form — verified live 2026-07-31.
307
+ */
308
+ private inferenceProfilePrefix(): string {
309
+ if (this.region.startsWith('eu-')) return 'eu.';
310
+ if (this.region.startsWith('ap-')) return 'apac.';
311
+ return 'us.';
312
+ }
313
+
274
314
  /**
275
315
  * Convert a standard Claude model ID to Bedrock format if needed
276
316
  */
@@ -282,12 +322,19 @@ export class BedrockAdapter implements ProviderAdapter {
282
322
 
283
323
  // If already in Bedrock format, use as-is
284
324
  // Supports both direct model IDs (anthropic.claude-...) and
285
- // cross-region inference profile IDs (us.anthropic.claude-..., eu.anthropic.claude-..., apac.anthropic.claude-...)
286
- if (modelId.startsWith('anthropic.') || /^[a-z]{2,4}\.anthropic\./.test(modelId)) {
325
+ // cross-region inference profile IDs (us.anthropic.claude-..., eu.anthropic.claude-...,
326
+ // apac.anthropic.claude-..., global.anthropic.claude-... — `global` is 6 chars, hence {2,6}).
327
+ if (modelId.startsWith('anthropic.') || /^[a-z]{2,6}\.anthropic\./.test(modelId)) {
287
328
  return modelId;
288
329
  }
289
330
 
290
- // Map common Claude model IDs to Bedrock format
331
+ const profile = this.inferenceProfilePrefix();
332
+
333
+ // Map common Claude model IDs to Bedrock format. The 3.x entries keep
334
+ // their historical direct-id form (those models predate inference
335
+ // profiles; all are EOL on Bedrock as of 2026-07 anyway, so the exact
336
+ // shape is moot). 4-era entries use the profile form — the direct id
337
+ // no longer invokes.
291
338
  const modelMap: Record<string, string> = {
292
339
  'claude-3-5-sonnet-20241022': 'anthropic.claude-3-5-sonnet-20241022-v2:0',
293
340
  'claude-3-5-sonnet-latest': 'anthropic.claude-3-5-sonnet-20241022-v2:0',
@@ -296,13 +343,15 @@ export class BedrockAdapter implements ProviderAdapter {
296
343
  'claude-3-opus-20240229': 'anthropic.claude-3-opus-20240229-v1:0',
297
344
  'claude-3-sonnet-20240229': 'anthropic.claude-3-sonnet-20240229-v1:0',
298
345
  'claude-3-haiku-20240307': 'anthropic.claude-3-haiku-20240307-v1:0',
299
- 'claude-sonnet-4-20250514': 'anthropic.claude-sonnet-4-20250514-v1:0',
300
- 'claude-opus-4-20250514': 'anthropic.claude-opus-4-20250514-v1:0',
301
- // Haiku 4.5 aliases
302
- 'claude-haiku-4-5-20251001': 'anthropic.claude-3-5-haiku-20241022-v1:0',
346
+ 'claude-sonnet-4-20250514': `${profile}anthropic.claude-sonnet-4-20250514-v1:0`,
347
+ 'claude-opus-4-20250514': `${profile}anthropic.claude-opus-4-20250514-v1:0`,
348
+ // Haiku 4.5 previously aliased to 3.5 Haiku (a stand-in from before
349
+ // Haiku 4.5 reached Bedrock). 3.5 Haiku is EOL on Bedrock now, so the
350
+ // alias routed every plain-id caller to a guaranteed error.
351
+ 'claude-haiku-4-5-20251001': `${profile}anthropic.claude-haiku-4-5-20251001-v1:0`,
303
352
  };
304
353
 
305
- return modelMap[modelId] ?? `anthropic.${modelId}-v1:0`;
354
+ return modelMap[modelId] ?? `${profile}anthropic.${modelId}-v1:0`;
306
355
  }
307
356
 
308
357
  async complete(
@@ -347,7 +396,14 @@ export class BedrockAdapter implements ProviderAdapter {
347
396
 
348
397
  private buildRequest(request: ProviderRequest, bedrockModelId?: string): BedrockMessageRequest {
349
398
  // Strip provider-specific fields (e.g., sourceUrl for Gemini) from image blocks
350
- // before sending to Bedrock/Anthropic, which rejects extra inputs
399
+ // before sending to Bedrock/Anthropic, which rejects extra inputs.
400
+ //
401
+ // Same treatment for cache_control.ttl: Bedrock's prompt cache runs at the
402
+ // fixed default (5m) TTL — the ttl field is a direct-API extension and
403
+ // Bedrock rejects it as an extra input. The marker itself is fine and
404
+ // caching works without the field, so strip just the ttl and keep the
405
+ // breakpoint. Transport quirks belong to the transport, not to every
406
+ // caller that sets cacheTtl. (Connectome issue #35.)
351
407
  const sanitizedMessages = (request.messages as any[]).map((msg: any) => {
352
408
  if (!Array.isArray(msg.content)) return msg;
353
409
  return {
@@ -355,9 +411,9 @@ export class BedrockAdapter implements ProviderAdapter {
355
411
  content: msg.content.map((block: any) => {
356
412
  if (block.type === 'image' && block.sourceUrl !== undefined) {
357
413
  const { sourceUrl, ...rest } = block;
358
- return rest;
414
+ return stripCacheTtl(rest);
359
415
  }
360
- return block;
416
+ return stripCacheTtl(block);
361
417
  }),
362
418
  };
363
419
  });
@@ -377,6 +433,10 @@ export class BedrockAdapter implements ProviderAdapter {
377
433
  if (needsFlatten && Array.isArray(request.system)) {
378
434
  const blocks = request.system as Array<{ type: string; text: string }>;
379
435
  params.system = blocks.map(b => b.text).join('\n\n');
436
+ } else if (Array.isArray(request.system)) {
437
+ params.system = (request.system as Array<Record<string, any>>).map(
438
+ b => stripCacheTtl(b)
439
+ ) as BedrockMessageRequest['system'];
380
440
  } else {
381
441
  params.system = request.system as BedrockMessageRequest['system'];
382
442
  }
@@ -400,7 +460,7 @@ export class BedrockAdapter implements ProviderAdapter {
400
460
  }
401
461
 
402
462
  if (request.tools && request.tools.length > 0) {
403
- params.tools = request.tools;
463
+ params.tools = (request.tools as Array<Record<string, any>>).map(t => stripCacheTtl(t));
404
464
  }
405
465
 
406
466
  // Handle extended thinking
@@ -434,7 +494,7 @@ export class BedrockAdapter implements ProviderAdapter {
434
494
  signal?: AbortSignal
435
495
  ): Promise<BedrockMessageResponse> {
436
496
  const url = new URL(
437
- `https://bedrock-runtime.${this.region}.amazonaws.com/model/${encodeURIComponent(modelId)}/invoke`
497
+ `${this.baseURL ?? `https://bedrock-runtime.${this.region}.amazonaws.com`}/model/${encodeURIComponent(modelId)}/invoke`
438
498
  );
439
499
 
440
500
  const body = JSON.stringify(request);
@@ -477,7 +537,7 @@ export class BedrockAdapter implements ProviderAdapter {
477
537
  signal?: AbortSignal
478
538
  ): Promise<ProviderResponse> {
479
539
  const url = new URL(
480
- `https://bedrock-runtime.${this.region}.amazonaws.com/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`
540
+ `${this.baseURL ?? `https://bedrock-runtime.${this.region}.amazonaws.com`}/model/${encodeURIComponent(modelId)}/invoke-with-response-stream`
481
541
  );
482
542
 
483
543
  const body = JSON.stringify(request);
@@ -516,6 +576,8 @@ export class BedrockAdapter implements ProviderAdapter {
516
576
  let finalMessage: BedrockMessageResponse | undefined;
517
577
  let inputTokens = 0;
518
578
  let outputTokens = 0;
579
+ let cacheCreationTokens: number | undefined;
580
+ let cacheReadTokens: number | undefined;
519
581
  let stopReason: string = 'end_turn';
520
582
  let stopSequence: string | undefined;
521
583
  let fullText = '';
@@ -630,7 +692,19 @@ export class BedrockAdapter implements ProviderAdapter {
630
692
  }
631
693
 
632
694
  if (eventData.type === 'message_start' && eventData.message) {
633
- inputTokens = eventData.message.usage?.input_tokens ?? 0;
695
+ // Cache metrics ride the same usage objects as on the direct
696
+ // API. Dropping them (pre-2026-07-31) made caching look
697
+ // permanently inert on Bedrock streams: complete() surfaced
698
+ // them, stream() zeroed them, and every ledger/pricing
699
+ // consumer downstream saw zeros. (Connectome issue #35.)
700
+ const startUsage = eventData.message.usage;
701
+ inputTokens = startUsage?.input_tokens ?? 0;
702
+ if (startUsage?.cache_creation_input_tokens != null) {
703
+ cacheCreationTokens = startUsage.cache_creation_input_tokens;
704
+ }
705
+ if (startUsage?.cache_read_input_tokens != null) {
706
+ cacheReadTokens = startUsage.cache_read_input_tokens;
707
+ }
634
708
  } else if (eventData.type === 'content_block_start') {
635
709
  currentBlockIndex = eventData.index ?? 0;
636
710
  contentBlocks[currentBlockIndex] = eventData.content_block as { type: string };
@@ -679,6 +753,15 @@ export class BedrockAdapter implements ProviderAdapter {
679
753
  } else if (eventData.type === 'message_delta') {
680
754
  if (eventData.usage) {
681
755
  outputTokens = eventData.usage.output_tokens;
756
+ // message_delta carries cumulative cache metrics — use as
757
+ // authoritative when present (same contract as the
758
+ // Anthropic adapter).
759
+ if (eventData.usage.cache_creation_input_tokens != null) {
760
+ cacheCreationTokens = eventData.usage.cache_creation_input_tokens;
761
+ }
762
+ if (eventData.usage.cache_read_input_tokens != null) {
763
+ cacheReadTokens = eventData.usage.cache_read_input_tokens;
764
+ }
682
765
  }
683
766
  if (eventData.delta?.stop_reason) {
684
767
  stopReason = eventData.delta.stop_reason;
@@ -751,6 +834,8 @@ export class BedrockAdapter implements ProviderAdapter {
751
834
  usage: {
752
835
  input_tokens: inputTokens,
753
836
  output_tokens: outputTokens,
837
+ ...(cacheCreationTokens != null ? { cache_creation_input_tokens: cacheCreationTokens } : {}),
838
+ ...(cacheReadTokens != null ? { cache_read_input_tokens: cacheReadTokens } : {}),
754
839
  },
755
840
  };
756
841
 
@@ -15,17 +15,53 @@ import type { PrefillFormatter } from '../formatters/types.js';
15
15
  export interface RetryConfig {
16
16
  /** Maximum number of retry attempts (default: 3) */
17
17
  maxRetries: number;
18
-
18
+
19
19
  /** Initial retry delay in milliseconds (default: 1000) */
20
20
  retryDelayMs: number;
21
-
21
+
22
22
  /** Backoff multiplier (default: 2) */
23
23
  backoffMultiplier: number;
24
-
24
+
25
25
  /** Maximum retry delay (default: 30000) */
26
26
  maxRetryDelayMs: number;
27
+
28
+ /**
29
+ * Separate, longer schedule for provider capacity errors (529
30
+ * overloaded_error). Capacity storms last minutes, not seconds — the
31
+ * standard schedule's 30s ceiling turns one into a dead turn. Overloaded
32
+ * retries are always attempted (mirroring the forced 429 retries), with
33
+ * jitter so a fleet backing off doesn't re-create the stampede in sync.
34
+ *
35
+ * maxRetries: 0 here disables this dedicated policy entirely: 529s then
36
+ * follow the base retry config like any other retryable server error
37
+ * (no forced retries, base schedule, no stream-path retry) — the exact
38
+ * pre-policy behavior.
39
+ */
40
+ overloaded: OverloadedRetryConfig;
27
41
  }
28
42
 
43
+ export interface OverloadedRetryConfig {
44
+ /** Attempt bound for overloaded errors, applied even when the base
45
+ * maxRetries is 0. Like the base maxRetries (and the forced 429 path),
46
+ * this bounds TOTAL attempts, not retries-after-the-first (default: 7) */
47
+ maxRetries: number;
48
+
49
+ /** Initial overloaded retry delay in milliseconds (default: 10000) */
50
+ retryDelayMs: number;
51
+
52
+ /** Backoff multiplier (default: 2) */
53
+ backoffMultiplier: number;
54
+
55
+ /** Maximum overloaded retry delay (default: 300000 — 5 minutes) */
56
+ maxRetryDelayMs: number;
57
+ }
58
+
59
+ /** Shape accepted by MembraneConfig.retry — every field optional, including
60
+ * inside the nested overloaded schedule. */
61
+ export type RetryConfigInput = Partial<Omit<RetryConfig, 'overloaded'>> & {
62
+ overloaded?: Partial<OverloadedRetryConfig>;
63
+ };
64
+
29
65
  // ============================================================================
30
66
  // Media Processing Config
31
67
  // ============================================================================
@@ -152,7 +188,7 @@ export interface MembraneConfig {
152
188
  formatter?: PrefillFormatter;
153
189
 
154
190
  /** Retry configuration */
155
- retry?: Partial<RetryConfig>;
191
+ retry?: RetryConfigInput;
156
192
 
157
193
  /** Media processing configuration */
158
194
  media?: Partial<MediaConfig>;
@@ -176,6 +212,14 @@ export const DEFAULT_RETRY_CONFIG: RetryConfig = {
176
212
  retryDelayMs: 1000,
177
213
  backoffMultiplier: 2,
178
214
  maxRetryDelayMs: 30000,
215
+ // 7 attempts = 6 waits: 10s → 20s → 40s → 80s → 160s → 300s, ~10 minutes
216
+ // of patience in total — the scale capacity storms actually resolve on.
217
+ overloaded: {
218
+ maxRetries: 7,
219
+ retryDelayMs: 10_000,
220
+ backoffMultiplier: 2,
221
+ maxRetryDelayMs: 300_000,
222
+ },
179
223
  };
180
224
 
181
225
  export const DEFAULT_MEDIA_CONFIG: MediaConfig = {
@@ -239,6 +239,24 @@ export function unsupportedError(message: string, rawRequest?: unknown): Membran
239
239
  // Error Classification
240
240
  // ============================================================================
241
241
 
242
+ /**
243
+ * Provider capacity exhaustion — Anthropic 529 overloaded_error, whichever
244
+ * path it arrived by (structured status from the provider handler, or the
245
+ * message-matched fallbacks in classifyError). Used only to CHOOSE the retry
246
+ * schedule among already-retryable errors, never to decide retryability.
247
+ * Matches the same deliberately narrow tokens as classifyError's fallback
248
+ * (status/`529`/exact `overloaded_error`) — a bare 'overloaded' in prose
249
+ * (e.g. "worker pool overloaded") must not put an unrelated error onto the
250
+ * ~10-minute schedule. The provider handlers' own bare-'overloaded' safety
251
+ * nets attach httpStatus 529, so those still land here via the status check.
252
+ */
253
+ export function isOverloadedError(info: ErrorInfo): boolean {
254
+ if (!info.retryable) return false;
255
+ if (info.httpStatus === 529) return true;
256
+ const m = info.message.toLowerCase();
257
+ return m.includes('529') || m.includes('overloaded_error');
258
+ }
259
+
242
260
  export function classifyError(error: unknown): ErrorInfo {
243
261
  if (error instanceof MembraneError) {
244
262
  return error.toErrorInfo();
@@ -165,6 +165,7 @@ export {
165
165
  safetyError,
166
166
  unsupportedError,
167
167
  classifyError,
168
+ isOverloadedError,
168
169
  } from './errors.js';
169
170
 
170
171
  // Config
@@ -15,7 +15,11 @@ export type StopReason =
15
15
  | 'stop_sequence' // Hit stop sequence
16
16
  | 'tool_use' // Stopped for tool use
17
17
  | 'refusal' // Content refused by safety
18
- | 'abort'; // Request was aborted
18
+ | 'abort' // Request was aborted
19
+ | 'no_progress' // Stall guard ended the turn (issue #39): consecutive
20
+ // automatic resumptions re-sent context without advancing
21
+ | 'round_limit'; // Resumption round cap ended the turn: the turn kept
22
+ // resuming (with progress) past maxResumptionRounds
19
23
 
20
24
  // ============================================================================
21
25
  // Usage Information
@@ -231,6 +231,17 @@ export interface StreamOptions {
231
231
  /** Maximum tool execution depth (default: 10) */
232
232
  maxToolDepth?: number;
233
233
 
234
+ /**
235
+ * Cap on AUTOMATIC false-positive stop-sequence resumptions per turn —
236
+ * membrane's own re-streams, not the caller's work. Tool rounds are
237
+ * deliberately not counted: they are governed by maxToolDepth and caller
238
+ * policy. Distinct from maxToolDepth on purpose: raising the tool budget
239
+ * for deep chains must not also raise how often a turn may re-send its
240
+ * full context on membrane's own initiative (issue #39). Exceeding it
241
+ * ends the turn with stopReason 'round_limit'. Default: 24.
242
+ */
243
+ maxResumptionRounds?: number;
244
+
234
245
  /** Timeout for each tool execution */
235
246
  toolTimeoutMs?: number;
236
247
 
@@ -251,6 +262,21 @@ export interface CompleteOptions {
251
262
  /** Abort signal for cancellation */
252
263
  signal?: AbortSignal;
253
264
 
265
+ /**
266
+ * Re-issue the request when it ends with `stop_reason: 'refusal'`, up to
267
+ * this many times. Default 0 (off).
268
+ *
269
+ * Safe and invisible on this path: nothing has been emitted to the caller
270
+ * yet, so a discarded attempt leaves no trace beyond its output tokens
271
+ * (the replay is cache-warm on input). The streaming equivalent needs the
272
+ * caller to handle `RetryingEvent` — see YieldingStreamOptions.
273
+ *
274
+ * Near the content-policy threshold a refusal is probabilistic rather than
275
+ * a property of the payload, so re-asking is the cheapest correct response
276
+ * — cheaper and far less invasive than rewriting the conversation.
277
+ */
278
+ refusalRetries?: number;
279
+
254
280
  /** Request timeout */
255
281
  timeoutMs?: number;
256
282
 
@@ -69,6 +69,35 @@ export interface ErrorEvent {
69
69
  error: Error;
70
70
  }
71
71
 
72
+ /**
73
+ * Retrying event — the provider ended the attempt with
74
+ * `stop_reason: 'refusal'` and membrane is re-issuing it (opt-in via
75
+ * `refusalRetries`).
76
+ *
77
+ * **The consumer MUST discard everything this call has emitted so far**:
78
+ * `tokens`, `block`, and any partially built assistant content belong to an
79
+ * attempt that no longer exists. A fresh sequence follows. Consumers that
80
+ * have already shown those tokens to a human (a TUI, a chat surface) must
81
+ * retract or overwrite them.
82
+ *
83
+ * Why this exists: near the classifier threshold a refusal is probabilistic
84
+ * rather than a property of the payload — the same bytes pass and refuse
85
+ * minutes apart — so re-asking is the cheapest correct response. Retrying
86
+ * silently would corrupt any consumer that already rendered the discarded
87
+ * attempt, which is why it is opt-in and announced rather than invisible.
88
+ */
89
+ export interface RetryingEvent {
90
+ type: 'retrying';
91
+ /** 1-based index of the retry about to be issued. */
92
+ attempt: number;
93
+ /** Configured maximum number of retries. */
94
+ maxAttempts: number;
95
+ /** Always 'refusal' today; widened only if other retryable stops appear. */
96
+ reason: 'refusal';
97
+ /** Provider's refusal category when it supplies one (e.g. 'cyber'). */
98
+ category?: string;
99
+ }
100
+
72
101
  /**
73
102
  * Aborted event - stream was cancelled.
74
103
  */
@@ -87,6 +116,7 @@ export interface AbortedEvent {
87
116
  export type StreamEvent =
88
117
  | TokensEvent
89
118
  | StreamBlockEvent
119
+ | RetryingEvent
90
120
  | ToolCallsEvent
91
121
  | UsageEvent
92
122
  | CompleteEvent
@@ -225,6 +255,23 @@ export interface YieldingStreamOptions {
225
255
  /** Request ID for correlation/logging */
226
256
  requestId?: string;
227
257
 
258
+ /**
259
+ * Re-issue an attempt that ends with `stop_reason: 'refusal'`, up to this
260
+ * many times. Default 0 (off).
261
+ *
262
+ * Enabling it means the stream can emit `RetryingEvent` — **consumers MUST
263
+ * handle it and discard what they have received for the call**, or two
264
+ * attempts will be concatenated. That is why it is off by default and why
265
+ * turning it on is a per-call decision by a consumer that has been updated.
266
+ *
267
+ * Rationale: near the content-policy threshold a refusal is probabilistic,
268
+ * not a property of the payload — identical bytes pass and refuse minutes
269
+ * apart. Re-asking is cheaper and less invasive than rewriting the
270
+ * conversation, and the replay is cache-warm, so only the discarded output
271
+ * tokens are real spend.
272
+ */
273
+ refusalRetries?: number;
274
+
228
275
  /**
229
276
  * Maximum tool execution depth. Default: unlimited.
230
277
  *
@@ -237,6 +284,19 @@ export interface YieldingStreamOptions {
237
284
  */
238
285
  maxToolDepth?: number;
239
286
 
287
+ /**
288
+ * Cap on AUTOMATIC false-positive stop-sequence resumptions per turn —
289
+ * membrane's own re-streams, not the caller's tool work. Tool rounds are
290
+ * deliberately NOT counted: this path's uncapped-by-default tool-loop
291
+ * contract stands (the caller budgets its own work via maxToolDepth).
292
+ * What this bounds is membrane's own failure surface — how many times a
293
+ * turn may re-send its full context on membrane's initiative; an
294
+ * unlimited resumption bound is how the 43-round Ash spin happened
295
+ * (issue #39). Exceeding it ends the turn with stopReason 'round_limit'.
296
+ * Default: 24. `-1` for unlimited, at your own risk.
297
+ */
298
+ maxResumptionRounds?: number;
299
+
240
300
  /**
241
301
  * Whether to emit 'tokens' events.
242
302
  * Set to false if you only care about tool calls and final response.