@librechat/agents 3.7.20 → 3.7.21

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 (41) hide show
  1. package/dist/cjs/tools/BashExecutor.cjs +3 -2
  2. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  3. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -1
  4. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  5. package/dist/cjs/tools/CodeExecutor.cjs +27 -10
  6. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  7. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +6 -5
  8. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  9. package/dist/cjs/tools/diagnostics.cjs +26 -0
  10. package/dist/cjs/tools/diagnostics.cjs.map +1 -0
  11. package/dist/cjs/tools/search/rerankers.cjs +6 -4
  12. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  13. package/dist/cjs/tools/search/search.cjs +14 -2
  14. package/dist/cjs/tools/search/search.cjs.map +1 -1
  15. package/dist/cjs/tools/search/tool.cjs +1 -1
  16. package/dist/esm/tools/BashExecutor.mjs +3 -2
  17. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  18. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +2 -1
  19. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  20. package/dist/esm/tools/CodeExecutor.mjs +27 -10
  21. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  22. package/dist/esm/tools/ProgrammaticToolCalling.mjs +6 -5
  23. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  24. package/dist/esm/tools/diagnostics.mjs +25 -0
  25. package/dist/esm/tools/diagnostics.mjs.map +1 -0
  26. package/dist/esm/tools/search/rerankers.mjs +6 -5
  27. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  28. package/dist/esm/tools/search/search.mjs +14 -2
  29. package/dist/esm/tools/search/search.mjs.map +1 -1
  30. package/dist/esm/tools/search/tool.mjs +1 -1
  31. package/dist/types/tools/CodeExecutor.d.ts +13 -2
  32. package/dist/types/tools/diagnostics.d.ts +52 -0
  33. package/dist/types/tools/search/rerankers.d.ts +10 -0
  34. package/package.json +3 -2
  35. package/src/tools/BashExecutor.ts +9 -4
  36. package/src/tools/BashProgrammaticToolCalling.ts +6 -3
  37. package/src/tools/CodeExecutor.ts +88 -15
  38. package/src/tools/ProgrammaticToolCalling.ts +24 -12
  39. package/src/tools/diagnostics.ts +107 -0
  40. package/src/tools/search/rerankers.ts +18 -3
  41. package/src/tools/search/search.ts +30 -5
@@ -31,6 +31,7 @@ import {
31
31
  runPlainExecution,
32
32
  formatCompletedResponse,
33
33
  } from './ProgrammaticToolCalling';
34
+ import { logCodeApiDiagnostic } from '@/tools/diagnostics';
34
35
  import { INTENT_PROPERTY } from '@/tools/intentArg';
35
36
  import { Constants } from '@/common';
36
37
 
