From 29b08fc6c5edb0e4ef8ccc4bb393baa21c482c35 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 27 Feb 2026 19:36:38 -0500 Subject: [PATCH] Implement web_search tool using Brave Search API Replace the placeholder web_search tool with a real implementation backed by the Brave Search API. Register it in all three profiles (Anthropic, OpenAI, Gemini) so every provider has web search capability. Co-Authored-By: Claude Opus 4.6 --- .env.example | 3 +- Cargo.lock | 1 + crates/agent/Cargo.toml | 1 + crates/agent/src/profiles/anthropic.rs | 17 +++- crates/agent/src/profiles/openai.rs | 14 ++- crates/agent/src/tools.rs | 124 ++++++++++++++++++++++++- 6 files changed, 147 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 8f09eecb9..aeabb1e3a 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ export ANTHROPIC_API_KEY= export OPENAI_API_KEY= export GEMINI_API_KEY= -export DAYTONA_API_KEY= \ No newline at end of file +export DAYTONA_API_KEY= +export BRAVE_SEARCH_API_KEY= \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index bf496b7bb..da513a552 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,7 @@ dependencies = [ "libc", "llm", "paste", + "reqwest 0.12.28", "serde", "serde_json", "tar", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index dc7d50dfe..5e66c538b 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -36,6 +36,7 @@ futures.workspace = true async-trait.workspace = true jsonschema.workspace = true chrono.workspace = true +reqwest.workspace = true tokio-util.workspace = true dirs = "6" glob = "0.3" diff --git a/crates/agent/src/profiles/anthropic.rs b/crates/agent/src/profiles/anthropic.rs index c3fefb10e..12afadccd 100644 --- a/crates/agent/src/profiles/anthropic.rs +++ b/crates/agent/src/profiles/anthropic.rs @@ -7,7 +7,7 @@ use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{ make_edit_file_tool, make_glob_tool, make_grep_tool, make_read_file_tool, - make_shell_tool_with_config, make_write_file_tool, + make_shell_tool_with_config, make_web_search_tool, make_write_file_tool, }; use super::EnvContext; @@ -31,6 +31,7 @@ impl AnthropicProfile { registry.register(make_shell_tool_with_config(&config)); registry.register(make_grep_tool()); registry.register(make_glob_tool()); + registry.register(make_web_search_tool()); Self { base: BaseProfile { @@ -131,6 +132,9 @@ Use this for searching the content of files rather than using shell grep or rg. Find files by name pattern. Results sorted by modification time (newest first). Use this for \ finding files rather than using shell find or ls commands. +## web_search +Search the web using Brave Search. Returns titles, URLs, and descriptions. + # Coding Best Practices Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \ @@ -236,6 +240,10 @@ mod tests { prompt.contains("Write clean, maintainable code"), "prompt should contain coding best practices" ); + assert!( + prompt.contains("web_search"), + "prompt should contain web_search guidance" + ); } #[test] @@ -283,13 +291,14 @@ mod tests { fn anthropic_tools_registered() { let profile = AnthropicProfile::new("claude-sonnet-4-20250514"); let names = profile.tool_registry().names(); - assert_eq!(names.len(), 6); + assert_eq!(names.len(), 7); assert!(names.contains(&"read_file".to_string())); assert!(names.contains(&"write_file".to_string())); assert!(names.contains(&"edit_file".to_string())); assert!(names.contains(&"shell".to_string())); assert!(names.contains(&"grep".to_string())); assert!(names.contains(&"glob".to_string())); + assert!(names.contains(&"web_search".to_string())); } #[test] @@ -339,7 +348,7 @@ mod tests { use std::sync::Arc; let mut profile = AnthropicProfile::new("claude-sonnet-4-20250514"); - assert_eq!(profile.tool_registry().names().len(), 6); + assert_eq!(profile.tool_registry().names().len(), 7); let manager = Arc::new(tokio::sync::Mutex::new(SubAgentManager::new(3))); let factory: SessionFactory = Arc::new(|| { @@ -349,7 +358,7 @@ mod tests { profile.register_subagent_tools(manager, factory, 0); let names = profile.tool_registry().names(); - assert_eq!(names.len(), 10, "should have 6 base + 4 subagent tools"); + assert_eq!(names.len(), 11, "should have 7 base + 4 subagent tools"); assert!(names.contains(&"spawn_agent".to_string())); assert!(names.contains(&"send_input".to_string())); assert!(names.contains(&"wait".to_string())); diff --git a/crates/agent/src/profiles/openai.rs b/crates/agent/src/profiles/openai.rs index f3b1a659c..0cd960fc7 100644 --- a/crates/agent/src/profiles/openai.rs +++ b/crates/agent/src/profiles/openai.rs @@ -6,7 +6,8 @@ use crate::skills::Skill; use crate::tool_registry::{RegisteredTool, ToolRegistry}; use llm::types::ToolDefinition; use crate::tools::{ - make_glob_tool, make_grep_tool, make_read_file_tool, make_shell_tool, make_write_file_tool, + make_glob_tool, make_grep_tool, make_read_file_tool, make_shell_tool, make_web_search_tool, + make_write_file_tool, }; use std::sync::Arc; @@ -28,6 +29,7 @@ impl OpenAiProfile { registry.register(make_grep_tool()); registry.register(make_glob_tool()); registry.register(make_apply_patch_tool()); + registry.register(make_web_search_tool()); Self { base: BaseProfile { @@ -144,6 +146,9 @@ Search file contents with regex. Use glob_filter to narrow results. ## glob Find files by name pattern. +## web_search +Search the web using Brave Search. Returns titles, URLs, and descriptions. + # Coding Best Practices Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \ @@ -532,27 +537,28 @@ mod tests { use crate::subagent::SessionFactory; let mut profile = OpenAiProfile::new("o3-mini"); - assert_eq!(profile.tool_registry().names().len(), 6); + assert_eq!(profile.tool_registry().names().len(), 7); let manager = Arc::new(tokio::sync::Mutex::new(SubAgentManager::new(3))); let factory: SessionFactory = Arc::new(|| { panic!("should not be called in test") }); profile.register_subagent_tools(manager, factory, 0); - assert_eq!(profile.tool_registry().names().len(), 10); + assert_eq!(profile.tool_registry().names().len(), 11); } #[test] fn openai_tools_registered() { let profile = OpenAiProfile::new("o3-mini"); let names = profile.tool_registry().names(); - assert_eq!(names.len(), 6); + assert_eq!(names.len(), 7); assert!(names.contains(&"read_file".to_string())); assert!(names.contains(&"write_file".to_string())); assert!(names.contains(&"shell".to_string())); assert!(names.contains(&"grep".to_string())); assert!(names.contains(&"glob".to_string())); assert!(names.contains(&"apply_patch".to_string())); + assert!(names.contains(&"web_search".to_string())); } #[test] diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index d9917a3de..12bb4569c 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -344,24 +344,79 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool { } } +fn format_brave_results(body: &serde_json::Value) -> String { + let results = body + .get("web") + .and_then(|w| w.get("results")) + .and_then(serde_json::Value::as_array); + + let Some(results) = results else { + return "No results found.".to_string(); + }; + + let mut output = String::new(); + for (i, result) in results.iter().enumerate() { + let title = result.get("title").and_then(serde_json::Value::as_str).unwrap_or("(no title)"); + let url = result.get("url").and_then(serde_json::Value::as_str).unwrap_or("(no url)"); + let description = result.get("description").and_then(serde_json::Value::as_str).unwrap_or(""); + let _ = write!(output, "{}. {}\n {}\n {}\n\n", i + 1, title, url, description); + } + output +} + #[must_use] pub(crate) fn make_web_search_tool() -> RegisteredTool { + make_web_search_tool_with_api_key(std::env::var("BRAVE_SEARCH_API_KEY").ok()) +} + +fn make_web_search_tool_with_api_key(api_key: Option) -> RegisteredTool { + let client = reqwest::Client::new(); RegisteredTool { definition: ToolDefinition { name: "web_search".into(), - description: "Search the web".into(), + description: "Search the web using Brave Search".into(), parameters: serde_json::json!({ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, - "max_results": {"type": "integer", "description": "Maximum number of results"} + "max_results": {"type": "integer", "description": "Maximum number of results (default 5, max 20)"} }, "required": ["query"] }), }, - executor: Arc::new(|_args, _env, _cancel| { + executor: Arc::new(move |args, _env, _cancel| { + let client = client.clone(); + let api_key = api_key.clone(); Box::pin(async move { - Ok("Web search is not configured. This is a placeholder tool.".to_string()) + let api_key = api_key + .ok_or_else(|| "BRAVE_SEARCH_API_KEY environment variable is not set".to_string())?; + + let query = required_str(&args, "query")?; + let count = args + .get("max_results") + .and_then(serde_json::Value::as_u64) + .unwrap_or(5) + .min(20); + + let resp = client + .get("https://api.search.brave.com/res/v1/web/search") + .header("X-Subscription-Token", &api_key) + .header("Accept", "application/json") + .query(&[("q", query), ("count", &count.to_string())]) + .send() + .await + .map_err(|e| format!("HTTP request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("Brave Search API returned status {}", resp.status())); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {e}"))?; + + Ok(format_brave_results(&body)) }) }), } @@ -645,4 +700,65 @@ mod tests { assert!(output.contains("src/main.rs")); assert!(output.contains("src/lib.rs")); } + + #[tokio::test] + async fn web_search_missing_api_key_returns_error() { + let tool = make_web_search_tool_with_api_key(None); + let env: Arc = Arc::new(MockExecutionEnvironment::default()); + let result = (tool.executor)(serde_json::json!({"query": "test"}), env, CancellationToken::new()).await; + let err = result.unwrap_err(); + assert!(err.contains("BRAVE_SEARCH_API_KEY"), "error should mention BRAVE_SEARCH_API_KEY, got: {err}"); + } + + #[tokio::test] + async fn web_search_missing_query_returns_error() { + let tool = make_web_search_tool_with_api_key(Some("fake-key".into())); + let env: Arc = Arc::new(MockExecutionEnvironment::default()); + let result = (tool.executor)(serde_json::json!({}), env, CancellationToken::new()).await; + let err = result.unwrap_err(); + assert!(err.contains("query"), "error should mention missing query, got: {err}"); + } + + #[test] + fn format_brave_results_formats_results() { + let body = serde_json::json!({ + "web": { + "results": [ + {"title": "Rust Lang", "url": "https://rust-lang.org", "description": "A systems language"}, + {"title": "Rust Book", "url": "https://doc.rust-lang.org/book", "description": "The Rust book"} + ] + } + }); + let output = format_brave_results(&body); + assert!(output.contains("1. Rust Lang")); + assert!(output.contains("https://rust-lang.org")); + assert!(output.contains("A systems language")); + assert!(output.contains("2. Rust Book")); + } + + #[test] + fn format_brave_results_no_results() { + let body = serde_json::json!({"web": {}}); + assert_eq!(format_brave_results(&body), "No results found."); + } + + #[tokio::test] + #[ignore] // Requires BRAVE_SEARCH_API_KEY env var + async fn web_search_returns_results() { + let api_key = std::env::var("BRAVE_SEARCH_API_KEY") + .expect("BRAVE_SEARCH_API_KEY must be set to run this test"); + let tool = make_web_search_tool_with_api_key(Some(api_key)); + let env: Arc = Arc::new(MockExecutionEnvironment::default()); + let result = (tool.executor)( + serde_json::json!({"query": "rust programming language"}), + env, + CancellationToken::new(), + ) + .await; + let output = result.expect("web search should succeed with valid API key"); + assert!( + output.to_lowercase().contains("rust"), + "results should mention rust, got: {output}" + ); + } }