@librechat/agents 3.0.45 → 3.0.47

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.
@@ -72,6 +72,7 @@ Requirements:
72
72
  - Tools are pre-defined as async functions - DO NOT write function definitions
73
73
  - Use await for all tool calls
74
74
  - Use asyncio.gather() for parallel execution of independent calls
75
+ - DO NOT call asyncio.run() - an event loop is already running
75
76
  - Only print() output flows back to the context window
76
77
  - Tool results from programmatic calls do NOT consume context tokens`
77
78
  ),
@@ -234,6 +235,67 @@ export function filterToolsByUsage(
234
235
  return toolDefs.filter((tool) => usedToolNames.has(tool.name));
235
236
  }
236
237
 
238
+ /**
239
+ * Fetches files from a previous session to make them available for the current execution.
240
+ * Files are returned as CodeEnvFile references to be included in the request.
241
+ * @param baseUrl - The base URL for the Code API
242
+ * @param apiKey - The API key for authentication
243
+ * @param sessionId - The session ID to fetch files from
244
+ * @param proxy - Optional HTTP proxy URL
245
+ * @returns Array of CodeEnvFile references, or empty array if fetch fails
246
+ */
247
+ export async function fetchSessionFiles(
248
+ baseUrl: string,
249
+ apiKey: string,
250
+ sessionId: string,
251
+ proxy?: string
252
+ ): Promise<t.CodeEnvFile[]> {
253
+ try {
254
+ const filesEndpoint = `${baseUrl}/files/${sessionId}?detail=full`;
255
+ const fetchOptions: RequestInit = {
256
+ method: 'GET',
257
+ headers: {
258
+ 'User-Agent': 'LibreChat/1.0',
259
+ 'X-API-Key': apiKey,
260
+ },
261
+ };
262
+
263
+ if (proxy != null && proxy !== '') {
264
+ fetchOptions.agent = new HttpsProxyAgent(proxy);
265
+ }
266
+
267
+ const response = await fetch(filesEndpoint, fetchOptions);
268
+ if (!response.ok) {
269
+ throw new Error(`Failed to fetch files for session: ${response.status}`);
270
+ }
271
+
272
+ const files = await response.json();
273
+ if (!Array.isArray(files) || files.length === 0) {
274
+ return [];
275
+ }
276
+
277
+ return files.map((file: Record<string, unknown>) => {
278
+ // Extract the ID from the file name (part after session ID prefix and before extension)
279
+ const nameParts = (file.name as string).split('/');
280
+ const id = nameParts.length > 1 ? nameParts[1].split('.')[0] : '';
281
+
282
+ return {
283
+ session_id: sessionId,
284
+ id,
285
+ name: (file.metadata as Record<string, unknown>)[
286
+ 'original-filename'
287
+ ] as string,
288
+ };
289
+ });
290
+ } catch (error) {
291
+ // eslint-disable-next-line no-console
292
+ console.warn(
293
+ `Failed to fetch files for session: ${sessionId}, ${(error as Error).message}`
294
+ );
295
+ return [];
296
+ }
297
+ }
298
+
237
299
  /**
238
300
  * Makes an HTTP request to the Code API.
239
301
  * @param endpoint - The API endpoint URL
@@ -275,7 +337,7 @@ export async function makeRequest(
275
337
  }
276
338
 
277
339
  /**
278
- * Unwraps tool responses that may be formatted as tuples.
340
+ * Unwraps tool responses that may be formatted as tuples or content blocks.
279
341
  * MCP tools return [content, artifacts], we need to extract the raw data.
280
342
  * @param result - The raw result from tool.invoke()
281
343
  * @param isMCPTool - Whether this is an MCP tool (has mcp property)
@@ -290,69 +352,116 @@ export function unwrapToolResponse(
290
352
  return result;
291
353
  }
292
354
 
355
+ /**
356
+ * Checks if a value is a content block object (has type and text).
357
+ */
358
+ const isContentBlock = (value: unknown): boolean => {
359
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
360
+ return false;
361
+ }
362
+ const obj = value as Record<string, unknown>;
363
+ return typeof obj.type === 'string';
364
+ };
365
+
366
+ /**
367
+ * Checks if an array is an array of content blocks.
368
+ */
369
+ const isContentBlockArray = (arr: unknown[]): boolean => {
370
+ return arr.length > 0 && arr.every(isContentBlock);
371
+ };
372
+
373
+ /**
374
+ * Extracts text from a single content block object.
375
+ * Returns the text if it's a text block, otherwise returns null.
376
+ */
377
+ const extractTextFromBlock = (block: unknown): string | null => {
378
+ if (typeof block !== 'object' || block === null) return null;
379
+ const b = block as Record<string, unknown>;
380
+ if (b.type === 'text' && typeof b.text === 'string') {
381
+ return b.text;
382
+ }
383
+ return null;
384
+ };
385
+
386
+ /**
387
+ * Extracts text from content blocks (array or single object).
388
+ * Returns combined text or null if no text blocks found.
389
+ */
390
+ const extractTextFromContent = (content: unknown): string | null => {
391
+ // Single content block object: { type: 'text', text: '...' }
392
+ if (
393
+ typeof content === 'object' &&
394
+ content !== null &&
395
+ !Array.isArray(content)
396
+ ) {
397
+ const text = extractTextFromBlock(content);
398
+ if (text !== null) return text;
399
+ }
400
+
401
+ // Array of content blocks: [{ type: 'text', text: '...' }, ...]
402
+ if (Array.isArray(content) && content.length > 0) {
403
+ const texts = content
404
+ .map(extractTextFromBlock)
405
+ .filter((t): t is string => t !== null);
406
+ if (texts.length > 0) {
407
+ return texts.join('\n');
408
+ }
409
+ }
410
+
411
+ return null;
412
+ };
413
+
414
+ /**
415
+ * Tries to parse a string as JSON if it looks like JSON.
416
+ */
417
+ const maybeParseJSON = (str: string): unknown => {
418
+ const trimmed = str.trim();
419
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
420
+ try {
421
+ return JSON.parse(trimmed);
422
+ } catch {
423
+ return str;
424
+ }
425
+ }
426
+ return str;
427
+ };
428
+
429
+ // Handle array of content blocks at top level FIRST
430
+ // (before checking for tuple, since both are arrays)
431
+ if (Array.isArray(result) && isContentBlockArray(result)) {
432
+ const extractedText = extractTextFromContent(result);
433
+ if (extractedText !== null) {
434
+ return maybeParseJSON(extractedText);
435
+ }
436
+ }
437
+
293
438
  // Check if result is a tuple/array with [content, artifacts]
294
439
  if (Array.isArray(result) && result.length >= 1) {
295
440
  const [content] = result;
296
441
 
297
- // If first element is a string, return it
442
+ // If first element is a string, return it (possibly parsed as JSON)
298
443
  if (typeof content === 'string') {
299
- // Try to parse as JSON if it looks like JSON
300
- if (typeof content === 'string' && content.trim().startsWith('{')) {
301
- try {
302
- return JSON.parse(content);
303
- } catch {
304
- return content;
305
- }
306
- }
307
- return content;
444
+ return maybeParseJSON(content);
308
445
  }
309
446
 
310
- // If first element is an array (content blocks), extract text/data
311
- if (Array.isArray(content)) {
312
- // If it's an array of content blocks (like [{ type: 'text', text: '...' }])
313
- if (
314
- content.length > 0 &&
315
- typeof content[0] === 'object' &&
316
- 'type' in content[0]
317
- ) {
318
- // Extract text from content blocks
319
- const texts = content
320
- .filter((block: unknown) => {
321
- if (typeof block !== 'object' || block === null) return false;
322
- const b = block as Record<string, unknown>;
323
- return b.type === 'text' && typeof b.text === 'string';
324
- })
325
- .map((block: unknown) => {
326
- const b = block as Record<string, unknown>;
327
- return b.text as string;
328
- });
329
-
330
- if (texts.length > 0) {
331
- const combined = texts.join('\n');
332
- // Try to parse as JSON if it looks like JSON (objects or arrays)
333
- if (
334
- combined.trim().startsWith('{') ||
335
- combined.trim().startsWith('[')
336
- ) {
337
- try {
338
- return JSON.parse(combined);
339
- } catch {
340
- return combined;
341
- }
342
- }
343
- return combined;
344
- }
345
- }
346
- // Otherwise return the content array as-is
347
- return content;
447
+ // Try to extract text from content blocks
448
+ const extractedText = extractTextFromContent(content);
449
+ if (extractedText !== null) {
450
+ return maybeParseJSON(extractedText);
348
451
  }
349
452
 
350
- // If first element is an object, return it
453
+ // If first element is an object (but not a text block), return it
351
454
  if (typeof content === 'object' && content !== null) {
352
455
  return content;
353
456
  }
354
457
  }
355
458
 
459
+ // Handle single content block object at top level (not in tuple)
460
+ const extractedText = extractTextFromContent(result);
461
+ if (extractedText !== null) {
462
+ return maybeParseJSON(extractedText);
463
+ }
464
+
356
465
  // Not a formatted response, return as-is
357
466
  return result;
358
467
  }
@@ -502,25 +611,21 @@ export function createProgrammaticToolCallingTool(
502
611
  const EXEC_ENDPOINT = `${baseUrl}/exec/programmatic`;
503
612
 
504
613
  const description = `
