diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 6636c13af6..d7fa396024 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -33,7 +33,7 @@ jest.mock('@librechat/agents', () => ({ })); import { Providers } from '@librechat/agents'; -import { EModelEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, Tools } from 'librechat-data-provider'; import type { Agent } from 'librechat-data-provider'; import type { ServerRequest, InitializeResultBase, EndpointTokenConfig } from '~/types'; import type { InitializeAgentDbMethods } from '../initialize'; @@ -126,6 +126,13 @@ function createMocks(overrides?: { maxOutputTokens?: number; endpointTokenConfig?: EndpointTokenConfig; useRealTokenLookup?: boolean; + providerTools?: unknown[]; + loadedToolDefinitions?: Array<{ + name: string; + description?: string; + parameters?: object; + }>; + structuredTools?: unknown[]; }) { const { provider = Providers.OPENAI, @@ -136,6 +143,9 @@ function createMocks(overrides?: { maxOutputTokens = 4096, endpointTokenConfig, useRealTokenLookup = false, + providerTools, + loadedToolDefinitions = [], + structuredTools = [], } = overrides ?? {}; const resolvedOverrideProvider = overrideProvider ?? provider; @@ -158,6 +168,7 @@ function createMocks(overrides?: { const mockGetOptions = jest.fn().mockResolvedValue({ llmConfig: { model, maxTokens: maxOutputTokens }, endpointTokenConfig, + ...(providerTools !== undefined ? { tools: providerTools } : {}), } satisfies InitializeResultBase); mockGetProviderConfig.mockReturnValue({ @@ -181,12 +192,12 @@ function createMocks(overrides?: { mockOptionalChainWithEmptyCheck.mockImplementation(realUtils.optionalChainWithEmptyCheck); const loadTools = jest.fn().mockResolvedValue({ - tools: [], + tools: structuredTools, toolContextMap: {}, dynamicToolContextMap: {}, userMCPAuthMap: undefined, toolRegistry: undefined, - toolDefinitions: [], + toolDefinitions: loadedToolDefinitions, hasDeferredTools: false, }); @@ -202,6 +213,27 @@ function createMocks(overrides?: { return { agent, req, res, loadTools, db }; } +function countNamedWebSearchTools(tools: unknown[] | undefined): number { + return ( + tools?.filter((tool) => { + if (tool == null || typeof tool !== 'object') { + return false; + } + const { name } = tool as { name?: unknown }; + return name === Tools.web_search; + }).length ?? 0 + ); +} + +function countWebSearchDefinitions( + toolDefinitions: Array<{ name: string }> | undefined, +): number { + return ( + toolDefinitions?.filter((toolDefinition) => toolDefinition.name === Tools.web_search).length ?? + 0 + ); +} + describe('initializeAgent — custom provider token lookup', () => { const CUSTOM_PROVIDER = 'EduGPT'; @@ -276,6 +308,123 @@ describe('initializeAgent — custom provider token lookup', () => { }); }); +describe('initializeAgent — Anthropic web_search precedence', () => { + const nativeWebSearchTool = { + type: 'web_search_20250305', + name: Tools.web_search, + }; + const libreChatWebSearchDefinition = { + name: Tools.web_search, + description: 'Search the web', + parameters: { type: 'object', properties: {} }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps Anthropic native web_search when LibreChat search is not selected', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.ANTHROPIC, + providerTools: [nativeWebSearchTool], + }); + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.ANTHROPIC]), + isInitialAgent: true, + }, + db, + ); + + expect(result.tools).toEqual([nativeWebSearchTool]); + expect(countNamedWebSearchTools(result.tools)).toBe(1); + expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(0); + }); + + it('keeps LibreChat web_search definitions when native Anthropic search is not enabled', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.ANTHROPIC, + loadedToolDefinitions: [libreChatWebSearchDefinition], + }); + agent.tools = [Tools.web_search]; + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.ANTHROPIC]), + isInitialAgent: true, + }, + db, + ); + + expect(result.tools).toEqual([]); + expect(countNamedWebSearchTools(result.tools)).toBe(0); + expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(1); + }); + + it('prefers LibreChat web_search when Anthropic native search is also enabled', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.ANTHROPIC, + providerTools: [nativeWebSearchTool], + loadedToolDefinitions: [libreChatWebSearchDefinition], + }); + agent.tools = [Tools.web_search]; + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.ANTHROPIC]), + isInitialAgent: true, + }, + db, + ); + + expect(result.tools).toEqual([]); + expect(countNamedWebSearchTools(result.tools)).toBe(0); + expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(1); + }); + + it('leaves non-Anthropic providers unchanged', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.OPENAI, + providerTools: [nativeWebSearchTool], + loadedToolDefinitions: [libreChatWebSearchDefinition], + }); + agent.tools = [Tools.web_search]; + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + }, + db, + ); + + expect(result.tools).toEqual([nativeWebSearchTool]); + expect(countNamedWebSearchTools(result.tools)).toBe(1); + expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(1); + }); +}); + describe('initializeAgent — stable and dynamic instruction fields', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index b4985d356e..1b668a9eb0 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -65,6 +65,53 @@ function appendAdditionalInstructions(agent: Agent, text?: string | null): void .join('\n\n'); } +function getToolName(tool: unknown): string | undefined { + if (tool == null || typeof tool !== 'object') { + return undefined; + } + const { name } = tool as { name?: unknown }; + return typeof name === 'string' ? name : undefined; +} + +function hasToolDefinition(toolDefinitions: LCTool[] | undefined, name: string): boolean { + return toolDefinitions?.some((toolDefinition) => toolDefinition.name === name) === true; +} + +function resolveAnthropicToolConflicts({ + provider, + tools, + toolDefinitions, +}: { + provider?: string; + tools?: unknown[]; + toolDefinitions?: LCTool[]; +}): unknown[] | undefined { + if (provider !== Providers.ANTHROPIC || !tools?.length) { + return tools; + } + + if (!hasToolDefinition(toolDefinitions, Tools.web_search)) { + return tools; + } + + let removed = 0; + const resolvedTools = tools.filter((tool) => { + const shouldRemove = getToolName(tool) === Tools.web_search; + if (shouldRemove) { + removed += 1; + } + return !shouldRemove; + }); + + if (removed > 0) { + logger.debug( + `[initializeAgent] Removed ${removed} Anthropic native web_search tool(s); LibreChat web_search is enabled.`, + ); + } + + return resolvedTools; +} + /** * Extended agent type with additional fields needed after initialization */ @@ -825,14 +872,20 @@ 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({ + provider: agent.provider, + tools: options.tools, + toolDefinitions, + }); + const hasProviderTools = (providerTools?.length ?? 0) > 0; - let tools: GenericTool[] = options.tools?.length - ? (options.tools as GenericTool[]) + let tools: GenericTool[] = hasProviderTools + ? (providerTools as GenericTool[]) : (structuredTools ?? []); if ( (agent.provider === Providers.GOOGLE || agent.provider === Providers.VERTEXAI) && - options.tools?.length && + hasProviderTools && hasAgentTools ) { throw new Error(`{ "type": "${ErrorTypes.GOOGLE_TOOL_CONFLICT}"}`); @@ -840,10 +893,10 @@ export async function initializeAgent( (agent.provider === Providers.OPENAI || agent.provider === Providers.AZURE || agent.provider === Providers.ANTHROPIC) && - options.tools?.length && + hasProviderTools && structuredTools?.length ) { - tools = structuredTools.concat(options.tools as GenericTool[]); + tools = structuredTools.concat(providerTools as GenericTool[]); } agent.model_parameters = { ...options.llmConfig } as Agent['model_parameters'];