@librechat/agents 3.0.42 → 3.0.44

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 (51) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +134 -70
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +1 -1
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +7 -13
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +6 -0
  8. package/dist/cjs/main.cjs.map +1 -1
  9. package/dist/cjs/messages/tools.cjs +85 -0
  10. package/dist/cjs/messages/tools.cjs.map +1 -0
  11. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +143 -34
  12. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +30 -13
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/esm/agents/AgentContext.mjs +134 -70
  16. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  17. package/dist/esm/common/enum.mjs +1 -1
  18. package/dist/esm/common/enum.mjs.map +1 -1
  19. package/dist/esm/graphs/Graph.mjs +8 -14
  20. package/dist/esm/graphs/Graph.mjs.map +1 -1
  21. package/dist/esm/main.mjs +2 -1
  22. package/dist/esm/main.mjs.map +1 -1
  23. package/dist/esm/messages/tools.mjs +82 -0
  24. package/dist/esm/messages/tools.mjs.map +1 -0
  25. package/dist/esm/tools/ProgrammaticToolCalling.mjs +141 -35
  26. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  27. package/dist/esm/tools/ToolNode.mjs +30 -13
  28. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  29. package/dist/types/agents/AgentContext.d.ts +37 -17
  30. package/dist/types/common/enum.d.ts +1 -1
  31. package/dist/types/messages/index.d.ts +1 -0
  32. package/dist/types/messages/tools.d.ts +17 -0
  33. package/dist/types/tools/ProgrammaticToolCalling.d.ts +28 -23
  34. package/dist/types/tools/ToolNode.d.ts +9 -7
  35. package/dist/types/types/tools.d.ts +7 -5
  36. package/package.json +1 -1
  37. package/src/agents/AgentContext.ts +157 -85
  38. package/src/agents/__tests__/AgentContext.test.ts +805 -0
  39. package/src/common/enum.ts +1 -1
  40. package/src/graphs/Graph.ts +9 -21
  41. package/src/messages/__tests__/tools.test.ts +473 -0
  42. package/src/messages/index.ts +1 -0
  43. package/src/messages/tools.ts +99 -0
  44. package/src/scripts/code_exec_ptc.ts +78 -21
  45. package/src/scripts/programmatic_exec.ts +3 -3
  46. package/src/scripts/programmatic_exec_agent.ts +4 -4
  47. package/src/tools/ProgrammaticToolCalling.ts +178 -43
  48. package/src/tools/ToolNode.ts +33 -14
  49. package/src/tools/__tests__/ProgrammaticToolCalling.integration.test.ts +9 -9
  50. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +245 -5
  51. package/src/types/tools.ts +5 -5
@@ -18,25 +18,79 @@ import type { RunnableConfig } from '@langchain/core/runnables';
18
18
  import type * as t from '@/types';
19
19
  import { ChatModelStreamHandler, createContentAggregator } from '@/stream';
20
20
  import {
21
- ToolEndHandler,
22
- ModelEndHandler,
21
+ // createProgrammaticToolRegistry,
22
+ createGetTeamMembersTool,
23
+ createGetExpensesTool,
24
+ createGetWeatherTool,
25
+ } from '@/test/mockTools';
26
+ import {
23
27
  createMetadataAggregator,
28
+ ModelEndHandler,
29
+ ToolEndHandler,
24
30
  } from '@/events';
31
+ import { createProgrammaticToolCallingTool } from '@/tools/ProgrammaticToolCalling';
32
+ import { createCodeExecutionTool } from '@/tools/CodeExecutor';
25
33
  import { getLLMConfig } from '@/utils/llmConfig';
26
34
  import { getArgs } from '@/scripts/args';
27
35
  import { GraphEvents } from '@/common';
28
36
  import { Run } from '@/run';
29
- import { createCodeExecutionTool } from '@/tools/CodeExecutor';
30
- import { createProgrammaticToolCallingTool } from '@/tools/ProgrammaticToolCalling';
31
- import {
32
- createGetTeamMembersTool,
33
- createGetExpensesTool,
34
- createGetWeatherTool,
35
- createProgrammaticToolRegistry,
36
- } from '@/test/mockTools';
37
37
 
