@juspay/neurolink 11.6.0 → 11.7.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.
@@ -1,16 +1,15 @@
1
- import { BedrockRuntimeClient, ConverseCommand, ConverseStreamCommand, ImageFormat, } from "@aws-sdk/client-bedrock-runtime";
1
+ import { BedrockRuntimeClient, ImageFormat, } from "@aws-sdk/client-bedrock-runtime";
2
2
  import path from "path";
3
3
  import { createAnalytics } from "../../core/analytics.js";
4
4
  import { BaseProvider } from "../../core/baseProvider.js";
5
5
  import { DEFAULT_MAX_STEPS } from "../../core/constants.js";
6
- import { withInferenceProfileFallback } from "./inferenceProfile.js";
6
+ import { runAgenticLoop } from "../../core/loopEngine.js";
7
+ import { createBedrockLoopAdapter } from "./loopAdapter.js";
7
8
  import { AuthenticationError, ProviderError, RateLimitError, } from "../../types/index.js";
8
9
  import { classifyProviderError } from "../../utils/errorClassifier.js";
9
10
  import { isAbortError, withTimeout } from "../../utils/errorHandling.js";
10
- import { emitToolEndFromStepFinish } from "../../utils/toolEndEmitter.js";
11
11
  import { logger } from "../../utils/logger.js";
12
12
  import { resolveSamplingParams } from "../../models/modelRegistry.js";
13
- import { calculateCost } from "../../utils/pricing.js";
14
13
  import { buildMultimodalMessagesArray } from "../../utils/messageBuilder.js";
15
14
  import { buildMultimodalOptions } from "../../utils/multimodalOptionsBuilder.js";
16
15
  import { convertZodToJsonSchema } from "../../utils/schemaConversion.js";
