@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.
- package/dist/cjs/main.cjs +1 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +155 -63
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/esm/main.mjs +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +155 -64
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/types/tools/ProgrammaticToolCalling.d.ts +11 -1
- package/package.json +1 -1
- package/src/tools/ProgrammaticToolCalling.ts +177 -65
- package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +155 -0
|
@@ -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
|
-
|
|
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
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
614
|
+
Run tools via Python code. Tools are injected as async functions—call with \`await\`.
|
|
506
615
|
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
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>();
|