@librechat/agents 3.0.43 → 3.0.45

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.
@@ -97,25 +97,90 @@ Requirements:
97
97
  // Helper Functions
98
98
  // ============================================================================
99
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
+
100
165
  /**
101
166
  * Extracts tool names that are actually called in the Python code.
102
- * Matches patterns like `await tool_name(`, `tool_name(`, and asyncio.gather calls.
167
+ * Handles hyphen/underscore conversion since Python identifiers use underscores.
103
168
  * @param code - The Python code to analyze
104
- * @param availableToolNames - Set of available tool names to match against
105
- * @returns Set of tool names found in the code
169
+ * @param toolNameMap - Map from normalized Python name to original tool name
170
+ * @returns Set of original tool names found in the code
106
171
  */
107
172
  export function extractUsedToolNames(
108
173
  code: string,
109
- availableToolNames: Set<string>
174
+ toolNameMap: Map<string, string>
110
175
  ): Set<string> {
111
176
  const usedTools = new Set<string>();
112
177
 
113
- for (const toolName of availableToolNames) {
114
- const escapedName = toolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
178
+ for (const [pythonName, originalName] of toolNameMap) {
179
+ const escapedName = pythonName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
115
180
  const pattern = new RegExp(`\\b${escapedName}\\s*\\(`, 'g');
116
181
 
117
182
  if (pattern.test(code)) {
118
- usedTools.add(toolName);
183
+ usedTools.add(originalName);
119
184
  }
120
185
  }
121
186
 
@@ -124,18 +189,45 @@ export function extractUsedToolNames(
124
189
 
125
190
  /**
126
191
  * Filters tool definitions to only include tools actually used in the code.
192
+ * Handles the hyphen-to-underscore conversion for Python compatibility.
127
193
  * @param toolDefs - All available tool definitions
128
194
  * @param code - The Python code to analyze
195
+ * @param debug - Enable debug logging
129
196
  * @returns Filtered array of tool definitions
130
197
  */
131
198
  export function filterToolsByUsage(
132
199
  toolDefs: t.LCTool[],
133
- code: string
200
+ code: string,
201
+ debug = false
134
202
  ): t.LCTool[] {
135
- const availableToolNames = new Set(toolDefs.map((tool) => tool.name));
136
- const usedToolNames = extractUsedToolNames(code, availableToolNames);
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
+ }
137
223
 
138
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
+ }
139
231
  return toolDefs;
140
232
  }
141
233
 
@@ -182,9 +274,93 @@ export async function makeRequest(
182
274
  return (await response.json()) as t.ProgrammaticExecutionResponse;
183
275
  }
184
276
 
277
+ /**
278
+ * Unwraps tool responses that may be formatted as tuples.
279
+ * MCP tools return [content, artifacts], we need to extract the raw data.
280
+ * @param result - The raw result from tool.invoke()
281
+ * @param isMCPTool - Whether this is an MCP tool (has mcp property)
282
+ * @returns Unwrapped raw data (string, object, or parsed JSON)
283
+ */
284
+ export function unwrapToolResponse(
285
+ result: unknown,
286
+ isMCPTool: boolean
287
+ ): unknown {
288
+ // Only unwrap if this is an MCP tool and result is a tuple
289
+ if (!isMCPTool) {
290
+ return result;
291
+ }
292
+
293
+ // Check if result is a tuple/array with [content, artifacts]
294
+ if (Array.isArray(result) && result.length >= 1) {
295
+ const [content] = result;
296
+
297
+ // If first element is a string, return it
298
+ if (typeof content === 'string') {
299
+ // Try to parse as JSON if it looks like JSON
300
+ if (typeof content === 'string' && content.trim().startsWith('{')) {
301
+ try {
302
+ return JSON.parse(content);
303
+ } catch {
304
+ return content;
305
+ }
306
+ }
307
+ return content;
308
+ }
309
+
310
+ // If first element is an array (content blocks), extract text/data
311
+ if (Array.isArray(content)) {
312
+ // If it's an array of content blocks (like [{ type: 'text', text: '...' }])
313
+ if (
314
+ content.length > 0 &&
315
+ typeof content[0] === 'object' &&
316
+ 'type' in content[0]
317
+ ) {
318
+ // Extract text from content blocks
319
+ const texts = content
320
+ .filter((block: unknown) => {
321
+ if (typeof block !== 'object' || block === null) return false;
322
+ const b = block as Record<string, unknown>;
323
+ return b.type === 'text' && typeof b.text === 'string';
324
+ })
325
+ .map((block: unknown) => {
326
+ const b = block as Record<string, unknown>;
327
+ return b.text as string;
328
+ });
329
+
330
+ if (texts.length > 0) {
331
+ const combined = texts.join('\n');
332
+ // Try to parse as JSON if it looks like JSON (objects or arrays)
333
+ if (
334
+ combined.trim().startsWith('{') ||
335
+ combined.trim().startsWith('[')
336
+ ) {
337
+ try {
338
+ return JSON.parse(combined);
339
+ } catch {
340
+ return combined;
341
+ }
342
+ }
343
+ return combined;
344
+ }
345
+ }
346
+ // Otherwise return the content array as-is
347
+ return content;
348
+ }
349
+
350
+ // If first element is an object, return it
351
+ if (typeof content === 'object' && content !== null) {
352
+ return content;
353
+ }
354
+ }
355
+
356
+ // Not a formatted response, return as-is
357
+ return result;
358
+ }
359
+
185
360
  /**
186
361
  * Executes tools in parallel when requested by the API.
187
362
  * Uses Promise.all for parallel execution, catching individual errors.
363
+ * Unwraps formatted responses (e.g., MCP tool tuples) to raw data.
188
364
  * @param toolCalls - Array of tool calls from the API
189
365
  * @param toolMap - Map of tool names to executable tools
190
366
  * @returns Array of tool results
@@ -209,9 +385,13 @@ export async function executeTools(
209
385
  const result = await tool.invoke(call.input, {
210
386
  metadata: { [Constants.PROGRAMMATIC_TOOL_CALLING]: true },
211
387
  });
388
+
389
+ const isMCPTool = tool.mcp === true;
390
+ const unwrappedResult = unwrapToolResponse(result, isMCPTool);
391
+
212
392
  return {
213
393
  call_id: call.id,
214
- result,
394
+ result: unwrappedResult,
215
395
  is_error: false,
216
396
  };
217
397
  } catch (error) {
@@ -318,6 +498,7 @@ export function createProgrammaticToolCallingTool(
318
498
  const baseUrl = initParams.baseUrl ?? getCodeBaseURL();
319
499
  const maxRoundTrips = initParams.maxRoundTrips ?? DEFAULT_MAX_ROUND_TRIPS;
320
500
  const proxy = initParams.proxy ?? process.env.PROXY;
501
+ const debug = initParams.debug ?? process.env.PTC_DEBUG === 'true';
321
502
  const EXEC_ENDPOINT = `${baseUrl}/exec/programmatic`;
322
503
 
323
504
  const description = `
@@ -371,7 +552,15 @@ When to use this instead of calling tools directly:
371
552
  // Phase 1: Filter tools and make initial request
372
553
  // ====================================================================
373
554
 
374
- const effectiveTools = filterToolsByUsage(toolDefs, code);
555
+ const effectiveTools = filterToolsByUsage(toolDefs, code, debug);
556
+
557
+ if (debug) {
558
+ // eslint-disable-next-line no-console
559
+ console.log(
560
+ `[PTC Debug] Sending ${effectiveTools.length} tools to API ` +
561
+ `(filtered from ${toolDefs.length})`
562
+ );
563
+ }
375
564
 
376
565
  let response = await makeRequest(
377
566
  EXEC_ENDPOINT,
@@ -400,10 +589,12 @@ When to use this instead of calling tools directly:
400
589
  );
401
590
  }
402
591
 
403
- // eslint-disable-next-line no-console
404
- console.log(
405
- `[PTC] Round trip ${roundTrip}: ${response.tool_calls?.length ?? 0} tool(s) to execute`
406
- );
592
+ if (debug) {
593
+ // eslint-disable-next-line no-console
594
+ console.log(
595
+ `[PTC Debug] Round trip ${roundTrip}: ${response.tool_calls?.length ?? 0} tool(s) to execute`
596
+ );
597
+ }
407
598
 
408
599
  const toolResults = await executeTools(
409
600
  response.tool_calls ?? [],
@@ -11,6 +11,7 @@ import {
11
11
  extractUsedToolNames,
12
12
  filterToolsByUsage,
13
13
  executeTools,
14
+ normalizeToPythonIdentifier,
14
15
  } from '../ProgrammaticToolCalling';
15
16
  import {
16
17
  createProgrammaticToolRegistry,
@@ -185,8 +186,58 @@ describe('ProgrammaticToolCalling', () => {
185
186
  });
186
187
  });
187
188
 
189
+ describe('normalizeToPythonIdentifier', () => {
190
+ it('converts hyphens to underscores', () => {
191
+ expect(normalizeToPythonIdentifier('my-tool-name')).toBe('my_tool_name');
192
+ });
193
+
194
+ it('converts spaces to underscores', () => {
195
+ expect(normalizeToPythonIdentifier('my tool name')).toBe('my_tool_name');
196
+ });
197
+
198
+ it('leaves underscores unchanged', () => {
199
+ expect(normalizeToPythonIdentifier('my_tool_name')).toBe('my_tool_name');
200
+ });
201
+
202
+ it('handles mixed hyphens and underscores', () => {
203
+ expect(normalizeToPythonIdentifier('my-tool_name-v2')).toBe(
204
+ 'my_tool_name_v2'
205
+ );
206
+ });
207
+
208
+ it('handles MCP-style names with hyphens', () => {
209
+ expect(
210
+ normalizeToPythonIdentifier('create_spreadsheet_mcp_Google-Workspace')
211
+ ).toBe('create_spreadsheet_mcp_Google_Workspace');
212
+ });
213
+
214
+ it('removes invalid characters', () => {
215
+ expect(normalizeToPythonIdentifier('tool@name!v2')).toBe('toolnamev2');
216
+ expect(normalizeToPythonIdentifier('get.data.v2')).toBe('getdatav2');
217
+ });
218
+
219
+ it('prefixes with underscore if starts with number', () => {
220
+ expect(normalizeToPythonIdentifier('123tool')).toBe('_123tool');
221
+ expect(normalizeToPythonIdentifier('1-tool')).toBe('_1_tool');
222
+ });
223
+
224
+ it('appends _tool suffix for Python keywords', () => {
225
+ expect(normalizeToPythonIdentifier('return')).toBe('return_tool');
226
+ expect(normalizeToPythonIdentifier('async')).toBe('async_tool');
227
+ expect(normalizeToPythonIdentifier('import')).toBe('import_tool');
228
+ });
229
+ });
230
+
188
231
  describe('extractUsedToolNames', () => {
189
- const availableTools = new Set([
232
+ const createToolMap = (names: string[]): Map<string, string> => {
233
+ const map = new Map<string, string>();
234
+ for (const name of names) {
235
+ map.set(normalizeToPythonIdentifier(name), name);
236
+ }
237
+ return map;
238
+ };
239
+
240
+ const availableTools = createToolMap([
190
241
  'get_weather',
191
242
  'get_team_members',
192
243
  'get_expenses',
@@ -262,16 +313,30 @@ x = 1 + 2`;
262
313
  expect(used.size).toBe(0);
263
314
  });
264
315
 
265
- it('handles tool names with special regex characters', () => {
266
- const specialTools = new Set(['get_data.v2', 'calc+plus']);
267
- const code = `await get_data.v2()
268
- await calc+plus()`;
316
+ it('handles tool names with special characters via normalization', () => {
317
+ const specialTools = createToolMap(['get_data.v2', 'calc+plus']);
318
+ const code = `await get_datav2()
319
+ await calcplus()`;
269
320
 
270
321
  const used = extractUsedToolNames(code, specialTools);
271
322
 
272
323
  expect(used.has('get_data.v2')).toBe(true);
273
324
  expect(used.has('calc+plus')).toBe(true);
274
325
  });
326
+
327
+ it('matches hyphenated tool names using underscore in code', () => {
328
+ const mcpTools = createToolMap([
329
+ 'create_spreadsheet_mcp_Google-Workspace',
330
+ 'search_gmail_mcp_Google-Workspace',
331
+ ]);
332
+ const code = `result = await create_spreadsheet_mcp_Google_Workspace(title="Test")
333
+ print(result)`;
334
+
335
+ const used = extractUsedToolNames(code, mcpTools);
336
+
337
+ expect(used.size).toBe(1);
338
+ expect(used.has('create_spreadsheet_mcp_Google-Workspace')).toBe(true);
339
+ });
275
340
  });
276
341
 
277
342
  describe('filterToolsByUsage', () => {
@@ -15,7 +15,9 @@ export type CustomToolCall = {
15
15
  output?: string;
16
16
  };
17
17
 
18
- export type GenericTool = StructuredToolInterface | RunnableToolLike;
18
+ export type GenericTool = (StructuredToolInterface | RunnableToolLike) & {
19
+ mcp?: boolean;
20
+ };
19
21
 
20
22
  export type ToolMap = Map<string, GenericTool>;
21
23
  export type ToolRefs = {
@@ -239,6 +241,8 @@ export type ProgrammaticToolCallingParams = {
239
241
  maxRoundTrips?: number;
240
242
  /** HTTP proxy URL */
241
243
  proxy?: string;
244
+ /** Enable debug logging (or set PTC_DEBUG=true env var) */
245
+ debug?: boolean;
242
246
  /** Environment variable key for API key */
243
247
  [key: string]: unknown;
244
248
  };