- agent/client: full jitter backoff (random(0, base_ms)) instead of equal jitter - agent/tool/executor: fix buffer_unordered ordering mismatch with HashMap-by-index approach for concurrent tool execution - agent/chat: AiChunkType emit fixes, is_retryable_tool_error refinements, process_react uses request.max_tool_depth - agent/chat/context: fix Function message sender_name field - file_tools/curl: shared reqwest::Client via OnceLock, manual redirect following with per-hop SSRF validation, blocked sensitive headers - file_tools/grep: fix case-insensitive glob matching, segment consumption - file_tools/json: bracket notation support, remove .vscodeignore from JSONC - git_tools: git_diff_stats resolve base/head independently, DiffFileOut old_file.path for Deleted, reflog offset_minutes - git/repo: create_commit read parent tree into index, bare repo init - project_tools/repos: branch/path validation, .git/ prefix check - service/agent: tokent integration, billing, pr_summary, code_review fixes
219 lines
13 KiB
Rust
219 lines
13 KiB
Rust
//! Git tree and file tools.
|
|
|
|
use super::ctx::GitToolCtx;
|
|
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
|
|
use base64::Engine;
|
|
use std::collections::HashMap;
|
|
|
|
async fn git_file_content_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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
|
|
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string());
|
|
|
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
|
let oid = if rev.len() >= 40 {
|
|
git::commit::types::CommitOid::new(&rev)
|
|
} else {
|
|
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
|
|
};
|
|
|
|
let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?;
|
|
let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?;
|
|
|
|
let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?;
|
|
let (display_content, is_binary) = if blob_info.is_binary {
|
|
(base64::engine::general_purpose::STANDARD.encode(&content.content), true)
|
|
} else {
|
|
(String::from_utf8_lossy(&content.content).to_string(), false)
|
|
};
|
|
|
|
Ok(serde_json::json!({
|
|
"path": path,
|
|
"oid": entry.oid.to_string(),
|
|
"size": blob_info.size,
|
|
"content": display_content,
|
|
"is_binary": is_binary
|
|
}))
|
|
}
|
|
|
|
async fn git_tree_ls_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 dir_path = p.get("path").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string());
|
|
|
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
|
let commit_oid = if rev.len() >= 40 {
|
|
git::commit::types::CommitOid::new(&rev)
|
|
} else {
|
|
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
|
|
};
|
|
|
|
let entries = match dir_path {
|
|
Some(ref dp) => {
|
|
let entry = domain.tree_entry_by_path(&commit_oid, dp).map_err(|e| e.to_string())?;
|
|
domain.tree_list(&entry.oid).map_err(|e| e.to_string())?
|
|
}
|
|
None => domain.tree_list(&commit_oid).map_err(|e| e.to_string())?,
|
|
};
|
|
|
|
let result: Vec<_> = entries.iter().map(|e| {
|
|
serde_json::json!({
|
|
"name": e.name,
|
|
"oid": e.oid.to_string(),
|
|
"kind": e.kind,
|
|
"is_binary": e.is_binary
|
|
})
|
|
}).collect();
|
|
|
|
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
|
}
|
|
|
|
async fn git_file_history_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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
|
|
let rev = p.get("rev").and_then(|v| v.as_str()).map(String::from).unwrap_or_else(|| "HEAD".to_string());
|
|
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
|
|
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
|
// Fetch extra commits to have enough candidates after filtering
|
|
let walk_limit = limit.saturating_mul(2).max(200);
|
|
let commits = domain.commit_log(Some(&rev), 0, walk_limit).map_err(|e| e.to_string())?;
|
|
|
|
let result: Vec<_> = commits.iter()
|
|
.filter(|c| domain.tree_entry_by_path(&c.tree_id, path).is_ok())
|
|
.take(limit)
|
|
.map(|c| flatten_commit(c))
|
|
.collect();
|
|
|
|
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
|
}
|
|
|
|
async fn git_blob_get_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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
|
|
let rev = p.get("rev").and_then(|v| v.as_str()).map(String::from).unwrap_or_else(|| "HEAD".to_string());
|
|
|
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
|
let oid = if rev.len() >= 40 {
|
|
git::commit::types::CommitOid::new(&rev)
|
|
} else {
|
|
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
|
|
};
|
|
|
|
let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?;
|
|
let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?;
|
|
|
|
if blob_info.is_binary {
|
|
return Err(format!("file '{}' is binary, cannot return as text", path));
|
|
}
|
|
|
|
let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?;
|
|
let text = String::from_utf8_lossy(&content.content).to_string();
|
|
|
|
Ok(serde_json::json!({
|
|
"path": path,
|
|
"oid": entry.oid.to_string(),
|
|
"size": blob_info.size,
|
|
"content": text,
|
|
}))
|
|
}
|
|
|
|
fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value {
|
|
use chrono::TimeZone;
|
|
let ts = c.author.time_secs - (c.author.offset_minutes as i64 * 60);
|
|
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
|
|
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs));
|
|
let oid = c.oid.to_string();
|
|
serde_json::json!({
|
|
"oid": oid.clone(),
|
|
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
|
|
"message": c.message, "summary": c.summary,
|
|
"author_name": c.author.name, "author_email": c.author.email, "author_time": author_time,
|
|
"committer_name": c.committer.name, "committer_email": c.committer.email,
|
|
"parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
|
|
"tree_oid": c.tree_id.to_string()
|
|
})
|
|
}
|
|
|
|
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
|
// git_file_content
|
|
let p = HashMap::from([
|
|
("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }),
|
|
("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }),
|
|
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path within the repository".into()), required: true, properties: None, items: None }),
|
|
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to read file from (default: HEAD)".into()), required: false, properties: None, items: None }),
|
|
]);
|
|
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) };
|
|
registry.register(
|
|
ToolDefinition::new("git_file_content").description("Read the full content of a file at a given revision. Handles both text and binary files.").parameters(schema),
|
|
ToolHandler::new(|ctx, args| {
|
|
let gctx = super::ctx::GitToolCtx::new(ctx);
|
|
Box::pin(async move {
|
|
git_file_content_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
|
})
|
|
}),
|
|
);
|
|
|
|
// git_tree_ls
|
|
let p = HashMap::from([
|
|
("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }),
|
|
("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }),
|
|
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("Directory path to list (root if omitted)".into()), required: false, properties: None, items: None }),
|
|
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to list tree from (default: HEAD)".into()), required: false, properties: None, items: None }),
|
|
]);
|
|
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into()]) };
|
|
registry.register(
|
|
ToolDefinition::new("git_tree_ls").description("List the contents of a directory (tree) at a given revision, showing files and subdirectories.").parameters(schema),
|
|
ToolHandler::new(|ctx, args| {
|
|
let gctx = super::ctx::GitToolCtx::new(ctx);
|
|
Box::pin(async move {
|
|
git_tree_ls_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
|
})
|
|
}),
|
|
);
|
|
|
|
// git_file_history
|
|
let p = HashMap::from([
|
|
("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }),
|
|
("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }),
|
|
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to trace history for".into()), required: true, properties: None, items: None }),
|
|
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to start history from (default: HEAD)".into()), required: false, properties: None, items: None }),
|
|
("limit".into(), ToolParam { name: "limit".into(), param_type: "integer".into(), description: Some("Maximum number of commits to return (default: 20)".into()), required: false, properties: None, items: None }),
|
|
]);
|
|
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) };
|
|
registry.register(
|
|
ToolDefinition::new("git_file_history").description("Show the commit history for a specific file, listing all commits that modified it.").parameters(schema),
|
|
ToolHandler::new(|ctx, args| {
|
|
let gctx = super::ctx::GitToolCtx::new(ctx);
|
|
Box::pin(async move {
|
|
git_file_history_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
|
})
|
|
}),
|
|
);
|
|
|
|
// git_blob_get
|
|
let p = HashMap::from([
|
|
("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }),
|
|
("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }),
|
|
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path within the repository".into()), required: true, properties: None, items: None }),
|
|
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to read file from (default: HEAD)".into()), required: false, properties: None, items: None }),
|
|
]);
|
|
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) };
|
|
registry.register(
|
|
ToolDefinition::new("git_blob_get").description("Retrieve the raw content of a single file (blob) at a given revision. Returns error if the file is binary.").parameters(schema),
|
|
ToolHandler::new(|ctx, args| {
|
|
let gctx = super::ctx::GitToolCtx::new(ctx);
|
|
Box::pin(async move {
|
|
git_blob_get_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
|
})
|
|
}),
|
|
);
|
|
} |