use serde_json::{Value, json};
use shuvarie_config::{WebSearchConfig, WebSearchKind, WebSearchParamKind, WebSearchParams};
use shuvarie_llm::{Tool, ToolContext, ToolExecutionError, ToolOutput};
use super::webfetch::{WEBFETCH_BROWSER_UA, webfetch_accept_header, webfetch_finish};
use super::{DEFAULT_TIMEOUT_SECS, arg_value};
const WEB_SEARCH_MAX_TIMEOUT_SECS: u64 = 120;
pub(crate) struct WebSearch {
url: String,
kind: WebSearchKind,
headers: Vec<(String, String)>,
params: WebSearchParams,
max_output_chars: usize,
}
impl WebSearch {
pub(crate) fn new(config: &WebSearchConfig, max_output_chars: usize) -> Self {
Self {
url: config.url.clone(),
kind: config.kind,
headers: config
.headers
.iter()
.map(|(name, value)| (name.clone(), crate::catalog::resolve_env(value)))
.collect(),
params: config.params.clone(),
max_output_chars,
}
}
}
impl Tool for WebSearch {
const NAME: &'static str = "web_search";
type Args = Value;
type Output = ToolOutput;
type Error = ToolExecutionError;
fn description(&self) -> String {
"Search the web with the configured web search endpoint and return matching \
results (titles, URLs, and content snippets). Use for current information, \
documentation lookup, and fact checking; follow up with webfetch to read a \
specific result page."
.to_string()
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "The search query" },
"timeout_secs": { "type": "integer", "minimum": 1, "description": "Optional timeout in seconds (max 120, default 30)" }
},
"required": ["query"]
})
}
async fn call(
&self,
_ctx: &mut ToolContext,
args: Value,
) -> Result<ToolOutput, ToolExecutionError> {
let max_output_chars = self.max_output_chars;
let result: Result<ToolOutput, String> = async move {
let query = arg_value(&args, "query")?;
let timeout_secs = args
.get("timeout_secs")
.and_then(Value::as_u64)
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.clamp(1, WEB_SEARCH_MAX_TIMEOUT_SECS);
let timeout = std::time::Duration::from_secs(timeout_secs);
let client = reqwest::Client::builder()
.user_agent(WEBFETCH_BROWSER_UA)
.connect_timeout(timeout)
.timeout(timeout)
.build()
.map_err(|e| format!("build http client: {e}"))?;
let (request_url, body) = match self.params.kind {
WebSearchParamKind::BodyJson => (
self.url.clone(),
Some(search_body_json(&self.params, &query)?),
),
WebSearchParamKind::Query => {
(search_query_url(&self.url, &self.params, &query)?, None)
}
};
let mut request = match &body {
Some(body) => client.post(&request_url).json(body),
None => client
.get(&request_url)
.header("Accept", webfetch_accept_header("markdown"))
.header("Accept-Language", "en-US,en;q=0.9"),
};
for (name, value) in &self.headers {
request = request.header(name, value);
}
let response = request
.send()
.await
.map_err(|e| format!("request {request_url}: {e}"))?;
match self.kind {
WebSearchKind::Ollama => {
let status = response.status();
let text = response
.text()
.await
.map_err(|e| format!("read {request_url}: {e}"))?;
if status.is_client_error() || status.is_server_error() {
return Err(format!(
"request {request_url}: HTTP {status}\n{}",
body_snippet(&text)
));
}
format_ollama_results(&text, max_output_chars)
}
WebSearchKind::ToMarkdown => {
let status = response.status();
if status.is_client_error() || status.is_server_error() {
return Err(format!("request {request_url}: HTTP {status}"));
}
webfetch_finish(response, &request_url, "markdown", max_output_chars).await
}
}
}
.await;
result.map_err(ToolExecutionError::other)
}
}
fn search_body_json(params: &WebSearchParams, query: &str) -> Result<Value, String> {
let Some(remote) = params.map.get("query") else {
return Err("params are missing the `query` mapping".to_string());
};
let mut body = serde_json::Map::new();
body.insert(remote.clone(), Value::String(query.to_string()));
Ok(Value::Object(body))
}
fn search_query_url(base: &str, params: &WebSearchParams, query: &str) -> Result<String, String> {
let Some(remote) = params.map.get("query") else {
return Err("params are missing the `query` mapping".to_string());
};
let separator = if base.contains('?') { '&' } else { '?' };
Ok(format!(
"{base}{separator}{remote}={}",
percent_encode(query)
))
}
fn percent_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char)
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
fn format_ollama_results(text: &str, max_output_chars: usize) -> Result<ToolOutput, String> {
let parsed: Value = serde_json::from_str(text)
.map_err(|e| format!("parse response JSON: {e} — body: {}", body_snippet(text)))?;
let results = parsed.get("results").ok_or_else(|| {
format!(
"response is missing `results` — body: {}",
body_snippet(text)
)
})?;
let results = results
.as_array()
.ok_or_else(|| format!("`results` is not an array — body: {}", body_snippet(text)))?;
if results.is_empty() {
return Ok(ToolOutput::text("No results found."));
}
let mut lines = Vec::with_capacity(results.len());
for (index, result) in results.iter().enumerate() {
let title = result
.get("title")
.and_then(Value::as_str)
.unwrap_or("(untitled)")
.trim();
let url = result
.get("url")
.and_then(Value::as_str)
.unwrap_or("(no url)")
.trim();
let content = result
.get("content")
.and_then(Value::as_str)
.map(collapse_whitespace)
.unwrap_or_default();
let mut line = format!("{}. {title} — {url}", index + 1);
if !content.is_empty() {
line.push_str("\n ");
line.push_str(&content);
}
lines.push(line);
}
let body = lines.join("\n\n");
if let Some(capped) = crate::truncate::truncate_output(
&body,
max_output_chars,
"narrow the query or fetch a result page with webfetch",
) {
return Ok(ToolOutput::text(capped));
}
Ok(ToolOutput::text(body))
}
fn collapse_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn body_snippet(body: &str) -> String {
const MAX_CHARS: usize = 300;
let flattened = collapse_whitespace(body.trim());
if flattened.chars().count() <= MAX_CHARS {
return flattened;
}
let truncated: String = flattened.chars().take(MAX_CHARS).collect();
format!("{truncated}…")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::new_ctx;
use shuvarie_config::DUCKDUCKGO_LITE_URL;
fn config(kind: WebSearchKind) -> WebSearchConfig {
WebSearchConfig {
disabled: false,
url: "https://search.example/api".to_string(),
kind,
headers: Default::default(),
params: WebSearchParams::default_for(kind),
}
}
fn ollama_results() -> &'static str {
r#"{
"results": [
{
"title": "Ollama",
"url": "https://ollama.com/",
"content": "Cloud models are now\n available\tfor everyone."
},
{
"title": "What is Ollama?",
"url": "https://www.hostinger.com/tutorials/what-is-ollama",
"content": "Ariffud M. 6min Read."
}
]
}"#
}
#[test]
fn percent_encode_encodes_reserved_bytes() {
assert_eq!(percent_encode("q=a b&c"), "q%3Da%20b%26c");
assert_eq!(percent_encode("héllo~"), "h%C3%A9llo~");
assert_eq!(percent_encode("-._"), "-._");
}
#[test]
fn search_query_url_appends_mapped_param() {
let params = WebSearchParams::default_for(WebSearchKind::ToMarkdown);
assert_eq!(
search_query_url(DUCKDUCKGO_LITE_URL, ¶ms, "rust async").unwrap(),
"https://lite.duckduckgo.com/lite/?q=rust%20async"
);
assert_eq!(
search_query_url("https://a/?x=1", ¶ms, "q").unwrap(),
"https://a/?x=1&q=q"
);
}
#[test]
fn search_body_json_maps_remote_name() {
let params = WebSearchParams::default_for(WebSearchKind::Ollama);
let body = search_body_json(¶ms, "rust async").unwrap();
assert_eq!(body, json!({ "query": "rust async" }));
let params = WebSearchParams {
kind: WebSearchParamKind::BodyJson,
map: [("query".to_string(), "search".to_string())].into(),
};
assert_eq!(
search_body_json(¶ms, "q").unwrap(),
json!({ "search": "q" })
);
}
#[tokio::test]
async fn web_search_rejects_empty_query() {
let err = WebSearch::new(&config(WebSearchKind::Ollama), 0)
.call(&mut new_ctx(), json!({}))
.await
.unwrap_err();
assert!(err.to_string().contains("query"), "{err}");
}
#[tokio::test]
async fn ollama_results_are_formatted() {
let response = http::Response::builder()
.status(200)
.header("content-type", "application/json")
.body(ollama_results().to_string())
.unwrap();
let response = reqwest::Response::from(response);
let out = format_ollama_results(&response.text().await.unwrap(), 0).unwrap();
let text = out.as_text().unwrap();
assert!(text.contains("1. Ollama — https://ollama.com/"), "{text}");
assert!(
text.contains("Cloud models are now available for everyone."),
"{text}"
);
assert!(
text.contains("2. What is Ollama? — https://www.hostinger.com"),
"{text}"
);
assert!(!text.contains('\t'), "{text}");
}
#[tokio::test]
async fn ollama_results_handle_empty_and_malformed() {
let out = format_ollama_results(r#"{"results": []}"#, 0).unwrap();
assert_eq!(out.as_text().unwrap(), "No results found.");
let err = format_ollama_results("not json", 0).unwrap_err();
assert!(err.contains("parse response JSON"), "{err}");
assert!(err.contains("not json"), "{err}");
let err = format_ollama_results(r#"{"hits": []}"#, 0).unwrap_err();
assert!(err.contains("missing `results`"), "{err}");
let err = format_ollama_results(r#"{"results": 5}"#, 0).unwrap_err();
assert!(err.contains("not an array"), "{err}");
}
#[tokio::test]
async fn ollama_results_truncate_long_output() {
let many: Value = json!({
"results": (0..200)
.map(|i| json!({
"title": format!("Result {i}"),
"url": format!("https://example.com/{i}"),
"content": "x".repeat(400),
}))
.collect::<Vec<_>>()
});
let out = format_ollama_results(&many.to_string(), 1000).unwrap();
let text = out.as_text().unwrap();
assert!(text.contains("output truncated"), "{text}");
assert!(text.contains("narrow the query"), "{text}");
}
#[test]
fn body_snippet_flattens_and_caps() {
assert_eq!(body_snippet(" a\nb\tc "), "a b c");
let long = "x".repeat(1000);
let snippet = body_snippet(&long);
assert!(snippet.chars().count() == 301, "{snippet}");
assert!(snippet.ends_with('…'));
}
#[test]
fn headers_resolve_env_placeholders() {
unsafe { std::env::set_var("SHUVARIE_TEST_SEARCH_KEY", "secret-token") };
let mut cfg = config(WebSearchKind::Ollama);
cfg.headers.insert(
"Authorization".to_string(),
"Bearer $SHUVARIE_TEST_SEARCH_KEY".to_string(),
);
let tool = WebSearch::new(&cfg, 0);
assert_eq!(
tool.headers.as_slice(),
[(
"Authorization".to_string(),
"Bearer secret-token".to_string()
)]
);
unsafe { std::env::remove_var("SHUVARIE_TEST_SEARCH_KEY") };
let tool = WebSearch::new(&cfg, 0);
assert_eq!(
tool.headers.as_slice(),
[(
"Authorization".to_string(),
"Bearer $SHUVARIE_TEST_SEARCH_KEY".to_string()
)]
);
}
}