@animalabs/membrane 0.5.37 → 0.5.38

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
@@ -203,14 +203,19 @@ export class Membrane {
203
203
  if (request.toolMode && request.toolMode !== 'auto') {
204
204
  return request.toolMode;
205
205
  }
206
-
207
- // Auto mode: choose based on provider
208
- // OpenRouter and OpenAI-compatible APIs use native tools
209
- // Anthropic direct with prefill mode uses XML tools
206
+
207
+ // Auto mode: choose based on formatter
208
+ // NativeFormatter native tools via API
209
+ // AnthropicXmlFormatter (default) XML tools in prefill
210
+ if (this.formatter.name === 'native') {
211
+ return 'native';
212
+ }
213
+
214
+ // Also handle known native-tool providers regardless of formatter
210
215
  if (this.adapter.name === 'openrouter') {
211
216
  return 'native';
212
217
  }
213
-
218
+
214
219
  // Default to XML for prefill compatibility
215
220
  return 'xml';
216
221
  }
@@ -837,14 +842,17 @@ export class Membrane {
837
842
  });
838
843
  }
839
844
 
840
- // Add assistant message with tool use and user message with tool results
845
+ // Add assistant message with tool use and user message with tool results.
846
+ // Use the request's participant name so role mapping is consistent.
847
+ const asstName = request.assistantParticipant
848
+ ?? this.config.assistantParticipant ?? 'Claude';
841
849
  messages.push({
842
- participant: 'Claude',
850
+ participant: asstName,
843
851
  content: responseBlocks,
844
852
  });
845
853
 
