refactor(tool-provider): rename web_search to exa_web_search, drop XOR toggle (#19969)

## 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) <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-22 14:57:44 +02:00
committed by GitHub
parent f018f17133
commit 0c929e7903
10 changed files with 63 additions and 73 deletions
@@ -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(
@@ -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)) {
@@ -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<boolean> {
@@ -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,
@@ -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,
),
);
}
@@ -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) {}
@@ -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',
@@ -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,
@@ -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({
@@ -247,7 +247,6 @@ ${skillsList}`;
preloadedTools: string[],
): string {
const preloadedSet = new Set(preloadedTools);
const hasWebSearch = preloadedSet.has('web_search');
const toolsByCategory = new Map<string, ToolIndexEntry[]>();
@@ -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');
}
@@ -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<AgentEntity>,
@@ -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,