@juspay/neurolink 12.47.14 → 12.48.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 (94) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/auth/middleware/AuthMiddleware.d.ts +1 -1
  3. package/dist/browser/neurolink.min.js +578 -565
  4. package/dist/cli/factories/commandFactory.d.ts +7 -0
  5. package/dist/cli/factories/commandFactory.js +26 -0
  6. package/dist/cli/loop/optionsSchema.js +9 -0
  7. package/dist/context/budgetChecker.js +54 -2
  8. package/dist/context/contextDecision.js +2 -0
  9. package/dist/context/toolPairRepair.d.ts +12 -0
  10. package/dist/context/toolPairRepair.js +29 -3
  11. package/dist/core/baseProvider.d.ts +92 -3
  12. package/dist/core/baseProvider.js +254 -10
  13. package/dist/core/conversationMemoryManager.js +17 -2
  14. package/dist/core/loopEngine.js +1 -1
  15. package/dist/core/modules/MessageBuilder.js +11 -3
  16. package/dist/core/modules/TelemetryHandler.d.ts +2 -2
  17. package/dist/core/modules/ToolsManager.d.ts +35 -0
  18. package/dist/core/modules/ToolsManager.js +159 -55
  19. package/dist/core/nativeGenerateLoop.d.ts +58 -1
  20. package/dist/core/nativeGenerateLoop.js +277 -3
  21. package/dist/core/redisConversationMemoryManager.js +10 -3
  22. package/dist/core/resolveRequestKind.d.ts +24 -4
  23. package/dist/core/resolveRequestKind.js +31 -4
  24. package/dist/core/toolExecutionRecorder.d.ts +6 -0
  25. package/dist/core/toolExecutionRecorder.js +24 -2
  26. package/dist/core/toolRoutingDecision.js +1 -0
  27. package/dist/factories/mediaHandlerCatalog.js +5 -0
  28. package/dist/factories/providerDescriptors.js +15 -0
  29. package/dist/files/fileTools.d.ts +1 -1
  30. package/dist/index.d.ts +5 -2
  31. package/dist/index.js +9 -2
  32. package/dist/memory/condensationPrompt.d.ts +8 -0
  33. package/dist/memory/condensationPrompt.js +29 -0
  34. package/dist/memory/hippocampusInitializer.d.ts +14 -2
  35. package/dist/memory/hippocampusInitializer.js +64 -3
  36. package/dist/neurolink.d.ts +252 -3
  37. package/dist/neurolink.js +898 -43
  38. package/dist/providers/amazonBedrock/client.js +6 -0
  39. package/dist/providers/amazonSagemaker.js +14 -1
  40. package/dist/providers/anthropic/client.js +148 -19
  41. package/dist/providers/anthropic/loopAdapter.js +2 -2
  42. package/dist/providers/googleAiStudio/client.js +2 -1
  43. package/dist/providers/openaiChatCompletionsBase.js +103 -23
  44. package/dist/providers/systemOneDecision.d.ts +6 -0
  45. package/dist/providers/systemOneDecision.js +14 -63
  46. package/dist/providers/typesafe.d.ts +6 -0
  47. package/dist/providers/typesafe.js +6 -0
  48. package/dist/rag/retrieval/searchDecision.js +1 -0
  49. package/dist/routing/classifierRouter.js +6 -5
  50. package/dist/routing/classifierStrategies.js +8 -1
  51. package/dist/types/classifierRouter.d.ts +16 -3
  52. package/dist/types/common.d.ts +2 -0
  53. package/dist/types/config.d.ts +9 -0
  54. package/dist/types/context.d.ts +8 -1
  55. package/dist/types/conversation.d.ts +87 -0
  56. package/dist/types/decision.d.ts +210 -1
  57. package/dist/types/decision.js +9 -1
  58. package/dist/types/dispatch.d.ts +11 -4
  59. package/dist/types/generate.d.ts +118 -54
  60. package/dist/types/loopEngine.d.ts +7 -2
  61. package/dist/types/memory.d.ts +177 -9
  62. package/dist/types/openaiCompatible.d.ts +11 -3
  63. package/dist/types/stream.d.ts +13 -16
  64. package/dist/types/stt.d.ts +58 -1
  65. package/dist/types/tools.d.ts +14 -0
  66. package/dist/types/tools.js +8 -0
  67. package/dist/types/tts.d.ts +108 -0
  68. package/dist/types/voice.d.ts +41 -4
  69. package/dist/utils/decisionAnswers.d.ts +31 -1
  70. package/dist/utils/decisionAnswers.js +114 -0
  71. package/dist/utils/decisionLimits.d.ts +39 -0
  72. package/dist/utils/decisionLimits.js +116 -0
  73. package/dist/utils/logger.d.ts +7 -0
  74. package/dist/utils/logger.js +9 -0
  75. package/dist/utils/messageBuilder.d.ts +45 -1
  76. package/dist/utils/messageBuilder.js +393 -16
  77. package/dist/utils/multimodalOptionsBuilder.d.ts +1 -0
  78. package/dist/utils/multimodalOptionsBuilder.js +1 -0
  79. package/dist/utils/speechText.d.ts +41 -0
  80. package/dist/utils/speechText.js +364 -0
  81. package/dist/utils/toolResultStatus.d.ts +26 -0
  82. package/dist/utils/toolResultStatus.js +73 -0
  83. package/dist/utils/ttsProcessor.d.ts +29 -0
  84. package/dist/utils/ttsProcessor.js +84 -2
  85. package/dist/voice/index.d.ts +1 -0
  86. package/dist/voice/index.js +3 -0
  87. package/dist/voice/providers/ElevenLabsSTT.d.ts +42 -0
  88. package/dist/voice/providers/ElevenLabsSTT.js +351 -0
  89. package/dist/voice/providers/ElevenLabsTTS.d.ts +81 -1
  90. package/dist/voice/providers/ElevenLabsTTS.js +415 -74
  91. package/docs-site/static/search-index.json +48 -33
  92. package/package.json +5 -2
  93. package/dist/utils/toolEndEmitter.d.ts +0 -25
  94. package/dist/utils/toolEndEmitter.js +0 -64
