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:
+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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user