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:
@@ -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) = ¤t_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(®ex::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(¬ebook)
|
||||
.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
|
||||
)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user