use anyhow::Context;
pub mod ask;
pub mod browser;
pub mod edit;
pub mod image_gen;
pub mod path;
pub mod read;
pub mod search;
pub mod search_archived_tickets;
pub mod shell;
pub mod ticket;
pub mod video_gen;
pub mod web_search;
pub(crate) const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
const MAX_REFERENCE_IMAGE_BYTES: u64 = 1_500_000;
const PATH_ALIAS_KEYS: &[&str] = &["file", "filename"];
fn check_file_size(meta: &std::fs::Metadata) -> anyhow::Result<()> {
if meta.len() > MAX_FILE_SIZE_BYTES {
anyhow::bail!(
"File too large: {} bytes (limit: {} bytes)",
meta.len(),
MAX_FILE_SIZE_BYTES
);
}
Ok(())
}
pub use ask::{AskTool, DispatchMode};
pub use browser::BrowserTool;
pub use edit::EditTool;
pub use image_gen::ImageGenTool;
pub use read::ReadTool;
pub use search::SearchTool;
pub use search_archived_tickets::SearchArchivedTicketsTool;
pub use shell::{ShellMode, ShellTool};
pub use ticket::{
AddCommentTool, CreateTicketTool, GetTicketTool, ListTicketsTool, UpdateTicketTool,
};
pub use video_gen::VideoGenTool;
pub use web_search::{WebSearchBackend, WebSearchTool};
use crate::{Tool, Workspace};
use std::path::PathBuf;
use crate::util::json::{
get_bool, get_opt_bool, get_opt_i64, get_opt_str, get_opt_u64, get_str, get_str_array,
get_usize,
};
#[must_use]
fn find_path_arg(args: &serde_json::Value) -> Option<&str> {
args.get("path")?.as_str()
}
fn require_path_arg(args: &serde_json::Value) -> anyhow::Result<String> {
find_path_arg(args).map(ToString::to_string).ok_or_else(|| {
anyhow::anyhow!(
"Missing required field: 'path'. \
Example: {{\"path\": \"src/main.rs\"}}"
)
})
}
#[must_use]
fn path_contains_wildcard(path: &str) -> bool {
path.contains(['*', '?', '[', ']'])
}
#[must_use]
fn tool_params_schema(properties: &serde_json::Value, required: &[&str]) -> serde_json::Value {
let mut schema = serde_json::json!({
"type": "object",
"properties": properties,
});
if !required.is_empty() {
schema["required"] = serde_json::json!(required);
}
schema
}
use crate::util::scrub_credentials;
#[must_use]
pub(crate) fn scrub_tool_output(
tool: &dyn Tool,
call_arguments: &serde_json::Value,
output: &str,
) -> String {
if tool.should_scrub_output(call_arguments) {
scrub_credentials(output)
} else {
output.to_string()
}
}
#[must_use]
pub(crate) fn format_tool_failure_feedback(
tool_name: &str,
tool_args: &serde_json::Value,
reason: &str,
) -> String {
let args_preview = scrub_credentials(&crate::util::truncate(&tool_args.to_string(), 1000));
format!(
"Tool call failed.\n\
tool: {tool_name}\n\
arguments: {args_preview}\n\
reason:\n{reason}"
)
}
#[derive(Debug, Clone)]
pub(crate) struct ToolExecutionOutcome {
pub output: String,
pub success: bool,
}
#[must_use]
pub(crate) fn normalize_tool_call(
name: &str,
mut args: serde_json::Value,
) -> (String, serde_json::Value) {
if name == "glob"
&& let Some(obj) = args.as_object_mut()
&& !obj.contains_key("mode")
{
obj.insert("mode".to_string(), serde_json::json!("files"));
}
let normalized_name = normalize_tool_name(name).to_string();
normalize_tool_arguments(&normalized_name, &mut args);
(normalized_name, args)
}
fn normalize_tool_name(name: &str) -> &str {
match name {
"bash" | "run_terminal_cmd" => "shell",
"grep" | "rg" | "grep_search" | "glob" => "search",
"read_file" => "read",
"str_replace" => "edit",
_ => name,
}
}
fn normalize_tool_arguments(name: &str, args: &mut serde_json::Value) {
let Some(obj) = args.as_object_mut() else {
return;
};
match name {
"shell" => {
remap_arg_key(obj, "cmd", "command");
remap_arg_key(obj, "script", "command");
}
"get_ticket" | "update_ticket" | "add_comment" => {
remap_arg_key(obj, "id", "ticket_id");
remap_arg_key(obj, "ticket", "ticket_id");
}
"read" | "edit" => {
for &alias in PATH_ALIAS_KEYS {
remap_arg_key(obj, alias, "path");
}
if name == "edit" {
remap_arg_key(obj, "old_str", "old_string");
remap_arg_key(obj, "new_str", "new_string");
}
}
_ => {}
}
}
fn remap_arg_key(obj: &mut serde_json::Map<String, serde_json::Value>, from: &str, to: &str) {
if !obj.contains_key(to)
&& let Some(v) = obj.remove(from)
{
obj.insert(to.to_string(), v);
}
}
#[must_use]
pub(crate) fn find_tool<'a>(tools: &'a [Box<dyn Tool>], name: &str) -> Option<&'a dyn Tool> {
let normalized = normalize_tool_name(name);
tools
.iter()
.find(|t| t.name() == normalized)
.map(Box::as_ref)
}
async fn save_generated_file(
ws: &Workspace,
bytes: &[u8],
prefix: &str,
ext: &str,
) -> anyhow::Result<PathBuf> {
let generated_dir = ws.as_path().join("generated");
tokio::fs::create_dir_all(&generated_dir)
.await
.with_context(|| {
format!(
"Failed to create generated directory at {}",
generated_dir.display()
)
})?;
let timestamp = crate::util::unix_millis();
let output_path = generated_dir.join(format!("{prefix}_{timestamp}.{ext}"));
tokio::fs::write(&output_path, bytes)
.await
.with_context(|| {
format!(
"Failed to write generated file to {}",
output_path.display()
)
})?;
Ok(output_path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Tool;
use crate::ToolSpec;
use crate::workspace::test_ws_named;
use tempfile::TempDir;
#[test]
fn tool_spec_serde_roundtrip() {
let spec = ToolSpec {
name: "test".into(),
description: "A test tool".into(),
parameters: serde_json::json!({"type": "object"}),
};
let parsed: ToolSpec =
serde_json::from_str(&serde_json::to_string(&spec).unwrap()).unwrap();
assert_eq!(parsed.name, "test");
}
#[test]
fn find_tool_aliases() {
let tools: Vec<Box<dyn Tool>> = vec![
Box::new(SearchTool),
Box::new(ShellTool::new(ShellMode::Full)),
Box::new(ReadTool),
Box::new(EditTool),
];
let cases: &[(&str, Option<&str>)] = &[
("search", Some("search")),
("shell", Some("shell")),
("read", Some("read")),
("edit", Some("edit")),
("bash", Some("shell")),
("run_terminal_cmd", Some("shell")),
("grep", Some("search")),
("rg", Some("search")),
("grep_search", Some("search")),
("glob", Some("search")),
("read_file", Some("read")),
("str_replace", Some("edit")),
("unknown", None),
];
for &(input, expected) in cases {
let found = find_tool(&tools, input);
assert_eq!(found.map(Tool::name), expected, "find_tool({input:?})");
}
}
#[test]
fn path_contains_wildcard_detects_globs() {
assert!(path_contains_wildcard("src/*.rs"));
assert!(path_contains_wildcard("lib?.rs"));
assert!(!path_contains_wildcard("src/main.rs"));
}
#[test]
fn normalize_tool_call_repairs_names_and_args() {
let (name, args) = normalize_tool_call("bash", serde_json::json!({"cmd": "echo hi"}));
assert_eq!(name, "shell");
assert_eq!(args["command"], "echo hi");
let (name, args) = normalize_tool_call("glob", serde_json::json!({"query": "main.rs"}));
assert_eq!(name, "search");
assert_eq!(args["mode"], "files");
let (name, args) = normalize_tool_call("get_ticket", serde_json::json!({"id": "mahbot-1"}));
assert_eq!(name, "get_ticket");
assert_eq!(args["ticket_id"], "mahbot-1");
let (name, args) = normalize_tool_call("read", serde_json::json!({"file": "src/main.rs"}));
assert_eq!(name, "read");
assert_eq!(args["path"], "src/main.rs");
let (name, args) =
normalize_tool_call("read_file", serde_json::json!({"filename": "lib.rs"}));
assert_eq!(name, "read");
assert_eq!(args["path"], "lib.rs");
let (name, args) = normalize_tool_call(
"edit",
serde_json::json!({"file": "main.rs", "old_str": "foo", "new_str": "bar"}),
);
assert_eq!(name, "edit");
assert_eq!(args["path"], "main.rs");
assert_eq!(args["old_string"], "foo");
assert_eq!(args["new_string"], "bar");
let (name, args) = normalize_tool_call(
"read",
serde_json::json!({"path": "canonical.rs", "file": "alias.rs"}),
);
assert_eq!(name, "read");
assert_eq!(args["path"], "canonical.rs");
assert!(args.as_object().unwrap().contains_key("file"));
}
#[test]
fn all_media_tools_implement_media_marker() {
let tools: [(&str, Box<dyn Tool>); 2] = [
("ImageGenTool", Box::new(ImageGenTool)),
("VideoGenTool", Box::new(VideoGenTool)),
];
for (name, tool) in &tools {
let marker = tool.media_marker();
assert!(
marker.is_some(),
"{name} should return Some from media_marker()"
);
let marker = marker.unwrap();
assert!(
marker.starts_with('['),
"{name} marker {marker:?} should start with '['"
);
assert!(
marker.ends_with(':'),
"{name} marker {marker:?} should end with ':'"
);
let kind = &marker[1..marker.len() - 1]; assert!(
!kind.is_empty() && kind.chars().all(char::is_uppercase),
"{name} marker kind {kind:?} should be non-empty uppercase letters"
);
let full_marker = format!("{marker}/some/path]");
assert!(
crate::util::MEDIA_MARKER_RE.is_match(&full_marker),
"{name} marker + path should match MEDIA_MARKER_RE, got: {full_marker:?}"
);
}
}
#[test]
fn require_path_arg_path_lookup() {
assert_eq!(
require_path_arg(&serde_json::json!({"path": "src/main.rs"})).unwrap(),
"src/main.rs"
);
let err = require_path_arg(&serde_json::json!({"path": ["invalid"]})).unwrap_err();
assert!(err.to_string().contains("path"));
let err = require_path_arg(&serde_json::json!({"other": "value"})).unwrap_err();
assert!(err.to_string().contains("path"));
}
#[test]
fn normalize_tool_call_remaps_all_path_aliases() {
for &alias in PATH_ALIAS_KEYS {
for (tool_name, extra) in &[
("read", serde_json::json!({})),
("edit", serde_json::json!({"old_str": "a", "new_str": "b"})),
] {
let mut input = serde_json::json!({});
input[alias] = serde_json::json!("src/main.rs");
if let Some(obj) = extra.as_object() {
for (k, v) in obj {
input[k] = v.clone();
}
}
let (name, args) = normalize_tool_call(tool_name, input);
assert_eq!(
name, *tool_name,
"tool name should not change for {tool_name} with alias {alias}"
);
assert_eq!(
args["path"], "src/main.rs",
"alias {alias} should be remapped to 'path' for tool {tool_name}"
);
assert!(
!args.as_object().unwrap().contains_key(alias),
"alias key {alias} should be removed after normalization for {tool_name}"
);
if *tool_name == "edit" {
assert_eq!(args["old_string"], "a");
assert_eq!(args["new_string"], "b");
}
}
}
}
#[tokio::test]
async fn save_generated_file_creates_file() {
let tmp = TempDir::new().expect("tempdir");
let ws = test_ws_named(&tmp.path().to_string_lossy(), "test");
let data = b"hello world";
let path = save_generated_file(&ws, data, "img", "png")
.await
.expect("save_generated_file should succeed");
assert!(path.exists(), "file should exist: {}", path.display());
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "hello world");
let file_name = path.file_name().unwrap().to_str().unwrap();
assert!(
file_name.starts_with("img_"),
"filename should start with 'img_': {file_name}",
);
assert!(
std::path::Path::new(file_name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("png")),
"filename should end with '.png': {file_name}",
);
let _ = tokio::fs::remove_dir_all(tmp.path()).await;
}
#[tokio::test]
async fn save_generated_file_creates_directory_if_missing() {
let tmp = TempDir::new().expect("tempdir");
let ws = test_ws_named(&tmp.path().join("nested").to_string_lossy(), "test");
let data = b"test content";
let path = save_generated_file(&ws, data, "vid", "mp4")
.await
.expect("save_generated_file should create dirs");
assert!(path.exists(), "file should exist: {}", path.display());
assert!(
path.starts_with(tmp.path().join("nested")),
"file should be inside workspace"
);
let _ = tokio::fs::remove_dir_all(tmp.path()).await;
}
}