@librechat/agents 3.1.30 → 3.1.32

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.
@@ -1,5 +1,11 @@
1
1
  // src/specs/thinking-prune.test.ts
2
- import { HumanMessage, AIMessage, SystemMessage, BaseMessage, ToolMessage } from '@langchain/core/messages';
2
+ import {
3
+ HumanMessage,
4
+ AIMessage,
5
+ SystemMessage,
6
+ BaseMessage,
7
+ ToolMessage,
8
+ } from '@langchain/core/messages';
3
9
  import type * as t from '@/types';
4
10
  import { createPruneMessages } from '@/messages/prune';
5
11
 
@@ -7,7 +13,10 @@ import { createPruneMessages } from '@/messages/prune';
7
13
  const createTestTokenCounter = (): t.TokenCounter => {
8
14
  return (message: BaseMessage): number => {
9
15
  // Use type assertion to help TypeScript understand the type
10
- const content = message.content as string | Array<t.MessageContentComplex | string> | undefined;
16
+ const content = message.content as
17
+ | string
18
+ | Array<t.MessageContentComplex | string>
19
+ | undefined;
11
20
 
12
21
  // Handle string content
13
22
  if (typeof content === 'string') {
@@ -61,7 +70,8 @@ describe('Prune Messages with Thinking Mode Tests', () => {
61
70
  content: [
62
71
  {
63
72
  type: 'thinking',
64
- thinking: 'The user is asking me to read a file located at `/home/danny/LibreChat/gistfile1.txt` in chunks of 200 lines at a time, mentioning that the file has 5000 lines total. They want me to continue reading through the entire file without stopping.\n\nI\'ll need to use the text editor tool to view the file in chunks of 200 lines each. Since the file has 5000 lines, I\'ll need to view it in 25 chunks (5000 ÷ 200 = 25).\n\nI\'ll need to make multiple calls to the text editor with the `view` command, specifying different line ranges for each call.\n\nLet me plan out the approach:\n1. Start with lines 1-200\n2. Then 201-400\n3. Then 401-600\n4. And so on until I reach 4801-5000\n\nFor each call, I\'ll use the `view` command with the specific line range in the `view_range` parameter. I\'ll continue until I\'ve shown all 5000 lines as requested.',
73
+ thinking:
74
+ 'The user is asking me to read a file located at `/home/danny/LibreChat/gistfile1.txt` in chunks of 200 lines at a time, mentioning that the file has 5000 lines total. They want me to continue reading through the entire file without stopping.\n\nI\'ll need to use the text editor tool to view the file in chunks of 200 lines each. Since the file has 5000 lines, I\'ll need to view it in 25 chunks (5000 ÷ 200 = 25).\n\nI\'ll need to make multiple calls to the text editor with the `view` command, specifying different line ranges for each call.\n\nLet me plan out the approach:\n1. Start with lines 1-200\n2. Then 201-400\n3. Then 401-600\n4. And so on until I reach 4801-5000\n\nFor each call, I\'ll use the `view` command with the specific line range in the `view_range` parameter. I\'ll continue until I\'ve shown all 5000 lines as requested.',
65
75
  },
66
76
  {
67
77
  type: 'text',
@@ -71,7 +81,8 @@ describe('Prune Messages with Thinking Mode Tests', () => {
71
81
  type: 'tool_use',
72
82
  id: 'toolu_01YApWuFsEQCuBFDgYXmcmeZ',
73
83
  name: 'text_editor_mcp_textEditor',
74
- input: '{"command": "view", "path": "/home/danny/LibreChat/gistfile1.txt", "description": "Viewing lines 1-200 of the file", "view_range": [1,200]}',
84
+ input:
85
+ '{"command": "view", "path": "/home/danny/LibreChat/gistfile1.txt", "description": "Viewing lines 1-200 of the file", "view_range": [1,200]}',
75
86
  },
76
87
  ],
77
88
  });
@@ -97,7 +108,8 @@ describe('Prune Messages with Thinking Mode Tests', () => {
97
108
  type: 'tool_use',
98
109
  id: 'toolu_01VnyMQ4CvEd6zLDxxtTd6d4',
99
110
  name: 'text_editor_mcp_textEditor',
100
- input: '{"command": "view", "path": "/home/danny/LibreChat/gistfile1.txt", "description": "Viewing lines 201-400 of the file", "view_range": [201,400]}',
111
+ input:
112
+ '{"command": "view", "path": "/home/danny/LibreChat/gistfile1.txt", "description": "Viewing lines 201-400 of the file", "view_range": [201,400]}',
101
113
  },
102
114
  ],
103
115
  });
@@ -123,7 +135,8 @@ describe('Prune Messages with Thinking Mode Tests', () => {
123
135
  type: 'tool_use',
124
136
  id: 'toolu_01TZKs4nnBc58BYXKz1Mw4fp',
125
137
  name: 'text_editor_mcp_textEditor',
126
- input: '{"command": "view", "path": "/home/danny/LibreChat/gistfile1.txt", "description": "Viewing lines 401-600 of the file", "view_range": [401,600]}',
138
+ input:
139
+ '{"command": "view", "path": "/home/danny/LibreChat/gistfile1.txt", "description": "Viewing lines 401-600 of the file", "view_range": [401,600]}',
127
140
  },
128
141
  ],
129
142
  });
@@ -149,7 +162,8 @@ describe('Prune Messages with Thinking Mode Tests', () => {
149
162
  type: 'tool_use',
150
163
  id: 'toolu_01TZgBacNxjx1QNUpJg9hca5',
151
164
  name: 'text_editor_mcp_textEditor',
152
- input: '{"command": "view", "path": "/home/danny/LibreChat/gistfile1.txt", "description": "Viewing lines 601-800 of the file", "view_range": [601,800]}',
165
+ input:
166
+ '{"command": "view", "path": "/home/danny/LibreChat/gistfile1.txt", "description": "Viewing lines 601-800 of the file", "view_range": [601,800]}',
153
167
  },
154
168
  ],
155
169
  });