505
- Run tools by writing Python code. Tools are available as async functions - just call them with await.
614
+ Run tools via Python code. Tools are injected as async functionscall with \`await\`.
506
615
 
507
- This is different from execute_code: here you can call your tools (like get_weather, get_expenses, etc.) directly in Python code.
616
+ Rules:
617
+ - Tools are pre-defined—DO NOT define them yourself
618
+ - Use \`await\` for calls, \`asyncio.gather()\` for parallel; NEVER call \`asyncio.run()\`
619
+ - Only \`print()\` output returns to the model; tool results are raw dicts/lists/strings
620
+ - Stateless: variables/imports don't persist; use \`session_id\` param for file access
621
+ - Files mount at \`/mnt/data/\` (READ-ONLY); write changes to NEW filenames
622
+ - Tool names normalized: hyphens→underscores, keywords get \`_tool\` suffix
508
623
 
509
- Usage:
510
- - Tools are pre-defined as async functions - call them with await
511
- - Use asyncio.gather() to run multiple tools in parallel
512
- - Only print() output is returned - tool results stay in Python
624
+ When to use (vs. direct tool calls): loops, conditionals, parallel execution, aggregation.
513
625
 
514
626
  Examples:
515
- - Simple: result = await get_weather(city="NYC")
516
- - Loop: for user in users: data = await get_expenses(user_id=user['id'])
517
- - Parallel: sf, ny = await asyncio.gather(get_weather(city="SF"), get_weather(city="NY"))
518
-
519
- When to use this instead of calling tools directly:
520
- - You need to call tools in a loop (process many items)
521
- - You want parallel execution (asyncio.gather)
522
- - You need conditionals based on tool results
523
- - You want to aggregate/filter data before returning
627
+ result = await get_weather(city="NYC"); print(result)
628
+ sf, ny = await asyncio.gather(get_weather(city="SF"), get_weather(city="NY"))
524
629
  `.trim();