38
38
  const conversationHistory: BaseMessage[] = [];
39
39
 
40
+ /**
41
+ * Creates a tool registry where ALL business tools are code_execution ONLY.
42
+ * This forces the LLM to use PTC - it cannot call these tools directly.
43
+ */
44
+ function createPTCOnlyToolRegistry(): t.LCToolRegistry {
45
+ const toolDefs: t.LCTool[] = [
46
+ {
47
+ name: 'get_team_members',
48
+ description:
49
+ 'Get list of team members. Returns array of objects with id, name, and department fields.',
50
+ parameters: {
51
+ type: 'object',
52
+ properties: {},
53
+ required: [],
54
+ },
55
+ allowed_callers: ['code_execution'], // PTC ONLY - not direct
56
+ },
57
+ {
58
+ name: 'get_expenses',
59
+ description:
60
+ 'Get expense records for a user. Returns array of objects with amount and category fields.',
61
+ parameters: {
62
+ type: 'object',
63
+ properties: {
64
+ user_id: {
65
+ type: 'string',
66
+ description: 'The user ID to fetch expenses for',
67
+ },
68
+ },
69
+ required: ['user_id'],
70
+ },
71
+ allowed_callers: ['code_execution'], // PTC ONLY - not direct
72
+ },
73
+ {
74
+ name: 'get_weather',
75
+ description:
76
+ 'Get current weather for a city. Returns object with temperature (number) and condition (string) fields.',
77
+ parameters: {
78
+ type: 'object',
79
+ properties: {
80
+ city: {
81
+ type: 'string',
82
+ description: 'City name',
83
+ },
84
+ },
85
+ required: ['city'],
86
+ },
87
+ allowed_callers: ['code_execution'], // PTC ONLY - not direct (changed from ['direct', 'code_execution'])
88
+ },
89
+ ];
90
+
91
+ return new Map(toolDefs.map((def) => [def.name, def]));
92
+ }
93
+
40
94
  async function testProgrammaticToolCalling(): Promise<void> {
41
95
  const { userName, location, provider, currentDate } = await getArgs();
42
96
  const { contentParts, aggregateContent } = createContentAggregator();
@@ -111,10 +165,10 @@ async function testProgrammaticToolCalling(): Promise<void> {
111
165
  const allTools = [teamTool, expensesTool, weatherTool, codeExecTool, ptcTool];
112
166
  const toolMap = new Map(allTools.map((t) => [t.name, t]));
113
167
 
114
- // Create tool registry with allowed_callers configuration
115
- // Only includes business logic tools (not special tools)
116
- // Special tools (execute_code, PTC) are always bound directly to LLM
117
- const toolRegistry = createProgrammaticToolRegistry();
168
+ // Create tool registry where ALL business tools are PTC-only
169
+ // This means the LLM CANNOT call get_team_members, get_expenses, get_weather directly
170
+ // It MUST use run_tools_with_code to invoke them
171
+ const toolRegistry = createPTCOnlyToolRegistry();
118
172
 
119
173
  console.log('\n' + '='.repeat(70));
120
174
  console.log('Tool Configuration Summary:');
@@ -150,11 +204,14 @@ async function testProgrammaticToolCalling(): Promise<void> {
150
204
  toolMap,
151
205
  toolRegistry,
152
206
  instructions:
153
- 'You are a friendly AI assistant with advanced coding capabilities. ' +
154
- 'You have access to team and expense management tools, but ONLY through programmatic code execution. ' +
155
- 'When you need to analyze expenses or process team data, use the programmatic_code_execution tool ' +
156
- 'to write Python code that calls get_team_members(), get_expenses(), and get_weather() functions. ' +
157
- 'These functions are async - use await. Use asyncio.gather() for parallel execution.',
207
+ 'You are a friendly AI assistant with advanced coding capabilities.\n\n' +
208
+ 'IMPORTANT: The tools get_team_members(), get_expenses(), and get_weather() are NOT available ' +
209
+ 'for direct function calling. You MUST use the run_tools_with_code tool to invoke them.\n\n' +
210
+ 'When you need to use these tools, write Python code using run_tools_with_code that calls:\n' +
211
+ '- await get_team_members() - returns list of team members\n' +
212
+ '- await get_expenses(user_id="...") - returns expenses for a user\n' +
213
+ '- await get_weather(city="...") - returns weather data\n\n' +
214
+ 'Use asyncio.gather() for parallel execution when calling multiple tools.',
158
215
  additional_instructions: `The user's name is ${userName} and they are located in ${location}. Today is ${currentDate}.`,
159
216
  },
160
217
  ],
@@ -187,7 +244,7 @@ async function testProgrammaticToolCalling(): Promise<void> {
187
244
  4. Identify anyone who spent more than $500
188
245
  5. Show me a summary report
189
246
 
190
- IMPORTANT: Use the programmatic_code_execution tool to do this efficiently.
247
+ IMPORTANT: Use the run_tools_with_code tool to do this efficiently.
191
248
  Don't call each tool separately - write Python code that orchestrates all the calls!`;
192
249
 
193
250
  conversationHistory.push(new HumanMessage(userMessage1));
@@ -217,7 +274,7 @@ Don't call each tool separately - write Python code that orchestrates all the ca
217
274
  3. For the Engineering team members only, calculate their travel expenses
218
275
  4. Show me the results
219
276
 
220
- Again, use programmatic_code_execution for maximum efficiency. Use asyncio.gather()
277
+ Again, use run_tools_with_code for maximum efficiency. Use asyncio.gather()
221
278
  to check both cities' weather at the same time!`;
222
279
 
223
280
  conversationHistory.push(new HumanMessage(userMessage2));
@@ -353,8 +353,8 @@ print(f"Temperature difference: {difference}°F")
353
353
  console.log('='.repeat(70));
354
354
  console.log(
355
355
  '\nWhen PTC is invoked through ToolNode in a real agent:\n' +
356
- '- ToolNode detects call.name === "programmatic_code_execution"\n' +
357
- '- ToolNode injects: { ...invokeParams, toolMap, programmaticToolDefs }\n' +
356
+ '- ToolNode detects call.name === "run_tools_with_code"\n' +
357
+ '- ToolNode injects: { ...invokeParams, toolMap, toolDefs }\n' +
358
358
  '- PTC tool extracts these from params (not from config)\n' +
359
359
  '- No explicit tools parameter needed in schema\n\n' +
360
360
  'This test demonstrates param injection with explicit tools:\n'
@@ -364,7 +364,7 @@ print(f"Temperature difference: {difference}°F")
364
364
  ptcTool,
365
365
  'Runtime injection with explicit tools',
366
366
  `
367
- # ToolNode would inject toolMap+programmaticToolDefs
367
+ # ToolNode would inject toolMap+toolDefs
368
368
  # For this test, we pass tools explicitly (same effect)
369
369
  team = await get_team_members()
370
370
  print(f"Team size: {len(team)}")
@@ -40,7 +40,7 @@ import {
40
40
 
41
41
  /**
42
42
  * Tool registry only needs business logic tools that require filtering.
43
- * Special tools (execute_code, programmatic_code_execution, tool_search_regex)
43
+ * Special tools (execute_code, run_tools_with_code, tool_search_regex)
44
44
  * are always bound directly to the LLM and don't need registry entries.
45
45
  */
46
46
  function createAgentToolRegistry(): t.LCToolRegistry {
@@ -133,7 +133,7 @@ async function main(): Promise<void> {
133
133
  instructions:
134
134
  'You are an AI assistant with access to programmatic tool calling. ' +
135
135
  'When you need to process multiple items or perform complex data operations, ' +
136
- 'use the programmatic_code_execution tool to write Python code that calls tools efficiently.',
136
+ 'use the run_tools_with_code tool to write Python code that calls tools efficiently.',
137
137
  },
138
138
  ],
139
139
  },
@@ -164,7 +164,7 @@ async function main(): Promise<void> {
164
164
  4. Identify anyone who spent more than $300
165
165
  5. Show me the results in a nice format
166
166
 
167
- Use the programmatic_code_execution tool to do this efficiently - don't call each tool separately!`
167
+ Use the run_tools_with_code tool to do this efficiently - don't call each tool separately!`
168
168
  );
169
169
 
170
170
  conversationHistory.push(userMessage);
@@ -199,7 +199,7 @@ Use the programmatic_code_execution tool to do this efficiently - don't call eac
199
199
  console.log('='.repeat(70));
200
200
  console.log('\nKey observations:');
201
201
  console.log(
202
- '1. LLM only sees tools with allowed_callers including "direct" (get_weather, execute_code, programmatic_code_execution, tool_search_regex)'
202
+ '1. LLM only sees tools with allowed_callers including "direct" (get_weather, execute_code, run_tools_with_code, tool_search_regex)'
203
203
  );
204
204
  console.log(
205
205
  '2. When PTC is invoked, ToolNode automatically injects programmatic tools (get_team_members, get_expenses, get_weather)'
@@ -5,6 +5,7 @@ import fetch, { RequestInit } from 'node-fetch';
5
5
  import { HttpsProxyAgent } from 'https-proxy-agent';
6
6
  import { getEnvironmentVariable } from '@langchain/core/utils/env';
7
7
  import { tool, DynamicStructuredTool } from '@langchain/core/tools';
8
+ import type { ToolCall } from '@langchain/core/messages/tool';
8
9
  import type * as t from '@/types';
9
10
  import { imageExtRegex, getCodeBaseURL } from './CodeExecutor';
10
11
  import { EnvVar, Constants } from '@/common';
@@ -74,18 +75,6 @@ Requirements:
74
75
  - Only print() output flows back to the context window
75
76
  - Tool results from programmatic calls do NOT consume context tokens`
76
77
  ),
77
- tools: z
78
- .array(
79
- z.object({
80
- name: z.string(),
81
- description: z.string().optional(),
82
- parameters: z.any(), // JsonSchemaType
83
- })
84
- )
85
- .optional()
86
- .describe(
87
- 'Optional array of tool definitions that can be called from the code. If not provided, uses programmatic tools configured in the agent context. Tool names must match tools available in the toolMap.'
88
- ),
89
78
  session_id: z
90
79
  .string()
91
80
  .optional()
@@ -108,6 +97,143 @@ Requirements:
108
97
  // Helper Functions
109
98
  // ============================================================================
110
99
 
100
+ /** Python reserved keywords that get `_tool` suffix in Code API */
101
+ const PYTHON_KEYWORDS = new Set([
102
+ 'False',
103
+ 'None',
104
+ 'True',
105
+ 'and',
106
+ 'as',
107
+ 'assert',
108
+ 'async',
109
+ 'await',
110
+ 'break',
111
+ 'class',
112
+ 'continue',
113
+ 'def',
114
+ 'del',
115
+ 'elif',
116
+ 'else',
117
+ 'except',
118
+ 'finally',
119
+ 'for',
120
+ 'from',
121
+ 'global',
122
+ 'if',
123
+ 'import',
124
+ 'in',
125
+ 'is',
126
+ 'lambda',
127
+ 'nonlocal',
128
+ 'not',
129
+ 'or',
130
+ 'pass',
131
+ 'raise',
132
+ 'return',
133
+ 'try',
134
+ 'while',
135
+ 'with',
136
+ 'yield',
137
+ ]);
138
+
139
+ /**
140
+ * Normalizes a tool name to Python identifier format.
141
+ * Must match the Code API's `normalizePythonFunctionName` exactly:
142
+ * 1. Replace hyphens and spaces with underscores
143
+ * 2. Remove any other invalid characters
144
+ * 3. Prefix with underscore if starts with number
145
+ * 4. Append `_tool` if it's a Python keyword
146
+ * @param name - The tool name to normalize
147
+ * @returns Normalized Python-safe identifier
148
+ */
149
+ export function normalizeToPythonIdentifier(name: string): string {
150
+ let normalized = name.replace(/[-\s]/g, '_');
151
+
152
+ normalized = normalized.replace(/[^a-zA-Z0-9_]/g, '');
153
+
154
+ if (/^[0-9]/.test(normalized)) {
155
+ normalized = '_' + normalized;
156
+ }
157
+
158
+ if (PYTHON_KEYWORDS.has(normalized)) {
159
+ normalized = normalized + '_tool';
160
+ }
161
+
162
+ return normalized;
163
+ }
164
+
165
+ /**
166
+ * Extracts tool names that are actually called in the Python code.
167
+ * Handles hyphen/underscore conversion since Python identifiers use underscores.
168
+ * @param code - The Python code to analyze
169
+ * @param toolNameMap - Map from normalized Python name to original tool name
170
+ * @returns Set of original tool names found in the code
171
+ */
172
+ export function extractUsedToolNames(
173
+ code: string,
174
+ toolNameMap: Map<string, string>
175
+ ): Set<string> {
176
+ const usedTools = new Set<string>();
177
+
178
+ for (const [pythonName, originalName] of toolNameMap) {
179
+ const escapedName = pythonName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
180
+ const pattern = new RegExp(`\\b${escapedName}\\s*\\(`, 'g');
181
+
182
+ if (pattern.test(code)) {
183
+ usedTools.add(originalName);
184
+ }
185
+ }
186
+
187
+ return usedTools;
188
+ }
189
+
190
+ /**
191
+ * Filters tool definitions to only include tools actually used in the code.
192
+ * Handles the hyphen-to-underscore conversion for Python compatibility.
193
+ * @param toolDefs - All available tool definitions
194
+ * @param code - The Python code to analyze
195
+ * @param debug - Enable debug logging
196
+ * @returns Filtered array of tool definitions
197
+ */
198
+ export function filterToolsByUsage(
199
+ toolDefs: t.LCTool[],
200
+ code: string,
201
+ debug = false
202
+ ): t.LCTool[] {
203
+ const toolNameMap = new Map<string, string>();
204
+ for (const tool of toolDefs) {
205
+ const pythonName = normalizeToPythonIdentifier(tool.name);
206
+ toolNameMap.set(pythonName, tool.name);
207
+ }
208
+
209
+ const usedToolNames = extractUsedToolNames(code, toolNameMap);
210
+
211
+ if (debug) {
212
+ // eslint-disable-next-line no-console
213
+ console.log(
214
+ `[PTC Debug] Tool filtering: found ${usedToolNames.size}/${toolDefs.length} tools in code`
215
+ );
216
+ if (usedToolNames.size > 0) {
217
+ // eslint-disable-next-line no-console
218
+ console.log(
219
+ `[PTC Debug] Matched tools: ${Array.from(usedToolNames).join(', ')}`
220
+ );
221
+ }
222
+ }
223
+
224
+ if (usedToolNames.size === 0) {
225
+ if (debug) {
226
+ // eslint-disable-next-line no-console
227
+ console.log(
228
+ '[PTC Debug] No tools detected in code - sending all tools as fallback'
229
+ );
230
+ }
231
+ return toolDefs;
232
+ }
233
+
234
+ return toolDefs.filter((tool) => usedToolNames.has(tool.name));
235
+ }
236
+
111
237
  /**
112
238
  * Makes an HTTP request to the Code API.
113
239
  * @param endpoint - The API endpoint URL
@@ -284,38 +410,38 @@ export function createProgrammaticToolCallingTool(
284
410
  const baseUrl = initParams.baseUrl ?? getCodeBaseURL();
285
411
  const maxRoundTrips = initParams.maxRoundTrips ?? DEFAULT_MAX_ROUND_TRIPS;
286
412
  const proxy = initParams.proxy ?? process.env.PROXY;
413
+ const debug = initParams.debug ?? process.env.PTC_DEBUG === 'true';
287
414
  const EXEC_ENDPOINT = `${baseUrl}/exec/programmatic`;
288
415
 
289
416
  const description = `
290
- Executes Python code with programmatic tool calling. Tools are automatically generated as async Python functions from the tool definitions - DO NOT define them in your code.
417
+ Run tools by writing Python code. Tools are available as async functions - just call them with await.
418
+
419
+ This is different from execute_code: here you can call your tools (like get_weather, get_expenses, etc.) directly in Python code.
291
420
 
292
421
  Usage:
293
- - Write Python code that calls tools using await: result = await get_data()
294
- - Tools are pre-defined as async functions - just call them
295
- - Use asyncio.gather() for parallel execution (single round-trip!)
296
- - Only print() output flows through the context window
297
- - Tool results from programmatic calls do NOT consume context tokens
298
-
299
- When to use:
300
- - Processing multiple records with tool calls (10+ items)
301
- - Loops, conditionals, or aggregation based on tool results
302
- - Any workflow requiring 3+ sequential tool calls
303
- - Parallel execution of independent tool calls
304
- - Filtering/summarizing large data before returning to context
305
-
306
- Patterns:
307
- - Simple: result = await get_data()
308
- - Loop: for item in items: data = await fetch(item)
309
- - Parallel: results = await asyncio.gather(t1(), t2(), t3())
310
- - Conditional: if x: await tool_a() else: await tool_b()
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
425
+
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"))
430
+
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
311
436
  `.trim();
312
437
 
313
438
  return tool<typeof ProgrammaticToolCallingSchema>(
314
439
  async (params, config) => {
315
- const { code, tools, session_id, timeout = DEFAULT_TIMEOUT } = params;
440
+ const { code, session_id, timeout = DEFAULT_TIMEOUT } = params;
316
441
 
317
442
  // Extra params injected by ToolNode (follows web_search pattern)
318
- const { toolMap, programmaticToolDefs } = config.toolCall ?? {};
443
+ const { toolMap, toolDefs } = (config.toolCall ?? {}) as ToolCall &
444
+ Partial<t.ProgrammaticCache>;
319
445
 
320
446
  if (toolMap == null || toolMap.size === 0) {
321
447
  throw new Error(
@@ -324,13 +450,10 @@ Patterns:
324
450
  );
325
451
  }
326
452
 
327
- // Use provided tools or fall back to programmaticToolDefs from ToolNode
328
- const effectiveTools = tools ?? programmaticToolDefs;
329
-
330
- if (effectiveTools == null || effectiveTools.length === 0) {
453
+ if (toolDefs == null || toolDefs.length === 0) {
331
454
  throw new Error(
332
455
  'No tool definitions provided. ' +
333
- 'Either pass tools in the input or ensure ToolNode injects programmaticToolDefs.'
456
+ 'Either pass tools in the input or ensure ToolNode injects toolDefs.'
334
457
  );
335
458
  }
336
459
 
@@ -338,9 +461,19 @@ Patterns:
338
461
 
339
462
  try {
340
463
  // ====================================================================
341
- // Phase 1: Initial request
464
+ // Phase 1: Filter tools and make initial request
342
465
  // ====================================================================
343
466
 
467
+ const effectiveTools = filterToolsByUsage(toolDefs, code, debug);
468
+
469
+ if (debug) {
470
+ // eslint-disable-next-line no-console
471
+ console.log(
472
+ `[PTC Debug] Sending ${effectiveTools.length} tools to API ` +
473
+ `(filtered from ${toolDefs.length})`
474
+ );
475
+ }
476
+
344
477
  let response = await makeRequest(
345
478
  EXEC_ENDPOINT,
346
479
  apiKey,
@@ -368,10 +501,12 @@ Patterns:
368
501
  );
369
502
  }
370
503
 
371
- // eslint-disable-next-line no-console
372
- console.log(
373
- `[PTC] Round trip ${roundTrip}: ${response.tool_calls?.length ?? 0} tool(s) to execute`
374
- );
504
+ if (debug) {
505
+ // eslint-disable-next-line no-console
506
+ console.log(
507
+ `[PTC Debug] Round trip ${roundTrip}: ${response.tool_calls?.length ?? 0} tool(s) to execute`
508
+ );
509
+ }
375
510
 
376
511
  const toolResults = await executeTools(
377
512
  response.tool_calls ?? [],
@@ -31,7 +31,6 @@ function isSend(value: unknown): value is Send {
31
31
 
32
32
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
33
  export class ToolNode<T = any> extends RunnableCallable<T, T> {
34
- tools: t.GenericTool[];
35
34
  private toolMap: Map<string, StructuredToolInterface | RunnableToolLike>;
36
35
  private loadRuntimeTools?: t.ToolRefGenerator;
37
36
  handleToolErrors = true;
@@ -39,12 +38,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
39
38
  toolCallStepIds?: Map<string, string>;
40
39
  errorHandler?: t.ToolNodeConstructorParams['errorHandler'];
41
40
  private toolUsageCount: Map<string, number>;
42
- /** Tools available for programmatic code execution */
43
- private programmaticToolMap?: t.ToolMap;
44
- /** Tool definitions for programmatic code execution (sent to Code API) */
45
- private programmaticToolDefs?: t.LCTool[];
46
- /** Tool registry for tool search (deferred tools) */
41
+ /** Tool registry for filtering (lazy computation of programmatic maps) */
47
42
  private toolRegistry?: t.LCToolRegistry;
43
+ /** Cached programmatic tools (computed once on first PTC call) */
44
+ private programmaticCache?: t.ProgrammaticCache;
48
45
 
49
46
  constructor({
50
47
  tools,
@@ -55,23 +52,44 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
55
52
  toolCallStepIds,
56
53
  handleToolErrors,
57
54
  loadRuntimeTools,
58
- programmaticToolMap,
59
- programmaticToolDefs,
60
55
  toolRegistry,
61
56
  }: t.ToolNodeConstructorParams) {
62
57
  super({ name, tags, func: (input, config) => this.run(input, config) });
63
- this.tools = tools;
64
58
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
65
59
  this.toolCallStepIds = toolCallStepIds;
66
60
  this.handleToolErrors = handleToolErrors ?? this.handleToolErrors;
67
61
  this.loadRuntimeTools = loadRuntimeTools;
68
62
  this.errorHandler = errorHandler;
69
63
  this.toolUsageCount = new Map<string, number>();
70
- this.programmaticToolMap = programmaticToolMap;
71
- this.programmaticToolDefs = programmaticToolDefs;
72
64
  this.toolRegistry = toolRegistry;
73
65
  }
74
66
 
67
+ /**
68
+ * Returns cached programmatic tools, computing once on first access.
69
+ * Single iteration builds both toolMap and toolDefs simultaneously.
70
+ */
71
+ private getProgrammaticTools(): { toolMap: t.ToolMap; toolDefs: t.LCTool[] } {
72
+ if (this.programmaticCache) return this.programmaticCache;
73
+
74
+ const toolMap: t.ToolMap = new Map();
75
+ const toolDefs: t.LCTool[] = [];
76
+
77
+ if (this.toolRegistry) {
78
+ for (const [name, toolDef] of this.toolRegistry) {
79
+ if (
80
+ (toolDef.allowed_callers ?? ['direct']).includes('code_execution')
81
+ ) {
82
+ toolDefs.push(toolDef);
83
+ const tool = this.toolMap.get(name);
84
+ if (tool) toolMap.set(name, tool);
85
+ }
86
+ }
87
+ }
88
+
89
+ this.programmaticCache = { toolMap, toolDefs };
90
+ return this.programmaticCache;
91
+ }
92
+
75
93
  /**
76
94
  * Returns a snapshot of the current tool usage counts.
77
95
  * @returns A ReadonlyMap where keys are tool names and values are their usage counts.
@@ -108,10 +126,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
108
126
 
109
127
  // Inject runtime data for special tools (becomes available at config.toolCall)
110
128
  if (call.name === Constants.PROGRAMMATIC_TOOL_CALLING) {
129
+ const { toolMap, toolDefs } = this.getProgrammaticTools();
111
130
  invokeParams = {
112
131
  ...invokeParams,
113
- toolMap: this.programmaticToolMap,
114
- programmaticToolDefs: this.programmaticToolDefs,
132
+ toolMap,
133
+ toolDefs,
115
134
  };
116
135
  } else if (call.name === Constants.TOOL_SEARCH_REGEX) {
117
136
  invokeParams = {
@@ -228,9 +247,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
228
247
  const { tools, toolMap } = this.loadRuntimeTools(
229
248
  aiMessage.tool_calls ?? []
230
249
  );
231
- this.tools = tools;
232
250
  this.toolMap =
233
251
  toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
252
+ this.programmaticCache = undefined; // Invalidate cache on toolMap change
234
253
  }
235
254
 
236
255
  outputs = await Promise.all(