mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-12 01:03:45 +00:00
♊ fix: Resolve Local JSON Pointer Refs & Sanitize Tool Search Schema for Gemini (#14161)
* ♊ fix: Resolve Local JSON Pointer Refs & Sanitize Tool Search Schema for Gemini Gemini/Vertex agents fail when MCP tools are attached, in two ways: 1. MCP servers with OpenAPI-derived schemas can emit local JSON pointer refs (e.g. `#/properties/body/properties/start`). `resolveJsonSchemaRefs` only resolves `#/$defs` and `#/definitions` refs, so these survive into the request payload and Vertex rejects it with 400 'Invalid JSON payload received. Unknown name "$ref"'. Resolve any local `#/...` pointer against the schema root (RFC 6901 unescaping, cycle protection), and strip still-unresolvable `$ref` keys in `sanitizeGeminiSchema` as a safety net. 2. When deferred tools are enabled, the injected `tool_search` schema declares `mcp_server` as `oneOf: [string, string[]]`, which `zod_to_gemini_parameters` rejects ('Gemini cannot handle union types'). `buildToolClassification` now accepts the agent provider and collapses the union via `sanitizeGeminiSchema` for Google/Vertex only — the tool's runtime (`normalizeServerFilter`) already accepts both forms. Complements #13623, which sanitizes MCP tool schemas but not the injected tool_search definition, and did not cover non-$defs local pointers. Underlying converter limitation: langchain-ai/langchainjs#9691. * style: format tool search schema assignment * style: sort tool classification imports --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
280da51004
commit
fc67416d08
6 changed files with 254 additions and 6 deletions
|
|
@ -1197,6 +1197,7 @@ async function loadAgentTools({
|
|||
loadedTools,
|
||||
userId: req.user.id,
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
agentToolOptions: agent.tool_options,
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
|
|
|
|||
|
|
@ -2907,4 +2907,112 @@ describe('sanitizeGeminiSchema', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('strips unresolvable `$ref` keys that survive ref resolution (Gemini rejects them)', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
recurrence: { $ref: 'https://example.com/external.json', description: 'Recurrence rule' },
|
||||
},
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
recurrence: { description: 'Recurrence rule' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveJsonSchemaRefs local pointer refs', () => {
|
||||
it('resolves a `#/properties/...` pointer against the root schema', () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
body: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
start: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
dateTime: { type: 'string' as const },
|
||||
timeZone: { type: 'string' as const },
|
||||
},
|
||||
},
|
||||
end: { $ref: '#/properties/body/properties/start' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveJsonSchemaRefs(schema);
|
||||
expect(resolved.properties?.body?.properties?.end).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
dateTime: { type: 'string' },
|
||||
timeZone: { type: 'string' },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(resolved)).not.toContain('$ref');
|
||||
});
|
||||
|
||||
it('resolves pointers that traverse array indices (e.g. anyOf members)', () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
daysOfWeek: {
|
||||
type: 'array' as const,
|
||||
items: {
|
||||
anyOf: [{ type: 'string' as const, enum: ['monday', 'tuesday'] }],
|
||||
},
|
||||
},
|
||||
firstDayOfWeek: { $ref: '#/properties/daysOfWeek/items/anyOf/0' },
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveJsonSchemaRefs(schema);
|
||||
expect(resolved.properties?.firstDayOfWeek).toEqual({
|
||||
type: 'string',
|
||||
enum: ['monday', 'tuesday'],
|
||||
});
|
||||
});
|
||||
|
||||
it('breaks mutually circular local pointers without hanging', () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
a: { $ref: '#/properties/b' },
|
||||
b: { $ref: '#/properties/a' },
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveJsonSchemaRefs(schema);
|
||||
expect(resolved.properties?.a).toEqual({ type: 'object' });
|
||||
expect(resolved.properties?.b).toEqual({ type: 'object' });
|
||||
});
|
||||
|
||||
it('keeps dangling local pointers as-is', () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
broken: { $ref: '#/properties/missing/nested' },
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveJsonSchemaRefs(schema);
|
||||
expect(resolved.properties?.broken).toEqual({ $ref: '#/properties/missing/nested' });
|
||||
});
|
||||
|
||||
it('unescapes RFC 6901 tokens (~0 → ~, ~1 → /) in pointer segments', () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
'a/b': { type: 'string' as const },
|
||||
alias: { $ref: '#/properties/a~1b' },
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveJsonSchemaRefs(schema);
|
||||
expect(resolved.properties?.alias).toEqual({ type: 'string' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -173,22 +173,57 @@ function convertToZodUnion(
|
|||
}
|
||||
|
||||
/**
|
||||
* Helper function to resolve $ref references
|
||||
* Resolves a local JSON pointer (e.g. `#/properties/body/properties/start`)
|
||||
* against the root schema, per RFC 6901 (`~1` → `/`, `~0` → `~`).
|
||||
* @returns The referenced subschema, or undefined when the pointer is dangling
|
||||
*/
|
||||
function resolveLocalPointer(
|
||||
root: Record<string, unknown>,
|
||||
pointer: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const segments = pointer
|
||||
.slice(2)
|
||||
.split('/')
|
||||
.map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'));
|
||||
|
||||
let node: unknown = root;
|
||||
for (const segment of segments) {
|
||||
if (node == null || typeof node !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
node = (node as Record<string, unknown>)[segment];
|
||||
}
|
||||
|
||||
if (node != null && typeof node === 'object' && !Array.isArray(node)) {
|
||||
return node as Record<string, unknown>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to resolve $ref references. Resolves `#/$defs/...` and
|
||||
* `#/definitions/...` refs via the definitions map, and any other local
|
||||
* `#/...` pointer (e.g. `#/properties/foo`) against the root schema —
|
||||
* MCP servers with OpenAPI-derived schemas commonly emit both forms.
|
||||
* @param schema - The schema to resolve
|
||||
* @param definitions - The definitions to use
|
||||
* @param visited - The set of visited references
|
||||
* @param root - The root schema local pointers resolve against (defaults to `schema`)
|
||||
* @returns The resolved schema
|
||||
*/
|
||||
export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
|
||||
schema: T,
|
||||
definitions?: Record<string, unknown>,
|
||||
visited: Set<string> = new Set<string>(),
|
||||
root?: Record<string, unknown>,
|
||||
): T {
|
||||
// Handle null, undefined, or non-object values first
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const rootSchema = root ?? schema;
|
||||
|
||||
// If no definitions provided, try to extract from schema.$defs or schema.definitions
|
||||
if (!definitions) {
|
||||
definitions = (schema.$defs || schema.definitions) as Record<string, unknown>;
|
||||
|
|
@ -196,7 +231,9 @@ export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
|
|||
|
||||
// Handle arrays
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((item) => resolveJsonSchemaRefs(item, definitions, visited)) as unknown as T;
|
||||
return schema.map((item) =>
|
||||
resolveJsonSchemaRefs(item, definitions, visited, rootSchema),
|
||||
) as unknown as T;
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
|
|
@ -219,7 +256,12 @@ export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
|
|||
|
||||
// Extract the reference path
|
||||
const refPath = value.replace(/^#\/(\$defs|definitions)\//, '');
|
||||
const resolved = definitions?.[refPath];
|
||||
let resolved = definitions?.[refPath];
|
||||
|
||||
// Fall back to resolving any other local pointer against the root schema
|
||||
if (!resolved && value.startsWith('#/')) {
|
||||
resolved = resolveLocalPointer(rootSchema, value);
|
||||
}
|
||||
|
||||
if (resolved) {
|
||||
visited.add(value);
|
||||
|
|
@ -227,6 +269,7 @@ export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
|
|||
resolved as Record<string, unknown>,
|
||||
definitions,
|
||||
visited,
|
||||
rootSchema,
|
||||
);
|
||||
visited.delete(value);
|
||||
|
||||
|
|
@ -238,7 +281,12 @@ export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
|
|||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
// Recursively resolve nested objects/arrays
|
||||
result[key] = resolveJsonSchemaRefs(value as Record<string, unknown>, definitions, visited);
|
||||
result[key] = resolveJsonSchemaRefs(
|
||||
value as Record<string, unknown>,
|
||||
definitions,
|
||||
visited,
|
||||
rootSchema,
|
||||
);
|
||||
} else {
|
||||
// Copy primitive values as is
|
||||
result[key] = value;
|
||||
|
|
@ -367,6 +415,7 @@ function mergeRequired(a: unknown, b: unknown): string[] | undefined {
|
|||
const GEMINI_UNSUPPORTED_KEYS = new Set([
|
||||
'additionalProperties',
|
||||
'$schema',
|
||||
'$ref',
|
||||
'$id',
|
||||
'id',
|
||||
'$comment',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Providers } from '@librechat/agents';
|
||||
import type { AgentToolOptions } from 'librechat-data-provider';
|
||||
import type { GenericTool } from '@librechat/agents';
|
||||
import type { LCToolRegistry } from './classification';
|
||||
|
|
@ -338,6 +339,77 @@ describe('classification.ts', () => {
|
|||
expect(result.toolRegistry?.has('tool_search')).toBe(true);
|
||||
});
|
||||
|
||||
type ToolSearchParams = {
|
||||
properties?: Record<string, { oneOf?: unknown[]; type?: string }>;
|
||||
};
|
||||
|
||||
it('should sanitize the tool_search schema for Google providers (no union types)', async () => {
|
||||
const loadedTools: GenericTool[] = [createMCPTool('tool1')];
|
||||
|
||||
const agentToolOptions: AgentToolOptions = {
|
||||
tool1: { defer_loading: true },
|
||||
};
|
||||
|
||||
const result = await buildToolClassification({
|
||||
loadedTools,
|
||||
userId: 'user1',
|
||||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
definitionsOnly: true,
|
||||
provider: Providers.GOOGLE,
|
||||
});
|
||||
|
||||
const toolSearchDef = result.toolDefinitions.find((d) => d.name === 'tool_search');
|
||||
const params = toolSearchDef?.parameters as ToolSearchParams | undefined;
|
||||
expect(params?.properties?.mcp_server?.oneOf).toBeUndefined();
|
||||
expect(params?.properties?.mcp_server?.type).toBe('string');
|
||||
});
|
||||
|
||||
it('should sanitize the tool_search instance schema for Vertex AI provider', async () => {
|
||||
const loadedTools: GenericTool[] = [createMCPTool('tool1')];
|
||||
|
||||
const agentToolOptions: AgentToolOptions = {
|
||||
tool1: { defer_loading: true },
|
||||
};
|
||||
|
||||
const result = await buildToolClassification({
|
||||
loadedTools,
|
||||
userId: 'user1',
|
||||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
provider: Providers.VERTEXAI,
|
||||
});
|
||||
|
||||
const toolSearchTool = result.additionalTools.find((t) => t.name === 'tool_search');
|
||||
const schema = (toolSearchTool as unknown as { schema: ToolSearchParams }).schema;
|
||||
expect(schema.properties?.mcp_server?.oneOf).toBeUndefined();
|
||||
expect(schema.properties?.mcp_server?.type).toBe('string');
|
||||
});
|
||||
|
||||
it('should keep the original tool_search schema for non-Google providers', async () => {
|
||||
const loadedTools: GenericTool[] = [createMCPTool('tool1')];
|
||||
|
||||
const agentToolOptions: AgentToolOptions = {
|
||||
tool1: { defer_loading: true },
|
||||
};
|
||||
|
||||
const result = await buildToolClassification({
|
||||
loadedTools,
|
||||
userId: 'user1',
|
||||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
definitionsOnly: true,
|
||||
provider: Providers.OPENAI,
|
||||
});
|
||||
|
||||
const toolSearchDef = result.toolDefinitions.find((d) => d.name === 'tool_search');
|
||||
const params = toolSearchDef?.parameters as ToolSearchParams | undefined;
|
||||
expect(Array.isArray(params?.properties?.mcp_server?.oneOf)).toBe(true);
|
||||
});
|
||||
|
||||
it('should add PTC definition when definitionsOnly=true and capabilities allow programmatic tools', async () => {
|
||||
const loadedTools: GenericTool[] = [createMCPTool('tool1')];
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import {
|
||||
Providers,
|
||||
createToolSearch,
|
||||
ToolSearchToolDefinition,
|
||||
BashProgrammaticToolCallingDefinition,
|
||||
createBashProgrammaticToolCallingTool,
|
||||
} from '@librechat/agents';
|
||||
import type { AgentToolOptions } from 'librechat-data-provider';
|
||||
import type {
|
||||
LCToolRegistry,
|
||||
JsonSchemaType,
|
||||
|
|
@ -21,6 +21,8 @@ import type {
|
|||
GenericTool,
|
||||
LCTool,
|
||||
} from '@librechat/agents';
|
||||
import type { AgentToolOptions } from 'librechat-data-provider';
|
||||
import { sanitizeGeminiSchema } from '~/mcp/zod';
|
||||
|
||||
export type { LCTool, LCToolRegistry, AllowedCaller, JsonSchemaType };
|
||||
|
||||
|
|
@ -191,6 +193,8 @@ export interface BuildToolClassificationParams {
|
|||
codeExecutionEnabled?: boolean;
|
||||
/** When true, skip creating tool instances (for event-driven mode) */
|
||||
definitionsOnly?: boolean;
|
||||
/** Agent provider — Gemini/Vertex rejects union types, so injected tool schemas get sanitized */
|
||||
provider?: Providers | string;
|
||||
/** Optional host-supplied Code API auth headers for remote programmatic execution. */
|
||||
authHeaders?: () => Promise<Record<string, string>> | Record<string, string>;
|
||||
}
|
||||
|
|
@ -253,6 +257,7 @@ export async function buildToolClassification(
|
|||
): Promise<BuildToolClassificationResult> {
|
||||
const {
|
||||
agentId,
|
||||
provider,
|
||||
loadedTools,
|
||||
agentToolOptions,
|
||||
definitionsOnly = false,
|
||||
|
|
@ -261,6 +266,7 @@ export async function buildToolClassification(
|
|||
codeExecutionEnabled = false,
|
||||
authHeaders,
|
||||
} = params;
|
||||
const isGoogle = provider === Providers.GOOGLE || provider === Providers.VERTEXAI;
|
||||
const additionalTools: GenericTool[] = [];
|
||||
|
||||
const mcpTools = loadedTools.filter(isMCPTool);
|
||||
|
|
@ -311,11 +317,22 @@ export async function buildToolClassification(
|
|||
|
||||
/** Tool search uses local mode (no API key needed) */
|
||||
if (hasDeferredTools) {
|
||||
/**
|
||||
* The ToolSearch schema declares `mcp_server` as a string/array union, which
|
||||
* `zod_to_gemini_parameters` rejects — collapse it for Gemini/Vertex agents.
|
||||
*/
|
||||
const toolSearchParameters = (isGoogle
|
||||
? sanitizeGeminiSchema(ToolSearchToolDefinition.schema as Record<string, unknown>)
|
||||
: ToolSearchToolDefinition.schema) as unknown as LCTool['parameters'];
|
||||
|
||||
if (!definitionsOnly) {
|
||||
const toolSearchTool = createToolSearch({
|
||||
mode: 'local',
|
||||
toolRegistry,
|
||||
});
|
||||
if (isGoogle) {
|
||||
toolSearchTool.schema = toolSearchParameters as typeof toolSearchTool.schema;
|
||||
}
|
||||
additionalTools.push(toolSearchTool);
|
||||
}
|
||||
|
||||
|
|
@ -323,7 +340,7 @@ export async function buildToolClassification(
|
|||
toolDefinitions.push({
|
||||
name: ToolSearchToolDefinition.name,
|
||||
description: ToolSearchToolDefinition.description,
|
||||
parameters: ToolSearchToolDefinition.schema as unknown as LCTool['parameters'],
|
||||
parameters: toolSearchParameters,
|
||||
});
|
||||
toolRegistry.set(ToolSearchToolDefinition.name, {
|
||||
name: ToolSearchToolDefinition.name,
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ export async function loadToolDefinitions(
|
|||
const classificationResult = await buildToolClassification({
|
||||
userId,
|
||||
agentId,
|
||||
provider,
|
||||
loadedTools,
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue