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:
ZhenYi 2026-05-18 20:43:08 +08:00
parent 8d144ac139
commit 1d48cdc973
9 changed files with 1321 additions and 2 deletions

View 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,
},
)
}

View 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,
},
)
}

View File

@ -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);

View 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,
},
)
}

View File

@ -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)
})
}),
);
}

View 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,
},
)
}

View 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()]),
})
}

View File

@ -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")
}

View File

@ -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(),