@juspay/neurolink 12.47.13 → 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.
- package/CHANGELOG.md +3 -3
- package/dist/auth/middleware/AuthMiddleware.d.ts +1 -1
- package/dist/browser/neurolink.min.js +578 -565
- package/dist/cli/factories/commandFactory.d.ts +7 -0
- package/dist/cli/factories/commandFactory.js +26 -0
- package/dist/cli/loop/optionsSchema.js +9 -0
- package/dist/context/budgetChecker.js +54 -2
- package/dist/context/contextDecision.js +2 -0
- package/dist/context/toolPairRepair.d.ts +12 -0
- package/dist/context/toolPairRepair.js +29 -3
- package/dist/core/baseProvider.d.ts +92 -3
- package/dist/core/baseProvider.js +254 -10
- package/dist/core/conversationMemoryManager.js +17 -2
- package/dist/core/loopEngine.js +1 -1
- package/dist/core/modules/MessageBuilder.js +11 -3
- package/dist/core/modules/TelemetryHandler.d.ts +2 -2
- package/dist/core/modules/ToolsManager.d.ts +35 -0
- package/dist/core/modules/ToolsManager.js +159 -55
- package/dist/core/nativeGenerateLoop.d.ts +58 -1
- package/dist/core/nativeGenerateLoop.js +277 -3
- package/dist/core/redisConversationMemoryManager.js +10 -3
- package/dist/core/resolveRequestKind.d.ts +24 -4
- package/dist/core/resolveRequestKind.js +31 -4
- package/dist/core/toolExecutionRecorder.d.ts +6 -0
- package/dist/core/toolExecutionRecorder.js +24 -2
- package/dist/core/toolRoutingDecision.js +1 -0
- package/dist/factories/mediaHandlerCatalog.js +5 -0
- package/dist/factories/providerDescriptors.js +15 -0
- package/dist/files/fileTools.d.ts +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +9 -2
- package/dist/memory/condensationPrompt.d.ts +8 -0
- package/dist/memory/condensationPrompt.js +29 -0
- package/dist/memory/hippocampusInitializer.d.ts +14 -2
- package/dist/memory/hippocampusInitializer.js +64 -3
- package/dist/neurolink.d.ts +252 -3
- package/dist/neurolink.js +898 -43
- package/dist/providers/amazonBedrock/client.js +6 -0
- package/dist/providers/amazonSagemaker.js +14 -1
- package/dist/providers/anthropic/client.js +148 -19
- package/dist/providers/anthropic/loopAdapter.js +2 -2
- package/dist/providers/googleAiStudio/client.js +29 -14
- package/dist/providers/openaiChatCompletionsBase.js +103 -23
- package/dist/providers/systemOneDecision.d.ts +6 -0
- package/dist/providers/systemOneDecision.js +14 -63
- package/dist/providers/typesafe.d.ts +6 -0
- package/dist/providers/typesafe.js +6 -0
- package/dist/rag/retrieval/searchDecision.js +1 -0
- package/dist/routing/classifierRouter.js +6 -5
- package/dist/routing/classifierStrategies.js +8 -1
- package/dist/types/classifierRouter.d.ts +16 -3
- package/dist/types/common.d.ts +2 -0
- package/dist/types/config.d.ts +9 -0
- package/dist/types/context.d.ts +8 -1
- package/dist/types/conversation.d.ts +87 -0
- package/dist/types/decision.d.ts +210 -1
- package/dist/types/decision.js +9 -1
- package/dist/types/dispatch.d.ts +11 -4
- package/dist/types/generate.d.ts +118 -54
- package/dist/types/loopEngine.d.ts +7 -2
- package/dist/types/memory.d.ts +177 -9
- package/dist/types/openaiCompatible.d.ts +11 -3
- package/dist/types/stream.d.ts +13 -16
- package/dist/types/stt.d.ts +58 -1
- package/dist/types/tools.d.ts +14 -0
- package/dist/types/tools.js +8 -0
- package/dist/types/tts.d.ts +108 -0
- package/dist/types/voice.d.ts +41 -4
- package/dist/utils/decisionAnswers.d.ts +31 -1
- package/dist/utils/decisionAnswers.js +114 -0
- package/dist/utils/decisionLimits.d.ts +39 -0
- package/dist/utils/decisionLimits.js +116 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.js +9 -0
- package/dist/utils/messageBuilder.d.ts +45 -1
- package/dist/utils/messageBuilder.js +393 -16
- package/dist/utils/multimodalOptionsBuilder.d.ts +1 -0
- package/dist/utils/multimodalOptionsBuilder.js +1 -0
- package/dist/utils/speechText.d.ts +41 -0
- package/dist/utils/speechText.js +364 -0
- package/dist/utils/toolResultStatus.d.ts +26 -0
- package/dist/utils/toolResultStatus.js +73 -0
- package/dist/utils/ttsProcessor.d.ts +29 -0
- package/dist/utils/ttsProcessor.js +84 -2
- package/dist/voice/index.d.ts +1 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/providers/ElevenLabsSTT.d.ts +42 -0
- package/dist/voice/providers/ElevenLabsSTT.js +351 -0
- package/dist/voice/providers/ElevenLabsTTS.d.ts +81 -1
- package/dist/voice/providers/ElevenLabsTTS.js +415 -74
- package/docs-site/static/search-index.json +48 -33
- package/package.json +5 -2
- package/dist/utils/toolEndEmitter.d.ts +0 -25
- 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:
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
909
|
-
|
|
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
|
-
:
|
|
152
|
-
? { 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
|
}
|