Files
orcs-code/src/tools/WebSearchTool/WebSearchTool.ts
Kevin Codex 531e3f1059 feat(tools): resilient web search and fetch across all providers (#836)
- Add exponential backoff retry to DuckDuckGo adapter (3 attempts with
  jitter) to handle transient rate-limiting and connection errors.
- Add native fetch() fallback in WebFetch when axios hangs with custom
  DNS lookup in bundled contexts.
- Prevent broken native-path fallback for web search on OpenAI shim
  providers (minimax, moonshot, nvidia-nim, etc.) that do not support
  Anthropic's web_search_20250305 tool.
- Cherry-pick existing fixes:
  - a48bd56: cover codex/minimax/nvidia-nim in getSmallFastModel()
  - 31f0b68: 45s budget + raw-markdown fallback for secondary model
  - 446c1e8: sparse Codex /responses payload parsing
  - ae3f0b2: echo reasoning_content on assistant tool-call messages
- Fix domainCheck.test.ts mock modules to include isFirstPartyAnthropicBaseUrl
  and isGithubNativeAnthropicMode exports.

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-23 01:14:00 +08:00

874 lines
27 KiB
TypeScript

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 { collectCodexCompletedResponse } from '../../services/api/codexShim.js'
import { fetchWithProxyRetry } from '../../services/api/fetchWithProxyRetry.js'
import {
resolveCodexApiCredentials,
resolveProviderRequest,
} from '../../services/api/providerConfig.js'
import { buildTool, type ToolDef } 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 { getWebSearchPrompt, WEB_SEARCH_TOOL_NAME } from './prompt.js'
import {
getToolUseSummary,
renderToolResultMessage,
renderToolUseMessage,
renderToolUseProgressMessage,
} from './UI.js'
import {
runSearch,
getProviderMode,
getAvailableProviders,
type ProviderOutput,
} from './providers/index.js'
const inputSchema = lazySchema(() =>
z.strictObject({
query: z.string().min(2).describe('The search query to use'),
allowed_domains: z
.array(z.string())
.optional()
.describe('Only include search results from these domains'),
blocked_domains: z
.array(z.string())
.optional()
.describe('Never include search results from these domains'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type Input = z.infer<InputSchema>
const searchResultSchema = lazySchema(() => {
const searchHitSchema = z.object({
title: z.string().describe('The title of the search result'),
url: z.string().describe('The URL of the search result'),
})
return z.object({
tool_use_id: z.string().describe('ID of the tool use'),
content: z.array(searchHitSchema).describe('Array of search hits'),
})
})
export type SearchResult = z.infer<ReturnType<typeof searchResultSchema>>
const outputSchema = lazySchema(() =>
z.object({
query: z.string().describe('The search query that was executed'),
results: z
.array(z.union([searchResultSchema(), z.string()]))
.describe('Search results and/or text commentary from the model'),
durationSeconds: z
.number()
.describe('Time taken to complete the search operation'),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
// Re-export WebSearchProgress from centralized types to break import cycles
export type { WebSearchProgress } from '../../types/tools.js'
import type { WebSearchProgress } from '../../types/tools.js'
// ---------------------------------------------------------------------------
// Shared formatting: ProviderOutput → Output
// ---------------------------------------------------------------------------
function formatProviderOutput(po: ProviderOutput, query: string): Output {
const results: (SearchResult | string)[] = []
const snippets = po.hits
.filter(h => h.description)
.map(h => `**${h.title}** — ${h.description} (${h.url})`)
.join('\n')
if (snippets) results.push(snippets)
if (po.hits.length > 0) {
results.push({
tool_use_id: `${po.providerName}-search`,
content: po.hits.map(h => ({ title: h.title, url: h.url })),
})
}
if (results.length === 0) results.push('No results found.')
return {
query,
results,
durationSeconds: po.durationSeconds,
}
}
// ---------------------------------------------------------------------------
// Native Anthropic + Codex paths (unchanged, tightly coupled to SDK)
// ---------------------------------------------------------------------------
function makeToolSchema(input: Input): BetaWebSearchTool20250305 {
return {
type: 'web_search_20250305',
name: 'web_search',
allowed_domains: input.allowed_domains,
blocked_domains: input.blocked_domains,
max_uses: 15, // Allow up to 15 searches per query for better coverage
}
}
function isClaudeModel(model: string): boolean {
return /claude/i.test(model)
}
function isCodexResponsesWebSearchEnabled(): boolean {
if (getAPIProvider() !== 'openai') {
return false
}
const request = resolveProviderRequest({
model: getMainLoopModel(),
baseUrl: process.env.OPENAI_BASE_URL,
})
return request.transport === 'codex_responses'
}
function makeCodexWebSearchTool(input: Input): Record<string, unknown> {
const tool: Record<string, unknown> = {
type: 'web_search',
}
if (input.allowed_domains?.length) {
tool.filters = {
allowed_domains: input.allowed_domains,
}
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (timezone) {
tool.user_location = {
type: 'approximate',
timezone,
}
}
return tool
}
function buildCodexWebSearchInputText(input: Input): string {
if (!input.blocked_domains?.length) {
return input.query
}
// Responses web_search supports allowed_domains filters but not blocked domains.
// Convert blocked domains into common search-engine exclusion operators so the
// constraint still affects ranking and candidate selection.
const excludedSites = input.blocked_domains.map(domain => `-site:${domain}`)
return `${input.query} ${excludedSites.join(' ')}`
}
function buildCodexWebSearchInput(input: Input): Array<Record<string, unknown>> {
return [
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text: buildCodexWebSearchInputText(input),
},
],
},
]
}
function buildCodexWebSearchInstructions(): string {
return [
'You are the OpenClaude web search tool.',
'Search the web for the user query and return a concise factual answer.',
'Include source URLs in the response.',
].join(' ')
}
function pushCodexTextResult(
results: (SearchResult | string)[],
value: unknown,
): void {
if (typeof value !== 'string') return
const trimmed = value.trim()
if (trimmed) {
results.push(trimmed)
}
}
function addCodexSource(
sourceMap: Map<string, { title: string; url: string }>,
source: unknown,
): void {
if (typeof source?.url !== 'string' || !source.url) return
sourceMap.set(source.url, {
title:
typeof source.title === 'string' && source.title
? source.title
: source.url,
url: source.url,
})
}
function getCodexSources(item: Record<string, any>): unknown[] {
if (Array.isArray(item.action?.sources)) {
return item.action.sources
}
if (Array.isArray(item.sources)) {
return item.sources
}
if (Array.isArray(item.result?.sources)) {
return item.result.sources
}
return []
}
function extractCodexWebSearchFailure(item: Record<string, any>): string | undefined {
// Codex web_search_call items can carry a status field. When the tool
// call fails (rate limit, upstream error, model-side guardrail), the
// parser should surface a meaningful error rather than the generic
// "No results found." fallback. Shape observed across recent payloads:
// { type: 'web_search_call', status: 'failed', error: { message?: string } }
// { type: 'web_search_call', status: 'failed', action: { error?: { message?: string } } }
if (item?.status !== 'failed') return undefined
const reason =
(typeof item.error?.message === 'string' && item.error.message) ||
(typeof item.action?.error?.message === 'string' &&
item.action.error.message) ||
(typeof item.error === 'string' && item.error) ||
undefined
return reason ? `Web search failed: ${reason}` : 'Web search failed.'
}
function makeOutputFromCodexWebSearchResponse(
response: Record<string, unknown>,
query: string,
durationSeconds: number,
): Output {
const results: (SearchResult | string)[] = []
const sourceMap = new Map<string, { title: string; url: string }>()
const output = Array.isArray(response.output) ? response.output : []
for (const item of output) {
if (item?.type === 'web_search_call') {
const failure = extractCodexWebSearchFailure(item)
if (failure) {
results.push(failure)
}
for (const source of getCodexSources(item)) {
addCodexSource(sourceMap, source)
}
continue
}
if (item?.type !== 'message' || !Array.isArray(item.content)) {
continue
}
for (const part of item.content) {
if (part?.type === 'output_text' || part?.type === 'text') {
pushCodexTextResult(results, part.text)
}
for (const source of getCodexSources(part)) {
addCodexSource(sourceMap, source)
}
const annotations = Array.isArray(part?.annotations)
? part.annotations
: []
for (const annotation of annotations) {
if (annotation?.type !== 'url_citation') continue
addCodexSource(sourceMap, annotation)
}
}
}
if (results.length === 0) {
pushCodexTextResult(results, response.output_text)
}
if (sourceMap.size > 0) {
results.push({
tool_use_id: 'codex-web-search',
content: Array.from(sourceMap.values()),
})
}
if (results.length === 0) {
results.push('No results found.')
}
return {
query,
results,
durationSeconds,
}
}
export const __test = {
makeOutputFromCodexWebSearchResponse,
}
async function runCodexWebSearch(
input: Input,
signal: AbortSignal,
): Promise<Output> {
const startTime = performance.now()
const request = resolveProviderRequest({
model: getMainLoopModel(),
baseUrl: process.env.OPENAI_BASE_URL,
})
const credentials = resolveCodexApiCredentials()
if (!credentials.apiKey) {
throw new Error('Codex web search requires CODEX_API_KEY or a valid auth.json.')
}
if (!credentials.accountId) {
throw new Error(
'Codex web search requires CHATGPT_ACCOUNT_ID or an auth.json with chatgpt_account_id.',
)
}
const body: Record<string, unknown> = {
model: request.resolvedModel,
input: buildCodexWebSearchInput(input),
instructions: buildCodexWebSearchInstructions(),
tools: [makeCodexWebSearchTool(input)],
tool_choice: 'required',
include: ['web_search_call.action.sources'],
store: false,
stream: true,
}
if (request.reasoning) {
body.reasoning = request.reasoning
}
const response = await fetchWithProxyRetry(`${request.baseUrl}/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${credentials.apiKey}`,
'chatgpt-account-id': credentials.accountId,
originator: 'openclaude',
},
body: JSON.stringify(body),
signal,
})
if (!response.ok) {
const errorBody = await response.text().catch(() => 'unknown error')
throw new Error(`Codex web search error ${response.status}: ${errorBody}`)
}
const payload = await collectCodexCompletedResponse(response)
const endTime = performance.now()
return makeOutputFromCodexWebSearchResponse(
payload,
input.query,
(endTime - startTime) / 1000,
)
}
function makeOutputFromSearchResponse(
result: BetaContentBlock[],
query: string,
durationSeconds: number,
): Output {
// The result is a sequence of these blocks:
// - text to start -- always?
// [
// - server_tool_use
// - web_search_tool_result
// - text and citation blocks intermingled
// ]+ (this block repeated for each search)
const results: (SearchResult | string)[] = []
let textAcc = ''
let inText = true
for (const block of result) {
if (block.type === 'server_tool_use') {
if (inText) {
inText = false
if (textAcc.trim().length > 0) {
results.push(textAcc.trim())
}
textAcc = ''
}
continue
}
if (block.type === 'web_search_tool_result') {
// Handle error case - content is a WebSearchToolResultError
if (!Array.isArray(block.content)) {
const errorMessage = `Web search error: ${block.content.error_code}`
logError(new Error(errorMessage))
results.push(errorMessage)
continue
}
// Success case - add results to our collection
const hits = block.content.map(r => ({ title: r.title, url: r.url }))
results.push({
tool_use_id: block.tool_use_id,
content: hits,
})
}
if (block.type === 'text') {
if (inText) {
textAcc += block.text
} else {
inText = true
textAcc = block.text
}
}
}
if (textAcc.length) {
results.push(textAcc.trim())
}
return {
query,
results,
durationSeconds,
}
}
// ---------------------------------------------------------------------------
// Helper: should we use adapter-based providers?
// ---------------------------------------------------------------------------
/**
* Returns true for transient errors that are safe to fall through on in auto mode
* (network failures, timeouts, HTTP 5xx). Config and guardrail errors return false.
*/
function isTransientError(err: unknown): boolean {
if (!(err instanceof Error)) return true
const msg = err.message.toLowerCase()
// Guardrail / config errors — must surface
if (msg.includes('must use https')) return false
if (msg.includes('private/reserved address')) return false
if (msg.includes('not in the safe allowlist')) return false
if (msg.includes('exceeds') && msg.includes('bytes')) return false
if (msg.includes('not a valid url')) return false
if (msg.includes('is not configured')) return false
// Transient errors — safe to fall through
if (err.name === 'AbortError') return true
if (msg.includes('timed out')) return true
if (msg.includes('fetch failed') || msg.includes('econnrefused') || msg.includes('enotfound')) return true
if (msg.includes('returned 5')) return true // HTTP 5xx
// Unknown — treat as transient to preserve auto-mode fallback semantics
return true
}
/**
* Returns true when we should use the adapter-based provider system.
*
* In auto mode: native/first-party/Codex paths take precedence.
* → Only falls back to adapter if no native path is available.
* In explicit adapter modes (tavily, ddg, custom, etc.): always true.
* In native mode: never true.
*/
function shouldUseAdapterProvider(): boolean {
const mode = getProviderMode()
if (mode === 'native') return false
if (mode !== 'auto') return true // explicit adapter mode (tavily, ddg, custom, etc.)
// Auto mode: native/first-party/Codex take precedence over adapter
if (isCodexResponsesWebSearchEnabled()) return false
const provider = getAPIProvider()
if (provider === 'firstParty' || provider === 'vertex' || provider === 'foundry') {
return false
}
// No native path available — fall back to adapter
return getAvailableProviders().length > 0
}
/**
* Returns true when the current provider has a working native or Codex
* web-search fallback after an adapter failure. OpenAI shim providers
* (moonshot, minimax, nvidia-nim, openai, github, etc.) do NOT support
* Anthropic's web_search_20250305 tool, so falling through to the native
* path silently produces "Did 0 searches".
*/
function hasNativeSearchFallback(): boolean {
if (isCodexResponsesWebSearchEnabled()) return true
const provider = getAPIProvider()
return provider === 'firstParty' || provider === 'vertex' || provider === 'foundry'
}
// ---------------------------------------------------------------------------
// Tool export
// ---------------------------------------------------------------------------
export const WebSearchTool = buildTool({
name: WEB_SEARCH_TOOL_NAME,
searchHint: 'search the web for current information',
maxResultSizeChars: 100_000,
shouldDefer: true,
async description(input) {
return `Claude wants to search the web for: ${input.query}`
},
userFacingName() {
return 'Web Search'
},
getToolUseSummary,
getActivityDescription(input) {
const summary = getToolUseSummary(input)
return summary ? `Searching for ${summary}` : 'Searching the web'
},
isEnabled() {
const mode = getProviderMode()
// Specific provider mode: enabled if any adapter is configured
if (mode !== 'auto' && mode !== 'native') {
return getAvailableProviders().length > 0
}
// Auto/native mode: check all paths
if (getAvailableProviders().length > 0) return true
if (isCodexResponsesWebSearchEnabled()) return true
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
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
toAutoClassifierInput(input) {
return input.query
},
async checkPermissions(_input): Promise<PermissionResult> {
return {
behavior: 'passthrough',
message: 'WebSearchTool requires permission.',
suggestions: [
{
type: 'addRules',
rules: [{ toolName: WEB_SEARCH_TOOL_NAME }],
behavior: 'allow',
destination: 'localSettings',
},
],
}
},
async prompt() {
// Strip "US only" when using non-native backends
if (shouldUseAdapterProvider() || isCodexResponsesWebSearchEnabled()) {
return getWebSearchPrompt().replace(
/\n\s*-\s*Web search is only available in the US/,
'',
)
}
return getWebSearchPrompt()
},
renderToolUseMessage,
renderToolUseProgressMessage,
renderToolResultMessage,
extractSearchText() {
// renderToolResultMessage shows only "Did N searches in Xs" chrome —
// the results[] content never appears on screen. Heuristic would index
// string entries in results[] (phantom match). Nothing to search.
return ''
},
async validateInput(input) {
const { query, allowed_domains, blocked_domains } = input
if (!query.length) {
return {
result: false,
message: 'Error: Missing query',
errorCode: 1,
}
}
if (allowed_domains?.length && blocked_domains?.length) {
return {
result: false,
message:
'Error: Cannot specify both allowed_domains and blocked_domains in the same request',
errorCode: 2,
}
}
return { result: true }
},
async call(input, context, _canUseTool, _parentMessage, onProgress) {
// --- Adapter-based providers (custom, firecrawl, ddg) ---
// runSearch handles fallback semantics based on WEB_SEARCH_PROVIDER mode:
// - "auto": tries each provider, falls through on failure
// - specific mode: runs one provider, throws on failure
if (shouldUseAdapterProvider()) {
const mode = getProviderMode()
const isExplicitAdapter = mode !== 'auto'
try {
const providerOutput = await runSearch(
{
query: input.query,
allowed_domains: input.allowed_domains,
blocked_domains: input.blocked_domains,
},
context.abortController.signal,
)
// Explicit adapter: return even 0 hits (no silent native fallback)
if (isExplicitAdapter || providerOutput.hits.length > 0) {
return { data: formatProviderOutput(providerOutput, input.query) }
}
// Auto mode with 0 hits: fall through to native
} catch (err) {
// Explicit adapter: throw the real error (no silent native fallback)
if (isExplicitAdapter) throw err
// Auto mode: only fall through on transient errors (network, timeout, 5xx).
// Config / guardrail errors (SSRF, HTTPS, bad URL, etc.) must surface.
if (!isTransientError(err)) throw err
// No viable fallback for this provider — surface the adapter error
// instead of falling through to a broken native path.
if (!hasNativeSearchFallback()) {
const provider = getAPIProvider()
const errMsg = err instanceof Error ? err.message : String(err)
throw new Error(
`Web search is unavailable for provider "${provider}". ` +
`The search adapter failed (${errMsg}). ` +
`Try switching to a provider with built-in web search (e.g. Anthropic, Codex) or try again later.`,
)
}
console.error(
`[web-search] Adapter failed, falling through to native: ${err}`,
)
}
}
// --- Codex / OpenAI Responses path ---
if (isCodexResponsesWebSearchEnabled()) {
return {
data: await runCodexWebSearch(input, context.abortController.signal),
}
}
// --- Native Anthropic path (firstParty / vertex / foundry) ---
const startTime = performance.now()
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 = 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 = ''
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 {
const queryMatch = currentToolUseJson.match(
/"query"\s*:\s*"((?:[^"\\]|\\.)*)"/,
)
if (queryMatch && queryMatch[1]) {
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') {
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 }
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
const { query, results } = output
let formattedOutput = `Web search results for query: "${query}"\n\n`
// Process the results array - it can contain both string summaries and search result objects.
// Guard against null/undefined entries that can appear after JSON round-tripping
// (e.g., from compaction or transcript deserialization).
;(results ?? []).forEach(result => {
if (result == null) {
return
}
if (typeof result === 'string') {
// Text summary
formattedOutput += result + '\n\n'
} else {
// Search result with links
if (result.content?.length > 0) {
formattedOutput += `Links: ${jsonStringify(result.content)}\n\n`
} else {
formattedOutput += 'No links found.\n\n'
}
}
})
formattedOutput +=
'\nREMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.'
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: formattedOutput.trim(),
}
},
} satisfies ToolDef<InputSchema, Output, WebSearchProgress>)