Add web search tool support

This commit is contained in:
Hannes Rudolph 2026-01-12 22:11:01 -07:00
parent b514996208
commit f215437872
33 changed files with 396 additions and 4 deletions

View file

@ -15,7 +15,7 @@ type ToolCategory = "file" | "directory" | "search" | "command" | "browser" | "m
function getToolCategory(toolName: string): ToolCategory {
const fileTools = ["readFile", "read_file", "writeToFile", "write_to_file", "applyDiff", "apply_diff"]
const dirTools = ["listFiles", "list_files", "listFilesRecursive", "listFilesTopLevel"]
const searchTools = ["searchFiles", "search_files"]
const searchTools = ["searchFiles", "search_files", "webSearch", "web_search"]
const commandTools = ["executeCommand", "execute_command"]
const browserTools = ["browserAction", "browser_action"]
const modeTools = ["switchMode", "switch_mode", "newTask", "new_task"]

View file

@ -35,7 +35,7 @@ export function getToolCategory(toolName: string): ToolCategory {
"writeToFile",
]
const searchTools = ["searchFiles", "search_files", "codebaseSearch", "codebase_search"]
const searchTools = ["searchFiles", "search_files", "codebaseSearch", "codebase_search", "webSearch", "web_search"]
const commandTools = ["execute_command", "executeCommand"]
const browserTools = ["browser_action", "browserAction"]
const modeTools = ["switchMode", "switch_mode", "newTask", "new_task", "finishTask"]

View file

@ -69,6 +69,8 @@ export function getToolDisplayName(toolName: string): string {
search_files: "Search Files",
codebaseSearch: "Codebase Search",
codebase_search: "Codebase Search",
webSearch: "Web Search",
web_search: "Web Search",
// Command operations
execute_command: "Execute Command",
@ -126,6 +128,8 @@ export function getToolIconName(toolName: string): IconName {
search_files: "search",
codebaseSearch: "search",
codebase_search: "search",
webSearch: "search",
web_search: "search",
// Command operations
execute_command: "terminal",

View file

@ -26,6 +26,7 @@ export const toolNames = [
"search_files",
"list_files",
"browser_action",
"web_search",
"use_mcp_tool",
"access_mcp_resource",
"ask_followup_question",

View file

@ -676,6 +676,7 @@ export interface ClineSayTool {
| "listFilesTopLevel"
| "listFilesRecursive"
| "searchFiles"
| "webSearch"
| "switchMode"
| "newTask"
| "finishTask"

View file

@ -40,6 +40,7 @@ import { generateImageTool } from "../tools/GenerateImageTool"
import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool"
import { validateToolUse } from "../tools/validateToolUse"
import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
import { webSearchTool } from "../tools/WebSearchTool"
import { formatResponse } from "../prompts/responses"
@ -1016,6 +1017,15 @@ export async function presentAssistantMessage(cline: Task) {
toolProtocol,
})
break
case "web_search":
await webSearchTool.handle(cline, block as ToolUse<"web_search">, {
askApproval,
handleError,
pushToolResult,
removeClosingTag,
toolProtocol,
})
break
case "browser_action":
await browserActionTool(
cline,

View file

@ -12,6 +12,7 @@ export function isReadOnlyToolAction(tool: ClineSayTool): boolean {
"listFilesRecursive",
"searchFiles",
"codebaseSearch",
"webSearch",
"runSlashCommand",
].includes(tool.tool)
}

View file

@ -294,6 +294,7 @@ export function filterNativeToolsForMode(
// Conditionally exclude browser_action if disabled in settings
if (settings?.browserToolEnabled === false) {
allowedToolNames.delete("browser_action")
allowedToolNames.delete("web_search")
}
// Conditionally exclude apply_diff if diffs are disabled
@ -383,7 +384,7 @@ export function isToolAllowedInMode(
}
// Check for browser_action being disabled by user settings
if (toolName === "browser_action" && settings?.browserToolEnabled === false) {
if ((toolName === "browser_action" || toolName === "web_search") && settings?.browserToolEnabled === false) {
return false
}

View file

@ -23,6 +23,7 @@ import { getAccessMcpResourceDescription } from "./access-mcp-resource"
import { getSwitchModeDescription } from "./switch-mode"
import { getNewTaskDescription } from "./new-task"
import { getCodebaseSearchDescription } from "./codebase-search"
import { getWebSearchDescription } from "./web-search"
import { getUpdateTodoListDescription } from "./update-todo-list"
import { getRunSlashCommandDescription } from "./run-slash-command"
import { getGenerateImageDescription } from "./generate-image"
@ -36,6 +37,7 @@ const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined>
search_files: (args) => getSearchFilesDescription(args),
list_files: (args) => getListFilesDescription(args),
browser_action: (args) => getBrowserActionDescription(args),
web_search: () => getWebSearchDescription(),
ask_followup_question: () => getAskFollowupQuestionDescription(),
attempt_completion: (args) => getAttemptCompletionDescription(args),
use_mcp_tool: (args) => getUseMcpToolDescription(args),
@ -131,6 +133,11 @@ export function getToolDescriptionsForMode(
tools.delete("run_slash_command")
}
// Conditionally exclude web_search if browser tools are disabled in settings
if (settings?.browserToolEnabled === false) {
tools.delete("web_search")
}
// Map tool descriptions for allowed tools
const descriptions = Array.from(tools).map((toolName) => {
const descriptionFn = toolDescriptionMap[toolName]
@ -164,6 +171,7 @@ export {
getAccessMcpResourceDescription,
getSwitchModeDescription,
getCodebaseSearchDescription,
getWebSearchDescription,
getRunSlashCommandDescription,
getGenerateImageDescription,
}

View file

@ -19,6 +19,7 @@ import edit_file from "./edit_file"
import searchFiles from "./search_files"
import switchMode from "./switch_mode"
import updateTodoList from "./update_todo_list"
import webSearch from "./web_search"
import writeToFile from "./write_to_file"
export { getMcpServerTools } from "./mcp_server"
@ -73,6 +74,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
searchFiles,
switchMode,
updateTodoList,
webSearch,
writeToFile,
] satisfies OpenAI.Chat.ChatCompletionTool[]
}

View file

@ -0,0 +1,38 @@
import type OpenAI from "openai"
const WEB_SEARCH_DESCRIPTION = `Search the public web for up-to-date information and return a concise list of results with titles, URLs, and snippets. Use this tool when you need real-time or external context that is not available in the local workspace.
Parameters:
- query: (required) The search query to run.
- max_results: (optional) Maximum number of results to return (default: 5, max: 10).
Example: Searching for release notes
{ "query": "Cline 3.48.0 web search tool", "max_results": 5 }`
const QUERY_PARAMETER_DESCRIPTION = `Search query string`
const MAX_RESULTS_PARAMETER_DESCRIPTION = `Optional cap on number of results (1-10)`
export default {
type: "function",
function: {
name: "web_search",
description: WEB_SEARCH_DESCRIPTION,
strict: true,
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: QUERY_PARAMETER_DESCRIPTION,
},
max_results: {
type: ["integer", "null"],
description: MAX_RESULTS_PARAMETER_DESCRIPTION,
},
},
required: ["query", "max_results"],
additionalProperties: false,
},
},
} satisfies OpenAI.Chat.ChatCompletionTool

View file

@ -0,0 +1,20 @@
export function getWebSearchDescription(): string {
return `## web_search
Description: Search the public web for up-to-date information and return a concise list of results with titles, URLs, and snippets. Use this tool when you need real-time or external context that is not available in the local workspace.
Parameters:
- query: (required) The search query to run.
- max_results: (optional) Maximum number of results to return (default: 5, max: 10).
Usage:
<web_search>
<query>Your search query</query>
<max_results>5</max_results>
</web_search>
Example:
<web_search>
<query>OpenAI Responses API web search tool</query>
<max_results>3</max_results>
</web_search>`
}

View file

@ -0,0 +1,195 @@
import { type ClineSayTool } from "@roo-code/types"
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import type { ToolUse } from "../../shared/tools"
import { BaseTool, ToolCallbacks } from "./BaseTool"
interface WebSearchParams {
query: string
max_results?: number | null
}
type WebSearchResult = {
title: string
url: string
snippet?: string
}
const DEFAULT_MAX_RESULTS = 5
const MAX_RESULTS_LIMIT = 10
const WEB_SEARCH_TIMEOUT_MS = 15000
function normalizeMaxResults(value?: number | null): number {
if (!value || Number.isNaN(value)) {
return DEFAULT_MAX_RESULTS
}
return Math.min(Math.max(Math.trunc(value), 1), MAX_RESULTS_LIMIT)
}
function formatWebSearchResults(query: string, results: WebSearchResult[]): string {
if (results.length === 0) {
return `No web results found for "${query}".`
}
const lines = results.map((result, index) => {
const header = `${index + 1}. ${result.title}`
const snippet = result.snippet ? `\n${result.snippet}` : ""
return `${header}\n${result.url}${snippet}`
})
return `Web search results for "${query}":\n\n${lines.join("\n\n")}`
}
function splitTextSnippet(text?: string): { title: string; snippet?: string } {
if (!text) {
return { title: "Untitled result" }
}
const [title, ...rest] = text.split(" - ")
const snippet = rest.length > 0 ? rest.join(" - ") : undefined
return { title: title.trim() || "Untitled result", snippet: snippet?.trim() || undefined }
}
function extractResults(payload: any, maxResults: number): WebSearchResult[] {
const results: WebSearchResult[] = []
if (payload?.AbstractURL && payload?.AbstractText) {
results.push({
title: payload.Heading || payload.AbstractText,
url: payload.AbstractURL,
snippet: payload.AbstractText,
})
}
const directResults: any[] = Array.isArray(payload?.Results) ? payload.Results : []
for (const result of directResults) {
if (!result?.FirstURL || !result?.Text) {
continue
}
const { title, snippet } = splitTextSnippet(result.Text)
results.push({
title,
url: result.FirstURL,
snippet,
})
}
const relatedTopics: any[] = Array.isArray(payload?.RelatedTopics) ? payload.RelatedTopics : []
const queue = [...relatedTopics]
while (queue.length > 0) {
const topic = queue.shift()
if (!topic) {
continue
}
if (Array.isArray(topic.Topics)) {
queue.push(...topic.Topics)
continue
}
if (!topic.FirstURL || !topic.Text) {
continue
}
const { title, snippet } = splitTextSnippet(topic.Text)
results.push({
title,
url: topic.FirstURL,
snippet,
})
}
return results.slice(0, maxResults)
}
export class WebSearchTool extends BaseTool<"web_search"> {
readonly name = "web_search" as const
parseLegacy(params: Partial<Record<string, string>>): WebSearchParams {
const maxResults = params.max_results ? Number.parseInt(params.max_results, 10) : undefined
return {
query: params.query || "",
max_results: Number.isNaN(maxResults) ? undefined : maxResults,
}
}
async execute(params: WebSearchParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks
const query = params.query
if (!query) {
task.consecutiveMistakeCount++
task.recordToolError("web_search")
task.didToolFailInCurrentTurn = true
pushToolResult(await task.sayAndCreateMissingParamError("web_search", "query"))
return
}
const maxResults = normalizeMaxResults(params.max_results)
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), WEB_SEARCH_TIMEOUT_MS)
try {
const url = new URL("https://api.duckduckgo.com/")
url.searchParams.set("q", query)
url.searchParams.set("format", "json")
url.searchParams.set("no_html", "1")
url.searchParams.set("skip_disambig", "1")
const response = await fetch(url.toString(), {
signal: controller.signal,
headers: {
"User-Agent": "RooCode-WebSearch",
},
})
if (!response.ok) {
throw new Error(`Web search request failed with status ${response.status}`)
}
const payload = await response.json()
const results = extractResults(payload, maxResults)
const displayContent = formatWebSearchResults(query, results)
const sharedMessageProps: ClineSayTool = {
tool: "webSearch",
query,
content: displayContent,
}
const didApprove = await askApproval("tool", JSON.stringify(sharedMessageProps))
if (!didApprove) {
pushToolResult(formatResponse.toolDenied(toolProtocol))
return
}
task.consecutiveMistakeCount = 0
pushToolResult(JSON.stringify({ query, source: "duckduckgo", results }, null, 2))
} finally {
clearTimeout(timeoutId)
}
} catch (error) {
await handleError("web_search", error as Error)
}
}
override async handlePartial(task: Task, block: ToolUse<"web_search">): Promise<void> {
const query = block.params.query
const maxResults = block.params.max_results
const sharedMessageProps: ClineSayTool = {
tool: "webSearch",
query: this.removeClosingTag("query", query, block.partial),
content: "",
}
if (maxResults) {
sharedMessageProps.content = `Results requested: ${this.removeClosingTag("max_results", maxResults, block.partial)}`
}
await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {})
}
}
export const webSearchTool = new WebSearchTool()

