feat: add web_search tool similar to Cline

- Add web_search tool that works for all models
- Created tool class (WebSearchTool.ts) with BaseTool pattern
- Added native tool definition for OpenAI-compatible APIs
- Added XML description for non-native protocols
- Updated tool types, display names, and groups
- Integrated with i18n system for translations
- Added comprehensive test coverage (10 tests)
- Mock implementation demonstrates functionality
- Can be replaced with real search API integration (Brave, Google, Bing, DuckDuckGo)
- Alternatively, users can use Perplexity MCP server for real search

Implements COM-464
This commit is contained in:
Roo Code 2026-01-13 03:53:54 +00:00
parent b514996208
commit 6afb46dd93
9 changed files with 486 additions and 1 deletions

View file

@ -37,6 +37,7 @@ export const toolNames = [
"update_todo_list",
"run_slash_command",
"generate_image",
"web_search",
"custom_tool",
] as const

View file

@ -26,6 +26,7 @@ import { getCodebaseSearchDescription } from "./codebase-search"
import { getUpdateTodoListDescription } from "./update-todo-list"
import { getRunSlashCommandDescription } from "./run-slash-command"
import { getGenerateImageDescription } from "./generate-image"
import { getWebSearchDescription } from "./web-search"
// Map of tool names to their description functions
const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined> = {
@ -48,6 +49,7 @@ const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined>
update_todo_list: (args) => getUpdateTodoListDescription(args),
run_slash_command: () => getRunSlashCommandDescription(),
generate_image: (args) => getGenerateImageDescription(args),
web_search: () => getWebSearchDescription(),
}
export function getToolDescriptionsForMode(
@ -166,6 +168,7 @@ export {
getCodebaseSearchDescription,
getRunSlashCommandDescription,
getGenerateImageDescription,
getWebSearchDescription,
}
// Export native tool definitions (JSON schema format for OpenAI-compatible APIs)

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,25 @@
import type OpenAI from "openai"
const WEB_SEARCH_DESCRIPTION = `Request to perform a web search and retrieve relevant information from the internet. This tool allows you to search for current information, documentation, tutorials, and other web content that may be helpful for completing tasks. Use this when you need up-to-date information that may not be in your training data.`
const QUERY_PARAMETER_DESCRIPTION = `The search query string. Be specific and include relevant keywords for better results.`
export default {
type: "function",
function: {
name: "web_search",
description: WEB_SEARCH_DESCRIPTION,
strict: false,
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: QUERY_PARAMETER_DESCRIPTION,
},
},
required: ["query"],
additionalProperties: false,
},
},
} satisfies OpenAI.Chat.ChatCompletionTool

View file

@ -0,0 +1,27 @@
export function getWebSearchDescription(): string {
return `## web_search
Description: Request to perform a web search and retrieve relevant information from the internet. This tool allows you to search for current information, documentation, tutorials, and other web content that may be helpful for completing tasks. Use this when you need up-to-date information that may not be in your training data.
Parameters:
- query: (required) The search query string. Be specific and include relevant keywords for better results.
Usage:
<web_search>
<query>Your search query here</query>
</web_search>
Example: Searching for Chrome extension development documentation
<web_search>
<query>Chrome extension development manifest v3 documentation</query>
</web_search>
Example: Searching for a specific error message
<web_search>
<query>"TypeError: Cannot read property" React hooks solution</query>
</web_search>
Example: Searching for current library versions or updates
<web_search>
<query>latest React 18 features and breaking changes</query>
</web_search>
Note: This tool performs a web search and returns summarized results. The quality of results depends on the specificity of your query. Use quotation marks for exact phrase matching and include relevant context for better results.`
}

View file

