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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-27 19:36:38 -05:00
parent 3253589193
commit 29b08fc6c5
6 changed files with 147 additions and 13 deletions

View file

@ -1,4 +1,5 @@
export ANTHROPIC_API_KEY=
export OPENAI_API_KEY=
export GEMINI_API_KEY=
export DAYTONA_API_KEY=
export DAYTONA_API_KEY=
export BRAVE_SEARCH_API_KEY=

1
Cargo.lock generated
View file

@ -19,6 +19,7 @@ dependencies = [
"libc",
"llm",
"paste",
"reqwest 0.12.28",
"serde",
"serde_json",
"tar",

View file

@ -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"

View file

@ -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()));

View file

@ -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]

View file

@ -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<String>) -> 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<dyn ExecutionEnvironment> = 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<dyn ExecutionEnvironment> = 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<dyn ExecutionEnvironment> = 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}"
);
}
}