Skip to main content

agentic_core/tool/
web_search.rs

1use std::collections::HashMap;
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::Arc;
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::types::io::output::{FunctionToolCall, WebSearchCall, WebSearchCallStatus, WebSearchSource};
10use crate::types::io::{FunctionTool, OutputItem};
11use crate::types::tools::{WebSearchContextSize, WebSearchToolParam};
12use crate::utils::common::serialize_to_value_or_custom_default;
13
14use super::handler::{GatewayExecutor, ToolError, ToolHandler, ToolOutput};
15use super::registry::{ToolEntry, ToolType};
16
17const YOU_API_KEY: &str = "YOU_API_KEY";
18const YOU_API_BASE_URL: &str = "YOU_API_BASE_URL";
19
20pub(crate) fn insert_web_search_entry(
21    entries: &mut HashMap<String, ToolEntry>,
22    p: &WebSearchToolParam,
23    handler: Option<Arc<dyn GatewayExecutor>>,
24) {
25    serialize_to_value_or_custom_default(
26        p,
27        "web_search tool config serialization failed",
28        |config| {
29            entries.insert(
30                "web_search".to_owned(),
31                ToolEntry {
32                    tool_type: ToolType::WebSearch,
33                    config,
34                    server_label: None,
35                    handler,
36                },
37            );
38        },
39        (),
40    );
41}
42
43#[must_use]
44pub(crate) fn web_search_function_tool() -> FunctionTool {
45    FunctionTool {
46        type_: "function".to_owned(),
47        name: "web_search".to_owned(),
48        description: Some(
49            "Search the public web for current information and return structured web and news results.".to_owned(),
50        ),
51        parameters: Some(serde_json::json!({
52            "type": "object",
53            "properties": {
54                "query": {
55                    "type": "string",
56                    "description": "The natural language web search query."
57                },
58                "count": {
59                    "type": "integer",
60                    "description": "Maximum results per section, from 1 to 100."
61                },
62                "freshness": {
63                    "type": "string",
64                    "description": "Optional recency filter: day, week, month, year, or YYYY-MM-DDtoYYYY-MM-DD."
65                },
66                "country": {
67                    "type": "string",
68                    "description": "Optional ISO 3166-1 alpha-2 country code."
69                },
70                "language": {
71                    "type": "string",
72                    "description": "Optional BCP 47 language code."
73                },
74                "include_domains": {
75                    "type": "array",
76                    "items": {"type": "string"},
77                    "description": "Optional strict allowlist of domains."
78                },
79                "exclude_domains": {
80                    "type": "array",
81                    "items": {"type": "string"},
82                    "description": "Optional domain blocklist."
83                }
84            },
85            "required": ["query"]
86        })),
87        strict: Some(false),
88    }
89}
90
91#[must_use]
92pub(crate) fn output_item(call: &FunctionToolCall, output: &ToolOutput, status: WebSearchCallStatus) -> OutputItem {
93    let parsed_output = serde_json::from_str::<Value>(&output.output).ok();
94    let query = parsed_output
95        .as_ref()
96        .and_then(|value| clean_json_str(value.get("query")))
97        .or_else(|| query_from_arguments(&call.arguments))
98        .unwrap_or_default();
99    let sources = parsed_output.as_ref().map(sources_from_output).unwrap_or_default();
100    OutputItem::WebSearchCall(WebSearchCall::new(call_output_id(call), status, query, sources))
101}
102
103#[must_use]
104pub(crate) fn started_output_item(call: &FunctionToolCall) -> OutputItem {
105    OutputItem::WebSearchCall(WebSearchCall::new(
106        call_output_id(call),
107        WebSearchCallStatus::InProgress,
108        query_from_arguments(&call.arguments).unwrap_or_default(),
109        Vec::new(),
110    ))
111}
112
113#[derive(Debug, Clone)]
114pub struct WebSearchHandler {
115    provider: Arc<dyn WebSearchProvider>,
116}
117
118impl WebSearchHandler {
119    #[must_use]
120    pub fn from_env(client: Arc<reqwest::Client>) -> Self {
121        Self::from_values(
122            client,
123            std::env::var(YOU_API_KEY).ok(),
124            std::env::var(YOU_API_BASE_URL).ok(),
125        )
126    }
127
128    #[must_use]
129    pub fn from_values(client: Arc<reqwest::Client>, api_key: Option<String>, base_url: Option<String>) -> Self {
130        Self {
131            provider: Arc::new(YouSearchProvider::from_values(client, api_key, base_url)),
132        }
133    }
134
135    #[must_use]
136    pub fn with_api_key(client: Arc<reqwest::Client>, api_key: String, base_url: &str) -> Self {
137        Self {
138            provider: Arc::new(YouSearchProvider::with_api_key(client, api_key, base_url)),
139        }
140    }
141
142    #[cfg(test)]
143    fn with_provider(provider: Arc<dyn WebSearchProvider>) -> Self {
144        Self { provider }
145    }
146
147    async fn execute_search(&self, call_id: &str, arguments: &str, config: &Value) -> Result<ToolOutput, ToolError> {
148        let args = WebSearchArguments::from_json(arguments)?;
149        let config = serde_json::from_value::<WebSearchToolParam>(config.clone())
150            .map_err(|e| ToolError::Config(format!("invalid web_search config: {e}")))?;
151        let response = self.provider.search(&args, &config).await?;
152        let output = serde_json::to_string(&serde_json::json!({
153            "query": response.query,
154            "results": response.results,
155            "metadata": response.metadata
156        }))
157        .map_err(|e| ToolError::Execution(format!("failed to serialize web_search output: {e}")))?;
158
159        Ok(ToolOutput {
160            call_id: call_id.to_owned(),
161            output,
162        })
163    }
164}
165
166trait WebSearchProvider: std::fmt::Debug + Send + Sync {
167    fn search<'a>(
168        &'a self,
169        args: &'a WebSearchArguments,
170        config: &'a WebSearchToolParam,
171    ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>>;
172}
173
174struct WebSearchProviderResponse {
175    query: String,
176    results: Value,
177    metadata: Value,
178}
179
180#[derive(Debug, Clone)]
181struct YouSearchProvider {
182    client: Arc<reqwest::Client>,
183    api_key: Option<String>,
184    base_url: Option<String>,
185}
186
187impl YouSearchProvider {
188    fn from_values(client: Arc<reqwest::Client>, api_key: Option<String>, base_url: Option<String>) -> Self {
189        let api_key = api_key
190            .map(|value| value.trim().to_owned())
191            .filter(|value| !value.is_empty());
192        let base_url = base_url.and_then(|value| clean_base_url(&value));
193        Self {
194            client,
195            api_key,
196            base_url,
197        }
198    }
199
200    fn with_api_key(client: Arc<reqwest::Client>, api_key: String, base_url: &str) -> Self {
201        Self {
202            client,
203            api_key: Some(api_key),
204            base_url: clean_base_url(base_url),
205        }
206    }
207}
208
209impl WebSearchProvider for YouSearchProvider {
210    fn search<'a>(
211        &'a self,
212        args: &'a WebSearchArguments,
213        config: &'a WebSearchToolParam,
214    ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>> {
215        Box::pin(async move {
216            let api_key = self
217                .api_key
218                .as_deref()
219                .ok_or_else(|| ToolError::Config(format!("{YOU_API_KEY} must be set to use the web_search tool")))?;
220            let base_url = self.base_url.as_deref().ok_or_else(|| {
221                ToolError::Config(format!("{YOU_API_BASE_URL} must be set to use the web_search tool"))
222            })?;
223            let request = YouSearchRequest::from_args_and_config(args, config)?;
224            let resp = self
225                .client
226                .get(format!("{base_url}/v1/search"))
227                .query(&request.query_params())
228                .header("X-API-Key", api_key)
229                .send()
230                .await
231                .map_err(|e| ToolError::Execution(format!("You.com search request failed: {e}")))?;
232
233            if !resp.status().is_success() {
234                let status = resp.status();
235                let body = resp.text().await.unwrap_or_default();
236                return Err(ToolError::Execution(format!(
237                    "You.com search returned {status}: {body}"
238                )));
239            }
240
241            let response_text = resp
242                .text()
243                .await
244                .map_err(|e| ToolError::Execution(format!("failed to read You.com search response: {e}")))?;
245            let response: Value = serde_json::from_str(&response_text)
246                .map_err(|e| ToolError::Execution(format!("You.com search returned invalid JSON: {e}")))?;
247            Ok(WebSearchProviderResponse {
248                query: request.query,
249                results: response
250                    .get("results")
251                    .cloned()
252                    .unwrap_or_else(|| serde_json::json!({"web": [], "news": []})),
253                metadata: response.get("metadata").cloned().unwrap_or(Value::Null),
254            })
255        })
256    }
257}
258
259impl ToolHandler for WebSearchHandler {
260    fn tool_type(&self) -> ToolType {
261        ToolType::WebSearch
262    }
263
264    fn validate(&self, param: &Value) -> Result<(), ToolError> {
265        serde_json::from_value::<WebSearchToolParam>(param.clone())
266            .map(|_| ())
267            .map_err(|e| ToolError::Config(format!("invalid web_search config: {e}")))
268    }
269
270    fn normalize(&self, _param: &Value) -> Vec<FunctionTool> {
271        vec![web_search_function_tool()]
272    }
273}
274
275impl GatewayExecutor for WebSearchHandler {
276    fn execute(
277        &self,
278        call_id: &str,
279        tool_name: &str,
280        arguments: &str,
281        config: &Value,
282    ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
283        let call_id = call_id.to_owned();
284        let tool_name = tool_name.to_owned();
285        let arguments = arguments.to_owned();
286        let config = config.clone();
287        Box::pin(async move {
288            if tool_name != "web_search" {
289                return Err(ToolError::Config(format!(
290                    "web_search handler cannot execute tool '{tool_name}'"
291                )));
292            }
293            self.execute_search(&call_id, &arguments, &config).await
294        })
295    }
296}
297
298#[derive(Debug, Deserialize)]
299struct WebSearchArguments {
300    query: String,
301    count: Option<u16>,
302    freshness: Option<String>,
303    country: Option<String>,
304    language: Option<String>,
305    safesearch: Option<String>,
306    livecrawl: Option<String>,
307    livecrawl_formats: Option<Vec<String>>,
308    crawl_timeout: Option<u16>,
309    include_domains: Option<Vec<String>>,
310    exclude_domains: Option<Vec<String>>,
311    boost_domains: Option<Vec<String>>,
312}
313
314impl WebSearchArguments {
315    fn from_json(arguments: &str) -> Result<Self, ToolError> {
316        let args = serde_json::from_str::<Self>(arguments)
317            .map_err(|e| ToolError::Config(format!("web_search arguments must be valid JSON: {e}")))?;
318        if args.query.trim().is_empty() {
319            return Err(ToolError::Config("web_search query must not be empty".to_owned()));
320        }
321        Ok(args)
322    }
323}
324
325#[derive(Debug, Serialize)]
326struct YouSearchRequest {
327    query: String,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    count: Option<u8>,
330    #[serde(skip_serializing_if = "Option::is_none")]
331    freshness: Option<String>,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    country: Option<String>,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    language: Option<String>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    safesearch: Option<String>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    livecrawl: Option<String>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    livecrawl_formats: Option<Vec<String>>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    crawl_timeout: Option<u8>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    include_domains: Option<Vec<String>>,
346    #[serde(skip_serializing_if = "Option::is_none")]
347    exclude_domains: Option<Vec<String>>,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    boost_domains: Option<Vec<String>>,
350}
351
352impl YouSearchRequest {
353    fn query_params(&self) -> Vec<(String, String)> {
354        let mut params = vec![("query".to_owned(), self.query.clone())];
355        if let Some(count) = self.count {
356            params.push(("count".to_owned(), count.to_string()));
357        }
358        if let Some(freshness) = &self.freshness {
359            params.push(("freshness".to_owned(), freshness.clone()));
360        }
361        if let Some(country) = &self.country {
362            params.push(("country".to_owned(), country.clone()));
363        }
364        if let Some(language) = &self.language {
365            params.push(("language".to_owned(), language.clone()));
366        }
367        if let Some(safesearch) = &self.safesearch {
368            params.push(("safesearch".to_owned(), safesearch.clone()));
369        }
370        if let Some(livecrawl) = &self.livecrawl {
371            params.push(("livecrawl".to_owned(), livecrawl.clone()));
372        }
373        for format in self.livecrawl_formats.iter().flatten() {
374            params.push(("livecrawl_formats".to_owned(), format.clone()));
375        }
376        if let Some(crawl_timeout) = self.crawl_timeout {
377            params.push(("crawl_timeout".to_owned(), crawl_timeout.to_string()));
378        }
379        for domain in self.include_domains.iter().flatten() {
380            params.push(("include_domains".to_owned(), domain.clone()));
381        }
382        for domain in self.exclude_domains.iter().flatten() {
383            params.push(("exclude_domains".to_owned(), domain.clone()));
384        }
385        for domain in self.boost_domains.iter().flatten() {
386            params.push(("boost_domains".to_owned(), domain.clone()));
387        }
388        params
389    }
390
391    fn from_args_and_config(args: &WebSearchArguments, config: &WebSearchToolParam) -> Result<Self, ToolError> {
392        let count = args
393            .count
394            .or_else(|| {
395                config
396                    .search_context_size
397                    .map(WebSearchContextSize::default_count)
398                    .map(u16::from)
399            })
400            .map(validate_count)
401            .transpose()?;
402        let crawl_timeout = args.crawl_timeout.map(validate_crawl_timeout).transpose()?;
403        let config_domains = config
404            .filters
405            .as_ref()
406            .and_then(|filters| clean_vec(filters.allowed_domains.as_deref()));
407        let config_blocked_domains = config
408            .filters
409            .as_ref()
410            .and_then(|filters| clean_vec(filters.blocked_domains.as_deref()));
411        let include_domains = config_domains.or_else(|| clean_vec(args.include_domains.as_deref()));
412        let exclude_domains = config_blocked_domains.or_else(|| clean_vec(args.exclude_domains.as_deref()));
413        let boost_domains = clean_vec(args.boost_domains.as_deref());
414        if include_domains.is_some() && (exclude_domains.is_some() || boost_domains.is_some()) {
415            return Err(ToolError::Config(
416                "include_domains cannot be combined with exclude_domains or boost_domains".to_owned(),
417            ));
418        }
419        let country = config
420            .user_location
421            .as_ref()
422            .and_then(|location| clean_string(location.country.as_deref()))
423            .or_else(|| clean_string(args.country.as_deref()))
424            .map(|value| value.to_ascii_uppercase());
425
426        Ok(Self {
427            query: args.query.trim().to_owned(),
428            count,
429            freshness: clean_string(args.freshness.as_deref()),
430            country,
431            language: clean_string(args.language.as_deref()),
432            safesearch: clean_string(args.safesearch.as_deref()),
433            livecrawl: clean_string(args.livecrawl.as_deref()),
434            livecrawl_formats: clean_vec(args.livecrawl_formats.as_deref()),
435            crawl_timeout,
436            include_domains,
437            exclude_domains,
438            boost_domains,
439        })
440    }
441}
442
443fn validate_count(count: u16) -> Result<u8, ToolError> {
444    if (1..=100).contains(&count) {
445        Ok(u8::try_from(count).expect("validated web_search count must fit in u8"))
446    } else {
447        Err(ToolError::Config(
448            "web_search count must be between 1 and 100".to_owned(),
449        ))
450    }
451}
452
453fn validate_crawl_timeout(timeout: u16) -> Result<u8, ToolError> {
454    if (1..=60).contains(&timeout) {
455        u8::try_from(timeout).map_err(|e| ToolError::Config(format!("invalid crawl_timeout: {e}")))
456    } else {
457        Err(ToolError::Config(
458            "web_search crawl_timeout must be between 1 and 60".to_owned(),
459        ))
460    }
461}
462
463fn clean_string(value: Option<&str>) -> Option<String> {
464    value
465        .map(str::trim)
466        .filter(|value| !value.is_empty())
467        .map(str::to_owned)
468}
469
470fn clean_json_str(value: Option<&Value>) -> Option<String> {
471    value
472        .and_then(Value::as_str)
473        .map(str::trim)
474        .filter(|value| !value.is_empty())
475        .map(str::to_owned)
476}
477
478fn call_output_id(call: &FunctionToolCall) -> String {
479    if let Some(suffix) = call.id.strip_prefix("fc_").filter(|suffix| !suffix.is_empty()) {
480        return format!("ws_{suffix}");
481    }
482    if let Some(suffix) = call.call_id.strip_prefix("call_").filter(|suffix| !suffix.is_empty()) {
483        return format!("ws_{suffix}");
484    }
485    crate::utils::uuid7_str("ws_")
486}
487
488fn query_from_arguments(arguments: &str) -> Option<String> {
489    let args = serde_json::from_str::<Value>(arguments).ok()?;
490    clean_json_str(args.get("query"))
491}
492
493fn sources_from_output(output: &Value) -> Vec<WebSearchSource> {
494    ["web", "news"]
495        .into_iter()
496        .filter_map(|section| output.get("results")?.get(section)?.as_array())
497        .flat_map(|results| results.iter())
498        .filter_map(source_from_result)
499        .collect()
500}
501
502fn source_from_result(result: &Value) -> Option<WebSearchSource> {
503    let url = clean_json_str(result.get("url"))?;
504    Some(WebSearchSource {
505        url,
506        title: clean_json_str(result.get("title")),
507    })
508}
509
510fn clean_base_url(value: &str) -> Option<String> {
511    let trimmed = value.trim().trim_end_matches('/');
512    (!trimmed.is_empty()).then(|| trimmed.to_owned())
513}
514
515fn clean_vec(values: Option<&[String]>) -> Option<Vec<String>> {
516    let cleaned: Vec<String> = values
517        .unwrap_or_default()
518        .iter()
519        .filter_map(|value| clean_string(Some(value.as_str())))
520        .collect();
521    (!cleaned.is_empty()).then_some(cleaned)
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[derive(Debug)]
529    struct MockSearchProvider;
530
531    impl WebSearchProvider for MockSearchProvider {
532        fn search<'a>(
533            &'a self,
534            args: &'a WebSearchArguments,
535            _config: &'a WebSearchToolParam,
536        ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>> {
537            Box::pin(async move {
538                Ok(WebSearchProviderResponse {
539                    query: args.query.trim().to_owned(),
540                    results: serde_json::json!({
541                        "web": [
542                            {
543                                "url": "https://example.com/potato",
544                                "title": "Potato"
545                            }
546                        ],
547                        "news": []
548                    }),
549                    metadata: serde_json::json!({"provider": "mock"}),
550                })
551            })
552        }
553    }
554
555    #[tokio::test]
556    async fn web_search_handler_delegates_to_provider() {
557        let handler = WebSearchHandler::with_provider(Arc::new(MockSearchProvider));
558        let output = handler
559            .execute(
560                "call_search",
561                "web_search",
562                r#"{"query":" potato "}"#,
563                &serde_json::json!({"type": "web_search_preview"}),
564            )
565            .await
566            .unwrap();
567        let body: Value = serde_json::from_str(&output.output).unwrap();
568        assert_eq!(output.call_id, "call_search");
569        assert_eq!(body["query"], "potato");
570        assert_eq!(body["metadata"]["provider"], "mock");
571        assert_eq!(body["results"]["web"][0]["url"], "https://example.com/potato");
572    }
573}