@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.
- package/dist/cjs/agents/AgentContext.cjs +8 -2
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/main.cjs +8 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +134 -67
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +29 -0
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +40 -0
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +8 -2
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/main.mjs +2 -2
- package/dist/esm/messages/format.mjs +129 -62
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +26 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +38 -2
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +2 -1
- package/dist/types/tools/ProgrammaticToolCalling.d.ts +43 -0
- package/dist/types/tools/ToolSearch.d.ts +85 -0
- package/dist/types/types/graph.d.ts +6 -0
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +10 -0
- package/src/messages/format.ts +163 -70
- package/src/messages/formatAgentMessages.test.ts +369 -0
- package/src/messages/formatAgentMessages.tools.test.ts +68 -49
- package/src/tools/ProgrammaticToolCalling.ts +29 -1
- package/src/tools/ToolSearch.ts +43 -0
- package/src/types/graph.ts +6 -0
|
@@ -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
|
|
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
|
|
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
|
|
76
|
-
expect(
|
|
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
|
|
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
|
|
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
|
|
117
|
-
expect(
|
|
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
|
|
206
|
-
expect(result.messages).toHaveLength(
|
|
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
|
|
211
|
-
expect(
|
|
212
|
-
expect(result.messages[1].
|
|
213
|
-
'
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
);
|
|
219
|
-
expect(result.messages[
|
|
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
|
|
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
|
|
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
|
|
299
|
-
expect(
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
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); //
|
|
385
|
-
expect(result.messages[4]).toBeInstanceOf(AIMessage); //
|
|
386
|
-
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
expect(
|
|
397
|
-
|
|
398
|
-
|
|
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
|
// ============================================================================
|
package/src/tools/ToolSearch.ts
CHANGED
|
@@ -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
|
|
package/src/types/graph.ts
CHANGED
|
@@ -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
|
}
|