@librechat/agents 3.2.64 → 3.2.66

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 (106) hide show
  1. package/dist/cjs/graphs/Graph.cjs +18 -2
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/instrumentation.cjs +15 -3
  4. package/dist/cjs/instrumentation.cjs.map +1 -1
  5. package/dist/cjs/langfuseToolOutputTracing.cjs +1 -2
  6. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  7. package/dist/cjs/langfuseTraceShaping.cjs +51 -24
  8. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  9. package/dist/cjs/llm/anthropic/index.cjs +73 -9
  10. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  11. package/dist/cjs/llm/anthropic/types.cjs.map +1 -1
  12. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +49 -9
  13. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  14. package/dist/cjs/llm/anthropic/utils/message_outputs.cjs +3 -1
  15. package/dist/cjs/llm/anthropic/utils/message_outputs.cjs.map +1 -1
  16. package/dist/cjs/llm/anthropic/utils/stream_events.cjs +337 -0
  17. package/dist/cjs/llm/anthropic/utils/stream_events.cjs.map +1 -0
  18. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +8 -0
  19. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  20. package/dist/cjs/tools/BashExecutor.cjs +9 -8
  21. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  22. package/dist/cjs/tools/CodeExecutor.cjs +9 -7
  23. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  24. package/dist/cjs/tools/search/crw-scraper.cjs +165 -0
  25. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -0
  26. package/dist/cjs/tools/search/crw-search.cjs +105 -0
  27. package/dist/cjs/tools/search/crw-search.cjs.map +1 -0
  28. package/dist/cjs/tools/search/search.cjs +4 -2
  29. package/dist/cjs/tools/search/search.cjs.map +1 -1
  30. package/dist/cjs/tools/search/tool.cjs +15 -3
  31. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  32. package/dist/esm/graphs/Graph.mjs +18 -2
  33. package/dist/esm/graphs/Graph.mjs.map +1 -1
  34. package/dist/esm/instrumentation.mjs +15 -3
  35. package/dist/esm/instrumentation.mjs.map +1 -1
  36. package/dist/esm/langfuseToolOutputTracing.mjs +1 -2
  37. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  38. package/dist/esm/langfuseTraceShaping.mjs +51 -24
  39. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  40. package/dist/esm/llm/anthropic/index.mjs +73 -9
  41. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  42. package/dist/esm/llm/anthropic/types.mjs.map +1 -1
  43. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +49 -9
  44. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  45. package/dist/esm/llm/anthropic/utils/message_outputs.mjs +3 -2
  46. package/dist/esm/llm/anthropic/utils/message_outputs.mjs.map +1 -1
  47. package/dist/esm/llm/anthropic/utils/stream_events.mjs +337 -0
  48. package/dist/esm/llm/anthropic/utils/stream_events.mjs.map +1 -0
  49. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +8 -0
  50. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  51. package/dist/esm/tools/BashExecutor.mjs +9 -8
  52. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  53. package/dist/esm/tools/CodeExecutor.mjs +9 -7
  54. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  55. package/dist/esm/tools/search/crw-scraper.mjs +163 -0
  56. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -0
  57. package/dist/esm/tools/search/crw-search.mjs +103 -0
  58. package/dist/esm/tools/search/crw-search.mjs.map +1 -0
  59. package/dist/esm/tools/search/search.mjs +4 -2
  60. package/dist/esm/tools/search/search.mjs.map +1 -1
  61. package/dist/esm/tools/search/tool.mjs +15 -3
  62. package/dist/esm/tools/search/tool.mjs.map +1 -1
  63. package/dist/types/graphs/Graph.d.ts +2 -0
  64. package/dist/types/langfuseTraceShaping.d.ts +12 -6
  65. package/dist/types/llm/anthropic/index.d.ts +2 -0
  66. package/dist/types/llm/anthropic/types.d.ts +2 -0
  67. package/dist/types/llm/anthropic/utils/message_outputs.d.ts +1 -2
  68. package/dist/types/llm/anthropic/utils/stream_events.d.ts +25 -0
  69. package/dist/types/tools/BashExecutor.d.ts +7 -6
  70. package/dist/types/tools/CodeExecutor.d.ts +7 -5
  71. package/dist/types/tools/search/crw-scraper.d.ts +41 -0
  72. package/dist/types/tools/search/crw-search.d.ts +4 -0
  73. package/dist/types/tools/search/types.d.ts +88 -3
  74. package/dist/types/types/graph.d.ts +10 -3
  75. package/package.json +1 -1
  76. package/src/graphs/Graph.ts +36 -3
  77. package/src/instrumentation.ts +20 -0
  78. package/src/langfuseToolOutputTracing.ts +2 -4
  79. package/src/langfuseTraceShaping.ts +73 -20
  80. package/src/llm/anthropic/index.ts +134 -10
  81. package/src/llm/anthropic/inherited-content-utils.spec.ts +10 -5
  82. package/src/llm/anthropic/inherited-stream-events.spec.ts +513 -9
  83. package/src/llm/anthropic/llm.spec.ts +100 -16
  84. package/src/llm/anthropic/types.ts +3 -0
  85. package/src/llm/anthropic/utils/cross-provider-server-tools.test.ts +110 -0
  86. package/src/llm/anthropic/utils/message_inputs.ts +75 -3
  87. package/src/llm/anthropic/utils/message_outputs.ts +10 -2
  88. package/src/llm/anthropic/utils/stream_events.ts +471 -0
  89. package/src/llm/bedrock/utils/cross-provider-server-tools.test.ts +122 -0
  90. package/src/llm/bedrock/utils/message_inputs.ts +13 -0
  91. package/src/specs/langfuse-instrumentation.test.ts +64 -0
  92. package/src/specs/langfuse-routing.integration.test.ts +49 -4
  93. package/src/specs/langfuse-tool-output-tracing.test.ts +5 -4
  94. package/src/specs/langfuse-trace-shaping.test.ts +80 -9
  95. package/src/specs/subagent.test.ts +180 -0
  96. package/src/tools/BashExecutor.ts +9 -8
  97. package/src/tools/CodeExecutor.ts +9 -7
  98. package/src/tools/__tests__/BashExecutor.test.ts +16 -5
  99. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +17 -6
  100. package/src/tools/search/crw-scraper.ts +244 -0
  101. package/src/tools/search/crw-search.ts +167 -0
  102. package/src/tools/search/crw.test.ts +836 -0
  103. package/src/tools/search/search.ts +7 -1
  104. package/src/tools/search/tool.ts +23 -3
  105. package/src/tools/search/types.ts +103 -3
  106. package/src/types/graph.ts +10 -3
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Converts a raw Anthropic SSE event stream into LangChain ChatModelStreamEvents.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import type {
8
+ ChatModelStreamEvent,
9
+ ContentBlockDelta,
10
+ FinishReason,
11
+ } from '@langchain/core/language_models/event';
12
+ import type { ContentBlock, UsageMetadata } from '@langchain/core/messages';
13
+ import type {
14
+ AnthropicCompactionBlock,
15
+ AnthropicCompactionContentBlockDelta,
16
+ AnthropicMessageStreamEvent,
17
+ } from '../types';
18
+ import type { AnthropicUsageData } from './message_outputs';
19
+ import { getAnthropicUsageMetadata } from './message_outputs';
20
+
21
+ type AnthropicContentBlock =
22
+ | Extract<
23
+ AnthropicMessageStreamEvent,
24
+ { type: 'content_block_start' }
25
+ >['content_block']
26
+ | AnthropicCompactionBlock;
27
+
28
+ type AnthropicContentDelta =
29
+ | Extract<
30
+ AnthropicMessageStreamEvent,
31
+ { type: 'content_block_delta' }
32
+ >['delta']
33
+ | AnthropicCompactionContentBlockDelta;
34
+
35
+ interface AnthropicStreamErrorEvent {
36
+ type: 'error';
37
+ error: {
38
+ type: string;
39
+ message: string;
40
+ };
41
+ request_id?: string | null;
42
+ }
43
+
44
+ type AnthropicStreamInputEvent =
45
+ | AnthropicMessageStreamEvent
46
+ | AnthropicStreamErrorEvent;
47
+
48
+ type BlockAccumulator = Record<string, unknown>;
49
+
50
+ interface AnthropicEventUsage {
51
+ inputTokens: number;
52
+ cacheCreationInputTokens: number;
53
+ cacheReadInputTokens: number;
54
+ outputTokens: number;
55
+ }
56
+
57
+ // ─── Public API ─────────────────────────────────────────────────
58
+
59
+ export interface ConvertAnthropicStreamOptions {
60
+ streamUsage?: boolean;
61
+ }
62
+
63
+ /**
64
+ * Convert an async iterable of raw Anthropic stream events into
65
+ * LangChain `ChatModelStreamEvent`s with typed deltas.
66
+ */
67
+ export async function* convertAnthropicStream(
68
+ source: AsyncIterable<AnthropicStreamInputEvent>,
69
+ options: ConvertAnthropicStreamOptions = {}
70
+ ): AsyncGenerator<ChatModelStreamEvent> {
71
+ const shouldStreamUsage = options.streamUsage ?? true;
72
+
73
+ // Track accumulated state per content block (for finalization)
74
+ const blockAccumulators = new Map<number, BlockAccumulator>();
75
+ let usageSnapshot: UsageMetadata | undefined;
76
+ let eventUsage: AnthropicEventUsage | undefined;
77
+ let stopReason: string | null = null;
78
+
79
+ for await (const data of source) {
80
+ switch (data.type) {
81
+ // ── Message lifecycle ──────────────────────────────────
82
+ case 'message_start': {
83
+ const { usage, id, model } = data.message;
84
+ if (shouldStreamUsage) {
85
+ eventUsage = {
86
+ inputTokens: usage.input_tokens,
87
+ cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0,
88
+ cacheReadInputTokens: usage.cache_read_input_tokens ?? 0,
89
+ outputTokens: usage.output_tokens,
90
+ };
91
+ usageSnapshot = buildUsageSnapshot(eventUsage);
92
+ }
93
+ yield {
94
+ event: 'message-start' as const,
95
+ id,
96
+ ...(usageSnapshot ? { usage: usageSnapshot } : {}),
97
+ };
98
+ yield {
99
+ event: 'provider' as const,
100
+ provider: 'anthropic',
101
+ name: 'message_start',
102
+ payload: { model, id },
103
+ };
104
+ break;
105
+ }
106
+
107
+ case 'message_delta': {
108
+ stopReason = data.delta.stop_reason;
109
+ if (shouldStreamUsage) {
110
+ eventUsage = updateEventUsage(eventUsage, data.usage);
111
+ usageSnapshot = buildUsageSnapshot(eventUsage);
112
+ yield { event: 'usage' as const, usage: usageSnapshot };
113
+ }
114
+ if (
115
+ 'context_management' in data.delta &&
116
+ data.delta.context_management != null
117
+ ) {
118
+ yield {
119
+ event: 'provider' as const,
120
+ provider: 'anthropic',
121
+ name: 'context_management',
122
+ payload: data.delta.context_management,
123
+ };
124
+ }
125
+ break;
126
+ }
127
+
128
+ case 'message_stop': {
129
+ const finishEvent = {
130
+ event: 'message-finish' as const,
131
+ reason: mapStopReason(stopReason),
132
+ ...(usageSnapshot ? { usage: usageSnapshot } : {}),
133
+ responseMetadata: { model_provider: 'anthropic' },
134
+ };
135
+ yield finishEvent;
136
+ break;
137
+ }
138
+
139
+ case 'error': {
140
+ const streamError = new Error(data.error.message);
141
+ streamError.name = data.error.type;
142
+ throw streamError;
143
+ }
144
+
145
+ // ── Content block lifecycle ───────────────────────────
146
+ case 'content_block_start': {
147
+ const { index, content_block } = data;
148
+ const mapped = mapBlockToContentBlock(content_block, index);
149
+ blockAccumulators.set(index, { ...mapped });
150
+ yield {
151
+ event: 'content-block-start' as const,
152
+ index,
153
+ content: mapped as unknown as ContentBlock,
154
+ };
155
+ break;
156
+ }
157
+
158
+ case 'content_block_delta': {
159
+ const { index, delta } = data;
160
+ const acc = blockAccumulators.get(index);
161
+ if (!acc) break;
162
+
163
+ const { contentDelta, accumulated } = applyAnthropicDelta(acc, delta);
164
+ blockAccumulators.set(index, accumulated);
165
+
166
+ yield {
167
+ event: 'content-block-delta' as const,
168
+ index,
169
+ delta: contentDelta,
170
+ };
171
+ break;
172
+ }
173
+
174
+ case 'content_block_stop': {
175
+ const { index } = data;
176
+ const acc = blockAccumulators.get(index);
177
+ if (!acc) break;
178
+
179
+ const finalized = finalizeBlock(acc);
180
+ yield {
181
+ event: 'content-block-finish' as const,
182
+ index,
183
+ content: finalized,
184
+ };
185
+ blockAccumulators.delete(index);
186
+ break;
187
+ }
188
+
189
+ // ── Unhandled → provider passthrough ───────────────────
190
+ default: {
191
+ const providerData = data as AnthropicMessageStreamEvent;
192
+ yield {
193
+ event: 'provider' as const,
194
+ provider: 'anthropic',
195
+ name: providerData.type,
196
+ payload: providerData,
197
+ };
198
+ break;
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ // ─── Internal helpers ───────────────────────────────────────────
205
+
206
+ function mapStopReason(stopReason: string | null | undefined): FinishReason {
207
+ switch (stopReason) {
208
+ case 'end_turn':
209
+ case 'stop_sequence':
210
+ return 'stop';
211
+ case 'tool_use':
212
+ return 'tool_use';
213
+ case 'max_tokens':
214
+ case 'model_context_window_exceeded':
215
+ return 'length';
216
+ case 'refusal':
217
+ return 'content_filter';
218
+ default:
219
+ return 'stop';
220
+ }
221
+ }
222
+
223
+ function updateEventUsage(
224
+ previous: AnthropicEventUsage | undefined,
225
+ current: AnthropicUsageData
226
+ ): AnthropicEventUsage {
227
+ return {
228
+ inputTokens: getCumulativeUsageValue(
229
+ current.input_tokens,
230
+ previous?.inputTokens
231
+ ),
232
+ cacheCreationInputTokens: getCumulativeUsageValue(
233
+ current.cache_creation_input_tokens,
234
+ previous?.cacheCreationInputTokens
235
+ ),
236
+ cacheReadInputTokens: getCumulativeUsageValue(
237
+ current.cache_read_input_tokens,
238
+ previous?.cacheReadInputTokens
239
+ ),
240
+ outputTokens: getCumulativeUsageValue(
241
+ current.output_tokens,
242
+ previous?.outputTokens
243
+ ),
244
+ };
245
+ }
246
+
247
+ function getCumulativeUsageValue(
248
+ current: number | null | undefined,
249
+ previous: number | undefined
250
+ ): number {
251
+ return Math.max(previous ?? 0, current ?? 0);
252
+ }
253
+
254
+ function buildUsageSnapshot(usage: AnthropicEventUsage): UsageMetadata {
255
+ const metadata = getAnthropicUsageMetadata({
256
+ input_tokens: usage.inputTokens,
257
+ output_tokens: usage.outputTokens,
258
+ cache_creation_input_tokens: usage.cacheCreationInputTokens,
259
+ cache_read_input_tokens: usage.cacheReadInputTokens,
260
+ });
261
+ if (metadata == null) {
262
+ throw new Error('Anthropic usage metadata was not created');
263
+ }
264
+ return metadata;
265
+ }
266
+
267
+ function getStringField(record: BlockAccumulator, key: string): string {
268
+ const value = record[key];
269
+ return typeof value === 'string' ? value : '';
270
+ }
271
+
272
+ function getArrayField(record: BlockAccumulator, key: string): unknown[] {
273
+ const value = record[key];
274
+ return Array.isArray(value) ? value : [];
275
+ }
276
+
277
+ function mapBlockToContentBlock(
278
+ block: AnthropicContentBlock,
279
+ index: number
280
+ ): BlockAccumulator {
281
+ switch (block.type) {
282
+ case 'text':
283
+ return {
284
+ type: 'text' as const,
285
+ text: block.text,
286
+ ...(block.citations != null ? { citations: block.citations } : {}),
287
+ index,
288
+ };
289
+ case 'thinking':
290
+ return {
291
+ type: 'reasoning' as const,
292
+ reasoning: block.thinking,
293
+ index,
294
+ };
295
+ case 'redacted_thinking':
296
+ return { ...block, index };
297
+ case 'tool_use':
298
+ return {
299
+ type: 'tool_call_chunk' as const,
300
+ id: block.id,
301
+ name: block.name,
302
+ args: '',
303
+ index,
304
+ };
305
+ case 'server_tool_use':
306
+ return {
307
+ type: 'server_tool_call_chunk' as const,
308
+ id: block.id,
309
+ name: block.name,
310
+ args: '',
311
+ index,
312
+ };
313
+ case 'web_search_tool_result':
314
+ return { ...block, index };
315
+ case 'compaction':
316
+ return { ...block, index };
317
+ default:
318
+ return { type: 'non_standard' as const, value: { ...block }, index };
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Map an Anthropic content_block_delta to a content block delta
324
+ * and update the accumulated state.
325
+ */
326
+ function applyAnthropicDelta(
327
+ accumulated: BlockAccumulator,
328
+ delta: AnthropicContentDelta
329
+ ): {
330
+ contentDelta: ContentBlockDelta;
331
+ accumulated: BlockAccumulator;
332
+ } {
333
+ const rawDelta = delta as unknown as Record<string, unknown>;
334
+ switch (delta.type) {
335
+ case 'text_delta':
336
+ return {
337
+ contentDelta: { type: 'text-delta' as const, text: delta.text },
338
+ accumulated: {
339
+ ...accumulated,
340
+ text: getStringField(accumulated, 'text') + delta.text,
341
+ },
342
+ };
343
+
344
+ case 'thinking_delta':
345
+ return {
346
+ contentDelta: {
347
+ type: 'reasoning-delta' as const,
348
+ reasoning: delta.thinking,
349
+ },
350
+ accumulated: {
351
+ ...accumulated,
352
+ reasoning: getStringField(accumulated, 'reasoning') + delta.thinking,
353
+ },
354
+ };
355
+
356
+ case 'input_json_delta': {
357
+ const newArgs = getStringField(accumulated, 'args') + delta.partial_json;
358
+ return {
359
+ contentDelta: {
360
+ type: 'block-delta' as const,
361
+ fields: {
362
+ type: getStringField(accumulated, 'type'),
363
+ args: newArgs,
364
+ },
365
+ },
366
+ accumulated: { ...accumulated, args: newArgs },
367
+ };
368
+ }
369
+
370
+ case 'citations_delta': {
371
+ const citations = [
372
+ ...getArrayField(accumulated, 'citations'),
373
+ delta.citation,
374
+ ];
375
+ return {
376
+ contentDelta: {
377
+ type: 'block-delta' as const,
378
+ fields: {
379
+ type: getStringField(accumulated, 'type'),
380
+ citations,
381
+ },
382
+ },
383
+ accumulated: {
384
+ ...accumulated,
385
+ citations,
386
+ },
387
+ };
388
+ }
389
+
390
+ case 'signature_delta':
391
+ return {
392
+ contentDelta: {
393
+ type: 'block-delta' as const,
394
+ fields: {
395
+ type: getStringField(accumulated, 'type'),
396
+ signature: delta.signature,
397
+ },
398
+ },
399
+ accumulated: { ...accumulated, signature: delta.signature },
400
+ };
401
+
402
+ case 'compaction_delta': {
403
+ const previousContent = accumulated.content;
404
+ const content =
405
+ delta.content == null
406
+ ? (previousContent ?? null)
407
+ : getStringField(accumulated, 'content') + delta.content;
408
+ return {
409
+ contentDelta: {
410
+ type: 'block-delta' as const,
411
+ fields: {
412
+ type: 'compaction',
413
+ content,
414
+ encrypted_content: delta.encrypted_content,
415
+ },
416
+ },
417
+ accumulated: {
418
+ ...accumulated,
419
+ content,
420
+ encrypted_content: delta.encrypted_content,
421
+ },
422
+ };
423
+ }
424
+
425
+ default:
426
+ return {
427
+ contentDelta: {
428
+ type: 'block-delta' as const,
429
+ fields: {
430
+ type: getStringField(accumulated, 'type'),
431
+ ...rawDelta,
432
+ },
433
+ },
434
+ accumulated,
435
+ };
436
+ }
437
+ }
438
+
439
+ function finalizeBlock(accumulated: BlockAccumulator): ContentBlock {
440
+ if (
441
+ accumulated.type === 'tool_call_chunk' ||
442
+ accumulated.type === 'server_tool_call_chunk'
443
+ ) {
444
+ const finalType =
445
+ accumulated.type === 'tool_call_chunk'
446
+ ? ('tool_call' as const)
447
+ : ('server_tool_call' as const);
448
+ const args = getStringField(accumulated, 'args');
449
+ let parsedArgs: unknown;
450
+ try {
451
+ parsedArgs = JSON.parse(args || '{}');
452
+ } catch {
453
+ return {
454
+ type: 'invalid_tool_call' as const,
455
+ id: getStringField(accumulated, 'id'),
456
+ name: getStringField(accumulated, 'name'),
457
+ args,
458
+ error: 'Failed to parse tool call arguments as JSON',
459
+ } as ContentBlock.Tools.InvalidToolCall;
460
+ }
461
+ return {
462
+ type: finalType,
463
+ id: getStringField(accumulated, 'id'),
464
+ name: getStringField(accumulated, 'name'),
465
+ args: parsedArgs,
466
+ } as ContentBlock;
467
+ }
468
+
469
+ const { index: _index, ...rest } = accumulated;
470
+ return rest as ContentBlock;
471
+ }
@@ -0,0 +1,122 @@
1
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
2
+ import type { BaseMessage } from '@langchain/core/messages';
3
+ import { convertToConverseMessages } from './message_inputs';
4
+
5
+ /**
6
+ * Regression for cross-provider agent handoffs (Google → Bedrock): a Gemini
7
+ * turn that used a server-side tool (URL context, Google Search) leaves
8
+ * `toolCall`/`toolResponse` content blocks in history. The Bedrock Converse
9
+ * converter has no branch for them and previously threw
10
+ * "Unsupported content block type: toolCall", crashing the handoff. Only
11
+ * Google can execute these blocks or validate their thought signatures, so
12
+ * they are dropped on assistant turns; any other unknown block still throws.
13
+ */
14
+ type ConverseResult = ReturnType<typeof convertToConverseMessages>;
15
+
16
+ /** Minimal view of a converted Bedrock Converse content block the assertions read. */
17
+ interface ConverseBlock {
18
+ text?: string;
19
+ toolUse?: {
20
+ toolUseId?: string;
21
+ name?: string;
22
+ input?: Record<string, string>;
23
+ };
24
+ }
25
+
26
+ const assistantContent = (result: ConverseResult): ConverseBlock[] => {
27
+ const msg = result.converseMessages.find((m) => m.role === 'assistant');
28
+ return (msg?.content ?? []) as ConverseBlock[];
29
+ };
30
+
31
+ describe('convertToConverseMessages — Google server-side tool blocks (Google → Bedrock)', () => {
32
+ it('drops toolCall/toolResponse on an assistant turn, keeping text and tool calls', () => {
33
+ const messages: BaseMessage[] = [
34
+ new HumanMessage('summarize this article'),
35
+ new AIMessage({
36
+ content: [
37
+ {
38
+ type: 'toolCall',
39
+ thoughtSignature: 'google-signature-not-valid-for-bedrock',
40
+ toolCall: {
41
+ toolType: 'URL_CONTEXT',
42
+ args: { urls: ['https://example.com/report'] },
43
+ id: 'j7pfyr6k',
44
+ },
45
+ },
46
+ {
47
+ type: 'toolResponse',
48
+ toolResponse: {
49
+ toolType: 'URL_CONTEXT',
50
+ id: 'j7pfyr6k',
51
+ result: { status: 'SUCCESS' },
52
+ },
53
+ },
54
+ {
55
+ type: 'text',
56
+ text: 'The article argues for dollar-cost averaging.',
57
+ },
58
+ ],
59
+ tool_calls: [
60
+ {
61
+ id: 'call_client_tool',
62
+ name: 'save_note',
63
+ args: { note: 'DCA summary' },
64
+ type: 'tool_call',
65
+ },
66
+ ],
67
+ }),
68
+ ];
69
+
70
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
71
+ const content = assistantContent(convertToConverseMessages(messages));
72
+
73
+ const serialized = JSON.stringify(content);
74
+ expect(serialized).not.toContain('URL_CONTEXT');
75
+ expect(serialized).not.toContain('google-signature-not-valid-for-bedrock');
76
+
77
+ expect(
78
+ content.some(
79
+ (b) => b.text === 'The article argues for dollar-cost averaging.'
80
+ )
81
+ ).toBe(true);
82
+ const toolUse = content.find((b) => b.toolUse != null);
83
+ expect(toolUse?.toolUse).toMatchObject({
84
+ toolUseId: 'call_client_tool',
85
+ name: 'save_note',
86
+ input: { note: 'DCA summary' },
87
+ });
88
+ });
89
+
90
+ it('emits a placeholder (not empty content) when a server-tool-only turn is fully dropped', () => {
91
+ const messages: BaseMessage[] = [
92
+ new HumanMessage('hi'),
93
+ new AIMessage({
94
+ content: [
95
+ {
96
+ type: 'toolCall',
97
+ toolCall: { toolType: 'URL_CONTEXT', args: { urls: [] }, id: 'x1' },
98
+ },
99
+ ],
100
+ }),
101
+ ];
102
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
103
+ const content = assistantContent(convertToConverseMessages(messages));
104
+ expect(content.length).toBeGreaterThan(0);
105
+ expect(content.every((b) => typeof b.text === 'string')).toBe(true);
106
+ });
107
+
108
+ it('still throws on a genuinely unknown assistant block', () => {
109
+ const messages: BaseMessage[] = [
110
+ new HumanMessage('run code'),
111
+ new AIMessage({
112
+ content: [
113
+ { type: 'some_future_block_type', foo: 'bar' },
114
+ { type: 'text', text: 'done' },
115
+ ],
116
+ }),
117
+ ];
118
+ expect(() => convertToConverseMessages(messages)).toThrow(
119
+ 'Unsupported content block type'
120
+ );
121
+ });
122
+ });
@@ -41,6 +41,14 @@ const FOREIGN_REASONING_TYPES = [
41
41
  'think',
42
42
  ];
43
43
 
44
+ /**
45
+ * Google server-side tool blocks (`toolCall`/`toolResponse` parts from e.g.
46
+ * URL context or Google Search). Only Google can execute these and validate
47
+ * their thought signatures, so they are dropped on a cross-provider handoff
48
+ * (e.g. Google → Bedrock); the assistant's answer text is kept.
49
+ */
50
+ const FOREIGN_SERVER_TOOL_TYPES = ['toolCall', 'toolResponse'];
51
+
44
52
  /**
45
53
  * Bedrock Converse rejects assistant messages with no content blocks. When
46
54
  * filtering (e.g. dropping foreign reasoning) empties an assistant turn that
@@ -727,6 +735,11 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
727
735
  // than crash. The Bedrock model produces its own reasoning. Anything
728
736
  // else unknown still throws below — real content must be surfaced.
729
737
  return;
738
+ } else if (FOREIGN_SERVER_TOOL_TYPES.some((t) => t === block.type)) {
739
+ // Google server-side tool call/response (e.g. URL context) — only
740
+ // Google can execute it or validate its thought signature; drop it
741
+ // on a cross-provider handoff rather than crash.
742
+ return;
730
743
  } else {
731
744
  const blockValues = Object.fromEntries(
732
745
  Object.entries(block).filter(([key]) => key !== 'type')
@@ -184,6 +184,70 @@ describe('Langfuse instrumentation', () => {
184
184
  expect(mockBasicTracerProvider).toHaveBeenCalledTimes(1);
185
185
  });
186
186
 
187
+ it('resolves environment from LANGFUSE_TRACING_ENVIRONMENT', async () => {
188
+ process.env.LANGFUSE_TRACING_ENVIRONMENT = 'staging';
189
+
190
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
191
+ initializeLangfuseTracing({
192
+ publicKey: 'pk-config',
193
+ secretKey: 'sk-config',
194
+ baseUrl: 'https://langfuse.config',
195
+ });
196
+
197
+ expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
198
+ expect.objectContaining({ environment: 'staging' })
199
+ );
200
+ });
201
+
202
+ it('falls back to NODE_ENV when LANGFUSE_TRACING_ENVIRONMENT is unset', async () => {
203
+ delete process.env.LANGFUSE_TRACING_ENVIRONMENT;
204
+ process.env.NODE_ENV = 'production';
205
+
206
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
207
+ initializeLangfuseTracing({
208
+ publicKey: 'pk-config',
209
+ secretKey: 'sk-config',
210
+ baseUrl: 'https://langfuse.config',
211
+ });
212
+
213
+ expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
214
+ expect.objectContaining({ environment: 'production' })
215
+ );
216
+ });
217
+
218
+ it('falls through blank environment overrides and trims the selected value', async () => {
219
+ process.env.LANGFUSE_TRACING_ENVIRONMENT = ' ';
220
+ process.env.NODE_ENV = ' production ';
221
+
222
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
223
+ initializeLangfuseTracing({
224
+ publicKey: 'pk-config',
225
+ secretKey: 'sk-config',
226
+ baseUrl: 'https://langfuse.config',
227
+ environment: '',
228
+ });
229
+
230
+ expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
231
+ expect.objectContaining({ environment: 'production' })
232
+ );
233
+ });
234
+
235
+ it('prefers an explicit config environment over environment variables', async () => {
236
+ process.env.LANGFUSE_TRACING_ENVIRONMENT = 'staging';
237
+
238
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
239
+ initializeLangfuseTracing({
240
+ publicKey: 'pk-config',
241
+ secretKey: 'sk-config',
242
+ baseUrl: 'https://langfuse.config',
243
+ environment: 'canary',
244
+ });
245
+
246
+ expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
247
+ expect.objectContaining({ environment: 'canary' })
248
+ );
249
+ });
250
+
187
251
  it('does not replace the global provider when explicit credentials change', async () => {
188
252
  const { initializeLangfuseTracing } = await import('@/instrumentation');
189
253
  initializeLangfuseTracing({