@@ -3,9 +3,26 @@ import { createToolEventPayload } from "../toolEvents.js";
3
3
  import { tracers, ATTR, withSpan } from "../../telemetry/index.js";
4
4
  import { SpanStatusCode } from "@opentelemetry/api";
5
5
  import { logger } from "../../utils/logger.js";
6
+ import { TOOL_EVENTS_WRAPPED } from "../../types/index.js";
6
7
  import { getKeyCount } from "../../utils/transformationUtils.js";
8
+ import { extractIsErrorText } from "../../utils/toolResultStatus.js";
7
9
  import { convertJsonSchemaToZod } from "../../utils/schemaConversion.js";
8
10
  import { generateToolOutputPreview, DEFAULT_MAX_PREVIEW_BYTES, RETRIEVE_CONTEXT_TOOL_NAME, } from "../../context/toolOutputLimits.js";
11
+ /**
12
+ * Own property stamped on a tool object whose `execute` already emits
13
+ * `tool:start` / `tool:end`. It lives on the OBJECT, not the function: later
14
+ * layers (the execution recorder, discovery) replace `execute` via
15
+ * `{ ...tool, execute }`, which drops a function-identity check but carries a
16
+ * symbol-keyed own property along — so one execution still yields one pair.
17
+ */
18
+ function isEventWrappedTool(tool) {
19
+ return (typeof tool === "object" &&
20
+ tool !== null &&
21
+ tool[TOOL_EVENTS_WRAPPED] === true);
22
+ }
23
+ function markEventWrapped(tool) {
24
+ return Object.assign(tool, { [TOOL_EVENTS_WRAPPED]: true });
25
+ }
9
26
  import { tool as createAISDKTool, jsonSchema } from "../../utils/tool.js";
10
27
  /** Abort-shaped error so provider loops route it to their cancellation path. */
