@juspay/neurolink 12.11.2 → 12.12.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/browser/neurolink.min.js +533 -581
- package/dist/constants/enums.d.ts +13 -0
- package/dist/constants/enums.js +14 -0
- package/dist/core/baseProvider.d.ts +71 -3
- package/dist/core/baseProvider.js +152 -44
- package/dist/core/modules/GenerationHandler.d.ts +22 -24
- package/dist/core/modules/GenerationHandler.js +28 -463
- package/dist/core/nativeGenerateLoop.d.ts +35 -0
- package/dist/core/nativeGenerateLoop.js +261 -0
- package/dist/files/fileTools.d.ts +5 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/mcp/toolRegistry.js +7 -0
- package/dist/middleware/builtin/guardrails.d.ts +0 -5
- package/dist/middleware/builtin/guardrails.js +33 -5
- package/dist/middleware/factory.js +1 -1
- package/dist/middleware/wrapLanguageModel.d.ts +18 -0
- package/dist/middleware/wrapLanguageModel.js +53 -0
- package/dist/neurolink.d.ts +7 -0
- package/dist/neurolink.js +61 -11
- package/dist/processors/media/AudioProcessor.js +46 -11
- package/dist/providers/amazonSagemaker.d.ts +17 -1
- package/dist/providers/amazonSagemaker.js +110 -0
- package/dist/providers/anthropic/client.d.ts +11 -0
- package/dist/providers/anthropic/client.js +148 -1
- package/dist/providers/catalog/index.generated.d.ts +1 -1
- package/dist/providers/catalog/index.generated.js +3 -0
- package/dist/providers/catalog/loader.js +1 -0
- package/dist/providers/catalog/mancer.json +192 -0
- package/dist/providers/configuredOpenAICompat.d.ts +11 -0
- package/dist/providers/configuredOpenAICompat.js +16 -0
- package/dist/providers/googleVertex/client.d.ts +0 -9
- package/dist/providers/googleVertex/client.js +0 -33
- package/dist/providers/openaiChatCompletionsBase.d.ts +21 -1
- package/dist/providers/openaiChatCompletionsBase.js +178 -0
- package/dist/providers/providerTypeUtils.d.ts +1 -2
- package/dist/providers/providerTypeUtils.js +5 -1
- package/dist/types/aiCompat.d.ts +485 -0
- package/dist/types/aiCompat.js +17 -0
- package/dist/types/conversation.d.ts +1 -1
- package/dist/types/generate.d.ts +52 -0
- package/dist/types/middleware.d.ts +3 -6
- package/dist/types/providerCatalog.generated.d.ts +2 -2
- package/dist/types/providers.d.ts +14 -1
- package/dist/types/tools.d.ts +25 -2
- package/dist/utils/errorHandling.d.ts +20 -3
- package/dist/utils/errorHandling.js +22 -5
- package/dist/utils/generationErrors.d.ts +78 -6
- package/dist/utils/generationErrors.js +114 -6
- package/dist/utils/mcpDefaults.d.ts +1 -1
- package/dist/utils/mcpDefaults.js +4 -1
- package/dist/utils/nativeSingleShot.d.ts +3 -0
- package/dist/utils/nativeSingleShot.js +83 -0
- package/dist/utils/tool.d.ts +30 -5
- package/dist/utils/tool.js +43 -5
- package/package.json +3 -6
- package/dist/utils/generation.d.ts +0 -8
- package/dist/utils/generation.js +0 -8
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The multi-step tool loop that the ai package's `generateText` used to supply.
|
|
3
|
+
*
|
|
4
|
+
* It deliberately loops over a provider's own `doGenerate` rather than over any
|
|
5
|
+
* streaming machinery. That is the whole lesson of the reverted first attempt:
|
|
6
|
+
* `doGenerate` is where the JSON-versus-SSE wire choice lives, along with the
|
|
7
|
+
* 400 retry, the context-overflow refit and the provider's own structured-output
|
|
8
|
+
* handling. Looping around the streaming path instead silently changed
|
|
9
|
+
* generate() to send `stream: true` and broke ten providers against a
|
|
10
|
+
* non-streaming body.
|
|
11
|
+
*
|
|
12
|
+
* Every provider whose delegating model exposes a v3-shaped `doGenerate` can
|
|
13
|
+
* share this: the v3 result shape (`content` parts, `finishReason`, `usage`) is
|
|
14
|
+
* the same across Anthropic, the OpenAI-compatible family and SageMaker.
|
|
15
|
+
*/
|
|
16
|
+
import { guardToolExecutor } from "./toolExecutionGuards.js";
|
|
17
|
+
/**
|
|
18
|
+
* Narrow a model handle to the delegating shape this loop drives.
|
|
19
|
+
* `LanguageModel` is a union that includes a bare string id, and a double
|
|
20
|
+
* assertion through unknown is banned by Critical Rule 14.
|
|
21
|
+
*/
|
|
22
|
+
export const hasNativeDoGenerate = (value) => typeof value === "object" &&
|
|
23
|
+
value !== null &&
|
|
24
|
+
typeof value.doGenerate === "function";
|
|
25
|
+
const asParts = (value) => Array.isArray(value) ? value : [];
|
|
26
|
+
const readTotal = (value) => typeof value === "object" &&
|
|
27
|
+
value !== null &&
|
|
28
|
+
typeof value.total === "number"
|
|
29
|
+
? value.total
|
|
30
|
+
: 0;
|
|
31
|
+
/**
|
|
32
|
+
* Parse a tool call's arguments, distinguishing "no arguments" from "the model
|
|
33
|
+
* emitted something that is not JSON". Silently substituting `{}` for the
|
|
34
|
+
* second case ran the tool with empty input and reported success, so a
|
|
35
|
+
* malformed call looked identical to a legitimate no-arg one.
|
|
36
|
+
*/
|
|
37
|
+
const parseToolInput = (raw) => {
|
|
38
|
+
if (typeof raw !== "string") {
|
|
39
|
+
return { input: raw ?? {} };
|
|
40
|
+
}
|
|
41
|
+
if (raw.trim() === "") {
|
|
42
|
+
return { input: {} };
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return { input: JSON.parse(raw) };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { input: {}, error: "arguments were not valid JSON" };
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Validate parsed input against the tool's own schema when it exposes one.
|
|
53
|
+
*
|
|
54
|
+
* `generateText` validated tool input before dispatch; the native loop did
|
|
55
|
+
* not, so a call whose shape the tool rejects reached `execute` and failed
|
|
56
|
+
* inside user code — or worse, did not fail. A Zod schema is detected by
|
|
57
|
+
* `safeParse`; anything else is passed through, since a JSON Schema needs a
|
|
58
|
+
* validator this loop has no business carrying.
|
|
59
|
+
*/
|
|
60
|
+
const validateToolInput = (tool, input) => {
|
|
61
|
+
const schema = tool?.inputSchema;
|
|
62
|
+
if (typeof schema?.safeParse !== "function") {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const result = schema.safeParse(input);
|
|
66
|
+
if (result.success) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const detail = result.error instanceof Error ? result.error.message : "schema mismatch";
|
|
70
|
+
return `input did not match the tool's schema: ${detail}`;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Spell a JSON Schema into the conversation's system turn.
|
|
74
|
+
*
|
|
75
|
+
* The structured-output fallback for vendors that reject or ignore
|
|
76
|
+
* `response_format`. Merged into an existing trailing system message rather
|
|
77
|
+
* than appended as a second one: several self-hosted OpenAI-compatible stacks
|
|
78
|
+
* honour only the first system message, so a second would be dropped and the
|
|
79
|
+
* fallback would silently do nothing.
|
|
80
|
+
*/
|
|
81
|
+
export const appendJsonSchemaInstruction = (conversation, schema) => {
|
|
82
|
+
const instruction = "When you give your final answer, respond with only a single JSON object " +
|
|
83
|
+
"that conforms to the following JSON Schema. No prose before or after it, " +
|
|
84
|
+
`and no markdown code fence. JSON Schema: ${JSON.stringify(schema)}`;
|
|
85
|
+
const lastSystemIndex = conversation.reduce((found, message, index) => (message.role === "system" ? index : found), -1);
|
|
86
|
+
if (lastSystemIndex === -1) {
|
|
87
|
+
return [{ role: "system", content: instruction }, ...conversation];
|
|
88
|
+
}
|
|
89
|
+
const existing = conversation[lastSystemIndex];
|
|
90
|
+
const content = typeof existing.content === "string"
|
|
91
|
+
? `${existing.content}\n\n${instruction}`
|
|
92
|
+
: instruction;
|
|
93
|
+
return conversation.map((message, index) => index === lastSystemIndex ? { ...message, content } : message);
|
|
94
|
+
};
|
|
95
|
+
export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
|
|
96
|
+
const toolsUsed = [];
|
|
97
|
+
let text = "";
|
|
98
|
+
let finishReason = "stop";
|
|
99
|
+
let rawFinishReason;
|
|
100
|
+
let inputTokens = 0;
|
|
101
|
+
let outputTokens = 0;
|
|
102
|
+
let cacheReadTokens = 0;
|
|
103
|
+
let cacheWriteTokens = 0;
|
|
104
|
+
let steps = 0;
|
|
105
|
+
// One bounded recovery re-ask per turn; see the empty-tool-calls branch.
|
|
106
|
+
let reasked = false;
|
|
107
|
+
const hasTools = Boolean(args.tools && args.tools.length > 0);
|
|
108
|
+
for (let step = 0; step < args.maxSteps; step++) {
|
|
109
|
+
steps = step + 1;
|
|
110
|
+
const res = await args.runStep(() => args.doGenerate({
|
|
111
|
+
prompt: args.conversation,
|
|
112
|
+
...(args.tools && args.tools.length > 0 ? { tools: args.tools } : {}),
|
|
113
|
+
// The v3 call option is an OBJECT — `{ type: "none" }`. Passing the
|
|
114
|
+
// bare string "none" type-checks against `unknown` and is then
|
|
115
|
+
// dropped by every converter that switches on `choice.type`, so the
|
|
116
|
+
// re-ask silently went out unchanged. Caught by the stand-in asserting
|
|
117
|
+
// the wire body, not by any live provider.
|
|
118
|
+
...(reasked
|
|
119
|
+
? { toolChoice: { type: "none" } }
|
|
120
|
+
: args.toolChoice !== undefined
|
|
121
|
+
? { toolChoice: args.toolChoice }
|
|
122
|
+
: {}),
|
|
123
|
+
...(args.responseFormat ? { responseFormat: args.responseFormat } : {}),
|
|
124
|
+
...(args.providerOptions
|
|
125
|
+
? { providerOptions: args.providerOptions }
|
|
126
|
+
: {}),
|
|
127
|
+
...(args.maxOutputTokens
|
|
128
|
+
? { maxOutputTokens: args.maxOutputTokens }
|
|
129
|
+
: {}),
|
|
130
|
+
...(args.temperature !== undefined
|
|
131
|
+
? { temperature: args.temperature }
|
|
132
|
+
: {}),
|
|
133
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
134
|
+
}));
|
|
135
|
+
const parts = asParts(res.content);
|
|
136
|
+
// Each step REPLACES the text rather than appending: the final step's
|
|
137
|
+
// answer is the turn's answer, matching what generateText reported.
|
|
138
|
+
text = parts
|
|
139
|
+
.filter((p) => p.type === "text" && typeof p.text === "string")
|
|
140
|
+
.map((p) => p.text)
|
|
141
|
+
.join("");
|
|
142
|
+
const fr = res.finishReason;
|
|
143
|
+
if (typeof fr === "string") {
|
|
144
|
+
finishReason = fr;
|
|
145
|
+
}
|
|
146
|
+
else if (typeof fr === "object" && fr !== null) {
|
|
147
|
+
const shaped = fr;
|
|
148
|
+
finishReason = shaped.unified ?? finishReason;
|
|
149
|
+
rawFinishReason = shaped.raw ?? rawFinishReason;
|
|
150
|
+
}
|
|
151
|
+
const usage = res.usage;
|
|
152
|
+
inputTokens += readTotal(usage?.inputTokens);
|
|
153
|
+
outputTokens += readTotal(usage?.outputTokens);
|
|
154
|
+
const inShaped = usage?.inputTokens;
|
|
155
|
+
cacheReadTokens += inShaped?.cacheRead ?? 0;
|
|
156
|
+
cacheWriteTokens += inShaped?.cacheWrite ?? 0;
|
|
157
|
+
const calls = parts.filter((p) => p.type === "tool-call");
|
|
158
|
+
if (calls.length === 0) {
|
|
159
|
+
// io.net's Llama endpoint ends a tool loop on `finish_reason:
|
|
160
|
+
// tool_calls` carrying neither a tool call nor any text: the model's
|
|
161
|
+
// JSON-shaped answer trips the vendor's tool-call parser, which drops it
|
|
162
|
+
// and reports `content: null` with no `tool_calls`. There is nothing to
|
|
163
|
+
// execute, so the loop would stop and hand the caller an empty turn even
|
|
164
|
+
// though the tool ran. Replaying the request once with
|
|
165
|
+
// `toolChoice: "none"` returns the answer.
|
|
166
|
+
//
|
|
167
|
+
// Ported from GenerationHandler.recoverEmptyToolCallsFinish, which runs
|
|
168
|
+
// this on the ai-package path. That path is unreachable for every
|
|
169
|
+
// provider driven by this loop — io.net among them, since it is a
|
|
170
|
+
// Tier-2 catalog provider on the OpenAI-compatible base — so without
|
|
171
|
+
// this the recovery would simply not happen for the provider it was
|
|
172
|
+
// written for.
|
|
173
|
+
const emptyToolCallsFinish = finishReason === "tool-calls" &&
|
|
174
|
+
text.trim() === "" &&
|
|
175
|
+
hasTools &&
|
|
176
|
+
!reasked &&
|
|
177
|
+
step + 1 < args.maxSteps;
|
|
178
|
+
if (emptyToolCallsFinish) {
|
|
179
|
+
reasked = true;
|
|
180
|
+
args.conversation.push({ role: "assistant", content: parts });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
// Tool turns go back in the message-builder shape each provider's own
|
|
186
|
+
// conversion already round-trips: an assistant message of tool-call parts,
|
|
187
|
+
// then one tool message of tool-result parts.
|
|
188
|
+
args.conversation.push({ role: "assistant", content: parts });
|
|
189
|
+
const resultParts = [];
|
|
190
|
+
for (const call of calls) {
|
|
191
|
+
const name = String(call.toolName ?? "");
|
|
192
|
+
const id = String(call.toolCallId ?? "");
|
|
193
|
+
const startTime = new Date();
|
|
194
|
+
const parsed = parseToolInput(call.input);
|
|
195
|
+
const input = parsed.input;
|
|
196
|
+
const tool = args.toolsRecord[name];
|
|
197
|
+
let output;
|
|
198
|
+
let failure;
|
|
199
|
+
const rejection = parsed.error ??
|
|
200
|
+
(typeof tool?.execute === "function"
|
|
201
|
+
? validateToolInput(tool, input)
|
|
202
|
+
: undefined);
|
|
203
|
+
if (typeof tool?.execute !== "function") {
|
|
204
|
+
failure = `Tool not found: ${name}`;
|
|
205
|
+
output = { error: failure };
|
|
206
|
+
}
|
|
207
|
+
else if (rejection) {
|
|
208
|
+
// An error tool-result rather than a throw: the model gets to see what
|
|
209
|
+
// was wrong and correct it on the next step, which is what the SDK's
|
|
210
|
+
// own validation did.
|
|
211
|
+
failure = `Tool ${name}: ${rejection}`;
|
|
212
|
+
output = { error: failure };
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
try {
|
|
216
|
+
// The turn's abort signal and the per-tool cap have to reach the
|
|
217
|
+
// tool, or a wedged tool parks the loop inside `await execute` and
|
|
218
|
+
// outlives the deadline that withTurnTimeout composed. Reuses the
|
|
219
|
+
// guard every other native loop already applies.
|
|
220
|
+
const guarded = guardToolExecutor(name, tool.execute, {
|
|
221
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
222
|
+
...(args.toolTimeoutMs !== undefined
|
|
223
|
+
? { toolTimeoutMs: args.toolTimeoutMs }
|
|
224
|
+
: {}),
|
|
225
|
+
});
|
|
226
|
+
output = await guarded(input, {
|
|
227
|
+
toolCallId: id,
|
|
228
|
+
messages: [],
|
|
229
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
230
|
+
});
|
|
231
|
+
toolsUsed.push(name);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
235
|
+
output = { error: failure };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
toolExecutionSummaries.push({
|
|
239
|
+
toolCallId: id,
|
|
240
|
+
toolName: name,
|
|
241
|
+
input,
|
|
242
|
+
...(failure ? { error: failure } : { output }),
|
|
243
|
+
startTime,
|
|
244
|
+
endTime: new Date(),
|
|
245
|
+
});
|
|
246
|
+
resultParts.push({ type: "tool-result", toolCallId: id, output });
|
|
247
|
+
}
|
|
248
|
+
args.conversation.push({ role: "tool", content: resultParts });
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
text,
|
|
252
|
+
finishReason,
|
|
253
|
+
...(rawFinishReason ? { rawFinishReason } : {}),
|
|
254
|
+
inputTokens,
|
|
255
|
+
outputTokens,
|
|
256
|
+
cacheReadTokens,
|
|
257
|
+
cacheWriteTokens,
|
|
258
|
+
toolsUsed,
|
|
259
|
+
steps,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -18,7 +18,7 @@ import type { FileReferenceRegistry } from "./fileReferenceRegistry.js";
|
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
20
|
export declare function createFileTools(registry: FileReferenceRegistry): {
|
|
21
|
-
list_attached_files: import("
|
|
21
|
+
list_attached_files: import("../index.js").Tool<Record<string, never>, {
|
|
22
22
|
success: boolean;
|
|
23
23
|
message: string;
|
|
24
24
|
fileCount: number;
|
|
@@ -53,7 +53,7 @@ export declare function createFileTools(registry: FileReferenceRegistry): {
|
|
|
53
53
|
files?: undefined;
|
|
54
54
|
formatted?: undefined;
|
|
55
55
|
}>;
|
|
56
|
-
read_file_section: import("
|
|
56
|
+
read_file_section: import("../index.js").Tool<{
|
|
57
57
|
file_id: string;
|
|
58
58
|
start_line: number;
|
|
59
59
|
token_budget: number;
|
|
@@ -79,7 +79,7 @@ export declare function createFileTools(registry: FileReferenceRegistry): {
|
|
|
79
79
|
guidance: string | undefined;
|
|
80
80
|
error?: undefined;
|
|
81
81
|
}>;
|
|
82
|
-
search_in_file: import("
|
|
82
|
+
search_in_file: import("../index.js").Tool<{
|
|
83
83
|
file_id: string;
|
|
84
84
|
pattern: string;
|
|
85
85
|
max_matches: number;
|
|
@@ -107,7 +107,7 @@ export declare function createFileTools(registry: FileReferenceRegistry): {
|
|
|
107
107
|
}[];
|
|
108
108
|
error?: undefined;
|
|
109
109
|
}>;
|
|
110
|
-
get_file_preview: import("
|
|
110
|
+
get_file_preview: import("../index.js").Tool<{
|
|
111
111
|
file_id: string;
|
|
112
112
|
}, {
|
|
113
113
|
success: boolean;
|
|
@@ -140,7 +140,7 @@ export declare function createFileTools(registry: FileReferenceRegistry): {
|
|
|
140
140
|
hasSummary: boolean;
|
|
141
141
|
error?: undefined;
|
|
142
142
|
}>;
|
|
143
|
-
extract_file_content: import("
|
|
143
|
+
extract_file_content: import("../index.js").Tool<{
|
|
144
144
|
file_id: string;
|
|
145
145
|
start_time?: number | undefined;
|
|
146
146
|
end_time?: number | undefined;
|
package/dist/index.d.ts
CHANGED
|
@@ -82,6 +82,7 @@ export { initializeOpenTelemetry, shutdownOpenTelemetry, flushOpenTelemetry, get
|
|
|
82
82
|
export { clearAnalyticsMetrics, createAnalyticsMiddleware, getAnalyticsMetrics, } from "./middleware/builtin/analytics.js";
|
|
83
83
|
export { createLifecycleMiddleware } from "./middleware/builtin/lifecycle.js";
|
|
84
84
|
export { MiddlewareFactory } from "./middleware/factory.js";
|
|
85
|
+
export { tool, jsonSchema, stepCountIs } from "./utils/tool.js";
|
|
85
86
|
export { ExporterRegistry } from "./observability/exporterRegistry.js";
|
|
86
87
|
export { NoOpExporter } from "./observability/exporters/baseExporter.js";
|
|
87
88
|
export { getMetricsAggregator, MetricsAggregator, resetMetricsAggregator, } from "./observability/metricsAggregator.js";
|
package/dist/index.js
CHANGED
|
@@ -165,6 +165,10 @@ runWithCurrentLangfuseContext, };
|
|
|
165
165
|
export { clearAnalyticsMetrics, createAnalyticsMiddleware, getAnalyticsMetrics, } from "./middleware/builtin/analytics.js";
|
|
166
166
|
export { createLifecycleMiddleware } from "./middleware/builtin/lifecycle.js";
|
|
167
167
|
export { MiddlewareFactory } from "./middleware/factory.js";
|
|
168
|
+
// Tool + schema helpers. Previously a consumer reached for `tool()` and
|
|
169
|
+
// `jsonSchema()` from the ai package to build tools for generate({tools}).
|
|
170
|
+
// That package is no longer a dependency, so the equivalents ship here.
|
|
171
|
+
export { tool, jsonSchema, stepCountIs } from "./utils/tool.js";
|
|
168
172
|
export { ExporterRegistry } from "./observability/exporterRegistry.js";
|
|
169
173
|
export { NoOpExporter } from "./observability/exporters/baseExporter.js";
|
|
170
174
|
// Observability modules and types
|
package/dist/mcp/toolRegistry.js
CHANGED
|
@@ -167,6 +167,7 @@ export class MCPToolRegistry extends MCPRegistry {
|
|
|
167
167
|
const toolId = isCustomTool ? tool.name : `${serverId}.${tool.name}`;
|
|
168
168
|
const toolTimeoutMs = serverInfo.metadata?.toolTimeoutMs;
|
|
169
169
|
const toolMaxRetries = serverInfo.metadata?.toolMaxRetries;
|
|
170
|
+
const toolTotalTimeoutMs = serverInfo.metadata?.toolTotalTimeoutMs;
|
|
170
171
|
const toolInfo = {
|
|
171
172
|
name: tool.name,
|
|
172
173
|
description: tool.description,
|
|
@@ -180,6 +181,9 @@ export class MCPToolRegistry extends MCPRegistry {
|
|
|
180
181
|
permissions: [], // MCPServerInfo.tools doesn't have permissions
|
|
181
182
|
...(toolTimeoutMs !== undefined && { timeoutMs: toolTimeoutMs }),
|
|
182
183
|
...(toolMaxRetries !== undefined && { maxRetries: toolMaxRetries }),
|
|
184
|
+
...(toolTotalTimeoutMs !== undefined && {
|
|
185
|
+
totalTimeoutMs: toolTotalTimeoutMs,
|
|
186
|
+
}),
|
|
183
187
|
};
|
|
184
188
|
// Register only with fully-qualified toolId to avoid collisions
|
|
185
189
|
this.tools.set(toolId, toolInfo);
|
|
@@ -197,6 +201,9 @@ export class MCPToolRegistry extends MCPRegistry {
|
|
|
197
201
|
}),
|
|
198
202
|
...(toolTimeoutMs !== undefined && { timeoutMs: toolTimeoutMs }),
|
|
199
203
|
...(toolMaxRetries !== undefined && { maxRetries: toolMaxRetries }),
|
|
204
|
+
...(toolTotalTimeoutMs !== undefined && {
|
|
205
|
+
totalTimeoutMs: toolTotalTimeoutMs,
|
|
206
|
+
}),
|
|
200
207
|
});
|
|
201
208
|
// Tool registered successfully
|
|
202
209
|
}
|
|
@@ -1,7 +1,2 @@
|
|
|
1
1
|
import type { NeuroLinkMiddleware, GuardrailsMiddlewareConfig } from "../../types/index.js";
|
|
2
|
-
/**
|
|
3
|
-
* Create Guardrails AI middleware for content filtering and policy enforcement
|
|
4
|
-
* @param config Configuration for the guardrails middleware
|
|
5
|
-
* @returns NeuroLink middleware instance
|
|
6
|
-
*/
|
|
7
2
|
export declare function createGuardrailsMiddleware(config?: GuardrailsMiddlewareConfig): NeuroLinkMiddleware;
|
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
import { createBlockedResponse, createBlockedStream, applyContentFiltering, handlePrecallGuardrails, } from "../utils/guardrailsUtils.js";
|
|
2
2
|
import { logger } from "../../utils/logger.js";
|
|
3
|
-
import {
|
|
3
|
+
import { generateOnceNative } from "../../utils/nativeSingleShot.js";
|
|
4
4
|
/**
|
|
5
5
|
* Create Guardrails AI middleware for content filtering and policy enforcement
|
|
6
6
|
* @param config Configuration for the guardrails middleware
|
|
7
7
|
* @returns NeuroLink middleware instance
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Turn whatever a caller put in `filterModel` into a model handle.
|
|
11
|
+
*
|
|
12
|
+
* A handle is used as-is. A string is resolved through NeuroLink's own
|
|
13
|
+
* provider factory, accepting either "provider:model" or a bare model id.
|
|
14
|
+
* Imported lazily so the middleware module does not pull the provider factory
|
|
15
|
+
* into every bundle that merely registers guardrails.
|
|
16
|
+
*/
|
|
17
|
+
async function resolveFilterModel(filterModel) {
|
|
18
|
+
if (typeof filterModel !== "string") {
|
|
19
|
+
return filterModel;
|
|
20
|
+
}
|
|
21
|
+
const [maybeProvider, ...rest] = filterModel.split(":");
|
|
22
|
+
const hasProvider = rest.length > 0;
|
|
23
|
+
const { ProviderFactory } = await import("../../factories/providerFactory.js");
|
|
24
|
+
const provider = await ProviderFactory.createProvider(hasProvider ? maybeProvider : undefined, hasProvider ? rest.join(":") : filterModel);
|
|
25
|
+
// `getModel()` is BaseProvider's sanctioned public handle but is not on the
|
|
26
|
+
// narrower `AIProvider` type, so narrow at the boundary rather than assert
|
|
27
|
+
// through it (Critical Rule 14).
|
|
28
|
+
const handle = provider;
|
|
29
|
+
if (typeof handle.getModel !== "function") {
|
|
30
|
+
throw new Error(`guardrails: provider for "${filterModel}" exposes no model handle`);
|
|
31
|
+
}
|
|
32
|
+
return handle.getModel();
|
|
33
|
+
}
|
|
9
34
|
export function createGuardrailsMiddleware(config = {}) {
|
|
10
35
|
const metadata = {
|
|
11
36
|
id: "guardrails",
|
|
@@ -44,10 +69,13 @@ export function createGuardrailsMiddleware(config = {}) {
|
|
|
44
69
|
logger.debug(`[GuardrailsMiddleware] Invoking model-based filter.`);
|
|
45
70
|
try {
|
|
46
71
|
const filterPrompt = `Is the following text safe? Respond with only "safe" or "unsafe".\n\nText: "${result.text}"`;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
72
|
+
// `ModelFilterConfig.filterModel` is typed `LanguageModel`, which
|
|
73
|
+
// admits a bare model id, and the documented examples used one.
|
|
74
|
+
// `generateOnceNative` needs a handle exposing doGenerate, so a
|
|
75
|
+
// string threw, the catch below logged it, and the turn returned
|
|
76
|
+
// unfiltered — a security control that silently did nothing.
|
|
77
|
+
const filterModel = await resolveFilterModel(config.modelFilter.filterModel);
|
|
78
|
+
const { text: filterResponse } = await generateOnceNative(filterModel, { prompt: filterPrompt });
|
|
51
79
|
if (filterResponse.toLowerCase().trim() === "unsafe") {
|
|
52
80
|
logger.warn(`[GuardrailsMiddleware] Model-based filter flagged content as unsafe.`);
|
|
53
81
|
result = { ...result, text: "<REDACTED BY AI GUARDRAIL>" };
|
|
@@ -4,7 +4,7 @@ import { createGuardrailsMiddleware } from "./builtin/guardrails.js";
|
|
|
4
4
|
import { createAutoEvaluationMiddleware } from "./builtin/autoEvaluation.js";
|
|
5
5
|
import { createLifecycleMiddleware } from "./builtin/lifecycle.js";
|
|
6
6
|
import { logger } from "../utils/logger.js";
|
|
7
|
-
import { wrapLanguageModel } from "
|
|
7
|
+
import { wrapLanguageModel } from "./wrapLanguageModel.js";
|
|
8
8
|
/**
|
|
9
9
|
* Middleware factory for creating and applying middleware chains.
|
|
10
10
|
* Each factory instance manages its own registry and configuration.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local `wrapLanguageModel`.
|
|
3
|
+
*
|
|
4
|
+
* Upstream is a reduce over the middleware array that returns a model whose
|
|
5
|
+
* `doGenerate` / `doStream` route through `transformParams` and the optional
|
|
6
|
+
* `wrapGenerate` / `wrapStream` hooks. Reproduced here so the middleware
|
|
7
|
+
* factory no longer needs the ai package.
|
|
8
|
+
*
|
|
9
|
+
* Worth recording: `wrapStream` does not currently run in this codebase. Every
|
|
10
|
+
* streaming path is native and bypasses the wrapped model entirely, so only
|
|
11
|
+
* `wrapGenerate` is reachable. That is a pre-existing gap, not one this
|
|
12
|
+
* introduced.
|
|
13
|
+
*/
|
|
14
|
+
import type { LanguageModelV3, LanguageModelV3Middleware } from "../types/index.js";
|
|
15
|
+
export declare const wrapLanguageModel: ({ model, middleware, }: {
|
|
16
|
+
model: LanguageModelV3;
|
|
17
|
+
middleware: LanguageModelV3Middleware | LanguageModelV3Middleware[];
|
|
18
|
+
}) => LanguageModelV3;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local `wrapLanguageModel`.
|
|
3
|
+
*
|
|
4
|
+
* Upstream is a reduce over the middleware array that returns a model whose
|
|
5
|
+
* `doGenerate` / `doStream` route through `transformParams` and the optional
|
|
6
|
+
* `wrapGenerate` / `wrapStream` hooks. Reproduced here so the middleware
|
|
7
|
+
* factory no longer needs the ai package.
|
|
8
|
+
*
|
|
9
|
+
* Worth recording: `wrapStream` does not currently run in this codebase. Every
|
|
10
|
+
* streaming path is native and bypasses the wrapped model entirely, so only
|
|
11
|
+
* `wrapGenerate` is reachable. That is a pre-existing gap, not one this
|
|
12
|
+
* introduced.
|
|
13
|
+
*/
|
|
14
|
+
const doWrap = (model, middleware) => {
|
|
15
|
+
const transform = async (params, type) => middleware.transformParams
|
|
16
|
+
? await middleware.transformParams({ type, params, model })
|
|
17
|
+
: params;
|
|
18
|
+
return {
|
|
19
|
+
specificationVersion: "v3",
|
|
20
|
+
provider: middleware.overrideProvider?.({ model }) ?? model.provider,
|
|
21
|
+
modelId: middleware.overrideModelId?.({ model }) ?? model.modelId,
|
|
22
|
+
supportedUrls: middleware.overrideSupportedUrls?.({ model }) ?? model.supportedUrls,
|
|
23
|
+
async doGenerate(params) {
|
|
24
|
+
const transformed = await transform(params, "generate");
|
|
25
|
+
const doGenerate = () => model.doGenerate(transformed);
|
|
26
|
+
const doStream = () => model.doStream(transformed);
|
|
27
|
+
return middleware.wrapGenerate
|
|
28
|
+
? await middleware.wrapGenerate({
|
|
29
|
+
doGenerate,
|
|
30
|
+
doStream,
|
|
31
|
+
params: transformed,
|
|
32
|
+
model,
|
|
33
|
+
})
|
|
34
|
+
: await doGenerate();
|
|
35
|
+
},
|
|
36
|
+
async doStream(params) {
|
|
37
|
+
const transformed = await transform(params, "stream");
|
|
38
|
+
const doGenerate = () => model.doGenerate(transformed);
|
|
39
|
+
const doStream = () => model.doStream(transformed);
|
|
40
|
+
return middleware.wrapStream
|
|
41
|
+
? await middleware.wrapStream({
|
|
42
|
+
doGenerate,
|
|
43
|
+
doStream,
|
|
44
|
+
params: transformed,
|
|
45
|
+
model,
|
|
46
|
+
})
|
|
47
|
+
: await doStream();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export const wrapLanguageModel = ({ model, middleware, }) => (Array.isArray(middleware) ? [...middleware] : [middleware])
|
|
52
|
+
.reverse()
|
|
53
|
+
.reduce((wrapped, m) => doWrap(wrapped, m), model);
|
package/dist/neurolink.d.ts
CHANGED
|
@@ -1587,8 +1587,15 @@ export declare class NeuroLink {
|
|
|
1587
1587
|
* @returns Tool execution result
|
|
1588
1588
|
*/
|
|
1589
1589
|
executeTool<T = unknown>(toolName: string, params?: unknown, options?: {
|
|
1590
|
+
/** Bound on ONE attempt. */
|
|
1590
1591
|
timeout?: number;
|
|
1591
1592
|
maxRetries?: number;
|
|
1593
|
+
/**
|
|
1594
|
+
* Bound on the WHOLE execution — every attempt plus the delays between
|
|
1595
|
+
* them. Defaults to `timeout * (maxRetries + 1)`, which is what the
|
|
1596
|
+
* retry loop already spent, so omitting it changes nothing.
|
|
1597
|
+
*/
|
|
1598
|
+
totalTimeoutMs?: number;
|
|
1592
1599
|
retryDelayMs?: number;
|
|
1593
1600
|
/** Disable tool result caching for this call */
|
|
1594
1601
|
disableToolCache?: boolean;
|
package/dist/neurolink.js
CHANGED
|
@@ -9566,7 +9566,7 @@ Current user's request: ${currentInput}`;
|
|
|
9566
9566
|
};
|
|
9567
9567
|
}
|
|
9568
9568
|
// SMART DEFAULTS: Use utility to eliminate boilerplate creation
|
|
9569
|
-
const mcpServerInfo = createCustomToolServerInfo(name, convertedTool, options?.timeout, options?.maxRetries);
|
|
9569
|
+
const mcpServerInfo = createCustomToolServerInfo(name, convertedTool, options?.timeout, options?.maxRetries, options?.totalTimeoutMs);
|
|
9570
9570
|
// Register with toolRegistry using MCPServerInfo directly
|
|
9571
9571
|
this.toolRegistry.registerServer(mcpServerInfo);
|
|
9572
9572
|
// Re-registration replaces options wholesale: omitting `cacheable`
|
|
@@ -10010,14 +10010,32 @@ Current user's request: ${currentInput}`;
|
|
|
10010
10010
|
executionId: executionContext.executionId,
|
|
10011
10011
|
}));
|
|
10012
10012
|
const toolInfo = this.toolRegistry.getToolInfo(toolName);
|
|
10013
|
+
const attemptTimeout = options?.timeout ??
|
|
10014
|
+
toolInfo?.tool?.timeoutMs ??
|
|
10015
|
+
TOOL_TIMEOUTS.EXECUTION_BATCH_MS;
|
|
10016
|
+
const maxRetries = options?.maxRetries ??
|
|
10017
|
+
toolInfo?.tool?.maxRetries ??
|
|
10018
|
+
RETRY_ATTEMPTS.DEFAULT;
|
|
10019
|
+
const retryDelayMs = options?.retryDelayMs || RETRY_DELAYS.BASE_MS;
|
|
10013
10020
|
const finalOptions = {
|
|
10014
|
-
timeout:
|
|
10015
|
-
|
|
10016
|
-
|
|
10017
|
-
|
|
10018
|
-
|
|
10019
|
-
|
|
10020
|
-
|
|
10021
|
+
timeout: attemptTimeout,
|
|
10022
|
+
maxRetries,
|
|
10023
|
+
// Ceiling on the whole execution, not one attempt. The default is what
|
|
10024
|
+
// the retry loop already spent, and that is BOTH terms: every attempt at
|
|
10025
|
+
// full length PLUS the fixed wait between them. Omitting the delays
|
|
10026
|
+
// makes the default ceiling shorter than the envelope it is meant to
|
|
10027
|
+
// reproduce, so `attemptTimeout = min(timeout, remaining)` clamps a
|
|
10028
|
+
// later attempt below its configured timeout — the opposite of the
|
|
10029
|
+
// "unchanged unless you ask for less" contract this default exists to
|
|
10030
|
+
// keep. Before any of this the total was unbounded and merely implied:
|
|
10031
|
+
// a tool that reliably hung burned every attempt at full length, and
|
|
10032
|
+
// the surfaced error reported the per-attempt bound beside the
|
|
10033
|
+
// whole-execution elapsed time, which reads as a timeout that was never
|
|
10034
|
+
// enforced.
|
|
10035
|
+
totalTimeout: options?.totalTimeoutMs ??
|
|
10036
|
+
toolInfo?.tool?.totalTimeoutMs ??
|
|
10037
|
+
attemptTimeout * (maxRetries + 1) + retryDelayMs * maxRetries,
|
|
10038
|
+
retryDelayMs,
|
|
10021
10039
|
authContext: options?.authContext,
|
|
10022
10040
|
disableToolCache: options?.disableToolCache,
|
|
10023
10041
|
};
|
|
@@ -10062,11 +10080,43 @@ Current user's request: ${currentInput}`;
|
|
|
10062
10080
|
options: prepared.finalOptions,
|
|
10063
10081
|
circuitBreakerState: prepared.circuitBreaker.getState(),
|
|
10064
10082
|
});
|
|
10083
|
+
const maxAttempts = prepared.finalOptions.maxRetries + 1;
|
|
10084
|
+
const totalTimeout = prepared.finalOptions.totalTimeout;
|
|
10085
|
+
const budgetStart = Date.now();
|
|
10086
|
+
const deadline = budgetStart + totalTimeout;
|
|
10087
|
+
let attemptNumber = 0;
|
|
10065
10088
|
const result = await prepared.circuitBreaker.execute(async () => {
|
|
10066
|
-
return withRetry(async () =>
|
|
10067
|
-
|
|
10089
|
+
return withRetry(async () => {
|
|
10090
|
+
attemptNumber++;
|
|
10091
|
+
const remaining = deadline - Date.now();
|
|
10092
|
+
if (remaining <= 0) {
|
|
10093
|
+
throw ErrorFactory.toolTimeout(toolName, totalTimeout, undefined, {
|
|
10094
|
+
attempt: attemptNumber,
|
|
10095
|
+
maxAttempts,
|
|
10096
|
+
attemptTimeoutMs: prepared.finalOptions.timeout,
|
|
10097
|
+
totalTimeoutMs: totalTimeout,
|
|
10098
|
+
elapsedMs: Date.now() - budgetStart,
|
|
10099
|
+
exhausted: true,
|
|
10100
|
+
});
|
|
10101
|
+
}
|
|
10102
|
+
// Clamping to what is left is what makes the ceiling hard. Gating
|
|
10103
|
+
// retries alone would not: the last attempt could start just under
|
|
10104
|
+
// the deadline and still run a full attempt timeout past it.
|
|
10105
|
+
const attemptTimeout = Math.min(prepared.finalOptions.timeout, remaining);
|
|
10106
|
+
return withTimeout(this.executeToolInternal(toolName, params, prepared.finalOptions, executionContext.hitlState), attemptTimeout, ErrorFactory.toolTimeout(toolName, attemptTimeout, undefined, {
|
|
10107
|
+
attempt: attemptNumber,
|
|
10108
|
+
maxAttempts,
|
|
10109
|
+
attemptTimeoutMs: prepared.finalOptions.timeout,
|
|
10110
|
+
totalTimeoutMs: totalTimeout,
|
|
10111
|
+
elapsedMs: Date.now() - budgetStart,
|
|
10112
|
+
}));
|
|
10113
|
+
}, {
|
|
10114
|
+
maxAttempts,
|
|
10068
10115
|
delayMs: prepared.finalOptions.retryDelayMs,
|
|
10069
|
-
|
|
10116
|
+
// Stop when there is not enough budget left for the retry delay
|
|
10117
|
+
// plus any real work after it.
|
|
10118
|
+
isRetriable: (error) => Date.now() + prepared.finalOptions.retryDelayMs < deadline &&
|
|
10119
|
+
isRetriableError(error),
|
|
10070
10120
|
onRetry: (attempt, error) => {
|
|
10071
10121
|
toolRetryCount = attempt;
|
|
10072
10122
|
mcpLogger.warn(`[${executionContext.functionTag}] Retrying tool execution (attempt ${attempt})`, {
|