gitdataai/libs/fctool/src/project_tools/bing.rs
ZhenYi 1d48cdc973 feat(fctool): add git LFS, merge analysis, ref listing, status tools and Bing search
New git subcommands: lfs (summary/scan_tree), merge_analysis,
ref_list/ref_info, and git_status. New project tool: bing_search.
Update repo_analysis with expanded field coverage and curl tool.
2026-05-18 20:43:08 +08:00

281 lines
8.5 KiB
Rust

//! 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<reqwest::Client> = 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<BingWebPages>,
#[serde(default)]
query_context: Option<BingQueryContext>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BingWebPages {
#[serde(default)]
total_estimated_matches: Option<u64>,
#[serde(default)]
value: Vec<BingWebResult>,
}
#[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<String>,
#[serde(default)]
language: Option<String>,
#[serde(default)]
is_family_friendly: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BingQueryContext {
#[serde(default)]
original_query: String,
#[serde(default)]
altered_query: Option<String>,
}
pub async fn bing_search_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
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::<Vec<_>>()
})
.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()]),
})
}