mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
🛰️ feat: Support Gemini Tool Combinations (#13273)
* feat: support Gemini tool combinations * test: align Gemini provider tool shape * fix: avoid duplicate native web search tools * fix: gate Gemini tool combinations by model
This commit is contained in:
parent
1693b586d4
commit
4f5486c932
2 changed files with 187 additions and 23 deletions
|
|
@ -225,6 +225,17 @@ function countNamedWebSearchTools(tools: unknown[] | undefined): number {
|
|||
);
|
||||
}
|
||||
|
||||
function countGoogleSearchTools(tools: unknown[] | undefined): number {
|
||||
return (
|
||||
tools?.filter((tool) => {
|
||||
if (tool == null || typeof tool !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return 'googleSearch' in tool || 'googleSearchRetrieval' in tool;
|
||||
}).length ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function countWebSearchDefinitions(
|
||||
toolDefinitions: Array<{ name: string }> | undefined,
|
||||
): number {
|
||||
|
|
@ -308,16 +319,22 @@ describe('initializeAgent — custom provider token lookup', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('initializeAgent — Anthropic web_search precedence', () => {
|
||||
describe('initializeAgent — provider web_search precedence', () => {
|
||||
const nativeWebSearchTool = {
|
||||
type: 'web_search_20250305',
|
||||
name: Tools.web_search,
|
||||
};
|
||||
const nativeGoogleSearchTool = { googleSearch: {} };
|
||||
const libreChatWebSearchDefinition = {
|
||||
name: Tools.web_search,
|
||||
description: 'Search the web',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
};
|
||||
const mcpToolDefinition = {
|
||||
name: 'mcp_lookup',
|
||||
description: 'Lookup context',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
@ -398,7 +415,85 @@ describe('initializeAgent — Anthropic web_search precedence', () => {
|
|||
expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves non-Anthropic providers unchanged', async () => {
|
||||
it('keeps Google native search when LibreChat web_search is not selected', async () => {
|
||||
const { agent, req, res, loadTools, db } = createMocks({
|
||||
provider: Providers.GOOGLE,
|
||||
model: 'gemini-3.5-flash',
|
||||
providerTools: [nativeGoogleSearchTool],
|
||||
loadedToolDefinitions: [mcpToolDefinition],
|
||||
});
|
||||
agent.tools = ['mcp_lookup'];
|
||||
|
||||
const result = await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.GOOGLE]),
|
||||
isInitialAgent: true,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([nativeGoogleSearchTool]);
|
||||
expect(countGoogleSearchTools(result.tools)).toBe(1);
|
||||
expect(result.toolDefinitions).toContain(mcpToolDefinition);
|
||||
});
|
||||
|
||||
it('rejects Google native search with external tools for unsupported Gemini models', async () => {
|
||||
const { agent, req, res, loadTools, db } = createMocks({
|
||||
provider: Providers.GOOGLE,
|
||||
model: 'gemini-2.5-flash',
|
||||
providerTools: [nativeGoogleSearchTool],
|
||||
loadedToolDefinitions: [mcpToolDefinition],
|
||||
});
|
||||
agent.tools = ['mcp_lookup'];
|
||||
|
||||
await expect(
|
||||
initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.GOOGLE]),
|
||||
isInitialAgent: true,
|
||||
},
|
||||
db,
|
||||
),
|
||||
).rejects.toThrow(/google_tool_conflict/);
|
||||
});
|
||||
|
||||
it('prefers LibreChat web_search when Google native search is also enabled', async () => {
|
||||
const { agent, req, res, loadTools, db } = createMocks({
|
||||
provider: Providers.GOOGLE,
|
||||
providerTools: [nativeGoogleSearchTool],
|
||||
loadedToolDefinitions: [libreChatWebSearchDefinition],
|
||||
});
|
||||
agent.tools = [Tools.web_search];
|
||||
|
||||
const result = await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.GOOGLE]),
|
||||
isInitialAgent: true,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([]);
|
||||
expect(countGoogleSearchTools(result.tools)).toBe(0);
|
||||
expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves providers without a native web search conflict unchanged', async () => {
|
||||
const { agent, req, res, loadTools, db } = createMocks({
|
||||
provider: Providers.OPENAI,
|
||||
providerTools: [nativeWebSearchTool],
|
||||
|
|
@ -1456,27 +1551,21 @@ describe('initializeAgent — execute_code capability expansion', () => {
|
|||
expect(neitherAgent.codeEnvAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('trips GOOGLE_TOOL_CONFLICT on Google/Vertex when execute_code expands alongside provider tools', async () => {
|
||||
/* Pre-Phase 8, an `execute_code`-only agent on Google/Vertex with
|
||||
`options.tools` populated would throw GOOGLE_TOOL_CONFLICT because
|
||||
`CodeExecutionToolDefinition` populated `toolDefinitions` and
|
||||
`hasAgentTools` was true. After dropping that registry entry, the
|
||||
check is now gated on the runtime-expanded `bash_tool` + `read_file`
|
||||
pair — so the expansion MUST happen before `hasAgentTools` is
|
||||
computed or the guard silently goes away for this scenario. */
|
||||
it('allows Google provider tools alongside execute_code definitions', async () => {
|
||||
const { agent, req, res, loadTools, db } = createMocks({
|
||||
provider: Providers.GOOGLE,
|
||||
overrideProvider: Providers.GOOGLE,
|
||||
model: 'gemini-3.5-flash',
|
||||
});
|
||||
agent.tools = ['execute_code'];
|
||||
|
||||
/* Surface an options.tools array from the provider config — this is
|
||||
the `google_search` / `url_context` built-in LLM tooling that
|
||||
the `googleSearch` / `urlContext` built-in LLM tooling that
|
||||
Google/Vertex exposes via provider options. */
|
||||
mockGetProviderConfig.mockReturnValue({
|
||||
getOptions: jest.fn().mockResolvedValue({
|
||||
llmConfig: { model: 'test-model', maxTokens: 4096 },
|
||||
tools: [{ google_search: {} }],
|
||||
llmConfig: { model: 'gemini-3.5-flash', maxTokens: 4096 },
|
||||
tools: [{ googleSearch: {} }],
|
||||
} satisfies InitializeResultBase),
|
||||
overrideProvider: Providers.GOOGLE,
|
||||
});
|
||||
|
|
@ -1495,7 +1584,52 @@ describe('initializeAgent — execute_code capability expansion', () => {
|
|||
},
|
||||
db,
|
||||
),
|
||||
).rejects.toThrow(/google_tool_conflict/);
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
tools: [{ googleSearch: {} }],
|
||||
toolDefinitions: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'bash_tool' }),
|
||||
expect.objectContaining({ name: 'read_file' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('combines Google provider tools with structured external tools', async () => {
|
||||
const structuredTool = {
|
||||
name: 'weather',
|
||||
description: 'Get weather',
|
||||
schema: { type: 'object', properties: {} },
|
||||
};
|
||||
const providerTool = { googleSearch: {} };
|
||||
const { agent, req, res, loadTools, db } = createMocks({
|
||||
provider: Providers.GOOGLE,
|
||||
overrideProvider: Providers.GOOGLE,
|
||||
model: 'gemini-3.5-flash',
|
||||
providerTools: [providerTool],
|
||||
structuredTools: [structuredTool],
|
||||
});
|
||||
agent.tools = ['weather'];
|
||||
|
||||
await expect(
|
||||
initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.GOOGLE]),
|
||||
isInitialAgent: true,
|
||||
codeEnvAvailable: false,
|
||||
},
|
||||
db,
|
||||
),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
tools: [structuredTool, providerTool],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,22 @@ function hasToolDefinition(toolDefinitions: LCTool[] | undefined, name: string):
|
|||
return toolDefinitions?.some((toolDefinition) => toolDefinition.name === name) === true;
|
||||
}
|
||||
|
||||
function resolveAnthropicToolConflicts({
|
||||
function hasGoogleSearchTool(tool: unknown): boolean {
|
||||
if (tool == null || typeof tool !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return 'googleSearch' in tool || 'googleSearchRetrieval' in tool;
|
||||
}
|
||||
|
||||
function supportsGoogleToolCombination(model: unknown): boolean {
|
||||
if (typeof model !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const normalized = model.toLowerCase().split('/').pop() ?? model.toLowerCase();
|
||||
return normalized.startsWith('gemini-3');
|
||||
}
|
||||
|
||||
function resolveProviderToolConflicts({
|
||||
provider,
|
||||
tools,
|
||||
toolDefinitions,
|
||||
|
|
@ -86,7 +101,7 @@ function resolveAnthropicToolConflicts({
|
|||
tools?: unknown[];
|
||||
toolDefinitions?: LCTool[];
|
||||
}): unknown[] | undefined {
|
||||
if (provider !== Providers.ANTHROPIC || !tools?.length) {
|
||||
if (!tools?.length) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
|
|
@ -94,9 +109,19 @@ function resolveAnthropicToolConflicts({
|
|||
return tools;
|
||||
}
|
||||
|
||||
const shouldRemoveTool = (tool: unknown): boolean => {
|
||||
if (provider === Providers.ANTHROPIC) {
|
||||
return getToolName(tool) === Tools.web_search;
|
||||
}
|
||||
if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) {
|
||||
return hasGoogleSearchTool(tool);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let removed = 0;
|
||||
const resolvedTools = tools.filter((tool) => {
|
||||
const shouldRemove = getToolName(tool) === Tools.web_search;
|
||||
const shouldRemove = shouldRemoveTool(tool);
|
||||
if (shouldRemove) {
|
||||
removed += 1;
|
||||
}
|
||||
|
|
@ -105,7 +130,7 @@ function resolveAnthropicToolConflicts({
|
|||
|
||||
if (removed > 0) {
|
||||
logger.debug(
|
||||
`[initializeAgent] Removed ${removed} Anthropic native web_search tool(s); LibreChat web_search is enabled.`,
|
||||
`[initializeAgent] Removed ${removed} ${provider} native web search tool(s); LibreChat web_search is enabled.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -847,9 +872,9 @@ export async function initializeAgent(
|
|||
* never accidentally registers `bash_tool` or primes sandbox files
|
||||
* just because the admin globally enabled code execution.
|
||||
*
|
||||
* Done BEFORE the `hasAgentTools` / GOOGLE_TOOL_CONFLICT gate so
|
||||
* execute-code-only agents on Google/Vertex still trip the conflict
|
||||
* guard when provider-specific tools are also configured. Also before
|
||||
* Done before provider-tool merging so execute-code-only agents on
|
||||
* Google/Vertex still surface as external function definitions when
|
||||
* provider-specific tools are also configured. Also before
|
||||
* `injectSkillCatalog` so the skill path's own
|
||||
* `registerCodeExecutionTools` call upgrades `read_file` from the
|
||||
* code-only description to the skill-aware description without adding a
|
||||
|
|
@ -882,7 +907,7 @@ export async function initializeAgent(
|
|||
|
||||
/** Check for tool presence from either full instances or definitions (event-driven mode) */
|
||||
const hasAgentTools = (structuredTools?.length ?? 0) > 0 || (toolDefinitions?.length ?? 0) > 0;
|
||||
const providerTools = resolveAnthropicToolConflicts({
|
||||
const providerTools = resolveProviderToolConflicts({
|
||||
provider: agent.provider,
|
||||
tools: options.tools,
|
||||
toolDefinitions,
|
||||
|
|
@ -898,7 +923,12 @@ export async function initializeAgent(
|
|||
hasProviderTools &&
|
||||
hasAgentTools
|
||||
) {
|
||||
throw new Error(`{ "type": "${ErrorTypes.GOOGLE_TOOL_CONFLICT}"}`);
|
||||
if (!supportsGoogleToolCombination(llmConfig.model)) {
|
||||
throw new Error(`{ "type": "${ErrorTypes.GOOGLE_TOOL_CONFLICT}"}`);
|
||||
}
|
||||
if (structuredTools?.length) {
|
||||
tools = structuredTools.concat(providerTools as GenericTool[]);
|
||||
}
|
||||
} else if (
|
||||
(agent.provider === Providers.OPENAI ||
|
||||
agent.provider === Providers.AZURE ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue