diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index f6302964..e7918317 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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() + + 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() + + 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() diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ef27c134..20c4f8fa 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 87fc06e1..5bec1ead 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -492,6 +492,23 @@ export const zh: Record = { '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': '新建会话', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 5aaf1a4f..073dbe34 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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 = { 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 (
{/* Appearance selector */} @@ -1002,6 +1018,96 @@ function GeneralSettings() {
+ +
+

{t('settings.general.webSearchTitle')}

+

{t('settings.general.webSearchDescription')}

+
+
+ {WEB_SEARCH_MODES.map(({ value, label }) => ( + + ))} +
+
+ + setWebSearchDraft({ + ...webSearchDraft, + tavilyApiKey: event.target.value, + }) + } + /> +
+ {t('settings.general.webSearchTavilyFreeHint')} + + {t('settings.general.webSearchGetApiKey')} + +
+ + setWebSearchDraft({ + ...webSearchDraft, + braveApiKey: event.target.value, + }) + } + /> +
+ {t('settings.general.webSearchBraveFreeHint')} + + {t('settings.general.webSearchGetApiKey')} + +
+
+
+

+ {t('settings.general.webSearchHint')} +

+ +
+
+
) } diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index ca038abe..81668221 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -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 setSkipWebFetchPreflight: (enabled: boolean) => Promise + setWebSearch: (settings: WebSearchSettings) => Promise } export const useSettingsStore = create((set, get) => ({ @@ -48,6 +50,7 @@ export const useSettingsStore = create((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((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((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 ?? '', + } +} diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 66352ef3..0880fe4b 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -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 } diff --git a/src/tools/WebSearchTool/WebSearchTool.ts b/src/tools/WebSearchTool/WebSearchTool.ts index bbe12eb7..a49742ff 100644 --- a/src/tools/WebSearchTool/WebSearchTool.ts +++ b/src/tools/WebSearchTool/WebSearchTool.ts @@ -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 -type Input = z.infer +export type Input = z.infer 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) + +async function callAnthropicNativeWebSearch( + input: Input, + context: ToolUseContext, + onProgress: ToolCallProgress | 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() + + 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 } +} diff --git a/src/tools/WebSearchTool/backend.test.ts b/src/tools/WebSearchTool/backend.test.ts new file mode 100644 index 00000000..772a3b5f --- /dev/null +++ b/src/tools/WebSearchTool/backend.test.ts @@ -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, + ) + }) +}) diff --git a/src/tools/WebSearchTool/backend.ts b/src/tools/WebSearchTool/backend.ts new file mode 100644 index 00000000..281b8bd5 --- /dev/null +++ b/src/tools/WebSearchTool/backend.ts @@ -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([ + 'auto', + 'anthropic', + 'tavily', + 'brave', + 'disabled', +]) + +const unsupportedNativeModels = new Set() + +export function isLikelyClaudeModel(model: string | undefined): boolean { + if (!model) { + return false + } + + return /(^|[/:._-])claude([/:._-]|$)/.test(model.toLowerCase()) +} + +export function getConfiguredWebSearchSettings( + settings: Pick = 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, + input: Input, + apiKey: string, + signal: AbortSignal, +): Promise { + 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 | null { + if (settings.tavilyApiKey) { + return 'tavily' + } + if (settings.braveApiKey) { + return 'brave' + } + return null +} + +export function getApiKeyForProvider( + provider: Exclude, + 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 { + 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 { + 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, + 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 { + const text = await response.text().catch(() => '') + return text.slice(0, 500) +} diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 1215605c..62aa755f 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -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()