fix: keep WebSearch usable across third-party models

Native Anthropic web search is only reliable for Claude-family model names,
while many configured providers either do not implement the server tool schema
or reject it through proxy layers. This routes WebSearch through a single
resolver, adds Tavily and Brave fallbacks, and exposes provider key setup in
the desktop settings page.

Constraint: Third-party Anthropic-compatible endpoints may reject web_search_20250305 even when the model name is Claude-like
Constraint: Non-Claude models still need a usable WebSearch path when users configure an external search provider
Rejected: Gate native WebSearch by base URL | third-party Claude proxies can support it and official-looking URLs are not the real capability boundary
Rejected: Always expose Anthropic native WebSearch | unsupported providers loop on schema or tool errors
Confidence: high
Scope-risk: moderate
Directive: Keep WebSearch capability resolution centralized in WebSearchTool/backend.ts before adding more search providers
Tested: bun test src/tools/WebSearchTool/backend.test.ts
Tested: cd desktop && bun run test -- generalSettings.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: agent-browser Settings E2E for Tavily/Brave links and settings persistence
Tested: live Tavily and Brave fallback searches returned results using transient keys
Not-tested: automatic LLM decision to invoke WebSearch end-to-end against a paid model session
This commit is contained in:
程序员阿江(Relakkes) 2026-05-01 10:14:02 +08:00
parent 9ecc178c7a
commit 3a6bad0e03
10 changed files with 876 additions and 167 deletions

View File

