Skip to main content

telos_agent/tools/builtin/
web_search.rs

1use 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
14/// Tool that searches the web without an API key.
15///
16/// It tries Bing China first for better availability on China networks, then
17/// falls back to DuckDuckGo Lite.
18///
19/// Returns a list of search results with titles, URLs, and snippets.
20pub 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 &amp; Test</a></h2>
208              <div class="b_caption"><p>Example snippet &amp; 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 &amp; Test</title>
227                  <link>https://example.com/page</link>
228                  <description>Snippet &amp; 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}