@librechat/agents 3.7.21 → 3.8.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/dist/cjs/graphs/Graph.cjs +21 -4
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +6 -2
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +21 -6
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/main.cjs +2 -0
- package/dist/cjs/stream.cjs +38 -1
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ArtifactDelivery.cjs +27 -0
- package/dist/cjs/tools/ArtifactDelivery.cjs.map +1 -0
- package/dist/cjs/tools/BashExecutor.cjs +6 -1
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +6 -1
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +95 -18
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +130 -24
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +1 -1
- package/dist/cjs/tools/preparedSubagents.cjs +129 -0
- package/dist/cjs/tools/preparedSubagents.cjs.map +1 -0
- package/dist/esm/graphs/Graph.mjs +21 -4
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +6 -2
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +21 -6
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/main.mjs +2 -2
- package/dist/esm/stream.mjs +39 -2
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ArtifactDelivery.mjs +26 -0
- package/dist/esm/tools/ArtifactDelivery.mjs.map +1 -0
- package/dist/esm/tools/BashExecutor.mjs +6 -1
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +6 -1
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +95 -18
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +129 -25
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +1 -1
- package/dist/esm/tools/preparedSubagents.mjs +128 -0
- package/dist/esm/tools/preparedSubagents.mjs.map +1 -0
- package/dist/types/graphs/Graph.d.ts +6 -1
- package/dist/types/langfuse.d.ts +5 -0
- package/dist/types/tools/ArtifactDelivery.d.ts +4 -0
- package/dist/types/tools/ToolNode.d.ts +13 -1
- package/dist/types/tools/ToolSearch.d.ts +46 -2
- package/dist/types/tools/preparedSubagents.d.ts +26 -0
- package/dist/types/types/graph.d.ts +8 -0
- package/dist/types/types/tools.d.ts +28 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +63 -5
- package/src/langfuse.ts +17 -2
- package/src/llm/invoke.ts +35 -8
- package/src/stream.ts +70 -1
- package/src/tools/ArtifactDelivery.ts +50 -0
- package/src/tools/BashExecutor.ts +18 -1
- package/src/tools/CodeExecutor.ts +18 -1
- package/src/tools/ProgrammaticToolCalling.ts +15 -1
- package/src/tools/ToolNode.ts +189 -43
- package/src/tools/ToolSearch.ts +278 -28
- package/src/tools/preparedSubagents.ts +228 -0
- package/src/types/graph.ts +8 -0
- package/src/types/tools.ts +29 -0
package/src/tools/ToolSearch.ts
CHANGED
|
@@ -176,6 +176,122 @@ function isFromMcpServer(toolName: string, serverName: string): boolean {
|
|
|
176
176
|
return toolServer === serverName;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Canonical form used for lenient matching. It folds case and treats the
|
|
181
|
+
* separators a host may rewrite as equivalent — LibreChat turns
|
|
182
|
+
* `ClickHouse Cloud` into `ClickHouse_Cloud`, so a model asking for
|
|
183
|
+
* `clickhouse-cloud` is naming the same server.
|
|
184
|
+
*
|
|
185
|
+
* **No character is ever discarded.** Every incorrect match this resolver has
|
|
186
|
+
* produced came from deleting characters, because deleting merges two distinct
|
|
187
|
+
* names onto one key: dropping non-ASCII made `éfoo` equal `foo`, dropping
|
|
188
|
+
* combining marks made `İfoo` equal `ifoo`, and dropping symbols made `❤️`
|
|
189
|
+
* equal `☀️`. Collapsing separators is the only equivalence intended here, so
|
|
190
|
+
* it is the only one performed.
|
|
191
|
+
*
|
|
192
|
+
* Case is folded with `toLowerCase` alone. A round trip through uppercase
|
|
193
|
+
* would additionally equate a Greek final sigma with its medial form, but it
|
|
194
|
+
* also equates dotless `ı` with ASCII `i`, which Unicode case folding keeps
|
|
195
|
+
* apart — and a false match hands over another server's tools while a missed
|
|
196
|
+
* one only produces the message naming the servers that do exist. The safe
|
|
197
|
+
* direction is the one taken here.
|
|
198
|
+
*/
|
|
199
|
+
function canonicalizeServerName(serverName: string): string {
|
|
200
|
+
return serverName
|
|
201
|
+
.trim()
|
|
202
|
+
.toLowerCase()
|
|
203
|
+
.normalize('NFC')
|
|
204
|
+
.replace(/[-_. ]+/g, '-');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Canonical form of a requested name with a leading `mcp` token removed, or
|
|
209
|
+
* undefined when it had none. Servers are commonly keyed by the bare product
|
|
210
|
+
* (`clickhouse`) while the package implementing them is `mcp-clickhouse`, and a
|
|
211
|
+
* model that read the package name asks for that instead.
|
|
212
|
+
*
|
|
213
|
+
* The boundary is checked on the original string, before canonicalization drops
|
|
214
|
+
* separators: `mcp-clickhouse` names the `clickhouse` server, but `mcparty` is
|
|
215
|
+
* simply a server called `mcparty` and must never resolve to `arty`.
|
|
216
|
+
*/
|
|
217
|
+
function canonicalizeWithoutMcpPrefix(requested: string): string | undefined {
|
|
218
|
+
const match = /^mcp[-_. ]+(.+)$/i.exec(requested.trim());
|
|
219
|
+
if (match === null) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
const key = canonicalizeServerName(match[1]);
|
|
223
|
+
return key === '' ? undefined : key;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Maps requested server names onto the servers that actually have tools
|
|
228
|
+
* registered, so a near-miss filters instead of silently returning nothing.
|
|
229
|
+
*
|
|
230
|
+
* Exact names win, then case/separator-insensitive matches, then the
|
|
231
|
+
* mcp-prefix-stripped form. Trying them in that order keeps a deployment that
|
|
232
|
+
* runs both `github` and `mcp-github` unambiguous: each resolves to itself.
|
|
233
|
+
*
|
|
234
|
+
* Several registered names can still share one canonical form (`foo-bar` and
|
|
235
|
+
* `foobar`). An inexact request cannot choose between them, so every candidate
|
|
236
|
+
* is returned rather than whichever was seen first: a superset is recoverable
|
|
237
|
+
* by reading the tool names, an arbitrary pick is not.
|
|
238
|
+
*
|
|
239
|
+
* @param requested - Server names as supplied by the caller
|
|
240
|
+
* @param available - Server names present in the tool registry
|
|
241
|
+
* @returns The registry names to filter by, and any request that matched none
|
|
242
|
+
*/
|
|
243
|
+
function resolveServerFilters(
|
|
244
|
+
requested: string[],
|
|
245
|
+
available: string[]
|
|
246
|
+
): { resolved: string[]; unresolved: string[] } {
|
|
247
|
+
const exact = new Set(available);
|
|
248
|
+
const canonical = new Map<string, string[]>();
|
|
249
|
+
for (const name of available) {
|
|
250
|
+
const key = canonicalizeServerName(name);
|
|
251
|
+
/** A name with no ASCII alphanumerics — `---`, or a Unicode-only name —
|
|
252
|
+
* canonicalizes to nothing. Indexing it would make every request that also
|
|
253
|
+
* canonicalizes to nothing resolve to this server. */
|
|
254
|
+
if (key === '') {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const bucket = canonical.get(key);
|
|
258
|
+
if (bucket === undefined) {
|
|
259
|
+
canonical.set(key, [name]);
|
|
260
|
+
} else {
|
|
261
|
+
bucket.push(name);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const resolved: string[] = [];
|
|
266
|
+
const unresolved: string[] = [];
|
|
267
|
+
for (const want of requested) {
|
|
268
|
+
if (exact.has(want)) {
|
|
269
|
+
resolved.push(want);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const key = canonicalizeServerName(want);
|
|
273
|
+
if (key === '') {
|
|
274
|
+
unresolved.push(want);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const direct = canonical.get(key);
|
|
278
|
+
if (direct !== undefined) {
|
|
279
|
+
resolved.push(...direct);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const strippedKey = canonicalizeWithoutMcpPrefix(want);
|
|
283
|
+
const stripped =
|
|
284
|
+
strippedKey === undefined ? undefined : canonical.get(strippedKey);
|
|
285
|
+
if (stripped !== undefined) {
|
|
286
|
+
resolved.push(...stripped);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
unresolved.push(want);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { resolved: Array.from(new Set(resolved)), unresolved };
|
|
293
|
+
}
|
|
294
|
+
|
|
179
295
|
/**
|
|
180
296
|
* Checks if a tool belongs to any of the specified MCP servers.
|
|
181
297
|
* @param toolName - The full tool name
|
|
@@ -709,11 +825,14 @@ function parseSearchResults(stdout: string): t.ToolSearchResponse {
|
|
|
709
825
|
* Formats search results as structured JSON for efficient parsing.
|
|
710
826
|
* @param searchResponse - The parsed search response
|
|
711
827
|
* @param nameFormat - Whether to show 'full' names (tool_mcp_server) or 'base' names (tool only)
|
|
828
|
+
* @param notes - Diagnostics about requested servers that were not searched;
|
|
829
|
+
* carried inside the JSON so the output stays parseable
|
|
712
830
|
* @returns JSON string with search results
|
|
713
831
|
*/
|
|
714
832
|
function formatSearchResults(
|
|
715
833
|
searchResponse: t.ToolSearchResponse,
|
|
716
|
-
nameFormat: t.McpNameFormat = 'full'
|
|
834
|
+
nameFormat: t.McpNameFormat = 'full',
|
|
835
|
+
notes: string[] = []
|
|
717
836
|
): string {
|
|
718
837
|
const { tool_references, total_tools_searched, pattern_used } =
|
|
719
838
|
searchResponse;
|
|
@@ -729,6 +848,7 @@ function formatSearchResults(
|
|
|
729
848
|
})),
|
|
730
849
|
total_searched: total_tools_searched,
|
|
731
850
|
query: pattern_used,
|
|
851
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
732
852
|
};
|
|
733
853
|
|
|
734
854
|
return JSON.stringify(output, null, 2);
|
|
@@ -820,12 +940,15 @@ function getDeferredToolsListing(
|
|
|
820
940
|
* @param tools - Array of tool metadata from the server(s)
|
|
821
941
|
* @param serverNames - The MCP server name(s)
|
|
822
942
|
* @param nameFormat - Whether to show 'full' names (tool_mcp_server) or 'base' names (tool only)
|
|
943
|
+
* @param notes - Diagnostics about requested servers that were not searched;
|
|
944
|
+
* carried inside the JSON so the output stays parseable
|
|
823
945
|
* @returns JSON string showing all tools grouped by server
|
|
824
946
|
*/
|
|
825
947
|
function formatServerListing(
|
|
826
948
|
tools: t.ToolMetadata[],
|
|
827
949
|
serverNames: string | string[],
|
|
828
|
-
nameFormat: t.McpNameFormat = 'full'
|
|
950
|
+
nameFormat: t.McpNameFormat = 'full',
|
|
951
|
+
notes: string[] = []
|
|
829
952
|
): string {
|
|
830
953
|
const servers = Array.isArray(serverNames) ? serverNames : [serverNames];
|
|
831
954
|
const useFullName = nameFormat === 'full';
|
|
@@ -837,6 +960,7 @@ function formatServerListing(
|
|
|
837
960
|
servers,
|
|
838
961
|
total_tools: 0,
|
|
839
962
|
tools_by_server: {},
|
|
963
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
840
964
|
hint: 'No tools found from the specified MCP server(s).',
|
|
841
965
|
},
|
|
842
966
|
null,
|
|
@@ -871,6 +995,7 @@ function formatServerListing(
|
|
|
871
995
|
servers,
|
|
872
996
|
total_tools: tools.length,
|
|
873
997
|
tools_by_server: toolsByServer,
|
|
998
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
874
999
|
hint: `To use a tool, search for it by name (e.g., query: "${exampleToolName}") to load it.`,
|
|
875
1000
|
};
|
|
876
1001
|
|
|
@@ -988,38 +1113,152 @@ ${mcpNote}${toolsListSection}
|
|
|
988
1113
|
];
|
|
989
1114
|
}
|
|
990
1115
|
|
|
991
|
-
const
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1116
|
+
const registeredServers = hasServerFilter ? new Set<string>() : undefined;
|
|
1117
|
+
const searchableServers = hasServerFilter ? new Set<string>() : undefined;
|
|
1118
|
+
/** The set to filter by is unknown until the whole registry has been
|
|
1119
|
+
* seen, so a server-filtered search has to stage its candidates. An
|
|
1120
|
+
* unfiltered search is the hot path: it builds the result during the same
|
|
1121
|
+
* single traversal, with no staging array and no copy of the registry. */
|
|
1122
|
+
const staged: t.LCTool[] | undefined = hasServerFilter ? [] : undefined;
|
|
1123
|
+
const deferredTools: t.ToolMetadata[] = [];
|
|
1124
|
+
let searchableCount = 0;
|
|
1125
|
+
|
|
1126
|
+
const describeTool = (lcTool: t.LCTool): t.ToolMetadata => ({
|
|
1127
|
+
name: lcTool.name,
|
|
1128
|
+
description: lcTool.description ?? '',
|
|
1129
|
+
parameters: simplifyParametersForSearch(lcTool.parameters),
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
for (const lcTool of toolRegistry.values()) {
|
|
1133
|
+
let server: string | undefined;
|
|
1134
|
+
if (hasServerFilter) {
|
|
1135
|
+
server = extractMcpServerName(lcTool.name);
|
|
1136
|
+
if (server !== undefined && server !== '') {
|
|
1137
|
+
registeredServers?.add(server);
|
|
1138
|
+
} else {
|
|
1139
|
+
server = undefined;
|
|
996
1140
|
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1141
|
+
}
|
|
1142
|
+
if (onlyDeferred === true && lcTool.defer_loading !== true) {
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
searchableCount += 1;
|
|
1146
|
+
if (staged === undefined) {
|
|
1147
|
+
deferredTools.push(describeTool(lcTool));
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
staged.push(lcTool);
|
|
1151
|
+
if (server !== undefined) {
|
|
1152
|
+
searchableServers?.add(server);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const availableServers =
|
|
1157
|
+
searchableServers === undefined
|
|
1158
|
+
? []
|
|
1159
|
+
: Array.from(searchableServers).sort();
|
|
1160
|
+
|
|
1161
|
+
/** Resolve against every registered server, not just the searchable ones,
|
|
1162
|
+
* so a server whose tools are all loaded already is reported as having
|
|
1163
|
+
* nothing left to search instead of as a name that does not exist. */
|
|
1164
|
+
const { resolved: matchedServers, unresolved: unknownServers } =
|
|
1165
|
+
hasServerFilter
|
|
1166
|
+
? resolveServerFilters(
|
|
1167
|
+
serverFilters,
|
|
1168
|
+
registeredServers === undefined
|
|
1169
|
+
? []
|
|
1170
|
+
: Array.from(registeredServers)
|
|
1171
|
+
)
|
|
1172
|
+
: { resolved: [], unresolved: [] };
|
|
1173
|
+
|
|
1174
|
+
const activeServers = matchedServers.filter(
|
|
1175
|
+
(name) => searchableServers?.has(name) === true
|
|
1176
|
+
);
|
|
1177
|
+
const idleServers = matchedServers.filter(
|
|
1178
|
+
(name) => searchableServers?.has(name) !== true
|
|
1179
|
+
);
|
|
1180
|
+
|
|
1181
|
+
/** A filter naming several servers can resolve only partly. Saying so on
|
|
1182
|
+
* the successful path too stops "searched github and slack" being read
|
|
1183
|
+
* into a result that only ever covered github. */
|
|
1184
|
+
const filterNotes: string[] = [];
|
|
1185
|
+
if (unknownServers.length > 0) {
|
|
1186
|
+
filterNotes.push(
|
|
1187
|
+
`No MCP server matched: ${unknownServers.join(', ')}.`
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
if (idleServers.length > 0) {
|
|
1191
|
+
filterNotes.push(
|
|
1192
|
+
`Registered with no tools left to search: ${idleServers.join(', ')}.`
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
const filterNotice =
|
|
1196
|
+
filterNotes.length > 0 ? `Note: ${filterNotes.join(' ')}\n\n` : '';
|
|
1197
|
+
const filterMetadata = {
|
|
1198
|
+
...(unknownServers.length > 0
|
|
1199
|
+
? { unmatched_mcp_servers: unknownServers }
|
|
1200
|
+
: {}),
|
|
1201
|
+
...(idleServers.length > 0 ? { idle_mcp_servers: idleServers } : {}),
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
if (staged !== undefined) {
|
|
1205
|
+
for (const lcTool of staged) {
|
|
1206
|
+
if (isFromAnyMcpServer(lcTool.name, activeServers)) {
|
|
1207
|
+
deferredTools.push(describeTool(lcTool));
|
|
1002
1208
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
.map((lcTool) => ({
|
|
1006
|
-
name: lcTool.name,
|
|
1007
|
-
description: lcTool.description ?? '',
|
|
1008
|
-
parameters: simplifyParametersForSearch(lcTool.parameters),
|
|
1009
|
-
}));
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1010
1211
|
|
|
1011
1212
|
if (deferredTools.length === 0) {
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1213
|
+
/** Say which of the three it is, so an empty result is not read as an
|
|
1214
|
+
* outage: nothing is registered, the name did not match anything, or
|
|
1215
|
+
* the named server genuinely has no tools. */
|
|
1216
|
+
let message: string;
|
|
1217
|
+
/** The server diagnosis comes first: a registry holding only loaded
|
|
1218
|
+
* tools makes both conditions true, and naming the requested server is
|
|
1219
|
+
* the more useful of the two answers. */
|
|
1220
|
+
if (
|
|
1221
|
+
hasServerFilter &&
|
|
1222
|
+
(unknownServers.length > 0 || idleServers.length > 0)
|
|
1223
|
+
) {
|
|
1224
|
+
const parts: string[] = [];
|
|
1225
|
+
if (unknownServers.length > 0) {
|
|
1226
|
+
parts.push(`No MCP server matched: ${unknownServers.join(', ')}.`);
|
|
1227
|
+
}
|
|
1228
|
+
if (idleServers.length > 0) {
|
|
1229
|
+
parts.push(
|
|
1230
|
+
`Registered with no tools left to search: ${idleServers.join(', ')}.`
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
parts.push(
|
|
1234
|
+
`Server(s) with searchable tools: ${
|
|
1235
|
+
availableServers.length > 0
|
|
1236
|
+
? availableServers.join(', ')
|
|
1237
|
+
: '(none)'
|
|
1238
|
+
}.`
|
|
1239
|
+
);
|
|
1240
|
+
message = parts.join(' ');
|
|
1241
|
+
} else if (searchableCount === 0) {
|
|
1242
|
+
message =
|
|
1243
|
+
'No tools available to search. The tool registry is empty or no deferred tools are registered.';
|
|
1244
|
+
} else {
|
|
1245
|
+
const serverMsg = hasServerFilter
|
|
1246
|
+
? ` from MCP server(s): ${serverFilters.join(', ')}`
|
|
1247
|
+
: '';
|
|
1248
|
+
message = `No tools available to search${serverMsg}. The tool registry is empty or no matching deferred tools are registered.`;
|
|
1249
|
+
}
|
|
1015
1250
|
return [
|
|
1016
|
-
|
|
1251
|
+
message,
|
|
1017
1252
|
{
|
|
1018
1253
|
tool_references: [],
|
|
1019
1254
|
metadata: {
|
|
1020
1255
|
total_searched: 0,
|
|
1021
1256
|
pattern: query,
|
|
1022
1257
|
mcp_server: serverFilters,
|
|
1258
|
+
...(hasServerFilter
|
|
1259
|
+
? { available_mcp_servers: availableServers }
|
|
1260
|
+
: {}),
|
|
1261
|
+
...filterMetadata,
|
|
1023
1262
|
},
|
|
1024
1263
|
},
|
|
1025
1264
|
];
|
|
@@ -1028,10 +1267,13 @@ ${mcpNote}${toolsListSection}
|
|
|
1028
1267
|
const isServerListing = hasServerFilter && query === '';
|
|
1029
1268
|
|
|
1030
1269
|
if (isServerListing) {
|
|
1270
|
+
/** The listing's contract is parseable JSON, so the diagnostics go
|
|
1271
|
+
* inside the payload rather than in front of it. */
|
|
1031
1272
|
const formattedOutput = formatServerListing(
|
|
1032
1273
|
deferredTools,
|
|
1033
|
-
|
|
1034
|
-
mcpNameFormat
|
|
1274
|
+
activeServers,
|
|
1275
|
+
mcpNameFormat,
|
|
1276
|
+
filterNotes
|
|
1035
1277
|
);
|
|
1036
1278
|
|
|
1037
1279
|
return [
|
|
@@ -1042,6 +1284,7 @@ ${mcpNote}${toolsListSection}
|
|
|
1042
1284
|
total_available: deferredTools.length,
|
|
1043
1285
|
mcp_server: serverFilters,
|
|
1044
1286
|
listing_mode: true,
|
|
1287
|
+
...filterMetadata,
|
|
1045
1288
|
},
|
|
1046
1289
|
},
|
|
1047
1290
|
];
|
|
@@ -1054,9 +1297,11 @@ ${mcpNote}${toolsListSection}
|
|
|
1054
1297
|
fields,
|
|
1055
1298
|
max_results
|
|
1056
1299
|
);
|
|
1300
|
+
/** Diagnostics ride inside the payload; this output is parsed. */
|
|
1057
1301
|
const formattedOutput = formatSearchResults(
|
|
1058
1302
|
searchResponse,
|
|
1059
|
-
mcpNameFormat
|
|
1303
|
+
mcpNameFormat,
|
|
1304
|
+
filterNotes
|
|
1060
1305
|
);
|
|
1061
1306
|
|
|
1062
1307
|
return [
|
|
@@ -1067,6 +1312,7 @@ ${mcpNote}${toolsListSection}
|
|
|
1067
1312
|
total_searched: searchResponse.total_tools_searched,
|
|
1068
1313
|
pattern: searchResponse.pattern_used,
|
|
1069
1314
|
mcp_server: serverFilters.length > 0 ? serverFilters : undefined,
|
|
1315
|
+
...filterMetadata,
|
|
1070
1316
|
},
|
|
1071
1317
|
},
|
|
1072
1318
|
];
|
|
@@ -1121,19 +1367,20 @@ ${mcpNote}${toolsListSection}
|
|
|
1121
1367
|
|
|
1122
1368
|
if (!result.stdout || !result.stdout.trim()) {
|
|
1123
1369
|
return [
|
|
1124
|
-
`${warningMessage}No tools matched the pattern "${sanitizedPattern}".\nTotal tools searched: ${deferredTools.length}`,
|
|
1370
|
+
`${filterNotice}${warningMessage}No tools matched the pattern "${sanitizedPattern}".\nTotal tools searched: ${deferredTools.length}`,
|
|
1125
1371
|
{
|
|
1126
1372
|
tool_references: [],
|
|
1127
1373
|
metadata: {
|
|
1128
1374
|
total_searched: deferredTools.length,
|
|
1129
1375
|
pattern: sanitizedPattern,
|
|
1376
|
+
...filterMetadata,
|
|
1130
1377
|
},
|
|
1131
1378
|
},
|
|
1132
1379
|
];
|
|
1133
1380
|
}
|
|
1134
1381
|
|
|
1135
1382
|
const searchResponse = parseSearchResults(result.stdout);
|
|
1136
|
-
const formattedOutput = `${warningMessage}${formatSearchResults(searchResponse, mcpNameFormat)}`;
|
|
1383
|
+
const formattedOutput = `${warningMessage}${formatSearchResults(searchResponse, mcpNameFormat, filterNotes)}`;
|
|
1137
1384
|
|
|
1138
1385
|
return [
|
|
1139
1386
|
formattedOutput,
|
|
@@ -1142,6 +1389,7 @@ ${mcpNote}${toolsListSection}
|
|
|
1142
1389
|
metadata: {
|
|
1143
1390
|
total_searched: searchResponse.total_tools_searched,
|
|
1144
1391
|
pattern: searchResponse.pattern_used,
|
|
1392
|
+
...filterMetadata,
|
|
1145
1393
|
},
|
|
1146
1394
|
},
|
|
1147
1395
|
];
|
|
@@ -1179,6 +1427,8 @@ export {
|
|
|
1179
1427
|
extractMcpServerName,
|
|
1180
1428
|
isFromMcpServer,
|
|
1181
1429
|
isFromAnyMcpServer,
|
|
1430
|
+
resolveServerFilters,
|
|
1431
|
+
canonicalizeServerName,
|
|
1182
1432
|
normalizeServerFilter,
|
|
1183
1433
|
getAvailableMcpServers,
|
|
1184
1434
|
getDeferredToolsListing,
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
2
|
+
import type { ToolCall } from '@langchain/core/messages/tool';
|
|
3
|
+
import { stableStringify, normalizeError } from './eagerEventExecution';
|
|
4
|
+
|
|
5
|
+
/** A model attempt cannot safely retry after delegated work has started. */
|
|
6
|
+
export class PreparedSubagentError extends Error {
|
|
7
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = 'PreparedSubagentError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Outcome =
|
|
14
|
+
| { output: unknown; error?: never }
|
|
15
|
+
| { error: Error; output?: never };
|
|
16
|
+
type Attempt = {
|
|
17
|
+
keys: Set<string>;
|
|
18
|
+
cancelled: boolean;
|
|
19
|
+
config: RunnableConfig;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const PREPARED_INVOCATION = Symbol('prepared-subagent-invocation');
|
|
23
|
+
type PreparedCall = ToolCall & { [PREPARED_INVOCATION]?: symbol };
|
|
24
|
+
|
|
25
|
+
type Reservation = {
|
|
26
|
+
token: symbol;
|
|
27
|
+
callId: string;
|
|
28
|
+
attempt: string;
|
|
29
|
+
fingerprint: string;
|
|
30
|
+
controller: AbortController;
|
|
31
|
+
outcome: Promise<Outcome>;
|
|
32
|
+
committed: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Owns speculative invocation work, never tool results or graph state. Only
|
|
37
|
+
* explicitly open model attempts admit work; closing an attempt fences late
|
|
38
|
+
* callbacks without retaining tombstones. Normal ToolNode execution adopts the
|
|
39
|
+
* raw output and performs its existing lifecycle/output processing once.
|
|
40
|
+
*/
|
|
41
|
+
export class PreparedSubagents {
|
|
42
|
+
private readonly attempts = new Map<string, Attempt>();
|
|
43
|
+
private readonly reservations = new Map<string, Reservation>();
|
|
44
|
+
private readonly running = new Set<AbortController>();
|
|
45
|
+
private epoch = 0;
|
|
46
|
+
|
|
47
|
+
begin(attempt: string, config: RunnableConfig = {}): void {
|
|
48
|
+
this.attempts.set(attempt, {
|
|
49
|
+
keys: new Set(),
|
|
50
|
+
cancelled: false,
|
|
51
|
+
config: {
|
|
52
|
+
...config,
|
|
53
|
+
configurable: { ...config.configurable },
|
|
54
|
+
metadata: { ...config.metadata },
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getConfig(attempt: string): RunnableConfig | undefined {
|
|
60
|
+
const entry = this.attempts.get(attempt);
|
|
61
|
+
return entry?.cancelled === false ? entry.config : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isOpen(attempt: string): boolean {
|
|
65
|
+
return this.attempts.get(attempt)?.cancelled === false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
start(
|
|
69
|
+
attempt: string,
|
|
70
|
+
owner: string,
|
|
71
|
+
call: ToolCall,
|
|
72
|
+
capacity: number,
|
|
73
|
+
invoke: (signal: AbortSignal) => Promise<unknown>
|
|
74
|
+
): boolean {
|
|
75
|
+
const entries = this.attempts.get(attempt);
|
|
76
|
+
if (
|
|
77
|
+
entries == null ||
|
|
78
|
+
entries.cancelled ||
|
|
79
|
+
call.id == null ||
|
|
80
|
+
call.id === ''
|
|
81
|
+
) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const key = JSON.stringify([owner, call.id]);
|
|
85
|
+
const canonical = fingerprint(call);
|
|
86
|
+
const previous = this.reservations.get(key);
|
|
87
|
+
if (previous != null) {
|
|
88
|
+
if (previous.attempt !== attempt || previous.fingerprint !== canonical) {
|
|
89
|
+
throw new PreparedSubagentError(
|
|
90
|
+
'Conflicting eager subagent call identity.'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
!Number.isSafeInteger(capacity) ||
|
|
97
|
+
capacity <= 0 ||
|
|
98
|
+
this.reservations.size >= capacity ||
|
|
99
|
+
this.running.size >= capacity
|
|
100
|
+
) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
this.running.add(controller);
|
|
105
|
+
const reservation: Reservation = {
|
|
106
|
+
token: Symbol(),
|
|
107
|
+
attempt,
|
|
108
|
+
callId: call.id,
|
|
109
|
+
fingerprint: canonical,
|
|
110
|
+
controller,
|
|
111
|
+
committed: false,
|
|
112
|
+
outcome: Promise.resolve()
|
|
113
|
+
.then(() => {
|
|
114
|
+
controller.signal.throwIfAborted();
|
|
115
|
+
return invoke(controller.signal);
|
|
116
|
+
})
|
|
117
|
+
.then(
|
|
118
|
+
(output): Outcome => ({ output }),
|
|
119
|
+
(error): Outcome => ({ error: normalizeError(error) })
|
|
120
|
+
)
|
|
121
|
+
.finally(() => {
|
|
122
|
+
this.running.delete(controller);
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
entries.keys.add(key);
|
|
126
|
+
this.reservations.set(key, reservation);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
finish(attempt: string, calls?: ToolCall[], cause?: unknown): void {
|
|
131
|
+
const entries = this.attempts.get(attempt);
|
|
132
|
+
this.attempts.delete(attempt);
|
|
133
|
+
if (entries == null || entries.keys.size === 0) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const finalCalls = new Map(calls?.map((call) => [call.id, call]));
|
|
137
|
+
for (const key of entries.keys) {
|
|
138
|
+
const record = this.reservations.get(key);
|
|
139
|
+
const finalCall =
|
|
140
|
+
record == null ? undefined : finalCalls.get(record.callId);
|
|
141
|
+
if (
|
|
142
|
+
entries.cancelled ||
|
|
143
|
+
record == null ||
|
|
144
|
+
record.attempt !== attempt ||
|
|
145
|
+
finalCall == null ||
|
|
146
|
+
fingerprint(finalCall) !== record.fingerprint
|
|
147
|
+
) {
|
|
148
|
+
const error = new PreparedSubagentError(
|
|
149
|
+
'The model attempt ended or changed after a subagent started; refusing automatic retry.',
|
|
150
|
+
{ cause }
|
|
151
|
+
);
|
|
152
|
+
for (const pendingKey of entries.keys) {
|
|
153
|
+
const pending = this.reservations.get(pendingKey);
|
|
154
|
+
if (pending?.attempt === attempt) {
|
|
155
|
+
pending.controller.abort(error);
|
|
156
|
+
this.reservations.delete(pendingKey);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
record.committed = true;
|
|
162
|
+
(finalCall as PreparedCall)[PREPARED_INVOCATION] = record.token;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
owns(owner: string, call: ToolCall): boolean {
|
|
167
|
+
const record = this.reservations.get(JSON.stringify([owner, call.id]));
|
|
168
|
+
return (
|
|
169
|
+
record != null &&
|
|
170
|
+
(call as PreparedCall)[PREPARED_INVOCATION] === record.token
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
take(owner: string, call: ToolCall): Promise<unknown> | undefined {
|
|
175
|
+
const key = JSON.stringify([owner, call.id]);
|
|
176
|
+
const record = this.reservations.get(key);
|
|
177
|
+
const token = (call as PreparedCall)[PREPARED_INVOCATION];
|
|
178
|
+
if (record == null && token == null) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
if (record == null || token !== record.token) {
|
|
182
|
+
return Promise.reject(
|
|
183
|
+
new PreparedSubagentError(
|
|
184
|
+
'Eager subagent invocation is no longer owned by this call.'
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
this.reservations.delete(key);
|
|
189
|
+
if (!record.committed || record.fingerprint !== fingerprint(call)) {
|
|
190
|
+
const error = new PreparedSubagentError(
|
|
191
|
+
'Subagent arguments changed after eager invocation.'
|
|
192
|
+
);
|
|
193
|
+
record.controller.abort(error);
|
|
194
|
+
return Promise.reject(error);
|
|
195
|
+
}
|
|
196
|
+
const epoch = this.epoch;
|
|
197
|
+
return record.outcome.then((result) => {
|
|
198
|
+
if (this.epoch !== epoch) {
|
|
199
|
+
throw new PreparedSubagentError(
|
|
200
|
+
'Eager subagent result belongs to a retired run.'
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
record.controller.signal.throwIfAborted();
|
|
204
|
+
if ('error' in result) {
|
|
205
|
+
throw result.error;
|
|
206
|
+
}
|
|
207
|
+
return result.output;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
clear(): void {
|
|
212
|
+
this.epoch++;
|
|
213
|
+
const reason = new PreparedSubagentError(
|
|
214
|
+
'Eager subagent execution was cancelled.'
|
|
215
|
+
);
|
|
216
|
+
for (const controller of this.running) {
|
|
217
|
+
controller.abort(reason);
|
|
218
|
+
}
|
|
219
|
+
this.reservations.clear();
|
|
220
|
+
for (const attempt of this.attempts.values()) {
|
|
221
|
+
attempt.cancelled = true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function fingerprint(call: ToolCall): string {
|
|
227
|
+
return stableStringify({ name: call.name, args: call.args });
|
|
228
|
+
}
|
package/src/types/graph.ts
CHANGED
|
@@ -829,6 +829,14 @@ export interface LangfuseConfig {
|
|
|
829
829
|
*/
|
|
830
830
|
additionalHeaders?: Record<string, string>;
|
|
831
831
|
metadata?: Record<string, string | number | boolean | null | undefined>;
|
|
832
|
+
/**
|
|
833
|
+
* User identity stamped on every trace this run emits — the agent stream,
|
|
834
|
+
* titles, activity and reasoning labels, and phases. Hosts set it when the
|
|
835
|
+
* observability identity differs from `configurable.user_id` (an email or
|
|
836
|
+
* IdP subject instead of an internal database id); that id remains the
|
|
837
|
+
* fallback when unset or blank.
|
|
838
|
+
*/
|
|
839
|
+
userId?: string;
|
|
832
840
|
/**
|
|
833
841
|
* Internal OTLP span attributes to attach to Langfuse observations before
|
|
834
842
|
* export. Intended for collector-side routing/filtering; strip these in the
|