use crate::provider::ToolSpec;
use crate::tools::{Tool, ToolSet};
use anyhow::{Context, Result};
use async_trait::async_trait;
use sapphire_workspace::WorkspaceState;
use serde_json::json;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, Weak};
fn expand_path(path_str: &str) -> PathBuf {
PathBuf::from(shellexpand::tilde(path_str).as_ref())
}
fn truncate_output(s: &str) -> String {
const MAX: usize = 50_000;
const HEAD: usize = 20_000;
const TAIL: usize = 30_000;
if s.len() <= MAX {
return s.to_string();
}
let head_end = s.floor_char_boundary(HEAD);
let tail_start = s.floor_char_boundary(s.len() - TAIL);
format!(
"{}\n\n[... {} chars truncated ...]\n\n{}",
&s[..head_end],
s.len() - HEAD - TAIL,
&s[tail_start..]
)
}
pub struct FileReadTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl FileReadTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "file_read".into(),
description: "Read a file with optional line-based pagination. \
Accepts absolute paths, ~/... paths, or workspace-relative paths \
(resolved against the workspace root). \
Returns lines prefixed with their 1-indexed line number in 'N|content' format. \
Use offset and limit for large files. \
Cannot read binary files or device paths (/dev/, /proc/)."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path — absolute, ~/..., or relative to the workspace root."
},
"offset": {
"type": "integer",
"description": "1-indexed line number to start reading from (default: 1).",
"default": 1,
"minimum": 1
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to read (default: 500, max: 2000).",
"default": 500,
"maximum": 2000
}
},
"required": ["path"]
}),
},
}
}
}
#[async_trait]
impl Tool for FileReadTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let path_str = input["path"].as_str().context("missing 'path'")?;
let offset = input["offset"].as_u64().unwrap_or(1).max(1) as usize;
let limit = input["limit"].as_u64().unwrap_or(500).min(2000) as usize;
let path = expand_path(path_str);
let path_abs = path.to_string_lossy();
if path_abs.starts_with("/dev/") || path_abs.starts_with("/proc/") {
anyhow::bail!("Reading device or proc paths is not allowed.");
}
let content = self
.state
.lock()
.expect("WorkspaceState mutex poisoned")
.read_file(&path)
.with_context(|| format!("Failed to read '{}'", path.display()))?;
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
let start = offset.saturating_sub(1);
if start >= total_lines && total_lines > 0 {
anyhow::bail!(
"offset {} exceeds file length ({} lines)",
offset,
total_lines
);
}
let end = (start + limit).min(total_lines);
let mut result = lines[start..end]
.iter()
.enumerate()
.map(|(i, l)| format!("{}|{}", start + i + 1, l))
.collect::<Vec<_>>()
.join("\n");
if end < total_lines {
result.push_str(&format!(
"\n[{} more lines — use offset={} to continue]",
total_lines - end,
end + 1
));
}
Ok(result)
}
}
static SENSITIVE_PREFIXES: &[&str] = &[
"/etc/",
"/boot/",
"/usr/lib/",
"/usr/bin/",
"/usr/sbin/",
"/bin/",
"/sbin/",
"/sys/",
"/proc/",
"/run/docker.sock",
"/var/run/docker.sock",
];
pub struct FileWriteTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl FileWriteTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "file_write".into(),
description: "Write content to a file, completely replacing its existing content. \
Accepts absolute paths, ~/... paths, or workspace-relative paths \
(resolved against the workspace root). \
Creates the file and any missing parent directories automatically. \
When the target file is inside the workspace, the search index and git sync \
are updated automatically. \
Refuses writes to sensitive system paths (/etc, /boot, /bin, etc.)."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path — absolute, ~/..., or relative to the workspace root."
},
"content": {
"type": "string",
"description": "Complete content to write to the file (overwrites entirely)."
}
},
"required": ["path", "content"]
}),
},
}
}
}
#[async_trait]
impl Tool for FileWriteTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let path_str = input["path"].as_str().context("missing 'path'")?;
let content = input["content"].as_str().context("missing 'content'")?;
let path = expand_path(path_str);
let path_abs = path.to_string_lossy().to_string();
for prefix in SENSITIVE_PREFIXES {
if path_abs.starts_with(prefix) || &path_abs == prefix {
anyhow::bail!(
"Writing to '{}' is not allowed (sensitive system path).",
path_abs
);
}
}
self.state
.lock()
.expect("WorkspaceState mutex poisoned")
.write_file(&path, content)
.with_context(|| format!("Failed to write '{}'", path.display()))?;
Ok(format!(
"Written: {} ({} bytes)",
path.display(),
content.len()
))
}
}
pub struct FileDeleteTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl FileDeleteTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "file_delete".into(),
description: "Delete a file from the filesystem. \
Accepts absolute paths, ~/... paths, or workspace-relative paths \
(resolved against the workspace root). \
When the file is inside the workspace, it is also removed from the search index and git sync \
automatically. \
Cannot delete directories.".into(),
input_schema: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path — absolute, ~/..., or relative to the workspace root."
}
},
"required": ["path"]
}),
},
}
}
}
#[async_trait]
impl Tool for FileDeleteTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let path_str = input["path"].as_str().context("missing 'path'")?;
let path = expand_path(path_str);
self.state
.lock()
.expect("WorkspaceState mutex poisoned")
.delete_file(&path)
.with_context(|| format!("Failed to delete '{}'", path.display()))?;
Ok(format!("Deleted: {}", path.display()))
}
}
pub struct FileAppendTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl FileAppendTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "file_append".into(),
description: "Append content to the end of a file, creating it if missing. \
Accepts absolute paths, ~/... paths, or workspace-relative paths \
(resolved against the workspace root). \
Creates any missing parent directories automatically. \
When the target file is inside the workspace, the search index and git sync \
are updated automatically. \
Refuses writes to sensitive system paths (/etc, /boot, /bin, etc.)."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path — absolute, ~/..., or relative to the workspace root."
},
"content": {
"type": "string",
"description": "Content to append to the end of the file."
}
},
"required": ["path", "content"]
}),
},
}
}
}
#[async_trait]
impl Tool for FileAppendTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let path_str = input["path"].as_str().context("missing 'path'")?;
let content = input["content"].as_str().context("missing 'content'")?;
let path = expand_path(path_str);
let path_abs = path.to_string_lossy().to_string();
for prefix in SENSITIVE_PREFIXES {
if path_abs.starts_with(prefix) || &path_abs == prefix {
anyhow::bail!(
"Writing to '{}' is not allowed (sensitive system path).",
path_abs
);
}
}
self.state
.lock()
.expect("WorkspaceState mutex poisoned")
.append_file(&path, content)
.with_context(|| format!("Failed to append to '{}'", path.display()))?;
Ok(format!(
"Appended: {} (+{} bytes)",
path.display(),
content.len()
))
}
}
pub struct DirListTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl DirListTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "dir_list".into(),
description: "List the direct children of a directory (non-recursive). \
Accepts absolute paths, ~/... paths, or workspace-relative paths. \
Entries are sorted alphabetically. Directories are shown with a \
trailing slash. For deeper exploration, use dir_walk."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path — absolute, ~/..., or relative to the workspace root."
}
},
"required": ["path"]
}),
},
}
}
}
#[async_trait]
impl Tool for DirListTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let path_str = input["path"].as_str().context("missing 'path'")?;
let path = expand_path(path_str);
let entries = self
.state
.lock()
.expect("WorkspaceState mutex poisoned")
.list_dir(&path)
.with_context(|| format!("Failed to list '{}'", path.display()))?;
if entries.is_empty() {
return Ok(format!("(empty) {}", path.display()));
}
let lines: Vec<String> = entries
.iter()
.map(|(p, is_dir)| {
if *is_dir {
format!("{}/", p.display())
} else {
p.display().to_string()
}
})
.collect();
Ok(lines.join("\n"))
}
}
pub struct DirWalkTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl DirWalkTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "dir_walk".into(),
description: "Recursively list all files and directories under a path. \
Accepts absolute paths, ~/... paths, or workspace-relative paths. \
Output is a sorted flat list; directories carry a trailing slash. \
Bounded by max_depth (default 5) and max_entries (default 500) to \
avoid runaway walks into large trees."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path — absolute, ~/..., or relative to the workspace root."
},
"max_depth": {
"type": "integer",
"description": "Maximum recursion depth (default 5, max 20). 0 = only direct children.",
"default": 5,
"minimum": 0,
"maximum": 20
},
"max_entries": {
"type": "integer",
"description": "Maximum number of entries to return before truncating (default 500, max 5000).",
"default": 500,
"minimum": 1,
"maximum": 5000
}
},
"required": ["path"]
}),
},
}
}
}
fn walk_recurse(
state: &WorkspaceState,
workspace_root: &std::path::Path,
abs_path: &std::path::Path,
depth: usize,
max_depth: usize,
max_entries: usize,
results: &mut Vec<(PathBuf, bool)>,
) -> Result<bool> {
let entries = state.list_dir(abs_path)?;
for (entry_path, is_dir) in entries {
if results.len() >= max_entries {
return Ok(true);
}
results.push((entry_path.clone(), is_dir));
if is_dir && depth < max_depth {
let abs_next = if entry_path.is_absolute() {
entry_path
} else {
workspace_root.join(&entry_path)
};
if walk_recurse(
state,
workspace_root,
&abs_next,
depth + 1,
max_depth,
max_entries,
results,
)? {
return Ok(true);
}
}
}
Ok(false)
}
#[async_trait]
impl Tool for DirWalkTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let path_str = input["path"].as_str().context("missing 'path'")?;
let max_depth = input["max_depth"].as_u64().unwrap_or(5).min(20) as usize;
let max_entries = input["max_entries"].as_u64().unwrap_or(500).clamp(1, 5000) as usize;
let path = expand_path(path_str);
let state = self.state.lock().expect("WorkspaceState mutex poisoned");
let workspace_root = state.workspace.root.clone();
let mut results: Vec<(PathBuf, bool)> = Vec::new();
let truncated = walk_recurse(
&state,
&workspace_root,
&path,
0,
max_depth,
max_entries,
&mut results,
)
.with_context(|| format!("Failed to walk '{}'", path.display()))?;
drop(state);
if results.is_empty() {
return Ok(format!("(empty) {}", path.display()));
}
let mut out: Vec<String> = results
.iter()
.map(|(p, is_dir)| {
if *is_dir {
format!("{}/", p.display())
} else {
p.display().to_string()
}
})
.collect();
if truncated {
out.push(format!(
"[truncated — more than {max_entries} entries; raise max_entries or narrow path]"
));
}
Ok(out.join("\n"))
}
}
const FALLBACK_SHELL: &str = "/bin/sh";
pub struct ShellTool {
workspace_root: PathBuf,
spec: ToolSpec,
}
impl ShellTool {
pub fn new(workspace_root: PathBuf) -> Self {
Self {
workspace_root,
spec: ToolSpec {
name: "shell".into(),
description: "Execute a shell command and return its output. \
Returns stdout, stderr, and exit code. \
The default working directory is the workspace root. \
By default the command runs under the shell named by the \
`$SHELL` environment variable (falling back to `/bin/sh`); \
override per call with the `shell` parameter (e.g. `bash`, \
`zsh`, `fish`, or an absolute path). \
Use the timeout parameter for long-running commands (default 60 s, max 600 s). \
Not suitable for interactive commands or persistent daemons."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute (run via `<shell> -c`)."
},
"shell": {
"type": "string",
"description": "Shell executable to run the command with — a name resolved via PATH (e.g. `bash`, `zsh`, `fish`) or an absolute path. Defaults to `$SHELL`, or `/bin/sh` if unset."
},
"timeout": {
"type": "integer",
"description": "Max seconds to wait before killing the process (default: 60, max: 600).",
"default": 60,
"minimum": 1,
"maximum": 600
},
"workdir": {
"type": "string",
"description": "Working directory (absolute or ~/... path). Defaults to the workspace root."
}
},
"required": ["command"]
}),
},
}
}
}
#[async_trait]
impl Tool for ShellTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
use std::time::Duration;
use tokio::process::Command;
let command = input["command"].as_str().context("missing 'command'")?;
let timeout_secs = input["timeout"].as_u64().unwrap_or(60).min(600);
let workdir = input["workdir"]
.as_str()
.map(expand_path)
.unwrap_or_else(|| self.workspace_root.clone());
let shell = input["shell"]
.as_str()
.map(|s| s.to_string())
.or_else(|| std::env::var("SHELL").ok())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| FALLBACK_SHELL.to_string());
let mut cmd = Command::new(&shell);
cmd.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.current_dir(&workdir);
let child = cmd
.spawn()
.with_context(|| format!("Failed to spawn shell '{shell}'"))?;
let pid = child.id();
let result =
tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait_with_output()).await;
match result {
Ok(Ok(output)) => {
let stdout = truncate_output(&String::from_utf8_lossy(&output.stdout));
let stderr = truncate_output(&String::from_utf8_lossy(&output.stderr));
let exit_code = output.status.code().unwrap_or(-1);
if stderr.is_empty() {
Ok(format!("[exit: {exit_code}]\n{stdout}"))
} else {
Ok(format!(
"[exit: {exit_code}]\nstdout:\n{stdout}\nstderr:\n{stderr}"
))
}
}
Ok(Err(e)) => Err(e.into()),
Err(_) => {
if let Some(pid) = pid {
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
}
Ok(format!(
"[exit: 124]\nCommand timed out after {timeout_secs}s"
))
}
}
}
}
pub struct WeatherTool {
spec: ToolSpec,
}
impl WeatherTool {
pub fn new() -> Self {
Self {
spec: ToolSpec {
name: "weather".into(),
description: "Fetch a weather forecast via the Open-Meteo API \
(no API key required). Resolve a place by passing `location` \
(e.g. \"Tokyo\", \"渋谷\", \"Paris, FR\") — it is geocoded \
into coordinates — or specify `latitude` and `longitude` \
directly to skip geocoding. Returns the current conditions \
and a daily forecast (min/max temperature, precipitation, \
weather code) for the next `days` days (default 3, max 7). \
Temperatures are in Celsius; timezone auto-detected from \
the resolved coordinates."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "Place name to geocode (e.g. \"Tokyo\", \"New York\"). Ignored if latitude and longitude are provided."
},
"latitude": {
"type": "number",
"description": "Latitude in decimal degrees (-90..90). If set, longitude is required and location is ignored."
},
"longitude": {
"type": "number",
"description": "Longitude in decimal degrees (-180..180). If set, latitude is required and location is ignored."
},
"days": {
"type": "integer",
"description": "Number of forecast days to return, starting today (default 3, max 7).",
"default": 3,
"minimum": 1,
"maximum": 7
}
}
}),
},
}
}
}
impl Default for WeatherTool {
fn default() -> Self {
Self::new()
}
}
fn wmo_code_description(code: i64) -> &'static str {
match code {
0 => "clear sky",
1 => "mainly clear",
2 => "partly cloudy",
3 => "overcast",
45 => "fog",
48 => "depositing rime fog",
51 => "light drizzle",
53 => "moderate drizzle",
55 => "dense drizzle",
56 => "light freezing drizzle",
57 => "dense freezing drizzle",
61 => "slight rain",
63 => "moderate rain",
65 => "heavy rain",
66 => "light freezing rain",
67 => "heavy freezing rain",
71 => "slight snow fall",
73 => "moderate snow fall",
75 => "heavy snow fall",
77 => "snow grains",
80 => "slight rain showers",
81 => "moderate rain showers",
82 => "violent rain showers",
85 => "slight snow showers",
86 => "heavy snow showers",
95 => "thunderstorm",
96 => "thunderstorm with slight hail",
99 => "thunderstorm with heavy hail",
_ => "unknown conditions",
}
}
#[async_trait]
impl Tool for WeatherTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let client = reqwest::Client::new();
let days = input["days"].as_u64().unwrap_or(3).clamp(1, 7);
let (latitude, longitude, resolved_name) =
match (input["latitude"].as_f64(), input["longitude"].as_f64()) {
(Some(lat), Some(lon)) => {
if !(-90.0..=90.0).contains(&lat) {
anyhow::bail!("latitude {lat} out of range (-90..90)");
}
if !(-180.0..=180.0).contains(&lon) {
anyhow::bail!("longitude {lon} out of range (-180..180)");
}
(lat, lon, format!("{lat:.4}, {lon:.4}"))
}
(Some(_), None) | (None, Some(_)) => {
anyhow::bail!("latitude and longitude must be provided together");
}
(None, None) => {
let location = input["location"]
.as_str()
.context("provide either 'location' or both 'latitude' and 'longitude'")?;
let geo_resp = client
.get("https://geocoding-api.open-meteo.com/v1/search")
.query(&[
("name", location),
("count", "1"),
("language", "en"),
("format", "json"),
])
.send()
.await
.context("Open-Meteo geocoding request failed")?;
if !geo_resp.status().is_success() {
let status = geo_resp.status();
let body = geo_resp.text().await.unwrap_or_default();
anyhow::bail!("Open-Meteo geocoding error {status}: {body}");
}
let geo: serde_json::Value = geo_resp
.json()
.await
.context("Failed to parse Open-Meteo geocoding response")?;
let result = geo["results"]
.as_array()
.and_then(|arr| arr.first())
.with_context(|| format!("No matches for location '{location}'"))?;
let lat = result["latitude"]
.as_f64()
.context("geocoding result missing 'latitude'")?;
let lon = result["longitude"]
.as_f64()
.context("geocoding result missing 'longitude'")?;
let name = result["name"].as_str().unwrap_or(location);
let admin = result["admin1"].as_str().unwrap_or("");
let country = result["country"].as_str().unwrap_or("");
let pretty = [name, admin, country]
.iter()
.filter(|s| !s.is_empty())
.copied()
.collect::<Vec<_>>()
.join(", ");
(lat, lon, pretty)
}
};
let lat_s = latitude.to_string();
let lon_s = longitude.to_string();
let days_s = days.to_string();
let forecast_resp = client
.get("https://api.open-meteo.com/v1/forecast")
.query(&[
("latitude", lat_s.as_str()),
("longitude", lon_s.as_str()),
("current", "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m"),
(
"daily",
"weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max",
),
("timezone", "auto"),
("forecast_days", days_s.as_str()),
])
.send()
.await
.context("Open-Meteo forecast request failed")?;
if !forecast_resp.status().is_success() {
let status = forecast_resp.status();
let body = forecast_resp.text().await.unwrap_or_default();
anyhow::bail!("Open-Meteo forecast error {status}: {body}");
}
let data: serde_json::Value = forecast_resp
.json()
.await
.context("Failed to parse Open-Meteo forecast response")?;
let timezone = data["timezone"].as_str().unwrap_or("UTC");
let mut out = format!("Weather for {resolved_name} ({timezone})\n");
if let Some(current) = data.get("current") {
let temp = current["temperature_2m"].as_f64();
let feels = current["apparent_temperature"].as_f64();
let humidity = current["relative_humidity_2m"].as_f64();
let precip = current["precipitation"].as_f64();
let wind = current["wind_speed_10m"].as_f64();
let code = current["weather_code"].as_i64().unwrap_or(-1);
let time = current["time"].as_str().unwrap_or("");
out.push_str(&format!(
"\nCurrent ({time}): {}\n",
wmo_code_description(code)
));
if let Some(t) = temp {
out.push_str(&format!(" temp: {t:.1}°C"));
if let Some(f) = feels {
out.push_str(&format!(" (feels {f:.1}°C)"));
}
out.push('\n');
}
if let Some(h) = humidity {
out.push_str(&format!(" humidity: {h:.0}%\n"));
}
if let Some(p) = precip {
out.push_str(&format!(" precipitation: {p:.1} mm\n"));
}
if let Some(w) = wind {
out.push_str(&format!(" wind: {w:.1} km/h\n"));
}
}
if let Some(daily) = data.get("daily") {
let dates = daily["time"].as_array();
let codes = daily["weather_code"].as_array();
let tmax = daily["temperature_2m_max"].as_array();
let tmin = daily["temperature_2m_min"].as_array();
let psum = daily["precipitation_sum"].as_array();
let pprob = daily["precipitation_probability_max"].as_array();
if let Some(dates) = dates {
out.push_str("\nForecast:\n");
for (i, date) in dates.iter().enumerate() {
let date = date.as_str().unwrap_or("");
let code = codes
.and_then(|a| a.get(i))
.and_then(|v| v.as_i64())
.unwrap_or(-1);
let hi = tmax.and_then(|a| a.get(i)).and_then(|v| v.as_f64());
let lo = tmin.and_then(|a| a.get(i)).and_then(|v| v.as_f64());
let pp = psum.and_then(|a| a.get(i)).and_then(|v| v.as_f64());
let pr = pprob.and_then(|a| a.get(i)).and_then(|v| v.as_f64());
out.push_str(&format!(" {date}: {}", wmo_code_description(code)));
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push_str(&format!(", {lo:.1}°C / {hi:.1}°C"));
}
if let Some(pp) = pp {
out.push_str(&format!(", precip {pp:.1} mm"));
}
if let Some(pr) = pr {
out.push_str(&format!(" ({pr:.0}%)"));
}
out.push('\n');
}
}
}
Ok(out)
}
}
pub struct WebSearchTool {
api_key: String,
spec: ToolSpec,
}
impl WebSearchTool {
pub fn new(api_key: String) -> Self {
Self {
api_key,
spec: ToolSpec {
name: "web_search".into(),
description: "Search the web for up-to-date information using Tavily. \
Returns titles, URLs, and short content excerpts for the top results."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query."
},
"limit": {
"type": "integer",
"description": "Number of results to return (default: 5, max: 10).",
"default": 5,
"maximum": 10
}
},
"required": ["query"]
}),
},
}
}
}
#[async_trait]
impl Tool for WebSearchTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let query = input["query"].as_str().context("missing 'query'")?;
let limit = input["limit"].as_u64().unwrap_or(5).min(10) as usize;
let client = reqwest::Client::new();
let resp = client
.post("https://api.tavily.com/search")
.json(&json!({
"api_key": self.api_key,
"query": query,
"max_results": limit,
}))
.send()
.await
.context("Tavily API request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Tavily API error {status}: {body}");
}
let data: serde_json::Value = resp
.json()
.await
.context("Failed to parse Tavily response")?;
let results = data["results"]
.as_array()
.context("Unexpected Tavily response format (missing 'results')")?;
if results.is_empty() {
return Ok("No results found.".to_string());
}
let lines: Vec<String> = results
.iter()
.map(|r| {
let title = r["title"].as_str().unwrap_or("(no title)");
let url = r["url"].as_str().unwrap_or("");
let content = r["content"].as_str().unwrap_or("");
let snippet = if content.len() > 300 {
&content[..content.floor_char_boundary(300)]
} else {
content
};
format!("**{title}**\n{url}\n{snippet}")
})
.collect();
Ok(lines.join("\n\n"))
}
}
pub struct McpReconnectTool {
spec: ToolSpec,
tool_set: Weak<ToolSet>,
}
impl McpReconnectTool {
pub fn new(tool_set: Weak<ToolSet>) -> Self {
Self {
spec: ToolSpec {
name: "mcp_reconnect".into(),
description:
"Reconnect to a configured MCP server (stdio or HTTP) and refresh its tool list. \
Use this when an MCP server has crashed, disconnected, or is being restarted \
during testing — tools registered under `mcp__<server>__*` become usable again \
without restarting the agent."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"server": {
"type": "string",
"description": "Name of the MCP server to reconnect (as configured in tools.mcp_servers)."
}
},
"required": ["server"]
}),
},
tool_set,
}
}
}
#[async_trait]
impl Tool for McpReconnectTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let server = input
.get("server")
.and_then(|v| v.as_str())
.context("Missing required field: server")?;
let tool_set = self
.tool_set
.upgrade()
.context("ToolSet has been dropped; cannot reconnect")?;
let known = tool_set.mcp_server_names();
if !known.iter().any(|n| n == server) {
anyhow::bail!(
"unknown MCP server '{server}'. Configured servers: [{}]",
known.join(", ")
);
}
tool_set.reconnect_mcp_server(server).await
}
}