@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.
Files changed (70) hide show
  1. package/dist/cjs/graphs/Graph.cjs +21 -4
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/langfuse.cjs +6 -2
  4. package/dist/cjs/langfuse.cjs.map +1 -1
  5. package/dist/cjs/llm/invoke.cjs +21 -6
  6. package/dist/cjs/llm/invoke.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +2 -0
  8. package/dist/cjs/stream.cjs +38 -1
  9. package/dist/cjs/stream.cjs.map +1 -1
  10. package/dist/cjs/tools/ArtifactDelivery.cjs +27 -0
  11. package/dist/cjs/tools/ArtifactDelivery.cjs.map +1 -0
  12. package/dist/cjs/tools/BashExecutor.cjs +6 -1
  13. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  14. package/dist/cjs/tools/CodeExecutor.cjs +6 -1
  15. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  16. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -1
  17. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  18. package/dist/cjs/tools/ToolNode.cjs +95 -18
  19. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  20. package/dist/cjs/tools/ToolSearch.cjs +130 -24
  21. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  22. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +1 -1
  23. package/dist/cjs/tools/preparedSubagents.cjs +129 -0
  24. package/dist/cjs/tools/preparedSubagents.cjs.map +1 -0
  25. package/dist/esm/graphs/Graph.mjs +21 -4
  26. package/dist/esm/graphs/Graph.mjs.map +1 -1
  27. package/dist/esm/langfuse.mjs +6 -2
  28. package/dist/esm/langfuse.mjs.map +1 -1
  29. package/dist/esm/llm/invoke.mjs +21 -6
  30. package/dist/esm/llm/invoke.mjs.map +1 -1
  31. package/dist/esm/main.mjs +2 -2
  32. package/dist/esm/stream.mjs +39 -2
  33. package/dist/esm/stream.mjs.map +1 -1
  34. package/dist/esm/tools/ArtifactDelivery.mjs +26 -0
  35. package/dist/esm/tools/ArtifactDelivery.mjs.map +1 -0
  36. package/dist/esm/tools/BashExecutor.mjs +6 -1
  37. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  38. package/dist/esm/tools/CodeExecutor.mjs +6 -1
  39. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  40. package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -1
  41. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  42. package/dist/esm/tools/ToolNode.mjs +95 -18
  43. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  44. package/dist/esm/tools/ToolSearch.mjs +129 -25
  45. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  46. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +1 -1
  47. package/dist/esm/tools/preparedSubagents.mjs +128 -0
  48. package/dist/esm/tools/preparedSubagents.mjs.map +1 -0
  49. package/dist/types/graphs/Graph.d.ts +6 -1
  50. package/dist/types/langfuse.d.ts +5 -0
  51. package/dist/types/tools/ArtifactDelivery.d.ts +4 -0
  52. package/dist/types/tools/ToolNode.d.ts +13 -1
  53. package/dist/types/tools/ToolSearch.d.ts +46 -2
  54. package/dist/types/tools/preparedSubagents.d.ts +26 -0
  55. package/dist/types/types/graph.d.ts +8 -0
  56. package/dist/types/types/tools.d.ts +28 -0
  57. package/package.json +1 -1
  58. package/src/graphs/Graph.ts +63 -5
  59. package/src/langfuse.ts +17 -2
  60. package/src/llm/invoke.ts +35 -8
  61. package/src/stream.ts +70 -1
  62. package/src/tools/ArtifactDelivery.ts +50 -0
  63. package/src/tools/BashExecutor.ts +18 -1
  64. package/src/tools/CodeExecutor.ts +18 -1
  65. package/src/tools/ProgrammaticToolCalling.ts +15 -1
  66. package/src/tools/ToolNode.ts +189 -43
  67. package/src/tools/ToolSearch.ts +278 -28
  68. package/src/tools/preparedSubagents.ts +228 -0
  69. package/src/types/graph.ts +8 -0
  70. package/src/types/tools.ts +29 -0
@@ -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 toolsArray: t.LCTool[] = Array.from(toolRegistry.values());
992
- const deferredTools: t.ToolMetadata[] = toolsArray
993
- .filter((lcTool) => {
994
- if (onlyDeferred === true && lcTool.defer_loading !== true) {
995
- return false;
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
- if (
998
- hasServerFilter &&
999
- !isFromAnyMcpServer(lcTool.name, serverFilters)
1000
- ) {
1001
- return false;
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
- return true;
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
- const serverMsg = hasServerFilter
1013
- ? ` from MCP server(s): ${serverFilters.join(', ')}`
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
- `No tools available to search${serverMsg}. The tool registry is empty or no matching deferred tools are registered.`,
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
- serverFilters,
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
+ }
@@ -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