From abfe7d19f4c61a781890d8a5b646bc67f0a8d5dc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 25 May 2026 18:58:11 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A2=20fix:=20Coerce=20Tool=20Execution?= =?UTF-8?q?=20Args=20By=20Schema=20(#13310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Coerce tool execution args by schema * 📦 chore: bump `@librechat/agents` to v3.1.95 --- api/package.json | 2 +- package-lock.json | 10 +- packages/api/package.json | 2 +- packages/api/src/agents/handlers.spec.ts | 85 ++++++++++++++- packages/api/src/agents/handlers.ts | 127 ++++++++++++++++++++++- 5 files changed, 216 insertions(+), 10 deletions(-) diff --git a/api/package.json b/api/package.json index 145c375045..3d6aa62ff7 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.94", + "@librechat/agents": "^3.1.95", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index d429913079..3235b20325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.94", + "@librechat/agents": "^3.1.95", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -12265,9 +12265,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.94", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.94.tgz", - "integrity": "sha512-o44JbOtW7CbdPjoHWyUSLA+8g7Y5XapbTyTVYhdGlKvPlxvAt1NWh7IbqNhH3RTa0tFQzE8dViOzCsDSdL4xYw==", + "version": "3.1.95", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.95.tgz", + "integrity": "sha512-s2LOwE02iYrUV1C/Tv3/MXKPmeh5X93Wr8YWc4SjVS34RiOFOfI4/Gga9EW5ISyHuM5frtUCDhq1CkzmfMWFGA==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -43530,7 +43530,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.94", + "@librechat/agents": "^3.1.95", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/package.json b/packages/api/package.json index 3dac44e12b..242aa0006e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -104,7 +104,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.94", + "@librechat/agents": "^3.1.95", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index dca3a85005..58a3b7287a 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -7,10 +7,16 @@ import type { } from '@librechat/agents'; import { createToolExecuteHandler, ToolExecuteOptions } from './handlers'; -function createMockTool(name: string, capturedConfigs: Record[]) { +function createMockTool( + name: string, + capturedConfigs: Record[], + options: { schema?: unknown; capturedArgs?: unknown[] } = {}, +) { return { name, + schema: options.schema, invoke: jest.fn(async (_args: unknown, config: Record) => { + options.capturedArgs?.push(_args); capturedConfigs.push({ ...(config.toolCall as Record) }); return { content: `stdout:\n${name} executed\n`, @@ -245,6 +251,83 @@ describe('createToolExecuteHandler', () => { }); }); + describe('tool argument normalization', () => { + it('parses JSON-string args for object-schema tools before invocation', async () => { + const capturedArgs: unknown[] = []; + const tool = createMockTool(Constants.BASH_PROGRAMMATIC_TOOL_CALLING, [], { + capturedArgs, + schema: { + type: 'object', + properties: { + code: { type: 'string' }, + timeout: { type: 'number' }, + }, + required: ['code'], + }, + }); + const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ + loadedTools: [tool] as never[], + })); + const handler = createToolExecuteHandler({ loadTools }); + + await invokeHandler(handler, [ + { + id: 'call_bash_json_string', + name: Constants.BASH_PROGRAMMATIC_TOOL_CALLING, + args: '{"code":"echo hi","timeout":30000}' as unknown as ToolCallRequest['args'], + }, + ]); + + expect(capturedArgs).toEqual([{ code: 'echo hi', timeout: 30000 }]); + }); + + it('preserves JSON-looking strings for string-schema tools', async () => { + const capturedArgs: unknown[] = []; + const payload = '{"serviceId":"svc","query":"SELECT price / 10.0 FROM default.uk_prices_3"}'; + const tool = createMockTool('raw_string_tool', [], { + capturedArgs, + schema: { type: 'string' }, + }); + const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ + loadedTools: [tool] as never[], + })); + const handler = createToolExecuteHandler({ loadTools }); + + await invokeHandler(handler, [ + { + id: 'call_raw_string', + name: 'raw_string_tool', + args: payload as unknown as ToolCallRequest['args'], + }, + ]); + + expect(capturedArgs).toEqual([payload]); + }); + + it('preserves JSON-looking strings when a tool accepts string or object input', async () => { + const capturedArgs: unknown[] = []; + const payload = '{"query":"SELECT * FROM t WHERE name IN (\'a\',\'b\')"}'; + const tool = createMockTool('union_tool', [], { + capturedArgs, + schema: { anyOf: [{ type: 'string' }, { type: 'object' }] }, + }); + const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ + loadedTools: [tool] as never[], + })); + const handler = createToolExecuteHandler({ loadTools }); + + await invokeHandler(handler, [ + { + id: 'call_union', + name: 'union_tool', + args: payload as unknown as ToolCallRequest['args'], + }, + ]); + + expect(capturedArgs).toEqual([payload]); + }); + }); + describe('programmatic tool config', () => { it('injects tool definitions for the legacy PTC tool name', async () => { const capturedConfigs: Record[] = []; diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 51fed861be..18eb86c4a3 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -159,6 +159,11 @@ const MAX_TOOL_ERROR_STACK_CHARS = 4_000; const IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); +type ToolInputSchemaKind = { + object: boolean; + string: boolean; +}; + function truncateMiddle(value: string, maxChars: number): string { if (value.length <= maxChars) { return value; @@ -225,6 +230,120 @@ function getSafeToolError(error: unknown): { }; } +function mergeSchemaKind(target: ToolInputSchemaKind, source: ToolInputSchemaKind): void { + target.object ||= source.object; + target.string ||= source.string; +} + +function detectToolInputSchemaKind(schema: unknown): ToolInputSchemaKind { + const kind: ToolInputSchemaKind = { object: false, string: false }; + + if (!schema || typeof schema !== 'object') { + return kind; + } + + const jsonSchemaType = (schema as { type?: unknown }).type; + if (jsonSchemaType === 'object') { + kind.object = true; + } else if (jsonSchemaType === 'string') { + kind.string = true; + } else if (Array.isArray(jsonSchemaType)) { + kind.object = jsonSchemaType.includes('object'); + kind.string = jsonSchemaType.includes('string'); + } + + for (const compositeKey of ['anyOf', 'oneOf', 'allOf'] as const) { + const options = (schema as Record)[compositeKey]; + if (Array.isArray(options)) { + for (const option of options) { + mergeSchemaKind(kind, detectToolInputSchemaKind(option)); + } + } + } + + const zodDef = (schema as { _def?: unknown })._def; + if (!zodDef || typeof zodDef !== 'object') { + return kind; + } + + const zodType = (zodDef as { type?: unknown; typeName?: unknown }).type; + const zodTypeName = (zodDef as { type?: unknown; typeName?: unknown }).typeName; + + if (zodType === 'object' || zodTypeName === 'ZodObject') { + kind.object = true; + } else if (zodType === 'string' || zodTypeName === 'ZodString') { + kind.string = true; + } + + const innerSchema = + (zodDef as { innerType?: unknown; schema?: unknown }).innerType ?? + (zodDef as { schema?: unknown }).schema; + if (innerSchema) { + mergeSchemaKind(kind, detectToolInputSchemaKind(innerSchema)); + } + + const zodOptions = (zodDef as { options?: unknown }).options; + if (Array.isArray(zodOptions)) { + for (const option of zodOptions) { + mergeSchemaKind(kind, detectToolInputSchemaKind(option)); + } + } + + return kind; +} + +function getToolInputSchemaKind(tool: StructuredToolInterface): ToolInputSchemaKind { + const constructorName = (tool as { constructor?: { name?: string } }).constructor?.name; + if (constructorName === 'DynamicTool') { + return { object: false, string: true }; + } + + return detectToolInputSchemaKind((tool as { schema?: unknown }).schema); +} + +function normalizeToolInvokeArgs(args: unknown, tool: StructuredToolInterface): unknown { + const schemaKind = getToolInputSchemaKind(tool); + + if (typeof args !== 'string') { + if (!schemaKind.string || schemaKind.object) { + return args; + } + + const inputValue = (args as { input?: unknown })?.input; + return typeof inputValue === 'string' ? args : JSON.stringify(args); + } + + if (!schemaKind.object || schemaKind.string) { + return args; + } + + const trimmed = args.trim(); + if (!trimmed.startsWith('{')) { + return args; + } + + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch { + return args; + } + + return args; +} + +function getValueShape(value: unknown): string { + if (value === null) { + return 'null'; + } + if (Array.isArray(value)) { + return 'array'; + } + return typeof value; +} + function addLineNumbers(content: string): string { const lines = content.split('\n'); const w = String(lines.length).length; @@ -1218,7 +1337,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand } } - const result = await tool.invoke(tc.args, { + const result = await tool.invoke(normalizeToolInvokeArgs(tc.args, tool), { toolCall: toolCallConfig, configurable: mergedConfigurable, metadata, @@ -1264,7 +1383,11 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand }; } catch (toolError) { const { message, logContext } = getSafeToolError(toolError); - logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error`, logContext); + logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error`, { + ...logContext, + toolCallArgsShape: getValueShape(tc.args), + toolInputSchemaKind: getToolInputSchemaKind(tool), + }); return { toolCallId: tc.id, status: 'error' as const,