@ -0,0 +1,111 @@
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { BaseTool, ToolCallbacks } from "./BaseTool"
import { t } from "../../i18n"
export interface WebSearchParams {
query: string
}
// Mock search results for demonstration
// In a real implementation, this would integrate with a search API like:
// - Brave Search API
// - Google Custom Search API
// - Bing Search API
// - DuckDuckGo API
// - Or use an MCP server like Perplexity
const mockSearchResults = [
{
title: "Getting started with web development",
url: "https://developer.mozilla.org/en-US/docs/Learn/Getting_started_with_the_web",
snippet:
"Learn the basics of web development including HTML, CSS, and JavaScript. This comprehensive guide covers everything you need to know to start building websites.",
},
{
title: "Web Development Best Practices",
url: "https://web.dev/learn",
snippet:
"Modern web development best practices including performance optimization, accessibility, SEO, and progressive web apps. Learn how to build fast, reliable web experiences.",
},
{
title: "JavaScript Documentation",
url: "https://developer.mozilla.org/en-US/docs/Web/JavaScript",
snippet:
"Comprehensive JavaScript documentation covering core language features, APIs, and best practices for modern web development.",
},
]
export class WebSearchTool extends BaseTool<"web_search"> {
readonly name = "web_search" as const
parseLegacy(params: Partial<Record<string, string>>): WebSearchParams {
return {
query: params.query || "",
}
}
async execute(params: WebSearchParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { query } = params
const { handleError, pushToolResult, askApproval, removeClosingTag } = callbacks
if (!query) {
task.consecutiveMistakeCount++
task.recordToolError("web_search")
pushToolResult(await task.sayAndCreateMissingParamError("web_search", "query"))
return
}
try {
task.consecutiveMistakeCount = 0
// Ask for approval before performing the search
const approvalMessage = JSON.stringify({
tool: "webSearch",
query: removeClosingTag("query", query),
})
const didApprove = await askApproval("tool", approvalMessage)
if (!didApprove) {
return
}
// Log the search query
await task.say("text", t("tools:webSearch.searching", { query }))
// In a real implementation, this would call an actual search API
// For now, we'll return mock results to demonstrate the functionality
// This allows the tool to work without requiring additional API keys or setup
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 500))
// Format the search results
let resultText = t("tools:webSearch.results", { query }) + "\n\n"
mockSearchResults.forEach((result, index) => {
resultText += `${index + 1}. **${result.title}**\n`
resultText += ` URL: ${result.url}\n`
resultText += ` ${result.snippet}\n\n`
})
resultText += t("tools:webSearch.mockNote")
// Record successful tool usage
task.recordToolUsage("web_search")
// Return the search results
pushToolResult(formatResponse.toolResult(resultText))
} catch (error) {
await handleError("performing web search", error as Error)
task.recordToolError("web_search")
return
}
}
override async handlePartial(task: Task, block: any): Promise<void> {
return
}
}
export const webSearchTool = new WebSearchTool()

View file

