@animalabs/membrane 0.5.77 → 0.5.79

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.
Files changed (37) hide show
  1. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  2. package/dist/formatters/anthropic-xml.js +30 -2
  3. package/dist/formatters/anthropic-xml.js.map +1 -1
  4. package/dist/membrane.d.ts.map +1 -1
  5. package/dist/membrane.js +93 -2
  6. package/dist/membrane.js.map +1 -1
  7. package/dist/providers/anthropic.d.ts +9 -0
  8. package/dist/providers/anthropic.d.ts.map +1 -1
  9. package/dist/providers/anthropic.js +1 -1
  10. package/dist/providers/anthropic.js.map +1 -1
  11. package/dist/providers/bedrock.d.ts.map +1 -1
  12. package/dist/providers/bedrock.js +34 -4
  13. package/dist/providers/bedrock.js.map +1 -1
  14. package/dist/types/content.d.ts +5 -0
  15. package/dist/types/content.d.ts.map +1 -1
  16. package/dist/types/content.js.map +1 -1
  17. package/dist/types/streaming.d.ts +14 -0
  18. package/dist/types/streaming.d.ts.map +1 -1
  19. package/dist/types/tools.d.ts +19 -0
  20. package/dist/types/tools.d.ts.map +1 -1
  21. package/dist/types/yielding-stream.d.ts +45 -1
  22. package/dist/types/yielding-stream.d.ts.map +1 -1
  23. package/dist/types/yielding-stream.js.map +1 -1
  24. package/dist/utils/tool-parser.d.ts +16 -2
  25. package/dist/utils/tool-parser.d.ts.map +1 -1
  26. package/dist/utils/tool-parser.js +143 -35
  27. package/dist/utils/tool-parser.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/formatters/anthropic-xml.ts +30 -2
  30. package/src/membrane.ts +118 -2
  31. package/src/providers/anthropic.ts +1 -1
  32. package/src/providers/bedrock.ts +35 -3
  33. package/src/types/content.ts +5 -0
  34. package/src/types/streaming.ts +15 -0
  35. package/src/types/tools.ts +20 -0
  36. package/src/types/yielding-stream.ts +47 -0
  37. package/src/utils/tool-parser.ts +147 -36
