@animalabs/membrane 0.3.2 → 0.5.0

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 (53) hide show
  1. package/dist/formatters/anthropic-xml.d.ts +63 -0
  2. package/dist/formatters/anthropic-xml.d.ts.map +1 -0
  3. package/dist/formatters/anthropic-xml.js +365 -0
  4. package/dist/formatters/anthropic-xml.js.map +1 -0
  5. package/dist/formatters/completions.d.ts +61 -0
  6. package/dist/formatters/completions.d.ts.map +1 -0
  7. package/dist/formatters/completions.js +224 -0
  8. package/dist/formatters/completions.js.map +1 -0
  9. package/dist/formatters/index.d.ts +8 -0
  10. package/dist/formatters/index.d.ts.map +1 -0
  11. package/dist/formatters/index.js +7 -0
  12. package/dist/formatters/index.js.map +1 -0
  13. package/dist/formatters/native.d.ts +35 -0
  14. package/dist/formatters/native.d.ts.map +1 -0
  15. package/dist/formatters/native.js +261 -0
  16. package/dist/formatters/native.js.map +1 -0
  17. package/dist/formatters/types.d.ts +152 -0
  18. package/dist/formatters/types.d.ts.map +1 -0
  19. package/dist/formatters/types.js +7 -0
  20. package/dist/formatters/types.js.map +1 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/membrane.d.ts +4 -0
  26. package/dist/membrane.d.ts.map +1 -1
  27. package/dist/membrane.js +40 -32
  28. package/dist/membrane.js.map +1 -1
  29. package/dist/providers/anthropic.d.ts.map +1 -1
  30. package/dist/providers/anthropic.js +3 -2
  31. package/dist/providers/anthropic.js.map +1 -1
  32. package/dist/transforms/index.d.ts +0 -1
  33. package/dist/transforms/index.d.ts.map +1 -1
  34. package/dist/transforms/index.js +2 -1
  35. package/dist/transforms/index.js.map +1 -1
  36. package/dist/types/config.d.ts +7 -0
  37. package/dist/types/config.d.ts.map +1 -1
  38. package/dist/types/config.js.map +1 -1
  39. package/dist/types/streaming.d.ts +6 -0
  40. package/dist/types/streaming.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/formatters/anthropic-xml.ts +490 -0
  43. package/src/formatters/completions.ts +343 -0
  44. package/src/formatters/index.ts +19 -0
  45. package/src/formatters/native.ts +340 -0
  46. package/src/formatters/types.ts +229 -0
  47. package/src/index.ts +3 -0
  48. package/src/membrane.ts +59 -45
  49. package/src/providers/anthropic.ts +3 -2
  50. package/src/transforms/index.ts +2 -10
  51. package/src/types/config.ts +9 -1
  52. package/src/types/streaming.ts +9 -0
  53. package/src/transforms/prefill.ts +0 -574
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Completions Formatter
3
+ *
4
+ * Formatter for base/completion models that use single-prompt input.
5
+ * Serializes conversations to "Participant: content<eot>" format.
6
+ *
7
+ * Key features:
8
+ * - Converts multi-turn conversations to single prompt string
9
+ * - Adds configurable end-of-turn tokens
10
+ * - Generates stop sequences from participant names
11
+ * - Strips images (not supported in completion models)
12
+ * - No XML block parsing (passthrough mode)
13
+ */
14
+
15
+ import type {
16
+ NormalizedMessage,
17
+ ContentBlock,
18
+ ToolDefinition,
19
+ ToolCall,
20
+ ToolResult,
21
+ } from '../types/index.js';
22
+ import type {
23
+ PrefillFormatter,
24
+ StreamParser,
25
+ BuildOptions,
26
+ BuildResult,
27
+ FormatterConfig,
28
+ ProviderMessage,
29
+ ParseResult,
30
+ BlockType,
31
+ } from './types.js';
32
+
33
+ // ============================================================================
34
+ // Configuration
35
+ // ============================================================================
36
+
37
+ export interface CompletionsFormatterConfig extends FormatterConfig {
38
+ /**
39
+ * End-of-turn token to append after each message.
40
+ * Set to empty string to disable.
41
+ * Default: '<|eot|>'
42
+ */
43
+ eotToken?: string;
44
+
45
+ /**
46
+ * Format for participant name prefix.
47
+ * Use {name} as placeholder.
48
+ * Default: '{name}: '
49
+ */
50
+ nameFormat?: string;
51
+
52
+ /**
53
+ * Message separator between turns.
54
+ * Default: '\n\n'
55
+ */
56
+ messageSeparator?: string;
57
+
58
+ /**
59
+ * Maximum participants to include in stop sequences.
60
+ * Default: 10
61
+ */
62
+ maxParticipantsForStop?: number;
63
+
64
+ /**
65
+ * Whether to warn when images are stripped.
66
+ * Default: true
67
+ */
68
+ warnOnImageStrip?: boolean;
69
+ }
70
+
71
+ // ============================================================================
72
+ // Passthrough Stream Parser (same as NativeFormatter)
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Simple pass-through parser for base models.
77
+ * No XML tracking - just accumulates content.
78
+ */
79
+ class CompletionsStreamParser implements StreamParser {
80
+ private accumulated = '';
81
+ private blockIndex = 0;
82
+
83
+ processChunk(chunk: string): ParseResult {
84
+ this.accumulated += chunk;
85
+ const meta = {
86
+ type: 'text' as const,
87
+ visible: true,
88
+ blockIndex: this.blockIndex,
89
+ };
90
+ return {
91
+ emissions: [{
92
+ kind: 'content' as const,
93
+ text: chunk,
94
+ meta,
95
+ }],
96
+ content: [{ text: chunk, meta }],
97
+ blockEvents: [],
98
+ };
99
+ }
100
+
101
+ flush(): ParseResult {
102
+ return { emissions: [], content: [], blockEvents: [] };
103
+ }
104
+
105
+ getAccumulated(): string {
106
+ return this.accumulated;
107
+ }
108
+
109
+ reset(): void {
110
+ this.accumulated = '';
111
+ this.blockIndex = 0;
112
+ }
113
+
114
+ push(content: string): void {
115
+ this.accumulated += content;
116
+ }
117
+
118
+ getCurrentBlockType(): BlockType {
119
+ return 'text';
120
+ }
121
+
122
+ getBlockIndex(): number {
123
+ return this.blockIndex;
124
+ }
125
+
126
+ incrementBlockIndex(): void {
127
+ this.blockIndex++;
128
+ }
129
+
130
+ isInsideBlock(): boolean {
131
+ return false;
132
+ }
133
+
134
+ resetForNewIteration(): void {
135
+ // No special reset needed
136
+ }
137
+ }
138
+
139
+ // ============================================================================
140
+ // Completions Formatter
141
+ // ============================================================================
142
+
143
+ export class CompletionsFormatter implements PrefillFormatter {
144
+ readonly name = 'completions';
145
+ readonly usesPrefill = true;
146
+
147
+ private config: Required<Omit<CompletionsFormatterConfig, 'unsupportedMedia' | 'warnOnStrip'>> & {
148
+ unsupportedMedia: 'strip'; // Always strip for completions
149
+ warnOnStrip: boolean;
150
+ };
151
+
152
+ constructor(config: CompletionsFormatterConfig = {}) {
153
+ this.config = {
154
+ eotToken: config.eotToken ?? '<|eot|>',
155
+ nameFormat: config.nameFormat ?? '{name}: ',
156
+ messageSeparator: config.messageSeparator ?? '\n\n',
157
+ maxParticipantsForStop: config.maxParticipantsForStop ?? 10,
158
+ warnOnImageStrip: config.warnOnImageStrip ?? true,
159
+ // Completions models don't support images - always strip
160
+ unsupportedMedia: 'strip',
161
+ warnOnStrip: config.warnOnStrip ?? true,
162
+ };
163
+ }
164
+
165
+ // ==========================================================================
166
+ // REQUEST BUILDING
167
+ // ==========================================================================
168
+
169
+ buildMessages(messages: NormalizedMessage[], options: BuildOptions): BuildResult {
170
+ const {
171
+ assistantParticipant,
172
+ systemPrompt,
173
+ additionalStopSequences,
174
+ maxParticipantsForStop = this.config.maxParticipantsForStop,
175
+ } = options;
176
+
177
+ const parts: string[] = [];
178
+ const participants = new Set<string>();
179
+ let hasStrippedImages = false;
180
+
181
+ // Add system prompt as first part if present
182
+ if (systemPrompt) {
183
+ const systemText = typeof systemPrompt === 'string'
184
+ ? systemPrompt
185
+ : systemPrompt
186
+ .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
187
+ .map(b => b.text)
188
+ .join('\n');
189
+
190
+ if (systemText) {
191
+ parts.push(systemText);
192
+ }
193
+ }
194
+
195
+ // Serialize each message
196
+ for (const message of messages) {
197
+ participants.add(message.participant);
198
+
199
+ const { text, hadImages } = this.extractTextContent(message.content);
200
+ if (hadImages) {
201
+ hasStrippedImages = true;
202
+ }
203
+
204
+ // Skip empty messages (except if it's the final completion target)
205
+ if (!text.trim()) {
206
+ continue;
207
+ }
208
+
209
+ // Format: "Participant: content<eot>"
210
+ const prefix = this.config.nameFormat.replace('{name}', message.participant);
211
+ const eot = this.config.eotToken;
212
+ parts.push(`${prefix}${text}${eot}`);
213
+ }
214
+
215
+ // Warn about stripped images
216
+ if (hasStrippedImages && this.config.warnOnImageStrip) {
217
+ console.warn('[CompletionsFormatter] Images were stripped from context (not supported in completions mode)');
218
+ }
219
+
220
+ // Add final assistant prefix (no EOT - model generates this)
221
+ const assistantPrefix = this.config.nameFormat.replace('{name}', assistantParticipant);
222
+ parts.push(assistantPrefix.trimEnd()); // Remove trailing space for cleaner completion
223
+
224
+ // Join all parts into single prompt
225
+ const prompt = parts.join(this.config.messageSeparator);
226
+
227
+ // Build stop sequences from participants
228
+ const stopSequences = this.buildStopSequences(
229
+ participants,
230
+ assistantParticipant,
231
+ maxParticipantsForStop,
232
+ additionalStopSequences
233
+ );
234
+
235
+ // Return as single assistant message with prompt as content
236
+ // The provider adapter will extract this as the prompt
237
+ const providerMessages: ProviderMessage[] = [
238
+ { role: 'assistant', content: prompt },
239
+ ];
240
+
241
+ return {
242
+ messages: providerMessages,
243
+ assistantPrefill: prompt,
244
+ stopSequences,
245
+ };
246
+ }
247
+
248
+ formatToolResults(results: ToolResult[], options?: { thinking?: boolean }): string {
249
+ // Completions mode typically doesn't support tools
250
+ // But format them as simple text if needed
251
+ const parts = results.map(r => {
252
+ const content = typeof r.content === 'string' ? r.content : JSON.stringify(r.content);
253
+ return `[Tool Result: ${content}]`;
254
+ });
255
+ return parts.join('\n');
256
+ }
257
+
258
+ // ==========================================================================
259
+ // RESPONSE PARSING
260
+ // ==========================================================================
261
+
262
+ createStreamParser(): StreamParser {
263
+ return new CompletionsStreamParser();
264
+ }
265
+
266
+ parseToolCalls(content: string): ToolCall[] {
267
+ // Base models don't have structured tool output
268
+ return [];
269
+ }
270
+
271
+ hasToolUse(content: string): boolean {
272
+ // Base models determine completion via stop sequences
273
+ return false;
274
+ }
275
+
276
+ parseContentBlocks(content: string): ContentBlock[] {
277
+ // Trim leading whitespace (model often starts with space after prefix)
278
+ const trimmed = content.replace(/^\s+/, '');
279
+
280
+ if (!trimmed) {
281
+ return [];
282
+ }
283
+
284
+ return [{ type: 'text', text: trimmed }];
285
+ }
286
+
287
+ // ==========================================================================
288
+ // PRIVATE HELPERS
289
+ // ==========================================================================
290
+
291
+ private extractTextContent(content: ContentBlock[]): { text: string; hadImages: boolean } {
292
+ const textParts: string[] = [];
293
+ let hadImages = false;
294
+
295
+ for (const block of content) {
296
+ if (block.type === 'text') {
297
+ textParts.push(block.text);
298
+ } else if (block.type === 'image') {
299
+ hadImages = true;
300
+ }
301
+ // Skip tool_use, tool_result, thinking blocks for base models
302
+ }
303
+
304
+ return {
305
+ text: textParts.join('\n'),
306
+ hadImages,
307
+ };
308
+ }
309
+
310
+ private buildStopSequences(
311
+ participants: Set<string>,
312
+ assistantParticipant: string,
313
+ maxParticipants: number,
314
+ additionalStopSequences?: string[]
315
+ ): string[] {
316
+ const stops: string[] = [];
317
+
318
+ // Get recent participants (excluding assistant)
319
+ let count = 0;
320
+ for (const participant of participants) {
321
+ if (participant === assistantParticipant) continue;
322
+ if (count >= maxParticipants) break;
323
+
324
+ // Add both "\n\nName:" and "\nName:" variants
325
+ const prefix = this.config.nameFormat.replace('{name}', participant).trimEnd();
326
+ stops.push(`\n\n${prefix}`);
327
+ stops.push(`\n${prefix}`);
328
+ count++;
329
+ }
330
+
331
+ // Add EOT token as stop sequence if configured
332
+ if (this.config.eotToken) {
333
+ stops.push(this.config.eotToken);
334
+ }
335
+
336
+ // Add any additional stop sequences
337
+ if (additionalStopSequences?.length) {
338
+ stops.push(...additionalStopSequences);
339
+ }
340
+
341
+ return stops;
342
+ }
343
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Formatter exports
3
+ */
4
+
5
+ // Export formatter-specific types only (avoid duplicates with types/streaming.js)
6
+ export type {
7
+ PrefillFormatter,
8
+ StreamParser,
9
+ FormatterConfig,
10
+ BuildOptions,
11
+ BuildResult,
12
+ ParseResult,
13
+ BlockType,
14
+ ProviderMessage,
15
+ } from './types.js';
16
+
17
+ export { AnthropicXmlFormatter, type AnthropicXmlFormatterConfig } from './anthropic-xml.js';
18
+ export { NativeFormatter, type NativeFormatterConfig } from './native.js';
19
+ export { CompletionsFormatter, type CompletionsFormatterConfig } from './completions.js';
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Native Formatter
3
+ *
4
+ * Pass-through formatter that converts messages to standard user/assistant
5
+ * format without prefill. Uses native API tool calling.
6
+ *
7
+ * Supports two participant modes:
8
+ * - 'simple': Strict two-party (Human/Assistant), no names in content
9
+ * - 'multiuser': Multiple participants, names prefixed to content
10
+ */
11
+
12
+ import type {
13
+ NormalizedMessage,
14
+ ContentBlock,
15
+ ToolDefinition,
16
+ ToolCall,
17
+ ToolResult,
18
+ } from '../types/index.js';
19
+ import type {
20
+ PrefillFormatter,
21
+ StreamParser,
22
+ BuildOptions,
23
+ BuildResult,
24
+ FormatterConfig,
25
+ ProviderMessage,
26
+ ParseResult,
27
+ BlockType,
28
+ } from './types.js';
29
+
30
+ // ============================================================================
31
+ // Configuration
32
+ // ============================================================================
33
+
34
+ export interface NativeFormatterConfig extends FormatterConfig {
35
+ /**
36
+ * Format for participant name prefix in multiuser mode.
37
+ * Use {name} as placeholder. Default: '{name}: '
38
+ */
39
+ nameFormat?: string;
40
+ }
41
+
42
+ // ============================================================================
43
+ // Pass-through Stream Parser
44
+ // ============================================================================
45
+
46
+ /**
47
+ * Simple pass-through parser that doesn't do XML tracking.
48
+ * Just accumulates content and emits text chunks.
49
+ */
50
+ class PassthroughParser implements StreamParser {
51
+ private accumulated = '';
52
+ private blockIndex = 0;
53
+
54
+ processChunk(chunk: string): ParseResult {
55
+ this.accumulated += chunk;
56
+ const meta = {
57
+ type: 'text' as const,
58
+ visible: true,
59
+ blockIndex: this.blockIndex,
60
+ };
61
+ return {
62
+ emissions: [{
63
+ kind: 'content' as const,
64
+ text: chunk,
65
+ meta,
66
+ }],
67
+ content: [{ text: chunk, meta }],
68
+ blockEvents: [],
69
+ };
70
+ }
71
+
72
+ flush(): ParseResult {
73
+ return { emissions: [], content: [], blockEvents: [] };
74
+ }
75
+
76
+ getAccumulated(): string {
77
+ return this.accumulated;
78
+ }
79
+
80
+ reset(): void {
81
+ this.accumulated = '';
82
+ this.blockIndex = 0;
83
+ }
84
+
85
+ push(content: string): void {
86
+ this.accumulated += content;
87
+ }
88
+
89
+ getCurrentBlockType(): BlockType {
90
+ return 'text';
91
+ }
92
+
93
+ getBlockIndex(): number {
94
+ return this.blockIndex;
95
+ }
96
+
97
+ incrementBlockIndex(): void {
98
+ this.blockIndex++;
99
+ }
100
+
101
+ isInsideBlock(): boolean {
102
+ // Pass-through mode never has nested blocks
103
+ return false;
104
+ }
105
+
106
+ resetForNewIteration(): void {
107
+ // No special reset needed for pass-through mode
108
+ }
109
+ }
110
+
111
+ // ============================================================================
112
+ // Native Formatter
113
+ // ============================================================================
114
+
115
+ export class NativeFormatter implements PrefillFormatter {
116
+ readonly name = 'native';
117
+ readonly usesPrefill = false;
118
+
119
+ private config: Required<NativeFormatterConfig>;
120
+
121
+ constructor(config: NativeFormatterConfig = {}) {
122
+ this.config = {
123
+ nameFormat: config.nameFormat ?? '{name}: ',
124
+ unsupportedMedia: config.unsupportedMedia ?? 'error',
125
+ warnOnStrip: config.warnOnStrip ?? true,
126
+ };
127
+ }
128
+
129
+ // ==========================================================================
130
+ // REQUEST BUILDING
131
+ // ==========================================================================
132
+
133
+ buildMessages(messages: NormalizedMessage[], options: BuildOptions): BuildResult {
134
+ const {
135
+ participantMode,
136
+ assistantParticipant,
137
+ humanParticipant,
138
+ tools,
139
+ systemPrompt,
140
+ } = options;
141
+
142
+ const providerMessages: ProviderMessage[] = [];
143
+
144
+ // Validate simple mode participants
145
+ if (participantMode === 'simple' && !humanParticipant) {
146
+ throw new Error('NativeFormatter in simple mode requires humanParticipant option');
147
+ }
148
+
149
+ for (const message of messages) {
150
+ // Determine role
151
+ const isAssistant = message.participant === assistantParticipant;
152
+
153
+ // Validate participant in simple mode
154
+ if (participantMode === 'simple') {
155
+ if (!isAssistant && message.participant !== humanParticipant) {
156
+ throw new Error(
157
+ `NativeFormatter in simple mode only supports "${humanParticipant}" and "${assistantParticipant}". ` +
158
+ `Got: "${message.participant}". Use participantMode: 'multiuser' for multiple participants.`
159
+ );
160
+ }
161
+ }
162
+
163
+ const role: 'user' | 'assistant' = isAssistant ? 'assistant' : 'user';
164
+
165
+ // Convert content
166
+ const content = this.convertContent(message.content, message.participant, {
167
+ includeNames: participantMode === 'multiuser' && !isAssistant,
168
+ });
169
+
170
+ if (content.length === 0) {
171
+ continue; // Skip empty messages
172
+ }
173
+
174
+ providerMessages.push({ role, content });
175
+ }
176
+
177
+ // Merge consecutive same-role messages (API requires alternating)
178
+ const mergedMessages = this.mergeConsecutiveRoles(providerMessages);
179
+
180
+ // Build system content
181
+ let systemContent: unknown;
182
+ if (typeof systemPrompt === 'string') {
183
+ systemContent = systemPrompt;
184
+ } else if (Array.isArray(systemPrompt)) {
185
+ systemContent = systemPrompt;
186
+ }
187
+
188
+ // Native tools
189
+ const nativeTools = tools?.length ? this.convertToNativeTools(tools) : undefined;
190
+
191
+ return {
192
+ messages: mergedMessages,
193
+ systemContent,
194
+ stopSequences: [], // Native mode doesn't use custom stop sequences
195
+ nativeTools,
196
+ };
197
+ }
198
+
199
+ formatToolResults(results: ToolResult[]): string {
200
+ // Native mode uses API tool_result blocks, not string formatting
201
+ // This method is mainly for prefill modes
202
+ return JSON.stringify(results.map(r => ({
203
+ tool_use_id: r.toolUseId,
204
+ content: r.content,
205
+ is_error: r.isError,
206
+ })));
207
+ }
208
+
209
+ // ==========================================================================
210
+ // RESPONSE PARSING
211
+ // ==========================================================================
212
+
213
+ createStreamParser(): StreamParser {
214
+ return new PassthroughParser();
215
+ }
216
+
217
+ parseToolCalls(content: string): ToolCall[] {
218
+ // Native mode gets tool calls from API response, not from content parsing
219
+ // Return empty - tool calls come through the native API response
220
+ return [];
221
+ }
222
+
223
+ hasToolUse(content: string): boolean {
224
+ // Native mode determines tool use from API stop_reason, not content
225
+ return false;
226
+ }
227
+
228
+ parseContentBlocks(content: string): ContentBlock[] {
229
+ // Native mode - content is plain text
230
+ if (!content.trim()) {
231
+ return [];
232
+ }
233
+ return [{ type: 'text', text: content }];
234
+ }
235
+
236
+ // ==========================================================================
237
+ // PRIVATE HELPERS
238
+ // ==========================================================================
239
+
240
+ private convertContent(
241
+ content: ContentBlock[],
242
+ participant: string,
243
+ options: { includeNames: boolean }
244
+ ): unknown[] {
245
+ const result: unknown[] = [];
246
+ let hasUnsupportedMedia = false;
247
+
248
+ for (const block of content) {
249
+ if (block.type === 'text') {
250
+ let text = block.text;
251
+ if (options.includeNames) {
252
+ const prefix = this.config.nameFormat.replace('{name}', participant);
253
+ text = prefix + text;
254
+ }
255
+ const textBlock: Record<string, unknown> = { type: 'text', text };
256
+ if (block.cache_control) {
257
+ textBlock.cache_control = block.cache_control;
258
+ }
259
+ result.push(textBlock);
260
+ } else if (block.type === 'image') {
261
+ if (block.source.type === 'base64') {
262
+ result.push({
263
+ type: 'image',
264
+ source: {
265
+ type: 'base64',
266
+ media_type: block.source.mediaType,
267
+ data: block.source.data,
268
+ },
269
+ });
270
+ }
271
+ } else if (block.type === 'tool_use') {
272
+ result.push({
273
+ type: 'tool_use',
274
+ id: block.id,
275
+ name: block.name,
276
+ input: block.input,
277
+ });
278
+ } else if (block.type === 'tool_result') {
279
+ result.push({
280
+ type: 'tool_result',
281
+ tool_use_id: block.toolUseId,
282
+ content: block.content,
283
+ is_error: block.isError,
284
+ });
285
+ } else if (block.type === 'thinking') {
286
+ result.push({
287
+ type: 'thinking',
288
+ thinking: block.thinking,
289
+ });
290
+ } else if (block.type === 'document' || block.type === 'audio') {
291
+ hasUnsupportedMedia = true;
292
+ }
293
+ }
294
+
295
+ if (hasUnsupportedMedia) {
296
+ if (this.config.unsupportedMedia === 'error') {
297
+ throw new Error(`NativeFormatter: unsupported media type in content. Configure unsupportedMedia: 'strip' to ignore.`);
298
+ } else if (this.config.warnOnStrip) {
299
+ console.warn(`[NativeFormatter] Stripped unsupported media from message`);
300
+ }
301
+ }
302
+
303
+ return result;
304
+ }
305
+
306
+ private mergeConsecutiveRoles(messages: ProviderMessage[]): ProviderMessage[] {
307
+ if (messages.length === 0) return [];
308
+
309
+ const merged: ProviderMessage[] = [];
310
+ let current: ProviderMessage = messages[0]!;
311
+
312
+ for (let i = 1; i < messages.length; i++) {
313
+ const next: ProviderMessage = messages[i]!;
314
+
315
+ if (next.role === current.role) {
316
+ // Merge content arrays
317
+ const currentContent = Array.isArray(current.content) ? current.content : [current.content];
318
+ const nextContent = Array.isArray(next.content) ? next.content : [next.content];
319
+ current = {
320
+ role: current.role,
321
+ content: [...currentContent, ...nextContent],
322
+ };
323
+ } else {
324
+ merged.push(current);
325
+ current = next;
326
+ }
327
+ }
328
+
329
+ merged.push(current);
330
+ return merged;
331
+ }
332
+
333
+ private convertToNativeTools(tools: ToolDefinition[]): unknown[] {
334
+ return tools.map(tool => ({
335
+ name: tool.name,
336
+ description: tool.description,
337
+ input_schema: tool.inputSchema,
338
+ }));
339
+ }
340
+ }