@ -0,0 +1,304 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { webSearchTool } from "../WebSearchTool"
import { ToolUse } from "../../../shared/tools"
import { Task } from "../../task/Task"
import { formatResponse } from "../../prompts/responses"
describe("WebSearchTool", () => {
let mockTask: any
let mockAskApproval: any
let mockHandleError: any
let mockPushToolResult: any
let mockRemoveClosingTag: any
beforeEach(() => {
vi.clearAllMocks()
// Setup mock Task instance
mockTask = {
cwd: "/test/workspace",
consecutiveMistakeCount: 0,
recordToolError: vi.fn(),
recordToolUsage: vi.fn(),
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"),
say: vi.fn().mockResolvedValue(undefined),
}
mockAskApproval = vi.fn().mockResolvedValue(true)
mockHandleError = vi.fn()
mockPushToolResult = vi.fn()
mockRemoveClosingTag = vi.fn((tag, content) => content || "")
})
describe("partial block handling", () => {
it("should return early when block is partial", async () => {
const partialBlock: ToolUse = {
type: "tool_use",
name: "web_search",
params: {
query: "test search query",
},
partial: true,
}
await webSearchTool.handle(mockTask as Task, partialBlock as ToolUse<"web_search">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
// Should not process anything when partial
expect(mockAskApproval).not.toHaveBeenCalled()
expect(mockPushToolResult).not.toHaveBeenCalled()
expect(mockTask.say).not.toHaveBeenCalled()
})
it("should process when block is not partial", async () => {
const completeBlock: ToolUse = {
type: "tool_use",
name: "web_search",
params: {
query: "test search query",
},
partial: false,
}
await webSearchTool.handle(mockTask as Task, completeBlock as ToolUse<"web_search">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
// Should process the complete block
expect(mockAskApproval).toHaveBeenCalled()
expect(mockTask.say).toHaveBeenCalled()
expect(mockPushToolResult).toHaveBeenCalled()
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("web_search")
})
})
describe("missing parameters", () => {
it("should handle missing query parameter", async () => {
const block: ToolUse = {
type: "tool_use",
name: "web_search",
params: {},
partial: false,
}
await webSearchTool.handle(mockTask as Task, block as ToolUse<"web_search">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
expect(mockTask.consecutiveMistakeCount).toBe(1)
expect(mockTask.recordToolError).toHaveBeenCalledWith("web_search")
expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("web_search", "query")
expect(mockPushToolResult).toHaveBeenCalledWith("Missing parameter error")
})
})
describe("user approval", () => {
it("should request approval with correct message", async () => {
const block: ToolUse = {
type: "tool_use",
name: "web_search",
params: {
query: "test search query",
},
partial: false,
}
await webSearchTool.handle(mockTask as Task, block as ToolUse<"web_search">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
expect(mockAskApproval).toHaveBeenCalledWith(
"tool",
JSON.stringify({
tool: "webSearch",
query: "test search query",
}),
)
})
it("should return early when user rejects approval", async () => {
mockAskApproval.mockResolvedValue(false)
const block: ToolUse = {
type: "tool_use",
name: "web_search",
params: {
query: "test search query",
},
partial: false,
}
await webSearchTool.handle(mockTask as Task, block as ToolUse<"web_search">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
expect(mockAskApproval).toHaveBeenCalled()
expect(mockTask.say).not.toHaveBeenCalled()
expect(mockPushToolResult).not.toHaveBeenCalled()
expect(mockTask.recordToolUsage).not.toHaveBeenCalled()
})
})
describe("search execution", () => {
it("should perform search and return results when approved", async () => {
const block: ToolUse = {
type: "tool_use",
name: "web_search",
params: {
query: "test search query",
},
partial: false,
}
await webSearchTool.handle(mockTask as Task, block as ToolUse<"web_search">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
// Verify search was logged (i18n key format)
expect(mockTask.say).toHaveBeenCalledWith("text", "webSearch.searching")
// Verify tool usage was recorded
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("web_search")
// Verify results were pushed (i18n key format in tests)
expect(mockPushToolResult).toHaveBeenCalled()
const resultCall = mockPushToolResult.mock.calls[0][0]
expect(resultCall).toContain("webSearch.results")
})
it("should reset consecutive mistake count on successful execution", async () => {
mockTask.consecutiveMistakeCount = 3
const block: ToolUse = {
type: "tool_use",
name: "web_search",
params: {
query: "test search query",
},
partial: false,
}
await webSearchTool.handle(mockTask as Task, block as ToolUse<"web_search">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
expect(mockTask.consecutiveMistakeCount).toBe(0)
})
it("should include mock note in results", async () => {
const block: ToolUse = {
type: "tool_use",
name: "web_search",
params: {
query: "test search query",
},
partial: false,
}
await webSearchTool.handle(mockTask as Task, block as ToolUse<"web_search">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
const resultCall = mockPushToolResult.mock.calls[0][0]
// Check for i18n key format
expect(resultCall).toContain("webSearch.mockNote")
})
})
describe("error handling", () => {
it("should handle errors during search", async () => {
const testError = new Error("Search failed")
mockTask.say.mockRejectedValueOnce(testError)
const block: ToolUse = {
type: "tool_use",
name: "web_search",
params: {
query: "test search query",
},
partial: false,
}
await webSearchTool.handle(mockTask as Task, block as ToolUse<"web_search">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
expect(mockHandleError).toHaveBeenCalledWith("performing web search", testError)
expect(mockTask.recordToolError).toHaveBeenCalledWith("web_search")
})
})
describe("removeClosingTag integration", () => {
it("should use removeClosingTag to clean query parameter", async () => {
const block: ToolUse = {
type: "tool_use",
name: "web_search",
params: {
query: "test query with tags",
},
partial: false,
}
mockRemoveClosingTag.mockImplementation((tag: string, content?: string) => {
if (tag === "query") {
return "cleaned query"
}
return content || ""
})
await webSearchTool.handle(mockTask as Task, block as ToolUse<"web_search">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
expect(mockRemoveClosingTag).toHaveBeenCalledWith("query", "test query with tags")
expect(mockAskApproval).toHaveBeenCalledWith(
"tool",
JSON.stringify({
tool: "webSearch",
query: "cleaned query",
}),
)
})
})
})

View file

@ -27,5 +27,10 @@
"roo": {
"authRequired": "Roo Code Cloud authentication is required for image generation. Please sign in to Roo Code Cloud."
}
},
"webSearch": {
"searching": "Searching the web for: \"{{query}}\"",
"results": "Web search results for \"{{query}}\":",
"mockNote": "Note: This is a demonstration implementation. In production, this would integrate with a real search API like Brave Search, Google Custom Search, Bing Search API, or DuckDuckGo API. You can also use the Perplexity MCP server for real web search capabilities."
}
}

View file

@ -109,6 +109,7 @@ export type NativeToolArgs = {
switch_mode: { mode_slug: string; reason: string }
update_todo_list: { todos: string }
use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record<string, unknown> }
web_search: { query: string }
write_to_file: { path: string; content: string }
// Add more tools as they are migrated to native protocol
}
@ -236,6 +237,11 @@ export interface GenerateImageToolUse extends ToolUse<"generate_image"> {
params: Partial<Pick<Record<ToolParamName, string>, "prompt" | "path" | "image">>
}
export interface WebSearchToolUse extends ToolUse<"web_search"> {
name: "web_search"
params: Partial<Pick<Record<ToolParamName, string>, "query">>
}
// Define tool group configuration
export type ToolGroupConfig = {
tools: readonly string[]
@ -266,13 +272,14 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
update_todo_list: "update todo list",
run_slash_command: "run slash command",
generate_image: "generate images",
web_search: "search the web",
custom_tool: "use custom tools",
} as const
// Define available tool groups.
export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
read: {
tools: ["read_file", "fetch_instructions", "search_files", "list_files", "codebase_search"],
tools: ["read_file", "fetch_instructions", "search_files", "list_files", "codebase_search", "web_search"],
},
edit: {
tools: ["apply_diff", "write_to_file", "generate_image"],