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:
@@ -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)) {
|
||||
|
||||
+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,
|
||||
|
||||
+15
-21
@@ -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({
|
||||
|
||||
+8
-17
@@ -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');
|
||||
}
|
||||
|
||||
+7
-9
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user