diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts index 857d43a355..670582f586 100644 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts @@ -86,6 +86,25 @@ describe('getToolDisplayMessage', () => { }); }); + describe('exa_web_search', () => { + it('should show the same searching-the-web message as native web_search', () => { + const message = getToolDisplayMessage( + { query: 'CRM tools' }, + 'exa_web_search', + false, + ); + + expect(message).toContain('Searching'); + expect(message).toContain('CRM tools'); + }); + + it('should handle missing query', () => { + const message = getToolDisplayMessage({}, 'exa_web_search', true); + + expect(message).toContain('Searched the web'); + }); + }); + describe('learn_tools', () => { it('should show tool names when provided', () => { const message = getToolDisplayMessage( diff --git a/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts b/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts index df8481850b..086e8ada4d 100644 --- a/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts +++ b/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts @@ -86,7 +86,10 @@ export const getToolDisplayMessage = ( const byStatus = (finished: string, inProgress: string): string => isFinished ? finished : inProgress; - if (resolvedToolName === 'web_search') { + if ( + resolvedToolName === 'web_search' || + resolvedToolName === 'exa_web_search' + ) { const query = extractSearchQuery(resolvedInput); if (isNonEmptyString(query)) { diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.service.ts index dbd2426593..f4269f1b90 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.service.ts @@ -7,7 +7,6 @@ import { type NativeToolBinder } from 'src/engine/core-modules/tool-provider/nat import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; import { ToolCategory } from 'twenty-shared/ai'; -import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service'; import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; @@ -18,7 +17,6 @@ export class NativeToolBinderService implements NativeToolBinder { constructor( private readonly aiModelConfigService: AiModelConfigService, private readonly aiModelRegistryService: AiModelRegistryService, - private readonly webSearchService: WebSearchService, ) {} async isAvailable(context: ToolProviderContext): Promise { @@ -30,13 +28,12 @@ export class NativeToolBinderService implements NativeToolBinder { return {}; } - if (!this.webSearchService.shouldUseNativeSearch()) { - return {}; - } - const registeredModel = await this.aiModelRegistryService.resolveModelForAgent(context.agent); + // Enablement is driven by the agent's model configuration + // (modelConfiguration.webSearch.enabled). If the agent does not opt into + // a capability, getNativeModelTools returns an empty ToolSet. return this.aiModelConfigService.getNativeModelTools( registeredModel, context.agent, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts index 322dbdabc5..037edb3edc 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts @@ -48,7 +48,7 @@ export class ActionToolProvider implements ToolProvider { ['search_help_center', this.searchHelpCenterTool], ['code_interpreter', this.codeInterpreterTool], ['navigate_app', this.navigateAppTool], - ['web_search', this.webSearchTool], + ['exa_web_search', this.webSearchTool], ]); } @@ -130,7 +130,11 @@ export class ActionToolProvider implements ToolProvider { if (this.webSearchService.isEnabled()) { descriptors.push( - this.buildDescriptor('web_search', this.webSearchTool, includeSchemas), + this.buildDescriptor( + 'exa_web_search', + this.webSearchTool, + includeSchemas, + ), ); } diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.ts index 54fc478a70..112eefdbcd 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/web-search-tool/web-search-tool.ts @@ -11,7 +11,7 @@ import { WebSearchService } from 'src/engine/core-modules/web-search/web-search. @Injectable() export class WebSearchTool implements Tool { description = - 'Search the web for real-time information. Returns relevant results with titles, URLs, and content snippets. Supports optional category filtering for company, people, news, and other content types.'; + 'Structured web search powered by Exa. Returns entity-aware results with category filtering (companies, people, research papers, news, and other content types). Prefer this when the query benefits from structured data or a specific category. For general real-time web browsing, prefer the native `web_search` tool when it is available.'; inputSchema = WebSearchInputZodSchema; constructor(private readonly webSearchService: WebSearchService) {} diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index cc2ba34948..c0111afe41 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -695,15 +695,6 @@ export class ConfigVariables { @ValidateIf((env) => env.WEB_SEARCH_DRIVER === WebSearchDriverType.EXA) EXA_API_KEY?: string; - @ConfigVariablesMetadata({ - group: ConfigVariablesGroup.LLM, - description: - 'When true, use native provider search (Anthropic/OpenAI) when available. When false, always prefer the configured driver (e.g. Exa).', - type: ConfigVariableType.BOOLEAN, - }) - @IsOptional() - WEB_SEARCH_PREFER_NATIVE = false; - @ConfigVariablesMetadata({ group: ConfigVariablesGroup.ANALYTICS_CONFIG, description: 'Enable or disable analytics for telemetry', diff --git a/packages/twenty-server/src/engine/core-modules/web-search/web-search.service.ts b/packages/twenty-server/src/engine/core-modules/web-search/web-search.service.ts index c2422810f6..f4b8f5cf35 100644 --- a/packages/twenty-server/src/engine/core-modules/web-search/web-search.service.ts +++ b/packages/twenty-server/src/engine/core-modules/web-search/web-search.service.ts @@ -33,13 +33,6 @@ export class WebSearchService { ); } - shouldUseNativeSearch(): boolean { - return ( - this.twentyConfigService.get('WEB_SEARCH_PREFER_NATIVE') || - !this.isEnabled() - ); - } - async search( query: string, options?: WebSearchOptions, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index dd3b7a5ab0..e317bbc7e9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -58,7 +58,6 @@ import { } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service'; import { type AiModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type'; -import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service'; import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; export type ChatExecutionOptions = { @@ -94,7 +93,6 @@ export class ChatExecutionService { private readonly exceptionHandlerService: ExceptionHandlerService, private readonly sdkProviderFactory: SdkProviderFactoryService, private readonly messagePruningService: MessagePruningService, - private readonly webSearchService: WebSearchService, ) {} async streamChat({ @@ -141,12 +139,10 @@ export class ChatExecutionService { `Built tool catalog with ${toolCatalog.length} tools, ${skillCatalog.length} skills available`, ); - const useNativeSearch = this.webSearchService.shouldUseNativeSearch(); - - const toolNamesToPreload = [ - ...COMMON_PRELOAD_TOOLS, - ...(useNativeSearch ? [] : ['web_search']), - ]; + // Preload Exa when the workspace has it enabled; ActionToolProvider + // only emits the exa_web_search descriptor when isEnabled() is true, + // so getToolsByName silently skips it otherwise. + const toolNamesToPreload = [...COMMON_PRELOAD_TOOLS, 'exa_web_search']; const preloadedTools = await this.toolRegistry.getToolsByName( toolNamesToPreload, @@ -170,10 +166,11 @@ export class ChatExecutionService { registeredModel.modelId, ); + // Native web_search is returned when the resolved model's SDK provider + // exposes it (Anthropic, OpenAI). Coexists with exa_web_search when both + // are available — the model picks based on tool descriptions. const { tools: nativeSearchTools, callableToolNames: searchToolNames } = - useNativeSearch - ? this.getNativeWebSearchTools(registeredModel) - : { tools: {}, callableToolNames: [] }; + this.getNativeWebSearchTools(registeredModel); // Tools the model can call directly: preloaded registry tools (already // serialized by the hydrator) plus SDK-native tools (opaque, never @@ -331,16 +328,13 @@ export class ChatExecutionService { userWorkspaceId, ); - if (useNativeSearch) { - const nativeWebSearchCallCount = - countNativeWebSearchCallsFromSteps(steps); - - this.aiBillingService.billNativeWebSearchUsage( - nativeWebSearchCallCount, - workspace.id, - userWorkspaceId, - ); - } + // billNativeWebSearchUsage short-circuits when count <= 0, so calling + // unconditionally is safe regardless of whether native search fired. + this.aiBillingService.billNativeWebSearchUsage( + countNativeWebSearchCallsFromSteps(steps), + workspace.id, + userWorkspaceId, + ); }; const stream = streamText({ diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts index 9a8d251580..4ff48e4463 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts @@ -247,7 +247,6 @@ ${skillsList}`; preloadedTools: string[], ): string { const preloadedSet = new Set(preloadedTools); - const hasWebSearch = preloadedSet.has('web_search'); const toolsByCategory = new Map(); @@ -261,23 +260,19 @@ ${skillsList}`; const sections: string[] = []; - const webSearchLine = hasWebSearch - ? `- \`web_search\` ✓: Search the web for real-time information (ALWAYS use this for current data, news, research)` - : `- Web search is automatically available — the model will search the web when needed. Do NOT call \`web_search\` as a tool.`; - - const otherPreloadedTools = preloadedTools.filter( - (name) => name !== 'web_search', - ); + const preloadedList = + preloadedTools.length > 0 + ? preloadedTools.map((toolName) => `- \`${toolName}\` ✓`).join('\n') + : '(none)'; sections.push(` ## Available Tools -You have access to ${toolCatalog.length} tools plus native web search. Some are pre-loaded and ready to use immediately. +You have access to ${toolCatalog.length} tools. Some are pre-loaded and ready to use immediately. To use any other tool, first call \`${LEARN_TOOLS_TOOL_NAME}\` to learn its schema, then call \`${EXECUTE_TOOL_TOOL_NAME}\` to run it. ### Pre-loaded Tools (ready to use now) -${webSearchLine} -${otherPreloadedTools.length > 0 ? otherPreloadedTools.map((toolName) => `- \`${toolName}\` ✓`).join('\n') : ''} +${preloadedList} ### Tool Catalog by Category`); @@ -303,14 +298,10 @@ ${tools .join('\n')}`); } - const webSearchInstruction = hasWebSearch - ? `1. **Web search** (\`web_search\`): Use for ANY request requiring current/real-time information from the internet\n` - : ''; - sections.push(` ### How to Use Tools -${webSearchInstruction}${hasWebSearch ? '2' : '1'}. **Pre-loaded tools** (marked with ✓): Use directly -${hasWebSearch ? '3' : '2'}. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NAME}({toolNames: ["tool_name"]})\` to learn the schema, then call \`${EXECUTE_TOOL_TOOL_NAME}({toolName: "tool_name", arguments: {...}})\` to run it`); +1. **Pre-loaded tools** (marked with ✓): Use directly +2. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NAME}({toolNames: ["tool_name"]})\` to learn the schema, then call \`${EXECUTE_TOOL_TOOL_NAME}({toolName: "tool_name", arguments: {...}})\` to run it`); return sections.join('\n'); } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts index 4cf5388f3f..b16453fc95 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts @@ -10,7 +10,6 @@ import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-age import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum'; -import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service'; import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants'; import { WorkflowStepExecutorException, @@ -28,7 +27,6 @@ export class AiAgentWorkflowAction implements WorkflowAction { constructor( private readonly aiAgentExecutionService: AgentAsyncExecutorService, private readonly aiBillingService: AiBillingService, - private readonly webSearchService: WebSearchService, private readonly workflowExecutionContextService: WorkflowExecutionContextService, @InjectRepository(AgentEntity) private readonly agentRepository: Repository, @@ -101,13 +99,13 @@ export class AiAgentWorkflowAction implements WorkflowAction { userWorkspaceId, ); - if (this.webSearchService.shouldUseNativeSearch()) { - this.aiBillingService.billNativeWebSearchUsage( - nativeWebSearchCallCount, - workspaceId, - userWorkspaceId, - ); - } + // billNativeWebSearchUsage short-circuits when count <= 0, so calling + // unconditionally is safe regardless of whether native search fired. + this.aiBillingService.billNativeWebSearchUsage( + nativeWebSearchCallCount, + workspaceId, + userWorkspaceId, + ); return { result,