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.
This commit is contained in:
parent
8d144ac139
commit
1d48cdc973
262
libs/fctool/src/git_tools/lfs.rs
Normal file
262
libs/fctool/src/git_tools/lfs.rs
Normal file
@ -0,0 +1,262 @@
|
||||
//! Git LFS query tools.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
|
||||
use git::lfs::types::LfsOid;
|
||||
use std::collections::HashMap;
|
||||
|
||||
async fn git_lfs_summary_exec(
|
||||
ctx: GitToolCtx,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let p = parse_args(args)?;
|
||||
let project_name = required_str(&p, "project_name")?;
|
||||
let repo_name = required_str(&p, "repo_name")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let objects = domain.lfs_object_list().map_err(|e| e.to_string())?;
|
||||
let cache_size = domain.lfs_cache_size().map_err(|e| e.to_string())?;
|
||||
let attributes = domain.lfs_gitattributes_list().map_err(|e| e.to_string())?;
|
||||
let config = domain.lfs_config().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"object_count": objects.len(),
|
||||
"cache_size_bytes": cache_size,
|
||||
"objects": objects.into_iter().map(|oid| oid.to_string()).collect::<Vec<_>>(),
|
||||
"gitattributes": attributes,
|
||||
"config": {
|
||||
"endpoint": config.endpoint,
|
||||
"has_access_token": config.access_token.as_ref().is_some_and(|s| !s.is_empty()),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn git_lfs_scan_tree_exec(
|
||||
ctx: GitToolCtx,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let p = parse_args(args)?;
|
||||
let project_name = required_str(&p, "project_name")?;
|
||||
let repo_name = required_str(&p, "repo_name")?;
|
||||
let rev = p.get("rev").and_then(|v| v.as_str()).unwrap_or("HEAD");
|
||||
let recursive = p.get("recursive").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let commit_oid = resolve_rev(&domain, rev)?;
|
||||
let commit = domain.commit_get(&commit_oid).map_err(|e| e.to_string())?;
|
||||
let entries = domain
|
||||
.lfs_scan_tree(&commit.tree_id, recursive)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"rev": rev,
|
||||
"commit_oid": commit_oid.to_string(),
|
||||
"count": entries.len(),
|
||||
"entries": entries.into_iter().map(|entry| {
|
||||
serde_json::json!({
|
||||
"path": entry.path,
|
||||
"oid": entry.pointer.oid.to_string(),
|
||||
"size": entry.pointer.size,
|
||||
"cached": domain.lfs_object_cached(&entry.pointer.oid),
|
||||
"extra": entry.pointer.extra,
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn git_lfs_pointer_info_exec(
|
||||
ctx: GitToolCtx,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let p = parse_args(args)?;
|
||||
let project_name = required_str(&p, "project_name")?;
|
||||
let repo_name = required_str(&p, "repo_name")?;
|
||||
let rev = p.get("rev").and_then(|v| v.as_str()).unwrap_or("HEAD");
|
||||
let blob_oid = p.get("blob_oid").and_then(|v| v.as_str());
|
||||
let path = p.get("path").and_then(|v| v.as_str());
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let blob_oid = if let Some(blob_oid) = blob_oid {
|
||||
git::commit::types::CommitOid::new(blob_oid)
|
||||
} else if let Some(path) = path {
|
||||
let commit_oid = resolve_rev(&domain, rev)?;
|
||||
domain
|
||||
.tree_entry_by_path_from_commit(&commit_oid, path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.oid
|
||||
} else {
|
||||
return Err("either blob_oid or path is required".into());
|
||||
};
|
||||
|
||||
let pointer = domain
|
||||
.lfs_pointer_from_blob(&blob_oid)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(match pointer {
|
||||
Some(pointer) => serde_json::json!({
|
||||
"is_lfs_pointer": true,
|
||||
"blob_oid": blob_oid.to_string(),
|
||||
"pointer": {
|
||||
"version": pointer.version,
|
||||
"oid": pointer.oid.to_string(),
|
||||
"size": pointer.size,
|
||||
"cached": domain.lfs_object_cached(&pointer.oid),
|
||||
"object_path": domain.lfs_object_path(&pointer.oid).ok().map(|p| p.to_string_lossy().to_string()),
|
||||
"extra": pointer.extra,
|
||||
}
|
||||
}),
|
||||
None => serde_json::json!({
|
||||
"is_lfs_pointer": false,
|
||||
"blob_oid": blob_oid.to_string(),
|
||||
"pointer": null,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn git_lfs_object_info_exec(
|
||||
ctx: GitToolCtx,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let p = parse_args(args)?;
|
||||
let project_name = required_str(&p, "project_name")?;
|
||||
let repo_name = required_str(&p, "repo_name")?;
|
||||
let oid = LfsOid::new(required_str(&p, "oid")?);
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
if !oid.is_valid() {
|
||||
return Err(format!("invalid LFS oid: {}", oid));
|
||||
}
|
||||
let path = domain.lfs_object_path(&oid).map_err(|e| e.to_string())?;
|
||||
let cached = domain.lfs_object_cached(&oid);
|
||||
let size = if cached {
|
||||
std::fs::metadata(&path).ok().map(|m| m.len())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"oid": oid.to_string(),
|
||||
"cached": cached,
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
"size_bytes": size,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_rev(
|
||||
domain: &git::GitDomain,
|
||||
rev: &str,
|
||||
) -> Result<git::commit::types::CommitOid, String> {
|
||||
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Ok(git::commit::types::CommitOid::new(rev));
|
||||
}
|
||||
if let Ok(Some(oid)) = domain.ref_target(rev) {
|
||||
return Ok(oid);
|
||||
}
|
||||
domain
|
||||
.commit_get_prefix(rev)
|
||||
.map(|m| m.oid)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
registry.register(
|
||||
ToolDefinition::new("git_lfs_summary")
|
||||
.description("Summarize Git LFS state for a repository: local LFS objects, cache size, .gitattributes LFS patterns, and sanitized LFS config.")
|
||||
.parameters(base_schema(vec![])),
|
||||
handler(git_lfs_summary_exec),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
ToolDefinition::new("git_lfs_scan_tree")
|
||||
.description("Scan a revision tree for Git LFS pointer files. Returns path, LFS object OID, declared size, and local cache status.")
|
||||
.parameters(base_schema(vec![
|
||||
param("rev", "string", "Revision to scan. Default HEAD.", false),
|
||||
param("recursive", "boolean", "Scan recursively. Default true.", false),
|
||||
])),
|
||||
handler(git_lfs_scan_tree_exec),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
ToolDefinition::new("git_lfs_pointer_info")
|
||||
.description("Inspect whether a blob or file path is a Git LFS pointer. Provide either blob_oid or path; path is resolved at rev, default HEAD.")
|
||||
.parameters(base_schema(vec![
|
||||
param("blob_oid", "string", "Blob object ID to inspect.", false),
|
||||
param("path", "string", "File path to inspect at rev.", false),
|
||||
param("rev", "string", "Revision used when path is provided. Default HEAD.", false),
|
||||
])),
|
||||
handler(git_lfs_pointer_info_exec),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
ToolDefinition::new("git_lfs_object_info")
|
||||
.description("Inspect one local Git LFS object by SHA-256 OID and report cache path and size if present.")
|
||||
.parameters(base_schema(vec![param(
|
||||
"oid",
|
||||
"string",
|
||||
"64-character Git LFS object SHA-256 OID.",
|
||||
true,
|
||||
)])),
|
||||
handler(git_lfs_object_info_exec),
|
||||
);
|
||||
}
|
||||
|
||||
fn handler<F, Fut>(f: F) -> ToolHandler
|
||||
where
|
||||
F: Fn(GitToolCtx, serde_json::Value) -> Fut + Send + Sync + 'static,
|
||||
Fut: std::future::Future<Output = Result<serde_json::Value, String>> + Send + 'static,
|
||||
{
|
||||
ToolHandler::new(move |ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
let fut = f(gctx, args);
|
||||
Box::pin(async move { fut.await.map_err(agent::ToolError::ExecutionError) })
|
||||
})
|
||||
}
|
||||
|
||||
fn base_schema(extra: Vec<(String, ToolParam)>) -> ToolSchema {
|
||||
let mut properties = HashMap::from([
|
||||
param("project_name", "string", "Project name (slug)", true),
|
||||
param("repo_name", "string", "Repository name", true),
|
||||
]);
|
||||
let mut required = vec!["project_name".into(), "repo_name".into()];
|
||||
required.extend(
|
||||
extra
|
||||
.iter()
|
||||
.filter(|(_, param)| param.required)
|
||||
.map(|(name, _)| name.clone()),
|
||||
);
|
||||
properties.extend(extra);
|
||||
ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(properties),
|
||||
required: Some(required),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args(
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn required_str<'a>(
|
||||
p: &'a serde_json::Map<String, serde_json::Value>,
|
||||
name: &str,
|
||||
) -> Result<&'a str, String> {
|
||||
p.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| format!("missing {}", name))
|
||||
}
|
||||
|
||||
fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) {
|
||||
(
|
||||
name.into(),
|
||||
ToolParam {
|
||||
name: name.into(),
|
||||
param_type: param_type.into(),
|
||||
description: Some(description.into()),
|
||||
required,
|
||||
properties: None,
|
||||
items: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
109
libs/fctool/src/git_tools/merge.rs
Normal file
109
libs/fctool/src/git_tools/merge.rs
Normal file
@ -0,0 +1,109 @@
|
||||
//! Git merge analysis tools.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
|
||||
use std::collections::HashMap;
|
||||
|
||||
async fn git_merge_analysis_exec(
|
||||
ctx: GitToolCtx,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let p: serde_json::Map<String, serde_json::Value> =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let project_name = required_str(&p, "project_name")?;
|
||||
let repo_name = required_str(&p, "repo_name")?;
|
||||
let target = required_str(&p, "target")?;
|
||||
let base_ref = p.get("base_ref").and_then(|v| v.as_str()).unwrap_or("HEAD");
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let target_oid = resolve_rev(&domain, target)?;
|
||||
let (analysis, preference) = if base_ref == "HEAD" {
|
||||
domain
|
||||
.merge_analysis(&target_oid)
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
domain
|
||||
.merge_analysis_for_ref(base_ref, &target_oid)
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let base_oid = domain.ref_target(base_ref).ok().flatten();
|
||||
let merge_base = base_oid
|
||||
.as_ref()
|
||||
.and_then(|base| domain.merge_base(base, &target_oid).ok())
|
||||
.map(|oid| oid.to_string());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"base_ref": base_ref,
|
||||
"base_oid": base_oid.map(|oid| oid.to_string()),
|
||||
"target": target,
|
||||
"target_oid": target_oid.to_string(),
|
||||
"merge_base": merge_base,
|
||||
"analysis": analysis,
|
||||
"preference": preference,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_rev(
|
||||
domain: &git::GitDomain,
|
||||
rev: &str,
|
||||
) -> Result<git::commit::types::CommitOid, String> {
|
||||
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Ok(git::commit::types::CommitOid::new(rev));
|
||||
}
|
||||
if let Ok(Some(oid)) = domain.ref_target(rev) {
|
||||
return Ok(oid);
|
||||
}
|
||||
domain
|
||||
.commit_get_prefix(rev)
|
||||
.map(|m| m.oid)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
registry.register(
|
||||
ToolDefinition::new("git_merge_analysis")
|
||||
.description("Analyze whether a target commit/ref can be merged into a base ref. Returns up-to-date, fast-forward, normal merge, unborn, merge preference, and merge-base information. This is read-only.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param("project_name", "string", "Project name (slug)", true),
|
||||
param("repo_name", "string", "Repository name", true),
|
||||
param("target", "string", "Target revision/ref to merge, e.g. refs/heads/feature, feature, or a commit SHA.", true),
|
||||
param("base_ref", "string", "Base ref to merge into. Default HEAD.", false),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into(), "target".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
git_merge_analysis_exec(gctx, args)
|
||||
.await
|
||||
.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn required_str<'a>(
|
||||
p: &'a serde_json::Map<String, serde_json::Value>,
|
||||
name: &str,
|
||||
) -> Result<&'a str, String> {
|
||||
p.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| format!("missing {}", name))
|
||||
}
|
||||
|
||||
fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) {
|
||||
(
|
||||
name.into(),
|
||||
ToolParam {
|
||||
name: name.into(),
|
||||
param_type: param_type.into(),
|
||||
description: Some(description.into()),
|
||||
required,
|
||||
properties: None,
|
||||
items: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -9,8 +9,12 @@ pub mod commit;
|
||||
pub mod ctx;
|
||||
pub mod diff;
|
||||
pub mod kb;
|
||||
pub mod lfs;
|
||||
pub mod merge;
|
||||
pub mod reference;
|
||||
pub mod repo_analysis;
|
||||
pub mod repo_util;
|
||||
pub mod status;
|
||||
pub mod tag;
|
||||
pub mod tree;
|
||||
pub mod types;
|
||||
@ -18,11 +22,15 @@ pub mod types;
|
||||
/// Batch-register all git tools into a ToolRegistry.
|
||||
pub fn register_all(registry: &mut agent::ToolRegistry) {
|
||||
commit::register_git_tools(registry);
|
||||
status::register_git_tools(registry);
|
||||
branch::register_git_tools(registry);
|
||||
reference::register_git_tools(registry);
|
||||
merge::register_git_tools(registry);
|
||||
diff::register_git_tools(registry);
|
||||
blob::register_git_tools(registry);
|
||||
tree::register_git_tools(registry);
|
||||
tag::register_git_tools(registry);
|
||||
lfs::register_git_tools(registry);
|
||||
repo_analysis::register_git_tools(registry);
|
||||
kb::register_git_tools(registry);
|
||||
repo_util::register_git_tools(registry);
|
||||
|
||||
137
libs/fctool/src/git_tools/reference.rs
Normal file
137
libs/fctool/src/git_tools/reference.rs
Normal file
@ -0,0 +1,137 @@
|
||||
//! Git reference query tools.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
|
||||
use std::collections::HashMap;
|
||||
|
||||
async fn git_ref_list_exec(
|
||||
ctx: GitToolCtx,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let p = parse_args(args)?;
|
||||
let project_name = required_str(&p, "project_name")?;
|
||||
let repo_name = required_str(&p, "repo_name")?;
|
||||
let pattern = p.get("pattern").and_then(|v| v.as_str());
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let refs = domain.ref_list(pattern).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"count": refs.len(),
|
||||
"refs": refs.into_iter().map(ref_json).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn git_ref_info_exec(
|
||||
ctx: GitToolCtx,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let p = parse_args(args)?;
|
||||
let project_name = required_str(&p, "project_name")?;
|
||||
let repo_name = required_str(&p, "repo_name")?;
|
||||
let name = required_str(&p, "name")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let info = domain.ref_get(name).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(ref_json(info))
|
||||
}
|
||||
|
||||
fn ref_json(info: git::reference::types::RefInfo) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": info.name,
|
||||
"oid": info.oid.map(|oid| oid.to_string()),
|
||||
"target": info.target.map(|oid| oid.to_string()),
|
||||
"is_symbolic": info.is_symbolic,
|
||||
"is_branch": info.is_branch,
|
||||
"is_remote": info.is_remote,
|
||||
"is_tag": info.is_tag,
|
||||
"is_note": info.is_note,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
registry.register(
|
||||
ToolDefinition::new("git_ref_list")
|
||||
.description("List Git references such as branches, remote-tracking refs, tags, and notes. Optional pattern supports exact names plus refs/heads/* or refs/tags/** style prefix matching.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param("project_name", "string", "Project name (slug)", true),
|
||||
param("repo_name", "string", "Repository name", true),
|
||||
param("pattern", "string", "Optional ref pattern, e.g. refs/heads/*, refs/tags/**, or refs/heads/main.", false),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
git_ref_list_exec(gctx, args)
|
||||
.await
|
||||
.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
ToolDefinition::new("git_ref_info")
|
||||
.description(
|
||||
"Get one Git reference by full name, including peeled commit OID and target OID.",
|
||||
)
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param("project_name", "string", "Project name (slug)", true),
|
||||
param("repo_name", "string", "Repository name", true),
|
||||
param(
|
||||
"name",
|
||||
"string",
|
||||
"Full ref name, e.g. refs/heads/main or refs/tags/v1.0.0.",
|
||||
true,
|
||||
),
|
||||
])),
|
||||
required: Some(vec![
|
||||
"project_name".into(),
|
||||
"repo_name".into(),
|
||||
"name".into(),
|
||||
]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
git_ref_info_exec(gctx, args)
|
||||
.await
|
||||
.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_args(
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn required_str<'a>(
|
||||
p: &'a serde_json::Map<String, serde_json::Value>,
|
||||
name: &str,
|
||||
) -> Result<&'a str, String> {
|
||||
p.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| format!("missing {}", name))
|
||||
}
|
||||
|
||||
fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) {
|
||||
(
|
||||
name.into(),
|
||||
ToolParam {
|
||||
name: name.into(),
|
||||
param_type: param_type.into(),
|
||||
description: Some(description.into()),
|
||||
required,
|
||||
properties: None,
|
||||
items: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -27,6 +27,23 @@ const DEPENDENCY_MANIFESTS: &[(&str, &str)] = &[
|
||||
("Makefile", "make"),
|
||||
];
|
||||
|
||||
const TEST_FILE_MARKERS: &[&str] = &[
|
||||
"_test.", ".test.", ".spec.", "_spec.", "test.", "tests.", "test_", ".feature",
|
||||
];
|
||||
|
||||
const TEST_DIR_MARKERS: &[&str] = &[
|
||||
"test",
|
||||
"tests",
|
||||
"__tests__",
|
||||
"spec",
|
||||
"specs",
|
||||
"e2e",
|
||||
"integration",
|
||||
"unit",
|
||||
"cypress",
|
||||
"playwright",
|
||||
];
|
||||
|
||||
/// Language detection by file extension (lowercase).
|
||||
fn ext_to_language(ext: &str) -> Option<&'static str> {
|
||||
match ext {
|
||||
@ -648,6 +665,271 @@ async fn repo_dependencies_exec(
|
||||
|
||||
// ── Registration ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tool: repo_test_discovery - discover likely tests and test commands.
|
||||
async fn repo_test_discovery_exec(
|
||||
ctx: GitToolCtx,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let p: serde_json::Map<String, serde_json::Value> =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let project_name = p
|
||||
.get("project_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing project_name")?;
|
||||
let repo_name = p
|
||||
.get("repo_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing repo_name")?;
|
||||
let max_files = p.get("max_files").and_then(|v| v.as_u64()).unwrap_or(200) as usize;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let tree = head_tree(&domain)?;
|
||||
let repo = domain.repo();
|
||||
let mut test_files = Vec::new();
|
||||
let mut manifests = Vec::new();
|
||||
let mut frameworks: HashMap<String, u64> = HashMap::new();
|
||||
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
|
||||
|
||||
while let Some((current_tree, prefix)) = stack.pop() {
|
||||
for entry in current_tree.iter() {
|
||||
let name = match entry.name() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let entry_path = if prefix.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
match entry.kind() {
|
||||
Some(git2::ObjectType::Tree) => {
|
||||
if !is_ignored_dir(name) {
|
||||
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
|
||||
stack.push((subtree, entry_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(git2::ObjectType::Blob) => {
|
||||
let lower_path = entry_path.to_lowercase();
|
||||
let lower_name = name.to_lowercase();
|
||||
if is_test_file(&lower_path, &lower_name) && test_files.len() < max_files {
|
||||
test_files.push(serde_json::json!({
|
||||
"path": entry_path,
|
||||
"kind": test_kind(&lower_path),
|
||||
}));
|
||||
}
|
||||
if is_test_manifest(name) {
|
||||
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
|
||||
let content = String::from_utf8_lossy(blob.content());
|
||||
for framework in detect_test_frameworks(name, &content) {
|
||||
*frameworks.entry(framework).or_insert(0) += 1;
|
||||
}
|
||||
manifests.push(serde_json::json!({ "path": entry_path, "name": name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut framework_list: Vec<_> = frameworks
|
||||
.into_iter()
|
||||
.map(|(name, evidence_count)| {
|
||||
serde_json::json!({ "name": name, "evidence_count": evidence_count })
|
||||
})
|
||||
.collect();
|
||||
framework_list.sort_by(|a, b| {
|
||||
b["evidence_count"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.cmp(&a["evidence_count"].as_u64().unwrap_or(0))
|
||||
});
|
||||
|
||||
let commands = infer_test_commands(&framework_list, &manifests);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"test_file_count_returned": test_files.len(),
|
||||
"test_files": test_files,
|
||||
"test_manifests": manifests,
|
||||
"frameworks": framework_list,
|
||||
"suggested_commands": commands,
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_test_file(lower_path: &str, lower_name: &str) -> bool {
|
||||
TEST_FILE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| lower_name.contains(marker))
|
||||
|| lower_path
|
||||
.split('/')
|
||||
.any(|part| TEST_DIR_MARKERS.iter().any(|marker| part == *marker))
|
||||
}
|
||||
|
||||
fn test_kind(lower_path: &str) -> &'static str {
|
||||
if lower_path.contains("/e2e/")
|
||||
|| lower_path.contains("/cypress/")
|
||||
|| lower_path.contains("/playwright/")
|
||||
{
|
||||
"e2e"
|
||||
} else if lower_path.contains("/integration/") {
|
||||
"integration"
|
||||
} else if lower_path.contains("/unit/") {
|
||||
"unit"
|
||||
} else {
|
||||
"test"
|
||||
}
|
||||
}
|
||||
|
||||
fn is_test_manifest(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"Cargo.toml"
|
||||
| "package.json"
|
||||
| "go.mod"
|
||||
| "pyproject.toml"
|
||||
| "requirements.txt"
|
||||
| "pom.xml"
|
||||
| "build.gradle"
|
||||
| "build.gradle.kts"
|
||||
| "pytest.ini"
|
||||
| "tox.ini"
|
||||
| "vitest.config.ts"
|
||||
| "vitest.config.js"
|
||||
| "jest.config.js"
|
||||
| "jest.config.ts"
|
||||
| "playwright.config.ts"
|
||||
| "playwright.config.js"
|
||||
| "cypress.config.ts"
|
||||
| "cypress.config.js"
|
||||
)
|
||||
}
|
||||
|
||||
fn detect_test_frameworks(name: &str, content: &str) -> Vec<String> {
|
||||
let lower = content.to_lowercase();
|
||||
let mut found = Vec::new();
|
||||
let mut add = |framework: &str| {
|
||||
if !found.iter().any(|v| v == framework) {
|
||||
found.push(framework.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
match name {
|
||||
"Cargo.toml" => {
|
||||
if lower.contains("[dev-dependencies]")
|
||||
|| lower.contains("tokio-test")
|
||||
|| lower.contains("rstest")
|
||||
{
|
||||
add("cargo test");
|
||||
}
|
||||
}
|
||||
"package.json" => {
|
||||
if lower.contains("\"test\"") {
|
||||
add("npm test");
|
||||
}
|
||||
if lower.contains("vitest") {
|
||||
add("vitest");
|
||||
}
|
||||
if lower.contains("jest") {
|
||||
add("jest");
|
||||
}
|
||||
if lower.contains("playwright") {
|
||||
add("playwright");
|
||||
}
|
||||
if lower.contains("cypress") {
|
||||
add("cypress");
|
||||
}
|
||||
}
|
||||
"go.mod" => add("go test"),
|
||||
"pyproject.toml" | "requirements.txt" | "pytest.ini" | "tox.ini" => {
|
||||
if lower.contains("pytest") {
|
||||
add("pytest");
|
||||
}
|
||||
if lower.contains("tox") {
|
||||
add("tox");
|
||||
}
|
||||
}
|
||||
"pom.xml" => add("maven test"),
|
||||
"build.gradle" | "build.gradle.kts" => add("gradle test"),
|
||||
_ => {
|
||||
if name.starts_with("vitest.config") {
|
||||
add("vitest");
|
||||
} else if name.starts_with("jest.config") {
|
||||
add("jest");
|
||||
} else if name.starts_with("playwright.config") {
|
||||
add("playwright");
|
||||
} else if name.starts_with("cypress.config") {
|
||||
add("cypress");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found
|
||||
}
|
||||
|
||||
fn infer_test_commands(
|
||||
frameworks: &[serde_json::Value],
|
||||
manifests: &[serde_json::Value],
|
||||
) -> Vec<serde_json::Value> {
|
||||
let names: Vec<String> = frameworks
|
||||
.iter()
|
||||
.filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string))
|
||||
.collect();
|
||||
let manifest_names: Vec<String> = manifests
|
||||
.iter()
|
||||
.filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string))
|
||||
.collect();
|
||||
let mut commands = Vec::new();
|
||||
let mut add = |command: &str, reason: &str| {
|
||||
if !commands
|
||||
.iter()
|
||||
.any(|v: &serde_json::Value| v.get("command").and_then(|c| c.as_str()) == Some(command))
|
||||
{
|
||||
commands.push(serde_json::json!({ "command": command, "reason": reason }));
|
||||
}
|
||||
};
|
||||
|
||||
if names.iter().any(|n| n == "cargo test") || manifest_names.iter().any(|n| n == "Cargo.toml") {
|
||||
add("cargo test", "Rust Cargo manifest detected");
|
||||
}
|
||||
if names.iter().any(|n| n == "npm test") || manifest_names.iter().any(|n| n == "package.json") {
|
||||
add("npm test", "Node package.json detected");
|
||||
}
|
||||
if names.iter().any(|n| n == "vitest") {
|
||||
add("npx vitest run", "Vitest detected");
|
||||
}
|
||||
if names.iter().any(|n| n == "jest") {
|
||||
add("npx jest", "Jest detected");
|
||||
}
|
||||
if names.iter().any(|n| n == "playwright") {
|
||||
add("npx playwright test", "Playwright detected");
|
||||
}
|
||||
if names.iter().any(|n| n == "cypress") {
|
||||
add("npx cypress run", "Cypress detected");
|
||||
}
|
||||
if names.iter().any(|n| n == "go test") || manifest_names.iter().any(|n| n == "go.mod") {
|
||||
add("go test ./...", "Go module detected");
|
||||
}
|
||||
if names.iter().any(|n| n == "pytest") {
|
||||
add("pytest", "Pytest detected");
|
||||
}
|
||||
if names.iter().any(|n| n == "tox") {
|
||||
add("tox", "Tox detected");
|
||||
}
|
||||
if names.iter().any(|n| n == "maven test") || manifest_names.iter().any(|n| n == "pom.xml") {
|
||||
add("mvn test", "Maven project detected");
|
||||
}
|
||||
if names.iter().any(|n| n == "gradle test")
|
||||
|| manifest_names
|
||||
.iter()
|
||||
.any(|n| n == "build.gradle" || n == "build.gradle.kts")
|
||||
{
|
||||
add("./gradlew test", "Gradle project detected");
|
||||
}
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
macro_rules! param {
|
||||
($name:expr, $type:expr, $desc:expr, $required:expr) => {
|
||||
(
|
||||
@ -746,4 +1028,27 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// repo_test_discovery
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_test_discovery")
|
||||
.description("Discover likely test files, test frameworks, and suggested test commands from repository manifests and file layout. Useful before changing code or planning validation.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
param!("max_files", "integer", "Maximum test file entries to return (default: 200)", false),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_test_discovery_exec(gctx, args)
|
||||
.await
|
||||
.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
199
libs/fctool/src/git_tools/status.rs
Normal file
199
libs/fctool/src/git_tools/status.rs
Normal file
@ -0,0 +1,199 @@
|
||||
//! Git status tools.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
|
||||
use std::collections::HashMap;
|
||||
|
||||
async fn git_status_exec(
|
||||
ctx: GitToolCtx,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let p: serde_json::Map<String, serde_json::Value> =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let project_name = p
|
||||
.get("project_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing project_name")?;
|
||||
let repo_name = p
|
||||
.get("repo_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing repo_name")?;
|
||||
let include_ignored = p
|
||||
.get("include_ignored")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let repo = domain.repo();
|
||||
let is_bare = repo.is_bare();
|
||||
let head = repo
|
||||
.head()
|
||||
.ok()
|
||||
.and_then(|h| h.shorthand().map(str::to_string));
|
||||
|
||||
if is_bare {
|
||||
return Ok(serde_json::json!({
|
||||
"is_bare": true,
|
||||
"head": head,
|
||||
"is_dirty": false,
|
||||
"files": [],
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"index": 0,
|
||||
"worktree": 0,
|
||||
"untracked": 0,
|
||||
"ignored": 0,
|
||||
"conflicted": 0,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let mut opts = git2::StatusOptions::new();
|
||||
opts.include_untracked(true)
|
||||
.renames_head_to_index(true)
|
||||
.renames_index_to_workdir(true)
|
||||
.recurse_untracked_dirs(true);
|
||||
if include_ignored {
|
||||
opts.include_ignored(true);
|
||||
}
|
||||
|
||||
let statuses = repo
|
||||
.statuses(Some(&mut opts))
|
||||
.map_err(|e| format!("git status failed: {}", e))?;
|
||||
|
||||
let mut files = Vec::new();
|
||||
let mut index = 0usize;
|
||||
let mut worktree = 0usize;
|
||||
let mut untracked = 0usize;
|
||||
let mut ignored = 0usize;
|
||||
let mut conflicted = 0usize;
|
||||
|
||||
for entry in statuses.iter() {
|
||||
let status = entry.status();
|
||||
let path = entry
|
||||
.head_to_index()
|
||||
.and_then(|d| d.new_file().path())
|
||||
.or_else(|| entry.index_to_workdir().and_then(|d| d.new_file().path()))
|
||||
.or_else(|| entry.path().map(std::path::Path::new))
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let index_status = index_status_label(status);
|
||||
let worktree_status = worktree_status_label(status);
|
||||
let is_untracked = status.contains(git2::Status::WT_NEW);
|
||||
let is_ignored = status.contains(git2::Status::IGNORED);
|
||||
let is_conflicted = status.is_conflicted();
|
||||
|
||||
if index_status.is_some() {
|
||||
index += 1;
|
||||
}
|
||||
if worktree_status.is_some() {
|
||||
worktree += 1;
|
||||
}
|
||||
if is_untracked {
|
||||
untracked += 1;
|
||||
}
|
||||
if is_ignored {
|
||||
ignored += 1;
|
||||
}
|
||||
if is_conflicted {
|
||||
conflicted += 1;
|
||||
}
|
||||
|
||||
files.push(serde_json::json!({
|
||||
"path": path,
|
||||
"index_status": index_status,
|
||||
"worktree_status": worktree_status,
|
||||
"is_untracked": is_untracked,
|
||||
"is_ignored": is_ignored,
|
||||
"is_conflicted": is_conflicted,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"is_bare": false,
|
||||
"head": head,
|
||||
"is_dirty": !files.is_empty(),
|
||||
"summary": {
|
||||
"total": files.len(),
|
||||
"index": index,
|
||||
"worktree": worktree,
|
||||
"untracked": untracked,
|
||||
"ignored": ignored,
|
||||
"conflicted": conflicted,
|
||||
},
|
||||
"files": files,
|
||||
}))
|
||||
}
|
||||
|
||||
fn index_status_label(status: git2::Status) -> Option<&'static str> {
|
||||
if status.contains(git2::Status::INDEX_NEW) {
|
||||
Some("added")
|
||||
} else if status.contains(git2::Status::INDEX_MODIFIED) {
|
||||
Some("modified")
|
||||
} else if status.contains(git2::Status::INDEX_DELETED) {
|
||||
Some("deleted")
|
||||
} else if status.contains(git2::Status::INDEX_RENAMED) {
|
||||
Some("renamed")
|
||||
} else if status.contains(git2::Status::INDEX_TYPECHANGE) {
|
||||
Some("typechange")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn worktree_status_label(status: git2::Status) -> Option<&'static str> {
|
||||
if status.contains(git2::Status::WT_NEW) {
|
||||
Some("untracked")
|
||||
} else if status.contains(git2::Status::WT_MODIFIED) {
|
||||
Some("modified")
|
||||
} else if status.contains(git2::Status::WT_DELETED) {
|
||||
Some("deleted")
|
||||
} else if status.contains(git2::Status::WT_RENAMED) {
|
||||
Some("renamed")
|
||||
} else if status.contains(git2::Status::WT_TYPECHANGE) {
|
||||
Some("typechange")
|
||||
} else if status.contains(git2::Status::IGNORED) {
|
||||
Some("ignored")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
registry.register(
|
||||
ToolDefinition::new("git_status")
|
||||
.description("Show repository working tree status: staged, unstaged, untracked, ignored, and conflicted files. Bare repositories return an empty clean status.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param("project_name", "string", "Project name (slug)", true),
|
||||
param("repo_name", "string", "Repository name", true),
|
||||
param("include_ignored", "boolean", "Include ignored files. Default false.", false),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
git_status_exec(gctx, args)
|
||||
.await
|
||||
.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) {
|
||||
(
|
||||
name.into(),
|
||||
ToolParam {
|
||||
name: name.into(),
|
||||
param_type: param_type.into(),
|
||||
description: Some(description.into()),
|
||||
required,
|
||||
properties: None,
|
||||
items: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
280
libs/fctool/src/project_tools/bing.rs
Normal file
280
libs/fctool/src/project_tools/bing.rs
Normal file
@ -0,0 +1,280 @@
|
||||
//! 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()]),
|
||||
})
|
||||
}
|
||||
@ -272,7 +272,7 @@ pub async fn curl_exec(
|
||||
|
||||
// ─── tool definition ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn tool_definition() -> ToolDefinition {
|
||||
fn tool_definition_with_name(name: &str) -> ToolDefinition {
|
||||
let mut p = HashMap::new();
|
||||
p.insert(
|
||||
"url".into(),
|
||||
@ -332,7 +332,7 @@ pub fn tool_definition() -> ToolDefinition {
|
||||
items: None,
|
||||
},
|
||||
);
|
||||
ToolDefinition::new("project_curl")
|
||||
ToolDefinition::new(name)
|
||||
.description(
|
||||
"Perform an HTTP request to any URL. Supports GET, POST, PUT, DELETE, PATCH, HEAD. \
|
||||
Returns status code, headers, and response body. \
|
||||
@ -345,3 +345,11 @@ pub fn tool_definition() -> ToolDefinition {
|
||||
required: Some(vec!["url".into()]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_definition() -> ToolDefinition {
|
||||
tool_definition_with_name("project_curl")
|
||||
}
|
||||
|
||||
pub fn alias_tool_definition() -> ToolDefinition {
|
||||
tool_definition_with_name("curl_exec")
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
//! - list / create / update boards and board cards
|
||||
|
||||
mod arxiv;
|
||||
mod bing;
|
||||
mod boards;
|
||||
mod curl;
|
||||
mod issues;
|
||||
@ -16,6 +17,7 @@ mod repos;
|
||||
use agent::{ToolHandler, ToolRegistry};
|
||||
|
||||
pub use arxiv::arxiv_search_exec;
|
||||
pub use bing::bing_search_exec;
|
||||
pub use boards::{
|
||||
create_board_card_exec, create_board_column_exec, create_board_exec, delete_board_card_exec,
|
||||
list_boards_exec, update_board_card_exec, update_board_exec,
|
||||
@ -34,10 +36,19 @@ pub fn register_all(registry: &mut ToolRegistry) {
|
||||
ToolHandler::new(|ctx, args| Box::pin(arxiv_search_exec(ctx, args))),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
bing::tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(bing_search_exec(ctx, args))),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
curl::tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(curl_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
curl::alias_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(curl_exec(ctx, args))),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
repos::list_tool_definition(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user