Skip to main content

agentic_core/tool/
web_search.rs

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