525
630
 
526
631
  return tool<typeof ProgrammaticToolCallingSchema>(
@@ -562,6 +667,12 @@ When to use this instead of calling tools directly:
562
667
  );
563
668
  }
564
669
 
670
+ // Fetch files from previous session if session_id is provided
671
+ let files: t.CodeEnvFile[] | undefined;
672
+ if (session_id != null && session_id.length > 0) {
673
+ files = await fetchSessionFiles(baseUrl, apiKey, session_id, proxy);
674
+ }
675
+
565
676
  let response = await makeRequest(
566
677
  EXEC_ENDPOINT,
567
678
  apiKey,
@@ -570,6 +681,7 @@ When to use this instead of calling tools directly:
570
681
  tools: effectiveTools,
571
682
  session_id,
572
683
  timeout,
684
+ ...(files && files.length > 0 ? { files } : {}),
573
685
  },
574
686
  proxy
575
687
  );
@@ -12,6 +12,7 @@ import {
12
12
  filterToolsByUsage,
13
13
  executeTools,
14
14
  normalizeToPythonIdentifier,
15
+ unwrapToolResponse,
15
16
  } from '../ProgrammaticToolCalling';
16
17
  import {
17
18
  createProgrammaticToolRegistry,
@@ -228,6 +229,160 @@ describe('ProgrammaticToolCalling', () => {
228
229
  });
229
230
  });
