@animalabs/membrane 0.5.74 → 0.5.76

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/src/membrane.ts CHANGED
@@ -476,8 +476,11 @@ export class Membrane {
476
476
 
477
477
  if (parsed && parsed.calls.length > 0) {
478
478
  // Notify about pre-tool content
479
- if (onPreToolContent && parsed.beforeText.trim()) {
480
- await onPreToolContent(parsed.beforeText);
479
+ // Slice the seeded prefill off: beforeText starts with the whole
480
+ // flattened document in XML mode (see ToolContext note below).
481
+ const preToolNew = parsed.beforeText.slice(initialPrefillLength);
482
+ if (onPreToolContent && preToolNew.trim()) {
483
+ await onPreToolContent(preToolNew);
481
484
  }
482
485
 
483
486
  // Emit block events for each tool call
@@ -504,13 +507,24 @@ export class Membrane {
504
507
  // Track the tool calls
505
508
  executedToolCalls.push(...parsed.calls);
506
509
 
507
- // Execute tools
510
+ // Execute tools.
511
+ // preamble/accumulated must expose the MODEL'S text only. The
512
+ // parser is seeded with the entire assistant prefill (the whole
513
+ // flattened document in XML mode), so parsed.beforeText starts
514
+ // with it — consumers that persist the preamble as "what the
515
+ // agent said this round" would otherwise write the full document
516
+ // back into the store as an assistant message (observed on Ash,
517
+ // 2026-07-26: a died-mid-rounds turn flushed a ~720k-char
518
+ // document echo into her message store as 62 sharded messages).
519
+ // The turn-END path already slices (newContent =
520
+ // fullAccumulated.slice(initialPrefillLength)); the tool-round
521
+ // path must match it.
508
522
  const context: ToolContext = {
509
523
  rawText: parsed.fullMatch,
510
- preamble: parsed.beforeText,
524
+ preamble: parsed.beforeText.slice(initialPrefillLength),
511
525
  depth: toolDepth,
512
526
  previousResults: executedToolResults,
513
- accumulated: parser.getAccumulated(),
527
+ accumulated: parser.getAccumulated().slice(initialPrefillLength),
514
528
  };
515
529
 
516
530
  const results = await onToolCalls(parsed.calls, context);
@@ -1033,6 +1047,13 @@ export class Membrane {
1033
1047
  const textBlock: Record<string, unknown> = { type: 'text', text };
1034
1048
  if ((block as any).cache_control) {
1035
1049
  textBlock.cache_control = (block as any).cache_control;
1050
+ // A block-level passthrough occupies one of the 4 breakpoint slots
1051
+ // exactly like a marked message — count it, so the tools/system
1052
+ // fallback below doesn't stack more on top. (Imported/seeded
1053
+ // conversations can carry stale request-time cache_control on
1054
+ // stored blocks — first seen wedging Sill 2026-07-25: 3 cm markers
1055
+ // + 2 stale Arc-export blocks = 5 → hard 400 on every inference.)
1056
+ messageBreakpoints++;
1036
1057
  }
1037
1058
  content.push(textBlock);
1038
1059
  } else if (block.type === 'tool_use') {
@@ -2225,13 +2246,24 @@ export class Membrane {
2225
2246
  // Track the tool calls
2226
2247
  executedToolCalls.push(...parsed.calls);
2227
2248
 
2228
- // Build tool context
2249
+ // Build tool context.
2250
+ // preamble/accumulated must expose the MODEL'S text only: the
2251
+ // parser was seeded with the entire assistant prefill (the whole
2252
+ // flattened document in XML mode), so parsed.beforeText starts
2253
+ // with it. Consumers persist the preamble as "what the agent said
2254
+ // this round" — unsliced, a turn that dies mid-rounds flushes the
2255
+ // full document back into the agent's store as its own message
2256
+ // (observed on Ash 2026-07-26: ~720k-char document echo persisted
2257
+ // as 62 sharded assistant messages, doubling her store and
2258
+ // wedging every subsequent compile). The turn-END path already
2259
+ // slices (newContent = fullAccumulated.slice(initialPrefillLength));
2260
+ // the tool-round path must match it.
2229
2261
  const context: ToolContext = {
2230
2262
  rawText: parsed.fullMatch,
2231
- preamble: parsed.beforeText,
2263
+ preamble: parsed.beforeText.slice(initialPrefillLength),
2232
2264
  depth: toolDepth,
2233
2265
  previousResults: executedToolResults,
2234
- accumulated: parser.getAccumulated(),
2266
+ accumulated: parser.getAccumulated().slice(initialPrefillLength),
2235
2267
  };
2236
2268
 
2237
2269
  // Yield control for tool execution
@@ -68,6 +68,61 @@ function noTemperatureSupport(model: string): boolean {
68
68
  return NO_TEMPERATURE_MODELS.some(prefix => model.startsWith(prefix));
69
69
  }
70
70
 
71
+ /** Beta flag for thinking blocks between tool calls on pre-4.6 Claude 4.
72
+ * Shared with the Bedrock adapter, where it rides in the request body
73
+ * (`anthropic_beta`) instead of an HTTP header. */
74
+ export const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14';
75
+
76
+ /**
77
+ * Interleaved thinking (thinking blocks between tool calls) is native from
78
+ * Opus/Sonnet 4.6 onward; earlier Claude 4 models only do it behind the
79
+ * `interleaved-thinking-2025-05-14` beta flag. Matches Claude 4 ids with a
80
+ * minor version below 6 — dated snapshots (claude-opus-4-1-20250805), bare
81
+ * bases (claude-opus-4), and date-only 4.0 ids (claude-opus-4-20250514) are
82
+ * all covered, as are gateway-prefixed ids ('anthropic/claude-opus-4-5').
83
+ * Claude 3.x never matches (no interleaved support, beta or not); 4.6+ and
84
+ * the 5-series need no flag.
85
+ */
86
+ export function needsInterleavedThinkingBeta(model: string): boolean {
87
+ const m = /claude-(?:opus|sonnet|haiku)-4(?:-(\d+))?/.exec(model);
88
+ if (!m) return false;
89
+ const minor = m[1];
90
+ if (minor === undefined) return true; // bare 'claude-opus-4'
91
+ if (minor.length >= 8) return true; // 8-digit date right after the major = a 4.0 id
92
+ return parseInt(minor, 10) < 6;
93
+ }
94
+
95
+ /** Pull any anthropic-beta value out of a ClientOptions defaultHeaders bag,
96
+ * which the SDK types as record | entries-array | Headers. Case-insensitive
97
+ * on the key; non-string values (explicit null override, arrays) are treated
98
+ * as absent. */
99
+ function extractBetaHeader(headers: ClientOptions['defaultHeaders']): string | undefined {
100
+ if (!headers) return undefined;
101
+ if (typeof (headers as Headers).get === 'function') {
102
+ return (headers as Headers).get('anthropic-beta') ?? undefined;
103
+ }
104
+ const entries = Array.isArray(headers)
105
+ ? headers
106
+ : Object.entries(headers as Record<string, unknown>);
107
+ for (const [key, value] of entries) {
108
+ if (String(key).toLowerCase() === 'anthropic-beta' && typeof value === 'string') {
109
+ return value;
110
+ }
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ /** Resolve whether a thinking config is enabled on a request. Thinking can
116
+ * arrive top-level OR smuggled through `extra` (see the sampling gate in
117
+ * buildRequest) — both the sampling strip and the beta header must agree on
118
+ * one answer, so they share this resolver. Exported for the Bedrock adapter,
119
+ * which applies the same interleaved-thinking gate to its request body. */
120
+ export function thinkingEnabled(request: ProviderRequest): boolean {
121
+ const extraThinking = (request.extra as { thinking?: { type?: string } } | undefined)?.thinking;
122
+ const thinkingConfig = request.thinking ?? extraThinking;
123
+ return thinkingConfig !== undefined && thinkingConfig.type !== 'disabled';
124
+ }
125
+
71
126
  // ============================================================================
72
127
  // Adapter Configuration
73
128
  // ============================================================================
@@ -101,12 +156,18 @@ export class AnthropicAdapter implements ProviderAdapter {
101
156
  readonly name = 'anthropic';
102
157
  private client: Anthropic;
103
158
  private defaultMaxTokens: number;
159
+ /** Any anthropic-beta value from defaultHeaders (e.g. the oauth beta for
160
+ * subscription tokens). Per-request headers REPLACE same-key defaults in
161
+ * the SDK rather than merging, so when we add a per-request beta we must
162
+ * re-carry this one alongside it or auth breaks. */
163
+ private defaultBeta: string | undefined;
104
164
 
105
165
  constructor(config: AnthropicAdapterConfig = {}) {
106
166
  const clientOptions: ClientOptions = {
107
167
  baseURL: config.baseURL,
108
168
  defaultHeaders: config.defaultHeaders,
109
169
  };
170
+ this.defaultBeta = extractBetaHeader(config.defaultHeaders);
110
171
 
111
172
  if (config.authToken !== undefined) {
112
173
  clientOptions.authToken = config.authToken;
@@ -134,6 +195,7 @@ export class AnthropicAdapter implements ProviderAdapter {
134
195
  try {
135
196
  const response = await this.client.messages.create(fullRequest, {
136
197
  signal: options?.signal,
198
+ headers: this.betaHeaders(request),
137
199
  });
138
200
 
139
201
  return this.parseResponse(response, fullRequest);
@@ -198,6 +260,7 @@ export class AnthropicAdapter implements ProviderAdapter {
198
260
  try {
199
261
  const stream = await this.client.messages.stream(anthropicRequest, {
200
262
  signal: idleAbort.signal,
263
+ headers: this.betaHeaders(request),
201
264
  });
202
265
 
203
266
  // Accumulate response metadata from SSE events directly, so we can
@@ -399,6 +462,25 @@ export class AnthropicAdapter implements ProviderAdapter {
399
462
  }
400
463
  }
401
464
 
465
+ /** Per-request headers for both create() and stream(): the interleaved-
466
+ * thinking beta when thinking is enabled on a pre-4.6 Claude 4 model.
467
+ * Any default anthropic-beta (oauth) is re-carried in the same header —
468
+ * the SDK replaces same-key defaults instead of merging, and the API
469
+ * accepts comma-separated betas. Undefined when nothing to add, so the
470
+ * defaults apply untouched. */
471
+ private betaHeaders(request: ProviderRequest): Record<string, string> | undefined {
472
+ if (!thinkingEnabled(request) || !needsInterleavedThinkingBeta(request.model)) {
473
+ return undefined;
474
+ }
475
+ // Set-join so a default that already carries the interleaved beta
476
+ // doesn't emit it twice (the API tolerates duplicates; this is hygiene).
477
+ const betas = new Set(
478
+ (this.defaultBeta ?? '').split(',').map((b) => b.trim()).filter(Boolean),
479
+ );
480
+ betas.add(INTERLEAVED_THINKING_BETA);
481
+ return { 'anthropic-beta': [...betas].join(',') };
482
+ }
483
+
402
484
  private buildRequest(request: ProviderRequest): Anthropic.MessageCreateParams {
403
485
  // Strip provider-specific fields (e.g., sourceUrl for Gemini) from image blocks
404
486
  // before sending to Anthropic, which rejects extra inputs.
@@ -464,9 +546,7 @@ export class AnthropicAdapter implements ProviderAdapter {
464
546
  // otherwise `extra: { thinking, temperature }` reproduces the exact 400
465
547
  // this gate exists to prevent (same bug class as the extra-sampling bypass,
466
548
  // one field over).
467
- const extraThinking = (request.extra as { thinking?: { type?: string } } | undefined)?.thinking;
468
- const thinkingConfig = request.thinking ?? extraThinking;
469
- const thinkingOn = thinkingConfig !== undefined && thinkingConfig.type !== 'disabled';
549
+ const thinkingOn = thinkingEnabled(request);
470
550
 
471
551
  if (request.temperature !== undefined && !stripSampling && !thinkingOn) {
472
552
  params.temperature = request.temperature;
@@ -21,6 +21,11 @@ import {
21
21
  abortError,
22
22
  } from '../types/index.js';
23
23
  import { createCombinedSignal } from './utils.js';
24
+ import {
25
+ INTERLEAVED_THINKING_BETA,
26
+ needsInterleavedThinkingBeta,
27
+ thinkingEnabled,
28
+ } from './anthropic.js';
24
29
 
25
30
  // ============================================================================
26
31
  // Adapter Configuration
@@ -64,6 +69,8 @@ interface BedrockMessageRequest {
64
69
  stop_sequences?: string[];
65
70
  tools?: unknown[];
66
71
  thinking?: { type: 'enabled'; budget_tokens: number };
72
+ /** Beta flags. On bedrock-runtime these ride in the body, not a header. */
73
+ anthropic_beta?: string[];
67
74
  }
68
75
 
69
76
  interface BedrockMessageResponse {
@@ -407,6 +414,17 @@ export class BedrockAdapter implements ProviderAdapter {
407
414
  Object.assign(params, rest);
408
415
  }
409
416
 
417
+ // Interleaved thinking on pre-4.6 Claude 4: same gate as the Anthropic
418
+ // adapter, but bedrock-runtime takes betas as the `anthropic_beta` body
419
+ // field rather than an HTTP header. Runs after the extra-assign so a
420
+ // consumer-supplied anthropic_beta is merged (Set-deduped), not clobbered.
421
+ // The gate only matches Claude 4 <4.6 ids, so legacy 3.x models (which
422
+ // reject unknown beta flags) never receive the field.
423
+ if (thinkingEnabled(request) && needsInterleavedThinkingBeta(request.model)) {
424
+ const existing = Array.isArray(params.anthropic_beta) ? params.anthropic_beta : [];
425
+ params.anthropic_beta = [...new Set([...existing, INTERLEAVED_THINKING_BETA])];
426
+ }
427
+
410
428
  return params;
411
429
  }
412
430
 
@@ -499,6 +517,7 @@ export class BedrockAdapter implements ProviderAdapter {
499
517
  let inputTokens = 0;
500
518
  let outputTokens = 0;
501
519
  let stopReason: string = 'end_turn';
520
+ let stopSequence: string | undefined;
502
521
  let fullText = '';
503
522
 
504
523
  const reader = response.body?.getReader();
@@ -664,6 +683,16 @@ export class BedrockAdapter implements ProviderAdapter {
664
683
  if (eventData.delta?.stop_reason) {
665
684
  stopReason = eventData.delta.stop_reason;
666
685
  }
686
+ // WHICH stop sequence fired, not just that one did. Dropping
687
+ // this (pre-2026-07-26) broke prefill/XML tool use on
688
+ // Bedrock entirely: membrane's tool gate matches
689
+ // stopSequence === '</function_calls>', so calls were never
690
+ // parsed or executed, the close tag was never restored, and
691
+ // the turn continuation looped forever on the dangling
692
+ // block (~6 output tokens per full-prefill round).
693
+ if (eventData.delta?.stop_sequence) {
694
+ stopSequence = eventData.delta.stop_sequence;
695
+ }
667
696
  }
668
697
  }
669
698
  } catch (e) {
@@ -718,6 +747,7 @@ export class BedrockAdapter implements ProviderAdapter {
718
747
  }),
719
748
  model: modelId,
720
749
  stop_reason: stopReason as BedrockMessageResponse['stop_reason'],
750
+ stop_sequence: stopSequence ?? null,
721
751
  usage: {
722
752
  input_tokens: inputTokens,
723
753
  output_tokens: outputTokens,
@@ -110,6 +110,17 @@ export interface ToolUseContent {
110
110
  id: string;
111
111
  name: string;
112
112
  input: Record<string, unknown>;
113
+ /**
114
+ * Verbatim document text this call was parsed from in prefill/XML mode:
115
+ * the full `<function_calls>…</function_calls>` block, shared by every
116
+ * invoke parsed from that block. Prefill formatters replay it exactly
117
+ * instead of synthesizing a rendering — in prefill mode the context IS
118
+ * the document the agent authored, and a paraphrase of its own action
119
+ * both corrupts the record and teaches the model a syntax the parser
120
+ * does not accept (membrane#36). Analogous to `signature` on a thinking
121
+ * block. Absent on native-tools blocks and on legacy stored blocks.
122
+ */
123
+ rawXml?: string;
113
124
  /** See {@link TextContent.rawItem}. */
114
125
  rawItem?: unknown;
115
126
  }
@@ -119,6 +130,13 @@ export interface ToolResultContent {
119
130
  toolUseId: string;
120
131
  content: string | ContentBlock[];
121
132
  isError?: boolean;
133
+ /**
134
+ * Verbatim document text this result was parsed from in prefill/XML mode:
135
+ * the full `<function_results>…</function_results>` block as the harness
136
+ * originally placed it in the document, shared by every result parsed
137
+ * from that block. Replayed exactly on the prefill path (membrane#36).
138
+ */
139
+ rawXml?: string;
122
140
  /** See {@link TextContent.rawItem}. */
123
141
  rawItem?: unknown;
124
142
  }
@@ -350,6 +350,10 @@ export function parseAccumulatedIntoBlocks(
350
350
  let funcMatch: RegExpExecArray | null;
351
351
  while ((funcMatch = FUNCTION_BLOCK_WITH_CONTENT_REGEX.exec(processedText)) !== null) {
352
352
  const innerContent = funcMatch[2] ?? '';
353
+ // Verbatim document text of the whole block — carried on each parsed
354
+ // tool_use so prefill replay reproduces the generation exactly instead
355
+ // of synthesizing a paraphrase (membrane#36).
356
+ const rawXml = funcMatch[0];
353
357
  const blockToolCalls: ContentBlock[] = [];
354
358
 
355
359
  // Parse invoke tags in this block
@@ -378,6 +382,7 @@ export function parseAccumulatedIntoBlocks(
378
382
  id,
379
383
  name: toolName,
380
384
  input,
385
+ rawXml,
381
386
  });
382
387
  }
383
388
 
@@ -394,6 +399,7 @@ export function parseAccumulatedIntoBlocks(
394
399
  id,
395
400
  name: toolName,
396
401
  input: {},
402
+ rawXml,
397
403
  });
398
404
  }
399
405
 
@@ -411,6 +417,9 @@ export function parseAccumulatedIntoBlocks(
411
417
  let resultsMatch: RegExpExecArray | null;
412
418
  while ((resultsMatch = FUNCTION_RESULTS_BLOCK_REGEX.exec(processedText)) !== null) {
413
419
  const innerContent = resultsMatch[2] ?? '';
420
+ // Verbatim document text the harness placed — carried for exact replay
421
+ // on the prefill path (membrane#36).
422
+ const rawXml = resultsMatch[0];
414
423
  const blockResults: ContentBlock[] = [];
415
424
 
416
425
  // Parse result tags
@@ -426,6 +435,7 @@ export function parseAccumulatedIntoBlocks(
426
435
  toolUseId,
427
436
  content,
428
437
  isError: false,
438
+ rawXml,
429
439
  });
430
440
  }
431
441
 
@@ -442,6 +452,7 @@ export function parseAccumulatedIntoBlocks(
442
452
  toolUseId,
443
453
  content,
444
454
  isError: true,
455
+ rawXml,
445
456
  });
446
457
  }
447
458