telos_agent/tools/builtin/
web_search.rs1use async_trait::async_trait;
2use serde_json::{Value, json};
3
4use crate::error::AgentError;
5use crate::tools::api::{Tool, ToolContext, ToolDefinition, ToolOutput, ToolProgress};
6
7mod filters;
8mod parsers;
9mod providers;
10
11use filters::DomainFilters;
12use providers::{bing_cn_search, duckduckgo_lite_search};
13
14pub struct WebSearchTool;
21
22#[async_trait]
23impl Tool for WebSearchTool {
24 fn definition(&self) -> ToolDefinition {
25 ToolDefinition {
26 name: "WebSearch".into(),
27 description:
28 "Search the web without an API key. Tries Bing China first, then DuckDuckGo Lite. Returns titles, URLs, and snippets."
29 .into(),
30 input_schema: json!({
31 "type": "object",
32 "properties": {
33 "query": { "type": "string" },
34 "allowed_domains": {
35 "type": "array",
36 "items": { "type": "string" },
37 "description": "Only include search results from these domains."
38 },
39 "blocked_domains": {
40 "type": "array",
41 "items": { "type": "string" },
42 "description": "Never include search results from these domains."
43 }
44 },
45 "required": ["query"]
46 }),
47 }
48 }
49
50 fn prompt_text(&self) -> Option<&'static str> {
51 Some(
52 "Use WebSearch for discovery of up-to-date information not in the codebase. \
53Prefer WebFetch when you know the URL. Use `allowed_domains`/`blocked_domains` to scope results. \
54If blocked, switch to WebFetch with known URLs or ask the user for sources.",
55 )
56 }
57
58 fn is_concurrency_safe(&self, _: &Value) -> bool {
59 true
60 }
61
62 async fn validate(&self, arguments: &Value, _context: &ToolContext) -> Result<(), AgentError> {
63 if arguments.get("query").and_then(|v| v.as_str()).is_none() {
64 return Err(AgentError::Validation("missing query".into()));
65 }
66 if arguments.get("allowed_domains").is_some() && arguments.get("blocked_domains").is_some()
67 {
68 return Err(AgentError::Validation(
69 "cannot specify both allowed_domains and blocked_domains".into(),
70 ));
71 }
72 let _ = DomainFilters::from_args(arguments)?;
73 Ok(())
74 }
75
76 async fn invoke(&self, args: Value, context: ToolContext) -> Result<ToolOutput, AgentError> {
77 let query = args
78 .get("query")
79 .and_then(|v| v.as_str())
80 .ok_or_else(|| AgentError::Validation("missing query".into()))?;
81 let filters = DomainFilters::from_args(&args)?;
82 emit_search_progress(&context, "searching web", query, None);
83
84 match bing_cn_search(query, &filters).await {
85 Ok(output) => {
86 emit_result_progress(&context, query, &output);
87 Ok(output)
88 }
89 Err(bing_err) => match duckduckgo_lite_search(query, &filters).await {
90 Ok(output) => {
91 emit_result_progress(&context, query, &output);
92 Ok(output)
93 }
94 Err(ddg_err) => Err(AgentError::ToolExecution {
95 tool: "WebSearch".into(),
96 message: format!("{bing_err}; fallback failed: {ddg_err}"),
97 }),
98 },
99 }
100 }
101}
102
103fn emit_search_progress(
104 context: &ToolContext,
105 message: &str,
106 query: &str,
107 result_count: Option<usize>,
108) {
109 if let Some(tx) = &context.progress {
110 let _ = tx.send(ToolProgress {
111 tool_call_id: None,
112 message: message.to_string(),
113 data: Some(json!({
114 "query": query,
115 "result_count": result_count,
116 })),
117 });
118 }
119}
120
121fn emit_result_progress(context: &ToolContext, query: &str, output: &ToolOutput) {
122 let count = output.content.get("count").and_then(Value::as_u64).map(|count| count as usize);
123 emit_search_progress(context, "search results received", query, count);
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn detects_ddg_bot_challenge_page() {
132 let challenge_html =
133 r#"<div class="anomaly-modal__title">Unfortunately, bots use DuckDuckGo too.</div>"#;
134 assert!(parsers::is_bot_challenge(challenge_html));
135 }
136
137 #[test]
138 fn normal_html_is_not_a_bot_challenge() {
139 let normal_html = r#"<a rel="nofollow" href="https://example.com">Example</a><td class="result-snippet">A snippet</td>"#;
140 assert!(!parsers::is_bot_challenge(normal_html));
141 }
142
143 #[test]
144 fn url_encode_uses_utf8_percent_encoding() {
145 assert_eq!(
146 parsers::url_encode("2026世界杯 比赛结果"),
147 "2026%E4%B8%96%E7%95%8C%E6%9D%AF+%E6%AF%94%E8%B5%9B%E7%BB%93%E6%9E%9C"
148 );
149 }
150
151 #[test]
152 fn detects_bing_challenge_pages() {
153 assert!(parsers::is_bing_challenge_or_non_result("Please verify you are human"));
154 assert!(parsers::is_bing_challenge_or_non_result(
155 "<div id=\"challenge-form\">captcha</div>"
156 ));
157 assert!(!parsers::is_bing_challenge_or_non_result(
158 "<rss><channel><item></item></channel></rss>"
159 ));
160 }
161
162 #[test]
163 fn domain_filters_allow_subdomains_and_block_domains() {
164 let filters = filters::DomainFilters {
165 allowed_domains: vec!["example.com".into()],
166 blocked_domains: vec![],
167 };
168 assert!(filters.allows("https://docs.example.com/page"));
169 assert!(!filters.allows("https://other.com/page"));
170
171 let filters = filters::DomainFilters {
172 allowed_domains: vec![],
173 blocked_domains: vec!["example.com".into()],
174 };
175 assert!(!filters.allows("https://docs.example.com/page"));
176 assert!(filters.allows("https://other.com/page"));
177 }
178
179 #[test]
180 fn filter_results_applies_domain_filters() {
181 let filters = filters::DomainFilters {
182 allowed_domains: vec!["example.com".into()],
183 blocked_domains: vec![],
184 };
185 let mut results = vec![
186 json!({"title": "A", "url": "https://example.com/a"}),
187 json!({"title": "B", "url": "https://blocked.test/b"}),
188 ];
189
190 filters::filter_results(&mut results, &filters);
191 assert_eq!(results.len(), 1);
192 assert_eq!(results[0]["url"], "https://example.com/a");
193 }
194
195 #[test]
196 fn prompt_discourages_retrying_after_bot_challenge() {
197 let prompt = WebSearchTool.prompt_text().unwrap();
198 assert!(prompt.contains("WebFetch"));
199 assert!(prompt.contains("blocked"));
200 assert!(!prompt.contains("BRAVE_SEARCH_API_KEY"));
201 }
202
203 #[test]
204 fn parses_bing_results() {
205 let html = r#"
206 <li class="b_algo">
207 <h2><a href="https://example.com">Example & Test</a></h2>
208 <div class="b_caption"><p>Example snippet & details</p></div>
209 </li>
210 "#;
211
212 let results = parsers::parse_bing_results(html);
213 assert_eq!(results.len(), 1);
214 assert_eq!(results[0]["title"], "Example & Test");
215 assert_eq!(results[0]["url"], "https://example.com");
216 assert_eq!(results[0]["snippet"], "Example snippet & details");
217 }
218
219 #[test]
220 fn parses_bing_rss_results() {
221 let xml = r#"
222 <?xml version="1.0" encoding="utf-8" ?>
223 <rss version="2.0">
224 <channel>
225 <item>
226 <title>Example & Test</title>
227 <link>https://example.com/page</link>
228 <description>Snippet & details</description>
229 <pubDate>Fri, 19 Jun 2026 16:09:20 GMT</pubDate>
230 </item>
231 </channel>
232 </rss>
233 "#;
234
235 let results = parsers::parse_bing_rss_results(xml);
236 assert_eq!(results.len(), 1);
237 assert_eq!(results[0]["title"], "Example & Test");
238 assert_eq!(results[0]["url"], "https://example.com/page");
239 assert_eq!(results[0]["snippet"], "Snippet & details");
240 assert_eq!(results[0]["published"], "Fri, 19 Jun 2026 16:09:20 GMT");
241 }
242}