π File detail
tools/WebSearchTool/WebSearchTool.ts
π― Use case
This module implements the βWebSearchToolβ tool (Web Search) β something the model can call at runtime alongside other agent tools. On the API surface it exposes SearchResult, Output, and WebSearchTool β mainly types, interfaces, or factory objects. Dependencies touch @anthropic-ai, src, and schema validation. It composes internal code from services, Tool, utils, prompt, and UI (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import type { BetaContentBlock, BetaWebSearchTool20250305, } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import { getAPIProvider } from 'src/utils/model/providers.js'
π€ Exports (heuristic)
SearchResultOutputWebSearchTool
π External import roots
Package roots from from "β¦" (relative paths omitted).
@anthropic-aisrczod
π₯οΈ Source preview
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 { 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'
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'
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: 8, // Hardcoded to 8 searches maximum
}
}
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,
}
}
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 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() {
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) {
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 = ''
// 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,
},
})
}
}
}
}
// 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>)