feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,107 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// Structured request passed to the ask callback.
#[derive(Debug, Clone)]
pub struct AskUserRequest {
pub question: String,
pub options: Option<Vec<String>>,
}
/// Callback function for asking the user a question.
pub type AskUserFn = Arc<
dyn Fn(AskUserRequest) -> futures::future::BoxFuture<'static, Result<String, String>>
+ Send
+ Sync,
>;
pub struct AskUserTool {
ask_fn: Option<AskUserFn>,
}
impl Default for AskUserTool {
fn default() -> Self {
Self { ask_fn: None }
}
}
impl AskUserTool {
pub fn new(ask_fn: AskUserFn) -> Self {
Self {
ask_fn: Some(ask_fn),
}
}
}
#[async_trait]
impl Tool for AskUserTool {
fn name(&self) -> &str {
"AskUserQuestion"
}
fn description(&self) -> &str {
"Ask the user a question and wait for their response. Use when you need clarification or input. Optionally provide a list of options for the user to choose from."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"question".to_string(),
json!({
"type": "string",
"description": "The question to ask the user"
}),
),
(
"options".to_string(),
json!({
"type": "array",
"items": { "type": "string" },
"description": "Optional list of choices for the user to select from (multi-select). The user can also provide additional free-form input."
}),
),
]),
required: vec!["question".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let question = input
.get("question")
.and_then(|q| q.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'question' field".to_string()))?;
let options: Option<Vec<String>> = input.get("options").and_then(|o| {
o.as_array().map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
});
let request = AskUserRequest {
question: question.to_string(),
options,
};
match &self.ask_fn {
Some(ask_fn) => {
let answer = (ask_fn)(request)
.await
.map_err(|e| ToolError::ExecutionError(e))?;
Ok(ToolResult::text(answer))
}
None => Ok(ToolResult::error(
"User interaction is not configured. Provide an ask_fn when creating the agent."
.to_string(),
)),
}
}
}
@@ -0,0 +1,368 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MAX_TIMEOUT_MS: u64 = 600_000;
const MAX_OUTPUT_SIZE: usize = 100_000;
/// Destructive command patterns that should be flagged.
const DESTRUCTIVE_PATTERNS: &[&str] = &[
"rm -rf /",
"rm -rf ~",
"rm -rf .",
"git push --force",
"git push -f",
"git reset --hard",
"chmod 777",
"chmod -R 777",
"> /dev/sda",
"mkfs.",
"dd if=",
":(){ :|:& };:",
];
pub struct BashTool;
#[async_trait]
impl Tool for BashTool {
fn name(&self) -> &str {
"Bash"
}
fn description(&self) -> &str {
"Executes a given bash command and returns its output. Use for system commands and terminal operations that require shell execution."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"command".to_string(),
json!({
"type": "string",
"description": "The command to execute"
}),
),
(
"timeout".to_string(),
json!({
"type": "number",
"description": "Optional timeout in milliseconds (max 600000)"
}),
),
(
"description".to_string(),
json!({
"type": "string",
"description": "Clear description of what this command does"
}),
),
]),
required: vec!["command".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, input: &Value) -> bool {
let command = input.get("command").and_then(|c| c.as_str()).unwrap_or("");
// Check if command starts with a read-only command
let cmd_trimmed = command.trim();
let first_cmd = cmd_trimmed.split_whitespace().next().unwrap_or("");
let single_word_reads = [
"ls", "cat", "head", "tail", "find", "grep", "rg", "wc", "pwd", "echo", "which",
"type", "file", "stat", "du", "df",
];
let prefix_reads = [
"git status",
"git log",
"git diff",
"git show",
"git branch",
"cargo check",
"cargo test --no-run",
"rustc --version",
];
single_word_reads.contains(&first_cmd)
|| prefix_reads.iter().any(|p| cmd_trimmed.starts_with(p))
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let command = input
.get("command")
.and_then(|c| c.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'command' field".to_string()))?;
// Security check
if let Some(warning) = check_destructive(command) {
return Ok(ToolResult::error(format!(
"Potentially destructive command detected: {}. Proceed with caution.",
warning
)));
}
let timeout_ms = input
.get("timeout")
.and_then(|t| t.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_MS)
.min(MAX_TIMEOUT_MS);
let output = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
run_command(command, &context.working_dir),
)
.await;
match output {
Ok(Ok((stdout, stderr, exit_code))) => {
let mut result = String::new();
if !stdout.is_empty() {
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str("STDERR:\n");
result.push_str(&stderr);
}
if result.len() > MAX_OUTPUT_SIZE {
result.truncate(MAX_OUTPUT_SIZE);
result.push_str("\n... (output truncated)");
}
if exit_code != 0 {
result.push_str(&format!("\n\nExit code: {}", exit_code));
}
if result.is_empty() {
result = "(no output)".to_string();
}
Ok(if exit_code != 0 {
ToolResult::error(result)
} else {
ToolResult::text(result)
})
}
Ok(Err(e)) => Ok(ToolResult::error(format!("Command failed: {}", e))),
Err(_) => Ok(ToolResult::error(format!(
"Command timed out after {}ms",
timeout_ms
))),
}
}
}
async fn run_command(
command: &str,
working_dir: &str,
) -> Result<(String, String, i32), std::io::Error> {
let shell_path = crate::mcp::shell_path::get_shell_path();
let shell = build_shell_runner(command);
let mut cmd = Command::new(&shell.program);
cmd.args(&shell.args)
.current_dir(working_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if !shell_path.is_empty() {
cmd.env("PATH", shell_path);
}
let output = cmd.output().await?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
Ok((stdout, stderr, exit_code))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ShellRunner {
program: String,
args: Vec<String>,
}
fn build_shell_runner(command: &str) -> ShellRunner {
select_shell_runner(
command,
cfg!(windows),
std::env::var("ComSpec").ok(),
program_exists,
)
}
fn select_shell_runner<F>(
command: &str,
is_windows: bool,
comspec: Option<String>,
mut has_program: F,
) -> ShellRunner
where
F: FnMut(&str) -> bool,
{
if is_windows {
for candidate in ["bash.exe", "bash"] {
if has_program(candidate) {
return ShellRunner {
program: candidate.to_string(),
args: vec!["-c".to_string(), command.to_string()],
};
}
}
for candidate in ["pwsh.exe", "pwsh", "powershell.exe", "powershell"] {
if has_program(candidate) {
return ShellRunner {
program: candidate.to_string(),
args: vec![
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
command.to_string(),
],
};
}
}
return ShellRunner {
program: comspec
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "cmd.exe".to_string()),
args: vec![
"/d".to_string(),
"/s".to_string(),
"/c".to_string(),
command.to_string(),
],
};
}
for candidate in ["/bin/bash", "bash", "/bin/sh", "sh"] {
if has_program(candidate) {
return ShellRunner {
program: candidate.to_string(),
args: vec!["-c".to_string(), command.to_string()],
};
}
}
ShellRunner {
program: "sh".to_string(),
args: vec!["-c".to_string(), command.to_string()],
}
}
fn program_exists(candidate: &str) -> bool {
if candidate.contains(std::path::MAIN_SEPARATOR)
|| candidate.contains('/')
|| candidate.contains('\\')
{
return Path::new(candidate).is_file();
}
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths).any(|dir| {
let full_path = dir.join(candidate);
if full_path.is_file() {
return true;
}
#[cfg(windows)]
{
let exe_path = dir.join(format!("{candidate}.exe"));
return exe_path.is_file();
}
#[cfg(not(windows))]
{
false
}
})
})
.unwrap_or(false)
}
fn check_destructive(command: &str) -> Option<&'static str> {
for pattern in DESTRUCTIVE_PATTERNS {
if command.contains(pattern) {
return Some(pattern);
}
}
None
}
#[cfg(test)]
mod tests {
use super::{select_shell_runner, ShellRunner};
#[test]
fn select_shell_runner_falls_back_to_powershell_on_windows() {
let runner = select_shell_runner("node -v", true, None, |candidate| {
matches!(candidate, "pwsh.exe")
});
assert_eq!(
runner,
ShellRunner {
program: "pwsh.exe".to_string(),
args: vec![
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
"node -v".to_string(),
],
}
);
}
#[test]
fn select_shell_runner_falls_back_to_cmd_on_windows() {
let runner = select_shell_runner(
"node -v",
true,
Some("C:\\Windows\\System32\\cmd.exe".to_string()),
|_| false,
);
assert_eq!(
runner,
ShellRunner {
program: "C:\\Windows\\System32\\cmd.exe".to_string(),
args: vec![
"/d".to_string(),
"/s".to_string(),
"/c".to_string(),
"node -v".to_string(),
],
}
);
}
#[test]
fn select_shell_runner_prefers_bash_on_unix() {
let runner =
select_shell_runner("node -v", false, None, |candidate| candidate == "/bin/bash");
assert_eq!(
runner,
ShellRunner {
program: "/bin/bash".to_string(),
args: vec!["-c".to_string(), "node -v".to_string()],
}
);
}
}
@@ -0,0 +1,136 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// In-memory config store for session configuration.
#[derive(Clone)]
pub struct ConfigStore {
data: Arc<RwLock<HashMap<String, Value>>>,
}
impl ConfigStore {
pub fn new() -> Self {
Self {
data: Arc::new(RwLock::new(HashMap::new())),
}
}
}
/// ConfigTool for get/set/list session settings.
pub struct ConfigTool {
store: ConfigStore,
}
impl ConfigTool {
pub fn new(store: ConfigStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for ConfigTool {
fn name(&self) -> &str {
"Config"
}
fn description(&self) -> &str {
"Get or set configuration values. Supports session-scoped settings."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"action".to_string(),
json!({
"type": "string",
"enum": ["get", "set", "list"],
"description": "Operation to perform"
}),
),
(
"key".to_string(),
json!({ "type": "string", "description": "Config key" }),
),
(
"value".to_string(),
json!({ "description": "Config value (for set)" }),
),
]),
required: vec!["action".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, input: &Value) -> bool {
input
.get("action")
.and_then(|a| a.as_str())
.map(|a| a == "get" || a == "list")
.unwrap_or(false)
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let action = input
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'action'".to_string()))?;
match action {
"get" => {
let key = input
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("key required for get".to_string()))?;
let data = self.store.data.read().await;
match data.get(key) {
Some(value) => Ok(ToolResult::text(serde_json::to_string(value).unwrap_or_default())),
None => Ok(ToolResult::text(format!("Config key \"{}\" not found", key))),
}
}
"set" => {
let key = input
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("key required for set".to_string()))?;
let value = input.get("value").cloned().unwrap_or(Value::Null);
let mut data = self.store.data.write().await;
data.insert(key.to_string(), value.clone());
Ok(ToolResult::text(format!(
"Config set: {} = {}",
key,
serde_json::to_string(&value).unwrap_or_default()
)))
}
"list" => {
let data = self.store.data.read().await;
if data.is_empty() {
return Ok(ToolResult::text("No config values set."));
}
let mut entries: Vec<(&String, &Value)> = data.iter().collect();
entries.sort_by_key(|(k, _)| (*k).clone());
let lines: Vec<String> = entries
.iter()
.map(|(k, v)| {
format!("{} = {}", k, serde_json::to_string(v).unwrap_or_default())
})
.collect();
Ok(ToolResult::text(lines.join("\n")))
}
_ => Ok(ToolResult::error(format!("Unknown action: {}", action))),
}
}
}
@@ -0,0 +1,247 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// A cron job definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronJob {
pub id: String,
pub name: String,
pub schedule: String,
pub command: String,
pub enabled: bool,
pub created_at: String,
#[serde(default)]
pub last_run_at: Option<String>,
#[serde(default)]
pub next_run_at: Option<String>,
}
/// In-memory cron job store shared across cron tools.
#[derive(Clone)]
pub struct CronStore {
jobs: Arc<RwLock<HashMap<String, CronJob>>>,
counter: Arc<RwLock<u64>>,
}
impl CronStore {
pub fn new() -> Self {
Self {
jobs: Arc::new(RwLock::new(HashMap::new())),
counter: Arc::new(RwLock::new(0)),
}
}
async fn next_id(&self) -> String {
let mut counter = self.counter.write().await;
*counter += 1;
format!("cron_{}", counter)
}
}
// --- CronCreateTool ---
pub struct CronCreateTool {
store: CronStore,
}
impl CronCreateTool {
pub fn new(store: CronStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for CronCreateTool {
fn name(&self) -> &str {
"CronCreate"
}
fn description(&self) -> &str {
"Create a scheduled recurring task (cron job). Supports cron expressions for scheduling."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"name".to_string(),
json!({ "type": "string", "description": "Job name" }),
),
(
"schedule".to_string(),
json!({ "type": "string", "description": "Cron expression (e.g., \"*/5 * * * *\" for every 5 minutes)" }),
),
(
"command".to_string(),
json!({ "type": "string", "description": "Command or prompt to execute" }),
),
]),
required: vec![
"name".to_string(),
"schedule".to_string(),
"command".to_string(),
],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let name = input
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'name'".to_string()))?;
let schedule = input
.get("schedule")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'schedule'".to_string()))?;
let command = input
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'command'".to_string()))?;
let id = self.store.next_id().await;
let now = chrono::Utc::now().to_rfc3339();
let job = CronJob {
id: id.clone(),
name: name.to_string(),
schedule: schedule.to_string(),
command: command.to_string(),
enabled: true,
created_at: now,
last_run_at: None,
next_run_at: None,
};
let mut jobs = self.store.jobs.write().await;
jobs.insert(id.clone(), job.clone());
Ok(ToolResult::text(format!(
"Cron job created: {} \"{}\" schedule=\"{}\"",
id, job.name, job.schedule
)))
}
}
// --- CronDeleteTool ---
pub struct CronDeleteTool {
store: CronStore,
}
impl CronDeleteTool {
pub fn new(store: CronStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for CronDeleteTool {
fn name(&self) -> &str {
"CronDelete"
}
fn description(&self) -> &str {
"Delete a scheduled cron job."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([(
"id".to_string(),
json!({ "type": "string", "description": "Cron job ID to delete" }),
)]),
required: vec!["id".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let id = input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'id'".to_string()))?;
let mut jobs = self.store.jobs.write().await;
if jobs.remove(id).is_some() {
Ok(ToolResult::text(format!("Cron job deleted: {}", id)))
} else {
Ok(ToolResult::error(format!("Cron job not found: {}", id)))
}
}
}
// --- CronListTool ---
pub struct CronListTool {
store: CronStore,
}
impl CronListTool {
pub fn new(store: CronStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for CronListTool {
fn name(&self) -> &str {
"CronList"
}
fn description(&self) -> &str {
"List all scheduled cron jobs."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema::default()
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
async fn call(
&self,
_input: Value,
_context: &ToolUseContext,
) -> Result<ToolResult, ToolError> {
let jobs = self.store.jobs.read().await;
if jobs.is_empty() {
return Ok(ToolResult::text("No cron jobs scheduled."));
}
let mut job_list: Vec<&CronJob> = jobs.values().collect();
job_list.sort_by(|a, b| a.created_at.cmp(&b.created_at));
let lines: Vec<String> = job_list
.iter()
.map(|j| {
let status = if j.enabled { "enabled" } else { "disabled" };
format!(
"[{}] {} \"{}\" schedule=\"{}\" command=\"{}\"",
j.id,
status,
j.name,
j.schedule,
if j.command.len() > 50 {
format!("{}...", &j.command[..50])
} else {
j.command.clone()
}
)
})
.collect();
Ok(ToolResult::text(lines.join("\n")))
}
}
@@ -0,0 +1,277 @@
/// Generate a unified diff between two strings.
pub fn unified_diff(old: &str, new: &str, filename: &str) -> String {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut result = String::new();
result.push_str(&format!("--- a/{}\n", filename));
result.push_str(&format!("+++ b/{}\n", filename));
let hunks = compute_hunks(&old_lines, &new_lines);
for hunk in hunks {
result.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
hunk.old_start + 1,
hunk.old_count,
hunk.new_start + 1,
hunk.new_count
));
for line in &hunk.lines {
match line {
DiffLine::Context(s) => {
result.push(' ');
result.push_str(s);
result.push('\n');
}
DiffLine::Added(s) => {
result.push('+');
result.push_str(s);
result.push('\n');
}
DiffLine::Removed(s) => {
result.push('-');
result.push_str(s);
result.push('\n');
}
}
}
}
result
}
/// Count lines added and removed.
pub fn count_changes(old: &str, new: &str) -> (usize, usize) {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let hunks = compute_hunks(&old_lines, &new_lines);
let mut added = 0;
let mut removed = 0;
for hunk in hunks {
for line in &hunk.lines {
match line {
DiffLine::Added(_) => added += 1,
DiffLine::Removed(_) => removed += 1,
DiffLine::Context(_) => {}
}
}
}
(added, removed)
}
enum DiffLine<'a> {
Context(&'a str),
Added(&'a str),
Removed(&'a str),
}
struct Hunk<'a> {
old_start: usize,
old_count: usize,
new_start: usize,
new_count: usize,
lines: Vec<DiffLine<'a>>,
}
fn compute_hunks<'a>(old: &[&'a str], new: &[&'a str]) -> Vec<Hunk<'a>> {
// Simple LCS-based diff
let n = old.len();
let m = new.len();
// Compute edit script using Myers-like approach (simplified)
let mut edits: Vec<DiffLine<'a>> = Vec::new();
let mut old_idx = 0;
let mut new_idx = 0;
// Simple line-by-line comparison with longest common subsequence
let lcs = compute_lcs(old, new);
let mut lcs_idx = 0;
while old_idx < n || new_idx < m {
if lcs_idx < lcs.len() && old_idx == lcs[lcs_idx].0 && new_idx == lcs[lcs_idx].1 {
edits.push(DiffLine::Context(old[old_idx]));
old_idx += 1;
new_idx += 1;
lcs_idx += 1;
} else if old_idx < n
&& (lcs_idx >= lcs.len() || old_idx < lcs[lcs_idx].0)
{
edits.push(DiffLine::Removed(old[old_idx]));
old_idx += 1;
} else if new_idx < m {
edits.push(DiffLine::Added(new[new_idx]));
new_idx += 1;
}
}
// Group edits into hunks with 3 lines of context
let context_lines = 3;
let mut hunks = Vec::new();
let mut i = 0;
while i < edits.len() {
// Find next change
while i < edits.len() {
if !matches!(edits[i], DiffLine::Context(_)) {
break;
}
i += 1;
}
if i >= edits.len() {
break;
}
// Start hunk with context before
let context_start = if i >= context_lines {
i - context_lines
} else {
0
};
let mut hunk_lines = Vec::new();
// Calculate starting positions
let mut oi = 0;
let mut ni = 0;
for j in 0..context_start {
match &edits[j] {
DiffLine::Context(_) => {
oi += 1;
ni += 1;
}
DiffLine::Removed(_) => oi += 1,
DiffLine::Added(_) => ni += 1,
}
}
let old_start = oi;
let new_start = ni;
// Add context before
let mut j = context_start;
while j < i {
hunk_lines.push(DiffLine::Context(match &edits[j] {
DiffLine::Context(s) => s,
_ => unreachable!(),
}));
j += 1;
}
// Add changes and merge close hunks
let mut consecutive_context = 0;
while j < edits.len() {
match &edits[j] {
DiffLine::Context(s) => {
consecutive_context += 1;
if consecutive_context > context_lines * 2 {
// End hunk, remove trailing context beyond limit
let to_remove = consecutive_context - context_lines;
for _ in 0..to_remove {
hunk_lines.pop();
}
break;
}
hunk_lines.push(DiffLine::Context(s));
}
DiffLine::Added(s) => {
consecutive_context = 0;
hunk_lines.push(DiffLine::Added(s));
}
DiffLine::Removed(s) => {
consecutive_context = 0;
hunk_lines.push(DiffLine::Removed(s));
}
}
j += 1;
}
// Remove trailing context beyond limit
while hunk_lines.len() > 0 {
if matches!(hunk_lines.last(), Some(DiffLine::Context(_))) {
let trailing_context = hunk_lines
.iter()
.rev()
.take_while(|l| matches!(l, DiffLine::Context(_)))
.count();
if trailing_context > context_lines {
hunk_lines.pop();
} else {
break;
}
} else {
break;
}
}
let mut old_count = 0;
let mut new_count = 0;
for line in &hunk_lines {
match line {
DiffLine::Context(_) => {
old_count += 1;
new_count += 1;
}
DiffLine::Removed(_) => old_count += 1,
DiffLine::Added(_) => new_count += 1,
}
}
if old_count > 0 || new_count > 0 {
hunks.push(Hunk {
old_start,
old_count,
new_start,
new_count,
lines: hunk_lines,
});
}
i = j;
}
hunks
}
fn compute_lcs<'a>(old: &[&'a str], new: &[&'a str]) -> Vec<(usize, usize)> {
let n = old.len();
let m = new.len();
if n == 0 || m == 0 {
return Vec::new();
}
// DP table (optimized for memory with two rows)
let mut dp = vec![vec![0u32; m + 1]; n + 1];
for i in 1..=n {
for j in 1..=m {
if old[i - 1] == new[j - 1] {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
}
}
}
// Backtrack to find LCS
let mut result = Vec::new();
let mut i = n;
let mut j = m;
while i > 0 && j > 0 {
if old[i - 1] == new[j - 1] {
result.push((i - 1, j - 1));
i -= 1;
j -= 1;
} else if dp[i - 1][j] > dp[i][j - 1] {
i -= 1;
} else {
j -= 1;
}
}
result.reverse();
result
}
@@ -0,0 +1,231 @@
use crate::types::{
CanUseToolFn, ContentBlock, Message, MessageRole, PermissionDecision, SDKMessage, Tool,
ToolError, ToolResult, ToolResultContentBlock, ToolUseContext,
};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::mpsc;
use super::registry::ToolRegistry;
/// Execute a set of tool calls from an assistant message.
/// Concurrent-safe tools run in parallel; others run sequentially.
pub async fn execute_tools(
message: &Message,
registry: &ToolRegistry,
context: &ToolUseContext,
permission_fn: Option<&CanUseToolFn>,
event_sender: Option<mpsc::Sender<SDKMessage>>,
) -> Vec<(String, String, ToolResult)> {
let tool_uses: Vec<(String, String, Value)> = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => {
Some((id.clone(), name.clone(), input.clone()))
}
_ => None,
})
.collect();
if tool_uses.is_empty() {
return Vec::new();
}
// Partition into concurrent and sequential
let mut concurrent_calls = Vec::new();
let mut sequential_calls = Vec::new();
for (id, name, input) in &tool_uses {
if let Some(tool) = registry.get(name) {
if tool.is_concurrency_safe(input) {
concurrent_calls.push((id.clone(), name.clone(), input.clone(), tool));
} else {
sequential_calls.push((id.clone(), name.clone(), input.clone(), tool));
}
} else {
// Unknown tool
sequential_calls.push((
id.clone(),
name.clone(),
input.clone(),
Arc::new(UnknownTool(name.clone())) as Arc<dyn Tool>,
));
}
}
let mut results = Vec::new();
// Run concurrent tools in parallel
if !concurrent_calls.is_empty() {
let mut handles = Vec::new();
for (id, name, input, tool) in concurrent_calls {
let ctx = context.clone();
let perm_fn = permission_fn.cloned();
let tool = tool.clone();
let sender = event_sender.clone();
handles.push(tokio::spawn(async move {
if let Some(sender) = &sender {
let _ = sender
.send(SDKMessage::ToolStart {
tool_use_id: id.clone(),
tool_name: name.clone(),
input: input.clone(),
})
.await;
}
let input =
check_permission(&name, input, perm_fn.as_ref(), &ctx.abort_signal).await;
match input {
Ok(input) => {
let result = call_tool_with_cancel(tool.as_ref(), input, &ctx).await;
let tool_result = match result {
Ok(r) => r,
Err(e) => ToolResult::error(e.to_string()),
};
(id, name, tool_result)
}
Err(msg) => (id, name, ToolResult::error(msg)),
}
}));
}
for handle in handles {
if let Ok(result) = handle.await {
results.push(result);
}
}
}
// Run sequential tools one at a time
for (id, name, input, tool) in sequential_calls {
if let Some(sender) = &event_sender {
let _ = sender
.send(SDKMessage::ToolStart {
tool_use_id: id.clone(),
tool_name: name.clone(),
input: input.clone(),
})
.await;
}
let input = check_permission(&name, input, permission_fn, &context.abort_signal).await;
match input {
Ok(input) => {
let result = call_tool_with_cancel(tool.as_ref(), input, context).await;
let tool_result = match result {
Ok(r) => r,
Err(e) => ToolResult::error(e.to_string()),
};
results.push((id, name, tool_result));
}
Err(msg) => {
results.push((id, name, ToolResult::error(msg)));
}
}
}
results
}
async fn check_permission(
tool_name: &str,
input: Value,
permission_fn: Option<&CanUseToolFn>,
abort_signal: &tokio_util::sync::CancellationToken,
) -> Result<Value, String> {
if abort_signal.is_cancelled() {
return Err("Tool aborted".to_string());
}
if let Some(perm_fn) = permission_fn {
let decision = tokio::select! {
decision = perm_fn(tool_name, &input) => decision,
_ = abort_signal.cancelled() => return Err("Tool aborted".to_string()),
};
match decision {
PermissionDecision::Allow => Ok(input),
PermissionDecision::Deny(msg) => Err(msg),
PermissionDecision::AllowWithModifiedInput(new_input) => Ok(new_input),
}
} else {
Ok(input)
}
}
async fn call_tool_with_cancel(
tool: &dyn Tool,
input: Value,
context: &ToolUseContext,
) -> Result<ToolResult, ToolError> {
tokio::select! {
result = tool.call(input, context) => result,
_ = context.abort_signal.cancelled() => Ok(ToolResult::error("Tool aborted")),
}
}
/// Build a user message containing tool results.
pub fn build_tool_results_message(results: &[(String, String, ToolResult)]) -> Message {
let content: Vec<ContentBlock> = results
.iter()
.map(|(id, _name, result)| {
let content_blocks: Vec<ToolResultContentBlock> = result
.content
.iter()
.map(|c| match c {
crate::types::ToolResultContent::Text { text } => {
ToolResultContentBlock::Text { text: text.clone() }
}
crate::types::ToolResultContent::Image { source } => {
ToolResultContentBlock::Image {
source: crate::types::ImageContentSource {
source_type: source.source_type.clone(),
media_type: source.media_type.clone(),
data: source.data.clone(),
},
}
}
})
.collect();
ContentBlock::ToolResult {
tool_use_id: id.clone(),
content: content_blocks,
is_error: result.is_error,
}
})
.collect();
Message {
role: MessageRole::User,
content,
}
}
/// Placeholder tool for unknown tool names.
struct UnknownTool(String);
#[async_trait::async_trait]
impl Tool for UnknownTool {
fn name(&self) -> &str {
&self.0
}
fn description(&self) -> &str {
"Unknown tool"
}
fn input_schema(&self) -> crate::types::ToolInputSchema {
crate::types::ToolInputSchema::default()
}
async fn call(
&self,
_input: Value,
_context: &ToolUseContext,
) -> Result<ToolResult, ToolError> {
Ok(ToolResult::error(format!(
"Unknown tool: {}. Use ToolSearch to discover available tools.",
self.0
)))
}
}
@@ -0,0 +1,177 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::Path;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
pub struct FileEditTool;
#[async_trait]
impl Tool for FileEditTool {
fn name(&self) -> &str {
"Edit"
}
fn description(&self) -> &str {
"Performs exact string replacements in files. The old_string must be unique in the file unless replace_all is true."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"file_path".to_string(),
json!({
"type": "string",
"description": "The absolute path to the file to modify"
}),
),
(
"old_string".to_string(),
json!({
"type": "string",
"description": "The text to replace"
}),
),
(
"new_string".to_string(),
json!({
"type": "string",
"description": "The replacement text"
}),
),
(
"replace_all".to_string(),
json!({
"type": "boolean",
"description": "Replace all occurrences (default false)",
"default": false
}),
),
]),
required: vec![
"file_path".to_string(),
"old_string".to_string(),
"new_string".to_string(),
],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let file_path = input
.get("file_path")
.and_then(|p| p.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'file_path' field".to_string()))?;
let old_string = input
.get("old_string")
.and_then(|s| s.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'old_string' field".to_string()))?;
let new_string = input
.get("new_string")
.and_then(|s| s.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'new_string' field".to_string()))?;
let replace_all = input
.get("replace_all")
.and_then(|r| r.as_bool())
.unwrap_or(false);
if old_string == new_string {
return Ok(ToolResult::error(
"old_string and new_string must be different".to_string(),
));
}
let path = if Path::new(file_path).is_absolute() {
std::path::PathBuf::from(file_path)
} else {
Path::new(&context.working_dir).join(file_path)
};
if !path.exists() {
return Ok(ToolResult::error(format!(
"File not found: {}",
path.display()
)));
}
let content = tokio::fs::read_to_string(&path)
.await
.map_err(ToolError::IoError)?;
// Check staleness
{
let current_hash = simple_hash(&content);
let state = context.read_file_state.read().await;
if let Some(saved_hash) = state.get(&path.to_string_lossy().to_string()) {
if *saved_hash != current_hash {
return Ok(ToolResult::error(
"File has been modified since last read. Please Read the file again before editing."
.to_string(),
));
}
}
}
// Count occurrences
let count = content.matches(old_string).count();
if count == 0 {
return Ok(ToolResult::error(format!(
"old_string not found in {}. Make sure the string matches exactly, including whitespace and indentation.",
path.display()
)));
}
if count > 1 && !replace_all {
return Ok(ToolResult::error(format!(
"old_string found {} times in {}. Either provide more context to make it unique, or set replace_all to true.",
count,
path.display()
)));
}
let new_content = if replace_all {
content.replace(old_string, new_string)
} else {
content.replacen(old_string, new_string, 1)
};
let diff = super::diff::unified_diff(&content, &new_content, file_path);
tokio::fs::write(&path, &new_content)
.await
.map_err(ToolError::IoError)?;
// Update file state
let hash = simple_hash(&new_content);
let mut state = context.read_file_state.write().await;
state.insert(path.to_string_lossy().to_string(), hash);
let replacements = if replace_all {
format!(" ({} replacements)", count)
} else {
String::new()
};
Ok(ToolResult::text(format!(
"Edited file: {}{}\n\n{}",
path.display(),
replacements,
diff
)))
}
}
fn simple_hash(content: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
@@ -0,0 +1,182 @@
use async_trait::async_trait;
use base64::Engine;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
const DEFAULT_LINE_LIMIT: usize = 2000;
const MAX_CONTENT_SIZE: usize = 100_000;
pub struct FileReadTool;
#[async_trait]
impl Tool for FileReadTool {
fn name(&self) -> &str {
"Read"
}
fn description(&self) -> &str {
"Reads a file from the local filesystem. Returns content with line numbers."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"file_path".to_string(),
json!({
"type": "string",
"description": "The absolute path to the file to read"
}),
),
(
"offset".to_string(),
json!({
"type": "integer",
"description": "The line number to start reading from (0-based)"
}),
),
(
"limit".to_string(),
json!({
"type": "integer",
"description": "The number of lines to read"
}),
),
]),
required: vec!["file_path".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let file_path = input
.get("file_path")
.and_then(|p| p.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'file_path' field".to_string()))?;
let path = resolve_path(file_path, &context.working_dir);
// Check if file is a device file
if file_path.starts_with("/dev/") || file_path.starts_with("/proc/") {
return Ok(ToolResult::error(
"Cannot read device or proc files".to_string(),
));
}
if !path.exists() {
return Ok(ToolResult::error(format!(
"File not found: {}",
path.display()
)));
}
if path.is_dir() {
return Ok(ToolResult::error(format!(
"{} is a directory, not a file. Use Bash with 'ls' to list directory contents.",
path.display()
)));
}
// Check for image files
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg") {
return read_image(&path, &ext).await;
}
// Read text file
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| ToolError::IoError(e))?;
// Track file state for staleness detection
{
let hash = simple_hash(&content);
let mut state = context.read_file_state.write().await;
state.insert(path.to_string_lossy().to_string(), hash);
}
let offset = input.get("offset").and_then(|o| o.as_u64()).unwrap_or(0) as usize;
let limit = input
.get("limit")
.and_then(|l| l.as_u64())
.unwrap_or(DEFAULT_LINE_LIMIT as u64) as usize;
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
let start = offset.min(total_lines);
let end = (start + limit).min(total_lines);
let selected_lines = &lines[start..end];
let mut result = String::new();
for (i, line) in selected_lines.iter().enumerate() {
let line_num = start + i + 1;
result.push_str(&format!("{}\t{}\n", line_num, line));
}
if result.len() > MAX_CONTENT_SIZE {
result.truncate(MAX_CONTENT_SIZE);
result.push_str("\n... (content truncated)");
}
if end < total_lines {
result.push_str(&format!(
"\n(showing lines {}-{} of {})",
start + 1,
end,
total_lines
));
}
Ok(ToolResult::text(result))
}
}
async fn read_image(path: &Path, ext: &str) -> Result<ToolResult, ToolError> {
let data = tokio::fs::read(path).await.map_err(ToolError::IoError)?;
let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
let media_type = match ext {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"svg" => "image/svg+xml",
_ => "application/octet-stream",
};
Ok(ToolResult::image(encoded, media_type.to_string()))
}
fn resolve_path(file_path: &str, working_dir: &str) -> PathBuf {
let path = Path::new(file_path);
if path.is_absolute() {
path.to_path_buf()
} else {
Path::new(working_dir).join(file_path)
}
}
fn simple_hash(content: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
@@ -0,0 +1,131 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::Path;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
pub struct FileWriteTool;
#[async_trait]
impl Tool for FileWriteTool {
fn name(&self) -> &str {
"Write"
}
fn description(&self) -> &str {
"Writes a file to the local filesystem. Will overwrite existing files. Use Read tool first for existing files."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"file_path".to_string(),
json!({
"type": "string",
"description": "The absolute path to the file to write"
}),
),
(
"content".to_string(),
json!({
"type": "string",
"description": "The content to write to the file"
}),
),
]),
required: vec!["file_path".to_string(), "content".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let file_path = input
.get("file_path")
.and_then(|p| p.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'file_path' field".to_string()))?;
let content = input
.get("content")
.and_then(|c| c.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'content' field".to_string()))?;
let path = if Path::new(file_path).is_absolute() {
std::path::PathBuf::from(file_path)
} else {
Path::new(&context.working_dir).join(file_path)
};
let is_new = !path.exists();
// Create parent directories if needed
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(ToolError::IoError)?;
}
// Check staleness for existing files
if !is_new {
let current_content = tokio::fs::read_to_string(&path).await.ok();
if let Some(current) = &current_content {
let current_hash = simple_hash(current);
let state = context.read_file_state.read().await;
if let Some(saved_hash) = state.get(&path.to_string_lossy().to_string()) {
if *saved_hash != current_hash {
return Ok(ToolResult::error(
"File has been modified since last read. Please Read the file again before writing."
.to_string(),
));
}
}
}
// Generate diff for existing files
if let Some(old_content) = current_content {
let diff = super::diff::unified_diff(&old_content, content, file_path);
tokio::fs::write(&path, content)
.await
.map_err(ToolError::IoError)?;
// Update file state
let hash = simple_hash(content);
let mut state = context.read_file_state.write().await;
state.insert(path.to_string_lossy().to_string(), hash);
return Ok(ToolResult::text(format!(
"Updated file: {}\n\n{}",
path.display(),
diff
)));
}
}
tokio::fs::write(&path, content)
.await
.map_err(ToolError::IoError)?;
// Update file state
let hash = simple_hash(content);
let mut state = context.read_file_state.write().await;
state.insert(path.to_string_lossy().to_string(), hash);
let action = if is_new { "Created" } else { "Updated" };
Ok(ToolResult::text(format!(
"{} file: {} ({} bytes)",
action,
path.display(),
content.len()
)))
}
}
fn simple_hash(content: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
@@ -0,0 +1,274 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
const MAX_RESULTS: usize = 100;
const MAX_VISITED_ENTRIES: usize = 20_000;
const MAX_SEARCH_MS: u64 = 3_000;
/// Directories to skip during glob matching.
const SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
".next",
"__pycache__",
".mypy_cache",
"target",
"dist",
"build",
];
pub struct GlobTool;
#[async_trait]
impl Tool for GlobTool {
fn name(&self) -> &str {
"Glob"
}
fn description(&self) -> &str {
"Fast file pattern matching tool. Supports glob patterns like \"**/*.rs\" or \"src/**/*.ts\". Returns matching file paths sorted by modification time."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"pattern".to_string(),
json!({
"type": "string",
"description": "The glob pattern to match files against"
}),
),
(
"path".to_string(),
json!({
"type": "string",
"description": "The directory to search in (defaults to working directory)"
}),
),
]),
required: vec!["pattern".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let pattern = input
.get("pattern")
.and_then(|p| p.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'pattern' field".to_string()))?;
let search_path_value = input
.get("path")
.and_then(|p| p.as_str())
.unwrap_or(&context.working_dir);
let search_path = resolve_search_path(search_path_value, &context.working_dir);
let pattern = normalize_pattern(pattern)?;
let matcher = glob::Pattern::new(&pattern)
.map_err(|e| ToolError::ExecutionError(format!("Invalid glob pattern: {}", e)))?;
let (walk_root, max_depth) = walk_root_and_depth(&search_path, &pattern);
let scan = scan_files(
&walk_root,
&search_path,
&matcher,
pattern.starts_with('/'),
max_depth,
context,
)?;
let mut files = scan.files;
// Sort by modification time (newest first)
files.sort_by(|a, b| b.1.cmp(&a.1));
let total = files.len();
let truncated = scan.truncated || total > MAX_RESULTS;
let files: Vec<String> = files
.into_iter()
.take(MAX_RESULTS)
.map(|(p, _)| p)
.collect();
if files.is_empty() {
return Ok(ToolResult::text(format!(
"No files found matching pattern: {}",
pattern
)));
}
let mut result = files.join("\n");
if truncated {
result.push_str(&format!(
"\n\n(showing first {} matches; search was bounded)",
files.len().min(MAX_RESULTS)
));
}
Ok(ToolResult::text(result))
}
}
struct GlobScan {
files: Vec<(String, std::time::SystemTime)>,
truncated: bool,
}
fn normalize_pattern(pattern: &str) -> Result<String, ToolError> {
let trimmed = pattern.trim();
if trimmed.is_empty() {
return Err(ToolError::InvalidInput("Empty glob pattern".to_string()));
}
Ok(match trimmed {
"." | "./" => "*".to_string(),
other => other.to_string(),
})
}
fn resolve_search_path(input_path: &str, working_dir: &str) -> PathBuf {
let path = Path::new(input_path);
if path.is_absolute() {
path.to_path_buf()
} else {
Path::new(working_dir).join(path)
}
}
fn walk_root_and_depth(search_path: &Path, pattern: &str) -> (PathBuf, Option<usize>) {
let base = extract_glob_base(pattern);
let root = if pattern.starts_with('/') {
PathBuf::from(if base.is_empty() { "/" } else { &base })
} else if base.is_empty() {
search_path.to_path_buf()
} else {
search_path.join(base)
};
let remaining = remaining_segments(pattern, &extract_glob_base(pattern));
let max_depth = if remaining.iter().any(|part| *part == "**") {
None
} else {
Some(remaining.len().max(1))
};
(root, max_depth)
}
fn extract_glob_base(pattern: &str) -> String {
let mut parts = Vec::new();
for segment in pattern.split('/') {
if segment.contains('*')
|| segment.contains('?')
|| segment.contains('[')
|| segment.contains('{')
{
break;
}
parts.push(segment);
}
parts.join("/")
}
fn remaining_segments<'a>(pattern: &'a str, base: &str) -> Vec<&'a str> {
let base_count = base.split('/').filter(|part| !part.is_empty()).count();
pattern
.split('/')
.filter(|part| !part.is_empty())
.skip(base_count)
.collect()
}
fn scan_files(
root: &Path,
search_path: &Path,
matcher: &glob::Pattern,
match_absolute: bool,
max_depth: Option<usize>,
context: &ToolUseContext,
) -> Result<GlobScan, ToolError> {
let deadline = Duration::from_millis(MAX_SEARCH_MS);
let started = Instant::now();
let mut stack = vec![(root.to_path_buf(), 0usize)];
let mut files = Vec::new();
let mut visited = 0usize;
let mut truncated = false;
if root.is_file() && path_matches(root, search_path, matcher, match_absolute) {
let modified = root
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
files.push((root.to_string_lossy().to_string(), modified));
return Ok(GlobScan { files, truncated });
}
while let Some((dir, depth)) = stack.pop() {
if context.abort_signal.is_cancelled() {
return Ok(GlobScan {
files,
truncated: true,
});
}
if visited >= MAX_VISITED_ENTRIES || started.elapsed() > deadline {
truncated = true;
break;
}
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
visited += 1;
let path = entry.path();
if path.is_dir() {
if should_skip_dir(&path) {
continue;
}
if max_depth.map_or(true, |limit| depth + 1 < limit) {
stack.push((path, depth + 1));
}
} else if path.is_file() && path_matches(&path, search_path, matcher, match_absolute) {
let modified = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
files.push((path.to_string_lossy().to_string(), modified));
if files.len() >= MAX_RESULTS {
truncated = true;
break;
}
}
}
if truncated {
break;
}
}
Ok(GlobScan { files, truncated })
}
fn path_matches(path: &Path, search_path: &Path, matcher: &glob::Pattern, absolute: bool) -> bool {
if absolute {
return matcher.matches_path(path);
}
path.strip_prefix(search_path)
.ok()
.is_some_and(|relative| matcher.matches_path(relative))
}
fn should_skip_dir(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| SKIP_DIRS.contains(&name))
}
@@ -0,0 +1,290 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::process::Stdio;
use tokio::process::Command;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
const DEFAULT_HEAD_LIMIT: usize = 250;
pub struct GrepTool;
#[async_trait]
impl Tool for GrepTool {
fn name(&self) -> &str {
"Grep"
}
fn description(&self) -> &str {
"A powerful search tool built on ripgrep. Supports full regex syntax, file type filtering, and multiple output modes."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"pattern".to_string(),
json!({
"type": "string",
"description": "The regex pattern to search for"
}),
),
(
"path".to_string(),
json!({
"type": "string",
"description": "File or directory to search in"
}),
),
(
"output_mode".to_string(),
json!({
"type": "string",
"enum": ["content", "files_with_matches", "count"],
"description": "Output mode (default: files_with_matches)"
}),
),
(
"glob".to_string(),
json!({
"type": "string",
"description": "Glob pattern to filter files (e.g. \"*.rs\")"
}),
),
(
"type".to_string(),
json!({
"type": "string",
"description": "File type to search (e.g. rust, js, py)"
}),
),
(
"-i".to_string(),
json!({
"type": "boolean",
"description": "Case insensitive search"
}),
),
(
"-n".to_string(),
json!({
"type": "boolean",
"description": "Show line numbers"
}),
),
(
"-A".to_string(),
json!({
"type": "number",
"description": "Lines to show after each match"
}),
),
(
"-B".to_string(),
json!({
"type": "number",
"description": "Lines to show before each match"
}),
),
(
"-C".to_string(),
json!({
"type": "number",
"description": "Lines of context around each match"
}),
),
(
"head_limit".to_string(),
json!({
"type": "number",
"description": "Limit output to first N entries (default 250)"
}),
),
(
"multiline".to_string(),
json!({
"type": "boolean",
"description": "Enable multiline matching"
}),
),
]),
required: vec!["pattern".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let pattern = input
.get("pattern")
.and_then(|p| p.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'pattern' field".to_string()))?;
let search_path = input
.get("path")
.and_then(|p| p.as_str())
.unwrap_or(&context.working_dir);
let output_mode = input
.get("output_mode")
.and_then(|m| m.as_str())
.unwrap_or("files_with_matches");
let head_limit = input
.get("head_limit")
.and_then(|h| h.as_u64())
.unwrap_or(DEFAULT_HEAD_LIMIT as u64) as usize;
// Try rg first, fall back to grep
let result = run_ripgrep(pattern, search_path, output_mode, &input).await;
match result {
Ok(output) => {
if output.is_empty() {
return Ok(ToolResult::text("No matches found.".to_string()));
}
let lines: Vec<&str> = output.lines().collect();
let total = lines.len();
let truncated = total > head_limit;
let lines: Vec<&str> = lines.into_iter().take(head_limit).collect();
let mut result = lines.join("\n");
if truncated {
result.push_str(&format!(
"\n\n(showing {} of {} results)",
head_limit, total
));
}
Ok(ToolResult::text(result))
}
Err(e) => {
// Fall back to grep
match run_grep(pattern, search_path, output_mode, &input).await {
Ok(output) => {
if output.is_empty() {
return Ok(ToolResult::text("No matches found.".to_string()));
}
Ok(ToolResult::text(output))
}
Err(_) => Ok(ToolResult::error(format!("Search failed: {}", e))),
}
}
}
}
}
async fn run_ripgrep(
pattern: &str,
path: &str,
output_mode: &str,
input: &Value,
) -> Result<String, String> {
let mut args = vec!["--no-heading".to_string()];
match output_mode {
"files_with_matches" => args.push("-l".to_string()),
"count" => args.push("-c".to_string()),
"content" => {
if input.get("-n").and_then(|n| n.as_bool()).unwrap_or(true) {
args.push("-n".to_string());
}
}
_ => args.push("-l".to_string()),
}
if input.get("-i").and_then(|i| i.as_bool()).unwrap_or(false) {
args.push("-i".to_string());
}
if let Some(after) = input.get("-A").and_then(|a| a.as_u64()) {
args.push(format!("-A{}", after));
}
if let Some(before) = input.get("-B").and_then(|b| b.as_u64()) {
args.push(format!("-B{}", before));
}
if let Some(ctx) = input.get("-C").and_then(|c| c.as_u64()) {
args.push(format!("-C{}", ctx));
}
if let Some(glob_pattern) = input.get("glob").and_then(|g| g.as_str()) {
args.push("--glob".to_string());
args.push(glob_pattern.to_string());
}
if let Some(file_type) = input.get("type").and_then(|t| t.as_str()) {
args.push("--type".to_string());
args.push(file_type.to_string());
}
if input
.get("multiline")
.and_then(|m| m.as_bool())
.unwrap_or(false)
{
args.push("-U".to_string());
args.push("--multiline-dotall".to_string());
}
args.push(pattern.to_string());
args.push(path.to_string());
let output = Command::new("rg")
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() || output.status.code() == Some(1) {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
async fn run_grep(
pattern: &str,
path: &str,
output_mode: &str,
input: &Value,
) -> Result<String, String> {
let mut args = vec!["-r".to_string()];
match output_mode {
"files_with_matches" => args.push("-l".to_string()),
"count" => args.push("-c".to_string()),
"content" => {
args.push("-n".to_string());
}
_ => args.push("-l".to_string()),
}
if input.get("-i").and_then(|i| i.as_bool()).unwrap_or(false) {
args.push("-i".to_string());
}
args.push(pattern.to_string());
args.push(path.to_string());
let output = Command::new("grep")
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| e.to_string())?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
@@ -0,0 +1,245 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// LSPTool - Language Server Protocol integration.
///
/// Provides code intelligence: go-to-definition, find-references,
/// hover, document symbols. Uses ripgrep/grep as fallback for symbol lookup.
pub struct LSPTool;
#[async_trait]
impl Tool for LSPTool {
fn name(&self) -> &str {
"LSP"
}
fn description(&self) -> &str {
"Language Server Protocol operations for code intelligence. Supports go-to-definition, find-references, hover, and symbol lookup."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"operation".to_string(),
json!({
"type": "string",
"enum": ["goToDefinition", "findReferences", "hover", "documentSymbol"],
"description": "LSP operation to perform"
}),
),
(
"file_path".to_string(),
json!({ "type": "string", "description": "File path for the operation" }),
),
(
"line".to_string(),
json!({ "type": "number", "description": "Line number (0-based)" }),
),
(
"character".to_string(),
json!({ "type": "number", "description": "Character position (0-based)" }),
),
(
"query".to_string(),
json!({ "type": "string", "description": "Symbol name (for workspace symbol search)" }),
),
]),
required: vec!["operation".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let operation = input
.get("operation")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'operation'".to_string()))?;
let file_path = input.get("file_path").and_then(|v| v.as_str());
let line = input.get("line").and_then(|v| v.as_u64());
let character = input.get("character").and_then(|v| v.as_u64()).unwrap_or(0);
let cwd = &context.working_dir;
match operation {
"goToDefinition" => {
let file_path = file_path
.ok_or_else(|| ToolError::InvalidInput("file_path required".to_string()))?;
let line = line
.ok_or_else(|| ToolError::InvalidInput("line required".to_string()))?;
let symbol = get_symbol_at_position(file_path, line as usize, character as usize, cwd);
match symbol {
Some(sym) => {
let pattern = format!(
r"(?:fn |struct |enum |trait |type |const |let |pub fn |pub struct |pub enum |pub trait |pub type |pub const |impl |mod |use )\s*{}",
regex::escape(&sym)
);
let result = run_rg_or_grep(&pattern, cwd);
if result.is_empty() {
Ok(ToolResult::text(format!("No definition found for \"{}\"", sym)))
} else {
Ok(ToolResult::text(result))
}
}
None => Ok(ToolResult::text("Could not identify symbol at position")),
}
}
"findReferences" => {
let file_path = file_path
.ok_or_else(|| ToolError::InvalidInput("file_path required".to_string()))?;
let line = line
.ok_or_else(|| ToolError::InvalidInput("line required".to_string()))?;
let symbol = get_symbol_at_position(file_path, line as usize, character as usize, cwd);
match symbol {
Some(sym) => {
let result = run_rg_or_grep(&regex::escape(&sym), cwd);
if result.is_empty() {
Ok(ToolResult::text(format!("No references found for \"{}\"", sym)))
} else {
Ok(ToolResult::text(result))
}
}
None => Ok(ToolResult::text("Could not identify symbol at position")),
}
}
"hover" => Ok(ToolResult::text(
"Hover information requires a running language server. Use FileRead tool to examine the file content.",
)),
"documentSymbol" => {
let file_path = file_path
.ok_or_else(|| ToolError::InvalidInput("file_path required".to_string()))?;
let pattern = r"^\s*(pub\s+)?(fn |struct |enum |trait |type |const |let |impl |mod |use )";
let result = run_rg_on_file(pattern, file_path, cwd);
if result.is_empty() {
Ok(ToolResult::text("No symbols found"))
} else {
Ok(ToolResult::text(result))
}
}
_ => Ok(ToolResult::text(format!(
"LSP operation \"{}\" requires a running language server.",
operation
))),
}
}
}
/// Extract the symbol (word) at a given line and character position in a file.
fn get_symbol_at_position(
file_path: &str,
line: usize,
character: usize,
cwd: &str,
) -> Option<String> {
let full_path = Path::new(cwd).join(file_path);
let content = std::fs::read_to_string(full_path).ok()?;
let lines: Vec<&str> = content.lines().collect();
if line >= lines.len() {
return None;
}
let line_text = lines[line];
if character >= line_text.len() {
return None;
}
// Find the word boundary around the character position
let bytes = line_text.as_bytes();
let mut start = character;
let mut end = character;
while start > 0 && is_word_char(bytes[start - 1]) {
start -= 1;
}
while end < bytes.len() && is_word_char(bytes[end]) {
end += 1;
}
if start == end {
return None;
}
Some(line_text[start..end].to_string())
}
fn is_word_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
/// Run ripgrep on the working directory, falling back to grep.
fn run_rg_or_grep(pattern: &str, cwd: &str) -> String {
// Try ripgrep first
if let Ok(output) = Command::new("rg")
.args(["-n", pattern, "--type-add", "src:*.{ts,tsx,js,jsx,py,go,rs,java}", "-t", "src"])
.arg(cwd)
.output()
{
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
// Limit output to 50 lines
let lines: Vec<&str> = text.lines().take(50).collect();
return lines.join("\n");
}
}
// Fallback to grep
if let Ok(output) = Command::new("grep")
.args(["-rn", pattern, cwd, "--include=*.rs", "--include=*.ts", "--include=*.py", "--include=*.go", "--include=*.java"])
.output()
{
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = text.lines().take(50).collect();
return lines.join("\n");
}
}
String::new()
}
/// Run ripgrep on a single file, falling back to grep.
fn run_rg_on_file(pattern: &str, file_path: &str, cwd: &str) -> String {
let full_path = Path::new(cwd).join(file_path);
let path_str = full_path.to_string_lossy();
if let Ok(output) = Command::new("rg")
.args(["-n", pattern])
.arg(path_str.as_ref())
.output()
{
if output.status.success() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
}
if let Ok(output) = Command::new("grep")
.args(["-n", pattern])
.arg(path_str.as_ref())
.output()
{
if output.status.success() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
}
String::new()
}
@@ -0,0 +1,174 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::mcp::McpClient;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// ListMcpResourcesTool - List available resources from connected MCP servers.
pub struct ListMcpResourcesTool {
mcp_client: Arc<RwLock<Option<Arc<McpClient>>>>,
}
impl ListMcpResourcesTool {
pub fn new(mcp_client: Arc<RwLock<Option<Arc<McpClient>>>>) -> Self {
Self { mcp_client }
}
}
#[async_trait]
impl Tool for ListMcpResourcesTool {
fn name(&self) -> &str {
"ListMcpResources"
}
fn description(&self) -> &str {
"List available resources from connected MCP servers. Resources can include files, databases, and other data sources."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([(
"server".to_string(),
json!({ "type": "string", "description": "Filter by MCP server name" }),
)]),
required: vec![],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let client_guard = self.mcp_client.read().await;
let client = match client_guard.as_ref() {
Some(c) => c.clone(),
None => return Ok(ToolResult::text("No MCP servers connected.")),
};
let server_filter = input.get("server").and_then(|v| v.as_str());
let connections = client.get_connections().await;
let filtered: Vec<_> = if let Some(server) = server_filter {
connections.into_iter().filter(|c| c.name == server).collect()
} else {
connections
};
if filtered.is_empty() {
return Ok(ToolResult::text("No MCP servers connected."));
}
let mut results = Vec::new();
for conn in &filtered {
if !conn.connected {
continue;
}
results.push(format!(
"Server: {} ({} tools available)",
conn.name,
conn.tools.len()
));
// Note: Full resource listing requires MCP resources/list support.
// Currently showing available tool count as a proxy.
}
if results.is_empty() {
Ok(ToolResult::text("No connected MCP servers found."))
} else {
Ok(ToolResult::text(results.join("\n")))
}
}
}
/// ReadMcpResourceTool - Read a specific resource from an MCP server.
pub struct ReadMcpResourceTool {
mcp_client: Arc<RwLock<Option<Arc<McpClient>>>>,
}
impl ReadMcpResourceTool {
pub fn new(mcp_client: Arc<RwLock<Option<Arc<McpClient>>>>) -> Self {
Self { mcp_client }
}
}
#[async_trait]
impl Tool for ReadMcpResourceTool {
fn name(&self) -> &str {
"ReadMcpResource"
}
fn description(&self) -> &str {
"Read a specific resource from an MCP server."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"server".to_string(),
json!({ "type": "string", "description": "MCP server name" }),
),
(
"uri".to_string(),
json!({ "type": "string", "description": "Resource URI to read" }),
),
]),
required: vec!["server".to_string(), "uri".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let server = input
.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'server'".to_string()))?;
let uri = input
.get("uri")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'uri'".to_string()))?;
let client_guard = self.mcp_client.read().await;
let client = match client_guard.as_ref() {
Some(c) => c.clone(),
None => {
return Ok(ToolResult::error(format!(
"MCP server not found: {}",
server
)))
}
};
let connections = client.get_connections().await;
let conn = connections.iter().find(|c| c.name == server);
if conn.is_none() {
return Ok(ToolResult::error(format!(
"MCP server not found: {}",
server
)));
}
// MCP resource reading is done via the resources/read method.
// Since the current McpClient doesn't expose a read_resource method,
// we provide a stub that indicates the operation is recognized but
// requires the MCP server to support the resources/read capability.
Ok(ToolResult::text(format!(
"Resource read request: server=\"{}\" uri=\"{}\". \
Full resource reading requires MCP server support for the resources/read method.",
server, uri
)))
}
}
@@ -0,0 +1,28 @@
pub mod askuser;
pub mod bash;
pub mod config_tool;
pub mod cron_tools;
pub mod diff;
pub mod executor;
pub mod fileedit;
pub mod fileread;
pub mod filewrite;
pub mod glob_tool;
pub mod grep;
pub mod lsp_tool;
pub mod mcp_resource_tools;
pub mod notebook_edit;
pub mod plan_tools;
pub mod registry;
pub mod send_message;
pub mod skill_tool;
pub mod tasks;
pub mod team_tools;
pub mod todo_tool;
pub mod toolsearch;
pub mod webfetch;
pub mod websearch;
pub mod worktree_tools;
pub use executor::*;
pub use registry::*;
@@ -0,0 +1,180 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::Path;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// NotebookEditTool - Edit Jupyter notebook (.ipynb) cells.
/// Supports insert, replace, and delete operations on notebook cells.
pub struct NotebookEditTool;
#[async_trait]
impl Tool for NotebookEditTool {
fn name(&self) -> &str {
"NotebookEdit"
}
fn description(&self) -> &str {
"Edit Jupyter notebook (.ipynb) cells. Can insert, replace, or delete cells."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"file_path".to_string(),
json!({ "type": "string", "description": "Path to the .ipynb file" }),
),
(
"command".to_string(),
json!({
"type": "string",
"enum": ["insert", "replace", "delete"],
"description": "The edit operation to perform"
}),
),
(
"cell_number".to_string(),
json!({
"type": "number",
"description": "Cell index (0-based) to operate on"
}),
),
(
"cell_type".to_string(),
json!({
"type": "string",
"enum": ["code", "markdown"],
"description": "Type of cell (for insert/replace)"
}),
),
(
"source".to_string(),
json!({
"type": "string",
"description": "Cell content (for insert/replace)"
}),
),
]),
required: vec![
"file_path".to_string(),
"command".to_string(),
"cell_number".to_string(),
],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let file_path = input
.get("file_path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'file_path'".to_string()))?;
let command = input
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'command'".to_string()))?;
let cell_number = input
.get("cell_number")
.and_then(|v| v.as_u64())
.ok_or_else(|| ToolError::InvalidInput("Missing 'cell_number'".to_string()))?
as usize;
let resolved = Path::new(&context.working_dir).join(file_path);
let resolved_str = resolved.to_string_lossy().to_string();
let content = match tokio::fs::read_to_string(&resolved).await {
Ok(c) => c,
Err(e) => return Ok(ToolResult::error(format!("Error: {}", e))),
};
let mut notebook: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => return Ok(ToolResult::error(format!("Error: Invalid JSON: {}", e))),
};
let cells = match notebook.get_mut("cells").and_then(|c| c.as_array_mut()) {
Some(c) => c,
None => return Ok(ToolResult::error("Error: Invalid notebook format".to_string())),
};
let cell_type = input
.get("cell_type")
.and_then(|v| v.as_str())
.unwrap_or("code");
let source = input
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("");
// Split source into lines matching Jupyter format (each line ends with \n except last)
let source_lines: Vec<Value> = source
.split('\n')
.enumerate()
.map(|(i, line)| {
let total = source.split('\n').count();
if i < total - 1 {
json!(format!("{}\n", line))
} else {
json!(line)
}
})
.collect();
match command {
"insert" => {
let mut new_cell = json!({
"cell_type": cell_type,
"source": source_lines,
"metadata": {}
});
if cell_type != "markdown" {
new_cell["outputs"] = json!([]);
new_cell["execution_count"] = Value::Null;
}
cells.insert(cell_number, new_cell);
}
"replace" => {
if cell_number >= cells.len() {
return Ok(ToolResult::error(format!(
"Error: Cell {} does not exist",
cell_number
)));
}
cells[cell_number]["source"] = json!(source_lines);
if !cell_type.is_empty() {
cells[cell_number]["cell_type"] = json!(cell_type);
}
}
"delete" => {
if cell_number >= cells.len() {
return Ok(ToolResult::error(format!(
"Error: Cell {} does not exist",
cell_number
)));
}
cells.remove(cell_number);
}
_ => {
return Ok(ToolResult::error(format!(
"Error: Unknown command '{}'",
command
)));
}
}
let output = serde_json::to_string_pretty(&notebook)
.map_err(|e| ToolError::ExecutionError(format!("Failed to serialize: {}", e)))?;
if let Err(e) = tokio::fs::write(&resolved, output).await {
return Ok(ToolResult::error(format!("Error writing file: {}", e)));
}
Ok(ToolResult::text(format!(
"Notebook {}: cell {} in {}",
command, cell_number, resolved_str
)))
}
}
@@ -0,0 +1,154 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// Shared plan mode state.
#[derive(Clone)]
pub struct PlanState {
active: Arc<RwLock<bool>>,
current_plan: Arc<RwLock<Option<String>>>,
}
impl PlanState {
pub fn new() -> Self {
Self {
active: Arc::new(RwLock::new(false)),
current_plan: Arc::new(RwLock::new(None)),
}
}
pub async fn is_active(&self) -> bool {
*self.active.read().await
}
pub async fn get_plan(&self) -> Option<String> {
self.current_plan.read().await.clone()
}
}
// ============================================================================
// EnterPlanModeTool
// ============================================================================
pub struct EnterPlanModeTool {
state: PlanState,
}
impl EnterPlanModeTool {
pub fn new(state: PlanState) -> Self {
Self { state }
}
}
#[async_trait]
impl Tool for EnterPlanModeTool {
fn name(&self) -> &str {
"EnterPlanMode"
}
fn description(&self) -> &str {
"Enter plan/design mode for complex tasks. In plan mode, the agent focuses on designing the approach before executing."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::new(),
required: Vec::new(),
additional_properties: Some(false),
}
}
async fn call(&self, _input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let mut active = self.state.active.write().await;
if *active {
return Ok(ToolResult::text("Already in plan mode."));
}
*active = true;
let mut plan = self.state.current_plan.write().await;
*plan = None;
Ok(ToolResult::text(
"Entered plan mode. Design your approach before executing. Use ExitPlanMode when the plan is ready.",
))
}
}
// ============================================================================
// ExitPlanModeTool
// ============================================================================
pub struct ExitPlanModeTool {
state: PlanState,
}
impl ExitPlanModeTool {
pub fn new(state: PlanState) -> Self {
Self { state }
}
}
#[async_trait]
impl Tool for ExitPlanModeTool {
fn name(&self) -> &str {
"ExitPlanMode"
}
fn description(&self) -> &str {
"Exit plan mode with a completed plan. The plan will be recorded and execution can proceed."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"plan".to_string(),
json!({ "type": "string", "description": "The completed plan" }),
),
(
"approved".to_string(),
json!({ "type": "boolean", "description": "Whether the plan is approved for execution" }),
),
]),
required: Vec::new(),
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let mut active = self.state.active.write().await;
if !*active {
return Ok(ToolResult::error("Not in plan mode."));
}
*active = false;
let plan_text = input.get("plan").and_then(|v| v.as_str()).map(String::from);
let approved = input
.get("approved")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let mut current_plan = self.state.current_plan.write().await;
*current_plan = plan_text.clone();
let status = if approved {
"approved"
} else {
"pending approval"
};
let mut msg = format!("Plan mode exited. Plan status: {}.", status);
if let Some(ref plan) = plan_text {
msg.push_str(&format!("\n\nPlan:\n{}", plan));
}
Ok(ToolResult::text(msg))
}
}
@@ -0,0 +1,181 @@
use crate::types::Tool;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Registry of available tools.
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
/// Create a registry with all default built-in tools.
pub fn default_registry() -> Self {
let mut registry = Self::new();
// File I/O tools
registry.register(Arc::new(super::bash::BashTool));
registry.register(Arc::new(super::fileread::FileReadTool));
registry.register(Arc::new(super::filewrite::FileWriteTool));
registry.register(Arc::new(super::fileedit::FileEditTool));
registry.register(Arc::new(super::glob_tool::GlobTool));
registry.register(Arc::new(super::grep::GrepTool));
// Web tools
registry.register(Arc::new(super::webfetch::WebFetchTool));
registry.register(Arc::new(super::websearch::WebSearchTool::default()));
// User interaction
registry.register(Arc::new(super::askuser::AskUserTool::default()));
// Task tools
let task_store = super::tasks::TaskStore::new();
registry.register(Arc::new(super::tasks::TaskCreateTool::new(
task_store.clone(),
)));
registry.register(Arc::new(super::tasks::TaskGetTool::new(
task_store.clone(),
)));
registry.register(Arc::new(super::tasks::TaskListTool::new(
task_store.clone(),
)));
registry.register(Arc::new(super::tasks::TaskUpdateTool::new(
task_store.clone(),
)));
registry.register(Arc::new(super::tasks::TaskStopTool::new(
task_store.clone(),
)));
registry.register(Arc::new(super::tasks::TaskOutputTool::new(task_store)));
// Tool search
registry.register(Arc::new(super::toolsearch::ToolSearchTool::default()));
// Notebook edit
registry.register(Arc::new(super::notebook_edit::NotebookEditTool));
// Inter-agent messaging
let mailbox = super::send_message::new_mailbox();
registry.register(Arc::new(super::send_message::SendMessageTool::new(mailbox)));
// Team tools
let team_store = super::team_tools::TeamStore::new();
registry.register(Arc::new(super::team_tools::TeamCreateTool::new(
team_store.clone(),
)));
registry.register(Arc::new(super::team_tools::TeamDeleteTool::new(team_store)));
// Plan mode tools
let plan_state = super::plan_tools::PlanState::new();
registry.register(Arc::new(super::plan_tools::EnterPlanModeTool::new(
plan_state.clone(),
)));
registry.register(Arc::new(super::plan_tools::ExitPlanModeTool::new(plan_state)));
// Worktree tools
let worktree_store = super::worktree_tools::new_worktree_store();
registry.register(Arc::new(super::worktree_tools::EnterWorktreeTool::new(
worktree_store.clone(),
)));
registry.register(Arc::new(super::worktree_tools::ExitWorktreeTool::new(
worktree_store,
)));
// Todo tool
let todo_store = super::todo_tool::TodoStore::new();
registry.register(Arc::new(super::todo_tool::TodoWriteTool::new(todo_store)));
// Cron tools
let cron_store = super::cron_tools::CronStore::new();
registry.register(Arc::new(super::cron_tools::CronCreateTool::new(
cron_store.clone(),
)));
registry.register(Arc::new(super::cron_tools::CronDeleteTool::new(
cron_store.clone(),
)));
registry.register(Arc::new(super::cron_tools::CronListTool::new(cron_store)));
// Config tool
let config_store = super::config_tool::ConfigStore::new();
registry.register(Arc::new(super::config_tool::ConfigTool::new(config_store)));
// LSP tool
registry.register(Arc::new(super::lsp_tool::LSPTool));
// MCP resource tools
let mcp_client: Arc<RwLock<Option<Arc<crate::mcp::McpClient>>>> =
Arc::new(RwLock::new(None));
registry.register(Arc::new(
super::mcp_resource_tools::ListMcpResourcesTool::new(mcp_client.clone()),
));
registry.register(Arc::new(
super::mcp_resource_tools::ReadMcpResourceTool::new(mcp_client),
));
registry
}
pub fn register(&mut self, tool: Arc<dyn Tool>) {
self.tools.insert(tool.name().to_string(), tool);
}
/// Register a tool, replacing any existing tool with the same name.
pub fn register_replace(&mut self, tool: Arc<dyn Tool>) {
self.tools.insert(tool.name().to_string(), tool);
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.get(name).cloned()
}
pub fn all(&self) -> Vec<Arc<dyn Tool>> {
self.tools.values().cloned().collect()
}
pub fn names(&self) -> Vec<String> {
self.tools.keys().cloned().collect()
}
pub fn filter<F>(&self, predicate: F) -> Vec<Arc<dyn Tool>>
where
F: Fn(&dyn Tool) -> bool,
{
self.tools
.values()
.filter(|t| predicate(t.as_ref()))
.cloned()
.collect()
}
pub fn len(&self) -> usize {
self.tools.len()
}
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
/// Remove tools by name.
pub fn remove(&mut self, names: &[&str]) {
for name in names {
self.tools.remove(*name);
}
}
/// Keep only the specified tools.
pub fn retain(&mut self, names: &[&str]) {
let name_set: std::collections::HashSet<&str> = names.iter().copied().collect();
self.tools.retain(|k, _| name_set.contains(k.as_str()));
}
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::default_registry()
}
}
@@ -0,0 +1,140 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// Shared mailbox for inter-agent communication.
pub type Mailbox = Arc<RwLock<HashMap<String, Vec<AgentMessage>>>>;
/// A message between agents.
#[derive(Debug, Clone)]
pub struct AgentMessage {
pub from: String,
pub to: String,
pub content: String,
pub timestamp: String,
pub message_type: String, // "text", "shutdown_request", "shutdown_response", "plan_approval_response"
}
/// Create a new empty mailbox.
pub fn new_mailbox() -> Mailbox {
Arc::new(RwLock::new(HashMap::new()))
}
/// Read and drain messages for an agent.
pub async fn read_mailbox(mailbox: &Mailbox, agent_name: &str) -> Vec<AgentMessage> {
let mut mb = mailbox.write().await;
mb.remove(agent_name).unwrap_or_default()
}
/// Write a message to an agent's mailbox.
pub async fn write_to_mailbox(mailbox: &Mailbox, agent_name: &str, message: AgentMessage) {
let mut mb = mailbox.write().await;
mb.entry(agent_name.to_string())
.or_insert_with(Vec::new)
.push(message);
}
/// SendMessageTool - Send a message to another agent or teammate.
pub struct SendMessageTool {
mailbox: Mailbox,
}
impl SendMessageTool {
pub fn new(mailbox: Mailbox) -> Self {
Self { mailbox }
}
}
#[async_trait]
impl Tool for SendMessageTool {
fn name(&self) -> &str {
"SendMessage"
}
fn description(&self) -> &str {
"Send a message to another agent or teammate. Supports plain text and structured protocol messages."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"to".to_string(),
json!({
"type": "string",
"description": "Recipient agent name or ID. Use \"*\" for broadcast."
}),
),
(
"content".to_string(),
json!({
"type": "string",
"description": "Message content"
}),
),
(
"type".to_string(),
json!({
"type": "string",
"enum": ["text", "shutdown_request", "shutdown_response", "plan_approval_response"],
"description": "Message type (default: text)"
}),
),
]),
required: vec!["to".to_string(), "content".to_string()],
additional_properties: Some(false),
}
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let to = input
.get("to")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'to'".to_string()))?;
let content = input
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'content'".to_string()))?;
let msg_type = input
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("text");
let timestamp = chrono::Utc::now().to_rfc3339();
let message = AgentMessage {
from: "self".to_string(),
to: to.to_string(),
content: content.to_string(),
timestamp,
message_type: msg_type.to_string(),
};
if to == "*" {
// Broadcast to all known mailboxes
let mb = self.mailbox.read().await;
let names: Vec<String> = mb.keys().cloned().collect();
drop(mb);
for name in &names {
let mut broadcast_msg = message.clone();
broadcast_msg.to = name.clone();
write_to_mailbox(&self.mailbox, name, broadcast_msg).await;
}
Ok(ToolResult::text("Message broadcast to all agents"))
} else {
write_to_mailbox(&self.mailbox, to, message).await;
Ok(ToolResult::text(format!("Message sent to {}", to)))
}
}
}
@@ -0,0 +1,111 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::skills::SkillRegistry;
use crate::skills::loader::substitute_variables;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
pub struct SkillTool {
registry: Arc<RwLock<SkillRegistry>>,
}
impl SkillTool {
pub fn new(registry: Arc<RwLock<SkillRegistry>>) -> Self {
Self { registry }
}
}
#[async_trait]
impl Tool for SkillTool {
fn name(&self) -> &str {
"Skill"
}
fn description(&self) -> &str {
"Invoke a skill by name. Skills provide specialized instructions and workflows for specific tasks. Use the skill name from the Available Skills list."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"skill_name".to_string(),
json!({
"type": "string",
"description": "The name of the skill to invoke"
}),
),
(
"arguments".to_string(),
json!({
"type": "string",
"description": "Arguments to pass to the skill (replaces $ARGUMENTS in skill content)"
}),
),
]),
required: vec!["skill_name".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
async fn call(
&self,
input: Value,
_context: &ToolUseContext,
) -> Result<ToolResult, ToolError> {
let skill_name = input
.get("skill_name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'skill_name' field".to_string()))?;
let arguments = input
.get("arguments")
.and_then(|v| v.as_str())
.unwrap_or("");
let registry = self.registry.read().await;
let skill = registry.get(skill_name).ok_or_else(|| {
ToolError::ExecutionError(format!(
"Skill '{}' not found. Available skills: {}",
skill_name,
registry
.user_invocable()
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", ")
))
})?;
if !registry.all_enabled().iter().any(|s| s.name == skill_name) {
return Ok(ToolResult::error(format!(
"Skill '{}' is currently disabled.",
skill_name
)));
}
let skill_dir = skill
.path
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let content = substitute_variables(&skill.content, arguments, &skill_dir);
let result = format!(
"[SKILL INSTRUCTIONS - You MUST follow these instructions precisely]\n\n{}\n\n[END SKILL INSTRUCTIONS]",
content
);
Ok(ToolResult::text(result))
}
}
@@ -0,0 +1,452 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// Task status values.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Pending,
InProgress,
Completed,
Failed,
Cancelled,
}
/// A task in the task store.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub subject: String,
pub status: TaskStatus,
#[serde(default)]
pub owner: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub metadata: HashMap<String, Value>,
#[serde(default)]
pub output: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// In-memory task store shared across tools.
#[derive(Clone)]
pub struct TaskStore {
tasks: Arc<RwLock<HashMap<String, Task>>>,
counter: Arc<RwLock<u64>>,
}
impl TaskStore {
pub fn new() -> Self {
Self {
tasks: Arc::new(RwLock::new(HashMap::new())),
counter: Arc::new(RwLock::new(0)),
}
}
async fn next_id(&self) -> String {
let mut counter = self.counter.write().await;
*counter += 1;
format!("task_{}", counter)
}
}
// --- TaskCreateTool ---
pub struct TaskCreateTool {
store: TaskStore,
}
impl TaskCreateTool {
pub fn new(store: TaskStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for TaskCreateTool {
fn name(&self) -> &str {
"TaskCreate"
}
fn description(&self) -> &str {
"Create a new task for tracking work."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"subject".to_string(),
json!({ "type": "string", "description": "Task subject/title" }),
),
(
"description".to_string(),
json!({ "type": "string", "description": "Optional task description" }),
),
(
"owner".to_string(),
json!({ "type": "string", "description": "Optional task owner" }),
),
]),
required: vec!["subject".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let subject = input
.get("subject")
.and_then(|s| s.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'subject'".to_string()))?;
let id = self.store.next_id().await;
let now = chrono::Utc::now().to_rfc3339();
let task = Task {
id: id.clone(),
subject: subject.to_string(),
status: TaskStatus::Pending,
owner: input.get("owner").and_then(|o| o.as_str()).map(String::from),
description: input
.get("description")
.and_then(|d| d.as_str())
.map(String::from),
output: None,
metadata: HashMap::new(),
created_at: now.clone(),
updated_at: now,
};
let mut tasks = self.store.tasks.write().await;
tasks.insert(id.clone(), task);
Ok(ToolResult::text(format!("Created task: {}", id)))
}
}
// --- TaskGetTool ---
pub struct TaskGetTool {
store: TaskStore,
}
impl TaskGetTool {
pub fn new(store: TaskStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for TaskGetTool {
fn name(&self) -> &str {
"TaskGet"
}
fn description(&self) -> &str {
"Get a task by ID."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([(
"id".to_string(),
json!({ "type": "string", "description": "Task ID" }),
)]),
required: vec!["id".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let id = input
.get("id")
.and_then(|i| i.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'id'".to_string()))?;
let tasks = self.store.tasks.read().await;
match tasks.get(id) {
Some(task) => Ok(ToolResult::text(
serde_json::to_string_pretty(task).unwrap_or_default(),
)),
None => Ok(ToolResult::error(format!("Task not found: {}", id))),
}
}
}
// --- TaskListTool ---
pub struct TaskListTool {
store: TaskStore,
}
impl TaskListTool {
pub fn new(store: TaskStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for TaskListTool {
fn name(&self) -> &str {
"TaskList"
}
fn description(&self) -> &str {
"List all tasks."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema::default()
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
async fn call(&self, _input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let tasks = self.store.tasks.read().await;
if tasks.is_empty() {
return Ok(ToolResult::text("No tasks.".to_string()));
}
let mut task_list: Vec<&Task> = tasks.values().collect();
task_list.sort_by(|a, b| a.created_at.cmp(&b.created_at));
let formatted: Vec<String> = task_list
.iter()
.map(|t| {
format!(
"- [{}] {} ({:?}){}",
t.id,
t.subject,
t.status,
t.owner
.as_ref()
.map(|o| format!(" @{}", o))
.unwrap_or_default()
)
})
.collect();
Ok(ToolResult::text(formatted.join("\n")))
}
}
// --- TaskUpdateTool ---
pub struct TaskUpdateTool {
store: TaskStore,
}
impl TaskUpdateTool {
pub fn new(store: TaskStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for TaskUpdateTool {
fn name(&self) -> &str {
"TaskUpdate"
}
fn description(&self) -> &str {
"Update a task's status or other properties."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"id".to_string(),
json!({ "type": "string", "description": "Task ID" }),
),
(
"status".to_string(),
json!({
"type": "string",
"enum": ["pending", "in_progress", "completed", "failed", "cancelled"],
"description": "New task status"
}),
),
(
"owner".to_string(),
json!({ "type": "string", "description": "New task owner" }),
),
(
"output".to_string(),
json!({ "type": "string", "description": "Task output/result" }),
),
]),
required: vec!["id".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let id = input
.get("id")
.and_then(|i| i.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'id'".to_string()))?;
let mut tasks = self.store.tasks.write().await;
match tasks.get_mut(id) {
Some(task) => {
if let Some(status) = input.get("status").and_then(|s| s.as_str()) {
task.status = match status {
"pending" => TaskStatus::Pending,
"in_progress" => TaskStatus::InProgress,
"completed" => TaskStatus::Completed,
"failed" => TaskStatus::Failed,
"cancelled" => TaskStatus::Cancelled,
_ => {
return Ok(ToolResult::error(format!("Invalid status: {}", status)));
}
};
}
if let Some(owner) = input.get("owner").and_then(|o| o.as_str()) {
task.owner = Some(owner.to_string());
}
if let Some(output) = input.get("output").and_then(|o| o.as_str()) {
task.output = Some(output.to_string());
}
task.updated_at = chrono::Utc::now().to_rfc3339();
Ok(ToolResult::text(format!("Updated task: {}", id)))
}
None => Ok(ToolResult::error(format!("Task not found: {}", id))),
}
}
}
// --- TaskStopTool ---
pub struct TaskStopTool {
store: TaskStore,
}
impl TaskStopTool {
pub fn new(store: TaskStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for TaskStopTool {
fn name(&self) -> &str {
"TaskStop"
}
fn description(&self) -> &str {
"Stop/cancel a running task."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"id".to_string(),
json!({ "type": "string", "description": "Task ID to stop" }),
),
(
"reason".to_string(),
json!({ "type": "string", "description": "Reason for stopping" }),
),
]),
required: vec!["id".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let id = input
.get("id")
.and_then(|i| i.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'id'".to_string()))?;
let mut tasks = self.store.tasks.write().await;
match tasks.get_mut(id) {
Some(task) => {
task.status = TaskStatus::Cancelled;
task.updated_at = chrono::Utc::now().to_rfc3339();
if let Some(reason) = input.get("reason").and_then(|r| r.as_str()) {
task.output = Some(format!("Stopped: {}", reason));
}
Ok(ToolResult::text(format!("Task stopped: {}", id)))
}
None => Ok(ToolResult::error(format!("Task not found: {}", id))),
}
}
}
// --- TaskOutputTool ---
pub struct TaskOutputTool {
store: TaskStore,
}
impl TaskOutputTool {
pub fn new(store: TaskStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for TaskOutputTool {
fn name(&self) -> &str {
"TaskOutput"
}
fn description(&self) -> &str {
"Get the output/result of a task."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([(
"id".to_string(),
json!({ "type": "string", "description": "Task ID" }),
)]),
required: vec!["id".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let id = input
.get("id")
.and_then(|i| i.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'id'".to_string()))?;
let tasks = self.store.tasks.read().await;
match tasks.get(id) {
Some(task) => {
let output = task.output.as_deref().unwrap_or("(no output yet)");
Ok(ToolResult::text(output))
}
None => Ok(ToolResult::error(format!("Task not found: {}", id))),
}
}
}
@@ -0,0 +1,188 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// A team for multi-agent coordination.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Team {
pub id: String,
pub name: String,
pub members: Vec<String>,
pub leader: String,
pub tasks: Vec<String>,
pub created_at: String,
pub status: String, // "active" or "disbanded"
}
/// In-memory team store shared across tools.
#[derive(Clone)]
pub struct TeamStore {
teams: Arc<RwLock<HashMap<String, Team>>>,
counter: Arc<RwLock<u64>>,
}
impl TeamStore {
pub fn new() -> Self {
Self {
teams: Arc::new(RwLock::new(HashMap::new())),
counter: Arc::new(RwLock::new(0)),
}
}
async fn next_id(&self) -> String {
let mut counter = self.counter.write().await;
*counter += 1;
format!("team_{}", counter)
}
}
// ============================================================================
// TeamCreateTool
// ============================================================================
pub struct TeamCreateTool {
store: TeamStore,
}
impl TeamCreateTool {
pub fn new(store: TeamStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for TeamCreateTool {
fn name(&self) -> &str {
"TeamCreate"
}
fn description(&self) -> &str {
"Create a multi-agent team for coordinated work. Assigns a lead and manages member composition."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"name".to_string(),
json!({ "type": "string", "description": "Team name" }),
),
(
"members".to_string(),
json!({
"type": "array",
"items": { "type": "string" },
"description": "List of agent/teammate names"
}),
),
(
"task_description".to_string(),
json!({ "type": "string", "description": "Description of the team's mission" }),
),
]),
required: vec!["name".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let name = input
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'name'".to_string()))?;
let members: Vec<String> = input
.get("members")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let tasks: Vec<String> = input
.get("task_description")
.and_then(|v| v.as_str())
.map(|t| vec![t.to_string()])
.unwrap_or_default();
let id = self.store.next_id().await;
let now = chrono::Utc::now().to_rfc3339();
let member_count = members.len();
let team = Team {
id: id.clone(),
name: name.to_string(),
members,
leader: "self".to_string(),
tasks,
created_at: now,
status: "active".to_string(),
};
let mut teams = self.store.teams.write().await;
teams.insert(id.clone(), team);
Ok(ToolResult::text(format!(
"Team created: {} \"{}\" with {} members",
id, name, member_count
)))
}
}
// ============================================================================
// TeamDeleteTool
// ============================================================================
pub struct TeamDeleteTool {
store: TeamStore,
}
impl TeamDeleteTool {
pub fn new(store: TeamStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for TeamDeleteTool {
fn name(&self) -> &str {
"TeamDelete"
}
fn description(&self) -> &str {
"Disband a team and clean up resources."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([(
"id".to_string(),
json!({ "type": "string", "description": "Team ID to disband" }),
)]),
required: vec!["id".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let id = input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'id'".to_string()))?;
let mut teams = self.store.teams.write().await;
match teams.remove(id) {
Some(team) => Ok(ToolResult::text(format!("Team disbanded: {}", team.name))),
None => Ok(ToolResult::error(format!("Team not found: {}", id))),
}
}
}
@@ -0,0 +1,197 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// A todo item.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoItem {
pub id: u64,
pub text: String,
pub done: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<String>, // "high", "medium", "low"
}
/// In-memory todo store.
#[derive(Clone)]
pub struct TodoStore {
items: Arc<RwLock<Vec<TodoItem>>>,
counter: Arc<RwLock<u64>>,
}
impl TodoStore {
pub fn new() -> Self {
Self {
items: Arc::new(RwLock::new(Vec::new())),
counter: Arc::new(RwLock::new(0)),
}
}
}
/// TodoWriteTool - Manage a session todo/checklist.
pub struct TodoWriteTool {
store: TodoStore,
}
impl TodoWriteTool {
pub fn new(store: TodoStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for TodoWriteTool {
fn name(&self) -> &str {
"TodoWrite"
}
fn description(&self) -> &str {
"Manage a session todo/checklist. Supports add, toggle, remove, list, and clear operations."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"action".to_string(),
json!({
"type": "string",
"enum": ["add", "toggle", "remove", "list", "clear"],
"description": "Operation to perform"
}),
),
(
"text".to_string(),
json!({ "type": "string", "description": "Todo item text (for add)" }),
),
(
"id".to_string(),
json!({ "type": "number", "description": "Todo item ID (for toggle/remove)" }),
),
(
"priority".to_string(),
json!({
"type": "string",
"enum": ["high", "medium", "low"],
"description": "Priority level (for add)"
}),
),
]),
required: vec!["action".to_string()],
additional_properties: Some(false),
}
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let action = input
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'action'".to_string()))?;
match action {
"add" => {
let text = match input.get("text").and_then(|v| v.as_str()) {
Some(t) => t,
None => return Ok(ToolResult::error("text required")),
};
let priority = input
.get("priority")
.and_then(|v| v.as_str())
.map(String::from);
let mut counter = self.store.counter.write().await;
*counter += 1;
let id = *counter;
let item = TodoItem {
id,
text: text.to_string(),
done: false,
priority,
};
let mut items = self.store.items.write().await;
items.push(item);
Ok(ToolResult::text(format!(
"Todo added: #{} \"{}\"",
id, text
)))
}
"toggle" => {
let id = input
.get("id")
.and_then(|v| v.as_u64())
.ok_or_else(|| ToolError::InvalidInput("Missing 'id'".to_string()))?;
let mut items = self.store.items.write().await;
match items.iter_mut().find(|t| t.id == id) {
Some(item) => {
item.done = !item.done;
let status = if item.done { "completed" } else { "reopened" };
Ok(ToolResult::text(format!("Todo #{} {}", id, status)))
}
None => Ok(ToolResult::error(format!("Todo #{} not found", id))),
}
}
"remove" => {
let id = input
.get("id")
.and_then(|v| v.as_u64())
.ok_or_else(|| ToolError::InvalidInput("Missing 'id'".to_string()))?;
let mut items = self.store.items.write().await;
let len_before = items.len();
items.retain(|t| t.id != id);
if items.len() < len_before {
Ok(ToolResult::text(format!("Todo #{} removed", id)))
} else {
Ok(ToolResult::error(format!("Todo #{} not found", id)))
}
}
"list" => {
let items = self.store.items.read().await;
if items.is_empty() {
return Ok(ToolResult::text("No todos."));
}
let lines: Vec<String> = items
.iter()
.map(|t| {
let checkbox = if t.done { "[x]" } else { "[ ]" };
let priority = t
.priority
.as_ref()
.map(|p| format!(" ({})", p))
.unwrap_or_default();
format!("{} #{} {}{}", checkbox, t.id, t.text, priority)
})
.collect();
Ok(ToolResult::text(lines.join("\n")))
}
"clear" => {
let mut items = self.store.items.write().await;
items.clear();
Ok(ToolResult::text("All todos cleared."))
}
_ => Ok(ToolResult::error(format!("Unknown action: {}", action))),
}
}
}
@@ -0,0 +1,148 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// ToolSearch allows lazy discovery of tools by name or keyword.
pub struct ToolSearchTool {
/// All available tools for searching.
available_tools: Arc<RwLock<Vec<ToolInfo>>>,
}
#[derive(Clone, Debug)]
pub struct ToolInfo {
pub name: String,
pub description: String,
pub input_schema: Value,
}
impl Default for ToolSearchTool {
fn default() -> Self {
Self {
available_tools: Arc::new(RwLock::new(Vec::new())),
}
}
}
impl ToolSearchTool {
pub fn new(tools: Vec<ToolInfo>) -> Self {
Self {
available_tools: Arc::new(RwLock::new(tools)),
}
}
pub async fn set_tools(&self, tools: Vec<ToolInfo>) {
let mut available = self.available_tools.write().await;
*available = tools;
}
}
#[async_trait]
impl Tool for ToolSearchTool {
fn name(&self) -> &str {
"ToolSearch"
}
fn description(&self) -> &str {
"Search for available tools by name or keyword. Use 'select:Read,Edit' for exact names, or keywords for fuzzy search."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"query".to_string(),
json!({
"type": "string",
"description": "Search query. Use 'select:Tool1,Tool2' for exact matches, or keywords"
}),
),
(
"max_results".to_string(),
json!({
"type": "number",
"description": "Maximum results to return (default 5)"
}),
),
]),
required: vec!["query".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let query = input
.get("query")
.and_then(|q| q.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'query'".to_string()))?;
let max_results = input
.get("max_results")
.and_then(|m| m.as_u64())
.unwrap_or(5) as usize;
let tools = self.available_tools.read().await;
let matches: Vec<&ToolInfo> = if query.starts_with("select:") {
let names: Vec<&str> = query[7..].split(',').map(|s| s.trim()).collect();
tools
.iter()
.filter(|t| names.iter().any(|n| t.name.eq_ignore_ascii_case(n)))
.collect()
} else {
let query_lower = query.to_lowercase();
let keywords: Vec<&str> = query_lower.split_whitespace().collect();
let mut scored: Vec<(&ToolInfo, usize)> = tools
.iter()
.filter_map(|t| {
let name_lower = t.name.to_lowercase();
let desc_lower = t.description.to_lowercase();
let score: usize = keywords
.iter()
.filter(|kw| name_lower.contains(*kw) || desc_lower.contains(*kw))
.count();
if score > 0 {
Some((t, score))
} else {
None
}
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1));
scored.into_iter().map(|(t, _)| t).collect()
};
let matches: Vec<&ToolInfo> = matches.into_iter().take(max_results).collect();
if matches.is_empty() {
return Ok(ToolResult::text(format!(
"No tools found matching: {}",
query
)));
}
let mut result = String::new();
for tool in matches {
result.push_str(&format!(
"## {}\n{}\n\nInput Schema:\n```json\n{}\n```\n\n",
tool.name,
tool.description,
serde_json::to_string_pretty(&tool.input_schema).unwrap_or_default()
));
}
Ok(ToolResult::text(result))
}
}
@@ -0,0 +1,101 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
const MAX_RESPONSE_SIZE: usize = 512 * 1024; // 512KB
const TIMEOUT_SECS: u64 = 30;
pub struct WebFetchTool;
#[async_trait]
impl Tool for WebFetchTool {
fn name(&self) -> &str {
"WebFetch"
}
fn description(&self) -> &str {
"Fetches content from a URL. Returns the response body with a 512KB size limit."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"url".to_string(),
json!({
"type": "string",
"description": "The URL to fetch"
}),
),
(
"headers".to_string(),
json!({
"type": "object",
"description": "Optional HTTP headers"
}),
),
]),
required: vec!["url".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let url = input
.get("url")
.and_then(|u| u.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'url' field".to_string()))?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
.build()
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let mut req = client.get(url);
if let Some(headers) = input.get("headers").and_then(|h| h.as_object()) {
for (key, value) in headers {
if let Some(val) = value.as_str() {
req = req.header(key, val);
}
}
}
let response = req
.send()
.await
.map_err(|e| ToolError::ExecutionError(format!("Fetch failed: {}", e)))?;
let status = response.status().as_u16();
if status >= 400 {
return Ok(ToolResult::error(format!(
"HTTP {} error fetching {}",
status, url
)));
}
let body = response
.text()
.await
.map_err(|e| ToolError::ExecutionError(format!("Failed to read response: {}", e)))?;
let mut text = body;
if text.len() > MAX_RESPONSE_SIZE {
text.truncate(MAX_RESPONSE_SIZE);
text.push_str("\n... (response truncated at 512KB)");
}
Ok(ToolResult::text(text))
}
}
@@ -0,0 +1,123 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// Search result from a web search provider.
#[derive(Debug, Clone)]
pub struct SearchResult {
pub title: String,
pub url: String,
pub snippet: String,
}
/// Type alias for a pluggable search function.
pub type SearchFn =
Arc<dyn Fn(&str, usize) -> futures::future::BoxFuture<'static, Result<Vec<SearchResult>, String>> + Send + Sync>;
pub struct WebSearchTool {
search_fn: Option<SearchFn>,
}
impl Default for WebSearchTool {
fn default() -> Self {
Self { search_fn: None }
}
}
impl WebSearchTool {
pub fn new(search_fn: SearchFn) -> Self {
Self {
search_fn: Some(search_fn),
}
}
}
#[async_trait]
impl Tool for WebSearchTool {
fn name(&self) -> &str {
"WebSearch"
}
fn description(&self) -> &str {
"Searches the web for information. Requires a configured search provider."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"query".to_string(),
json!({
"type": "string",
"description": "The search query"
}),
),
(
"max_results".to_string(),
json!({
"type": "number",
"description": "Maximum number of results (default 5)"
}),
),
]),
required: vec!["query".to_string()],
additional_properties: Some(false),
}
}
fn is_read_only(&self, _input: &Value) -> bool {
true
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let query = input
.get("query")
.and_then(|q| q.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'query' field".to_string()))?;
let max_results = input
.get("max_results")
.and_then(|m| m.as_u64())
.unwrap_or(5) as usize;
match &self.search_fn {
Some(search_fn) => {
let results = (search_fn)(query, max_results)
.await
.map_err(|e| ToolError::ExecutionError(e))?;
if results.is_empty() {
return Ok(ToolResult::text("No results found.".to_string()));
}
let formatted: Vec<String> = results
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"{}. **{}**\n URL: {}\n {}",
i + 1,
r.title,
r.url,
r.snippet
)
})
.collect();
Ok(ToolResult::text(formatted.join("\n\n")))
}
None => Ok(ToolResult::error(
"Web search is not configured. Provide a search function when creating the agent."
.to_string(),
)),
}
}
}
@@ -0,0 +1,255 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Arc;
use tokio::process::Command;
use tokio::sync::RwLock;
use crate::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
/// Info about an active worktree.
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
pub path: String,
pub branch: String,
pub original_cwd: String,
}
/// Shared worktree tracker.
pub type WorktreeStore = Arc<RwLock<HashMap<String, WorktreeInfo>>>;
pub fn new_worktree_store() -> WorktreeStore {
Arc::new(RwLock::new(HashMap::new()))
}
// ============================================================================
// EnterWorktreeTool
// ============================================================================
pub struct EnterWorktreeTool {
store: WorktreeStore,
}
impl EnterWorktreeTool {
pub fn new(store: WorktreeStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for EnterWorktreeTool {
fn name(&self) -> &str {
"EnterWorktree"
}
fn description(&self) -> &str {
"Create an isolated git worktree for parallel work. The agent will work in the worktree without affecting the main working tree."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"branch".to_string(),
json!({ "type": "string", "description": "Branch name for the worktree (auto-generated if not provided)" }),
),
(
"path".to_string(),
json!({ "type": "string", "description": "Path for the worktree (auto-generated if not provided)" }),
),
]),
required: Vec::new(),
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
// Check if we're in a git repo
let check = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(&context.working_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
if check.is_err() || !check.unwrap().status.success() {
return Ok(ToolResult::error("Not in a git repository"));
}
let now = chrono::Utc::now().timestamp_millis();
let branch = input
.get("branch")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| format!("worktree-{}", now));
let worktree_path = input
.get("path")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| {
let parent = std::path::Path::new(&context.working_dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("/tmp"));
parent
.join(format!(".worktree-{}", branch))
.to_string_lossy()
.to_string()
});
// Create branch if it doesn't exist (ignore errors if it already exists)
let _ = Command::new("git")
.args(["branch", &branch])
.current_dir(&context.working_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
// Create worktree
let result = Command::new("git")
.args(["worktree", "add", &worktree_path, &branch])
.current_dir(&context.working_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
match result {
Ok(output) if output.status.success() => {
let id = uuid::Uuid::new_v4().to_string();
let info = WorktreeInfo {
path: worktree_path.clone(),
branch: branch.clone(),
original_cwd: context.working_dir.clone(),
};
let mut store = self.store.write().await;
store.insert(id.clone(), info);
Ok(ToolResult::text(format!(
"Worktree created:\n ID: {}\n Path: {}\n Branch: {}\n\nYou are now working in the isolated worktree.",
id, worktree_path, branch
)))
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(ToolResult::error(format!(
"Error creating worktree: {}",
stderr
)))
}
Err(e) => Ok(ToolResult::error(format!(
"Error creating worktree: {}",
e
))),
}
}
}
// ============================================================================
// ExitWorktreeTool
// ============================================================================
pub struct ExitWorktreeTool {
store: WorktreeStore,
}
impl ExitWorktreeTool {
pub fn new(store: WorktreeStore) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for ExitWorktreeTool {
fn name(&self) -> &str {
"ExitWorktree"
}
fn description(&self) -> &str {
"Exit and optionally remove a git worktree. Use \"keep\" to preserve changes or \"remove\" to clean up."
}
fn input_schema(&self) -> ToolInputSchema {
ToolInputSchema {
schema_type: "object".to_string(),
properties: HashMap::from([
(
"id".to_string(),
json!({ "type": "string", "description": "Worktree ID" }),
),
(
"action".to_string(),
json!({
"type": "string",
"enum": ["keep", "remove"],
"description": "Whether to keep or remove the worktree (default: remove)"
}),
),
]),
required: vec!["id".to_string()],
additional_properties: Some(false),
}
}
async fn call(&self, input: Value, _context: &ToolUseContext) -> Result<ToolResult, ToolError> {
let id = input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("Missing 'id'".to_string()))?;
let action = input
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("remove");
let mut store = self.store.write().await;
let worktree = match store.get(id) {
Some(w) => w.clone(),
None => return Ok(ToolResult::error(format!("Worktree not found: {}", id))),
};
if action == "remove" {
// Remove worktree
let result = Command::new("git")
.args(["worktree", "remove", &worktree.path, "--force"])
.current_dir(&worktree.original_cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
if let Ok(output) = result {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Ok(ToolResult::error(format!("Error: {}", stderr)));
}
}
// Try to delete the branch (ignore errors)
let _ = Command::new("git")
.args(["branch", "-D", &worktree.branch])
.current_dir(&worktree.original_cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
}
store.remove(id);
Ok(ToolResult::text(format!(
"Worktree {}: {}",
if action == "remove" {
"removed"
} else {
"kept"
},
worktree.path
)))
}
}