package/src/membrane.ts CHANGED
@@ -105,6 +105,10 @@ export class Membrane {
105
105
  const startTime = Date.now();
106
106
  let attempts = 0;
107
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;
108
112
 
109
113
  while (true) {
110
114
  attempts++;
@@ -140,6 +144,19 @@ export class Membrane {
140
144
  rawRequest
141
145
  );
142
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
+
143
160
  // Call afterResponse hook
144
161
  if (this.config.hooks?.afterResponse) {
145
162
  return await this.config.hooks.afterResponse(response, providerResponse.raw);
@@ -391,10 +408,16 @@ export class Membrane {
391
408
  // Track the initial prefill length so we can extract only NEW content for response
392
409
  // Also track what block type we're inside at the end of prefill
393
410
  let initialPrefillLength = 0;
411
+ // Watermark for per-round delta text (ToolContext.roundPreamble): start of
412
+ // the CURRENT round's model text in parser-accumulated coordinates.
413
+ // Advanced past each round's injected results push, so injected
414
+ // <function_results> XML never enters a round's delta.
415
+ let roundStartLen = 0;
394
416
  let initialBlockType: 'thinking' | 'tool_call' | 'tool_result' | null = null;
395
417
  if (prefillResult.assistantPrefill) {
396
418
  parser.push(prefillResult.assistantPrefill);
397
419
  initialPrefillLength = prefillResult.assistantPrefill.length;
420
+ roundStartLen = initialPrefillLength;
398
421
  // Capture what block type we're inside after prefill (if any)
399
422
  if (parser.isInsideBlock()) {
400
423
  const blockType = parser.getCurrentBlockType();
@@ -682,6 +705,7 @@ export class Membrane {
682
705
  const context: ToolContext = {
683
706
  rawText: parsed.fullMatch,
684
707
  preamble: parsed.beforeText.slice(initialPrefillLength),
708
+ roundPreamble: parsed.beforeText.slice(roundStartLen),
685
709
  depth: toolDepth,
686
710
  previousResults: executedToolResults,
687
711
  accumulated: parser.getAccumulated().slice(initialPrefillLength),
@@ -694,6 +718,14 @@ export class Membrane {
694
718
  );
695
719
  }
696
720
 
721
+ // Backfill tool names for the legacy XML result rendering
722
+ // (<result><tool_name>…</tool_name><stdout>…) when the executor
723
+ // didn't supply them.
724
+ const callNames = new Map(parsed.calls.map((c) => [c.id, c.name]));
725
+ for (const r of results) {
726
+ if (!r.toolName) r.toolName = callNames.get(r.toolUseId);
727
+ }
728
+
697
729
  // Track the tool results
698
730
  executedToolResults.push(...results);
699
731
 
@@ -815,6 +847,10 @@ export class Membrane {
815
847
  );
816
848
  }
817
849
 
850
+ // Next round's model text starts after everything injected this
851
+ // round (results XML, image-split tags, thinking opener).
852
+ roundStartLen = parser.getAccumulated().length;
853
+
818
854
  // Reset parser state for new streaming iteration. Tool rounds
819
855
  // are the caller's work — they count against maxToolDepth only,
820
856
  // never against the resumption guards (issue #39 review).
@@ -1681,6 +1717,23 @@ export class Membrane {
1681
1717
  * separate `streamOnceWithoutHook` so the bypass is intentional.
1682
1718
  */
1683
1719
  normalizedRequest: NormalizedRequest;
1720
+ /**
1721
+ * Re-issue this attempt when the provider ends it with
1722
+ * `stop_reason: 'refusal'` (see RetryingEvent). Default 0 = off, so
1723
+ * every existing caller keeps byte-identical behaviour.
1724
+ */
1725
+ refusalRetries?: number;
1726
+ /**
1727
+ * REQUIRED to enable streaming retries. Called immediately before a
1728
+ * re-issue so the caller can discard the abandoned attempt: reset its
1729
+ * accumulators and tell its own consumer to drop what it emitted.
1730
+ *
1731
+ * Without it a retry would silently concatenate two attempts, so a
1732
+ * caller that does not pass this simply does not get retries — an
1733
+ * unaware consumer can never be corrupted by enabling the option
1734
+ * somewhere upstream.
1735
+ */
1736
+ onRetrying?: (info: { attempt: number; maxAttempts: number; category?: string }) => void;
1684
1737
  }
1685
1738
  ) {
1686
1739
  // Strip `normalizedRequest` before forwarding to the adapter — it's
@@ -1688,9 +1741,21 @@ export class Membrane {
1688
1741
  // compatibility won't catch the excess field (checked only on object
1689
1742
  // literals, not on variables). Leaving it in would silently leak the
1690
1743
  // normalized form into every adapter's options.
1691
- const { normalizedRequest, ...adapterOptions } = options;
1744
+ const { normalizedRequest, refusalRetries, onRetrying, ...adapterOptions } = options;
1692
1745
  const finalRequest = (await this.applyBeforeRequestHook(normalizedRequest, request)) as typeof request;
1693
- return await this.adapter.stream(finalRequest, callbacks, adapterOptions);
1746
+
1747
+ // Retries are only safe when the caller can discard the abandoned
1748
+ // attempt, so they require BOTH a budget and an onRetrying hook.
1749
+ const maxAttempts = onRetrying ? Math.max(0, refusalRetries ?? 0) : 0;
1750
+ let retried = 0;
1751
+ while (true) {
1752
+ const result = await this.adapter.stream(finalRequest, callbacks, adapterOptions);
1753
+ if (result.stopReason !== 'refusal' || retried >= maxAttempts) return result;
1754
+ retried++;
1755
+ const category = (result.raw as { response?: { stop_details?: { category?: string } } } | undefined)
1756
+ ?.response?.stop_details?.category;
1757
+ onRetrying!({ attempt: retried, maxAttempts, category });
1758
+ }
1694
1759
  }
1695
1760
 
1696
1761
  private buildContinuationRequest(
@@ -2172,6 +2237,18 @@ export class Membrane {
2172
2237
  ): YieldingStream {
2173
2238
  const toolMode = this.resolveToolMode(request);
2174
2239
 
2240
+ // refusalRetries is implemented on the native path only. The XML path
2241
+ // accumulates into a streaming parser carrying prefill context and
2242
+ // resumption depths; rolling that back mid-turn is a separate problem,
2243
+ // and a partial implementation would corrupt the turn instead of
2244
+ // retrying it. Fail LOUD and OFF rather than silently mis-retrying.
2245
+ if (toolMode !== 'native' && (options.refusalRetries ?? 0) > 0) {
2246
+ (this.config.logger ?? console).warn(
2247
+ '[membrane] refusalRetries is ignored in XML tool mode ' +
2248
+ '(native-only for now) — the turn will surface the refusal as before.',
2249
+ );
2250
+ }
2251
+
2175
2252
  // Create the yielding stream with the appropriate inference runner
2176
2253
  const runInference = toolMode === 'native'
2177
2254
  ? (stream: YieldingStreamImpl) => this.runNativeToolsYielding(request, options, stream)
@@ -2256,10 +2333,14 @@ export class Membrane {
2256
2333
 
2257
2334
  // Initialize parser with prefill content
2258
2335
  let initialPrefillLength = 0;
2336
+ // Watermark for per-round delta text (ToolContext.roundPreamble) — see
2337
+ // streamWithXmlTools for rationale. Advanced past each results push.
2338
+ let roundStartLen = 0;
2259
2339
  let initialBlockType: 'thinking' | 'tool_call' | 'tool_result' | null = null;
2260
2340
  if (prefillResult.assistantPrefill) {
2261
2341
  parser.push(prefillResult.assistantPrefill);
2262
2342
  initialPrefillLength = prefillResult.assistantPrefill.length;
2343
+ roundStartLen = initialPrefillLength;
2263
2344
  if (parser.isInsideBlock()) {
2264
2345
  const blockType = parser.getCurrentBlockType();
2265
2346
  if (blockType === 'thinking' || blockType === 'tool_call' || blockType === 'tool_result') {
@@ -2506,6 +2587,7 @@ export class Membrane {
2506
2587
  const context: ToolContext = {
2507
2588
  rawText: parsed.fullMatch,
2508
2589
  preamble: parsed.beforeText.slice(initialPrefillLength),
2590
+ roundPreamble: parsed.beforeText.slice(roundStartLen),
2509
2591
  depth: toolDepth,
2510
2592
  previousResults: executedToolResults,
2511
2593
  accumulated: parser.getAccumulated().slice(initialPrefillLength),
@@ -2520,6 +2602,14 @@ export class Membrane {
2520
2602
 
2521
2603
  const { results, injectedMessages } = await stream.requestToolExecution(toolCallsEvent);
2522
2604
 
2605
+ // Backfill tool names for the legacy XML result rendering
2606
+ // (<result><tool_name>…</tool_name><stdout>…) when the executor
2607
+ // didn't supply them.
2608
+ const yieldCallNames = new Map(parsed.calls.map((c) => [c.id, c.name]));
2609
+ for (const r of results) {
2610
+ if (!r.toolName) r.toolName = yieldCallNames.get(r.toolUseId);
2611
+ }
2612
+
2523
2613
  // Mid-turn injected messages are not supported on the XML prefill
2524
2614
  // path: the continuation is an assistant prefill over an XML
2525
2615
  // transcript, not a message array, so there is no user envelope
@@ -2667,6 +2757,10 @@ export class Membrane {
2667
2757
  );
2668
2758
  }
2669
2759
 
2760
+ // Next round's model text starts after everything injected this
2761
+ // round (results XML, image-split tags, thinking opener).
2762
+ roundStartLen = parser.getAccumulated().length;
2763
+
2670
2764
  // Tool rounds are the caller's work — they count against
2671
2765
  // maxToolDepth only, never against the resumption guards
2672
2766
  // (issue #39 review: the uncapped tool-loop contract stands).
@@ -2824,6 +2918,9 @@ export class Membrane {
2824
2918
  // Stream from provider
2825
2919
  let textAccumulated = '';
2826
2920
  let blockIndex = 0;
2921
+ // Where this attempt starts inside the tool-loop-spanning buffer, so
2922
+ // a refusal retry can roll back exactly this attempt's contribution.
2923
+ const allTextBefore = allTextAccumulated.length;
2827
2924
  // Track block-type from the provider's content_block_start signal so
2828
2925
  // every token chunk is tagged with the membrane block it belongs to.
2829
2926
  // Without this, thinking_delta chunks get mislabelled as 'text' and
@@ -2902,6 +2999,25 @@ export class Membrane {
2902
2999
  idleTimeoutMs: options.idleTimeoutMs,
2903
3000
  normalizedRequest: request,
2904
3001
  onRequest: (req: unknown) => { rawRequest = req; },
3002
+ refusalRetries: options.refusalRetries,
3003
+ // Discard the refused attempt: roll the accumulators back to
3004
+ // where this attempt began and tell the consumer to drop what it
3005
+ // already received. `allTextAccumulated` spans the whole tool
3006
+ // loop, so it is truncated rather than cleared.
3007
+ onRetrying: (info) => {
3008
+ allTextAccumulated = allTextAccumulated.slice(0, allTextBefore);
3009
+ textAccumulated = '';
3010
+ blockIndex = 0;
3011
+ currentBlockType = 'text';
3012
+ seenBlockIndices.clear();
3013
+ stream.emit({
3014
+ type: 'retrying',
3015
+ attempt: info.attempt,
3016
+ maxAttempts: info.maxAttempts,
3017
+ reason: 'refusal',
3018
+ ...(info.category ? { category: info.category } : {}),
3019
+ });
3020
+ },
2905
3021
  }
2906
3022
  );
2907
3023
 
@@ -780,7 +780,7 @@ function toAnthropicToolResultContent(
780
780
  * can lose or mislabel mediaType (e.g. a PNG tagged image/jpeg), which the
781
781
  * Anthropic API rejects with a 400. Trust the bytes; fall back to the declared
782
782
  * type, then jpeg. */
783
- function detectImageMediaType(data: string | undefined, fallback?: string): string {
783
+ export function detectImageMediaType(data: string | undefined, fallback?: string): string {
784
784
  try {
785
785
  const b = Buffer.from((data || "").slice(0, 24), "base64");
786
786
  if (b[0]===0x89&&b[1]===0x50&&b[2]===0x4e&&b[3]===0x47) return "image/png";
@@ -25,6 +25,7 @@ import {
25
25
  INTERLEAVED_THINKING_BETA,
26
26
  needsInterleavedThinkingBeta,
27
27
  thinkingEnabled,
28
+ detectImageMediaType,
28
29
  } from './anthropic.js';
29
30
 
30
31
  // ============================================================================
@@ -322,8 +323,9 @@ export class BedrockAdapter implements ProviderAdapter {
322
323
 
323
324
  // If already in Bedrock format, use as-is
324
325
  // Supports both direct model IDs (anthropic.claude-...) and
325
- // cross-region inference profile IDs (us.anthropic.claude-..., eu.anthropic.claude-..., apac.anthropic.claude-...)
326
- if (modelId.startsWith('anthropic.') || /^[a-z]{2,4}\.anthropic\./.test(modelId)) {
326
+ // cross-region inference profile IDs (us.anthropic.claude-..., eu.anthropic.claude-...,
327
+ // apac.anthropic.claude-..., global.anthropic.claude-... — `global` is 6 chars, hence {2,6}).
328
+ if (modelId.startsWith('anthropic.') || /^[a-z]{2,6}\.anthropic\./.test(modelId)) {
327
329
  return modelId;
328
330
  }
329
331
 
@@ -403,6 +405,25 @@ export class BedrockAdapter implements ProviderAdapter {
403
405
  // caching works without the field, so strip just the ttl and keep the
404
406
  // breakpoint. Transport quirks belong to the transport, not to every
405
407
  // caller that sets cacheTtl. (Connectome issue #35.)
408
+ // Bedrock is strict about the wire shape of image sources: internal blocks
409
+ // carry camelCase `mediaType`, the API requires snake_case `media_type`.
410
+ // The Anthropic adapter converts on both paths (toAnthropicContent /
411
+ // toAnthropicToolResultContent); without the same conversion here a
412
+ // tool-returned image 400s with "media_type: Field required" mid-turn
413
+ // (observed on eidoverse snapshot results, 2026-08-08). Applies to
414
+ // top-level image blocks AND images nested inside tool_result content.
415
+ const toWireImage = (block: any): any => {
416
+ const source = block.source;
417
+ if (!source || source.type !== 'base64') return block;
418
+ const { mediaType, media_type, ...restSource } = source;
419
+ return {
420
+ ...block,
421
+ source: {
422
+ ...restSource,
423
+ media_type: detectImageMediaType(source.data, (media_type ?? mediaType) as string),
424
+ },
425
+ };
426
+ };
406
427
  const sanitizedMessages = (request.messages as any[]).map((msg: any) => {
407
428
  if (!Array.isArray(msg.content)) return msg;
408
429
  return {
@@ -410,7 +431,18 @@ export class BedrockAdapter implements ProviderAdapter {
410
431
  content: msg.content.map((block: any) => {
411
432
  if (block.type === 'image' && block.sourceUrl !== undefined) {
412
433
  const { sourceUrl, ...rest } = block;
413
- return stripCacheTtl(rest);
434
+ return stripCacheTtl(toWireImage(rest));
435
+ }
436
+ if (block.type === 'image') {
437
+ return stripCacheTtl(toWireImage(block));
438
+ }
439
+ if (block.type === 'tool_result' && Array.isArray(block.content)) {
440
+ return stripCacheTtl({
441
+ ...block,
442
+ content: block.content.map((inner: any) =>
443
+ inner?.type === 'image' ? toWireImage(inner) : inner,
444
+ ),
445
+ });
414
446
  }
415
447
  return stripCacheTtl(block);
416
448
  }),
@@ -128,6 +128,11 @@ export interface ToolUseContent {
128
128
  export interface ToolResultContent {
129
129
  type: 'tool_result';
130
130
  toolUseId: string;
131
+ /**
132
+ * Tool name, persisted so XML replay can reconstruct the legacy
133
+ * `<tool_name>` element byte-identically to the live injection.
134
+ */
135
+ toolName?: string;
131
136
  content: string | ContentBlock[];
132
137
  isError?: boolean;
133
138
  /**
@@ -262,6 +262,21 @@ export interface CompleteOptions {
262
262
  /** Abort signal for cancellation */
263
263
  signal?: AbortSignal;
264
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
+
265
280
  /** Request timeout */
266
281
  timeoutMs?: number;
267
282
 
@@ -37,6 +37,13 @@ export interface ToolCall {
37
37
 
38
38
  export interface ToolResult {
39
39
  toolUseId: string;
40
+ /**
41
+ * Tool name, for the legacy XML result rendering
42
+ * (`<result><tool_name>…</tool_name><stdout>…</stdout></result>`).
43
+ * Optional: XML paths backfill it from the round's parsed calls when the
44
+ * executor didn't supply it.
45
+ */
46
+ toolName?: string;
40
47
  /**
41
48
  * Result content - can be string or structured content blocks (for images).
42
49
  * For XML mode, images are noted in text. For native mode, passed as content blocks.
@@ -63,6 +70,19 @@ export interface ToolContext {
63
70
  /** Text before the tool calls (already streamed to user) */
64
71
  preamble: string;
65
72
 
73
+ /**
74
+ * XML mode only: THIS round's model-authored text — the slice of the
75
+ * turn between the end of the previous round's injected results and this
76
+ * round's <function_calls> opener. Unlike `preamble` (cumulative: the
77
+ * whole turn so far, including harness-injected <function_results> XML),
78
+ * this never repeats earlier rounds and never contains injected results.
79
+ * Consumers persisting per-round assistant text must prefer this field —
80
+ * persisting the cumulative `preamble` per round stores each round's text
81
+ * N times and re-persists injected results as model text (the Evander
82
+ * 2026-08-08 scaffold-leak pyramid).
83
+ */
84
+ roundPreamble?: string;
85
+
66
86
  /** Current depth in tool execution loop */
67
87
  depth: number;
68
88
 
@@ -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
  *