//! Tool: project_bing_search - search the web with Bing Web Search API. use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; use serde::Deserialize; use std::collections::HashMap; use std::sync::OnceLock; const DEFAULT_COUNT: u64 = 10; const MAX_COUNT: u64 = 50; const DEFAULT_ENDPOINT: &str = "https://api.bing.microsoft.com/v7.0/search"; static SHARED_CLIENT: OnceLock = OnceLock::new(); fn shared_client() -> &'static reqwest::Client { SHARED_CLIENT.get_or_init(|| { reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(30)) .build() .expect("reqwest client build should not fail") }) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BingSearchResponse { #[serde(default)] web_pages: Option, #[serde(default)] query_context: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BingWebPages { #[serde(default)] total_estimated_matches: Option, #[serde(default)] value: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BingWebResult { #[serde(default)] name: String, #[serde(default)] url: String, #[serde(default)] display_url: String, #[serde(default)] snippet: String, #[serde(default)] date_last_crawled: Option, #[serde(default)] language: Option, #[serde(default)] is_family_friendly: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BingQueryContext { #[serde(default)] original_query: String, #[serde(default)] altered_query: Option, } pub async fn bing_search_exec( ctx: ToolContext, args: serde_json::Value, ) -> Result { let query = args .get("query") .and_then(|v| v.as_str()) .map(str::trim) .filter(|s| !s.is_empty()) .ok_or_else(|| ToolError::ExecutionError("query is required".into()))?; let count = args .get("count") .and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_COUNT) .clamp(1, MAX_COUNT); let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0); let market = args .get("market") .and_then(|v| v.as_str()) .unwrap_or("en-US"); let safe_search = args .get("safe_search") .and_then(|v| v.as_str()) .unwrap_or("Moderate"); let freshness = args.get("freshness").and_then(|v| v.as_str()); let api_key = ctx .config() .env .get("APP_BING_SEARCH_API_KEY") .or_else(|| ctx.config().env.get("BING_SEARCH_API_KEY")) .map(String::as_str) .filter(|s| !s.trim().is_empty()) .ok_or_else(|| { ToolError::ExecutionError( "Bing search API key is required: set APP_BING_SEARCH_API_KEY or BING_SEARCH_API_KEY" .into(), ) })?; let endpoint = ctx .config() .env .get("APP_BING_SEARCH_ENDPOINT") .map(String::as_str) .unwrap_or(DEFAULT_ENDPOINT); let mut url = reqwest::Url::parse(endpoint) .map_err(|e| ToolError::ExecutionError(format!("Invalid Bing endpoint: {}", e)))?; { let mut query_pairs = url.query_pairs_mut(); query_pairs .append_pair("q", query) .append_pair("count", &count.to_string()) .append_pair("offset", &offset.to_string()) .append_pair("mkt", market) .append_pair("safeSearch", safe_search) .append_pair("responseFilter", "Webpages") .append_pair("textFormat", "Raw"); if let Some(freshness) = freshness { query_pairs.append_pair("freshness", freshness); } } let response = shared_client() .get(url) .header("Ocp-Apim-Subscription-Key", api_key) .send() .await .map_err(|e| ToolError::ExecutionError(format!("Bing search request failed: {}", e)))?; let status = response.status(); let body = response .text() .await .map_err(|e| ToolError::ExecutionError(format!("Failed to read Bing response: {}", e)))?; if !status.is_success() { return Err(ToolError::ExecutionError(format!( "Bing search returned status {}: {}", status, truncate(&body, 500) ))); } let parsed: BingSearchResponse = serde_json::from_str(&body) .map_err(|e| ToolError::ExecutionError(format!("Failed to parse Bing response: {}", e)))?; let results = parsed .web_pages .as_ref() .map(|pages| { pages .value .iter() .map(|item| { serde_json::json!({ "title": item.name, "url": item.url, "display_url": item.display_url, "snippet": item.snippet, "date_last_crawled": item.date_last_crawled, "language": item.language, "is_family_friendly": item.is_family_friendly, }) }) .collect::>() }) .unwrap_or_default(); Ok(serde_json::json!({ "query": query, "original_query": parsed.query_context.as_ref().map(|q| q.original_query.as_str()).unwrap_or(query), "altered_query": parsed.query_context.and_then(|q| q.altered_query), "count": results.len(), "total_estimated_matches": parsed.web_pages.and_then(|p| p.total_estimated_matches), "results": results, })) } fn truncate(s: &str, max_chars: usize) -> String { let mut chars = s.chars(); let truncated: String = chars.by_ref().take(max_chars).collect(); if chars.next().is_some() { format!("{}...", truncated) } else { truncated } } pub fn tool_definition() -> ToolDefinition { let mut p = HashMap::new(); p.insert( "query".into(), ToolParam { name: "query".into(), param_type: "string".into(), description: Some("Web search query. Required.".into()), required: true, properties: None, items: None, }, ); p.insert( "count".into(), ToolParam { name: "count".into(), param_type: "integer".into(), description: Some("Number of results to return. Default 10, max 50.".into()), required: false, properties: None, items: None, }, ); p.insert( "offset".into(), ToolParam { name: "offset".into(), param_type: "integer".into(), description: Some("Result offset for pagination. Default 0.".into()), required: false, properties: None, items: None, }, ); p.insert( "market".into(), ToolParam { name: "market".into(), param_type: "string".into(), description: Some("Market code such as en-US or zh-CN. Default en-US.".into()), required: false, properties: None, items: None, }, ); p.insert( "safe_search".into(), ToolParam { name: "safe_search".into(), param_type: "string".into(), description: Some( "Bing safe search level: Off, Moderate, or Strict. Default Moderate.".into(), ), required: false, properties: None, items: None, }, ); p.insert( "freshness".into(), ToolParam { name: "freshness".into(), param_type: "string".into(), description: Some("Optional freshness filter: Day, Week, or Month.".into()), required: false, properties: None, items: None, }, ); ToolDefinition::new("project_bing_search") .description( "Search the public web with Bing Web Search API. Returns titles, URLs, snippets, crawl dates, and estimated match counts. Requires APP_BING_SEARCH_API_KEY or BING_SEARCH_API_KEY.", ) .parameters(ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["query".into()]), }) }