illuma-agents 1.0.17 → 1.0.18

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 (94) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +3 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +18 -9
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/llm/bedrock/index.cjs +5 -3
  6. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  7. package/dist/cjs/llm/openrouter/index.cjs +10 -1
  8. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  9. package/dist/cjs/llm/vertexai/index.cjs +7 -8
  10. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  11. package/dist/cjs/main.cjs +2 -0
  12. package/dist/cjs/main.cjs.map +1 -1
  13. package/dist/cjs/messages/cache.cjs +11 -6
  14. package/dist/cjs/messages/cache.cjs.map +1 -1
  15. package/dist/cjs/messages/core.cjs +2 -2
  16. package/dist/cjs/messages/core.cjs.map +1 -1
  17. package/dist/cjs/messages/format.cjs +2 -1
  18. package/dist/cjs/messages/format.cjs.map +1 -1
  19. package/dist/cjs/messages/tools.cjs +2 -2
  20. package/dist/cjs/messages/tools.cjs.map +1 -1
  21. package/dist/cjs/stream.cjs +29 -16
  22. package/dist/cjs/stream.cjs.map +1 -1
  23. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +209 -47
  24. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  25. package/dist/cjs/tools/ToolNode.cjs +1 -1
  26. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  27. package/dist/cjs/tools/search/search.cjs.map +1 -1
  28. package/dist/cjs/tools/search/tool.cjs +3 -1
  29. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  30. package/dist/cjs/utils/contextAnalytics.cjs +7 -5
  31. package/dist/cjs/utils/contextAnalytics.cjs.map +1 -1
  32. package/dist/cjs/utils/run.cjs.map +1 -1
  33. package/dist/cjs/utils/toonFormat.cjs +42 -12
  34. package/dist/cjs/utils/toonFormat.cjs.map +1 -1
  35. package/dist/esm/agents/AgentContext.mjs +3 -1
  36. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  37. package/dist/esm/graphs/Graph.mjs +18 -9
  38. package/dist/esm/graphs/Graph.mjs.map +1 -1
  39. package/dist/esm/llm/bedrock/index.mjs +5 -3
  40. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  41. package/dist/esm/llm/openrouter/index.mjs +10 -1
  42. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  43. package/dist/esm/llm/vertexai/index.mjs +7 -8
  44. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  45. package/dist/esm/main.mjs +1 -1
  46. package/dist/esm/messages/cache.mjs +11 -6
  47. package/dist/esm/messages/cache.mjs.map +1 -1
  48. package/dist/esm/messages/core.mjs +2 -2
  49. package/dist/esm/messages/core.mjs.map +1 -1
  50. package/dist/esm/messages/format.mjs +2 -1
  51. package/dist/esm/messages/format.mjs.map +1 -1
  52. package/dist/esm/messages/tools.mjs +2 -2
  53. package/dist/esm/messages/tools.mjs.map +1 -1
  54. package/dist/esm/stream.mjs +29 -16
  55. package/dist/esm/stream.mjs.map +1 -1
  56. package/dist/esm/tools/ProgrammaticToolCalling.mjs +208 -48
  57. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  58. package/dist/esm/tools/ToolNode.mjs +1 -1
  59. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  60. package/dist/esm/tools/search/search.mjs.map +1 -1
  61. package/dist/esm/tools/search/tool.mjs +3 -1
  62. package/dist/esm/tools/search/tool.mjs.map +1 -1
  63. package/dist/esm/utils/contextAnalytics.mjs +7 -5
  64. package/dist/esm/utils/contextAnalytics.mjs.map +1 -1
  65. package/dist/esm/utils/run.mjs.map +1 -1
  66. package/dist/esm/utils/toonFormat.mjs +42 -12
  67. package/dist/esm/utils/toonFormat.mjs.map +1 -1
  68. package/dist/types/tools/ProgrammaticToolCalling.d.ts +19 -0
  69. package/dist/types/types/tools.d.ts +3 -1
  70. package/package.json +2 -2
  71. package/src/agents/AgentContext.ts +28 -20
  72. package/src/graphs/Graph.ts +76 -37
  73. package/src/llm/bedrock/__tests__/bedrock-caching.test.ts +495 -473
  74. package/src/llm/bedrock/index.ts +47 -35
  75. package/src/llm/openrouter/index.ts +11 -1
  76. package/src/llm/vertexai/index.ts +9 -10
  77. package/src/messages/cache.ts +104 -55
  78. package/src/messages/core.ts +5 -3
  79. package/src/messages/format.ts +6 -2
  80. package/src/messages/tools.ts +2 -2
  81. package/src/scripts/simple.ts +1 -1
  82. package/src/specs/emergency-prune.test.ts +407 -355
  83. package/src/stream.ts +28 -20
  84. package/src/tools/ProgrammaticToolCalling.ts +246 -52
  85. package/src/tools/ToolNode.ts +4 -4
  86. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +155 -0
  87. package/src/tools/search/jina-reranker.test.ts +32 -28
  88. package/src/tools/search/search.ts +3 -1
  89. package/src/tools/search/tool.ts +16 -7
  90. package/src/types/tools.ts +3 -1
  91. package/src/utils/contextAnalytics.ts +103 -95
  92. package/src/utils/llmConfig.ts +8 -1
  93. package/src/utils/run.ts +5 -4
  94. package/src/utils/toonFormat.ts +475 -437
