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:
+3
-6
@@ -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,
|
||||
|
||||
+6
-2
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user