11
28
  function makeToolAbortError() {
@@ -183,6 +200,9 @@ export class ToolsManager {
183
200
  // Session context
184
201
  sessionId;
185
202
  userId;
203
+ // Execute functions this manager already instrumented for tool events,
204
+ // so re-wrapping is a no-op rather than a second start/end pair.
205
+ eventWrappedExecutes = new WeakSet();
186
206
  constructor(providerName, directTools, neurolink, utilities) {
187
207
  this.providerName = providerName;
188
208
  this.directTools = directTools;
@@ -443,12 +463,128 @@ export class ToolsManager {
443
463
  this.sessionId = sessionId;
444
464
  this.userId = userId;
445
465
  }
466
+ /**
467
+ * Emit one tool event. A listener that throws must never reach the tool
468
+ * loop: it would turn a successful execution into a failed one (the model
469
+ * then retries a side effect that already happened) and, from inside the
470
+ * wrapper's catch, produce a second `tool:end` for the same call. Listener
471
+ * failures are logged and swallowed here instead.
472
+ */
446
473
  emitToolEvent(eventName, toolName, payload) {
447
- if (this.neurolink?.getEventEmitter) {
474
+ if (!this.neurolink?.getEventEmitter) {
475
+ return;
476
+ }
477
+ try {
448
478
  this.neurolink
449
479
  .getEventEmitter()
450
480
  .emit(eventName, createToolEventPayload(toolName, payload));
451
481
  }
482
+ catch (listenerError) {
483
+ logger.warn(`A ${eventName} listener threw for tool ${toolName}`, {
484
+ error: listenerError instanceof Error
485
+ ? listenerError.message
486
+ : String(listenerError),
487
+ });
488
+ }
489
+ }
490
+ /**
491
+ * Wrap an execute function so one `tool:start` and exactly one `tool:end`
492
+ * (with the real duration) fire per invocation. Idempotent: a function this
493
+ * manager already wrapped is returned as is, so a tool that re-enters
494
+ * resolution (the stream → generate fallback re-resolves `options.tools`)
495
+ * never emits twice.
496
+ *
497
+ * A result the tool RETURNED with `isError: true` (MCP failure, breaker
498
+ * refusal, a thrown error already converted by `neurolink.executeTool`) is
499
+ * a failure: `tool:end` carries `success: false` and the extracted text,
500
+ * as the step-finish emitter this wrapper replaced did.
501
+ */
502
+ wrapExecuteWithEvents(toolName, execute) {
503
+ if (this.eventWrappedExecutes.has(execute)) {
504
+ return execute;
505
+ }
506
+ const wrapped = async (params, execOptions) => {
507
+ const startTime = Date.now();
508
+ // The loop's execute options carry the model's tool_call id, which is
509
+ // what lets a listener pair concurrent same-name start/end events.
510
+ const toolCallId = execOptions && typeof execOptions === "object"
511
+ ? execOptions.toolCallId
512
+ : undefined;
513
+ const idField = typeof toolCallId === "string" && toolCallId.length > 0
514
+ ? { toolCallId }
515
+ : {};
516
+ this.emitToolEvent("tool:start", toolName, {
517
+ input: params,
518
+ ...idField,
519
+ });
520
+ let result;
521
+ try {
522
+ result = await execute(params, execOptions);
523
+ }
524
+ catch (error) {
525
+ const errorMsg = error instanceof Error ? error.message : String(error);
526
+ this.emitToolEvent("tool:end", toolName, {
527
+ error: errorMsg,
528
+ success: false,
529
+ responseTime: Date.now() - startTime,
530
+ ...idField,
531
+ });
532
+ throw error;
533
+ }
534
+ const isErrorText = extractIsErrorText(result);
535
+ this.emitToolEvent("tool:end", toolName, {
536
+ result,
537
+ success: isErrorText === undefined,
538
+ ...(isErrorText !== undefined ? { error: isErrorText } : {}),
539
+ responseTime: Date.now() - startTime,
540
+ ...idField,
541
+ });
542
+ return result;
543
+ };
544
+ this.eventWrappedExecutes.add(wrapped);
545
+ return wrapped;
546
+ }
547
+ /**
548
+ * Report a tool call the loop rejected BEFORE any execute ran — an unknown
549
+ * tool name, arguments the schema rejected, a breaker skip. Nothing else
550
+ * emits for such a call, so listeners (memory event capture, tool spans,
551
+ * error-rate metrics) saw zero attempts while the result list held the
552
+ * failures. One `tool:start` / `tool:end(success: false)` pair.
553
+ */
554
+ emitRejectedToolCall(toolName, error, toolCallId) {
555
+ const idField = toolCallId ? { toolCallId } : {};
556
+ this.emitToolEvent("tool:start", toolName, { ...idField });
557
+ this.emitToolEvent("tool:end", toolName, {
558
+ error,
559
+ success: false,
560
+ responseTime: 0,
561
+ ...idField,
562
+ });
563
+ }
564
+ /**
565
+ * Give per-call tools (`options.tools`) the same `tool:start` / `tool:end`
566
+ * emission that registered and MCP tools get from their executors. Tools
567
+ * without an `execute` pass through untouched.
568
+ */
569
+ wrapExternalToolsWithEvents(tools) {
570
+ const wrapped = {};
571
+ for (const [toolName, tool] of Object.entries(tools)) {
572
+ if (isEventWrappedTool(tool)) {
573
+ wrapped[toolName] = tool;
574
+ continue;
575
+ }
576
+ const execute = tool && typeof tool === "object" && "execute" in tool
577
+ ? tool.execute
578
+ : undefined;
579
+ wrapped[toolName] =
580
+ typeof execute === "function"
581
+ ? markEventWrapped({
582
+ ...tool,
583
+ execute: this.wrapExecuteWithEvents(toolName, execute),
584
+ })
585
+ : tool;
586
+ }
587
+ return wrapped;
452
588
  }
453
589
  /**
454
590
  * Set up tool executor for a provider to enable actual tool execution
@@ -586,31 +722,10 @@ export class ToolsManager {
586
722
  const originalExecute = directTool.execute;
587
723
  // Create a new tool with wrapped execute function (BZ-666/BZ-664 guards applied)
588
724
  const guardedExecute = this.wrapExecuteWithTruncation(toolName, originalExecute);
589
- tools[toolName] = {
725
+ tools[toolName] = markEventWrapped({
590
726
  ...directTool,
591
- execute: async (params, execOptions) => {
592
- const startTime = Date.now();
593
- this.emitToolEvent("tool:start", toolName, { input: params });
594
- try {
595
- const result = await guardedExecute(params, execOptions);
596
- this.emitToolEvent("tool:end", toolName, {
597
- result,
598
- success: true,
599
- responseTime: Date.now() - startTime,
600
- });
601
- return result;
602
- }
603
- catch (error) {
604
- const errorMsg = error instanceof Error ? error.message : String(error);
605
- this.emitToolEvent("tool:end", toolName, {
606
- error: errorMsg,
607
- success: false,
608
- responseTime: Date.now() - startTime,
609
- });
610
- throw error;
611
- }
612
- },
613
- };
727
+ execute: this.wrapExecuteWithEvents(toolName, guardedExecute),
728
+ });
614
729
  }
615
730
  else {
616
731
  // Fallback: include tool as-is if it doesn't have execute function
@@ -722,7 +837,9 @@ export class ToolsManager {
722
837
  else {
723
838
  finalSchema = z.object({});
724
839
  }
725
- return createAISDKTool({
840
+ // Emits its own tool:start / tool:end through neurolink.executeTool, so
841
+ // it is marked as instrumented and never wrapped a second time.
842
+ return markEventWrapped(createAISDKTool({
726
843
  description: toolInfo.description || `Tool ${toolName}`,
727
844
  inputSchema: finalSchema, // AI SDK v6 uses inputSchema (not parameters)
728
845
  execute: async (params) => {
@@ -849,7 +966,7 @@ export class ToolsManager {
849
966
  customToolSpan.end();
850
967
  }
851
968
  },
852
- });
969
+ }));
853
970
  }
854
971
  catch (toolCreationError) {
855
972
  logger.error(`Failed to create tool: ${toolName}`, toolCreationError);
@@ -902,36 +1019,23 @@ export class ToolsManager {
902
1019
  throw new Error(`Cannot execute external MCP tool: NeuroLink executeExternalMCPTool not available`);
903
1020
  };
904
1021
  const guardedExecute = this.wrapExecuteWithTruncation(tool.name, rawExecute);
905
- return createAISDKTool({
1022
+ const loggedExecute = async (params, execOptions) => {
1023
+ try {
1024
+ return await guardedExecute(params, execOptions);
1025
+ }
1026
+ catch (mcpError) {
1027
+ logger.error(`External MCP tool failed: ${tool.name}`, {
1028
+ serverId: tool.serverId,
1029
+ error: mcpError instanceof Error ? mcpError.message : String(mcpError),
1030
+ });
1031
+ throw mcpError;
1032
+ }
1033
+ };
1034
+ return markEventWrapped(createAISDKTool({
906
1035
  description: tool.description || `External MCP tool ${tool.name}`,
907
1036
  inputSchema: finalSchema, // AI SDK v6 uses inputSchema (not parameters)
908
- execute: async (params, execOptions) => {
909
- const startTime = Date.now();
910
- this.emitToolEvent("tool:start", tool.name, { input: params });
911
- try {
912
- const result = await guardedExecute(params, execOptions);
913
- this.emitToolEvent("tool:end", tool.name, {
914
- result,
915
- success: true,
916
- responseTime: Date.now() - startTime,
917
- });
918
- return result;
919
- }
920
- catch (mcpError) {
921
- const errorMsg = mcpError instanceof Error ? mcpError.message : String(mcpError);
922
- this.emitToolEvent("tool:end", tool.name, {
923
- error: errorMsg,
924
- success: false,
925
- responseTime: Date.now() - startTime,
926
- });
927
- logger.error(`External MCP tool failed: ${tool.name}`, {
928
- serverId: tool.serverId,
929
- error: errorMsg,
930
- });
931
- throw mcpError;
932
- }
933
- },
934
- });
1037
+ execute: this.wrapExecuteWithEvents(tool.name, loggedExecute),
1038
+ }));
935
1039
  }
936
1040
  catch (toolCreationError) {
937
1041
  logger.error(`Failed to create external MCP tool: ${tool.name}`, toolCreationError);
@@ -13,7 +13,64 @@
13
13
  * share this: the v3 result shape (`content` parts, `finishReason`, `usage`) is
14
14
  * the same across Anthropic, the OpenAI-compatible family and SageMaker.
15
15
  */
16
- import type { NativeGenerateLoopArgs, NativeGenerateLoopResult, ToolExecutionSummaryInternal } from "../types/index.js";
16
+ import type { NativeGenerateLoopArgs, NativeGenerateLoopResult, StepResult, StepToolChoiceInput, Tool, ToolExecutionSummaryInternal } from "../types/index.js";
17
+ /**
18
+ * Decide the tool choice for ONE step of a multi-step loop.
19
+ *
20
+ * A forced choice held on every step compels a tool call on every step, so
21
+ * the loop could only end when `maxSteps` ran out — which is exactly what
22
+ * happened before this existed. The rule: a forced choice applies while
23
+ * `step < toolChoiceSteps` (default 1), after which the model chooses
24
+ * (`"auto"`). `"auto"` and `"none"` pass through unchanged on every step.
25
+ *
26
+ * A `prepareStep` hook, when supplied, runs first and its `toolChoice` wins
27
+ * for the step — including a forced choice past `toolChoiceSteps`, which is
28
+ * how a caller forces a tool on step 3 only. A hook that returns no
29
+ * `toolChoice` defers to the rule. Nothing else on the hook's result is read.
30
+ *
31
+ * A hook that THROWS is logged and treated as "no override": the steps
32
+ * already billed are kept and the rule decides. An abort of the turn while
33
+ * the hook is pending is not a hook failure and is re-thrown. The two are
34
+ * told apart by the LOOP's signal, never by the error's name: a hook that
35
+ * rejects with its own `AbortError` (its policy service timed out) has
36
+ * failed, and must not end a turn the caller never cancelled.
37
+ */
38
+ export declare function resolveStepToolChoice(input: StepToolChoiceInput): Promise<unknown>;
39
+ /**
40
+ * Translate a NeuroLink-shape tool choice (`"auto"` / `"none"` /
41
+ * `"required"` / `{ type: "tool", toolName }`) into the v3 call-option
42
+ * OBJECT every `doGenerate` converter switches on. The bare strings were
43
+ * being handed straight to `doGenerate`; Anthropic's converter normalises
44
+ * them, but the OpenAI-compatible one reads `.type` off the value and
45
+ * silently dropped every string form. An already-object value passes
46
+ * through untouched.
47
+ */
48
+ export declare const toV3ToolChoice: (choice: unknown) => Record<string, unknown> | undefined;
49
+ /**
50
+ * The record of one completed step that `prepareStep` receives in `steps`.
51
+ * Built the same way by every native loop so a hook sees one shape.
52
+ */
53
+ export declare const toPrepareStepRecord: (step: {
54
+ stepNumber: number;
55
+ content: Array<Record<string, unknown>>;
56
+ text: string;
57
+ toolCalls: Array<{
58
+ toolName: string;
59
+ toolCallId: string;
60
+ input: unknown;
61
+ }>;
62
+ toolResults: Array<{
63
+ toolName: string;
64
+ toolCallId: string;
65
+ output: unknown;
66
+ }>;
67
+ finishReason: string;
68
+ inputTokens: number;
69
+ outputTokens: number;
70
+ /** Prompt-cache tokens for the step, when the provider reports them. */
71
+ cacheReadTokens?: number;
72
+ cacheWriteTokens?: number;
73
+ }) => StepResult<Record<string, Tool>>;
17
74
  /**
18
75
  * Narrow a model handle to the delegating shape this loop drives.
19
76
  * `LanguageModel` is a union that includes a bare string id, and a double
@@ -15,6 +15,221 @@
15
15
  */
16
16
  import { logger } from "../utils/logger.js";
17
17
  import { guardToolExecutor } from "./toolExecutionGuards.js";
18
+ const DEFAULT_TOOL_CHOICE_STEPS = 1;
19
+ /**
20
+ * A choice that compels a tool call: `"required"` or a named tool, in either
21
+ * the NeuroLink string/object shape or the v3 `{ type }` object shape.
22
+ * `"auto"` and `"none"` leave the model free (or forbidden) and are never
23
+ * time-limited.
24
+ */
25
+ const isForcedToolChoice = (choice) => {
26
+ if (choice === "required") {
27
+ return true;
28
+ }
29
+ if (typeof choice === "object" && choice !== null) {
30
+ const type = choice.type;
31
+ return type === "required" || type === "tool" || type === "any";
32
+ }
33
+ return false;
34
+ };
35
+ /**
36
+ * `toolChoiceSteps` is a count of leading steps, so anything but a
37
+ * non-negative integer is meaningless. Fall back to the default with a WARN
38
+ * rather than throw: a bad knob should not fail a turn that would otherwise
39
+ * complete, but it must not be silent either.
40
+ */
41
+ const normalizeToolChoiceSteps = (value) => {
42
+ if (value === undefined) {
43
+ return DEFAULT_TOOL_CHOICE_STEPS;
44
+ }
45
+ if (Number.isInteger(value) && value >= 0) {
46
+ return value;
47
+ }
48
+ logger.warn(`toolChoiceSteps must be a non-negative integer; got ${String(value)} — using ${DEFAULT_TOOL_CHOICE_STEPS}`);
49
+ return DEFAULT_TOOL_CHOICE_STEPS;
50
+ };
51
+ /**
52
+ * Fields of a `prepareStep` result that the former AI-SDK loop honoured and
53
+ * the native loops do not. Their presence is reported once per process: a
54
+ * hook written for the old loop that hides a destructive tool through
55
+ * `experimental_activeTools` would otherwise fail open in silence.
56
+ */
57
+ const IGNORED_PREPARE_STEP_FIELDS = [
58
+ "model",
59
+ "activeTools",
60
+ "experimental_activeTools",
61
+ ];
62
+ let warnedIgnoredPrepareStepFields = false;
63
+ const warnIgnoredPrepareStepFields = (prepared) => {
64
+ if (warnedIgnoredPrepareStepFields ||
65
+ !prepared ||
66
+ typeof prepared !== "object") {
67
+ return;
68
+ }
69
+ const present = IGNORED_PREPARE_STEP_FIELDS.filter((field) => prepared[field] !== undefined);
70
+ if (present.length === 0) {
71
+ return;
72
+ }
73
+ warnedIgnoredPrepareStepFields = true;
74
+ logger.warn(`prepareStep returned ${present.join(", ")}, which the native loops ignore — only toolChoice is honoured. Use toolFilter / excludeTools for tool visibility and set the model on the request. (reported once)`);
75
+ };
76
+ /**
77
+ * Await the caller's hook without letting it outlive the turn: a hook that
78
+ * stalls (a remote policy service that never answers) parked the loop past
79
+ * every deadline, because nothing observed the abort signal while the hook
80
+ * was pending. An abort rejects with the signal's reason so the loop's
81
+ * cancellation path handles it like any other abort.
82
+ */
83
+ const awaitPrepareStep = (pending, abortSignal) => {
84
+ if (!abortSignal) {
85
+ return Promise.resolve(pending);
86
+ }
87
+ if (abortSignal.aborted) {
88
+ // The hook has already been invoked. Its promise is abandoned here, so
89
+ // a rejection it settles with later must have a handler: without one it
90
+ // is an unhandled rejection, which terminates Node by default.
91
+ void Promise.resolve(pending).catch(() => undefined);
92
+ return Promise.reject(abortReason(abortSignal));
93
+ }
94
+ return new Promise((resolve, reject) => {
95
+ const onAbort = () => reject(abortReason(abortSignal));
96
+ abortSignal.addEventListener("abort", onAbort, { once: true });
97
+ // Both handlers stay attached after an abort wins the race, so a hook
98
+ // that rejects afterwards is observed (the second `reject` is a no-op).
99
+ Promise.resolve(pending).then((value) => {
100
+ abortSignal.removeEventListener("abort", onAbort);
101
+ resolve(value);
102
+ }, (error) => {
103
+ abortSignal.removeEventListener("abort", onAbort);
104
+ reject(error);
105
+ });
106
+ });
107
+ };
108
+ const abortReason = (signal) => {
109
+ if (signal.reason instanceof Error) {
110
+ return signal.reason;
111
+ }
112
+ const error = new Error(typeof signal.reason === "string" ? signal.reason : "Request was aborted.");
113
+ error.name = "AbortError";
114
+ return error;
115
+ };
116
+ /**
117
+ * Decide the tool choice for ONE step of a multi-step loop.
118
+ *
119
+ * A forced choice held on every step compels a tool call on every step, so
120
+ * the loop could only end when `maxSteps` ran out — which is exactly what
121
+ * happened before this existed. The rule: a forced choice applies while
122
+ * `step < toolChoiceSteps` (default 1), after which the model chooses
123
+ * (`"auto"`). `"auto"` and `"none"` pass through unchanged on every step.
124
+ *
125
+ * A `prepareStep` hook, when supplied, runs first and its `toolChoice` wins
126
+ * for the step — including a forced choice past `toolChoiceSteps`, which is
127
+ * how a caller forces a tool on step 3 only. A hook that returns no
128
+ * `toolChoice` defers to the rule. Nothing else on the hook's result is read.
129
+ *
130
+ * A hook that THROWS is logged and treated as "no override": the steps
131
+ * already billed are kept and the rule decides. An abort of the turn while
132
+ * the hook is pending is not a hook failure and is re-thrown. The two are
133
+ * told apart by the LOOP's signal, never by the error's name: a hook that
134
+ * rejects with its own `AbortError` (its policy service timed out) has
135
+ * failed, and must not end a turn the caller never cancelled.
136
+ */
137
+ export async function resolveStepToolChoice(input) {
138
+ if (input.prepareStep) {
139
+ let prepared;
140
+ try {
141
+ prepared = await awaitPrepareStep(input.prepareStep({
142
+ steps: input.steps,
143
+ stepNumber: input.step,
144
+ maxSteps: input.maxSteps,
145
+ model: input.model,
146
+ }), input.abortSignal);
147
+ }
148
+ catch (hookError) {
149
+ if (input.abortSignal?.aborted) {
150
+ throw abortReason(input.abortSignal);
151
+ }
152
+ logger.warn(`prepareStep threw at step ${input.step}; using the default tool choice for this step`, {
153
+ error: hookError instanceof Error ? hookError.message : String(hookError),
154
+ });
155
+ prepared = undefined;
156
+ }
157
+ warnIgnoredPrepareStepFields(prepared);
158
+ if (prepared?.toolChoice !== undefined) {
159
+ return prepared.toolChoice;
160
+ }
161
+ }
162
+ if (input.base === undefined) {
163
+ return undefined;
164
+ }
165
+ if (!isForcedToolChoice(input.base) ||
166
+ input.step < normalizeToolChoiceSteps(input.toolChoiceSteps)) {
167
+ return input.base;
168
+ }
169
+ return "auto";
170
+ }
171
+ /**
172
+ * Translate a NeuroLink-shape tool choice (`"auto"` / `"none"` /
173
+ * `"required"` / `{ type: "tool", toolName }`) into the v3 call-option
174
+ * OBJECT every `doGenerate` converter switches on. The bare strings were
175
+ * being handed straight to `doGenerate`; Anthropic's converter normalises
176
+ * them, but the OpenAI-compatible one reads `.type` off the value and
177
+ * silently dropped every string form. An already-object value passes
178
+ * through untouched.
179
+ */
180
+ export const toV3ToolChoice = (choice) => {
181
+ if (choice === undefined || choice === null) {
182
+ return undefined;
183
+ }
184
+ if (typeof choice === "string") {
185
+ return choice === "auto" || choice === "none" || choice === "required"
186
+ ? { type: choice }
187
+ : undefined;
188
+ }
189
+ if (typeof choice === "object") {
190
+ return choice;
191
+ }
192
+ return undefined;
193
+ };
194
+ const KNOWN_FINISH_REASONS = new Set([
195
+ "stop",
196
+ "length",
197
+ "content-filter",
198
+ "tool-calls",
199
+ "error",
200
+ "other",
201
+ ]);
202
+ const toFinishReason = (value) => KNOWN_FINISH_REASONS.has(value) ? value : "other";
203
+ /**
204
+ * The record of one completed step that `prepareStep` receives in `steps`.
205
+ * Built the same way by every native loop so a hook sees one shape.
206
+ */
207
+ export const toPrepareStepRecord = (step) => {
208
+ const hasCacheDetail = step.cacheReadTokens !== undefined || step.cacheWriteTokens !== undefined;
209
+ return {
210
+ stepNumber: step.stepNumber,
211
+ content: step.content,
212
+ text: step.text,
213
+ toolCalls: step.toolCalls,
214
+ toolResults: step.toolResults,
215
+ finishReason: toFinishReason(step.finishReason),
216
+ usage: {
217
+ inputTokens: step.inputTokens,
218
+ outputTokens: step.outputTokens,
219
+ totalTokens: step.inputTokens + step.outputTokens,
220
+ ...(hasCacheDetail
221
+ ? {
222
+ inputTokenDetails: {
223
+ noCacheTokens: step.inputTokens,
224
+ cacheReadTokens: step.cacheReadTokens ?? 0,
225
+ cacheWriteTokens: step.cacheWriteTokens ?? 0,
226
+ },
227
+ cachedInputTokens: step.cacheReadTokens ?? 0,
228
+ }
229
+ : {}),
230
+ },
231
+ };
232
+ };
18
233
  /**
19
234
  * Narrow a model handle to the delegating shape this loop drives.
20
235
  * `LanguageModel` is a union that includes a bare string id, and a double
@@ -122,8 +337,29 @@ export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
122
337
  // answer is that result — not a thrown turn.
123
338
  let preReask;
124
339
  const hasTools = Boolean(args.tools && args.tools.length > 0);
340
+ // Completed-step records for `prepareStep`; one per step that returned.
341
+ const stepRecords = [];
125
342
  for (let step = 0; step < args.maxSteps; step++) {
126
343
  steps = step + 1;
344
+ // The forced-choice window and the caller's hook are resolved BEFORE the
345
+ // re-ask override below: the re-ask has to win, since its whole point is
346
+ // a request with tools disabled.
347
+ // A request with no tools declares no tool_choice either: a hook that
348
+ // forces a tool on a tool-less request (the schema reformat pass starts
349
+ // every pass at step 0) would be a 400 on OpenAI-compatible backends.
350
+ const stepToolChoice = hasTools
351
+ ? await resolveStepToolChoice({
352
+ base: args.toolChoice,
353
+ step,
354
+ toolChoiceSteps: args.toolChoiceSteps,
355
+ prepareStep: args.prepareStep,
356
+ steps: stepRecords,
357
+ maxSteps: args.maxSteps,
358
+ model: args.modelId,
359
+ ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
360
+ })
361
+ : undefined;
362
+ const wireToolChoice = toV3ToolChoice(stepToolChoice);
127
363
  // Per-step context reclaim. The pre-dispatch budget check runs ONCE and
128
364
  // never sees the assistant turns and tool results this loop appends, which
129
365
  // is how a long agentic run overflows the model window mid-loop and loses
@@ -145,11 +381,12 @@ export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
145
381
  // bare string "none" type-checks against `unknown` and is then
146
382
  // dropped by every converter that switches on `choice.type`, so the
147
383
  // re-ask silently went out unchanged. Caught by the stand-in asserting
148
- // the wire body, not by any live provider.
384
+ // the wire body, not by any live provider. The same applied to the
385
+ // caller's own string-form choice, hence `toV3ToolChoice` above.
149
386
  ...(reasked
150
387
  ? { toolChoice: { type: "none" } }
151
- : args.toolChoice !== undefined
152
- ? { toolChoice: args.toolChoice }
388
+ : wireToolChoice !== undefined
389
+ ? { toolChoice: wireToolChoice }
153
390
  : {}),
154
391
  ...(args.responseFormat
155
392
  ? { responseFormat: args.responseFormat }
@@ -237,6 +474,39 @@ export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
237
474
  cacheReadTokens += inShaped?.cacheRead ?? 0;
238
475
  cacheWriteTokens += inShaped?.cacheWrite ?? 0;
239
476
  const calls = parts.filter((p) => p.type === "tool-call");
477
+ const stepInputTokens = typeof inShaped?.noCache === "number"
478
+ ? inShaped.noCache
479
+ : readTotal(usage?.inputTokens);
480
+ const stepOutputTokens = readTotal(usage?.outputTokens);
481
+ // `input` is the PARSED object, as the stream loops record it: the v3
482
+ // part carries the raw JSON string, and a hook reading
483
+ // `steps.at(-1)?.toolCalls[0]?.input?.orderId` got undefined on
484
+ // generate() while the same hook worked on stream().
485
+ const stepToolCalls = calls.map((call) => ({
486
+ toolName: String(call.toolName ?? ""),
487
+ toolCallId: String(call.toolCallId ?? ""),
488
+ input: parseToolInput(call.input).input,
489
+ }));
490
+ // Pushed now with empty results and filled in below once the tools have
491
+ // run, so a hook consulted on the next step sees this step's outputs.
492
+ const stepRecord = toPrepareStepRecord({
493
+ stepNumber: step,
494
+ content: parts,
495
+ text,
496
+ toolCalls: stepToolCalls,
497
+ toolResults: [],
498
+ finishReason,
499
+ inputTokens: stepInputTokens,
500
+ outputTokens: stepOutputTokens,
501
+ ...(inShaped?.cacheRead !== undefined ||
502
+ inShaped?.cacheWrite !== undefined
503
+ ? {
504
+ cacheReadTokens: inShaped?.cacheRead ?? 0,
505
+ cacheWriteTokens: inShaped?.cacheWrite ?? 0,
506
+ }
507
+ : {}),
508
+ });
509
+ stepRecords.push(stepRecord);
240
510
  if (calls.length === 0) {
241
511
  // io.net's Llama endpoint ends a tool loop on `finish_reason:
242
512
  // tool_calls` carrying neither a tool call nor any text: the model's
@@ -287,6 +557,7 @@ export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
287
557
  if (typeof tool?.execute !== "function") {
288
558
  failure = `Tool not found: ${name}`;
289
559
  output = { error: failure };
560
+ args.onRejectedToolCall?.(name, failure, id);
290
561
  }
291
562
  else if (rejection) {
292
563
  // An error tool-result rather than a throw: the model gets to see what
@@ -294,6 +565,7 @@ export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
294
565
  // own validation did.
295
566
  failure = `Tool ${name}: ${rejection}`;
296
567
  output = { error: failure };
568
+ args.onRejectedToolCall?.(name, failure, id);
297
569
  }
298
570
  else {
299
571
  try {
@@ -326,8 +598,10 @@ export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
326
598
  ...(failure ? { error: failure } : { output }),
327
599
  startTime,
328
600
  endTime: new Date(),
601
+ stepIndex: step,
329
602
  });
330
603
  resultParts.push({ type: "tool-result", toolCallId: id, output });
604
+ stepRecord.toolResults.push({ toolName: name, toolCallId: id, output });
331
605
  }
332
606
  args.conversation.push({ role: "tool", content: resultParts });
333
607
  }