@ -108,12 +108,16 @@ describe('Settings > General tab', () => {
locale: 'en',
thinkingEnabled: true,
skipWebFetchPreflight: true,
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ thinkingEnabled: enabled })
}),
setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
}),
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
useSettingsStore.setState({ webSearch })
}),
})
useUIStore.setState({ pendingSettingsTab: null })
@ -166,6 +170,39 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
})
it('saves WebSearch fallback provider settings', () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
fireEvent.click(screen.getByRole('button', { name: 'Tavily' }))
fireEvent.change(screen.getByLabelText('Tavily API key'), {
target: { value: 'tvly-test-key' },
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(useSettingsStore.getState().setWebSearch).toHaveBeenCalledWith({
mode: 'tavily',
tavilyApiKey: 'tvly-test-key',
braveApiKey: '',
})
})
it('links to WebSearch provider API key dashboards', () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
expect(screen.getByRole('link', { name: 'Get Tavily API key' })).toHaveAttribute(
'href',
'https://app.tavily.com/home',
)
expect(screen.getByRole('link', { name: 'Get Brave Search API key' })).toHaveAttribute(
'href',
'https://api-dashboard.search.brave.com/app/keys',
)
})
it('keeps extension tabs available alongside the terminal tab', () => {
render(<Settings />)

View File

@ -490,6 +490,23 @@ export const en = {
'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.',
'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight',
'settings.general.webFetchPreflightHint': 'Turn this off only if you explicitly want to restore the upstream safety preflight before each WebFetch request.',
'settings.general.webSearchTitle': 'WebSearch',
'settings.general.webSearchDescription': 'Choose how agent web search is resolved across official Claude, third-party providers, and local fallback keys.',
'settings.general.webSearch.mode.auto': 'Auto',
'settings.general.webSearch.mode.tavily': 'Tavily',
'settings.general.webSearch.mode.brave': 'Brave',
'settings.general.webSearch.mode.anthropic': 'Claude',
'settings.general.webSearch.mode.disabled': 'Off',
'settings.general.webSearchTavilyKey': 'Tavily API key',
'settings.general.webSearchBraveKey': 'Brave Search API key',
'settings.general.webSearchBravePlaceholder': 'Brave Search token',
'settings.general.webSearchGetApiKey': 'Get API key',
'settings.general.webSearchTavilyApiKeyLink': 'Get Tavily API key',
'settings.general.webSearchBraveApiKeyLink': 'Get Brave Search API key',
'settings.general.webSearchTavilyFreeHint': 'Create an account and copy a key; the free tier includes 1000 credits.',
'settings.general.webSearchBraveFreeHint': 'Create an account to generate a Search API key with free usage for testing.',
'settings.general.webSearchHint': 'Auto uses native Claude web search for Claude model names, then falls back to Tavily and Brave keys.',
'settings.general.webSearchSave': 'Save',
// ─── Empty Session ──────────────────────────────────────
'empty.title': 'New session',

View File

@ -492,6 +492,23 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。',
'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检',
'settings.general.webFetchPreflightHint': '只有在你明确需要恢复上游默认安全预检时,才建议关闭这个选项。',
'settings.general.webSearchTitle': 'WebSearch',
'settings.general.webSearchDescription': '配置 Agent 联网搜索在 Claude 原生、第三方供应商和本地 fallback key 之间如何选择。',
'settings.general.webSearch.mode.auto': '自动',
'settings.general.webSearch.mode.tavily': 'Tavily',
'settings.general.webSearch.mode.brave': 'Brave',
'settings.general.webSearch.mode.anthropic': 'Claude',
'settings.general.webSearch.mode.disabled': '关闭',
'settings.general.webSearchTavilyKey': 'Tavily API Key',
'settings.general.webSearchBraveKey': 'Brave Search API Key',
'settings.general.webSearchBravePlaceholder': 'Brave Search token',
'settings.general.webSearchGetApiKey': '获取 API Key',
'settings.general.webSearchTavilyApiKeyLink': '获取 Tavily API Key',
'settings.general.webSearchBraveApiKeyLink': '获取 Brave Search API Key',
'settings.general.webSearchTavilyFreeHint': '注册账号即可复制 API Key免费额度包含 1000 Credits。',
'settings.general.webSearchBraveFreeHint': '注册账号后可创建 Search API Key免费额度可用于测试。',
'settings.general.webSearchHint': '自动模式会对 Claude 模型名优先使用原生 WebSearch失败或非 Claude 模型时再使用 Tavily/Brave。',
'settings.general.webSearchSave': '保存',
// ─── Empty Session ──────────────────────────────────────
'empty.title': '新建会话',

View File

@ -7,7 +7,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { Dropdown } from '../components/shared/Dropdown'
import type { PermissionMode, EffortLevel, ThemeMode } from '../types/settings'
import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings'
import type { Locale } from '../i18n'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
@ -880,8 +880,16 @@ function GeneralSettings() {
setTheme,
skipWebFetchPreflight,
setSkipWebFetchPreflight,
webSearch,
setWebSearch,
} = useSettingsStore()
const t = useTranslation()
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
useEffect(() => {
setWebSearchDraft(webSearch)
}, [webSearch])
const EFFORT_LABELS: Record<EffortLevel, string> = {
low: t('settings.general.effort.low'),
@ -900,6 +908,14 @@ function GeneralSettings() {
{ value: 'dark', label: t('settings.general.appearance.dark') },
]
const WEB_SEARCH_MODES: Array<{ value: WebSearchMode; label: string }> = [
{ value: 'auto', label: t('settings.general.webSearch.mode.auto') },
{ value: 'tavily', label: t('settings.general.webSearch.mode.tavily') },
{ value: 'brave', label: t('settings.general.webSearch.mode.brave') },
{ value: 'anthropic', label: t('settings.general.webSearch.mode.anthropic') },
{ value: 'disabled', label: t('settings.general.webSearch.mode.disabled') },
]
return (
<div className="max-w-xl">
{/* Appearance selector */}
@ -1002,6 +1018,96 @@ function GeneralSettings() {
</div>
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webSearchTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webSearchDescription')}</p>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
<div className="grid grid-cols-5 gap-1.5 mb-4">
{WEB_SEARCH_MODES.map(({ value, label }) => (
<button
key={value}
onClick={() => setWebSearchDraft({ ...webSearchDraft, mode: value })}
className={`h-9 px-2 text-xs font-semibold rounded-lg border transition-all truncate ${
(webSearchDraft.mode ?? 'auto') === value
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
title={label}
>
{label}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-3">
<Input
id="web-search-tavily-key"
type="password"
label={t('settings.general.webSearchTavilyKey')}
value={webSearchDraft.tavilyApiKey ?? ''}
placeholder="tvly-..."
autoComplete="off"
onChange={(event) =>
setWebSearchDraft({
...webSearchDraft,
tavilyApiKey: event.target.value,
})
}
/>
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.general.webSearchTavilyFreeHint')}</span>
<a
href="https://app.tavily.com/home"
target="_blank"
rel="noreferrer"
aria-label={t('settings.general.webSearchTavilyApiKeyLink')}
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
>
{t('settings.general.webSearchGetApiKey')}
</a>
</div>
<Input
id="web-search-brave-key"
type="password"
label={t('settings.general.webSearchBraveKey')}
value={webSearchDraft.braveApiKey ?? ''}
placeholder={t('settings.general.webSearchBravePlaceholder')}
autoComplete="off"
onChange={(event) =>
setWebSearchDraft({
...webSearchDraft,
braveApiKey: event.target.value,
})
}
/>
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.general.webSearchBraveFreeHint')}</span>
<a
href="https://api-dashboard.search.brave.com/app/keys"
target="_blank"
rel="noreferrer"
aria-label={t('settings.general.webSearchBraveApiKeyLink')}
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
>
{t('settings.general.webSearchGetApiKey')}
</a>
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<p className="text-xs text-[var(--color-text-tertiary)] leading-5">
{t('settings.general.webSearchHint')}
</p>
<Button
size="sm"
variant="secondary"
disabled={!webSearchDirty}
onClick={() => void setWebSearch(webSearchDraft)}
>
{t('settings.general.webSearchSave')}
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,7 +1,7 @@
import { create } from 'zustand'
import { settingsApi } from '../api/settings'
import { modelsApi } from '../api/models'
import type { PermissionMode, EffortLevel, ModelInfo, ThemeMode } from '../types/settings'
import type { PermissionMode, EffortLevel, ModelInfo, ThemeMode, WebSearchSettings } from '../types/settings'
import type { Locale } from '../i18n'
import { useUIStore } from './uiStore'
@ -25,6 +25,7 @@ type SettingsStore = {
locale: Locale
theme: ThemeMode
skipWebFetchPreflight: boolean
webSearch: WebSearchSettings
isLoading: boolean
error: string | null
@ -36,6 +37,7 @@ type SettingsStore = {
setLocale: (locale: Locale) => void
setTheme: (theme: ThemeMode) => Promise<void>
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
setWebSearch: (settings: WebSearchSettings) => Promise<void>
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
@ -48,6 +50,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
locale: getStoredLocale(),
theme: useUIStore.getState().theme,
skipWebFetchPreflight: true,
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
isLoading: false,
error: null,
@ -72,6 +75,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
theme,
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
isLoading: false,
error: null,
})
@ -145,4 +149,23 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
set({ skipWebFetchPreflight: prev })
}
},
setWebSearch: async (webSearch) => {
const prev = get().webSearch
const next = normalizeWebSearchSettings(webSearch)
set({ webSearch: next })
try {
await settingsApi.updateUser({ webSearch: next })
} catch {
set({ webSearch: prev })
}
},
}))
function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): WebSearchSettings {
return {
mode: settings?.mode ?? 'auto',
tavilyApiKey: settings?.tavilyApiKey ?? '',
braveApiKey: settings?.braveApiKey ?? '',
}
}

View File

@ -4,6 +4,13 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermiss
export type EffortLevel = 'low' | 'medium' | 'high' | 'max'
export type ThemeMode = 'light' | 'dark'
export type WebSearchMode = 'auto' | 'anthropic' | 'tavily' | 'brave' | 'disabled'
export type WebSearchSettings = {
mode?: WebSearchMode
tavilyApiKey?: string
braveApiKey?: string
}
export type ModelInfo = {
id: string
@ -20,5 +27,6 @@ export type UserSettings = {
permissionMode?: PermissionMode
theme?: ThemeMode
skipWebFetchPreflight?: boolean
webSearch?: WebSearchSettings
[key: string]: unknown
}

View File

@ -2,18 +2,32 @@ import type {
BetaContentBlock,
BetaWebSearchTool20250305,
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { getAPIProvider } from 'src/utils/model/providers.js'
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
import { z } from 'zod/v4'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { queryModelWithStreaming } from '../../services/api/claude.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import {
buildTool,
type ToolCallProgress,
type ToolDef,
type ToolUseContext,
} from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { logError } from '../../utils/log.js'
import { createUserMessage } from '../../utils/messages.js'
import { getMainLoopModel, getSmallFastModel } from '../../utils/model/model.js'
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
import { asSystemPrompt } from '../../utils/systemPromptType.js'
import {
getApiKeyForProvider,
getFallbackProvider,
isWebSearchEnabledForModel,
makeWebSearchUnavailableOutput,
markAnthropicNativeUnsupported,
resolveWebSearchProvider,
searchWithExternalProvider,
shouldFallbackFromNativeError,
} from './backend.js'
import { getWebSearchPrompt, WEB_SEARCH_TOOL_NAME } from './prompt.js'
import {
getToolUseSummary,
@ -37,7 +51,7 @@ const inputSchema = lazySchema(() =>
)
type InputSchema = ReturnType<typeof inputSchema>
type Input = z.infer<InputSchema>
export type Input = z.infer<InputSchema>
const searchResultSchema = lazySchema(() => {
const searchHitSchema = z.object({
@ -166,30 +180,7 @@ export const WebSearchTool = buildTool({
return summary ? `Searching for ${summary}` : 'Searching the web'
},
isEnabled() {
const provider = getAPIProvider()
const model = getMainLoopModel()
// Enable for firstParty
if (provider === 'firstParty') {
return true
}
// Enable for Vertex AI with supported models (Claude 4.0+)
if (provider === 'vertex') {
const supportsWebSearch =
model.includes('claude-opus-4') ||
model.includes('claude-sonnet-4') ||
model.includes('claude-haiku-4')
return supportsWebSearch
}
// Foundry only ships models that already support Web Search
if (provider === 'foundry') {
return true
}
return false
return isWebSearchEnabledForModel(getMainLoopModel())
},
get inputSchema(): InputSchema {
return inputSchema()
@ -254,149 +245,104 @@ export const WebSearchTool = buildTool({
async call(input, context, _canUseTool, _parentMessage, onProgress) {
const startTime = performance.now()
const { query } = input
const userMessage = createUserMessage({
content: 'Perform a web search for the query: ' + query,
})
const toolSchema = makeToolSchema(input)
const model = context.options.mainLoopModel
const resolved = resolveWebSearchProvider(model)
const useHaiku = getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_plum_vx3',
false,
)
const appState = context.getAppState()
const queryStream = queryModelWithStreaming({
messages: [userMessage],
systemPrompt: asSystemPrompt([
'You are an assistant for performing a web search tool use',
]),
thinkingConfig: useHaiku
? { type: 'disabled' as const }
: context.options.thinkingConfig,
tools: [],
signal: context.abortController.signal,
options: {
getToolPermissionContext: async () => appState.toolPermissionContext,
model: useHaiku ? getSmallFastModel() : context.options.mainLoopModel,
toolChoice: useHaiku ? { type: 'tool', name: 'web_search' } : undefined,
isNonInteractiveSession: context.options.isNonInteractiveSession,
hasAppendSystemPrompt: !!context.options.appendSystemPrompt,
extraToolSchemas: [toolSchema],
querySource: 'web_search_tool',
agents: context.options.agentDefinitions.activeAgents,
mcpTools: [],
agentId: context.agentId,
effortValue: appState.effortValue,
},
})
const allContentBlocks: BetaContentBlock[] = []
let currentToolUseId = null
let currentToolUseJson = ''
let progressCounter = 0
const toolUseQueries = new Map() // Map of tool_use_id to query
for await (const event of queryStream) {
if (event.type === 'assistant') {
allContentBlocks.push(...event.message.content)
continue
}
// Track tool use ID when server_tool_use starts
if (
event.type === 'stream_event' &&
event.event?.type === 'content_block_start'
) {
const contentBlock = event.event.content_block
if (contentBlock && contentBlock.type === 'server_tool_use') {
currentToolUseId = contentBlock.id
currentToolUseJson = ''
// Note: The ServerToolUseBlock doesn't contain input.query
// The actual query comes through input_json_delta events
continue
}
}
// Accumulate JSON for current tool use
if (
currentToolUseId &&
event.type === 'stream_event' &&
event.event?.type === 'content_block_delta'
) {
const delta = event.event.delta
if (delta?.type === 'input_json_delta' && delta.partial_json) {
currentToolUseJson += delta.partial_json
// Try to extract query from partial JSON for progress updates
try {
// Look for a complete query field
const queryMatch = currentToolUseJson.match(
/"query"\s*:\s*"((?:[^"\\]|\\.)*)"/,
)
if (queryMatch && queryMatch[1]) {
// The regex properly handles escaped characters
const query = jsonParse('"' + queryMatch[1] + '"')
if (
!toolUseQueries.has(currentToolUseId) ||
toolUseQueries.get(currentToolUseId) !== query
) {
toolUseQueries.set(currentToolUseId, query)
progressCounter++
if (onProgress) {
onProgress({
toolUseID: `search-progress-${progressCounter}`,
data: {
type: 'query_update',
query,
},
})
}
}
}
} catch {
// Ignore parsing errors for partial JSON
}
}
}
// Yield progress when search results come in
if (
event.type === 'stream_event' &&
event.event?.type === 'content_block_start'
) {
const contentBlock = event.event.content_block
if (contentBlock && contentBlock.type === 'web_search_tool_result') {
// Get the actual query that was used for this search
const toolUseId = contentBlock.tool_use_id
const actualQuery = toolUseQueries.get(toolUseId) || query
const content = contentBlock.content
progressCounter++
if (onProgress) {
onProgress({
toolUseID: toolUseId || `search-progress-${progressCounter}`,
data: {
type: 'search_results_received',
resultCount: Array.isArray(content) ? content.length : 0,
query: actualQuery,
},
})
}
}
if (resolved.provider === 'disabled') {
const durationSeconds = (performance.now() - startTime) / 1000
return {
data: makeWebSearchUnavailableOutput(
query,
durationSeconds,
'Web search is not configured for this model. Use a Claude model for native web search or add a Tavily/Brave API key in Settings.',
),
}
}
// Process the final result
const endTime = performance.now()
const durationSeconds = (endTime - startTime) / 1000
if (resolved.provider === 'tavily' || resolved.provider === 'brave') {
onProgress?.({
toolUseID: `${resolved.provider}-web-search`,
data: {
type: 'query_update',
query,
},
})
const apiKey = getApiKeyForProvider(resolved.provider, resolved.settings)
if (!apiKey) {
const durationSeconds = (performance.now() - startTime) / 1000
return {
data: makeWebSearchUnavailableOutput(
query,
durationSeconds,
`Web search provider ${resolved.provider} is selected but its API key is missing.`,
),
}
}
const data = await searchWithExternalProvider(
resolved.provider,
input,
apiKey,
context.abortController.signal,
)
onProgress?.({
toolUseID: `${resolved.provider}-web-search`,
data: {
type: 'search_results_received',
resultCount:
typeof data.results[1] === 'object' ? data.results[1].content.length : 0,
query,
},
})
return { data }
}
const data = makeOutputFromSearchResponse(
allContentBlocks,
query,
durationSeconds,
)
return { data }
try {
return await callAnthropicNativeWebSearch(
input,
context,
onProgress,
startTime,
)
} catch (error) {
if (!shouldFallbackFromNativeError(error)) {
throw error
}
markAnthropicNativeUnsupported(model)
const fallbackProvider = getFallbackProvider(resolved.settings)
if (!fallbackProvider) {
const durationSeconds = (performance.now() - startTime) / 1000
logError(error instanceof Error ? error : new Error(String(error)))
return {
data: makeWebSearchUnavailableOutput(
query,
durationSeconds,
'Native Anthropic web search failed for this endpoint, and no Tavily/Brave API key is configured for fallback.',
),
}
}
const apiKey = getApiKeyForProvider(fallbackProvider, resolved.settings)
if (!apiKey) {
const durationSeconds = (performance.now() - startTime) / 1000
return {
data: makeWebSearchUnavailableOutput(
query,
durationSeconds,
`Fallback provider ${fallbackProvider} is configured without an API key.`,
),
}
}
logError(error instanceof Error ? error : new Error(String(error)))
const data = await searchWithExternalProvider(
fallbackProvider,
input,
apiKey,
context.abortController.signal,
)
return { data }
}
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
const { query, results } = output
@ -433,3 +379,155 @@ export const WebSearchTool = buildTool({
}
},
} satisfies ToolDef<InputSchema, Output, WebSearchProgress>)
async function callAnthropicNativeWebSearch(
input: Input,
context: ToolUseContext,
onProgress: ToolCallProgress<WebSearchProgress> | undefined,
startTime: number,
) {
const { query } = input
const userMessage = createUserMessage({
content: 'Perform a web search for the query: ' + query,
})
const toolSchema = makeToolSchema(input)
const useHaiku = getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_plum_vx3',
false,
)
const appState = context.getAppState()
const queryStream = queryModelWithStreaming({
messages: [userMessage],
systemPrompt: asSystemPrompt([
'You are an assistant for performing a web search tool use',
]),
thinkingConfig: useHaiku
? { type: 'disabled' as const }
: context.options.thinkingConfig,
tools: [],
signal: context.abortController.signal,
options: {
getToolPermissionContext: async () => appState.toolPermissionContext,
model: useHaiku ? getSmallFastModel() : context.options.mainLoopModel,
toolChoice: useHaiku ? { type: 'tool', name: 'web_search' } : undefined,
isNonInteractiveSession: context.options.isNonInteractiveSession,
hasAppendSystemPrompt: !!context.options.appendSystemPrompt,
extraToolSchemas: [toolSchema],
querySource: 'web_search_tool',
agents: context.options.agentDefinitions.activeAgents,
mcpTools: [],
agentId: context.agentId,
effortValue: appState.effortValue,
},
})
const allContentBlocks: BetaContentBlock[] = []
let currentToolUseId: string | null = null
let currentToolUseJson = ''
let progressCounter = 0
const toolUseQueries = new Map<string, string>()
for await (const event of queryStream) {
if (event.type === 'assistant') {
allContentBlocks.push(...event.message.content)
continue
}
// Track tool use ID when server_tool_use starts
if (
event.type === 'stream_event' &&
event.event?.type === 'content_block_start'
) {
const contentBlock = event.event.content_block
if (contentBlock && contentBlock.type === 'server_tool_use') {
currentToolUseId = contentBlock.id
currentToolUseJson = ''
// Note: The ServerToolUseBlock doesn't contain input.query
// The actual query comes through input_json_delta events
continue
}
}
// Accumulate JSON for current tool use
if (
currentToolUseId &&
event.type === 'stream_event' &&
event.event?.type === 'content_block_delta'
) {
const delta = event.event.delta
if (delta?.type === 'input_json_delta' && delta.partial_json) {
currentToolUseJson += delta.partial_json
// Try to extract query from partial JSON for progress updates
try {
// Look for a complete query field
const queryMatch = currentToolUseJson.match(
/"query"\s*:\s*"((?:[^"\\]|\\.)*)"/,
)
if (queryMatch && queryMatch[1]) {
// The regex properly handles escaped characters
const parsedQuery = jsonParse('"' + queryMatch[1] + '"') as string
if (
!toolUseQueries.has(currentToolUseId) ||
toolUseQueries.get(currentToolUseId) !== parsedQuery
) {
toolUseQueries.set(currentToolUseId, parsedQuery)
progressCounter++
if (onProgress) {
onProgress({
toolUseID: `search-progress-${progressCounter}`,
data: {
type: 'query_update',
query: parsedQuery,
},
})
}
}
}
} catch {
// Ignore parsing errors for partial JSON
}
}
}
// Yield progress when search results come in
if (
event.type === 'stream_event' &&
event.event?.type === 'content_block_start'
) {
const contentBlock = event.event.content_block
if (contentBlock && contentBlock.type === 'web_search_tool_result') {
// Get the actual query that was used for this search
const toolUseId = contentBlock.tool_use_id
const actualQuery = toolUseQueries.get(toolUseId) || query
const content = contentBlock.content
progressCounter++
if (onProgress) {
onProgress({
toolUseID: toolUseId || `search-progress-${progressCounter}`,
data: {
type: 'search_results_received',
resultCount: Array.isArray(content) ? content.length : 0,
query: actualQuery,
},
})
}
}
}
}
// Process the final result
const endTime = performance.now()
const durationSeconds = (endTime - startTime) / 1000
const data = makeOutputFromSearchResponse(
allContentBlocks,
query,
durationSeconds,
)
return { data }
}

View File

@ -0,0 +1,81 @@
import { describe, expect, test } from 'bun:test'
import {
isLikelyClaudeModel,
isWebSearchEnabledForModel,
resolveWebSearchProvider,
shouldFallbackFromNativeError,
} from './backend.js'
describe('WebSearch backend resolver', () => {
test('detects Claude models by model name instead of provider URL', () => {
expect(isLikelyClaudeModel('claude-sonnet-4-5')).toBe(true)
expect(isLikelyClaudeModel('anthropic/claude-3-7-sonnet')).toBe(true)
expect(isLikelyClaudeModel('anthropic.claude-opus-4-1')).toBe(true)
expect(isLikelyClaudeModel('MiniMax-M2.7-highspeed')).toBe(false)
})
test('auto mode prefers native Anthropic web search for Claude model names', () => {
expect(
resolveWebSearchProvider('anthropic/claude-3-7-sonnet', {
mode: 'auto',
tavilyApiKey: 'tvly-key',
braveApiKey: 'brave-key',
}).provider,
).toBe('anthropic')
})
test('auto mode keeps WebSearch available for non-Claude models with fallback keys', () => {
expect(
resolveWebSearchProvider('gpt-5.4', {
mode: 'auto',
tavilyApiKey: 'tvly-key',
braveApiKey: 'brave-key',
}).provider,
).toBe('tavily')
expect(
resolveWebSearchProvider('gpt-5.4', {
mode: 'auto',
braveApiKey: 'brave-key',
}).provider,
).toBe('brave')
})
test('explicit provider modes require their API key', () => {
expect(resolveWebSearchProvider('gpt-5.4', { mode: 'tavily' }).provider).toBe(
'disabled',
)
expect(
resolveWebSearchProvider('gpt-5.4', {
mode: 'brave',
braveApiKey: 'brave-key',
}).provider,
).toBe('brave')
})
test('isEnabled reflects native Claude or external fallback availability', () => {
expect(isWebSearchEnabledForModel('claude-sonnet-4-5', { mode: 'auto' })).toBe(
true,
)
expect(
isWebSearchEnabledForModel('qwen3-coder', {
mode: 'auto',
tavilyApiKey: 'tvly-key',
}),
).toBe(true)
expect(isWebSearchEnabledForModel('qwen3-coder', { mode: 'auto' })).toBe(
false,
)
})
test('falls back on native tool schema/provider mismatch errors', () => {
expect(
shouldFallbackFromNativeError(
new Error('422 Extra inputs are not permitted: web_search_20250305'),
),
).toBe(true)
expect(shouldFallbackFromNativeError(new Error('network timeout'))).toBe(
false,
)
})
})

View File

@ -0,0 +1,303 @@
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
import type { SettingsJson } from '../../utils/settings/types.js'
import type { Input, Output, SearchResult } from './WebSearchTool.js'
export type WebSearchMode =
| 'auto'
| 'anthropic'
| 'tavily'
| 'brave'
| 'disabled'
export type WebSearchProvider = 'anthropic' | 'tavily' | 'brave' | 'disabled'
export type WebSearchSettings = {
mode?: WebSearchMode
tavilyApiKey?: string
braveApiKey?: string
}
export type ResolvedWebSearch = {
provider: WebSearchProvider
settings: WebSearchSettings
}
type ExternalSearchHit = {
title: string
url: string
}
const WEB_SEARCH_MODES = new Set<WebSearchMode>([
'auto',
'anthropic',
'tavily',
'brave',
'disabled',
])
const unsupportedNativeModels = new Set<string>()
export function isLikelyClaudeModel(model: string | undefined): boolean {
if (!model) {
return false
}
return /(^|[/:._-])claude([/:._-]|$)/.test(model.toLowerCase())
}
export function getConfiguredWebSearchSettings(
settings: Pick<SettingsJson, 'webSearch'> = getSettings_DEPRECATED(),
): WebSearchSettings {
const raw = settings.webSearch
if (!raw || typeof raw !== 'object') {
return {}
}
const modeCandidate = raw.mode ?? 'auto'
return {
mode: WEB_SEARCH_MODES.has(modeCandidate) ? modeCandidate : 'auto',
tavilyApiKey: normalizeApiKey(raw.tavilyApiKey),
braveApiKey: normalizeApiKey(raw.braveApiKey),
}
}
export function resolveWebSearchProvider(
model: string | undefined,
settings: WebSearchSettings = getConfiguredWebSearchSettings(),
): ResolvedWebSearch {
const mode = settings.mode ?? 'auto'
if (mode === 'disabled') {
return { provider: 'disabled', settings }
}
if (mode === 'tavily') {
return { provider: settings.tavilyApiKey ? 'tavily' : 'disabled', settings }
}
if (mode === 'brave') {
return { provider: settings.braveApiKey ? 'brave' : 'disabled', settings }
}
if (mode === 'anthropic') {
return {
provider: canUseAnthropicNativeWebSearch(model) ? 'anthropic' : 'disabled',
settings,
}
}
if (canUseAnthropicNativeWebSearch(model)) {
return { provider: 'anthropic', settings }
}
if (settings.tavilyApiKey) {
return { provider: 'tavily', settings }
}
if (settings.braveApiKey) {
return { provider: 'brave', settings }
}
return { provider: 'disabled', settings }
}
export function isWebSearchEnabledForModel(
model: string | undefined,
settings: WebSearchSettings = getConfiguredWebSearchSettings(),
): boolean {
return resolveWebSearchProvider(model, settings).provider !== 'disabled'
}
export function shouldFallbackFromNativeError(error: unknown): boolean {
const message = String(error instanceof Error ? error.message : error)
return (
/\b(400|422)\b/.test(message) ||
/web_search|server tool|tool schema|input_schema|extra input|unsupported/i.test(
message,
)
)
}
export function markAnthropicNativeUnsupported(model: string | undefined): void {
const key = normalizeModelKey(model)
if (key) {
unsupportedNativeModels.add(key)
}
}
export async function searchWithExternalProvider(
provider: Exclude<WebSearchProvider, 'anthropic' | 'disabled'>,
input: Input,
apiKey: string,
signal: AbortSignal,
): Promise<Output> {
const startTime = performance.now()
const hits =
provider === 'tavily'
? await searchWithTavily(input, apiKey, signal)
: await searchWithBrave(input, apiKey, signal)
const durationSeconds = (performance.now() - startTime) / 1000
return makeExternalSearchOutput(provider, input.query, hits, durationSeconds)
}
export function getFallbackProvider(
settings: WebSearchSettings,
): Exclude<WebSearchProvider, 'anthropic' | 'disabled'> | null {
if (settings.tavilyApiKey) {
return 'tavily'
}
if (settings.braveApiKey) {
return 'brave'
}
return null
}
export function getApiKeyForProvider(
provider: Exclude<WebSearchProvider, 'anthropic' | 'disabled'>,
settings: WebSearchSettings,
): string | null {
return provider === 'tavily'
? settings.tavilyApiKey ?? null
: settings.braveApiKey ?? null
}
export function makeWebSearchUnavailableOutput(
query: string,
durationSeconds: number,
reason: string,
): Output {
return {
query,
results: [reason],
durationSeconds,
}
}
function canUseAnthropicNativeWebSearch(model: string | undefined): boolean {
const key = normalizeModelKey(model)
return isLikelyClaudeModel(model) && (!key || !unsupportedNativeModels.has(key))
}
function normalizeModelKey(model: string | undefined): string | null {
const trimmed = model?.trim().toLowerCase()
return trimmed || null
}
function normalizeApiKey(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
return trimmed.length ? trimmed : undefined
}
async function searchWithTavily(
input: Input,
apiKey: string,
signal: AbortSignal,
): Promise<ExternalSearchHit[]> {
const response = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: input.query,
max_results: 8,
search_depth: 'basic',
include_answer: false,
include_domains: input.allowed_domains,
exclude_domains: input.blocked_domains,
}),
signal,
})
if (!response.ok) {
throw new Error(`Tavily search failed: ${response.status} ${await readErrorBody(response)}`)
}
const body = (await response.json()) as {
results?: Array<{ title?: unknown; url?: unknown }>
}
return (body.results ?? [])
.map(hit => normalizeHit(hit.title, hit.url))
.filter((hit): hit is ExternalSearchHit => hit != null)
}
async function searchWithBrave(
input: Input,
apiKey: string,
signal: AbortSignal,
): Promise<ExternalSearchHit[]> {
const url = new URL('https://api.search.brave.com/res/v1/web/search')
url.searchParams.set('q', applyDomainFiltersToQuery(input))
url.searchParams.set('count', '8')
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'X-Subscription-Token': apiKey,
},
signal,
})
if (!response.ok) {
throw new Error(`Brave search failed: ${response.status} ${await readErrorBody(response)}`)
}
const body = (await response.json()) as {
web?: { results?: Array<{ title?: unknown; url?: unknown }> }
}
return (body.web?.results ?? [])
.map(hit => normalizeHit(hit.title, hit.url))
.filter((hit): hit is ExternalSearchHit => hit != null)
}
function applyDomainFiltersToQuery(input: Input): string {
const allowed = input.allowed_domains?.filter(Boolean) ?? []
const blocked = input.blocked_domains?.filter(Boolean) ?? []
const allowedClause = allowed.length
? `(${allowed.map(domain => `site:${domain}`).join(' OR ')}) `
: ''
const blockedClause = blocked.length
? `${blocked.map(domain => `-site:${domain}`).join(' ')} `
: ''
return `${allowedClause}${blockedClause}${input.query}`.trim()
}
function normalizeHit(title: unknown, url: unknown): ExternalSearchHit | null {
if (typeof title !== 'string' || typeof url !== 'string') {
return null
}
return { title, url }
}
function makeExternalSearchOutput(
provider: Exclude<WebSearchProvider, 'anthropic' | 'disabled'>,
query: string,
hits: ExternalSearchHit[],
durationSeconds: number,
): Output {
const result: SearchResult = {
tool_use_id: `${provider}-web-search`,
content: hits,
}
return {
query,
results: [`Search provider: ${provider}`, result],
durationSeconds,
}
}
async function readErrorBody(response: Response): Promise<string> {
const text = await response.text().catch(() => '')
return text.slice(0, 500)
}

View File

@ -652,6 +652,25 @@ export const SettingsSchema = lazySchema(() =>
.describe(
'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies',
),
webSearch: z
.object({
mode: z
.enum(['auto', 'anthropic', 'tavily', 'brave', 'disabled'])
.optional()
.describe(
'WebSearch backend selection. auto uses native Claude web search for Claude model names, then Tavily, then Brave.',
),
tavilyApiKey: z
.string()
.optional()
.describe('Tavily API key for WebSearch fallback'),
braveApiKey: z
.string()
.optional()
.describe('Brave Search API key for WebSearch fallback'),
})
.optional()
.describe('Configures native and external WebSearch backends'),
sandbox: SandboxSettingsSchema().optional(),
feedbackSurveyRate: z
.number()