@@ -196,8 +195,10 @@ export class AmazonBedrockProvider extends BaseProvider {
196
195
  let text;
197
196
  let usage;
198
197
  let finishReason;
198
+ let rawFinishReason;
199
199
  try {
200
- ({ text, usage, finishReason } = await this.conversationLoop(options));
200
+ ({ text, usage, finishReason, rawFinishReason } =
201
+ await this.conversationLoop(options));
201
202
  }
202
203
  catch (error) {
203
204
  // Emit failure generation:end so Pipeline B records the failed generation
@@ -244,344 +245,78 @@ export class AmazonBedrockProvider extends BaseProvider {
244
245
  usage,
245
246
  model: this.modelName || this.getDefaultModel(),
246
247
  provider: this.getProviderName(),
248
+ ...(finishReason !== undefined && { finishReason }),
249
+ ...(rawFinishReason !== undefined && { rawFinishReason }),
247
250
  };
248
251
  }
249
252
  async conversationLoop(options) {
250
- const maxIterations = 10; // Prevent infinite loops
251
- let iteration = 0;
252
- let totalInputTokens = 0;
253
- let totalOutputTokens = 0;
254
- let totalCacheReadTokens = 0;
255
- let totalCacheWriteTokens = 0;
256
- let lastFinishReason;
257
- while (iteration < maxIterations) {
258
- iteration++;
259
- logger.debug(`[AmazonBedrockProvider] Conversation iteration ${iteration}`);
260
- try {
261
- logger.debug(`[AmazonBedrockProvider] About to call Bedrock API`);
262
- const response = await this.callBedrock(options);
263
- logger.debug(`[AmazonBedrockProvider] Received Bedrock response`, JSON.stringify(response, null, 2));
264
- // Accumulate real token counts and capture the stop reason so
265
- // Pipeline B (Langfuse) gets correct usage and finishReason.
266
- // Converse follows the Anthropic additive convention: inputTokens is
267
- // the UNCACHED remainder; cache reads/writes are reported separately.
268
- totalInputTokens += response.usage?.inputTokens ?? 0;
269
- totalOutputTokens += response.usage?.outputTokens ?? 0;
270
- totalCacheReadTokens += response.usage?.cacheReadInputTokens ?? 0;
271
- totalCacheWriteTokens += response.usage?.cacheWriteInputTokens ?? 0;
272
- if (response.stopReason) {
273
- lastFinishReason = response.stopReason;
274
- }
275
- const result = await this.handleBedrockResponse(response);
276
- logger.debug(`[AmazonBedrockProvider] Handle response result:`, result);
277
- if (result.shouldContinue) {
278
- logger.debug(`[AmazonBedrockProvider] Continuing conversation loop...`);
279
- }
280
- else {
281
- logger.debug(`[AmazonBedrockProvider] Conversation completed with final text`);
282
- logger.debug(`[AmazonBedrockProvider] Returning final text: "${result.text}"`);
283
- return {
284
- text: result.text || "",
285
- usage: {
286
- input: totalInputTokens,
287
- output: totalOutputTokens,
288
- // Cache reads/writes are billed tokens reported separately
289
- // from inputTokens — the total must include them.
290
- total: totalInputTokens +
291
- totalCacheReadTokens +
292
- totalCacheWriteTokens +
293
- totalOutputTokens,
294
- ...(totalCacheReadTokens > 0 && {
295
- cacheReadTokens: totalCacheReadTokens,
296
- }),
297
- ...(totalCacheWriteTokens > 0 && {
298
- cacheCreationTokens: totalCacheWriteTokens,
299
- }),
300
- },
301
- finishReason: lastFinishReason,
302
- };
303
- }
304
- }
305
- catch (error) {
306
- logger.error(`[AmazonBedrockProvider] Error in conversation loop:`, error);
307
- throw this.handleProviderError(error);
308
- }
309
- }
310
- throw new Error("Conversation loop exceeded maximum iterations");
311
- }
312
- async callBedrock(options) {
313
- const startTime = Date.now();
314
- return bedrockTracer.startActiveSpan("bedrock.generate", {
315
- kind: SpanKind.CLIENT,
316
- attributes: {
317
- "gen_ai.system": "aws.bedrock",
318
- "gen_ai.request.model": this.modelName || this.getDefaultModel(),
319
- "gen_ai.operation.name": "chat",
320
- },
321
- }, async (generateSpan) => {
322
- logger.info(`[AmazonBedrockProvider] Starting Bedrock API call at ${new Date().toISOString()}`);
323
- try {
324
- // Pre-call validation and logging
325
- let region = "unknown";
326
- try {
327
- region =
328
- typeof this.bedrockClient.config.region === "function"
329
- ? await this.bedrockClient.config.region()
330
- : (this.bedrockClient.config.region ?? "unknown");
331
- }
332
- catch {
333
- // Region lookup failed — not critical, only used for logging
334
- }
335
- logger.info(`[AmazonBedrockProvider] Client region: ${region}`);
336
- logger.info(`[AmazonBedrockProvider] Model: ${this.modelName || this.getDefaultModel()}`);
337
- logger.info(`[AmazonBedrockProvider] Conversation history length: ${this.conversationHistory.length}`);
338
- // Get all available tools
339
- const aiTools = await this.getAllTools();
340
- const allTools = this.convertAISDKToolsToToolDefinitions(aiTools);
341
- const toolConfig = this.formatToolsForBedrock(allTools);
342
- // Registry-driven strip: models that reject sampling params
343
- // (Sonnet 5 / Opus 4.7+ / Fable 5 Claude families on Bedrock)
344
- // must not receive the legacy 0.7 default temperature.
345
- const generateSampling = resolveSamplingParams(this.providerName, this.modelName || this.getDefaultModel(), { temperature: options.temperature ?? 0.7 }, "bedrock.converse");
346
- const commandInput = {
347
- modelId: this.modelName || this.getDefaultModel(),
348
- messages: this.convertToAWSMessages(this.conversationHistory),
349
- system: [
350
- {
351
- text: options.systemPrompt ||
352
- "You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.",
353
- },
354
- ],
355
- inferenceConfig: {
356
- maxTokens: options.maxTokens, // No default limit - unlimited unless specified
357
- ...(generateSampling.temperature !== undefined && {
358
- temperature: generateSampling.temperature,
359
- }),
253
+ // The step cap is now the same `maxSteps || DEFAULT_MAX_STEPS` the
254
+ // streaming path has always used. It used to be a hardcoded 10 that
255
+ // ignored the caller's maxSteps entirely, and reaching it threw — which
256
+ // then propagated into NeuroLink's provider-level retry and ran the whole
257
+ // turn twice more, so a runaway tool loop cost thirty billed calls. The
258
+ // engine stops at the cap and returns what the turn produced.
259
+ const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
260
+ const tools = await this.resolveTurnTools(options.tools, options.disableTools);
261
+ const toolConfig = this.formatToolsForBedrock(tools);
262
+ const sampling = resolveSamplingParams(this.providerName, this.modelName || this.getDefaultModel(), { temperature: options.temperature ?? 0.7 }, "bedrock.converse");
263
+ const adapter = createBedrockLoopAdapter({
264
+ client: this.bedrockClient,
265
+ streaming: false,
266
+ region: this.region,
267
+ maxSteps,
268
+ buildCommandInput: (conversation) => ({
269
+ modelId: this.modelName || this.getDefaultModel(),
270
+ messages: this.convertToAWSMessages(conversation),
271
+ system: [
272
+ {
273
+ text: options.systemPrompt ||
274
+ "You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.",
360
275
  },
361
- };
362
- if (toolConfig) {
363
- commandInput.toolConfig = toolConfig;
364
- logger.info(`[AmazonBedrockProvider] Tools configured: ${toolConfig.tools?.length || 0}`);
365
- }
366
- // Log command details for debugging
367
- logger.info(`[AmazonBedrockProvider] Command input summary:`);
368
- logger.info(` - Model ID: ${commandInput.modelId}`);
369
- logger.info(` - Messages count: ${commandInput.messages?.length || 0}`);
370
- logger.info(` - System prompts: ${commandInput.system?.length || 0}`);
371
- logger.info(` - Max tokens: ${commandInput.inferenceConfig?.maxTokens}`);
372
- logger.info(` - Temperature: ${commandInput.inferenceConfig?.temperature}`);
373
- logger.debug(`[AmazonBedrockProvider] Calling Bedrock with ${this.conversationHistory.length} messages and ${toolConfig?.tools?.length || 0} tools`);
374
- logger.debug("[Observability] Bedrock API request", {
375
- model: commandInput.modelId,
376
- region: region,
377
- messageCount: commandInput.messages?.length || 0,
378
- toolCount: commandInput.toolConfig?.tools?.length || 0,
379
- maxTokens: commandInput.inferenceConfig?.maxTokens,
380
- });
381
- const apiCallStartTime = Date.now();
382
- const response = await withInferenceProfileFallback(commandInput.modelId ?? "",
383
- // `this.region`, not the local `region` above: that one is
384
- // best-effort for logging and stays "unknown" if the lookup
385
- // throws. It must also match the id the streaming path keys its
386
- // cache by, or a resolution found here is never reused there.
387
- this.region, (effectiveModelId) => withTimeout(this.bedrockClient.send(new ConverseCommand({
388
- ...commandInput,
389
- modelId: effectiveModelId,
390
- })), 120_000, new Error("Bedrock API call timed out")));
391
- const apiCallDuration = Date.now() - apiCallStartTime;
392
- logger.debug("[Observability] Bedrock API response", {
393
- model: commandInput.modelId,
394
- durationMs: apiCallDuration,
395
- hasContent: !!response.output?.message?.content?.length,
396
- stopReason: response.stopReason,
397
- usage: response.usage
398
- ? {
399
- inputTokens: response.usage.inputTokens,
400
- outputTokens: response.usage.outputTokens,
401
- totalTokens: (response.usage.inputTokens || 0) +
402
- (response.usage.outputTokens || 0),
403
- }
404
- : undefined,
405
- });
406
- logger.info(`[AmazonBedrockProvider] Bedrock API call successful`);
407
- logger.info(`[AmazonBedrockProvider] API call duration: ${apiCallDuration}ms`);
408
- const totalDuration = Date.now() - startTime;
409
- logger.info(`[AmazonBedrockProvider] Total callBedrock duration: ${totalDuration}ms`);
410
- generateSpan.setAttribute("gen_ai.response.stop_reason", response.stopReason ?? "");
411
- const spanCacheRead = response.usage?.cacheReadInputTokens ?? 0;
412
- const spanCacheWrite = response.usage?.cacheWriteInputTokens ?? 0;
413
- // Converse's inputTokens is only the UNCACHED remainder — the span
414
- // attribute reports the FULL prompt (uncached + cache read/write)
415
- // so telemetry matches the cache-inclusive pricing inputs below.
416
- generateSpan.setAttribute("gen_ai.usage.input_tokens", (response.usage?.inputTokens ?? 0) + spanCacheRead + spanCacheWrite);
417
- generateSpan.setAttribute("gen_ai.usage.cache_read_input_tokens", spanCacheRead);
418
- generateSpan.setAttribute("gen_ai.usage.cache_creation_input_tokens", spanCacheWrite);
419
- generateSpan.setAttribute("gen_ai.usage.output_tokens", response.usage?.outputTokens ?? 0);
420
- const cost = calculateCost(this.providerName, this.modelName, {
421
- input: response.usage?.inputTokens ?? 0,
422
- output: response.usage?.outputTokens ?? 0,
423
- total: (response.usage?.inputTokens ?? 0) +
424
- spanCacheRead +
425
- spanCacheWrite +
426
- (response.usage?.outputTokens ?? 0),
427
- ...(spanCacheRead > 0 && { cacheReadTokens: spanCacheRead }),
428
- ...(spanCacheWrite > 0 && {
429
- cacheCreationTokens: spanCacheWrite,
276
+ ],
277
+ inferenceConfig: {
278
+ maxTokens: options.maxTokens,
279
+ ...(sampling.temperature !== undefined && {
280
+ temperature: sampling.temperature,
430
281
  }),
431
- });
432
- if (cost && cost > 0) {
433
- generateSpan.setAttribute("neurolink.cost", cost);
434
- }
435
- generateSpan.setStatus({ code: SpanStatusCode.OK });
436
- generateSpan.end();
437
- return response;
438
- }
439
- catch (error) {
440
- const errorDuration = Date.now() - startTime;
441
- // Extract AWS metadata for structured logging
442
- const awsError = error && typeof error === "object"
443
- ? error
444
- : null;
445
- const metadata = awsError?.$metadata && typeof awsError.$metadata === "object"
446
- ? awsError.$metadata
447
- : null;
448
- logger.debug("[Observability] Bedrock API request failed", {
449
- model: this.modelName || this.getDefaultModel(),
450
- durationMs: errorDuration,
451
- error: error instanceof Error ? error.message : String(error),
452
- errorName: error instanceof Error ? error.name : undefined,
453
- httpStatus: metadata?.httpStatusCode,
454
- awsRequestId: metadata?.requestId,
455
- awsErrorCode: awsError?.Code,
456
- });
457
- logger.error(`[AmazonBedrockProvider] Bedrock API call failed after ${errorDuration}ms`);
458
- if (error instanceof Error) {
459
- logger.error(`[AmazonBedrockProvider] Error: ${error.name} - ${error.message}`);
460
- }
461
- if (metadata) {
462
- logger.error(`[AmazonBedrockProvider] AWS SDK metadata`, {
463
- httpStatus: metadata.httpStatusCode,
464
- requestId: metadata.requestId,
465
- attempts: metadata.attempts,
466
- totalRetryDelay: metadata.totalRetryDelay,
467
- });
468
- }
469
- generateSpan.setStatus({
470
- code: SpanStatusCode.ERROR,
471
- message: error instanceof Error ? error.message : String(error),
472
- });
473
- generateSpan.recordException(error instanceof Error ? error : new Error(String(error)));
474
- generateSpan.end();
475
- throw error;
476
- }
477
- }); // end bedrockTracer.startActiveSpan('bedrock.generate')
478
- }
479
- async handleBedrockResponse(response) {
480
- logger.debug(`[AmazonBedrockProvider] Received response with stopReason: ${response.stopReason}`);
481
- if (!response.output || !response.output.message) {
482
- throw new Error("Invalid response structure from Bedrock API");
483
- }
484
- const assistantMessage = response.output.message;
485
- const stopReason = response.stopReason;
486
- // Add assistant message to conversation history
487
- const bedrockAssistantMessage = {
488
- role: "assistant",
489
- content: (assistantMessage.content || []).map((item) => {
490
- const bedrockItem = {};
491
- if ("text" in item && item.text) {
492
- bedrockItem.text = item.text;
493
- }
494
- if ("toolUse" in item && item.toolUse) {
495
- bedrockItem.toolUse = {
496
- toolUseId: item.toolUse.toolUseId || "",
497
- name: item.toolUse.name || "",
498
- input: item.toolUse.input || {},
499
- };
500
- }
501
- if ("toolResult" in item && item.toolResult) {
502
- bedrockItem.toolResult = {
503
- toolUseId: item.toolResult.toolUseId || "",
504
- content: (item.toolResult.content || []).map((c) => ({
505
- text: typeof c === "object" && "text" in c
506
- ? c.text || ""
507
- : "",
508
- })),
509
- status: item.toolResult.status || "unknown",
510
- };
511
- }
512
- return bedrockItem;
282
+ },
283
+ ...(toolConfig ? { toolConfig } : {}),
513
284
  }),
514
- };
515
- this.conversationHistory.push(bedrockAssistantMessage);
516
- if (stopReason === "end_turn" || stopReason === "stop_sequence") {
517
- // Extract text from assistant message
518
- const textContent = bedrockAssistantMessage.content
519
- .filter((item) => item.text)
520
- .map((item) => item.text)
521
- .join(" ");
522
- return { shouldContinue: false, text: textContent };
523
- }
524
- else if (stopReason === "tool_use") {
525
- logger.debug(`[AmazonBedrockProvider] Tool use detected - executing tools immediately`);
526
- // Execute all tool uses in the message
527
- const toolResults = [];
528
- for (const contentItem of bedrockAssistantMessage.content) {
529
- if (contentItem.toolUse) {
530
- logger.debug(`[AmazonBedrockProvider] Executing tool: ${contentItem.toolUse.name}`);
531
- try {
532
- // Execute tool using BaseProvider's tool execution
533
- logger.debug(`[AmazonBedrockProvider] Debug toolUse.input:`, JSON.stringify(contentItem.toolUse.input, null, 2));
534
- const toolResult = await this.executeSingleTool(contentItem.toolUse.name, contentItem.toolUse.input || {}, contentItem.toolUse.toolUseId);
535
- logger.debug(`[AmazonBedrockProvider] Tool execution successful: ${contentItem.toolUse.name}`);
536
- toolResults.push({
537
- toolResult: {
538
- toolUseId: contentItem.toolUse.toolUseId,
539
- content: [{ text: String(toolResult) }],
540
- status: "success",
541
- },
542
- });
543
- }
544
- catch (error) {
545
- logger.error(`[AmazonBedrockProvider] Tool execution failed: ${contentItem.toolUse.name}`, error);
546
- const errorMessage = error instanceof Error ? error.message : String(error);
547
- // Still create toolResult for failed tools to maintain 1:1 mapping with toolUse blocks
548
- toolResults.push({
549
- toolResult: {
550
- toolUseId: contentItem.toolUse.toolUseId,
551
- content: [
552
- {
553
- text: `Error executing tool ${contentItem.toolUse.name}: ${errorMessage}`,
554
- },
555
- ],
556
- status: "error",
557
- },
558
- });
559
- }
560
- }
561
- }
562
- // Add tool results as user message
563
- if (toolResults.length > 0) {
564
- const userMessageWithToolResults = {
565
- role: "user",
566
- content: toolResults,
567
- };
568
- this.conversationHistory.push(userMessageWithToolResults);
569
- logger.debug(`[AmazonBedrockProvider] Added ${toolResults.length} tool results to conversation`);
570
- }
571
- return { shouldContinue: true };
572
- }
573
- else if (stopReason === "max_tokens") {
574
- // Max tokens reached — return what we have rather than continuing,
575
- // since the model hit the configured limit.
576
- const textContent = bedrockAssistantMessage.content
577
- .filter((item) => item.text)
578
- .map((item) => item.text)
579
- .join(" ");
580
- return { shouldContinue: false, text: textContent };
285
+ });
286
+ try {
287
+ const { resultPromise } = runAgenticLoop(adapter, this.conversationHistory, {
288
+ tools: this.toEngineTools(tools),
289
+ abortSignal: options.abortSignal,
290
+ });
291
+ const result = await resultPromise;
292
+ this.conversationHistory = result.conversation;
293
+ const input = result.usage.inputTokens;
294
+ const output = result.usage.outputTokens;
295
+ const cacheRead = result.usage.cacheReadTokens ?? 0;
296
+ const cacheWrite = result.usage.cacheWriteTokens ?? 0;
297
+ return {
298
+ text: result.text || "",
299
+ usage: {
300
+ input,
301
+ output,
302
+ // Cache reads/writes are billed tokens reported separately from
303
+ // inputTokens the total must include them.
304
+ total: input + cacheRead + cacheWrite + output,
305
+ ...(cacheRead > 0 && { cacheReadTokens: cacheRead }),
306
+ ...(cacheWrite > 0 && { cacheCreationTokens: cacheWrite }),
307
+ },
308
+ // The MAPPED reason, matching the streaming path and every AI-SDK
309
+ // backed provider. This path previously surfaced Bedrock's raw value,
310
+ // so a turn stopped at the step cap reported "tool_use" here while
311
+ // the same turn reported "tool-calls" when streamed. The raw value is
312
+ // kept alongside rather than dropped.
313
+ finishReason: result.finishReason,
314
+ rawFinishReason: result.rawStopReason,
315
+ };
581
316
  }
582
- else {
583
- logger.warn(`[AmazonBedrockProvider] Unrecognized stop reason "${stopReason}", ending conversation.`);
584
- return { shouldContinue: false, text: "" };
317
+ catch (error) {
318
+ logger.error(`[AmazonBedrockProvider] Error in conversation loop:`, error);
319
+ throw this.handleProviderError(error);
585
320
  }
586
321
  }
587
322
  convertToAWSMessages(bedrockMessages) {
@@ -625,7 +360,16 @@ export class AmazonBedrockProvider extends BaseProvider {
625
360
  }),
626
361
  }));
627
362
  }
628
- async executeSingleTool(toolName, args, _toolUseId) {
363
+ /**
364
+ * `tools` is passed in rather than re-resolved from `getAllTools()`. That
365
+ * call returns only the provider's own registry, so resolving here meant a
366
+ * tool the caller passed to generate/stream could never execute — the
367
+ * streaming path advertised it to the model and then failed every call to
368
+ * it with "Tool not found", and the generate path never advertised it at
369
+ * all. The turn's full merged tool set is resolved once by the caller and
370
+ * handed down.
371
+ */
372
+ async executeSingleTool(tools, toolName, args, _toolUseId) {
629
373
  return bedrockTracer.startActiveSpan("bedrock.tool.execute", {
630
374
  kind: SpanKind.CLIENT,
631
375
  attributes: {
@@ -637,9 +381,6 @@ export class AmazonBedrockProvider extends BaseProvider {
637
381
  logger.debug(`[AmazonBedrockProvider] Executing single tool: ${toolName}`, {
638
382
  args,
639
383
  });
640
- // Use BaseProvider's tool execution mechanism
641
- const aiTools = await this.getAllTools();
642
- const tools = this.convertAISDKToolsToToolDefinitions(aiTools);
643
384
  if (!tools[toolName]) {
644
385
  throw new Error(`Tool not found: ${toolName}`);
645
386
  }
@@ -727,6 +468,39 @@ export class AmazonBedrockProvider extends BaseProvider {
727
468
  }
728
469
  });
729
470
  }
471
+ /**
472
+ * Resolve the turn's tools once: whatever the caller passed, else the
473
+ * provider's own registry. `BaseProvider.stream()` has already merged base
474
+ * tools into `options.tools` by the time it reaches the streaming path;
475
+ * the generate path has no such pre-merge, so it falls back here.
476
+ */
477
+ async resolveTurnTools(optionTools, disableTools) {
478
+ // `generate()` reaches conversationLoop() without going through
479
+ // BaseProvider's tool preparation, so `options.tools` is undefined there
480
+ // and the getAllTools() fallback would hand the model the whole registry
481
+ // even when the caller asked for no tools at all.
482
+ if (disableTools) {
483
+ return {};
484
+ }
485
+ const aiTools = optionTools ??
486
+ (await this.getAllTools());
487
+ return this.convertAISDKToolsToToolDefinitions(aiTools);
488
+ }
489
+ /**
490
+ * Present the resolved tools in the shape `runAgenticLoop` dispatches
491
+ * through. Execution still goes through `executeSingleTool`, so the tool
492
+ * span, the parameter defaults and the ToolResult unwrapping are unchanged
493
+ * — only which tools are reachable changes.
494
+ */
495
+ toEngineTools(tools) {
496
+ const engineTools = {};
497
+ for (const name of Object.keys(tools)) {
498
+ engineTools[name] = {
499
+ execute: (args) => this.executeSingleTool(tools, name, args),
500
+ };
501
+ }
502
+ return engineTools;
503
+ }
730
504
  convertAISDKToolsToToolDefinitions(aiTools) {
731
505
  const result = {};
732
506
  for (const [name, tool] of Object.entries(aiTools)) {
@@ -1052,631 +826,183 @@ export class AmazonBedrockProvider extends BaseProvider {
1052
826
  });
1053
827
  }
1054
828
  async streamingConversationLoop(options, streamSpan) {
1055
- logger.debug("[TRACE] streamingConversationLoop ENTRY");
1056
829
  const startTime = Date.now();
1057
- const maxIterations = options.maxSteps || DEFAULT_MAX_STEPS;
1058
- let iteration = 0;
1059
- // Shared counters updated by both the first-iteration inline loop and
1060
- // the processStreamResponse loop. Read by the final generation:end emit
1061
- // so Pipeline B (Langfuse) gets real token counts from Bedrock streams.
1062
- let streamTotalInputTokens = 0;
1063
- let streamTotalOutputTokens = 0;
1064
- let streamTotalCacheReadTokens = 0;
1065
- let streamTotalCacheWriteTokens = 0;
1066
- let streamLastStopReason;
1067
- // The REAL issue: ReadableStream errors don't bubble up to the caller
1068
- // So we need to make the first streaming call synchronously to test permissions
1069
- try {
1070
- logger.debug("[TRACE] streamingConversationLoop - testing first streaming call");
1071
- const commandInput = await this.prepareStreamCommand(options);
1072
- logger.debug("[Observability] Bedrock streaming API request", {
1073
- model: commandInput.modelId,
1074
- messageCount: commandInput.messages?.length || 0,
1075
- toolCount: commandInput.toolConfig?.tools?.length || 0,
1076
- });
1077
- streamSpan.addEvent("stream.api_call", {
1078
- "bedrock.message_count": commandInput.messages?.length || 0,
1079
- "bedrock.tool_count": commandInput.toolConfig?.tools?.length || 0,
1080
- });
1081
- const streamStartTime = Date.now();
1082
- const response = await withInferenceProfileFallback(commandInput.modelId ?? "", this.region, (effectiveModelId) => withTimeout(this.bedrockClient.send(new ConverseStreamCommand({
1083
- ...commandInput,
1084
- modelId: effectiveModelId,
1085
- })), 120_000, new Error("Bedrock streaming API call timed out")));
1086
- logger.debug("[Observability] Bedrock streaming API connection established", {
1087
- model: commandInput.modelId,
1088
- durationMs: Date.now() - streamStartTime,
1089
- hasStream: !!response.stream,
1090
- });
1091
- // Process the first response immediately to avoid waste
1092
- const stream = new ReadableStream({
1093
- start: async (controller) => {
1094
- logger.debug("[TRACE] streamingConversationLoop - ReadableStream start() called");
1095
- try {
1096
- // Process the first response we already have, tracking all event types
1097
- let firstStopReason = "";
1098
- if (response.stream) {
1099
- const firstMessageContent = [];
1100
- let firstText = "";
1101
- for await (const chunk of response.stream) {
1102
- if (chunk.contentBlockStart) {
1103
- firstMessageContent.push({});
1104
- }
1105
- if (chunk.contentBlockDelta?.delta?.text) {
1106
- const textDelta = chunk.contentBlockDelta.delta.text;
1107
- firstText += textDelta;
1108
- controller.enqueue({ content: textDelta });
1109
- }
1110
- if (chunk.contentBlockStart?.start?.toolUse) {
1111
- const currentBlock = firstMessageContent[firstMessageContent.length - 1];
1112
- currentBlock.toolUse = {
1113
- name: chunk.contentBlockStart.start.toolUse.name || "",
1114
- input: {},
1115
- toolUseId: chunk.contentBlockStart.start.toolUse.toolUseId ||
1116
- `tool_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
1117
- };
1118
- }
1119
- if (chunk.contentBlockDelta?.delta?.toolUse) {
1120
- const currentBlock = firstMessageContent[firstMessageContent.length - 1];
1121
- if (!currentBlock.toolUse) {
1122
- currentBlock.toolUse = {
1123
- name: "",
1124
- input: {},
1125
- toolUseId: `tool_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
1126
- };
1127
- }
1128
- const deltaInput = chunk.contentBlockDelta.delta.toolUse.input;
1129
- if (!deltaInput) {
1130
- // no input delta
1131
- }
1132
- else if (typeof deltaInput === "string") {
1133
- currentBlock._inputBuffer =
1134
- (currentBlock._inputBuffer || "") + deltaInput;
1135
- }
1136
- else if (typeof deltaInput === "object" &&
1137
- !Array.isArray(deltaInput)) {
1138
- const currentInput = currentBlock.toolUse.input || {};
1139
- currentBlock.toolUse.input = {
1140
- ...currentInput,
1141
- ...deltaInput,
1142
- };
1143
- }
1144
- }
1145
- if (chunk.contentBlockStop) {
1146
- const currentBlock = firstMessageContent[firstMessageContent.length - 1];
1147
- if (currentBlock?.toolUse && currentBlock._inputBuffer) {
1148
- try {
1149
- currentBlock.toolUse.input = JSON.parse(currentBlock._inputBuffer);
1150
- }
1151
- catch {
1152
- currentBlock.toolUse.input = {};
1153
- }
1154
- delete currentBlock._inputBuffer;
1155
- }
1156
- if (firstText && currentBlock && !currentBlock.toolUse) {
1157
- currentBlock.text = firstText;
1158
- }
1159
- firstText = "";
1160
- }
1161
- if (chunk.messageStop) {
1162
- firstStopReason = chunk.messageStop.stopReason || "end_turn";
1163
- // Don't break — metadata chunk with usage comes after messageStop
1164
- continue;
1165
- }
1166
- // Accumulate usage from Bedrock metadata chunk for Pipeline B.
1167
- // The metadata chunk is emitted after messageStop with aggregate usage.
1168
- if (chunk.metadata?.usage) {
1169
- streamTotalInputTokens +=
1170
- chunk.metadata.usage.inputTokens ?? 0;
1171
- streamTotalOutputTokens +=
1172
- chunk.metadata.usage.outputTokens ?? 0;
1173
- // inputTokens excludes cache reads/writes (Converse follows
1174
- // the Anthropic additive convention) — track them too.
1175
- streamTotalCacheReadTokens +=
1176
- chunk.metadata.usage.cacheReadInputTokens ?? 0;
1177
- streamTotalCacheWriteTokens +=
1178
- chunk.metadata.usage.cacheWriteInputTokens ?? 0;
1179
- // Stream is effectively complete after metadata chunk
1180
- break;
1181
- }
1182
- }
1183
- if (firstStopReason) {
1184
- streamLastStopReason = firstStopReason;
1185
- }
1186
- // Add first assistant message to conversation history
1187
- const firstAssistantMessage = {
1188
- role: "assistant",
1189
- content: firstMessageContent,
1190
- };
1191
- this.conversationHistory.push(firstAssistantMessage);
1192
- streamSpan.addEvent("stream.turn_complete", {
1193
- iteration: 0,
1194
- stop_reason: firstStopReason,
1195
- });
1196
- if (firstStopReason === "tool_use") {
1197
- const toolNames = firstMessageContent
1198
- .flatMap((b) => (b.toolUse?.name ? [b.toolUse.name] : []))
1199
- .join(", ");
1200
- streamSpan.addEvent("stream.tool_use", {
1201
- iteration: 0,
1202
- tool_names: toolNames,
1203
- });
1204
- }
1205
- // Handle the stop reason from the first response
1206
- const shouldContinue = await this.handleStreamStopReason(firstStopReason, firstAssistantMessage, controller, options);
1207
- if (!shouldContinue) {
1208
- streamSpan.setAttribute("gen_ai.response.stop_reason", firstStopReason);
1209
- // Close the controller so downstream `for await` exits;
1210
- // see the close() comment near the bottom of this start()
1211
- // function for the spec rationale.
1212
- controller.close();
1213
- return;
1214
- }
1215
- }
1216
- // Continue with normal iterations if needed
1217
- while (iteration < maxIterations) {
1218
- iteration++;
1219
- logger.debug(`[AmazonBedrockProvider] Streaming iteration ${iteration}`);
1220
- const commandInput = await this.prepareStreamCommand(options);
1221
- const { stopReason, assistantMessage, usage } = await this.processStreamResponse(commandInput, controller);
1222
- // Accumulate real usage from Bedrock metadata chunks.
1223
- if (usage) {
1224
- streamTotalInputTokens += usage.input;
1225
- streamTotalOutputTokens += usage.output;
1226
- streamTotalCacheReadTokens += usage.cacheReadTokens ?? 0;
1227
- streamTotalCacheWriteTokens += usage.cacheCreationTokens ?? 0;
1228
- }
1229
- if (stopReason) {
1230
- streamLastStopReason = stopReason;
1231
- }
1232
- streamSpan.addEvent("stream.turn_complete", {
1233
- iteration,
1234
- stop_reason: stopReason,
1235
- });
1236
- if (stopReason === "tool_use") {
1237
- const toolNames = assistantMessage.content
1238
- .flatMap((b) => (b.toolUse?.name ? [b.toolUse.name] : []))
1239
- .join(", ");
1240
- streamSpan.addEvent("stream.tool_use", {
1241
- iteration,
1242
- tool_names: toolNames,
1243
- });
1244
- }
1245
- const shouldContinue = await this.handleStreamStopReason(stopReason, assistantMessage, controller, options);
1246
- if (!shouldContinue) {
1247
- streamSpan.setAttribute("gen_ai.response.stop_reason", stopReason);
1248
- break;
1249
- }
1250
- }
1251
- if (iteration >= maxIterations) {
1252
- streamSpan.setAttribute("gen_ai.response.stop_reason", "max_iterations");
1253
- controller.error(new Error("Streaming conversation exceeded maximum iterations"));
1254
- return;
1255
- }
1256
- // CRITICAL: ReadableStream's start() returning does NOT auto-close
1257
- // the controller per the WHATWG Streams spec. Without this, the
1258
- // downstream `for await (const chunk of stream)` in
1259
- // convertToAsyncIterable never sees `done: true` and the
1260
- // consumer hangs forever — manifested as a 240s harness
1261
- // PER_TEST_TIMEOUT_SKIP for `[bedrock] stream tokens`. The first-
1262
- // iteration `return` path and the while-loop natural `break`
1263
- // path both reach here, so closing once at the bottom covers
1264
- // every non-error exit.
1265
- controller.close();
1266
- }
1267
- catch (error) {
1268
- logger.debug("[TRACE] streamingConversationLoop - CATCH block hit in ReadableStream");
1269
- controller.error(error);
1270
- }
1271
- },
1272
- });
1273
- // Emit generation:end after the stream completes so Pipeline B (Langfuse)
1274
- // creates a GENERATION observation. Bedrock bypasses the Vercel AI SDK so
1275
- // experimental_telemetry is never injected; we emit the event manually.
1276
- const streamEmitter = this.neurolink?.getEventEmitter();
1277
- const streamAsyncIterable = this.convertToAsyncIterable(stream);
1278
- const self = this;
1279
- // Defer analytics resolution until the stream completes so we have
1280
- // real token counts aggregated from Bedrock metadata chunks.
1281
- let resolveAnalytics;
1282
- const analyticsPromise = new Promise((resolve) => {
1283
- resolveAnalytics = resolve;
1284
- });
1285
- const wrappedStreamIterable = {
1286
- async *[Symbol.asyncIterator]() {
1287
- let streamErrored = false;
1288
- try {
1289
- yield* streamAsyncIterable;
1290
- }
1291
- catch (error) {
1292
- streamErrored = true;
1293
- throw error;
1294
- }
1295
- finally {
1296
- const aggregatedUsage = {
1297
- input: streamTotalInputTokens,
1298
- output: streamTotalOutputTokens,
1299
- // Cache reads/writes are billed tokens reported separately
1300
- // from inputTokens — the total must include them.
1301
- total: streamTotalInputTokens +
1302
- streamTotalCacheReadTokens +
1303
- streamTotalCacheWriteTokens +
1304
- streamTotalOutputTokens,
1305
- ...(streamTotalCacheReadTokens > 0 && {
1306
- cacheReadTokens: streamTotalCacheReadTokens,
1307
- }),
1308
- ...(streamTotalCacheWriteTokens > 0 && {
1309
- cacheCreationTokens: streamTotalCacheWriteTokens,
1310
- }),
1311
- };
1312
- // Resolve analytics with accumulated token counts from Bedrock
1313
- // metadata chunks so Pipeline A also reports real usage.
1314
- resolveAnalytics(createAnalytics(self.providerName, self.modelName || self.getDefaultModel(), { usage: aggregatedUsage }, Date.now() - startTime, {
1315
- requestId: `bedrock-stream-${Date.now()}`,
1316
- streamingMode: true,
1317
- }));
1318
- if (streamEmitter) {
1319
- streamEmitter.emit("generation:end", {
1320
- provider: self.providerName,
1321
- responseTime: Date.now() - startTime,
1322
- timestamp: Date.now(),
1323
- result: {
1324
- content: "",
1325
- usage: aggregatedUsage,
1326
- model: self.modelName || self.getDefaultModel(),
1327
- provider: self.providerName,
1328
- finishReason: streamErrored ? "error" : streamLastStopReason,
1329
- },
1330
- success: !streamErrored,
1331
- });
1332
- }
1333
- }
1334
- },
1335
- };
1336
- return {
1337
- stream: wrappedStreamIterable,
1338
- // No usage key here on purpose: the real aggregate resolves through
1339
- // `analytics` after the stream drains. A literal zero object is
1340
- // truthy and would block every downstream usage fallback.
1341
- model: this.modelName || this.getDefaultModel(),
1342
- provider: this.getProviderName(),
1343
- analytics: analyticsPromise,
1344
- metadata: {
1345
- startTime,
1346
- streamId: `bedrock-${Date.now()}`,
830
+ const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
831
+ // Resolved once for the whole turn. Bedrock has no mid-turn tool
832
+ // discovery, so nothing here would need re-resolving per step.
833
+ const tools = await this.resolveTurnTools(options.tools, options.disableTools);
834
+ const toolConfig = this.formatToolsForBedrock(tools);
835
+ const sampling = resolveSamplingParams(this.providerName, this.modelName || this.getDefaultModel(), { temperature: options.temperature ?? 0.7 }, "bedrock.converseStream");
836
+ const baseAdapter = createBedrockLoopAdapter({
837
+ client: this.bedrockClient,
838
+ streaming: true,
839
+ region: this.region,
840
+ maxSteps,
841
+ buildCommandInput: (conversation) => ({
842
+ modelId: this.modelName || this.getDefaultModel(),
843
+ messages: this.convertToAWSMessages(conversation),
844
+ system: [
845
+ {
846
+ text: options.systemPrompt ||
847
+ "You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.",
848
+ },
849
+ ],
850
+ inferenceConfig: {
851
+ maxTokens: options.maxTokens,
852
+ ...(sampling.temperature !== undefined && {
853
+ temperature: sampling.temperature,
854
+ }),
1347
855
  },
856
+ ...(toolConfig ? { toolConfig } : {}),
857
+ }),
858
+ });
859
+ // executeStream() falls back to non-streaming generate() when the first
860
+ // call fails on permissions, and that only works if the failure reaches it
861
+ // synchronously. The engine runs the turn in the background, so the first
862
+ // successful send reports separately.
863
+ //
864
+ // Success is the ONLY thing signalled here. The engine wraps every
865
+ // executeStep in withProviderRetry, so rejecting on a failed attempt
866
+ // would settle this promise on attempt one and never un-settle it: a
867
+ // retryable 429 would be reported as a dead turn even though the engine's
868
+ // own retry went on to succeed, and this method would throw while that
869
+ // retried — and billed — turn kept running in the background with its
870
+ // output discarded, with the fallback generate() billed on top. A
871
+ // terminal failure is taken from the turn's settled outcome instead,
872
+ // which by definition is reached only after the engine has stopped
873
+ // retrying.
874
+ let firstStepSucceeded = false;
875
+ let firstStepSent;
876
+ const firstStep = new Promise((resolve) => {
877
+ firstStepSent = () => {
878
+ firstStepSucceeded = true;
879
+ resolve();
1348
880
  };
1349
- }
1350
- catch (error) {
1351
- logger.debug("[TRACE] streamingConversationLoop - first streaming call FAILED, throwing");
1352
- throw error; // This will be caught by executeStream
1353
- }
1354
- }
1355
- convertToAsyncIterable(stream) {
1356
- return {
1357
- async *[Symbol.asyncIterator]() {
1358
- const reader = stream.getReader();
1359
- try {
1360
- while (true) {
1361
- const { done, value } = await reader.read();
1362
- if (done) {
1363
- break;
1364
- }
1365
- yield value;
1366
- }
1367
- }
1368
- finally {
1369
- reader.releaseLock();
1370
- }
881
+ });
882
+ const adapter = {
883
+ ...baseAdapter,
884
+ executeStep: async (request, channel, signal) => {
885
+ const stepResult = await baseAdapter.executeStep(request, channel, signal);
886
+ firstStepSent();
887
+ return stepResult;
1371
888
  },
1372
889
  };
1373
- }
1374
- async prepareStreamCommand(options) {
1375
- // CRITICAL DEBUG: Log conversation history before conversion
1376
- if (logger.shouldLog("debug")) {
1377
- logger.debug(`[AmazonBedrockProvider] BEFORE conversion - conversationHistory length: ${this.conversationHistory.length}`);
1378
- this.conversationHistory.forEach((msg, index) => {
1379
- logger.debug(`[AmazonBedrockProvider] Message ${index}: role=${msg.role}, content=${JSON.stringify(msg.content)}`);
1380
- });
1381
- }
1382
- // Get all available tools
1383
- // BaseProvider.stream() pre-merges base tools + external tools into options.tools
1384
- const aiTools = options.tools || (await this.getAllTools());
1385
- const allTools = this.convertAISDKToolsToToolDefinitions(aiTools);
1386
- const toolConfig = this.formatToolsForBedrock(allTools);
1387
- const convertedMessages = this.convertToAWSMessages(this.conversationHistory);
1388
- if (logger.shouldLog("debug")) {
1389
- logger.debug(`[AmazonBedrockProvider] AFTER conversion - messages length: ${convertedMessages.length}`);
1390
- convertedMessages.forEach((msg, index) => {
1391
- logger.debug(`[AmazonBedrockProvider] Converted Message ${index}: role=${msg.role}, content=${JSON.stringify(msg.content)}`);
1392
- });
890
+ streamSpan.addEvent("stream.api_call", {
891
+ "bedrock.tool_count": toolConfig?.tools?.length ?? 0,
892
+ });
893
+ const { stream, resultPromise } = runAgenticLoop(adapter, this.conversationHistory, {
894
+ tools: this.toEngineTools(tools),
895
+ abortSignal: options.abortSignal,
896
+ });
897
+ // The stream surfaces the same failure, so this settled view exists only
898
+ // so the turn's outcome can be read without a second unhandled rejection.
899
+ const settled = resultPromise.then((result) => ({ result, error: undefined }), (error) => ({ result: undefined, error }));
900
+ // A turn that ends without ever completing a step — an abort before the
901
+ // first request, or a failure the engine gave up retrying — would leave
902
+ // `firstStep` pending forever, so the settled outcome releases the wait
903
+ // too.
904
+ await Promise.race([firstStep, settled]);
905
+ if (!firstStepSucceeded) {
906
+ // The turn ended before any send succeeded. Surface its error here so
907
+ // executeStream's permission fallback still sees it synchronously.
908
+ const outcome = await settled;
909
+ if (outcome.error) {
910
+ throw outcome.error;
911
+ }
1393
912
  }
1394
- // Registry-driven strip: models that reject sampling params (Sonnet 5 /
1395
- // Opus 4.7+ / Fable 5 Claude families on Bedrock) must not receive the
1396
- // legacy 0.7 default temperature.
1397
- const streamSampling = resolveSamplingParams(this.providerName, this.modelName || this.getDefaultModel(), { temperature: options.temperature ?? 0.7 }, "bedrock.converseStream");
1398
- const commandInput = {
1399
- modelId: this.modelName || this.getDefaultModel(),
1400
- messages: convertedMessages,
1401
- system: [
1402
- {
1403
- text: options.systemPrompt ||
1404
- "You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.",
1405
- },
1406
- ],
1407
- inferenceConfig: {
1408
- maxTokens: options.maxTokens, // No default limit - unlimited unless specified
1409
- ...(streamSampling.temperature !== undefined && {
1410
- temperature: streamSampling.temperature,
1411
- }),
1412
- },
913
+ const streamEmitter = this.neurolink?.getEventEmitter();
914
+ const self = this;
915
+ const metadata = {
916
+ startTime,
917
+ streamId: `bedrock-${Date.now()}`,
1413
918
  };
1414
- if (toolConfig) {
1415
- commandInput.toolConfig = toolConfig;
1416
- }
1417
- logger.debug(`[AmazonBedrockProvider] Calling Bedrock streaming with ${this.conversationHistory.length} messages`);
1418
- // DEBUG: Log exact conversation structure being sent to Bedrock
1419
- logger.debug(`[AmazonBedrockProvider] DEBUG - Conversation structure:`);
1420
- this.conversationHistory.forEach((msg, index) => {
1421
- logger.debug(` Message ${index} (${msg.role}): ${msg.content.length} content items`);
1422
- msg.content.forEach((item, itemIndex) => {
1423
- const keys = Object.keys(item);
1424
- logger.debug(` Content ${itemIndex}: ${keys.join(", ")}`);
1425
- });
1426
- });
1427
- return commandInput;
1428
- }
1429
- async processStreamResponse(commandInput, controller) {
1430
- const command = new ConverseStreamCommand(commandInput);
1431
- logger.debug("[Observability] Bedrock streaming API request (continuation)", {
1432
- model: commandInput.modelId,
1433
- messageCount: commandInput.messages?.length || 0,
919
+ let resolveAnalytics;
920
+ const analyticsPromise = new Promise((resolve) => {
921
+ resolveAnalytics = resolve;
1434
922
  });
1435
- const iterationStartTime = Date.now();
1436
- const response = await withTimeout(this.bedrockClient.send(command), 120_000, new Error("Bedrock streaming API call timed out"));
1437
- logger.debug("[Observability] Bedrock streaming API connection established (continuation)", {
1438
- model: commandInput.modelId,
1439
- durationMs: Date.now() - iterationStartTime,
923
+ const usageFromOutcome = (usage) => {
924
+ const input = usage?.inputTokens ?? 0;
925
+ const output = usage?.outputTokens ?? 0;
926
+ const cacheRead = usage?.cacheReadTokens ?? 0;
927
+ const cacheWrite = usage?.cacheWriteTokens ?? 0;
928
+ return {
929
+ input,
930
+ output,
931
+ // Cache reads/writes are billed tokens reported separately from
932
+ // inputTokens — the total must include them.
933
+ total: input + cacheRead + cacheWrite + output,
934
+ ...(cacheRead > 0 && { cacheReadTokens: cacheRead }),
935
+ ...(cacheWrite > 0 && { cacheCreationTokens: cacheWrite }),
936
+ };
937
+ };
938
+ // Bind analytics to the turn ending, not to the consumer draining the
939
+ // stream. A caller that awaits `result.analytics` without iterating
940
+ // `result.stream` would otherwise wait forever, because the generator
941
+ // body — and its finally block — never runs. Resolving twice is
942
+ // harmless; the first call wins.
943
+ void settled.then((outcome) => {
944
+ resolveAnalytics(createAnalytics(this.providerName, this.modelName || this.getDefaultModel(), { usage: usageFromOutcome(outcome.result?.usage) }, Date.now() - startTime, {
945
+ requestId: `bedrock-stream-${Date.now()}`,
946
+ streamingMode: true,
947
+ }));
1440
948
  });
1441
- if (!response.stream) {
1442
- throw new Error("No stream returned from Bedrock");
1443
- }
1444
- const currentMessageContent = [];
1445
- let stopReason = "";
1446
- let currentText = "";
1447
- let streamUsage;
1448
- // Process streaming chunks
1449
- for await (const chunk of response.stream) {
1450
- if (chunk.contentBlockStart) {
1451
- // Starting a new content block
1452
- currentMessageContent.push({});
1453
- }
1454
- if (chunk.contentBlockDelta?.delta?.text) {
1455
- // Text delta - stream it to user
1456
- const textDelta = chunk.contentBlockDelta.delta.text;
1457
- currentText += textDelta;
1458
- controller.enqueue({
1459
- content: textDelta,
1460
- });
1461
- }
1462
- if (chunk.contentBlockStart?.start?.toolUse) {
1463
- // Tool use block starting - initialize tool information
1464
- const currentBlock = currentMessageContent[currentMessageContent.length - 1];
1465
- currentBlock.toolUse = {
1466
- name: chunk.contentBlockStart.start.toolUse.name || "",
1467
- input: {}, // Initialize empty - will be populated by delta chunks
1468
- toolUseId: chunk.contentBlockStart.start.toolUse.toolUseId ||
1469
- `tool_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
1470
- };
1471
- }
1472
- if (chunk.contentBlockDelta?.delta?.toolUse) {
1473
- // Tool use delta - accumulate tool information
1474
- const currentBlock = currentMessageContent[currentMessageContent.length - 1];
1475
- if (!currentBlock.toolUse) {
1476
- currentBlock.toolUse = {
1477
- name: "",
1478
- input: {},
1479
- toolUseId: `tool_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
1480
- };
949
+ const wrappedStreamIterable = {
950
+ async *[Symbol.asyncIterator]() {
951
+ let streamErrored = false;
952
+ try {
953
+ yield* stream;
1481
954
  }
1482
- // Accumulate JSON string fragments into _inputBuffer.
1483
- // Bedrock sends toolUse.input as incremental JSON string fragments,
1484
- // not pre-parsed objects. We buffer them and parse at contentBlockStop.
1485
- if (chunk.contentBlockDelta.delta.toolUse.input) {
1486
- const deltaInput = chunk.contentBlockDelta.delta.toolUse.input;
1487
- if (typeof deltaInput === "string") {
1488
- currentBlock._inputBuffer =
1489
- (currentBlock._inputBuffer || "") + deltaInput;
1490
- }
1491
- else if (deltaInput &&
1492
- typeof deltaInput === "object" &&
1493
- !Array.isArray(deltaInput)) {
1494
- // Some SDK versions may deliver pre-parsed objects; merge directly
1495
- const currentInput = currentBlock.toolUse.input || {};
1496
- currentBlock.toolUse.input = {
1497
- ...currentInput,
1498
- ...deltaInput,
1499
- };
1500
- }
955
+ catch (error) {
956
+ streamErrored = true;
957
+ throw error;
1501
958
  }
1502
- }
1503
- if (chunk.contentBlockStop) {
1504
- // Content block completed
1505
- const currentBlock = currentMessageContent[currentMessageContent.length - 1];
1506
- // Parse accumulated JSON input buffer for tool-use blocks
1507
- if (currentBlock?.toolUse && currentBlock._inputBuffer) {
1508
- try {
1509
- currentBlock.toolUse.input = JSON.parse(currentBlock._inputBuffer);
959
+ finally {
960
+ const outcome = await settled;
961
+ if (outcome.error) {
962
+ streamErrored = true;
1510
963
  }
1511
- catch {
1512
- currentBlock.toolUse.input = {};
964
+ const aggregatedUsage = usageFromOutcome(outcome.result?.usage);
965
+ if (outcome.result) {
966
+ self.conversationHistory = outcome.result.conversation;
967
+ metadata.finishReason = outcome.result.finishReason;
968
+ metadata.rawFinishReason = outcome.result.rawStopReason;
969
+ streamSpan.setAttribute("gen_ai.response.stop_reason", outcome.result.rawStopReason ?? "unknown");
970
+ }
971
+ // Analytics is resolved off `settled` above, so it lands whether or
972
+ // not anyone drains the stream.
973
+ // Bedrock bypasses the Vercel AI SDK, so experimental_telemetry is
974
+ // never injected and generation:end is emitted by hand for
975
+ // Pipeline B (Langfuse).
976
+ if (streamEmitter) {
977
+ streamEmitter.emit("generation:end", {
978
+ provider: self.providerName,
979
+ responseTime: Date.now() - startTime,
980
+ timestamp: Date.now(),
981
+ result: {
982
+ content: "",
983
+ usage: aggregatedUsage,
984
+ model: self.modelName || self.getDefaultModel(),
985
+ provider: self.providerName,
986
+ finishReason: streamErrored
987
+ ? "error"
988
+ : outcome.result?.rawStopReason,
989
+ },
990
+ success: !streamErrored,
991
+ });
1513
992
  }
1514
- delete currentBlock._inputBuffer;
1515
- }
1516
- if (currentText && currentBlock && !currentBlock.toolUse) {
1517
- // Only add text to blocks that don't have toolUse
1518
- currentBlock.text = currentText;
1519
993
  }
1520
- currentText = "";
1521
- }
1522
- if (chunk.messageStop) {
1523
- stopReason = chunk.messageStop.stopReason || "end_turn";
1524
- // Don't break metadata chunk with usage arrives after messageStop
1525
- continue;
1526
- }
1527
- // Bedrock ConverseStream emits a metadata chunk at the end with
1528
- // aggregate usage. Capture it for Pipeline B telemetry.
1529
- if (chunk.metadata?.usage) {
1530
- const input = chunk.metadata.usage.inputTokens ?? 0;
1531
- const output = chunk.metadata.usage.outputTokens ?? 0;
1532
- const cacheRead = chunk.metadata.usage.cacheReadInputTokens ?? 0;
1533
- const cacheWrite = chunk.metadata.usage.cacheWriteInputTokens ?? 0;
1534
- streamUsage = {
1535
- input,
1536
- output,
1537
- // Computed rather than trusting totalTokens: inputTokens excludes
1538
- // cache reads/writes (additive convention), and the total must
1539
- // count every billed component.
1540
- total: input + cacheRead + cacheWrite + output,
1541
- ...(cacheRead > 0 && { cacheReadTokens: cacheRead }),
1542
- ...(cacheWrite > 0 && { cacheCreationTokens: cacheWrite }),
1543
- };
1544
- // Stream is effectively complete after metadata chunk
1545
- break;
1546
- }
1547
- }
1548
- // Add assistant message to conversation history
1549
- const assistantMessage = {
1550
- role: "assistant",
1551
- content: currentMessageContent,
994
+ },
995
+ };
996
+ return {
997
+ stream: wrappedStreamIterable,
998
+ // No usage key here on purpose: the real aggregate resolves through
999
+ // `analytics` after the stream drains. A literal zero object is truthy
1000
+ // and would block every downstream usage fallback.
1001
+ model: this.modelName || this.getDefaultModel(),
1002
+ provider: this.getProviderName(),
1003
+ analytics: analyticsPromise,
1004
+ metadata,
1552
1005
  };
1553
- this.conversationHistory.push(assistantMessage);
1554
- return { stopReason, assistantMessage, usage: streamUsage };
1555
- }
1556
- async handleStreamStopReason(stopReason, assistantMessage, controller, options) {
1557
- if (stopReason === "end_turn" || stopReason === "stop_sequence") {
1558
- // Conversation completed
1559
- controller.close();
1560
- return false;
1561
- }
1562
- else if (stopReason === "tool_use") {
1563
- logger.debug(`[AmazonBedrockProvider] Tool use detected in streaming - executing tools`);
1564
- await this.executeStreamTools(assistantMessage.content, options);
1565
- return true; // Continue conversation loop
1566
- }
1567
- else if (stopReason === "max_tokens") {
1568
- // Max tokens reached — close the stream rather than continuing,
1569
- // since the model hit the configured limit.
1570
- controller.close();
1571
- return false;
1572
- }
1573
- else {
1574
- // Unknown stop reason - end conversation
1575
- controller.close();
1576
- return false;
1577
- }
1578
- }
1579
- async executeStreamTools(messageContent, options) {
1580
- // Execute all tool uses in the message - ensure 1:1 mapping like Bedrock-MCP-Connector
1581
- const toolResults = [];
1582
- let toolUseCount = 0;
1583
- // Track tool calls and results for storage (similar to Vertex onStepFinish)
1584
- const toolCalls = [];
1585
- const toolResultsForStorage = [];
1586
- // Count toolUse blocks first to ensure 1:1 mapping
1587
- for (const contentItem of messageContent) {
1588
- if (contentItem.toolUse) {
1589
- toolUseCount++;
1590
- }
1591
- }
1592
- logger.debug(`[AmazonBedrockProvider] Found ${toolUseCount} toolUse blocks in assistant message`);
1593
- for (const contentItem of messageContent) {
1594
- if (contentItem.toolUse) {
1595
- logger.debug(`[AmazonBedrockProvider] Executing tool: ${contentItem.toolUse.name}`);
1596
- // Track tool call
1597
- toolCalls.push({
1598
- type: "tool-call",
1599
- toolCallId: contentItem.toolUse.toolUseId,
1600
- toolName: contentItem.toolUse.name,
1601
- args: contentItem.toolUse.input || {},
1602
- });
1603
- try {
1604
- const toolResult = await this.executeSingleTool(contentItem.toolUse.name, contentItem.toolUse.input || {}, contentItem.toolUse.toolUseId);
1605
- logger.debug(`[AmazonBedrockProvider] Tool execution successful: ${contentItem.toolUse.name}`);
1606
- // Track tool result for storage
1607
- toolResultsForStorage.push({
1608
- type: "tool-result",
1609
- toolCallId: contentItem.toolUse.toolUseId,
1610
- toolName: contentItem.toolUse.name,
1611
- result: toolResult,
1612
- });
1613
- // Ensure exact structure matching Bedrock-MCP-Connector
1614
- toolResults.push({
1615
- toolResult: {
1616
- toolUseId: contentItem.toolUse.toolUseId,
1617
- content: [{ text: String(toolResult) }],
1618
- status: "success",
1619
- },
1620
- });
1621
- }
1622
- catch (error) {
1623
- logger.error(`[AmazonBedrockProvider] Tool execution failed: ${contentItem.toolUse.name}`, error);
1624
- const errorMessage = error instanceof Error ? error.message : String(error);
1625
- // Track failed tool result
1626
- toolResultsForStorage.push({
1627
- type: "tool-result",
1628
- toolCallId: contentItem.toolUse.toolUseId,
1629
- toolName: contentItem.toolUse.name,
1630
- result: { error: errorMessage },
1631
- });
1632
- toolResults.push({
1633
- toolResult: {
1634
- toolUseId: contentItem.toolUse.toolUseId,
1635
- content: [
1636
- {
1637
- text: `Error executing tool ${contentItem.toolUse.name}: ${errorMessage}`,
1638
- },
1639
- ],
1640
- status: "error",
1641
- },
1642
- });
1643
- }
1644
- }
1645
- }
1646
- logger.debug(`[AmazonBedrockProvider] Created ${toolResults.length} toolResult blocks for ${toolUseCount} toolUse blocks`);
1647
- // Validate 1:1 mapping before adding to conversation
1648
- if (toolResults.length !== toolUseCount) {
1649
- logger.error(`[AmazonBedrockProvider] Mismatch: ${toolResults.length} toolResults vs ${toolUseCount} toolUse blocks`);
1650
- throw new Error(`Tool mapping mismatch: ${toolResults.length} toolResults for ${toolUseCount} toolUse blocks`);
1651
- }
1652
- // Add tool results as user message - exact structure like Bedrock-MCP-Connector
1653
- if (toolResults.length > 0) {
1654
- const userMessageWithToolResults = {
1655
- role: "user",
1656
- content: toolResults,
1657
- };
1658
- this.conversationHistory.push(userMessageWithToolResults);
1659
- logger.debug(`[AmazonBedrockProvider] Added ${toolResults.length} tool results to conversation (1:1 mapping validated)`);
1660
- // Emit tool:end for each completed tool result so Pipeline B
1661
- // captures telemetry for Bedrock-driven tool calls (gap S2).
1662
- emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), toolResultsForStorage.map((tr) => {
1663
- const hasError = tr.result && typeof tr.result === "object" && "error" in tr.result;
1664
- return {
1665
- toolName: tr.toolName,
1666
- result: tr.result,
1667
- error: hasError
1668
- ? String(tr.result.error)
1669
- : undefined,
1670
- };
1671
- }));
1672
- // Store tool execution for analytics and debugging (similar to Vertex onStepFinish)
1673
- this.handleToolExecutionStorage(toolCalls, toolResultsForStorage, options, new Date()).catch((error) => {
1674
- logger.warn("[AmazonBedrockProvider] Failed to store tool executions", {
1675
- provider: this.providerName,
1676
- error: error instanceof Error ? error.message : String(error),
1677
- });
1678
- });
1679
- }
1680
1006
  }
1681
1007
  /**
1682
1008
  * Health check for Amazon Bedrock service