230
231
 
232
+ describe('unwrapToolResponse', () => {
233
+ describe('non-MCP tools', () => {
234
+ it('returns result as-is for non-MCP tools', () => {
235
+ const result = { temperature: 65, condition: 'Foggy' };
236
+ expect(unwrapToolResponse(result, false)).toEqual(result);
237
+ });
238
+
239
+ it('returns string as-is for non-MCP tools', () => {
240
+ expect(unwrapToolResponse('plain string', false)).toBe('plain string');
241
+ });
242
+
243
+ it('returns array as-is for non-MCP tools', () => {
244
+ const result = [1, 2, 3];
245
+ expect(unwrapToolResponse(result, false)).toEqual(result);
246
+ });
247
+ });
248
+
249
+ describe('MCP tools - tuple format [content, artifacts]', () => {
250
+ it('extracts string content from tuple', () => {
251
+ const result = ['Hello world', { artifacts: [] }];
252
+ expect(unwrapToolResponse(result, true)).toBe('Hello world');
253
+ });
254
+
255
+ it('parses JSON string content from tuple', () => {
256
+ const result = ['{"temperature": 65}', { artifacts: [] }];
257
+ expect(unwrapToolResponse(result, true)).toEqual({ temperature: 65 });
258
+ });
259
+
260
+ it('parses JSON array string content from tuple', () => {
261
+ const result = ['[1, 2, 3]', { artifacts: [] }];
262
+ expect(unwrapToolResponse(result, true)).toEqual([1, 2, 3]);
263
+ });
264
+
265
+ it('extracts text from single content block in tuple', () => {
266
+ const result = [{ type: 'text', text: 'Spreadsheet info here' }, {}];
267
+ expect(unwrapToolResponse(result, true)).toBe('Spreadsheet info here');
268
+ });
269
+
270
+ it('extracts and parses JSON from single content block in tuple', () => {
271
+ const result = [
272
+ { type: 'text', text: '{"id": "123", "name": "Test"}' },
273
+ {},
274
+ ];
275
+ expect(unwrapToolResponse(result, true)).toEqual({
276
+ id: '123',
277
+ name: 'Test',
278
+ });
279
+ });
280
+
281
+ it('extracts text from array of content blocks in tuple', () => {
282
+ const result = [
283
+ [
284
+ { type: 'text', text: 'Line 1' },
285
+ { type: 'text', text: 'Line 2' },
286
+ ],
287
+ {},
288
+ ];
289
+ expect(unwrapToolResponse(result, true)).toBe('Line 1\nLine 2');
290
+ });
291
+
292
+ it('returns object content as-is when not a text block', () => {
293
+ const result = [{ temperature: 65, condition: 'Foggy' }, {}];
294
+ expect(unwrapToolResponse(result, true)).toEqual({
295
+ temperature: 65,
296
+ condition: 'Foggy',
297
+ });
298
+ });
299
+ });
300
+
301
+ describe('MCP tools - single content block (not in tuple)', () => {
302
+ it('extracts text from single content block object', () => {
303
+ const result = { type: 'text', text: 'No data found in range' };
304
+ expect(unwrapToolResponse(result, true)).toBe('No data found in range');
305
+ });
306
+
307
+ it('extracts and parses JSON from single content block object', () => {
308
+ const result = {
309
+ type: 'text',
310
+ text: '{"sheets": [{"name": "raw_data"}]}',
311
+ };
312
+ expect(unwrapToolResponse(result, true)).toEqual({
313
+ sheets: [{ name: 'raw_data' }],
314
+ });
315
+ });
316
+
317
+ it('handles real-world MCP spreadsheet response', () => {
318
+ const result = {
319
+ type: 'text',
320
+ text: 'Spreadsheet: "NYC Taxi - Top Pickup Neighborhoods" (ID: abc123)\nSheets (2):\n - "raw_data" (ID: 123) | Size: 1000x26',
321
+ };
322
+ expect(unwrapToolResponse(result, true)).toBe(
323
+ 'Spreadsheet: "NYC Taxi - Top Pickup Neighborhoods" (ID: abc123)\nSheets (2):\n - "raw_data" (ID: 123) | Size: 1000x26'
324
+ );
325
+ });
326
+
327
+ it('handles real-world MCP no data response', () => {
328
+ const result = {
329
+ type: 'text',
330
+ text: 'No data found in range \'raw_data!A1:D25\' for user@example.com.',
331
+ };
332
+ expect(unwrapToolResponse(result, true)).toBe(
333
+ 'No data found in range \'raw_data!A1:D25\' for user@example.com.'
334
+ );
335
+ });
336
+ });
337
+
338
+ describe('MCP tools - array of content blocks (not in tuple)', () => {
339
+ it('extracts text from array of content blocks', () => {
340
+ const result = [
341
+ { type: 'text', text: 'First block' },
342
+ { type: 'text', text: 'Second block' },
343
+ ];
344
+ expect(unwrapToolResponse(result, true)).toBe(
345
+ 'First block\nSecond block'
346
+ );
347
+ });
348
+
349
+ it('filters out non-text blocks', () => {
350
+ const result = [
351
+ { type: 'text', text: 'Text content' },
352
+ { type: 'image', data: 'base64...' },
353
+ { type: 'text', text: 'More text' },
354
+ ];
355
+ expect(unwrapToolResponse(result, true)).toBe(
356
+ 'Text content\nMore text'
357
+ );
358
+ });
359
+ });
360
+
361
+ describe('edge cases', () => {
362
+ it('returns non-text block object as-is', () => {
363
+ const result = { type: 'image', data: 'base64...' };
364
+ expect(unwrapToolResponse(result, true)).toEqual(result);
365
+ });
366
+
367
+ it('handles empty array', () => {
368
+ expect(unwrapToolResponse([], true)).toEqual([]);
369
+ });
370
+
371
+ it('handles malformed JSON in text block gracefully', () => {
372
+ const result = { type: 'text', text: '{ invalid json }' };
373
+ expect(unwrapToolResponse(result, true)).toBe('{ invalid json }');
374
+ });
375
+
376
+ it('handles null', () => {
377
+ expect(unwrapToolResponse(null, true)).toBe(null);
378
+ });
379
+
380
+ it('handles undefined', () => {
381
+ expect(unwrapToolResponse(undefined, true)).toBe(undefined);
382
+ });
383
+ });
384
+ });
385
+
231
386
  describe('extractUsedToolNames', () => {
232
387
  const createToolMap = (names: string[]): Map<string, string> => {
233
388
  const map = new Map<string, string>();