package/src/stream.ts CHANGED
@@ -107,24 +107,27 @@ export function getChunkContent({
107
107
  | undefined
108
108
  )?.summary?.[0]?.text;
109
109
  }
110
- if (
111
- provider === Providers.OPENROUTER &&
112
- chunk?.additional_kwargs?.reasoning_details != null &&
113
- Array.isArray(chunk.additional_kwargs.reasoning_details)
114
- ) {
115
- // Extract text from reasoning_details array (for Gemini, DeepSeek, etc.)
116
- const textEntries = chunk.additional_kwargs.reasoning_details
117
- .filter(
118
- (detail) =>
119
- detail.type === 'reasoning.text' &&
120
- detail.text != null &&
121
- detail.text !== ''
122
- )
123
- .map((detail) => detail.text)
124
- .join('');
125
- if (textEntries) {
126
- return textEntries;
110
+ /**
111
+ * For OpenRouter, reasoning is stored in additional_kwargs.reasoning (not reasoning_content).
112
+ * NOTE: We intentionally do NOT extract text from reasoning_details here.
113
+ * The reasoning_details array contains the FULL accumulated reasoning text (set only on final chunk),
114
+ * but individual reasoning tokens are already streamed via additional_kwargs.reasoning.
115
+ * Extracting from reasoning_details would cause duplication.
116
+ * The reasoning_details is only used for:
117
+ * 1. Detecting reasoning mode in handleReasoning()
118
+ * 2. Final message storage (for thought signatures)
119
+ */
120
+ if (provider === Providers.OPENROUTER) {
121
+ // Content presence signals end of reasoning phase - prefer content over reasoning
122
+ // This handles transitional chunks that may have both reasoning and content
123
+ if (typeof chunk?.content === 'string' && chunk.content !== '') {
124
+ return chunk.content;
125
+ }
126
+ const reasoning = chunk?.additional_kwargs?.reasoning as string | undefined;
127
+ if (reasoning != null && reasoning !== '') {
128
+ return reasoning;
127
129
  }
130
+ return chunk?.content;
128
131
  }
129
132
  return (
130
133
  ((chunk?.additional_kwargs?.[reasoningKey] as string | undefined) ?? '') ||
@@ -376,9 +379,14 @@ hasToolCallChunks: ${hasToolCallChunks}
376
379
  reasoning_content = 'valid';
377
380
  } else if (
378
381
  agentContext.provider === Providers.OPENROUTER &&
379
- chunk.additional_kwargs?.reasoning_details != null &&
380
- Array.isArray(chunk.additional_kwargs.reasoning_details) &&
381
- chunk.additional_kwargs.reasoning_details.length > 0
382
+ // Only set reasoning as valid if content is NOT present (content signals end of reasoning)
383
+ (chunk.content == null || chunk.content === '') &&
384
+ // Check for reasoning_details (final chunk) OR reasoning string (intermediate chunks)
385
+ ((chunk.additional_kwargs?.reasoning_details != null &&
386
+ Array.isArray(chunk.additional_kwargs.reasoning_details) &&
387
+ chunk.additional_kwargs.reasoning_details.length > 0) ||
388
+ (typeof chunk.additional_kwargs?.reasoning === 'string' &&
389
+ chunk.additional_kwargs.reasoning !== ''))
382
390
  ) {
383
391
  reasoning_content = 'valid';
384
392
  }
@@ -38,42 +38,35 @@ const ProgrammaticToolCallingSchema = z.object({
38
38
  .string()
39
39
  .min(1)
40
40
  .describe(
41
- `Python code that calls tools programmatically. Tools are automatically available as async Python functions - DO NOT define them yourself.
42
-
43
- The Code API generates async function stubs from the tool definitions. Just call them directly:
44
-
45
- Example (Simple call):
46
- result = await get_weather(city="San Francisco")
47
- print(result)
48
-
49
- Example (Parallel - Fastest):
50
- results = await asyncio.gather(
51
- get_weather(city="SF"),
52
- get_weather(city="NYC"),
53
- get_weather(city="London")
54
- )
55
- for city, weather in zip(["SF", "NYC", "London"], results):
56
- print(f"{city}: {weather['temperature']}°F")
57
-
58
- Example (Loop with processing):
59
- team = await get_team_members()
60
- for member in team:
61
- expenses = await get_expenses(user_id=member['id'])
62
- total = sum(e['amount'] for e in expenses)
63
- print(f"{member['name']}: \${total:.2f}")
64
-
65
- Example (Conditional logic):
66
- data = await fetch_data(source="primary")
67
- if not data:
68
- data = await fetch_data(source="backup")
69
- print(f"Got {len(data)} records")
70
-
71
- Requirements:
72
- - Tools are pre-defined as async functions - DO NOT write function definitions
73
- - Use await for all tool calls
74
- - Use asyncio.gather() for parallel execution of independent calls
75
- - Only print() output flows back to the context window
76
- - Tool results from programmatic calls do NOT consume context tokens`
41
+ `Python code that calls tools programmatically. Tools are available as async functions.
42
+
43
+ CRITICAL - STATELESS EXECUTION:
44
+ Each call is a fresh Python interpreter. Variables, imports, and data do NOT persist between calls.
45
+ You MUST complete your entire workflow in ONE code block: query → process → output.
46
+ DO NOT split work across multiple calls expecting to reuse variables.
47
+
48
+ Your code is auto-wrapped in async context. Just write logic with await—no boilerplate needed.
49
+
50
+ Example (Complete workflow in one call):
51
+ # Query data
52
+ data = await query_database(sql="SELECT * FROM users")
53
+ # Process it
54
+ df = pd.DataFrame(data)
55
+ summary = df.groupby('region').sum()
56
+ # Output results
57
+ await write_to_sheet(spreadsheet_id=sid, data=summary.to_dict())
58
+ print(f"Wrote {len(summary)} rows")
59
+
60
+ Example (Parallel calls):
61
+ sf, ny = await asyncio.gather(get_weather(city="SF"), get_weather(city="NY"))
62
+ print(f"SF: {sf}, NY: {ny}")
63
+
64
+ Rules:
65
+ - EVERYTHING in one call—no state persists between executions
66
+ - Just write code with await—auto-wrapped in async context
67
+ - DO NOT define async def main() or call asyncio.run()
68
+ - Tools are pre-defined—DO NOT write function definitions
69
+ - Only print() output returns to the model`
77
70
  ),
78
71
  session_id: z
79
72
  .string()
@@ -234,6 +227,67 @@ export function filterToolsByUsage(
234
227
  return toolDefs.filter((tool) => usedToolNames.has(tool.name));
235
228
  }
236
229
 
230
+ /**
231
+ * Fetches files from a previous session to make them available for the current execution.
232
+ * Files are returned as CodeEnvFile references to be included in the request.
233
+ * @param baseUrl - The base URL for the Code API
234
+ * @param apiKey - The API key for authentication
235
+ * @param sessionId - The session ID to fetch files from
236
+ * @param proxy - Optional HTTP proxy URL
237
+ * @returns Array of CodeEnvFile references, or empty array if fetch fails
238
+ */
239
+ export async function fetchSessionFiles(
240
+ baseUrl: string,
241
+ apiKey: string,
242
+ sessionId: string,
243
+ proxy?: string
244
+ ): Promise<t.CodeEnvFile[]> {
245
+ try {
246
+ const filesEndpoint = `${baseUrl}/files/${sessionId}?detail=full`;
247
+ const fetchOptions: RequestInit = {
248
+ method: 'GET',
249
+ headers: {
250
+ 'User-Agent': 'LibreChat/1.0',
251
+ 'X-API-Key': apiKey,
252
+ },
253
+ };
254
+
255
+ if (proxy != null && proxy !== '') {
256
+ fetchOptions.agent = new HttpsProxyAgent(proxy);
257
+ }
258
+
259
+ const response = await fetch(filesEndpoint, fetchOptions);
260
+ if (!response.ok) {
261
+ throw new Error(`Failed to fetch files for session: ${response.status}`);
262
+ }
263
+
264
+ const files = await response.json();
265
+ if (!Array.isArray(files) || files.length === 0) {
266
+ return [];
267
+ }
268
+
269
+ return files.map((file: Record<string, unknown>) => {
270
+ // Extract the ID from the file name (part after session ID prefix and before extension)
271
+ const nameParts = (file.name as string).split('/');
272
+ const id = nameParts.length > 1 ? nameParts[1].split('.')[0] : '';
273
+
274
+ return {
275
+ session_id: sessionId,
276
+ id,
277
+ name: (file.metadata as Record<string, unknown>)[
278
+ 'original-filename'
279
+ ] as string,
280
+ };
281
+ });
282
+ } catch (error) {
283
+ // eslint-disable-next-line no-console
284
+ console.warn(
285
+ `Failed to fetch files for session: ${sessionId}, ${(error as Error).message}`
286
+ );
287
+ return [];
288
+ }
289
+ }
290
+
237
291
  /**
238
292
  * Makes an HTTP request to the Code API.
239
293
  * @param endpoint - The API endpoint URL
@@ -274,9 +328,140 @@ export async function makeRequest(
274
328
  return (await response.json()) as t.ProgrammaticExecutionResponse;
275
329
  }
276
330
 
331
+ /**
332
+ * Unwraps tool responses that may be formatted as tuples or content blocks.
333
+ * MCP tools return [content, artifacts], we need to extract the raw data.
334
+ * @param result - The raw result from tool.invoke()
335
+ * @param isMCPTool - Whether this is an MCP tool (has mcp property)
336
+ * @returns Unwrapped raw data (string, object, or parsed JSON)
337
+ */
338
+ export function unwrapToolResponse(
339
+ result: unknown,
340
+ isMCPTool: boolean
341
+ ): unknown {
342
+ // Only unwrap if this is an MCP tool and result is a tuple
343
+ if (!isMCPTool) {
344
+ return result;
345
+ }
346
+
347
+ /**
348
+ * Checks if a value is a content block object (has type and text).
349
+ */
350
+ const isContentBlock = (value: unknown): boolean => {
351
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
352
+ return false;
353
+ }
354
+ const obj = value as Record<string, unknown>;
355
+ return typeof obj.type === 'string';
356
+ };
357
+
358
+ /**
359
+ * Checks if an array is an array of content blocks.
360
+ */
361
+ const isContentBlockArray = (arr: unknown[]): boolean => {
362
+ return arr.length > 0 && arr.every(isContentBlock);
363
+ };
364
+
365
+ /**
366
+ * Extracts text from a single content block object.
367
+ * Returns the text if it's a text block, otherwise returns null.
368
+ */
369
+ const extractTextFromBlock = (block: unknown): string | null => {
370
+ if (typeof block !== 'object' || block === null) return null;
371
+ const b = block as Record<string, unknown>;
372
+ if (b.type === 'text' && typeof b.text === 'string') {
373
+ return b.text;
374
+ }
375
+ return null;
376
+ };
377
+
378
+ /**
379
+ * Extracts text from content blocks (array or single object).
380
+ * Returns combined text or null if no text blocks found.
381
+ */
382
+ const extractTextFromContent = (content: unknown): string | null => {
383
+ // Single content block object: { type: 'text', text: '...' }
384
+ if (
385
+ typeof content === 'object' &&
386
+ content !== null &&
387
+ !Array.isArray(content)
388
+ ) {
389
+ const text = extractTextFromBlock(content);
390
+ if (text !== null) return text;
391
+ }
392
+
393
+ // Array of content blocks: [{ type: 'text', text: '...' }, ...]
394
+ if (Array.isArray(content) && content.length > 0) {
395
+ const texts = content
396
+ .map(extractTextFromBlock)
397
+ .filter((t): t is string => t !== null);
398
+ if (texts.length > 0) {
399
+ return texts.join('\n');
400
+ }
401
+ }
402
+
403
+ return null;
404
+ };
405
+
406
+ /**
407
+ * Tries to parse a string as JSON if it looks like JSON.
408
+ */
409
+ const maybeParseJSON = (str: string): unknown => {
410
+ const trimmed = str.trim();
411
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
412
+ try {
413
+ return JSON.parse(trimmed);
414
+ } catch {
415
+ return str;
416
+ }
417
+ }
418
+ return str;
419
+ };
420
+
421
+ // Handle array of content blocks at top level FIRST
422
+ // (before checking for tuple, since both are arrays)
423
+ if (Array.isArray(result) && isContentBlockArray(result)) {
424
+ const extractedText = extractTextFromContent(result);
425
+ if (extractedText !== null) {
426
+ return maybeParseJSON(extractedText);
427
+ }
428
+ }
429
+
430
+ // Check if result is a tuple/array with [content, artifacts]
431
+ if (Array.isArray(result) && result.length >= 1) {
432
+ const [content] = result;
433
+
434
+ // If first element is a string, return it (possibly parsed as JSON)
435
+ if (typeof content === 'string') {
436
+ return maybeParseJSON(content);
437
+ }
438
+
439
+ // Try to extract text from content blocks
440
+ const extractedText = extractTextFromContent(content);
441
+ if (extractedText !== null) {
442
+ return maybeParseJSON(extractedText);
443
+ }
444
+
445
+ // If first element is an object (but not a text block), return it
446
+ if (typeof content === 'object' && content !== null) {
447
+ return content;
448
+ }
449
+ }
450
+
451
+ // Handle single content block object at top level (not in tuple)
452
+ const extractedText = extractTextFromContent(result);
453
+ if (extractedText !== null) {
454
+ return maybeParseJSON(extractedText);
455
+ }
456
+
457
+ // Not a formatted response, return as-is
458
+ return result;
459
+ }
460
+
277
461
  /**
278
462
  * Executes tools in parallel when requested by the API.
279
463
  * Uses Promise.all for parallel execution, catching individual errors.
464
+ * Unwraps formatted responses (e.g., MCP tool tuples) to raw data.
280
465
  * @param toolCalls - Array of tool calls from the API
281
466
  * @param toolMap - Map of tool names to executable tools
282
467
  * @returns Array of tool results
@@ -301,9 +486,13 @@ export async function executeTools(
301
486
  const result = await tool.invoke(call.input, {
302
487
  metadata: { [Constants.PROGRAMMATIC_TOOL_CALLING]: true },
303
488
  });
489
+
490
+ const isMCPTool = tool.mcp === true;
491
+ const unwrappedResult = unwrapToolResponse(result, isMCPTool);
492
+
304
493
  return {
305
494
  call_id: call.id,
306
- result,
495
+ result: unwrappedResult,
307
496
  is_error: false,
308
497
  };
309
498
  } catch (error) {
@@ -414,25 +603,23 @@ export function createProgrammaticToolCallingTool(
414
603
  const EXEC_ENDPOINT = `${baseUrl}/exec/programmatic`;
415
604
 
416
605
  const description = `
417
- Run tools by writing Python code. Tools are available as async functions - just call them with await.
606
+ Run tools via Python code. Auto-wrapped in async context—just use \`await\` directly.
418
607
 
419
- This is different from execute_code: here you can call your tools (like get_weather, get_expenses, etc.) directly in Python code.
608
+ CRITICAL - STATELESS: Each call is a fresh interpreter. Variables/imports do NOT persist.
609
+ Complete your ENTIRE workflow in ONE call: fetch → process → save. No splitting across calls.
420
610
 
421
- Usage:
422
- - Tools are pre-defined as async functions - call them with await
423
- - Use asyncio.gather() to run multiple tools in parallel
424
- - Only print() output is returned - tool results stay in Python
611
+ Rules:
612
+ - Everything in ONE code block—no state carries over between executions
613
+ - Do NOT define \`async def main()\` or call \`asyncio.run()\`—just write code with await
614
+ - Tools are pre-defined—DO NOT write function definitions
615
+ - Only \`print()\` output returns; tool results are raw dicts/lists/strings
616
+ - Use \`session_id\` param for file persistence across calls
617
+ - Tool names normalized: hyphens→underscores, keywords get \`_tool\` suffix
425
618
 
426
- Examples:
427
- - Simple: result = await get_weather(city="NYC")
428
- - Loop: for user in users: data = await get_expenses(user_id=user['id'])
429
- - Parallel: sf, ny = await asyncio.gather(get_weather(city="SF"), get_weather(city="NY"))
619
+ When to use: loops, conditionals, parallel (\`asyncio.gather\`), multi-step pipelines.
430
620
 
431
- When to use this instead of calling tools directly:
432
- - You need to call tools in a loop (process many items)
433
- - You want parallel execution (asyncio.gather)
434
- - You need conditionals based on tool results
435
- - You want to aggregate/filter data before returning
621
+ Example (complete pipeline):
622
+ data = await query_db(sql="..."); df = process(data); await save_to_sheet(data=df); print("Done")
436
623
  `.trim();
437
624
 
438
625
  return tool<typeof ProgrammaticToolCallingSchema>(
@@ -474,6 +661,12 @@ When to use this instead of calling tools directly:
474
661
  );
475
662
  }
476
663
 
664
+ // Fetch files from previous session if session_id is provided
665
+ let files: t.CodeEnvFile[] | undefined;
666
+ if (session_id != null && session_id.length > 0) {
667
+ files = await fetchSessionFiles(baseUrl, apiKey, session_id, proxy);
668
+ }
669
+
477
670
  let response = await makeRequest(
478
671
  EXEC_ENDPOINT,
479
672
  apiKey,
@@ -482,6 +675,7 @@ When to use this instead of calling tools directly:
482
675
  tools: effectiveTools,
483
676
  session_id,
484
677
  timeout,
678
+ ...(files && files.length > 0 ? { files } : {}),
485
679
  },
486
680
  proxy
487
681
  );
@@ -40,7 +40,7 @@ function extractStringContent(content: unknown): string {
40
40
  if (typeof content === 'string') {
41
41
  return content;
42
42
  }
43
-
43
+
44
44
  // Array of content blocks - extract text from each
45
45
  if (Array.isArray(content)) {
46
46
  const textParts: string[] = [];
@@ -62,7 +62,7 @@ function extractStringContent(content: unknown): string {
62
62
  return textParts.join('\n');
63
63
  }
64
64
  }
65
-
65
+
66
66
  // Fallback: stringify whatever it is
67
67
  return JSON.stringify(content);
68
68
  }
@@ -186,12 +186,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
186
186
 
187
187
  // ========================================================================
188
188
  // TOOL OUTPUT PROCESSING - Single point for all tools (MCP and non-MCP)
189
- // 1. Extract string content from any output format
189
+ // 1. Extract string content from any output format
190
190
  // 2. Apply TOON conversion if content contains JSON
191
191
  // 3. Apply truncation if still too large
192
192
  // 4. Return ToolMessage with processed string content
193
193
  // ========================================================================
194
-
194
+
195
195
  // Step 1: Extract string content from the output
196
196
  let rawContent: string;
197
197
  if (isBaseMessage(output) && output._getType() === 'tool') {
@@ -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>();