@juspay/neurolink 12.9.5 → 12.10.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/artifacts/artifactBanking.d.ts +6 -2
- package/dist/artifacts/artifactBanking.js +9 -14
- package/dist/artifacts/artifactReader.d.ts +69 -0
- package/dist/artifacts/artifactReader.js +157 -0
- package/dist/artifacts/artifactStore.d.ts +7 -3
- package/dist/artifacts/artifactStore.js +10 -21
- package/dist/artifacts/artifactStoreFactory.d.ts +39 -0
- package/dist/artifacts/artifactStoreFactory.js +101 -0
- package/dist/artifacts/redisArtifactStore.d.ts +84 -0
- package/dist/artifacts/redisArtifactStore.js +270 -0
- package/dist/browser/neurolink.min.js +378 -382
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/memory/memoryRetrievalTools.js +104 -35
- package/dist/neurolink.d.ts +74 -2
- package/dist/neurolink.js +197 -19
- package/dist/types/artifact.d.ts +124 -3
- package/dist/types/config.d.ts +7 -0
- package/dist/types/mcp.d.ts +16 -0
- package/dist/utils/redis.d.ts +15 -0
- package/dist/utils/redis.js +64 -6
- package/dist/utils/toolCallRepair.d.ts +42 -0
- package/dist/utils/toolCallRepair.js +78 -15
- package/package.json +7 -6
package/dist/types/artifact.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module types/artifactTypes
|
|
9
9
|
*/
|
|
10
|
+
import type { RedisStorageConfig } from "./conversation.js";
|
|
10
11
|
/** Metadata recorded alongside a stored artifact. */
|
|
11
12
|
export type ArtifactMeta = {
|
|
12
13
|
/** Tool name that produced the output. */
|
|
@@ -83,10 +84,63 @@ export type ArtifactPageRequest = {
|
|
|
83
84
|
limit?: number;
|
|
84
85
|
};
|
|
85
86
|
/**
|
|
86
|
-
*
|
|
87
|
+
* One window of an artifact, as returned by `ArtifactStore.retrieveRange` or
|
|
88
|
+
* by the shared reader when the store only supports whole-payload reads.
|
|
87
89
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
+
* Offsets and lengths are CHARACTERS (UTF-16 code units, the unit
|
|
91
|
+
* `String.prototype.slice` and `retrieve_context`'s `offset` / `limit` use),
|
|
92
|
+
* never bytes — so a model advancing `offset` by the characters it received
|
|
93
|
+
* lands exactly where the previous window ended.
|
|
94
|
+
*/
|
|
95
|
+
export type ArtifactWindow = {
|
|
96
|
+
/** The characters in `[offset, offset + content.length)`. */
|
|
97
|
+
content: string;
|
|
98
|
+
/** Character offset this window starts at. */
|
|
99
|
+
offset: number;
|
|
100
|
+
/** Total character length of the whole payload. */
|
|
101
|
+
totalLength: number;
|
|
102
|
+
};
|
|
103
|
+
/** One hit from a literal search over an artifact. */
|
|
104
|
+
export type ArtifactSearchMatch = {
|
|
105
|
+
/** Character offset of the match — pass it back as `offset` to read there. */
|
|
106
|
+
offset: number;
|
|
107
|
+
/** 1-based line number the match sits on. */
|
|
108
|
+
line: number;
|
|
109
|
+
/** Character offset the snippet starts at (≤ `offset`). */
|
|
110
|
+
snippetOffset: number;
|
|
111
|
+
/**
|
|
112
|
+
* Bounded context around the match. Bounded on purpose: an MCP artifact is
|
|
113
|
+
* usually one compact JSON line, so "the matching line" would be the whole
|
|
114
|
+
* payload.
|
|
115
|
+
*/
|
|
116
|
+
snippet: string;
|
|
117
|
+
};
|
|
118
|
+
/** Result of a literal search over an artifact. */
|
|
119
|
+
export type ArtifactSearchResult = {
|
|
120
|
+
/** Matches returned, in payload order. */
|
|
121
|
+
matches: ArtifactSearchMatch[];
|
|
122
|
+
/** `matches.length`. */
|
|
123
|
+
matchCount: number;
|
|
124
|
+
/** Every match in the payload, including the ones not returned. */
|
|
125
|
+
totalMatches: number;
|
|
126
|
+
/** True when `totalMatches > matchCount`. */
|
|
127
|
+
truncated: boolean;
|
|
128
|
+
/** Character offset to pass as `offset` to search for the next matches. */
|
|
129
|
+
nextSearchOffset?: number;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Pluggable storage contract for externalized MCP tool outputs and banked
|
|
133
|
+
* payloads.
|
|
134
|
+
*
|
|
135
|
+
* Shipped backends: `LocalTempArtifactStore` (filesystem, per-process index
|
|
136
|
+
* with a cross-process sidecar) and `RedisArtifactStore` (TTL-expired, shared
|
|
137
|
+
* across replicas, range reads). Pick one with `artifacts.storage` or the
|
|
138
|
+
* `STORAGE_TYPE` environment variable, or inject any implementation via
|
|
139
|
+
* `artifacts.store` / `setArtifactStore()`.
|
|
140
|
+
*
|
|
141
|
+
* Only `store`, `retrieve`, `delete`, `cleanup` and `generatePreview` are
|
|
142
|
+
* required. `retrieveRange` and `close` are optional capabilities: NeuroLink
|
|
143
|
+
* uses them when present and falls back cleanly when absent.
|
|
90
144
|
*/
|
|
91
145
|
export type ArtifactStore = {
|
|
92
146
|
/**
|
|
@@ -100,6 +154,22 @@ export type ArtifactStore = {
|
|
|
100
154
|
* Returns `null` if the artifact is not found or has been cleaned up.
|
|
101
155
|
*/
|
|
102
156
|
retrieve(id: string): Promise<string | null>;
|
|
157
|
+
/**
|
|
158
|
+
* Retrieve one character window without materialising the whole payload.
|
|
159
|
+
*
|
|
160
|
+
* Optional. When present, `retrieve_context` and `readArtifact` call it for
|
|
161
|
+
* every paged read instead of `retrieve()` + slice, so a backend with native
|
|
162
|
+
* range reads (Redis `GETRANGE`, S3 `Range`) moves only the window. The
|
|
163
|
+
* result carries `totalLength` so `hasMore` never needs the payload.
|
|
164
|
+
*
|
|
165
|
+
* `offset` and `limit` are characters. A backend that can only address
|
|
166
|
+
* bytes must either know the payload is single-byte (ASCII) or fall back to
|
|
167
|
+
* a full read and slice — it must never return a window that starts at the
|
|
168
|
+
* wrong character. `limit` omitted means "to the end".
|
|
169
|
+
*
|
|
170
|
+
* Returns `null` if the artifact is not found or has expired.
|
|
171
|
+
*/
|
|
172
|
+
retrieveRange?(id: string, range: ArtifactPageRequest): Promise<ArtifactWindow | null>;
|
|
103
173
|
/** Delete a single artifact. No-op if the ID does not exist. */
|
|
104
174
|
delete(id: string): Promise<void>;
|
|
105
175
|
/**
|
|
@@ -109,6 +179,13 @@ export type ArtifactStore = {
|
|
|
109
179
|
cleanup(olderThanMs: number): Promise<number>;
|
|
110
180
|
/** Generate a short preview string from a serialized payload. */
|
|
111
181
|
generatePreview(payload: string): string;
|
|
182
|
+
/**
|
|
183
|
+
* Release whatever the store holds open (a pooled connection, a file
|
|
184
|
+
* handle). Optional. NeuroLink calls it — from `shutdown()`, and when
|
|
185
|
+
* `setArtifactStore()` replaces the store — only for stores it built
|
|
186
|
+
* itself; a store you inject is yours to close.
|
|
187
|
+
*/
|
|
188
|
+
close?(): Promise<void>;
|
|
112
189
|
};
|
|
113
190
|
/**
|
|
114
191
|
* In-memory index row tracked by LocalTempArtifactStore.
|
|
@@ -122,3 +199,47 @@ export type IndexEntry = ArtifactMeta & {
|
|
|
122
199
|
*/
|
|
123
200
|
rehydrated?: boolean;
|
|
124
201
|
};
|
|
202
|
+
/**
|
|
203
|
+
* Where artifacts live. Mirrors conversation memory's `STORAGE_TYPE`:
|
|
204
|
+
* - "local" OS temp directory, per-process index with a cross-process
|
|
205
|
+
* sidecar. Fine for one machine; artifacts do not survive a pod.
|
|
206
|
+
* - "redis" Shared across replicas, expired by TTL, range reads via
|
|
207
|
+
* `GETRANGE`. Uses the same connection pool as Redis conversation
|
|
208
|
+
* memory.
|
|
209
|
+
*/
|
|
210
|
+
export type ArtifactStorageType = "local" | "redis";
|
|
211
|
+
/**
|
|
212
|
+
* Artifact storage configuration (`new NeuroLink({ artifacts })`).
|
|
213
|
+
*
|
|
214
|
+
* Resolution order for the backend: `store` → `storage` → `STORAGE_TYPE`
|
|
215
|
+
* environment variable → `"local"`. Resolution order for the Redis connection:
|
|
216
|
+
* `redisConfig` → `conversationMemory.redisConfig` → `REDIS_URL` / `REDIS_HOST`
|
|
217
|
+
* environment variables — so a deployment already running conversation memory
|
|
218
|
+
* on Redis keeps its artifacts on the same Redis without new settings.
|
|
219
|
+
*/
|
|
220
|
+
export type ArtifactStorageConfig = {
|
|
221
|
+
/** Backend to use. Default: `STORAGE_TYPE` env var, else `"local"`. */
|
|
222
|
+
storage?: ArtifactStorageType;
|
|
223
|
+
/**
|
|
224
|
+
* Redis connection for `storage: "redis"`. `keyPrefix` defaults to
|
|
225
|
+
* `neurolink:artifact:` (NOT the conversation prefix). `ttl` is seconds,
|
|
226
|
+
* must be positive, and defaults to 86400; zero or negative is replaced by
|
|
227
|
+
* the default with a warning — artifacts in Redis always expire.
|
|
228
|
+
* `userSessionsKeyPrefix` is ignored.
|
|
229
|
+
*/
|
|
230
|
+
redisConfig?: RedisStorageConfig;
|
|
231
|
+
/**
|
|
232
|
+
* A ready-made backend. Wins over `storage`. Use this for S3, a database,
|
|
233
|
+
* or a wrapped store; `setArtifactStore()` does the same after construction.
|
|
234
|
+
*/
|
|
235
|
+
store?: ArtifactStore;
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* What `RedisArtifactStore` keeps beside each payload. `charLength` is what
|
|
239
|
+
* makes range reads honest: when it equals `sizeBytes` the payload is pure
|
|
240
|
+
* ASCII and a byte range IS a character range.
|
|
241
|
+
*/
|
|
242
|
+
export type RedisArtifactRecord = ArtifactMeta & {
|
|
243
|
+
/** `payload.length` at store time — characters, not bytes. */
|
|
244
|
+
charLength: number;
|
|
245
|
+
};
|
package/dist/types/config.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { MCPToolRegistry } from "../mcp/toolRegistry.js";
|
|
|
6
6
|
import type { TaskManagerConfig } from "./task.js";
|
|
7
7
|
import type { HITLConfig } from "../types/hitl.js";
|
|
8
8
|
import type { ConversationMemoryConfig } from "./conversation.js";
|
|
9
|
+
import type { ArtifactStorageConfig } from "./artifact.js";
|
|
9
10
|
import type { ObservabilityConfig } from "./observability.js";
|
|
10
11
|
import type { AuthProvider, AuthProviderType, AuthProviderConfig, Auth0Config, ClerkConfig, FirebaseConfig, SupabaseConfig, WorkOSConfig, BetterAuthConfig, JWTConfig, OAuth2Config, CognitoConfig, KeycloakConfig, AuthenticatedContext } from "./auth.js";
|
|
11
12
|
import type { NeurolinkCredentials } from "./providers.js";
|
|
@@ -61,6 +62,12 @@ export type NeurolinkConstructorConfig = {
|
|
|
61
62
|
modelAliasConfig?: ModelAliasConfig;
|
|
62
63
|
/** MCP enhancement modules configuration (cache, router, batcher, annotations, middleware) */
|
|
63
64
|
mcp?: MCPEnhancementsConfig;
|
|
65
|
+
/**
|
|
66
|
+
* Artifact storage: where externalized MCP tool outputs and banked payloads
|
|
67
|
+
* live. The backend follows `STORAGE_TYPE` exactly like conversation memory
|
|
68
|
+
* unless chosen here. See {@link ArtifactStorageConfig}.
|
|
69
|
+
*/
|
|
70
|
+
artifacts?: ArtifactStorageConfig;
|
|
64
71
|
/** Authentication provider configuration */
|
|
65
72
|
auth?: NeuroLinkAuthConfig;
|
|
66
73
|
/** TaskManager configuration (scheduled and self-running tasks) */
|
package/dist/types/mcp.d.ts
CHANGED
|
@@ -422,6 +422,22 @@ export type ToolDiscoveryResult = {
|
|
|
422
422
|
/** Server ID */
|
|
423
423
|
serverId: string;
|
|
424
424
|
};
|
|
425
|
+
/**
|
|
426
|
+
* Outcome of matching a possibly-misspelled tool name against a list of
|
|
427
|
+
* available tool names (see `resolveToolName` in
|
|
428
|
+
* src/lib/utils/toolCallRepair.ts). Shared between the AI-SDK generation-path
|
|
429
|
+
* repair (`experimental_repairToolCall`) and direct MCP execution boundaries
|
|
430
|
+
* (`NeuroLink.executeExternalMCPTool`) so both recover from the same class of
|
|
431
|
+
* near-miss the same way.
|
|
432
|
+
*/
|
|
433
|
+
export type ToolNameResolution = {
|
|
434
|
+
/** The resolved, available tool name. */
|
|
435
|
+
name: string;
|
|
436
|
+
/** Which strategy produced the match, in the order they are attempted. */
|
|
437
|
+
strategy: "case" | "substring" | "levenshtein";
|
|
438
|
+
/** Normalized Levenshtein distance (0–1) — only set when strategy is "levenshtein". */
|
|
439
|
+
score?: number;
|
|
440
|
+
};
|
|
425
441
|
/**
|
|
426
442
|
* External MCP tool execution options
|
|
427
443
|
* Moved from src/lib/mcp/toolDiscoveryService.ts
|
package/dist/utils/redis.d.ts
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
* Helper functions for Redis storage operations
|
|
4
4
|
*/
|
|
5
5
|
import type { ChatMessage, RedisClient, RedisConversationObject, RedisStorageConfig } from "../types/index.js";
|
|
6
|
+
/**
|
|
7
|
+
* Redact the userinfo of every `scheme://user:pass@host` in `text`.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately not a regex. `/:\/\/[^@]+@/` and its relatives backtrack
|
|
10
|
+
* quadratically on input with many `://` and no `@` (CodeQL
|
|
11
|
+
* js/polynomial-redos), and the URL here can come straight from a caller —
|
|
12
|
+
* `RedisStorageConfig.url` reaches this through `RedisArtifactStore` and the
|
|
13
|
+
* conversation memory manager. One forward pass, every character visited a
|
|
14
|
+
* bounded number of times.
|
|
15
|
+
*
|
|
16
|
+
* Redacts everything before the LAST `@` of the authority, so a password
|
|
17
|
+
* containing `@` is still hidden. A URL with only a user name is redacted
|
|
18
|
+
* too; the old regexes let that through.
|
|
19
|
+
*/
|
|
20
|
+
export declare function redactUrlCredentials(text: string): string;
|
|
6
21
|
/**
|
|
7
22
|
* Get a pooled Redis connection. Multiple callers with the same host:port:db
|
|
8
23
|
* share a single connection, reducing connection count.
|
package/dist/utils/redis.js
CHANGED
|
@@ -6,17 +6,75 @@ import { randomUUID } from "crypto";
|
|
|
6
6
|
import { createClient } from "redis";
|
|
7
7
|
import { logger } from "./logger.js";
|
|
8
8
|
const SESSION_ONLY_PREFIX = "session-only:";
|
|
9
|
+
/** Characters that end the authority (`user:pass@host:port`) of a URL in text. */
|
|
10
|
+
const AUTHORITY_TERMINATORS = new Set(["/", "?", "#", " ", "\t", "\n", "\r"]);
|
|
11
|
+
/**
|
|
12
|
+
* Redact the userinfo of every `scheme://user:pass@host` in `text`.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately not a regex. `/:\/\/[^@]+@/` and its relatives backtrack
|
|
15
|
+
* quadratically on input with many `://` and no `@` (CodeQL
|
|
16
|
+
* js/polynomial-redos), and the URL here can come straight from a caller —
|
|
17
|
+
* `RedisStorageConfig.url` reaches this through `RedisArtifactStore` and the
|
|
18
|
+
* conversation memory manager. One forward pass, every character visited a
|
|
19
|
+
* bounded number of times.
|
|
20
|
+
*
|
|
21
|
+
* Redacts everything before the LAST `@` of the authority, so a password
|
|
22
|
+
* containing `@` is still hidden. A URL with only a user name is redacted
|
|
23
|
+
* too; the old regexes let that through.
|
|
24
|
+
*/
|
|
25
|
+
export function redactUrlCredentials(text) {
|
|
26
|
+
let out = "";
|
|
27
|
+
let cursor = 0;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const schemeEnd = text.indexOf("://", cursor);
|
|
30
|
+
if (schemeEnd === -1) {
|
|
31
|
+
return out + text.slice(cursor);
|
|
32
|
+
}
|
|
33
|
+
const authorityStart = schemeEnd + 3;
|
|
34
|
+
let authorityEnd = authorityStart;
|
|
35
|
+
let lastAt = -1;
|
|
36
|
+
while (authorityEnd < text.length) {
|
|
37
|
+
const ch = text[authorityEnd];
|
|
38
|
+
if (AUTHORITY_TERMINATORS.has(ch)) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
if (ch === "@") {
|
|
42
|
+
lastAt = authorityEnd;
|
|
43
|
+
}
|
|
44
|
+
authorityEnd += 1;
|
|
45
|
+
}
|
|
46
|
+
if (lastAt === -1) {
|
|
47
|
+
out += text.slice(cursor, authorityEnd);
|
|
48
|
+
cursor = authorityEnd;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
out += `${text.slice(cursor, authorityStart)}[redacted]@`;
|
|
52
|
+
cursor = lastAt + 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
9
56
|
// Connection pool - keyed by host:port:db
|
|
10
57
|
const connectionPool = new Map();
|
|
11
58
|
const pendingConnections = new Map();
|
|
59
|
+
/**
|
|
60
|
+
* One pool key per distinct connection. Shared by acquire AND release: they
|
|
61
|
+
* used to compute it separately, and the release side never knew about the
|
|
62
|
+
* `url:` form, so a URL-configured client was acquired under one key and
|
|
63
|
+
* "released" under another that did not exist — its reference count never
|
|
64
|
+
* dropped and the connection outlived every owner. Credentials never appear
|
|
65
|
+
* in the key; it is logged.
|
|
66
|
+
*/
|
|
67
|
+
function poolKeyFor(config) {
|
|
68
|
+
return config.url
|
|
69
|
+
? `url:${redactUrlCredentials(config.url)}`
|
|
70
|
+
: `${config.host}:${config.port}:${config.db}:${config.password ? "auth" : "noauth"}`;
|
|
71
|
+
}
|
|
12
72
|
/**
|
|
13
73
|
* Get a pooled Redis connection. Multiple callers with the same host:port:db
|
|
14
74
|
* share a single connection, reducing connection count.
|
|
15
75
|
*/
|
|
16
76
|
export async function getPooledRedisClient(config) {
|
|
17
|
-
const key = config
|
|
18
|
-
? `url:${config.url.replace(/:\/\/[^:]+:[^@]+@/, "://[redacted]@")}`
|
|
19
|
-
: `${config.host}:${config.port}:${config.db}:${config.password ? "auth" : "noauth"}`;
|
|
77
|
+
const key = poolKeyFor(config);
|
|
20
78
|
const existing = connectionPool.get(key);
|
|
21
79
|
if (existing && existing.client.isOpen) {
|
|
22
80
|
existing.refCount++;
|
|
@@ -77,7 +135,7 @@ export async function getPooledRedisClient(config) {
|
|
|
77
135
|
* Release a pooled Redis connection. Only closes when refCount reaches 0.
|
|
78
136
|
*/
|
|
79
137
|
export async function releasePooledRedisClient(config) {
|
|
80
|
-
const key =
|
|
138
|
+
const key = poolKeyFor(config);
|
|
81
139
|
const entry = connectionPool.get(key);
|
|
82
140
|
if (!entry) {
|
|
83
141
|
return;
|
|
@@ -140,7 +198,7 @@ export async function createRedisClient(config) {
|
|
|
140
198
|
// Create client with secured options
|
|
141
199
|
const client = createClient(clientOptions);
|
|
142
200
|
client.on("error", (err) => {
|
|
143
|
-
const sanitizedMessage = err.message
|
|
201
|
+
const sanitizedMessage = redactUrlCredentials(err.message);
|
|
144
202
|
logger.error("Redis client error", { error: sanitizedMessage });
|
|
145
203
|
});
|
|
146
204
|
client.on("connect", () => {
|
|
@@ -420,7 +478,7 @@ export function getNormalizedConfig(config) {
|
|
|
420
478
|
: 0;
|
|
421
479
|
}
|
|
422
480
|
catch (e) {
|
|
423
|
-
const sanitizedUrl = url
|
|
481
|
+
const sanitizedUrl = redactUrlCredentials(url);
|
|
424
482
|
logger.warn("[redisUtils] Failed to parse Redis URL, falling back to component-based connection", {
|
|
425
483
|
url: sanitizedUrl,
|
|
426
484
|
error: e instanceof Error ? e.message : String(e),
|
|
@@ -1,9 +1,51 @@
|
|
|
1
1
|
import type { ToolCallRepairFunction, ToolSet } from "../types/index.js";
|
|
2
|
+
import type { ToolNameResolution } from "../types/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* Create an `experimental_repairToolCall` handler for streamText/generateText.
|
|
4
5
|
* Fully dynamic — reads the tool schema at repair time, no configuration needed.
|
|
5
6
|
*/
|
|
6
7
|
export declare function createToolCallRepair(): ToolCallRepairFunction<ToolSet>;
|
|
8
|
+
/**
|
|
9
|
+
* Match a possibly-misspelled tool name against a list of available tool
|
|
10
|
+
* names. Strategies (in order): case-insensitive exact → unambiguous
|
|
11
|
+
* substring → Levenshtein.
|
|
12
|
+
*
|
|
13
|
+
* Pulled out of `repairToolName` so the same matching policy can be reused
|
|
14
|
+
* outside the AI-SDK generation loop — `experimental_repairToolCall` only
|
|
15
|
+
* runs inside `streamText`/`generateText`, so a name typo at a direct MCP
|
|
16
|
+
* execution boundary (`NeuroLink.executeExternalMCPTool`) previously had no
|
|
17
|
+
* recovery at all. This function is pure name-matching: no `LanguageModelV3ToolCall`,
|
|
18
|
+
* no logging, so callers with a different call shape can reuse it directly.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveToolName(calledName: string, availableTools: string[]): ToolNameResolution | null;
|
|
21
|
+
/**
|
|
22
|
+
* Rank every available tool name by similarity to `calledName` (ascending
|
|
23
|
+
* normalized Levenshtein distance) and return the closest `limit`.
|
|
24
|
+
*
|
|
25
|
+
* Used to build the candidate list on `ExternalMcpToolNotFoundError` when
|
|
26
|
+
* `resolveToolName` found no unambiguous match — unlike `resolveToolName`,
|
|
27
|
+
* this makes no accept/reject judgment, it just orders what is available so
|
|
28
|
+
* a caller (human or AI) can pick the right name themselves.
|
|
29
|
+
*/
|
|
30
|
+
export declare function rankToolNameCandidates(calledName: string, availableTools: string[], limit?: number): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Thrown at a direct MCP execution boundary (`NeuroLink.executeExternalMCPTool`)
|
|
33
|
+
* when `resolveToolName` cannot find an unambiguous match for a requested
|
|
34
|
+
* tool name against a server's discovered tools. Distinct from the plain
|
|
35
|
+
* `Error` that `ToolDiscoveryService.executeTool` throws deeper in the stack
|
|
36
|
+
* for the same condition, so callers can distinguish "no match — here are
|
|
37
|
+
* the closest names" from every other execution failure programmatically,
|
|
38
|
+
* instead of parsing a message string.
|
|
39
|
+
*/
|
|
40
|
+
export declare class ExternalMcpToolNotFoundError extends Error {
|
|
41
|
+
/** The tool name that was requested and could not be resolved. */
|
|
42
|
+
readonly requestedName: string;
|
|
43
|
+
/** The server the tool was requested against. */
|
|
44
|
+
readonly serverId: string;
|
|
45
|
+
/** Closest available tool names on that server, capped (see `rankToolNameCandidates`). */
|
|
46
|
+
readonly candidates: string[];
|
|
47
|
+
constructor(requestedName: string, serverId: string, candidates: string[]);
|
|
48
|
+
}
|
|
7
49
|
/**
|
|
8
50
|
* Coerce a value to match the expected schema type.
|
|
9
51
|
* Handles: string→number, JSON string→object, JSON string→array, value→[value].
|
|
@@ -25,31 +25,36 @@ export function createToolCallRepair() {
|
|
|
25
25
|
}
|
|
26
26
|
// ─── Tool Name Repair ──────────────────────────────────────────────
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
* Strategies (in order): case-insensitive exact →
|
|
28
|
+
* Match a possibly-misspelled tool name against a list of available tool
|
|
29
|
+
* names. Strategies (in order): case-insensitive exact → unambiguous
|
|
30
|
+
* substring → Levenshtein.
|
|
31
|
+
*
|
|
32
|
+
* Pulled out of `repairToolName` so the same matching policy can be reused
|
|
33
|
+
* outside the AI-SDK generation loop — `experimental_repairToolCall` only
|
|
34
|
+
* runs inside `streamText`/`generateText`, so a name typo at a direct MCP
|
|
35
|
+
* execution boundary (`NeuroLink.executeExternalMCPTool`) previously had no
|
|
36
|
+
* recovery at all. This function is pure name-matching: no `LanguageModelV3ToolCall`,
|
|
37
|
+
* no logging, so callers with a different call shape can reuse it directly.
|
|
30
38
|
*/
|
|
31
|
-
function
|
|
32
|
-
const called = toolCall.toolName;
|
|
39
|
+
export function resolveToolName(calledName, availableTools) {
|
|
33
40
|
// Guard: empty or whitespace-only tool name cannot be meaningfully repaired
|
|
34
|
-
if (!
|
|
41
|
+
if (!calledName || calledName.trim().length === 0) {
|
|
35
42
|
return null;
|
|
36
43
|
}
|
|
37
44
|
// 1. Case-insensitive exact match
|
|
38
|
-
const ciMatch = availableTools.find((t) => t.toLowerCase() ===
|
|
45
|
+
const ciMatch = availableTools.find((t) => t.toLowerCase() === calledName.toLowerCase());
|
|
39
46
|
if (ciMatch) {
|
|
40
|
-
|
|
41
|
-
return { ...toolCall, toolName: ciMatch };
|
|
47
|
+
return { name: ciMatch, strategy: "case" };
|
|
42
48
|
}
|
|
43
49
|
// 2. Substring match: "search_file" is substring of "search_files" or vice versa.
|
|
44
50
|
// Only accept when exactly one tool matches to avoid ambiguous repairs.
|
|
45
|
-
const calledLower =
|
|
51
|
+
const calledLower = calledName.toLowerCase();
|
|
46
52
|
const subCandidates = availableTools.filter((t) => {
|
|
47
53
|
const tLower = t.toLowerCase();
|
|
48
54
|
return tLower.includes(calledLower) || calledLower.includes(tLower);
|
|
49
55
|
});
|
|
50
56
|
if (subCandidates.length === 1) {
|
|
51
|
-
|
|
52
|
-
return { ...toolCall, toolName: subCandidates[0] };
|
|
57
|
+
return { name: subCandidates[0], strategy: "substring" };
|
|
53
58
|
}
|
|
54
59
|
// 3. Levenshtein distance — accept if normalized distance < 0.3
|
|
55
60
|
// Compare by normalized score (not raw edits) so length differences don't skew selection.
|
|
@@ -57,7 +62,7 @@ function repairToolName(toolCall, availableTools) {
|
|
|
57
62
|
let bestNormalized = Infinity;
|
|
58
63
|
for (const t of availableTools) {
|
|
59
64
|
const dist = levenshtein(calledLower, t.toLowerCase());
|
|
60
|
-
const maxLen = Math.max(
|
|
65
|
+
const maxLen = Math.max(calledName.length, t.length);
|
|
61
66
|
const normalized = maxLen === 0 ? 0 : dist / maxLen;
|
|
62
67
|
if (normalized < 0.3 && normalized < bestNormalized) {
|
|
63
68
|
bestNormalized = normalized;
|
|
@@ -65,12 +70,70 @@ function repairToolName(toolCall, availableTools) {
|
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
if (bestMatch) {
|
|
68
|
-
|
|
69
|
-
return { ...toolCall, toolName: bestMatch };
|
|
73
|
+
return { name: bestMatch, strategy: "levenshtein", score: bestNormalized };
|
|
70
74
|
}
|
|
71
|
-
logger.debug(`[ToolCallRepair] Could not repair tool name "${called}". Available: [${availableTools.join(", ")}]`);
|
|
72
75
|
return null;
|
|
73
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Rank every available tool name by similarity to `calledName` (ascending
|
|
79
|
+
* normalized Levenshtein distance) and return the closest `limit`.
|
|
80
|
+
*
|
|
81
|
+
* Used to build the candidate list on `ExternalMcpToolNotFoundError` when
|
|
82
|
+
* `resolveToolName` found no unambiguous match — unlike `resolveToolName`,
|
|
83
|
+
* this makes no accept/reject judgment, it just orders what is available so
|
|
84
|
+
* a caller (human or AI) can pick the right name themselves.
|
|
85
|
+
*/
|
|
86
|
+
export function rankToolNameCandidates(calledName, availableTools, limit = 5) {
|
|
87
|
+
const calledLower = calledName.toLowerCase();
|
|
88
|
+
return [...availableTools]
|
|
89
|
+
.sort((a, b) => levenshtein(calledLower, a.toLowerCase()) -
|
|
90
|
+
levenshtein(calledLower, b.toLowerCase()))
|
|
91
|
+
.slice(0, limit);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Thrown at a direct MCP execution boundary (`NeuroLink.executeExternalMCPTool`)
|
|
95
|
+
* when `resolveToolName` cannot find an unambiguous match for a requested
|
|
96
|
+
* tool name against a server's discovered tools. Distinct from the plain
|
|
97
|
+
* `Error` that `ToolDiscoveryService.executeTool` throws deeper in the stack
|
|
98
|
+
* for the same condition, so callers can distinguish "no match — here are
|
|
99
|
+
* the closest names" from every other execution failure programmatically,
|
|
100
|
+
* instead of parsing a message string.
|
|
101
|
+
*/
|
|
102
|
+
export class ExternalMcpToolNotFoundError extends Error {
|
|
103
|
+
/** The tool name that was requested and could not be resolved. */
|
|
104
|
+
requestedName;
|
|
105
|
+
/** The server the tool was requested against. */
|
|
106
|
+
serverId;
|
|
107
|
+
/** Closest available tool names on that server, capped (see `rankToolNameCandidates`). */
|
|
108
|
+
candidates;
|
|
109
|
+
constructor(requestedName, serverId, candidates) {
|
|
110
|
+
const candidateList = candidates.length > 0 ? candidates.join(", ") : "(none registered)";
|
|
111
|
+
super(`Tool '${requestedName}' not found for server '${serverId}'. Closest available: ${candidateList}`);
|
|
112
|
+
this.name = "ExternalMcpToolNotFoundError";
|
|
113
|
+
this.requestedName = requestedName;
|
|
114
|
+
this.serverId = serverId;
|
|
115
|
+
this.candidates = candidates;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Attempt to match a wrong tool name against available tool names and
|
|
120
|
+
* produce a repaired `LanguageModelV3ToolCall` for the AI-SDK generation
|
|
121
|
+
* path. Thin wrapper around `resolveToolName` that restores this function's
|
|
122
|
+
* original debug-log wording so generation-path behaviour is unchanged.
|
|
123
|
+
*/
|
|
124
|
+
function repairToolName(toolCall, availableTools) {
|
|
125
|
+
const called = toolCall.toolName;
|
|
126
|
+
const resolution = resolveToolName(called, availableTools);
|
|
127
|
+
if (!resolution) {
|
|
128
|
+
logger.debug(`[ToolCallRepair] Could not repair tool name "${called}". Available: [${availableTools.join(", ")}]`);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const label = resolution.strategy === "levenshtein"
|
|
132
|
+
? `levenshtein ${resolution.score.toFixed(2)}`
|
|
133
|
+
: resolution.strategy;
|
|
134
|
+
logger.debug(`[ToolCallRepair] Name repair (${label}): "${called}" → "${resolution.name}"`);
|
|
135
|
+
return { ...toolCall, toolName: resolution.name };
|
|
136
|
+
}
|
|
74
137
|
// ─── Tool Input Repair ─────────────────────────────────────────────
|
|
75
138
|
/**
|
|
76
139
|
* Attempt to repair wrong parameter names and types using the JSON schema.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.10.0",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"test:mcp:sdk": "pnpm exec tsx test/continuous-test-suite-mcp-sdk.ts",
|
|
87
87
|
"test:mcp:cli": "pnpm exec tsx test/continuous-test-suite-mcp-cli.ts",
|
|
88
88
|
"test:mcp:stdio-lifecycle": "pnpm exec tsx test/continuous-test-suite-mcp-stdio-lifecycle.ts",
|
|
89
|
+
"test:mcp-direct-name-repair": "pnpm exec tsx test/continuous-test-suite-mcp-direct-name-repair.ts",
|
|
89
90
|
"test:mcp:full": "pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:mcp:stdio-lifecycle && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:mcp:http",
|
|
90
91
|
"test:media": "pnpm exec tsx test/continuous-test-suite-media-gen.ts",
|
|
91
92
|
"test:media-registry-collisions": "pnpm exec tsx test/continuous-test-suite-media-registry-collisions.ts",
|
|
@@ -154,7 +155,7 @@
|
|
|
154
155
|
"// CI tier — fast, no live AI calls, safe for every commit (test:unit; also see the separate provider-safety-net CI job, which runs build + test:providers-mocked + test:provider-structure + test:error-classifier-contract on every PR)": "",
|
|
155
156
|
"test:tool-routing": "pnpm exec tsx test/continuous-test-suite-tool-routing.ts",
|
|
156
157
|
"test:tool-routing-semantic": "pnpm exec tsx test/continuous-test-suite-tool-routing-semantic.ts",
|
|
157
|
-
"test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
|
|
158
|
+
"test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:mcp-direct-name-repair && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
|
|
158
159
|
"// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit; test:matrix, a different suite covering the full provider capability matrix, runs nightly via .github/workflows/live-matrix.yml — test:providers itself is still only wired into test:live, not any GitHub Actions workflow)": "",
|
|
159
160
|
"test:live": "pnpm run test:providers && pnpm run test:mcp:http && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:observability && pnpm run test:context && pnpm run test:memory && pnpm run test:tool-reliability && pnpm run test:evaluation && pnpm run test:autoresearch && pnpm run test:credentials && pnpm run test:dynamic",
|
|
160
161
|
"// CI tier — product output (image/video/TTS/PPT) — costs $$ per run (not wired into any GitHub Actions workflow as of this comment; run manually or add to live-matrix.yml if nightly coverage is needed)": "",
|
|
@@ -455,7 +456,7 @@
|
|
|
455
456
|
"exceljs": "^4.4.0",
|
|
456
457
|
"express": "^5.1.0",
|
|
457
458
|
"express-rate-limit": "^8.2.1",
|
|
458
|
-
"fastify": "^5.
|
|
459
|
+
"fastify": "^5.12.1",
|
|
459
460
|
"ffmpeg-static": "^5.3.0",
|
|
460
461
|
"fluent-ffmpeg": "^2.1.3",
|
|
461
462
|
"koa": "^3.1.1",
|
|
@@ -604,7 +605,7 @@
|
|
|
604
605
|
"@opentelemetry/sdk-trace-node": "^2.6.0",
|
|
605
606
|
"jws@<4.0.1": ">=4.0.1",
|
|
606
607
|
"tar@<7.5.8": ">=7.5.8",
|
|
607
|
-
"qs@<6.
|
|
608
|
+
"qs@<6.16.0": ">=6.16.0",
|
|
608
609
|
"minimatch@>=10.0.0 <10.2.3": ">=10.2.3",
|
|
609
610
|
"minimatch@>=9.0.0 <9.0.7": ">=9.0.7",
|
|
610
611
|
"typedoc>minimatch": ">=10.2.3",
|
|
@@ -621,7 +622,7 @@
|
|
|
621
622
|
"ajv@>=8.0.0 <8.18.0": ">=8.18.0",
|
|
622
623
|
"@grpc/grpc-js@<1.14.4": ">=1.14.4",
|
|
623
624
|
"@protobufjs/utf8@<1.1.1": ">=1.1.1",
|
|
624
|
-
"fast-uri@<3.1.
|
|
625
|
+
"fast-uri@<3.1.6": ">=3.1.6 <4",
|
|
625
626
|
"ip-address@<10.1.1": ">=10.1.1",
|
|
626
627
|
"basic-ftp@<5.2.2": ">=5.2.2",
|
|
627
628
|
"fast-xml-builder@<1.1.7": ">=1.1.7",
|
|
@@ -631,7 +632,7 @@
|
|
|
631
632
|
"undici@>=8.0.0": ">=7.24.0 <8.0.0",
|
|
632
633
|
"pdfjs-dist": "5.4.624",
|
|
633
634
|
"markdown-it@<14.2.0": ">=14.2.0",
|
|
634
|
-
"@xmldom/xmldom@<0.9.
|
|
635
|
+
"@xmldom/xmldom@<0.9.12": ">=0.9.12",
|
|
635
636
|
"protobufjs@<7.6.5": ">=7.6.5",
|
|
636
637
|
"brace-expansion@>=5.0.0 <5.0.9": ">=5.0.9",
|
|
637
638
|
"@opentelemetry/core@>=2.0.0 <2.8.0": ">=2.8.0"
|