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_string, 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 {
122            provider: Arc::new(YouSearchProvider::from_env(client)),
123        }
124    }
125
126    #[must_use]
127    pub fn with_api_key(client: Arc<reqwest::Client>, api_key: String, base_url: &str) -> Self {
128        Self {
129            provider: Arc::new(YouSearchProvider::with_api_key(client, api_key, base_url)),
130        }
131    }
132
133    #[cfg(test)]
134    fn with_provider(provider: Arc<dyn WebSearchProvider>) -> Self {
135        Self { provider }
136    }
137
138    async fn execute_search(&self, call_id: &str, arguments: &str, config: &Value) -> Result<ToolOutput, ToolError> {
139        let args = WebSearchArguments::from_json(arguments)?;
140        let config = serde_json::from_value::<WebSearchToolParam>(config.clone())
141            .map_err(|e| ToolError::Config(format!("invalid web_search config: {e}")))?;
142        let response = self.provider.search(&args, &config).await?;
143        let output = serde_json::to_string(&serde_json::json!({
144            "query": response.query,
145            "results": response.results,
146            "metadata": response.metadata
147        }))
148        .map_err(|e| ToolError::Execution(format!("failed to serialize web_search output: {e}")))?;
149
150        Ok(ToolOutput {
151            call_id: call_id.to_owned(),
152            output,
153        })
154    }
155}
156
157trait WebSearchProvider: std::fmt::Debug + Send + Sync {
158    fn search<'a>(
159        &'a self,
160        args: &'a WebSearchArguments,
161        config: &'a WebSearchToolParam,
162    ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>>;
163}
164
165struct WebSearchProviderResponse {
166    query: String,
167    results: Value,
168    metadata: Value,
169}
170
171#[derive(Debug, Clone)]
172struct YouSearchProvider {
173    client: Arc<reqwest::Client>,
174    api_key: Option<String>,
175    base_url: Option<String>,
176}
177
178impl YouSearchProvider {
179    fn from_env(client: Arc<reqwest::Client>) -> Self {
180        let api_key = std::env::var(YOU_API_KEY)
181            .ok()
182            .map(|value| value.trim().to_owned())
183            .filter(|value| !value.is_empty());
184        let base_url = std::env::var(YOU_API_BASE_URL)
185            .ok()
186            .and_then(|value| clean_base_url(&value));
187        Self {
188            client,
189            api_key,
190            base_url,
191        }
192    }
193
194    fn with_api_key(client: Arc<reqwest::Client>, api_key: String, base_url: &str) -> Self {
195        Self {
196            client,
197            api_key: Some(api_key),
198            base_url: clean_base_url(base_url),
199        }
200    }
201}
202
203impl WebSearchProvider for YouSearchProvider {
204    fn search<'a>(
205        &'a self,
206        args: &'a WebSearchArguments,
207        config: &'a WebSearchToolParam,
208    ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>> {
209        Box::pin(async move {
210            let api_key = self
211                .api_key
212                .as_deref()
213                .ok_or_else(|| ToolError::Config(format!("{YOU_API_KEY} must be set to use the web_search tool")))?;
214            let base_url = self.base_url.as_deref().ok_or_else(|| {
215                ToolError::Config(format!("{YOU_API_BASE_URL} must be set to use the web_search tool"))
216            })?;
217            let request = YouSearchRequest::from_args_and_config(args, config)?;
218            let url = format!("{base_url}/v1/search");
219            let body = serialize_to_string(&request)
220                .map_err(|e| ToolError::Execution(format!("failed to serialize web_search request: {e}")))?;
221
222            let resp = self
223                .client
224                .post(url)
225                .header("X-API-Key", api_key)
226                .header("Content-Type", "application/json")
227                .body(body)
228                .send()
229                .await
230                .map_err(|e| ToolError::Execution(format!("You.com search request failed: {e}")))?;
231
232            if !resp.status().is_success() {
233                let status = resp.status();
234                let body = resp.text().await.unwrap_or_default();
235                return Err(ToolError::Execution(format!(
236                    "You.com search returned {status}: {body}"
237                )));
238            }
239
240            let response_text = resp
241                .text()
242                .await
243                .map_err(|e| ToolError::Execution(format!("failed to read You.com search response: {e}")))?;
244            let response: Value = serde_json::from_str(&response_text)
245                .map_err(|e| ToolError::Execution(format!("You.com search returned invalid JSON: {e}")))?;
246            Ok(WebSearchProviderResponse {
247                query: request.query,
248                results: response
249                    .get("results")
250                    .cloned()
251                    .unwrap_or_else(|| serde_json::json!({"web": [], "news": []})),
252                metadata: response.get("metadata").cloned().unwrap_or(Value::Null),
253            })
254        })
255    }
256}
257
258impl ToolHandler for WebSearchHandler {
259    fn tool_type(&self) -> ToolType {
260        ToolType::WebSearch
261    }
262
263    fn validate(&self, param: &Value) -> Result<(), ToolError> {
264        serde_json::from_value::<WebSearchToolParam>(param.clone())
265            .map(|_| ())
266            .map_err(|e| ToolError::Config(format!("invalid web_search config: {e}")))
267    }
268
269    fn normalize(&self, _param: &Value) -> Vec<FunctionTool> {
270        vec![web_search_function_tool()]
271    }
272}
273
274impl GatewayExecutor for WebSearchHandler {
275    fn execute(
276        &self,
277        call_id: &str,
278        tool_name: &str,
279        arguments: &str,
280        config: &Value,
281    ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
282        let call_id = call_id.to_owned();
283        let tool_name = tool_name.to_owned();
284        let arguments = arguments.to_owned();
285        let config = config.clone();
286        Box::pin(async move {
287            if tool_name != "web_search" {
288                return Err(ToolError::Config(format!(
289                    "web_search handler cannot execute tool '{tool_name}'"
290                )));
291            }
292            self.execute_search(&call_id, &arguments, &config).await
293        })
294    }
295}
296
297#[derive(Debug, Deserialize)]
298struct WebSearchArguments {
299    query: String,
300    count: Option<u16>,
301    freshness: Option<String>,
302    country: Option<String>,
303    language: Option<String>,
304    safesearch: Option<String>,
305    livecrawl: Option<String>,
306    livecrawl_formats: Option<Vec<String>>,
307    crawl_timeout: Option<u16>,
308    include_domains: Option<Vec<String>>,
309    exclude_domains: Option<Vec<String>>,
310    boost_domains: Option<Vec<String>>,
311}
312
313impl WebSearchArguments {
314    fn from_json(arguments: &str) -> Result<Self, ToolError> {
315        let args = serde_json::from_str::<Self>(arguments)
316            .map_err(|e| ToolError::Config(format!("web_search arguments must be valid JSON: {e}")))?;
317        if args.query.trim().is_empty() {
318            return Err(ToolError::Config("web_search query must not be empty".to_owned()));
319        }
320        Ok(args)
321    }
322}
323
324#[derive(Debug, Serialize)]
325struct YouSearchRequest {
326    query: String,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    count: Option<u8>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    freshness: Option<String>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    country: Option<String>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    language: Option<String>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    safesearch: Option<String>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    livecrawl: Option<String>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    livecrawl_formats: Option<Vec<String>>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    crawl_timeout: Option<u8>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    include_domains: Option<Vec<String>>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    exclude_domains: Option<Vec<String>>,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    boost_domains: Option<Vec<String>>,
349}
350
351impl YouSearchRequest {
352    fn from_args_and_config(args: &WebSearchArguments, config: &WebSearchToolParam) -> Result<Self, ToolError> {
353        let count = args
354            .count
355            .or_else(|| {
356                config
357                    .search_context_size
358                    .map(WebSearchContextSize::default_count)
359                    .map(u16::from)
360            })
361            .map(validate_count)
362            .transpose()?;
363        let crawl_timeout = args.crawl_timeout.map(validate_crawl_timeout).transpose()?;
364        let config_domains = config
365            .filters
366            .as_ref()
367            .and_then(|filters| clean_vec(filters.allowed_domains.as_deref()));
368        let config_blocked_domains = config
369            .filters
370            .as_ref()
371            .and_then(|filters| clean_vec(filters.blocked_domains.as_deref()));
372        let include_domains = config_domains.or_else(|| clean_vec(args.include_domains.as_deref()));
373        let exclude_domains = config_blocked_domains.or_else(|| clean_vec(args.exclude_domains.as_deref()));
374        let boost_domains = clean_vec(args.boost_domains.as_deref());
375        if include_domains.is_some() && (exclude_domains.is_some() || boost_domains.is_some()) {
376            return Err(ToolError::Config(
377                "include_domains cannot be combined with exclude_domains or boost_domains".to_owned(),
378            ));
379        }
380        let country = config
381            .user_location
382            .as_ref()
383            .and_then(|location| clean_string(location.country.as_deref()))
384            .or_else(|| clean_string(args.country.as_deref()))
385            .map(|value| value.to_ascii_uppercase());
386
387        Ok(Self {
388            query: args.query.trim().to_owned(),
389            count,
390            freshness: clean_string(args.freshness.as_deref()),
391            country,
392            language: clean_string(args.language.as_deref()),
393            safesearch: clean_string(args.safesearch.as_deref()),
394            livecrawl: clean_string(args.livecrawl.as_deref()),
395            livecrawl_formats: clean_vec(args.livecrawl_formats.as_deref()),
396            crawl_timeout,
397            include_domains,
398            exclude_domains,
399            boost_domains,
400        })
401    }
402}
403
404fn validate_count(count: u16) -> Result<u8, ToolError> {
405    if (1..=100).contains(&count) {
406        Ok(u8::try_from(count).expect("validated web_search count must fit in u8"))
407    } else {
408        Err(ToolError::Config(
409            "web_search count must be between 1 and 100".to_owned(),
410        ))
411    }
412}
413
414fn validate_crawl_timeout(timeout: u16) -> Result<u8, ToolError> {
415    if (1..=60).contains(&timeout) {
416        u8::try_from(timeout).map_err(|e| ToolError::Config(format!("invalid crawl_timeout: {e}")))
417    } else {
418        Err(ToolError::Config(
419            "web_search crawl_timeout must be between 1 and 60".to_owned(),
420        ))
421    }
422}
423
424fn clean_string(value: Option<&str>) -> Option<String> {
425    value
426        .map(str::trim)
427        .filter(|value| !value.is_empty())
428        .map(str::to_owned)
429}
430
431fn clean_json_str(value: Option<&Value>) -> Option<String> {
432    value
433        .and_then(Value::as_str)
434        .map(str::trim)
435        .filter(|value| !value.is_empty())
436        .map(str::to_owned)
437}
438
439fn call_output_id(call: &FunctionToolCall) -> String {
440    if let Some(suffix) = call.id.strip_prefix("fc_").filter(|suffix| !suffix.is_empty()) {
441        return format!("ws_{suffix}");
442    }
443    if let Some(suffix) = call.call_id.strip_prefix("call_").filter(|suffix| !suffix.is_empty()) {
444        return format!("ws_{suffix}");
445    }
446    crate::utils::uuid7_str("ws_")
447}
448
449fn query_from_arguments(arguments: &str) -> Option<String> {
450    let args = serde_json::from_str::<Value>(arguments).ok()?;
451    clean_json_str(args.get("query"))
452}
453
454fn sources_from_output(output: &Value) -> Vec<WebSearchSource> {
455    ["web", "news"]
456        .into_iter()
457        .filter_map(|section| output.get("results")?.get(section)?.as_array())
458        .flat_map(|results| results.iter())
459        .filter_map(source_from_result)
460        .collect()
461}
462
463fn source_from_result(result: &Value) -> Option<WebSearchSource> {
464    let url = clean_json_str(result.get("url"))?;
465    Some(WebSearchSource {
466        url,
467        title: clean_json_str(result.get("title")),
468    })
469}
470
471fn clean_base_url(value: &str) -> Option<String> {
472    let trimmed = value.trim().trim_end_matches('/');
473    (!trimmed.is_empty()).then(|| trimmed.to_owned())
474}
475
476fn clean_vec(values: Option<&[String]>) -> Option<Vec<String>> {
477    let cleaned: Vec<String> = values
478        .unwrap_or_default()
479        .iter()
480        .filter_map(|value| clean_string(Some(value.as_str())))
481        .collect();
482    (!cleaned.is_empty()).then_some(cleaned)
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[derive(Debug)]
490    struct MockSearchProvider;
491
492    impl WebSearchProvider for MockSearchProvider {
493        fn search<'a>(
494            &'a self,
495            args: &'a WebSearchArguments,
496            _config: &'a WebSearchToolParam,
497        ) -> Pin<Box<dyn Future<Output = Result<WebSearchProviderResponse, ToolError>> + Send + 'a>> {
498            Box::pin(async move {
499                Ok(WebSearchProviderResponse {
500                    query: args.query.trim().to_owned(),
501                    results: serde_json::json!({
502                        "web": [
503                            {
504                                "url": "https://example.com/potato",
505                                "title": "Potato"
506                            }
507                        ],
508                        "news": []
509                    }),
510                    metadata: serde_json::json!({"provider": "mock"}),
511                })
512            })
513        }
514    }
515
516    #[tokio::test]
517    async fn web_search_handler_delegates_to_provider() {
518        let handler = WebSearchHandler::with_provider(Arc::new(MockSearchProvider));
519        let output = handler
520            .execute(
521                "call_search",
522                "web_search",
523                r#"{"query":" potato "}"#,
524                &serde_json::json!({"type": "web_search_preview"}),
525            )
526            .await
527            .unwrap();
528        let body: Value = serde_json::from_str(&output.output).unwrap();
529        assert_eq!(output.call_id, "call_search");
530        assert_eq!(body["query"], "potato");
531        assert_eq!(body["metadata"]["provider"], "mock");
532        assert_eq!(body["results"]["web"][0]["url"], "https://example.com/potato");
533    }
534}