@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.
- package/dist/cjs/main.cjs +2 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +176 -14
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/esm/main.mjs +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +175 -15
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/types/tools/ProgrammaticToolCalling.d.ts +27 -5
- package/dist/types/types/tools.d.ts +5 -1
- package/package.json +1 -1
- package/src/tools/ProgrammaticToolCalling.ts +207 -16
- package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +70 -5
- package/src/types/tools.ts +5 -1
|
@@ -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
|
-
*
|
|
167
|
+
* Handles hyphen/underscore conversion since Python identifiers use underscores.
|
|
103
168
|
* @param code - The Python code to analyze
|
|
104
|
-
* @param
|
|
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
|
-
|
|
174
|
+
toolNameMap: Map<string, string>
|
|
110
175
|
): Set<string> {
|
|
111
176
|
const usedTools = new Set<string>();
|
|
112
177
|
|
|
113
|
-
for (const
|
|
114
|
-
const escapedName =
|
|
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(
|
|
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
|
|
136
|
-
const
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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
|
|
266
|
-
const specialTools =
|
|
267
|
-
const code = `await
|
|
268
|
-
await
|
|
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', () => {
|
package/src/types/tools.ts
CHANGED
|
@@ -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
|
};
|