From 0c929e7903becfbe86c629f7f0a555b19dab2cf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 22 Apr 2026 14:57:44 +0200 Subject: [PATCH] refactor(tool-provider): rename web_search to exa_web_search, drop XOR toggle (#19969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Today `WEB_SEARCH_PREFER_NATIVE` forces a **mutual exclusion**: either the custom Exa tool preloads as `web_search` or the SDK-native `web_search` binds. Same name, different backends. - This PR lets them **coexist**. Custom Exa becomes `exa_web_search`; native keeps `web_search`. The model picks based on tool descriptions. - `WEB_SEARCH_PREFER_NATIVE` and `shouldUseNativeSearch()` are deleted. Exa enablement follows `WEB_SEARCH_DRIVER` (existing). Native enablement follows the agent's `modelConfiguration.webSearch.enabled` (existing). ## Key changes **Config / service** - Deleted `WEB_SEARCH_PREFER_NATIVE` (config-variables.ts) - Deleted `WebSearchService.shouldUseNativeSearch()` - `WebSearchService.isEnabled()` unchanged — still gates Exa availability **Custom tool rename** - `ActionToolProvider.toolMap`: `'web_search'` → `'exa_web_search'` - Descriptor name matches - `WebSearchTool.description` rewritten to position Exa as structured/entity-aware, complementary to native **Native tool binder** - `NativeToolBinder.bind()` drops the `shouldUseNativeSearch` gate. Per-agent `modelConfiguration.webSearch.enabled` (inside `getNativeModelTools`) stays authoritative. **Chat** - Preload list now always includes `exa_web_search` — `ActionToolProvider` silently skips the descriptor when Exa is disabled, so `getToolsByName` degrades gracefully - Native tools always attempted; returns empty ToolSet when the model doesn't support them - `directTools = { ...preloadedTools, ...nativeSearchTools }` — both present when both enabled - `billNativeWebSearchUsage` called unconditionally (the function already short-circuits on count ≤ 0) **Workflow agent** - Same unconditional billing pattern - `WebSearchService` dependency removed **System prompt** - Dropped the special-cased `web_search` branch. Preloaded tools list uniformly now. **Frontend** - `exa_web_search` reuses the same "Searching the web for X" display as native - Test coverage added ## Billing isolation (verified) - `countNativeWebSearchCallsFromSteps` counts `toolName === 'web_search'` only. After the rename, only native calls match. Exa calls (`exa_web_search`) are billed separately via `WebSearchService.emitUsageEvent` inside `search()`. - No double-billing path. ## Behavior deltas (intended) | Scenario | Before | After | |---|---|---| | Anthropic model + Exa enabled + PREFER_NATIVE=true | native only | **both** | | Anthropic + Exa enabled + PREFER_NATIVE=false | Exa only (as `web_search`) | **both** | | Non-native model + Exa enabled | Exa as `web_search` | Exa as `exa_web_search` | | Any model + Exa disabled + native supported | native only | native only | | Workflow agent with `webSearch.enabled=true` + Anthropic + Exa enabled | native only | **both** | ## Known regression (accepted) Customers who set `WEB_SEARCH_PREFER_NATIVE=false` to force Exa-only will now **also** see native `web_search` if the model supports it. There's no chat-level kill switch after this PR. Per discussion, this is accepted — future model-level capability gating (in the model JSON) will be the right place for that control. ## Stats - 10 files, +63 / −73 (net deletion) - Typecheck clean (server: 7 pre-existing unrelated, front: 13 pre-existing unrelated — zero new either side) - Prettier clean ## Test plan - [ ] `npx nx typecheck twenty-server` and `npx nx typecheck twenty-front` pass - [ ] With Anthropic + Exa enabled: chat shows both `web_search` and `exa_web_search` in preloaded list; model can call either - [ ] With Anthropic + Exa disabled: chat shows only native `web_search` - [ ] With non-native model + Exa enabled: chat shows only `exa_web_search` - [ ] Workflow agent with `modelConfiguration.webSearch.enabled=true` + Exa enabled: both available - [ ] Billing: native calls billed via `billNativeWebSearchUsage`; Exa calls billed via `WebSearchService.emitUsageEvent`; no double-billing - [ ] Frontend: `exa_web_search` renders "Searching the web for X" the same as `web_search` Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/getToolDisplayMessage.test.ts | 19 ++++++++++ .../modules/ai/utils/getToolDisplayMessage.ts | 5 ++- .../native/native-tool-binder.service.ts | 9 ++--- .../providers/action-tool.provider.ts | 8 +++-- .../tools/web-search-tool/web-search-tool.ts | 2 +- .../twenty-config/config-variables.ts | 9 ----- .../web-search/web-search.service.ts | 7 ---- .../services/chat-execution.service.ts | 36 ++++++++----------- .../services/system-prompt-builder.service.ts | 25 +++++-------- .../ai-agent/ai-agent.workflow-action.ts | 16 ++++----- 10 files changed, 63 insertions(+), 73 deletions(-) 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,