View file

@ -63,6 +63,7 @@ export const toolParamNames = [
"task",
"size",
"query",
"max_results",
"args",
"start_line",
"end_line",
@ -102,6 +103,7 @@ export type NativeToolArgs = {
}
browser_action: BrowserActionParams
codebase_search: { query: string; path?: string }
web_search: { query: string; max_results?: number }
fetch_instructions: { task: string }
generate_image: GenerateImageParams
run_slash_command: { command: string; args?: string }
@ -196,6 +198,11 @@ export interface BrowserActionToolUse extends ToolUse<"browser_action"> {
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text" | "size" | "path">>
}
export interface WebSearchToolUse extends ToolUse<"web_search"> {
name: "web_search"
params: Partial<Pick<Record<ToolParamName, string>, "query" | "max_results">>
}
export interface UseMcpToolToolUse extends ToolUse<"use_mcp_tool"> {
name: "use_mcp_tool"
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
@ -256,6 +263,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
search_files: "search files",
list_files: "list files",
browser_action: "use a browser",
web_search: "search the web",
use_mcp_tool: "use mcp tools",
access_mcp_resource: "access mcp resources",
ask_followup_question: "ask questions",
@ -279,7 +287,7 @@ export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
customTools: ["search_and_replace", "search_replace", "edit_file", "apply_patch"],
},
browser: {
tools: ["browser_action"],
tools: ["browser_action", "web_search"],
},
command: {
tools: ["execute_command"],

View file

@ -769,6 +769,37 @@ export const ChatRowContent = ({
</div>
</>
)
case "webSearch":
return (
<>
<div style={headerStyle}>
{toolIcon("search")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask" ? (
<Trans
i18nKey="chat:webSearch.wantsToSearch"
components={{ code: <code className="font-medium">{tool.query}</code> }}
values={{ query: tool.query }}
/>
) : (
<Trans
i18nKey="chat:webSearch.didSearch"
components={{ code: <code className="font-medium">{tool.query}</code> }}
values={{ query: tool.query }}
/>
)}
</span>
</div>
<div className="pl-6">
<CodeAccordian
code={tool.content || ""}
language="markdown"
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
</div>
</>
)
case "switchMode":
return (
<>

View file

@ -480,5 +480,9 @@
"updated": "S'ha actualitzat la llista de tasques pendents",
"completed": "Completat",
"started": "Iniciat"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -480,5 +480,9 @@
"updated": "Die To-Do-Liste wurde aktualisiert",
"completed": "Abgeschlossen",
"started": "Gestartet"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -472,5 +472,9 @@
"updated": "Updated the to-do list",
"completed": "Completed",
"started": "Started"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -480,5 +480,9 @@
"updated": "Se actualizó la lista de tareas pendientes",
"completed": "Completado",
"started": "Iniciado"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -480,5 +480,9 @@
"updated": "La liste des tâches a été mise à jour",
"completed": "Terminé",
"started": "Commencé"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -480,5 +480,9 @@
"updated": "टू-डू सूची अपडेट की गई",
"completed": "पूरा हुआ",
"started": "शुरू हुआ"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -486,5 +486,9 @@
"updated": "Memperbarui daftar to-do",
"completed": "Selesai",
"started": "Dimulai"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -480,5 +480,9 @@
"updated": "Aggiornata la lista delle cose da fare",
"completed": "Completato",
"started": "Iniziato"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -480,5 +480,9 @@
"updated": "To-Doリストを更新しました",
"completed": "完了",
"started": "開始"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -480,5 +480,9 @@
"updated": "할 일 목록을 업데이트했습니다",
"completed": "완료됨",
"started": "시작됨"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -480,5 +480,9 @@
"updated": "De to-do-lijst is bijgewerkt",
"completed": "Voltooid",
"started": "Gestart"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -480,5 +480,9 @@
"updated": "Zaktualizowano listę zadań do wykonania",
"completed": "Ukończono",
"started": "Rozpoczęto"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -480,5 +480,9 @@
"updated": "A lista de tarefas foi atualizada",
"completed": "Concluído",
"started": "Iniciado"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -481,5 +481,9 @@
"updated": "Список задач обновлен",
"completed": "Завершено",
"started": "Начато"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -481,5 +481,9 @@
"updated": "Yapılacaklar listesi güncellendi",
"completed": "Tamamlandı",
"started": "Başladı"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -481,5 +481,9 @@
"updated": "Đã cập nhật danh sách công việc",
"completed": "Đã hoàn thành",
"started": "Đã bắt đầu"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -481,5 +481,9 @@
"updated": "已更新待办事项列表",
"completed": "已完成",
"started": "已开始"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}

View file

@ -481,5 +481,9 @@
"updated": "已更新待辦事項列表",
"completed": "已完成",
"started": "已開始"
},
"webSearch": {
"wantsToSearch": "Roo wants to search the web for <code>{{query}}</code>",
"didSearch": "Roo searched the web for <code>{{query}}</code>"
}
}