@librechat/agents 3.1.26 → 3.1.28

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.
@@ -306,6 +306,375 @@ describe('formatAgentMessages', () => {
306
306
  ]);
307
307
  });
308
308
 
309
+ it('should dynamically discover tools from tool_search output and keep their tool calls', () => {
310
+ const tools = new Set(['tool_search', 'calculator']);
311
+ const payload = [
312
+ {
313
+ role: 'user',
314
+ content: 'Search for commits and list them',
315
+ },
316
+ {
317
+ role: 'assistant',
318
+ content: [
319
+ {
320
+ type: ContentTypes.TEXT,
321
+ [ContentTypes.TEXT]: 'I\'ll search for tools first.',
322
+ tool_call_ids: ['ts_1'],
323
+ },
324
+ {
325
+ type: ContentTypes.TOOL_CALL,
326
+ tool_call: {
327
+ id: 'ts_1',
328
+ name: 'tool_search',
329
+ args: '{"query":"commits"}',
330
+ output: '{"found": 1, "tools": [{"name": "list_commits"}]}',
331
+ },
332
+ },
333
+ {
334
+ type: ContentTypes.TEXT,
335
+ [ContentTypes.TEXT]: 'Now listing commits.',
336
+ tool_call_ids: ['lc_1'],
337
+ },
338
+ {
339
+ type: ContentTypes.TOOL_CALL,
340
+ tool_call: {
341
+ id: 'lc_1',
342
+ name: 'list_commits',
343
+ args: '{"repo":"test"}',
344
+ output: '[{"sha":"abc123"}]',
345
+ },
346
+ },
347
+ {
348
+ type: ContentTypes.TEXT,
349
+ [ContentTypes.TEXT]: 'Here are the results.',
350
+ },
351
+ ],
352
+ },
353
+ {
354
+ role: 'user',
355
+ content: 'Thanks!',
356
+ },
357
+ ];
358
+
359
+ const result = formatAgentMessages(payload, undefined, tools);
360
+
361
+ /**
362
+ * Since tool_search discovered list_commits, both should be kept.
363
+ * The dynamic discovery adds list_commits to the valid tools set.
364
+ */
365
+ const toolMessages = result.messages.filter(
366
+ (m) => m._getType() === 'tool'
367
+ ) as ToolMessage[];
368
+ expect(toolMessages.length).toBe(2);
369
+
370
+ const toolNames = toolMessages.map((m) => m.name).sort();
371
+ expect(toolNames).toEqual(['list_commits', 'tool_search']);
372
+ });
373
+
374
+ it('should filter out tool calls not in set and not discovered by tool_search', () => {
375
+ const tools = new Set(['tool_search', 'calculator']);
376
+ const payload = [
377
+ {
378
+ role: 'assistant',
379
+ content: [
380
+ {
381
+ type: ContentTypes.TEXT,
382
+ [ContentTypes.TEXT]: 'I\'ll call an unknown tool.',
383
+ tool_call_ids: ['uk_1'],
384
+ },
385
+ {
386
+ type: ContentTypes.TOOL_CALL,
387
+ tool_call: {
388
+ id: 'uk_1',
389
+ name: 'unknown_tool',
390
+ args: '{}',
391
+ output: 'result',
392
+ },
393
+ },
394
+ {
395
+ type: ContentTypes.TEXT,
396
+ [ContentTypes.TEXT]: 'Done.',
397
+ },
398
+ ],
399
+ },
400
+ ];
401
+
402
+ const result = formatAgentMessages(payload, undefined, tools);
403
+
404
+ /** unknown_tool should be filtered out since it's not in tools set and not discovered */
405
+ const toolMessages = result.messages.filter(
406
+ (m) => m._getType() === 'tool'
407
+ ) as ToolMessage[];
408
+ expect(toolMessages.length).toBe(0);
409
+ });
410
+
411
+ it('should keep all tool calls when all are in the tools set', () => {
412
+ const tools = new Set(['search', 'calculator']);
413
+ const payload = [
414
+ {
415
+ role: 'assistant',
416
+ content: [
417
+ {
418
+ type: ContentTypes.TEXT,
419
+ [ContentTypes.TEXT]: 'Let me help.',
420
+ tool_call_ids: ['s1', 'c1'],
421
+ },
422
+ {
423
+ type: ContentTypes.TOOL_CALL,
424
+ tool_call: {
425
+ id: 's1',
426
+ name: 'search',
427
+ args: '{"q":"test"}',
428
+ output: 'Search results',
429
+ },
430
+ },
431
+ {
432
+ type: ContentTypes.TOOL_CALL,
433
+ tool_call: {
434
+ id: 'c1',
435
+ name: 'calculator',
436
+ args: '{"expr":"2+2"}',
437
+ output: '4',
438
+ },
439
+ },
440
+ ],
441
+ },
442
+ ];
443
+
444
+ const result = formatAgentMessages(payload, undefined, tools);
445
+
446
+ const toolMessages = result.messages.filter(
447
+ (m) => m._getType() === 'tool'
448
+ ) as ToolMessage[];
449
+ expect(toolMessages.length).toBe(2);
450
+ expect(toolMessages.map((m) => m.name).sort()).toEqual([
451
+ 'calculator',
452
+ 'search',
453
+ ]);
454
+ });
455
+
456
+ it('should preserve discovered tools across multiple assistant messages', () => {
457
+ /**
458
+ * This test verifies that once tool_search discovers a tool, it remains valid
459
+ * for all subsequent messages in the conversation, not just the current message.
460
+ */
461
+ const tools = new Set(['tool_search']);
462
+ const payload = [
463
+ {
464
+ role: 'user',
465
+ content: 'Find me a tool to list commits and use it',
466
+ },
467
+ {
468
+ role: 'assistant',
469
+ content: [
470
+ {
471
+ type: ContentTypes.TEXT,
472
+ [ContentTypes.TEXT]: 'Let me search for that tool.',
473
+ tool_call_ids: ['ts_1'],
474
+ },
475
+ {
476
+ type: ContentTypes.TOOL_CALL,
477
+ tool_call: {
478
+ id: 'ts_1',
479
+ name: 'tool_search',
480
+ args: '{"query":"commits"}',
481
+ output: '{"found": 1, "tools": [{"name": "list_commits_mcp_github"}]}',
482
+ },
483
+ },
484
+ ],
485
+ },
486
+ {
487
+ role: 'assistant',
488
+ content: [
489
+ {
490
+ type: ContentTypes.TEXT,
491
+ [ContentTypes.TEXT]: 'Now using the discovered tool.',
492
+ tool_call_ids: ['lc_1'],
493
+ },
494
+ {
495
+ type: ContentTypes.TOOL_CALL,
496
+ tool_call: {
497
+ id: 'lc_1',
498
+ name: 'list_commits_mcp_github',
499
+ args: '{"repo":"test"}',
500
+ output: '[{"sha":"abc123","message":"Initial commit"}]',
501
+ },
502
+ },
503
+ ],
504
+ },
505
+ {
506
+ role: 'user',
507
+ content: 'Show me more commits',
508
+ },
509
+ {
510
+ role: 'assistant',
511
+ content: [
512
+ {
513
+ type: ContentTypes.TEXT,
514
+ [ContentTypes.TEXT]: 'Fetching more commits.',
515
+ tool_call_ids: ['lc_2'],
516
+ },
517
+ {
518
+ type: ContentTypes.TOOL_CALL,
519
+ tool_call: {
520
+ id: 'lc_2',
521
+ name: 'list_commits_mcp_github',
522
+ args: '{"repo":"test","page":2}',
523
+ output: '[{"sha":"def456","message":"Second commit"}]',
524
+ },
525
+ },
526
+ ],
527
+ },
528
+ ];
529
+
530
+ const result = formatAgentMessages(payload, undefined, tools);
531
+
532
+ /** All three tool calls should be preserved as ToolMessages */
533
+ const toolMessages = result.messages.filter(
534
+ (m) => m._getType() === 'tool'
535
+ ) as ToolMessage[];
536
+
537
+ expect(toolMessages.length).toBe(3);
538
+ expect(toolMessages[0].name).toBe('tool_search');
539
+ expect(toolMessages[1].name).toBe('list_commits_mcp_github');
540
+ expect(toolMessages[2].name).toBe('list_commits_mcp_github');
541
+ });
542
+
543
+ it('should convert invalid tools to string while keeping valid tools as ToolMessages', () => {
544
+ /**
545
+ * This test documents the hybrid behavior:
546
+ * - Valid tools remain as proper AIMessage + ToolMessage structures
547
+ * - Invalid tools are converted to string and appended to text content
548
+ * (preserving context without losing information)
549
+ */
550
+ const tools = new Set(['calculator']);
551
+ const payload = [
552
+ {
553
+ role: 'assistant',
554
+ content: [
555
+ {
556
+ type: ContentTypes.TEXT,
557
+ [ContentTypes.TEXT]: 'I will use two tools.',
558
+ tool_call_ids: ['calc_1', 'unknown_1'],
559
+ },
560
+ {
561
+ type: ContentTypes.TOOL_CALL,
562
+ tool_call: {
563
+ id: 'calc_1',
564
+ name: 'calculator',
565
+ args: '{"expr":"2+2"}',
566
+ output: '4',
567
+ },
568
+ },
569
+ {
570
+ type: ContentTypes.TOOL_CALL,
571
+ tool_call: {
572
+ id: 'unknown_1',
573
+ name: 'some_unknown_tool',
574
+ args: '{"query":"test"}',
575
+ output: 'This is the result from unknown tool',
576
+ },
577
+ },
578
+ ],
579
+ },
580
+ ];
581
+
582
+ const result = formatAgentMessages(payload, undefined, tools);
583
+
584
+ /** Should have AIMessage + ToolMessage for calculator */
585
+ expect(result.messages.length).toBe(2);
586
+ expect(result.messages[0]).toBeInstanceOf(AIMessage);
587
+ expect(result.messages[1]).toBeInstanceOf(ToolMessage);
588
+
589
+ /** The valid tool should be kept */
590
+ expect((result.messages[0] as AIMessage).tool_calls).toHaveLength(1);
591
+ expect((result.messages[0] as AIMessage).tool_calls?.[0].name).toBe(
592
+ 'calculator'
593
+ );
594
+ expect((result.messages[1] as ToolMessage).name).toBe('calculator');
595
+
596
+ /** The invalid tool should be converted to string in the content */
597
+ const aiContent = result.messages[0].content;
598
+ const aiContentStr =
599
+ typeof aiContent === 'string' ? aiContent : JSON.stringify(aiContent);
600
+ expect(aiContentStr).toContain('some_unknown_tool');
601
+ expect(aiContentStr).toContain('This is the result from unknown tool');
602
+ });
603
+
604
+ it('should simulate realistic deferred tools flow with tool_search', () => {
605
+ /**
606
+ * This test simulates the real-world use case:
607
+ * 1. Agent only has tool_search initially (deferred tools not in set)
608
+ * 2. User asks to do something that requires a deferred tool
609
+ * 3. Agent uses tool_search to discover the tool
610
+ * 4. Agent then uses the discovered tool
611
+ * 5. On subsequent conversation turns, both tool calls should be valid
612
+ */
613
+ const tools = new Set(['tool_search', 'execute_code']);
614
+ const payload = [
615
+ { role: 'user', content: 'List the recent commits from the repo' },
616
+ {
617
+ role: 'assistant',
618
+ content: [
619
+ {
620
+ type: ContentTypes.TEXT,
621
+ [ContentTypes.TEXT]:
622
+ 'I need to find a tool for listing commits. Let me search.',
623
+ tool_call_ids: ['search_1'],
624
+ },
625
+ {
626
+ type: ContentTypes.TOOL_CALL,
627
+ tool_call: {
628
+ id: 'search_1',
629
+ name: 'tool_search',
630
+ args: '{"query":"git commits list"}',
631
+ output:
632
+ '{\n "found": 1,\n "tools": [\n {\n "name": "list_commits_mcp_github",\n "score": 0.95,\n "matched_in": "name",\n "snippet": "Lists commits from a GitHub repository"\n }\n ],\n "total_searched": 50,\n "query": "git commits list"\n}',
633
+ },
634
+ },
635
+ {
636
+ type: ContentTypes.TEXT,
637
+ [ContentTypes.TEXT]:
638
+ 'Found the tool! Now I will list the commits.',
639
+ tool_call_ids: ['commits_1'],
640
+ },
641
+ {
642
+ type: ContentTypes.TOOL_CALL,
643
+ tool_call: {
644
+ id: 'commits_1',
645
+ name: 'list_commits_mcp_github',
646
+ args: '{"owner":"librechat","repo":"librechat"}',
647
+ output:
648
+ '[{"sha":"abc123","message":"feat: add deferred tools"},{"sha":"def456","message":"fix: tool loading"}]',
649
+ },
650
+ },
651
+ ],
652
+ },
653
+ ];
654
+
655
+ const result = formatAgentMessages(payload, undefined, tools);
656
+
657
+ /** Both tool_search and list_commits_mcp_github should be preserved */
658
+ const toolMessages = result.messages.filter(
659
+ (m) => m._getType() === 'tool'
660
+ ) as ToolMessage[];
661
+
662
+ expect(toolMessages.length).toBe(2);
663
+ expect(toolMessages[0].name).toBe('tool_search');
664
+ expect(toolMessages[1].name).toBe('list_commits_mcp_github');
665
+
666
+ /** The AI messages should have proper tool_calls */
667
+ const aiMessages = result.messages.filter(
668
+ (m) => m._getType() === 'ai'
669
+ ) as AIMessage[];
670
+
671
+ const toolCallNames = aiMessages.flatMap(
672
+ (m) => m.tool_calls?.map((tc) => tc.name) ?? []
673
+ );
674
+ expect(toolCallNames).toContain('tool_search');
675
+ expect(toolCallNames).toContain('list_commits_mcp_github');
676
+ });
677
+
309
678
  it.skip('should not produce two consecutive assistant messages and format content correctly', () => {
310
679
  const payload = [
311
680
  { role: 'user', content: 'Hello' },
@@ -38,7 +38,7 @@ describe('formatAgentMessages with tools parameter', () => {
38
38
  expect((result.messages[2] as ToolMessage).tool_call_id).toBe('123');
39
39
  });
40
40
 
41
- it('should treat an empty tools set the same as disallowing all tools', () => {
41
+ it('should filter out all tool calls when tools set is empty', () => {
42
42
  const payload: TPayload = [
43
43
  { role: 'user', content: 'What\'s the weather?' },
44
44
  {
@@ -67,19 +67,16 @@ describe('formatAgentMessages with tools parameter', () => {
67
67
 
68
68
  const result = formatAgentMessages(payload, undefined, allowedTools);
69
69
 
70
- // Should convert to a single AIMessage with string content
70
+ // Should filter out the tool call, keeping only text content
71
71
  expect(result.messages).toHaveLength(2);
72
72
  expect(result.messages[0]).toBeInstanceOf(HumanMessage);
73
73
  expect(result.messages[1]).toBeInstanceOf(AIMessage);
74
74
 
75
- // The content should be a string representation of both messages
76
- expect(typeof result.messages[1].content).toBe('string');
77
- expect(result.messages[1].content).toEqual(
78
- 'AI: Let me check the weather for you.\nTool: check_weather, Sunny, 75°F'
79
- );
75
+ // The AIMessage should have no tool_calls (they were filtered out)
76
+ expect((result.messages[1] as AIMessage).tool_calls).toHaveLength(0);
80
77
  });
81
78
 
82
- it('should convert tool messages to string when tool is not in the allowed set', () => {
79
+ it('should filter out tool calls not in the allowed set', () => {
83
80
  const payload: TPayload = [
84
81
  { role: 'user', content: 'What\'s the weather?' },
85
82
  {
@@ -108,16 +105,13 @@ describe('formatAgentMessages with tools parameter', () => {
108
105
 
109
106
  const result = formatAgentMessages(payload, undefined, allowedTools);
110
107
 
111
- // Should convert to a single AIMessage with string content
108
+ // Should filter out the invalid tool call, keeping text content
112
109
  expect(result.messages).toHaveLength(2);
113
110
  expect(result.messages[0]).toBeInstanceOf(HumanMessage);
114
111
  expect(result.messages[1]).toBeInstanceOf(AIMessage);
115
112
 
116
- // The content should be a string representation of both messages
117
- expect(typeof result.messages[1].content).toBe('string');
118
- expect(result.messages[1].content).toEqual(
119
- 'AI: Let me check the weather for you.\nTool: check_weather, Sunny, 75°F'
120
- );
113
+ // The AIMessage should have no tool_calls (check_weather was filtered out)
114
+ expect((result.messages[1] as AIMessage).tool_calls).toHaveLength(0);
121
115
  });
122
116
 
123
117
  it('should not convert tool messages when tool is in the allowed set', () => {
@@ -202,21 +196,25 @@ describe('formatAgentMessages with tools parameter', () => {
202
196
 
203
197
  const result = formatAgentMessages(payload, undefined, allowedTools);
204
198
 
205
- // Should convert the entire sequence to a single AIMessage
206
- expect(result.messages).toHaveLength(2);
199
+ // Should keep valid tool (calculator) and convert invalid (check_weather) to string
200
+ expect(result.messages).toHaveLength(3);
207
201
  expect(result.messages[0]).toBeInstanceOf(HumanMessage);
208
202
  expect(result.messages[1]).toBeInstanceOf(AIMessage);
203
+ expect(result.messages[2]).toBeInstanceOf(ToolMessage);
209
204
 
210
- // The content should include all parts
211
- expect(typeof result.messages[1].content).toBe('string');
212
- expect(result.messages[1].content).toContain(
213
- 'Let me check the weather first.'
205
+ // The AIMessage should have the calculator tool_call
206
+ expect((result.messages[1] as AIMessage).tool_calls).toHaveLength(1);
207
+ expect((result.messages[1] as AIMessage).tool_calls?.[0].name).toBe(
208
+ 'calculator'
214
209
  );
210
+
211
+ // The content should include invalid tool as string
212
+ expect(result.messages[1].content).toContain('check_weather');
215
213
  expect(result.messages[1].content).toContain('Sunny, 75°F');
216
- expect(result.messages[1].content).toContain(
217
- 'Now let me calculate something for you.'
218
- );
219
- expect(result.messages[1].content).toContain('2');
214
+
215
+ // The ToolMessage should be for calculator
216
+ expect((result.messages[2] as ToolMessage).name).toBe('calculator');
217
+ expect(result.messages[2].content).toBe('2');
220
218
  });
221
219
 
222
220
  it('should update indexTokenCountMap correctly when converting tool messages', () => {
@@ -268,7 +266,7 @@ describe('formatAgentMessages with tools parameter', () => {
268
266
  expect(result.indexTokenCountMap?.[1]).toBe(40);
269
267
  });
270
268
 
271
- it('should heal invalid tool call structure when converting to string', () => {
269
+ it('should convert invalid tool to text content when no other content exists', () => {
272
270
  const payload: TPayload = [
273
271
  {
274
272
  role: 'assistant',
@@ -291,14 +289,19 @@ describe('formatAgentMessages with tools parameter', () => {
291
289
 
292
290
  const result = formatAgentMessages(payload, undefined, allowedTools);
293
291
 
294
- // Should convert to a single AIMessage with string content
292
+ // Should create an AIMessage with the invalid tool converted to text
295
293
  expect(result.messages).toHaveLength(1);
296
294
  expect(result.messages[0]).toBeInstanceOf(AIMessage);
297
295
 
298
- // The content should be a string representation of the tool message
299
- expect(typeof result.messages[0].content).toBe('string');
300
- expect(result.messages[0].content).toContain('check_weather');
301
- expect(result.messages[0].content).toContain('Sunny, 75°F');
296
+ // The AIMessage should have no tool_calls (all were invalid)
297
+ expect((result.messages[0] as AIMessage).tool_calls).toHaveLength(0);
298
+
299
+ // The content should contain the invalid tool info
300
+ const content = result.messages[0].content;
301
+ const contentStr =
302
+ typeof content === 'string' ? content : JSON.stringify(content);
303
+ expect(contentStr).toContain('check_weather');
304
+ expect(contentStr).toContain('Sunny, 75°F');
302
305
  });
303
306
 
304
307
  it('should handle complex sequences with multiple tool calls', () => {
@@ -372,29 +375,45 @@ describe('formatAgentMessages with tools parameter', () => {
372
375
 
373
376
  const result = formatAgentMessages(payload, undefined, allowedTools);
374
377
 
375
- // Should have the user message, search tool sequence (2 messages),
376
- // a combined message for weather and calculator (since one has an invalid tool),
377
- // and final message
378
- expect(result.messages).toHaveLength(5);
378
+ // With selective filtering: valid tools are kept, invalid tools are converted to string
379
+ // 1. HumanMessage
380
+ // 2. AIMessage (search tool_call)
381
+ // 3. ToolMessage (search result)
382
+ // 4. AIMessage (text + invalid weather tool as string, no tool_calls)
383
+ // 5. AIMessage (calculator tool_call)
384
+ // 6. ToolMessage (calculator result)
385
+ // 7. AIMessage (final text)
386
+ expect(result.messages).toHaveLength(7);
379
387
 
380
388
  // Check the types of messages
381
389
  expect(result.messages[0]).toBeInstanceOf(HumanMessage);
382
390
  expect(result.messages[1]).toBeInstanceOf(AIMessage); // Search message
383
391
  expect(result.messages[2]).toBeInstanceOf(ToolMessage); // Search tool response
384
- expect(result.messages[3]).toBeInstanceOf(AIMessage); // Converted weather+calculator message
385
- expect(result.messages[4]).toBeInstanceOf(AIMessage); // Final message
386
-
387
- // Check that the combined message was converted to a string
388
- expect(typeof result.messages[3].content).toBe('string');
389
-
390
- // The format might vary based on the getBufferString implementation
391
- // but we should check that all the key information is present
392
- const content = result.messages[3].content as string;
393
- expect(content).toContain('Now I\'ll check the weather');
394
- expect(content).toContain('Sunny');
395
- expect(content).toContain('75');
396
- expect(content).toContain('Finally');
397
- expect(content).toContain('calculate');
398
- expect(content).toContain('2');
392
+ expect(result.messages[3]).toBeInstanceOf(AIMessage); // Weather message (tool converted to string)
393
+ expect(result.messages[4]).toBeInstanceOf(AIMessage); // Calculator message
394
+ expect(result.messages[5]).toBeInstanceOf(ToolMessage); // Calculator tool response
395
+ expect(result.messages[6]).toBeInstanceOf(AIMessage); // Final message
396
+
397
+ // Check that search tool was kept
398
+ expect((result.messages[1] as AIMessage).tool_calls).toHaveLength(1);
399
+ expect((result.messages[1] as AIMessage).tool_calls?.[0].name).toBe(
400
+ 'search'
401
+ );
402
+
403
+ // Check that weather message has no tool_calls but contains the invalid tool as text
404
+ expect((result.messages[3] as AIMessage).tool_calls).toHaveLength(0);
405
+ const weatherContent = result.messages[3].content;
406
+ const weatherContentStr =
407
+ typeof weatherContent === 'string'
408
+ ? weatherContent
409
+ : JSON.stringify(weatherContent);
410
+ expect(weatherContentStr).toContain('check_weather');
411
+ expect(weatherContentStr).toContain('Sunny');
412
+
413
+ // Check that calculator tool was kept
414
+ expect((result.messages[4] as AIMessage).tool_calls).toHaveLength(1);
415
+ expect((result.messages[4] as AIMessage).tool_calls?.[0].name).toBe(
416
+ 'calculator'
417
+ );
399
418
  });
400
419
  });
@@ -32,7 +32,7 @@ const DEFAULT_TIMEOUT = 60000;
32
32
  // Schema
33
33
  // ============================================================================
34
34
 
35
- const ProgrammaticToolCallingSchema = {
35
+ export const ProgrammaticToolCallingSchema = {
36
36
  type: 'object',
37
37
  properties: {
38
38
  code: {
@@ -80,6 +80,34 @@ Rules:
80
80
  required: ['code'],
81
81
  } as const;
82
82
 
83
+ export const ProgrammaticToolCallingName = Constants.PROGRAMMATIC_TOOL_CALLING;
84
+
85
+ export const ProgrammaticToolCallingDescription = `
86
+ Run tools via Python code. Auto-wrapped in async context—just use \`await\` directly.
87
+
88
+ CRITICAL - STATELESS: Each call is a fresh interpreter. Variables/imports do NOT persist.
89
+ Complete your ENTIRE workflow in ONE call: fetch → process → save. No splitting across calls.
90
+
91
+ Rules:
92
+ - Everything in ONE code block—no state carries over between executions
93
+ - Do NOT define \`async def main()\` or call \`asyncio.run()\`—just write code with await
94
+ - Tools are pre-defined—DO NOT write function definitions
95
+ - Only \`print()\` output returns; tool results are raw dicts/lists/strings
96
+ - Generated files are automatically available in /mnt/data/ for subsequent executions
97
+ - Tool names normalized: hyphens→underscores, keywords get \`_tool\` suffix
98
+
99
+ When to use: loops, conditionals, parallel (\`asyncio.gather\`), multi-step pipelines.
100
+
101
+ Example (complete pipeline):
102
+ data = await query_db(sql="..."); df = process(data); await save_to_sheet(data=df); print("Done")
103
+ `.trim();
104
+
105
+ export const ProgrammaticToolCallingDefinition = {
106
+ name: ProgrammaticToolCallingName,
107
+ description: ProgrammaticToolCallingDescription,
108
+ schema: ProgrammaticToolCallingSchema,
109
+ } as const;
110
+
83
111
  // ============================================================================
84
112
  // Helper Functions
85
113
  // ============================================================================
@@ -33,6 +33,49 @@ config();
33
33
  /** Maximum allowed regex pattern length */
34
34
  const MAX_PATTERN_LENGTH = 200;
35
35
 
36
+ export const ToolSearchToolName = Constants.TOOL_SEARCH;
37
+
38
+ export const ToolSearchToolDescription =
39
+ 'Searches deferred tools using BM25 ranking. Multi-word queries supported. Use mcp_server param to filter by server.';
40
+
41
+ export const ToolSearchToolSchema = {
42
+ type: 'object',
43
+ properties: {
44
+ query: {
45
+ type: 'string',
46
+ maxLength: MAX_PATTERN_LENGTH,
47
+ default: '',
48
+ description:
49
+ 'Search term to find in tool names and descriptions. Case-insensitive substring matching. Optional if mcp_server is provided.',
50
+ },
51
+ fields: {
52
+ type: 'array',
53
+ items: { type: 'string', enum: ['name', 'description', 'parameters'] },
54
+ default: ['name', 'description'],
55
+ description: 'Which fields to search. Default: name and description',
56
+ },
57
+ max_results: {
58
+ type: 'integer',
59
+ minimum: 1,
60
+ maximum: 50,
61
+ default: 10,
62
+ description: 'Maximum number of matching tools to return',
63
+ },
64
+ mcp_server: {
65
+ oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
66
+ description:
67
+ 'Filter to tools from specific MCP server(s). Can be a single server name or array of names. If provided without a query, lists all tools from those servers.',
68
+ },
69
+ },
70
+ required: [],
71
+ } as const;
72
+
73
+ export const ToolSearchToolDefinition = {
74
+ name: ToolSearchToolName,
75
+ description: ToolSearchToolDescription,
76
+ schema: ToolSearchToolSchema,
77
+ } as const;
78
+
36
79
  /** Maximum allowed regex nesting depth */
37
80
  const MAX_REGEX_COMPLEXITY = 5;
38
81
 
@@ -383,4 +383,10 @@ export interface AgentInputs {
383
383
  * ON_TOOL_EXECUTE events instead of invoking tools directly.
384
384
  */
385
385
  toolDefinitions?: LCTool[];
386
+ /**
387
+ * Tool names discovered from previous conversation history.
388
+ * These tools will be pre-marked as discovered so they're included
389
+ * in tool binding without requiring tool_search.
390
+ */
391
+ discoveredTools?: string[];
386
392
  }