mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🪢 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:
parent
ee66e43207
commit
abfe7d19f4
5 changed files with 216 additions and 10 deletions
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>[] = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue