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.
281 lines
8.5 KiB
Rust
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()]),
|
|
})
|
|
}
|