@@ -423,9 +424,11 @@ export function createBashProgrammaticToolCallingTool(
423
424
  if (_injected_files && _injected_files.length > 0) {
424
425
  files = _injected_files;
425
426
  } else if (session_id != null && session_id.length > 0) {
426
- // eslint-disable-next-line no-console
427
- console.debug(
428
- `[BashProgrammaticToolCalling] No injected files for session_id=${session_id} — exec will run without input files`
427
+ logCodeApiDiagnostic(
428
+ 'BashProgrammaticToolCalling',
429
+ 'debug',
430
+ 'session carried no injected files; exec will run without input files',
431
+ { files: 'none' }
429
432
  );
430
433
  }
431
434
 
@@ -2,7 +2,13 @@ import { config } from 'dotenv';
2
2
  import fetch, { RequestInit } from 'node-fetch';
3
3
  import { getEnvironmentVariable } from '@langchain/core/utils/env';
4
4
  import { tool, DynamicStructuredTool } from '@langchain/core/tools';
5
+ import type { Readable } from 'node:stream';
6
+ import type { CodeApiMethod } from '@/tools/diagnostics';
5
7
  import type * as t from '@/types';
8
+ import {
9
+ describeCodeApiError,
10
+ logCodeApiDiagnostic,
11
+ } from '@/tools/diagnostics';
6
12
  import { appendCodeSessionFileSummary } from '@/tools/CodeSessionFileSummary';
7
13
  import { resolveFetchProxyAgent } from '@/utils/proxy';
8
14
  import { INTENT_PROPERTY } from '@/tools/intentArg';
@@ -119,8 +125,7 @@ export const CodeExecutionToolSchema = {
119
125
  required: ['lang', 'code'],
120
126
  } as const;
121
127
 
122
- export const CODE_API_EXPECTED_PROFILE_HEADER =
123
- 'X-CodeAPI-Expected-Profile';
128
+ export const CODE_API_EXPECTED_PROFILE_HEADER = 'X-CodeAPI-Expected-Profile';
124
129
 
125
130
  type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
126
131
 
@@ -221,7 +226,8 @@ export function buildCodeApiExecutionErrorMessage(response: {
221
226
  }
222
227
 
223
228
  export async function resolveCodeApiAuthHeaders(
224
- authHeaders?: t.CodeApiAuthHeaders
229
+ authHeaders?: t.CodeApiAuthHeaders,
230
+ options?: { recoverable?: boolean }
225
231
  ): Promise<t.CodeApiAuthHeaderMap> {
226
232
  if (authHeaders == null) {
227
233
  return {};
@@ -230,7 +236,15 @@ export async function resolveCodeApiAuthHeaders(
230
236
  try {
231
237
  const resolvedHeaders = await authHeaders();
232
238
  return resolvedHeaders;
233
- } catch {
239
+ } catch (error) {
240
+ if (options?.recoverable !== true) {
241
+ logCodeApiDiagnostic(
242
+ 'CodeExecutor',
243
+ 'error',
244
+ 'auth header resolution failed; the Code API request was never sent',
245
+ describeCodeApiError(error)
246
+ );
247
+ }
234
248
  throw new CodeApiRequestError(CODE_API_AUTHORIZATION_ERROR_MESSAGE);
235
249
  }
236
250
  }
@@ -250,18 +264,73 @@ export function addCodeApiExecutionProfileHeader(
250
264
  };
251
265
  }
252
266
 
267
+ type CodeApiErrorResponse = {
268
+ status: number;
269
+ text: () => Promise<string>;
270
+ /** node-fetch types this as `NodeJS.ReadableStream`, which does not declare
271
+ * `destroy`; every runtime instance is a `Readable`, and a test double may
272
+ * supply nothing at all. */
273
+ body?: NodeJS.ReadableStream | null;
274
+ };
275
+
276
+ /**
277
+ * Only the 429 branch reads the body, and node-fetch keeps the stream and its
278
+ * socket alive until something does. Repeated backend failures would otherwise
279
+ * accumulate connections holding payloads this module deliberately discards.
280
+ */
281
+ function discardResponseBody(response: CodeApiErrorResponse): void {
282
+ const body = response.body;
283
+ if (body == null || !('destroy' in body)) {
284
+ return;
285
+ }
286
+ const destroy = (body as NodeJS.ReadableStream & Partial<Readable>).destroy;
287
+ if (typeof destroy !== 'function') {
288
+ return;
289
+ }
290
+ try {
291
+ destroy.call(body);
292
+ } catch {
293
+ /* Already finished or not destroyable; nothing is being held open. */
294
+ }
295
+ }
296
+
253
297
  export async function buildCodeApiHttpErrorMessage(
254
- _method: string,
298
+ method: CodeApiMethod,
255
299
  _endpoint: string,
256
- response: { status: number; text: () => Promise<string> }
300
+ response: CodeApiErrorResponse,
301
+ options?: { recoverable?: boolean; profile?: t.CodeApiExecutionProfile }
257
302
  ): Promise<string> {
258
- let responseBody = '';
259
- try {
260
- responseBody = await response.text();
261
- } catch {
262
- responseBody = '';
303
+ /* Logged before the body is touched. A non-OK response can leave a chunked
304
+ body open, and there is no read timeout here, so draining first would
305
+ withhold the diagnostic indefinitely for exactly the backend it identifies.
306
+ The body itself is never logged — it is upstream free text that can echo
307
+ the header that was sent — and the endpoint is host-configured, so the
308
+ backend is named by the profile this module chose rather than by its
309
+ address. A recoverable caller reports its own outcome; logging here too
310
+ would raise an error-level alert for a lookup that succeeds by returning
311
+ nothing. */
312
+ if (options?.recoverable !== true) {
313
+ logCodeApiDiagnostic(
314
+ 'CodeExecutor',
315
+ response.status === 429 ? 'warn' : 'error',
316
+ 'Code API rejected the request',
317
+ {
318
+ method,
319
+ profile: options?.profile ?? 'unset',
320
+ status: response.status,
321
+ }
322
+ );
323
+ }
324
+ if (response.status !== 429) {
325
+ discardResponseBody(response);
263
326
  }
264
327
  if (response.status === 429) {
328
+ let responseBody = '';
329
+ try {
330
+ responseBody = await response.text();
331
+ } catch {
332
+ responseBody = '';
333
+ }
265
334
  const retryAfterSeconds = getRetryAfterSeconds(responseBody);
266
335
  return retryAfterSeconds != null
267
336
  ? `Code execution is temporarily rate-limited. Retry after ${retryAfterSeconds} seconds.`
@@ -445,9 +514,11 @@ function createCodeExecutionTool(
445
514
  session_id.length > 0 &&
446
515
  !Array.isArray(postData.files)
447
516
  ) {
448
- // eslint-disable-next-line no-console
449
- console.debug(
450
- `[CodeExecutor] No injected files for session_id=${session_id} — exec will run without input files`
517
+ logCodeApiDiagnostic(
518
+ 'CodeExecutor',
519
+ 'debug',
520
+ 'session carried no injected files; exec will run without input files',
521
+ { files: 'none' }
451
522
  );
452
523
  }
453
524
 
@@ -474,7 +545,9 @@ function createCodeExecutionTool(
474
545
  const response = await fetch(execEndpoint, fetchOptions);
475
546
  if (!response.ok) {
476
547
  throw new CodeApiRequestError(
477
- await buildCodeApiHttpErrorMessage('POST', execEndpoint, response)
548
+ await buildCodeApiHttpErrorMessage('POST', execEndpoint, response, {
549
+ profile: executionProfile,
550
+ })
478
551
  );
479
552
  }
480
553
 
@@ -33,6 +33,10 @@ import {
33
33
  createCodeApiRunTimeoutSchema,
34
34
  resolveCodeApiRunTimeoutMs,
35
35
  } from './ptcTimeout';
36
+ import {
37
+ describeCodeApiError,
38
+ logCodeApiDiagnostic,
39
+ } from '@/tools/diagnostics';
36
40
  import { resolveFetchProxyAgent } from '@/utils/proxy';
37
41
  import { INTENT_PROPERTY } from '@/tools/intentArg';
38
42
  import { Constants } from '@/common';
@@ -449,7 +453,9 @@ export async function fetchSessionFiles(
449
453
  }
450
454
  }
451
455
  const filesEndpoint = `${baseUrl}/files/${encodeURIComponent(sessionId)}?${query.toString()}`;
452
- const resolvedAuthHeaders = await resolveCodeApiAuthHeaders(authHeaders);
456
+ const resolvedAuthHeaders = await resolveCodeApiAuthHeaders(authHeaders, {
457
+ recoverable: true,
458
+ });
453
459
  const fetchOptions: RequestInit = {
454
460
  method: 'GET',
455
461
  headers: {
@@ -466,7 +472,9 @@ export async function fetchSessionFiles(
466
472
  const response = await fetch(filesEndpoint, fetchOptions);
467
473
  if (!response.ok) {
468
474
  throw new Error(
469
- await buildCodeApiHttpErrorMessage('GET', filesEndpoint, response)
475
+ await buildCodeApiHttpErrorMessage('GET', filesEndpoint, response, {
476
+ recoverable: true,
477
+ })
470
478
  );
471
479
  }
472
480
 
@@ -479,9 +487,11 @@ export async function fetchSessionFiles(
479
487
  .filter(isCodeApiSessionFileWire)
480
488
  .map((file) => normalizeSessionFile(file, sessionId, scope));
481
489
  } catch (error) {
482
- // eslint-disable-next-line no-console
483
- console.warn(
484
- `Failed to fetch files for session: ${sessionId}, ${(error as Error).message}`
490
+ logCodeApiDiagnostic(
491
+ 'ProgrammaticToolCalling',
492
+ 'warn',
493
+ 'session file lookup failed; continuing without input files',
494
+ describeCodeApiError(error)
485
495
  );
486
496
  return [];
487
497
  }
@@ -525,7 +535,9 @@ export async function makeRequest(
525
535
 
526
536
  if (!response.ok) {
527
537
  throw new CodeApiRequestError(
528
- await buildCodeApiHttpErrorMessage('POST', endpoint, response)
538
+ await buildCodeApiHttpErrorMessage('POST', endpoint, response, {
539
+ profile: executionProfile,
540
+ })
529
541
  );
530
542
  }
531
543
 
@@ -931,9 +943,7 @@ export async function runPlainExecution(args: {
931
943
  {
932
944
  lang: args.lang,
933
945
  code:
934
- args.lang === 'py'
935
- ? wrapPythonForPlainExecution(args.code)
936
- : args.code,
946
+ args.lang === 'py' ? wrapPythonForPlainExecution(args.code) : args.code,
937
947
  ...(args.timeout != null ? { timeout: args.timeout } : {}),
938
948
  ...(args.sessionId != null && args.sessionId !== ''
939
949
  ? { session_id: args.sessionId }
@@ -1085,9 +1095,11 @@ export function createProgrammaticToolCallingTool(
1085
1095
  if (_injected_files && _injected_files.length > 0) {
1086
1096
  files = _injected_files;
1087
1097
  } else if (session_id != null && session_id.length > 0) {
1088
- // eslint-disable-next-line no-console
1089
- console.debug(
1090
- `[ProgrammaticToolCalling] No injected files for session_id=${session_id} — exec will run without input files`
1098
+ logCodeApiDiagnostic(
1099
+ 'ProgrammaticToolCalling',
1100
+ 'debug',
1101
+ 'session carried no injected files; exec will run without input files',
1102
+ { files: 'none' }
1091
1103
  );
1092
1104
  }
1093
1105
 
@@ -0,0 +1,107 @@
1
+ import type * as t from '@/types';
2
+
3
+ /**
4
+ * Operator diagnostics for the Code API tools.
5
+ *
6
+ * Every failure these tools surface to the model is reduced to a fixed
7
+ * sentence, so this log is the only surviving account of what went wrong —
8
+ * which also makes it the one place a credential can escape by accident. It
9
+ * escaped repeatedly while any field was quoted from something the tools did
10
+ * not produce: a response body echoing the header that was sent, an error
11
+ * message quoting an excerpt of a malformed signing key, a `stack` whose first
12
+ * line is that message, a writable `name`, a configured base URL carrying a
13
+ * capability in its path or its authority.
14
+ *
15
+ * Filtering those carriers case by case never converged, because the shape of
16
+ * a credential inside free text is not decidable. The rule here is structural
17
+ * instead: a diagnostic NAMES what happened using values this module owns, and
18
+ * QUOTES nothing it received. `CodeApiDiagnosticDetail` is a closed union of
19
+ * such values, so host-authored text cannot reach a log without failing to
20
+ * compile — and adding a field means widening that union in this file, which
21
+ * is the point at which the question gets asked.
22
+ */
23
+
24
+ export type CodeApiMethod = 'GET' | 'POST';
25
+
26
+ type CodeApiProfileLabel = t.CodeApiExecutionProfile | 'unset';
27
+
28
+ const KNOWN_ERROR_TYPES: ReadonlyArray<
29
+ readonly [CodeApiErrorLabel, new () => Error]
30
+ > = [
31
+ ['SyntaxError', SyntaxError],
32
+ ['TypeError', TypeError],
33
+ ['RangeError', RangeError],
34
+ ['ReferenceError', ReferenceError],
35
+ ['URIError', URIError],
36
+ ['EvalError', EvalError],
37
+ ];
38
+
39
+ /** Built-in error types, the `typeof` results for a non-Error rejection, and
40
+ * the two labels this module supplies when neither applies. */
41
+ type CodeApiErrorLabel =
42
+ | 'SyntaxError'
43
+ | 'TypeError'
44
+ | 'RangeError'
45
+ | 'ReferenceError'
46
+ | 'URIError'
47
+ | 'EvalError'
48
+ | 'Error'
49
+ | 'UndescribableError'
50
+ | 'string'
51
+ | 'number'
52
+ | 'bigint'
53
+ | 'boolean'
54
+ | 'symbol'
55
+ | 'undefined'
56
+ | 'object'
57
+ | 'function';
58
+
59
+ export type CodeApiDiagnosticDetail =
60
+ | { type: CodeApiErrorLabel }
61
+ | { method: CodeApiMethod; profile: CodeApiProfileLabel; status: number }
62
+ | { files: 'none' };
63
+
64
+ /**
65
+ * Classifies a rejection without reading anything off it. `instanceof` walks
66
+ * the prototype chain, so an accessor trap is never reached; the guard covers
67
+ * a `getPrototypeOf` trap, which would otherwise cost both the log and the
68
+ * rejection.
69
+ */
70
+ export function describeCodeApiError(error: unknown): {
71
+ type: CodeApiErrorLabel;
72
+ } {
73
+ try {
74
+ if (!(error instanceof Error)) {
75
+ return { type: typeof error };
76
+ }
77
+ for (const [label, constructor] of KNOWN_ERROR_TYPES) {
78
+ if (error instanceof constructor) {
79
+ return { type: label };
80
+ }
81
+ }
82
+ return { type: 'Error' };
83
+ } catch {
84
+ return { type: 'UndescribableError' };
85
+ }
86
+ }
87
+
88
+ type CodeApiDiagnosticSource =
89
+ | 'CodeExecutor'
90
+ | 'BashExecutor'
91
+ | 'ProgrammaticToolCalling'
92
+ | 'BashProgrammaticToolCalling';
93
+
94
+ /**
95
+ * Console is the SDK's diagnostic channel; a winston logger is the host's
96
+ * concern. Hosts correlate these lines with their own request-scoped logs,
97
+ * which is where received identifiers such as a session id belong.
98
+ */
99
+ export function logCodeApiDiagnostic(
100
+ source: CodeApiDiagnosticSource,
101
+ level: 'debug' | 'warn' | 'error',
102
+ message: string,
103
+ detail: CodeApiDiagnosticDetail
104
+ ): void {
105
+ // eslint-disable-next-line no-console
106
+ console[level](`[${source}] ${message}`, detail);
107
+ }
@@ -21,6 +21,23 @@ const getDefaultCohereApiUrl = (): string =>
21
21
  ? process.env.COHERE_API_URL
22
22
  : DEFAULT_COHERE_API_URL;
23
23
 
24
+ /**
25
+ * Ranking used whenever no reranker scores the chunks: the candidates' own
26
+ * order, capped at `topK`, with a neutral score.
27
+ *
28
+ * Exported because it is not only a *fallback*. With `rerankerType: 'none'`
29
+ * there is no reranker to fall back from, and the search pipeline needs the
30
+ * same ranking to hand the scraped text downstream — see `getHighlights` in
31
+ * `./search`.
32
+ */
33
+ export const getDefaultRanking = (
34
+ documents: string[],
35
+ topK: number
36
+ ): t.Highlight[] =>
37
+ documents
38
+ .slice(0, Math.min(topK, documents.length))
39
+ .map((doc) => ({ text: doc, score: 0 }));
40
+
24
41
  export abstract class BaseReranker {
25
42
  protected apiKey: string | undefined;
26
43
  protected logger: t.Logger;
@@ -51,9 +68,7 @@ export abstract class BaseReranker {
51
68
  documents: string[],
52
69
  topK: number
53
70
  ): t.Highlight[] {
54
- return documents
55
- .slice(0, Math.min(topK, documents.length))
56
- .map((doc) => ({ text: doc, score: 0 }));
71
+ return getDefaultRanking(documents, topK);
57
72
  }
58
73
 
59
74
  /** A direct caller has no enclosing search to fold into, so one
@@ -10,7 +10,7 @@ import { createKeenableAPI } from './keenable-search';
10
10
  import { createTavilyAPI } from './tavily-search';
11
11
  import { createSearchMetrics } from './metrics';
12
12
  import { createCrwAPI } from './crw-search';
13
- import { BaseReranker } from './rerankers';
13
+ import { BaseReranker, getDefaultRanking } from './rerankers';
14
14
 
15
15
  /** Engines queried when `searxngSearchOptions.engines` is not configured. */
16
16
  const DEFAULT_SEARXNG_ENGINES = 'google,bing,duckduckgo';
@@ -144,9 +144,20 @@ function createSourceUpdateCallback(sourceMap: Map<string, t.ValidSource>) {
144
144
  };
145
145
  }
146
146
 
147
+ /** Provider label for the metrics summary when reranking is switched off.
148
+ * `undefined` would be indistinguishable from a reranker that never ran. */
149
+ const NO_RERANKER_PROVIDER = 'none';
150
+
147
151
  /** Returns undefined without logging when there is nothing to rank: an empty
148
- * scrape is already counted by the scrape summary, and a missing reranker is
149
- * reported once at tool construction rather than once per source. */
152
+ * scrape is already counted by the scrape summary.
153
+ *
154
+ * A *missing* reranker is not the same as nothing to rank. `rerankerType:
155
+ * 'none'` is a schema-valid opt-out, and `createReranker` returns `undefined`
156
+ * for it by design. Returning `undefined` here as well would leave the source
157
+ * without highlights, and `expandHighlights` then strips its content — the
158
+ * search would answer with links and no text at all. So the chunks are handed
159
+ * on in their original order via `getDefaultRanking`, exactly what every
160
+ * reranker's own failure path already does. */
150
161
  const getHighlights = async ({
151
162
  query,
152
163
  content,
@@ -164,7 +175,7 @@ const getHighlights = async ({
164
175
  maxContentLength?: number;
165
176
  chunkOptions?: { chunkSize: number; chunkOverlap: number };
166
177
  }): Promise<t.Highlight[] | undefined> => {
167
- if (!content || !reranker) {
178
+ if (!content) {
168
179
  return;
169
180
  }
170
181
 
@@ -181,7 +192,7 @@ const getHighlights = async ({
181
192
  );
182
193
  } catch (error) {
183
194
  metrics.recordRerank({
184
- provider: reranker.provider,
195
+ provider: reranker?.provider ?? NO_RERANKER_PROVIDER,
185
196
  chunks: 0,
186
197
  results: 0,
187
198
  durationMs: Date.now() - chunkStartedAt,
@@ -191,6 +202,20 @@ const getHighlights = async ({
191
202
  return;
192
203
  }
193
204
 
205
+ /** Reranking is switched off, so the chunks pass through unscored. Recorded
206
+ * like any other rerank so the search summary still accounts for the source;
207
+ * it carries no `reason`, because nothing failed. */
208
+ if (!reranker) {
209
+ const highlights = getDefaultRanking(documents, topResults);
210
+ metrics.recordRerank({
211
+ provider: NO_RERANKER_PROVIDER,
212
+ chunks: documents.length,
213
+ results: highlights.length,
214
+ durationMs: Date.now() - chunkStartedAt,
215
+ });
216
+ return highlights;
217
+ }
218
+
194
219
  const rerankStartedAt = Date.now();
195
220
  try {
196
221
  return await reranker.rerank(query, documents, topResults, metrics);