@@ -180,7 +194,7 @@ describe('Prune Messages with Thinking Mode Tests', () => {
180
194
  // Create indexTokenCountMap based on the example provided
181
195
  const indexTokenCountMap = {
182
196
  '0': 617, // userMessage
183
- '1': 52, // assistantMessageWithThinking
197
+ '1': 52, // assistantMessageWithThinking
184
198
  '2': 4995, // toolResponseMessage1
185
199
  '3': 307, // assistantMessage2
186
200
  '4': 9359, // toolResponseMessage2
@@ -218,22 +232,34 @@ describe('Prune Messages with Thinking Mode Tests', () => {
218
232
  expect(result.context.length).toBeGreaterThan(0);
219
233
 
220
234
  // Find the first assistant message in the pruned context
221
- const firstAssistantIndex = result.context.findIndex(msg => msg.getType() === 'ai');
235
+ const firstAssistantIndex = result.context.findIndex(
236
+ (msg) => msg.getType() === 'ai'
237
+ );
222
238
  expect(firstAssistantIndex).toBe(0);
223
239
 
224
240
  const firstAssistantMsg = result.context[firstAssistantIndex];
225
241
  expect(Array.isArray(firstAssistantMsg.content)).toBe(true);
226
242
 
227
243
  // Verify that the first assistant message has a thinking block
228
- const hasThinkingBlock = (firstAssistantMsg.content as t.MessageContentComplex[]).some((item: t.MessageContentComplex) =>
229
- typeof item === 'object' && item.type === 'thinking');
244
+ const hasThinkingBlock = (
245
+ firstAssistantMsg.content as t.MessageContentComplex[]
246
+ ).some(
247
+ (item: t.MessageContentComplex) =>
248
+ typeof item === 'object' && item.type === 'thinking'
249
+ );
230
250
  expect(hasThinkingBlock).toBe(true);
231
251
 
232
252
  // Verify that the thinking block is from the original assistant message
233
- const thinkingBlock = (firstAssistantMsg.content as t.MessageContentComplex[]).find((item: t.MessageContentComplex) =>
234
- typeof item === 'object' && item.type === 'thinking');
253
+ const thinkingBlock = (
254
+ firstAssistantMsg.content as t.MessageContentComplex[]
255
+ ).find(
256
+ (item: t.MessageContentComplex) =>
257
+ typeof item === 'object' && item.type === 'thinking'
258
+ );
235
259
  expect(thinkingBlock).toBeDefined();
236
- expect((thinkingBlock as t.ThinkingContentText).thinking).toContain('The user is asking me to read a file');
260
+ expect((thinkingBlock as t.ThinkingContentText).thinking).toContain(
261
+ 'The user is asking me to read a file'
262
+ );
237
263
  });
238
264
 
239
265
  it('should handle token recalculation when inserting thinking blocks', () => {
@@ -451,35 +477,49 @@ describe('Prune Messages with Thinking Mode Tests', () => {
451
477
  const result = pruneMessages({ messages });
452
478
 
453
479
  // Find assistant message with tool call and its corresponding tool message in the pruned context
454
- const assistantIndex = result.context.findIndex(msg =>
455
- msg.getType() === 'ai' &&
456
- Array.isArray(msg.content) &&
457
- msg.content.some(item => typeof item === 'object' && item.type === 'tool_use' && item.id === 'tool123')
480
+ const assistantIndex = result.context.findIndex(
481
+ (msg) =>
482
+ msg.getType() === 'ai' &&
483
+ Array.isArray(msg.content) &&
484
+ msg.content.some(
485
+ (item) =>
486
+ typeof item === 'object' &&
487
+ item.type === 'tool_use' &&
488
+ item.id === 'tool123'
489
+ )
458
490
  );
459
491
 
460
492
  // If the assistant message with tool call is in the context, its corresponding tool message should also be there
461
493
  if (assistantIndex !== -1) {
462
- const toolIndex = result.context.findIndex(msg =>
463
- msg.getType() === 'tool' &&
464
- 'tool_call_id' in msg &&
465
- msg.tool_call_id === 'tool123'
494
+ const toolIndex = result.context.findIndex(
495
+ (msg) =>
496
+ msg.getType() === 'tool' &&
497
+ 'tool_call_id' in msg &&
498
+ msg.tool_call_id === 'tool123'
466
499
  );
467
500
 
468
501
  expect(toolIndex).not.toBe(-1);
469
502
  }
470
503
 
471
504
  // If the tool message is in the context, its corresponding assistant message should also be there
472
- const toolIndex = result.context.findIndex(msg =>
473
- msg.getType() === 'tool' &&
474
- 'tool_call_id' in msg &&
475
- msg.tool_call_id === 'tool123'
505
+ const toolIndex = result.context.findIndex(
506
+ (msg) =>
507
+ msg.getType() === 'tool' &&
508
+ 'tool_call_id' in msg &&
509
+ msg.tool_call_id === 'tool123'
476
510
  );
477
511
 
478
512
  if (toolIndex !== -1) {
479
- const assistantWithToolIndex = result.context.findIndex(msg =>
480
- msg.getType() === 'ai' &&
481
- Array.isArray(msg.content) &&
482
- msg.content.some(item => typeof item === 'object' && item.type === 'tool_use' && item.id === 'tool123')
513
+ const assistantWithToolIndex = result.context.findIndex(
514
+ (msg) =>
515
+ msg.getType() === 'ai' &&
516
+ Array.isArray(msg.content) &&
517
+ msg.content.some(
518
+ (item) =>
519
+ typeof item === 'object' &&
520
+ item.type === 'tool_use' &&
521
+ item.id === 'tool123'
522
+ )
483
523
  );
484
524
 
485
525
  expect(assistantWithToolIndex).not.toBe(-1);
@@ -544,7 +584,9 @@ describe('Prune Messages with Thinking Mode Tests', () => {
544
584
  // Calculate token counts for each message
545
585
  const indexTokenCountMapWithoutSystem: Record<string, number> = {};
546
586
  for (let i = 0; i < messagesWithoutSystem.length; i++) {
547
- indexTokenCountMapWithoutSystem[i] = tokenCounter(messagesWithoutSystem[i]);
587
+ indexTokenCountMapWithoutSystem[i] = tokenCounter(
588
+ messagesWithoutSystem[i]
589
+ );
548
590
  }
549
591
 
550
592
  // Create pruneMessages function with thinking mode enabled
@@ -557,24 +599,33 @@ describe('Prune Messages with Thinking Mode Tests', () => {
557
599
  });
558
600
 
559
601
  // Prune messages
560
- const resultWithoutSystem = pruneMessagesWithoutSystem({ messages: messagesWithoutSystem });
602
+ const resultWithoutSystem = pruneMessagesWithoutSystem({
603
+ messages: messagesWithoutSystem,
604
+ });
561
605
 
562
606
  // Verify that the pruned context contains at least one message
563
607
  expect(resultWithoutSystem.context.length).toBeGreaterThan(0);
564
608
 
565
609
  // Find all assistant messages in the latest sequence (after the last human message)
566
- const lastHumanIndex = resultWithoutSystem.context.map(msg => msg.getType()).lastIndexOf('human');
567
- const assistantMessagesAfterLastHuman = resultWithoutSystem.context.slice(lastHumanIndex + 1)
568
- .filter(msg => msg.getType() === 'ai');
610
+ const lastHumanIndex = resultWithoutSystem.context
611
+ .map((msg) => msg.getType())
612
+ .lastIndexOf('human');
613
+ const assistantMessagesAfterLastHuman = resultWithoutSystem.context
614
+ .slice(lastHumanIndex + 1)
615
+ .filter((msg) => msg.getType() === 'ai');
569
616
 
570
617
  // Verify that at least one assistant message exists in the latest sequence
571
618
  expect(assistantMessagesAfterLastHuman.length).toBeGreaterThan(0);
572
619
 
573
620
  // Verify that at least one of these assistant messages has a thinking block
574
- const hasThinkingBlock = assistantMessagesAfterLastHuman.some(msg => {
621
+ const hasThinkingBlock = assistantMessagesAfterLastHuman.some((msg) => {
575
622
  const content = msg.content as t.MessageContentComplex[];
576
- return Array.isArray(content) && content.some(item =>
577
- typeof item === 'object' && item.type === 'thinking');
623
+ return (
624
+ Array.isArray(content) &&
625
+ content.some(
626
+ (item) => typeof item === 'object' && item.type === 'thinking'
627
+ )
628
+ );
578
629
  });
579
630
  expect(hasThinkingBlock).toBe(true);
580
631
 
@@ -604,26 +655,36 @@ describe('Prune Messages with Thinking Mode Tests', () => {
604
655
  });
605
656
 
606
657
  // Prune messages
607
- const resultWithSystem = pruneMessagesWithSystem({ messages: messagesWithSystem });
658
+ const resultWithSystem = pruneMessagesWithSystem({
659
+ messages: messagesWithSystem,
660
+ });
608
661
 
609
662
  // Verify that the system message remains first
610
663
  expect(resultWithSystem.context.length).toBeGreaterThan(1);
611
664
  expect(resultWithSystem.context[0].getType()).toBe('system');
612
665
 
613
666
  // Find all assistant messages in the latest sequence (after the last human message)
614
- const lastHumanIndexWithSystem = resultWithSystem.context.map(msg => msg.getType()).lastIndexOf('human');
615
- const assistantMessagesAfterLastHumanWithSystem = resultWithSystem.context.slice(lastHumanIndexWithSystem + 1)
616
- .filter(msg => msg.getType() === 'ai');
667
+ const lastHumanIndexWithSystem = resultWithSystem.context
668
+ .map((msg) => msg.getType())
669
+ .lastIndexOf('human');
670
+ const assistantMessagesAfterLastHumanWithSystem = resultWithSystem.context
671
+ .slice(lastHumanIndexWithSystem + 1)
672
+ .filter((msg) => msg.getType() === 'ai');
617
673
 
618
674
  // Verify that at least one assistant message exists in the latest sequence
619
675
  expect(assistantMessagesAfterLastHumanWithSystem.length).toBeGreaterThan(0);
620
676
 
621
677
  // Verify that at least one of these assistant messages has a thinking block
622
- const hasThinkingBlockWithSystem = assistantMessagesAfterLastHumanWithSystem.some(msg => {
623
- const content = msg.content as t.MessageContentComplex[];
624
- return Array.isArray(content) && content.some(item =>
625
- typeof item === 'object' && item.type === 'thinking');
626
- });
678
+ const hasThinkingBlockWithSystem =
679
+ assistantMessagesAfterLastHumanWithSystem.some((msg) => {
680
+ const content = msg.content as t.MessageContentComplex[];
681
+ return (
682
+ Array.isArray(content) &&
683
+ content.some(
684
+ (item) => typeof item === 'object' && item.type === 'thinking'
685
+ )
686
+ );
687
+ });
627
688
  expect(hasThinkingBlockWithSystem).toBe(true);
628
689
  });
629
690
 
@@ -686,18 +747,78 @@ describe('Prune Messages with Thinking Mode Tests', () => {
686
747
  const result = pruneMessages({ messages });
687
748
 
688
749
  // Find the first assistant message in the pruned context
689
- const firstAssistantIndex = result.context.findIndex(msg => msg.getType() === 'ai');
750
+ const firstAssistantIndex = result.context.findIndex(
751
+ (msg) => msg.getType() === 'ai'
752
+ );
690
753
  expect(firstAssistantIndex).not.toBe(-1);
691
754
 
692
755
  const firstAssistantMsg = result.context[firstAssistantIndex];
693
756
  expect(Array.isArray(firstAssistantMsg.content)).toBe(true);
694
757
 
695
758
  // Verify that the first assistant message has a thinking block
696
- const thinkingBlock = (firstAssistantMsg.content as t.MessageContentComplex[]).find(item =>
697
- typeof item === 'object' && item.type === 'thinking');
759
+ const thinkingBlock = (
760
+ firstAssistantMsg.content as t.MessageContentComplex[]
761
+ ).find((item) => typeof item === 'object' && item.type === 'thinking');
698
762
  expect(thinkingBlock).toBeDefined();
699
763
 
700
764
  // Verify that it's the newer thinking block
701
- expect((thinkingBlock as t.ThinkingContentText).thinking).toContain('newer thinking block');
765
+ expect((thinkingBlock as t.ThinkingContentText).thinking).toContain(
766
+ 'newer thinking block'
767
+ );
768
+ });
769
+
770
+ it('should throw descriptive error when aggressive pruning removes all AI messages', () => {
771
+ const tokenCounter = createTestTokenCounter();
772
+
773
+ const assistantMessageWithThinking = new AIMessage({
774
+ content: [
775
+ {
776
+ type: 'thinking',
777
+ thinking: 'This is a thinking block that will be pruned',
778
+ },
779
+ {
780
+ type: 'text',
781
+ text: 'Response with thinking',
782
+ },
783
+ {
784
+ type: 'tool_use',
785
+ id: 'tool123',
786
+ name: 'large_tool',
787
+ input: '{"query": "test"}',
788
+ },
789
+ ],
790
+ });
791
+
792
+ const largeToolResponse = new ToolMessage({
793
+ content: 'A'.repeat(10000),
794
+ tool_call_id: 'tool123',
795
+ name: 'large_tool',
796
+ });
797
+
798
+ const messages = [
799
+ new SystemMessage('System instruction'),
800
+ new HumanMessage('Hello'),
801
+ assistantMessageWithThinking,
802
+ largeToolResponse,
803
+ ];
804
+
805
+ const indexTokenCountMap: Record<string, number> = {
806
+ '0': 17,
807
+ '1': 5,
808
+ '2': 100,
809
+ '3': 10000,
810
+ };
811
+
812
+ const pruneMessages = createPruneMessages({
813
+ maxTokens: 50,
814
+ startIndex: 0,
815
+ tokenCounter,
816
+ indexTokenCountMap: { ...indexTokenCountMap },
817
+ thinkingEnabled: true,
818
+ });
819
+
820
+ expect(() => pruneMessages({ messages })).toThrow(
821
+ /Context window exceeded/
822
+ );
702
823
  });
703
824
  });
@@ -29,25 +29,25 @@ const DEFAULT_MAX_ROUND_TRIPS = 20;
29
29
  const DEFAULT_TIMEOUT = 60000;
30
30
 
31
31
  // ============================================================================
32
- // Schema
32
+ // Description Components (Single Source of Truth)
33
33
  // ============================================================================
34
34
 
35
- export const ProgrammaticToolCallingSchema = {
36
- type: 'object',
37
- properties: {
38
- code: {
39
- type: 'string',
40
- minLength: 1,
41
- description: `Python code that calls tools programmatically. Tools are available as async functions.
42
-
43
- CRITICAL - STATELESS EXECUTION:
35
+ const STATELESS_WARNING = `CRITICAL - STATELESS EXECUTION:
44
36
  Each call is a fresh Python interpreter. Variables, imports, and data do NOT persist between calls.
45
37
  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.
38
+ DO NOT split work across multiple calls expecting to reuse variables.`;
47
39
 
48
- Your code is auto-wrapped in async context. Just write logic with await—no boilerplate needed.
40
+ const CORE_RULES = `Rules:
41
+ - EVERYTHING in one call—no state persists between executions
42
+ - Just write code with await—auto-wrapped in async context
43
+ - DO NOT define async def main() or call asyncio.run()
44
+ - Tools are pre-defined—DO NOT write function definitions
45
+ - Only print() output returns to the model`;
46
+
47
+ const ADDITIONAL_RULES = `- Generated files are automatically available in /mnt/data/ for subsequent executions
48
+ - Tool names normalized: hyphens→underscores, keywords get \`_tool\` suffix`;
49
49
 
50
- Example (Complete workflow in one call):
50
+ const EXAMPLES = `Example (Complete workflow in one call):
51
51
  # Query data
52
52
  data = await query_database(sql="SELECT * FROM users")
53
53
  # Process it
@@ -59,14 +59,29 @@ Example (Complete workflow in one call):
59
59
 
60
60
  Example (Parallel calls):
61
61
  sf, ny = await asyncio.gather(get_weather(city="SF"), get_weather(city="NY"))
62
- print(f"SF: {sf}, NY: {ny}")
62
+ print(f"SF: {sf}, NY: {ny}")`;
63
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`,
64
+ // ============================================================================
65
+ // Schema
66
+ // ============================================================================
67
+
68
+ const CODE_PARAM_DESCRIPTION = `Python code that calls tools programmatically. Tools are available as async functions.
69
+
70
+ ${STATELESS_WARNING}
71
+
72
+ Your code is auto-wrapped in async context. Just write logic with await—no boilerplate needed.
73
+
74
+ ${EXAMPLES}
75
+
76
+ ${CORE_RULES}`;
77
+
78
+ export const ProgrammaticToolCallingSchema = {
79
+ type: 'object',
80
+ properties: {
81
+ code: {
82
+ type: 'string',
83
+ minLength: 1,
84
+ description: CODE_PARAM_DESCRIPTION,
70
85
  },
71
86
  timeout: {
72
87
  type: 'integer',
@@ -85,21 +100,14 @@ export const ProgrammaticToolCallingName = Constants.PROGRAMMATIC_TOOL_CALLING;
85
100
  export const ProgrammaticToolCallingDescription = `
86
101
  Run tools via Python code. Auto-wrapped in async context—just use \`await\` directly.
87
102
 
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.
103
+ ${STATELESS_WARNING}
90
104
 
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
105
+ ${CORE_RULES}
106
+ ${ADDITIONAL_RULES}
98
107
 
99
108
  When to use: loops, conditionals, parallel (\`asyncio.gather\`), multi-step pipelines.
100
109
 
101
- Example (complete pipeline):
102
- data = await query_db(sql="..."); df = process(data); await save_to_sheet(data=df); print("Done")
110
+ ${EXAMPLES}
103
111
  `.trim();
104
112
 
105
113
  export const ProgrammaticToolCallingDefinition = {
@@ -624,26 +632,6 @@ export function createProgrammaticToolCallingTool(
624
632
  const debug = initParams.debug ?? process.env.PTC_DEBUG === 'true';
625
633
  const EXEC_ENDPOINT = `${baseUrl}/exec/programmatic`;
626
634
 
627
- const description = `
628
- Run tools via Python code. Auto-wrapped in async context—just use \`await\` directly.
629
-
630
- CRITICAL - STATELESS: Each call is a fresh interpreter. Variables/imports do NOT persist.
631
- Complete your ENTIRE workflow in ONE call: fetch → process → save. No splitting across calls.
632
-
633
- Rules:
634
- - Everything in ONE code block—no state carries over between executions
635
- - Do NOT define \`async def main()\` or call \`asyncio.run()\`—just write code with await
636
- - Tools are pre-defined—DO NOT write function definitions
637
- - Only \`print()\` output returns; tool results are raw dicts/lists/strings
638
- - Generated files are automatically available in /mnt/data/ for subsequent executions
639
- - Tool names normalized: hyphens→underscores, keywords get \`_tool\` suffix
640
-
641
- When to use: loops, conditionals, parallel (\`asyncio.gather\`), multi-step pipelines.
642
-
643
- Example (complete pipeline):
644
- data = await query_db(sql="..."); df = process(data); await save_to_sheet(data=df); print("Done")
645
- `.trim();
646
-
647
635
  return tool(
648
636
  async (rawParams, config) => {
649
637
  const params = rawParams as { code: string; timeout?: number };
@@ -777,7 +765,7 @@ Example (complete pipeline):
777
765
  },
778
766
  {
779
767
  name: Constants.PROGRAMMATIC_TOOL_CALLING,
780
- description,
768
+ description: ProgrammaticToolCallingDescription,
781
769
  schema: ProgrammaticToolCallingSchema,
782
770
  responseFormat: Constants.CONTENT_AND_ARTIFACT,
783
771
  }
@@ -585,17 +585,16 @@ export function toolsCondition<T extends string>(
585
585
  toolNode: T,
586
586
  invokedToolIds?: Set<string>
587
587
  ): T | typeof END {
588
- const message: AIMessage = Array.isArray(state)
589
- ? state[state.length - 1]
590
- : state.messages[state.messages.length - 1];
588
+ const messages = Array.isArray(state) ? state : state.messages;
589
+ const message = messages[messages.length - 1] as AIMessage | undefined;
591
590
 
592
591
  if (
592
+ message &&
593
593
  'tool_calls' in message &&
594
594
  (message.tool_calls?.length ?? 0) > 0 &&
595
595
  !areToolCallsInvoked(message, invokedToolIds)
596
596
  ) {
597
597
  return toolNode;
598
- } else {
599
- return END;
600
598
  }
599
+ return END;
601
600
  }