🪢 fix: Coerce Tool Execution Args By Schema (#13310)

* fix: Coerce tool execution args by schema

* 📦 chore: bump `@librechat/agents` to v3.1.95
This commit is contained in:
Danny Avila 2026-05-25 18:58:11 -04:00
parent ee66e43207
commit abfe7d19f4
5 changed files with 216 additions and 10 deletions

View file

@ -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",

10
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -7,10 +7,16 @@ import type {
} from '@librechat/agents';
import { createToolExecuteHandler, ToolExecuteOptions } from './handlers';
function createMockTool(name: string, capturedConfigs: Record<string, unknown>[]) {
function createMockTool(
name: string,
capturedConfigs: Record<string, unknown>[],
options: { schema?: unknown; capturedArgs?: unknown[] } = {},
) {
return {
name,
schema: options.schema,
invoke: jest.fn(async (_args: unknown, config: Record<string, unknown>) => {
options.capturedArgs?.push(_args);
capturedConfigs.push({ ...(config.toolCall as Record<string, unknown>) });
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<string, unknown>[] = [];

View file

@ -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<typeof compositeKey, unknown>)[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,