846
854
  messages.push({
847
- participant: 'User',
855
+ participant: asstName === 'Claude' ? 'User' : 'user',
848
856
  content: results.map(r => ({
849
857
  type: 'tool_result' as const,
850
858
  toolUseId: r.toolUseId,
@@ -923,8 +931,11 @@ export class Membrane {
923
931
  // Convert messages to provider format
924
932
  const providerMessages: any[] = [];
925
933
 
934
+ const assistantName = request.assistantParticipant
935
+ ?? this.config.assistantParticipant ?? 'Claude';
936
+
926
937
  for (const msg of messages) {
927
- const isAssistant = msg.participant === 'Claude';
938
+ const isAssistant = msg.participant === assistantName;
928
939
  const role = isAssistant ? 'assistant' : 'user';
929
940
 
930
941
  // Convert content blocks
@@ -940,7 +951,7 @@ export class Membrane {
940
951
  content.push({
941
952
  type: 'tool_use',
942
953
  id: block.id,
943
- name: block.name,
954
+ name: sanitizeToolName(block.name),
944
955
  input: block.input,
945
956
  });
946
957
  } else if (block.type === 'tool_result') {
@@ -972,9 +983,11 @@ export class Membrane {
972
983
  providerMessages.push({ role, content });
973
984
  }
974
985
 
975
- // Convert tools to provider format
986
+ // Convert tools to provider format.
987
+ // Native tool names must match ^[a-zA-Z0-9_-]{1,128}$ — sanitize colons
988
+ // from the module:tool namespace convention. Reversed in parseProviderContent.
976
989
  const tools = request.tools?.map(tool => ({
977
- name: tool.name,
990
+ name: sanitizeToolName(tool.name),
978
991
  description: tool.description,
979
992
  input_schema: tool.inputSchema,
980
993
  }));
@@ -1017,7 +1030,7 @@ export class Membrane {
1017
1030
  blocks.push({
1018
1031
  type: 'tool_use',
1019
1032
  id: item.id,
1020
- name: item.name,
1033
+ name: unsanitizeToolName(item.name),
1021
1034
  input: item.input,
1022
1035
  });
1023
1036
  } else if (item.type === 'thinking') {
@@ -1071,7 +1084,7 @@ export class Membrane {
1071
1084
  // Use formatter's buildMessages for all request building
1072
1085
  const buildResult = activeFormatter.buildMessages(request.messages, {
1073
1086
  participantMode: 'multiuser',
1074
- assistantParticipant: this.config.assistantParticipant ?? 'Claude',
1087
+ assistantParticipant: request.assistantParticipant ?? this.config.assistantParticipant ?? 'Claude',
1075
1088
  tools: request.tools,
1076
1089
  thinking: request.config.thinking,
1077
1090
  systemPrompt: request.system,
@@ -1107,7 +1120,7 @@ export class Membrane {
1107
1120
  private async streamOnce(
1108
1121
  request: any,
1109
1122
  callbacks: { onChunk: (chunk: string) => void; onContentBlock?: (index: number, block: unknown) => void },
1110
- options: { signal?: AbortSignal; timeoutMs?: number; onRequest?: (rawRequest: unknown) => void }
1123
+ options: { signal?: AbortSignal; timeoutMs?: number; idleTimeoutMs?: number; onRequest?: (rawRequest: unknown) => void }
1111
1124
  ) {
1112
1125
  return await this.adapter.stream(request, callbacks, options);
1113
1126
  }
@@ -1693,6 +1706,7 @@ export class Membrane {
1693
1706
  {
1694
1707
  signal: stream.signal,
1695
1708
  timeoutMs: options.timeoutMs,
1709
+ idleTimeoutMs: options.idleTimeoutMs,
1696
1710
  onRequest: (req: unknown) => { rawRequest = req; },
1697
1711
  }
1698
1712
  );
@@ -2070,6 +2084,7 @@ export class Membrane {
2070
2084
  {
2071
2085
  signal: stream.signal,
2072
2086
  timeoutMs: options.timeoutMs,
2087
+ idleTimeoutMs: options.idleTimeoutMs,
2073
2088
  onRequest: (req: unknown) => { rawRequest = req; },
2074
2089
  }
2075
2090
  );
@@ -2135,14 +2150,16 @@ export class Membrane {
2135
2150
  });
2136
2151
  }
2137
2152
 
2138
- // Add messages for next iteration
2153
+ // Add messages for next iteration — use the request's participant names
2154
+ const assistantName = request.assistantParticipant
2155
+ ?? this.config.assistantParticipant ?? 'Claude';
2139
2156
  messages.push({
2140
- participant: 'Claude',
2157
+ participant: assistantName,
2141
2158
  content: responseBlocks,
2142
2159
  });
2143
2160
 
2144
2161
  messages.push({
2145
- participant: 'User',
2162
+ participant: assistantName === 'Claude' ? 'User' : 'user',
2146
2163
  content: results.map(r => ({
2147
2164
  type: 'tool_result' as const,
2148
2165
  toolUseId: r.toolUseId,
@@ -2212,3 +2229,15 @@ export class Membrane {
2212
2229
  }
2213
2230
  }
2214
2231
  }
2232
+
2233
+ // Native tool names must match ^[a-zA-Z0-9_-]{1,128}$.
2234
+ // The framework uses module:tool namespacing, so we round-trip colons
2235
+ // through an escape encoding for the API wire format.
2236
+ // Lossless: escape underscores first (_u), then encode colons (_c).
2237
+ function sanitizeToolName(name: string): string {
2238
+ return name.replace(/_/g, '_u').replace(/:/g, '_c');
2239
+ }
2240
+
2241
+ function unsanitizeToolName(name: string): string {
2242
+ return name.replace(/_c/g, ':').replace(/_u/g, '_');
2243
+ }
@@ -85,38 +85,151 @@ export class AnthropicAdapter implements ProviderAdapter {
85
85
  const fullRequest = { ...anthropicRequest, stream: true };
86
86
  options?.onRequest?.(fullRequest);
87
87
 
88
+ // Idle timeout: abort if no SSE event arrives within the deadline.
89
+ // The SDK's timeout only covers the initial HTTP response headers;
90
+ // once streaming starts, a silently dropped connection waits forever.
91
+ const idleMs = options?.idleTimeoutMs ?? 120_000;
92
+ const idleAbort = new AbortController();
93
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
94
+ let idleTimedOut = false;
95
+
96
+ // Link caller's signal so external cancellation still works
97
+ const onExternalAbort = () => idleAbort.abort();
98
+ if (options?.signal) {
99
+ if (options.signal.aborted) { idleAbort.abort(); }
100
+ else { options.signal.addEventListener('abort', onExternalAbort, { once: true }); }
101
+ }
102
+
103
+ const resetIdleTimer = () => {
104
+ if (idleTimer) clearTimeout(idleTimer);
105
+ idleTimer = setTimeout(() => { idleTimedOut = true; idleAbort.abort(); }, idleMs);
106
+ };
107
+
108
+ resetIdleTimer();
109
+
88
110
  try {
89
111
  const stream = await this.client.messages.stream(anthropicRequest, {
90
- signal: options?.signal,
112
+ signal: idleAbort.signal,
91
113
  });
92
114
 
93
- let accumulated = '';
94
- const contentBlocks: unknown[] = [];
115
+ // Accumulate response metadata from SSE events directly, so we can
116
+ // skip finalMessage() and its variable-latency connection teardown.
117
+ let model = '';
118
+ let inputTokens = 0;
119
+ let outputTokens = 0;
120
+ let cacheCreationTokens: number | undefined;
121
+ let cacheReadTokens: number | undefined;
122
+ let stopReason: string = 'end_turn';
123
+ let stopSequence: string | undefined;
124
+
125
+ // Content block tracking — finalized on content_block_stop
126
+ const contentBlocks: Record<string, unknown>[] = [];
95
127
  let currentBlockIndex = -1;
128
+ let currentBlockContent = '';
129
+ let currentBlockInputJson = '';
96
130
 
97
131
  for await (const event of stream) {
98
- if (event.type === 'content_block_start') {
132
+ resetIdleTimer();
133
+ if (event.type === 'message_start') {
134
+ model = event.message.model;
135
+ const usage = event.message.usage as unknown as Record<string, number>;
136
+ inputTokens = usage.input_tokens ?? 0;
137
+ cacheCreationTokens = usage.cache_creation_input_tokens;
138
+ cacheReadTokens = usage.cache_read_input_tokens;
139
+
140
+ } else if (event.type === 'content_block_start') {
99
141
  currentBlockIndex = event.index;
100
- contentBlocks[currentBlockIndex] = event.content_block;
142
+ currentBlockContent = '';
143
+ currentBlockInputJson = '';
144
+ contentBlocks[currentBlockIndex] = { ...event.content_block };
101
145
  callbacks.onContentBlock?.(currentBlockIndex, event.content_block);
146
+
102
147
  } else if (event.type === 'content_block_delta') {
103
148
  if (event.delta.type === 'text_delta') {
104
149
  const chunk = event.delta.text;
105
- accumulated += chunk;
150
+ currentBlockContent += chunk;
106
151
  callbacks.onChunk(chunk);
107
152
  } else if (event.delta.type === 'thinking_delta') {
108
- // Handle thinking delta
153
+ currentBlockContent += event.delta.thinking;
109
154
  callbacks.onChunk(event.delta.thinking);
155
+ } else if ((event.delta as { type: string }).type === 'input_json_delta') {
156
+ currentBlockInputJson += (event.delta as { partial_json: string }).partial_json;
110
157
  }
158
+
111
159
  } else if (event.type === 'content_block_stop') {
112
- callbacks.onContentBlock?.(currentBlockIndex, contentBlocks[currentBlockIndex]);
160
+ // Finalize block — use event.index for defensive correctness
161
+ const blockIdx = (event as { index: number }).index;
162
+ const block = contentBlocks[blockIdx];
163
+ if (block) {
164
+ if (block.type === 'text') {
165
+ block.text = currentBlockContent;
166
+ } else if (block.type === 'thinking') {
167
+ block.thinking = currentBlockContent;
168
+ } else if (block.type === 'tool_use' && currentBlockInputJson) {
169
+ try { block.input = JSON.parse(currentBlockInputJson); } catch { /* partial JSON */ }
170
+ }
171
+ }
172
+ callbacks.onContentBlock?.(blockIdx, contentBlocks[blockIdx]);
173
+
174
+ } else if (event.type === 'message_delta') {
175
+ // All content blocks are finalized by the time message_delta arrives.
176
+ // Capture final metadata and exit — message_stop and the SSE connection
177
+ // teardown after it add only variable latency with no useful data.
178
+ const delta = event.delta as { stop_reason?: string; stop_sequence?: string };
179
+ stopReason = delta.stop_reason ?? 'end_turn';
180
+ stopSequence = delta.stop_sequence ?? undefined;
181
+ outputTokens = (event.usage as { output_tokens: number }).output_tokens ?? 0;
182
+ break;
113
183
  }
114
184
  }
115
185
 
116
- const finalMessage = await stream.finalMessage();
117
- return this.parseResponse(finalMessage, fullRequest);
186
+ // Clean up idle timer and external signal listener
187
+ if (idleTimer) clearTimeout(idleTimer);
188
+ options?.signal?.removeEventListener('abort', onExternalAbort);
189
+
190
+ // Force-close the HTTP connection so we don't block on SSE drain
191
+ try { stream.controller.abort(); } catch { /* already closed */ }
192
+
193
+ return {
194
+ content: contentBlocks,
195
+ stopReason,
196
+ stopSequence,
197
+ usage: {
198
+ inputTokens,
199
+ outputTokens,
200
+ cacheCreationTokens,
201
+ cacheReadTokens,
202
+ },
203
+ model,
204
+ rawRequest: fullRequest,
205
+ raw: {
206
+ content: contentBlocks,
207
+ stop_reason: stopReason,
208
+ stop_sequence: stopSequence ?? null,
209
+ model,
210
+ usage: {
211
+ input_tokens: inputTokens,
212
+ output_tokens: outputTokens,
213
+ cache_creation_input_tokens: cacheCreationTokens,
214
+ cache_read_input_tokens: cacheReadTokens,
215
+ },
216
+ },
217
+ };
118
218
 
119
219
  } catch (error) {
220
+ // Clean up timer on error path too
221
+ if (idleTimer) clearTimeout(idleTimer);
222
+ options?.signal?.removeEventListener('abort', onExternalAbort);
223
+
224
+ if (idleTimedOut && error instanceof Error && error.name === 'AbortError') {
225
+ throw new MembraneError({
226
+ type: 'timeout',
227
+ message: `SSE stream idle timeout — no events received within ${idleMs}ms`,
228
+ retryable: true,
229
+ rawError: error,
230
+ rawRequest: fullRequest,
231
+ });
232
+ }
120
233
  throw this.handleError(error, fullRequest);
121
234
  }
122
235
  }
@@ -332,10 +332,26 @@ export class BedrockAdapter implements ProviderAdapter {
332
332
  }
333
333
 
334
334
  private buildRequest(request: ProviderRequest): BedrockMessageRequest {
335
+ // Strip provider-specific fields (e.g., sourceUrl for Gemini) from image blocks
336
+ // before sending to Bedrock/Anthropic, which rejects extra inputs
337
+ const sanitizedMessages = (request.messages as any[]).map((msg: any) => {
338
+ if (!Array.isArray(msg.content)) return msg;
339
+ return {
340
+ ...msg,
341
+ content: msg.content.map((block: any) => {
342
+ if (block.type === 'image' && block.sourceUrl !== undefined) {
343
+ const { sourceUrl, ...rest } = block;
344
+ return rest;
345
+ }
346
+ return block;
347
+ }),
348
+ };
349
+ });
350
+
335
351
  const params: BedrockMessageRequest = {
336
352
  anthropic_version: this.anthropicVersion,
337
353
  max_tokens: request.maxTokens || this.defaultMaxTokens,
338
- messages: request.messages as BedrockMessageRequest['messages'],
354
+ messages: sanitizedMessages as BedrockMessageRequest['messages'],
339
355
  };
340
356
 
341
357
  // Handle system prompt
@@ -228,6 +228,8 @@ export interface ProviderRequest {
228
228
  export interface ProviderRequestOptions {
229
229
  signal?: AbortSignal;
230
230
  timeoutMs?: number;
231
+ /** Abort if no SSE event arrives within this many ms (default: 120000) */
232
+ idleTimeoutMs?: number;
231
233
  /** Called with the raw API request body right before fetch */
232
234
  onRequest?: (rawRequest: unknown) => void;
233
235
  }
@@ -154,6 +154,13 @@ export interface NormalizedRequest {
154
154
  */
155
155
  prefillUserMessage?: string;
156
156
 
157
+ /**
158
+ * Participant name that maps to the 'assistant' role.
159
+ * Messages with this participant are formatted as assistant turns.
160
+ * Default: 'Claude'
161
+ */
162
+ assistantParticipant?: string;
163
+
157
164
  /** Provider-specific parameters (pass-through) */
158
165
  providerParams?: Record<string, unknown>;
159
166
  }
@@ -20,7 +20,7 @@ export interface ToolDefinition {
20
20
  description: string;
21
21
  inputSchema: {
22
22
  type: 'object';
23
- properties: Record<string, ToolParameter>;
23
+ properties?: Record<string, ToolParameter>;
24
24
  required?: string[];
25
25
  };
26
26
  }
@@ -170,6 +170,9 @@ export interface YieldingStreamOptions {
170
170
  /** Request timeout (per API call, not total) */
171
171
  timeoutMs?: number;
172
172
 
173
+ /** Abort if no SSE event arrives within this many ms (default: 120000) */
174
+ idleTimeoutMs?: number;
175
+
173
176
  /** Request ID for correlation/logging */
174
177
  requestId?: string;
175
178
 
@@ -246,7 +246,9 @@ export class YieldingStreamImpl implements YieldingStream {
246
246
  this.markDone();
247
247
  })
248
248
  .catch((error) => {
249
- this.emit({ type: 'error', error });
249
+ if (!this.isCancelled) {
250
+ this.emit({ type: 'error', error });
251
+ }
250
252
  this.markDone();
251
253
  });
252
254
  }