@animalabs/membrane 0.5.52 → 0.5.54

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.
@@ -81,8 +81,38 @@ export interface BuildOptions {
81
81
  * This enables per-message cache boundaries in the conversation.
82
82
  */
83
83
  hasCacheMarker?: (message: NormalizedMessage, index: number) => boolean;
84
+
85
+ /**
86
+ * IDs of tool_use blocks the caller knows are currently in-flight
87
+ * (e.g. a yielding stream that has emitted the tool_use but is still
88
+ * waiting on the result). If a trailing unmatched tool_use's id is in
89
+ * this set, the normalizer signals `ready: false` instead of injecting
90
+ * a synthetic `[pending]` result. Default: empty (always synthesize).
91
+ */
92
+ pendingToolCallIds?: ReadonlySet<string>;
93
+
94
+ /**
95
+ * Telemetry callback fired once per normalization action. Lets the
96
+ * framework count/log normalizations without coupling Membrane to a
97
+ * specific logger. See `NormalizeEvent` for the event shapes.
98
+ */
99
+ onNormalize?: (event: NormalizeEvent) => void;
84
100
  }
85
101
 
102
+ /**
103
+ * Events emitted by the tool-pair normalizer. Surfaced through
104
+ * `BuildOptions.onNormalize`. Every normalization action emits one
105
+ * event; treat non-zero counts as a producer-side bug to investigate.
106
+ */
107
+ export type NormalizeEvent =
108
+ | { kind: 'block_re_roled'; blockType: string; from: 'user' | 'assistant'; to: 'user' | 'assistant' }
109
+ | { kind: 'tool_result_hoisted'; toolUseId: string; fromEnvelope: number; toEnvelope: number }
110
+ | { kind: 'interloper_deferred'; blockType: string; fromEnvelope: number }
111
+ | { kind: 'synthetic_pending_result'; toolUseId: string; reason: 'trailing' | 'mid_stream' }
112
+ | { kind: 'orphan_tool_result_textified'; toolUseId: string }
113
+ | { kind: 'pending_in_flight'; toolUseId: string }
114
+ | { kind: 'cache_suppressed_for_synthetic'; envelopeIndex: number };
115
+
86
116
  // ============================================================================
87
117
  // Build Result
88
118
  // ============================================================================
@@ -105,6 +135,15 @@ export interface BuildResult {
105
135
 
106
136
  /** Number of cache control markers applied (for Anthropic prompt caching) */
107
137
  cacheMarkersApplied?: number;
138
+
139
+ /**
140
+ * `false` only when the tool-pair normalizer detected a trailing
141
+ * unmatched tool_use whose id is in `pendingToolCallIds` — i.e. the
142
+ * caller (yielding stream) is mid-cycle and the request should not be
143
+ * shipped yet. Callers that don't pass `pendingToolCallIds` will never
144
+ * see `false` here.
145
+ */
146
+ ready?: boolean;
108
147
  }
109
148
 
110
149
  export interface ProviderMessage {
package/src/membrane.ts CHANGED
@@ -52,6 +52,7 @@ import type {
52
52
  } from './types/yielding-stream.js';
53
53
  import type { PrefillFormatter, StreamParser } from './formatters/types.js';
54
54
  import { AnthropicXmlFormatter } from './formatters/anthropic-xml.js';
55
+ import { normalizeToolPairs, mergeConsecutiveRoles } from './formatters/normalize-tool-pairs.js';
55
56
  import { YieldingStreamImpl } from './yielding-stream.js';
56
57
  import { calculateCost } from './utils/cost.js';
57
58
  import { getDefaultPricing } from './registry/default-pricing.js';
@@ -1030,7 +1031,29 @@ export class Membrane {
1030
1031
 
1031
1032
  providerMessages.push({ role, content });
1032
1033
  }
1033
-
1034
+
1035
+ // Wire-boundary safety net: repair upstream-produced violations of
1036
+ // Anthropic's tool-cycle structural rules (orphan tool_use, mis-roled
1037
+ // blocks, consecutive same-role envelopes from upstream chunkers that
1038
+ // dropped a tool_result). Mirrors NativeFormatter.buildMessages — the
1039
+ // streaming-native path (runNativeToolsYielding) used to bypass this
1040
+ // and exposed every agent inference to the 400 family.
1041
+ //
1042
+ // Synthesized [pending] tool_results land in fresh user envelopes;
1043
+ // the normalizer also suppresses cache_control on those envelopes
1044
+ // so an in-flight gap can't poison the prompt cache. Merging after
1045
+ // normalize collapses any same-role neighbours the upstream may have
1046
+ // produced before they reach the API's alternating-role check.
1047
+ //
1048
+ // `pendingToolCallIds` is intentionally not threaded here: by the
1049
+ // time runNativeToolsYielding rebuilds the request between
1050
+ // tool-execution rounds, it has already appended the corresponding
1051
+ // tool_results to `messages`. Any unmatched tool_use that reaches
1052
+ // this splice is upstream stranding (the bug class this fix exists
1053
+ // to catch) — `[pending]` is exactly the right synthesis.
1054
+ const normalized = normalizeToolPairs(providerMessages);
1055
+ const mergedMessages = mergeConsecutiveRoles(normalized.messages);
1056
+
1034
1057
  // Convert tools to provider format.
1035
1058
  // Native tool names must match ^[a-zA-Z0-9_-]{1,128}$ — sanitize colons
1036
1059
  // from the module:tool namespace convention. Reversed in parseProviderContent.
@@ -1073,7 +1096,7 @@ export class Membrane {
1073
1096
  model: request.config.model,
1074
1097
  maxTokens: request.config.maxTokens,
1075
1098
  temperature,
1076
- messages: providerMessages,
1099
+ messages: mergedMessages,
1077
1100
  system,
1078
1101
  tools,
1079
1102
  thinking,