use std::path::PathBuf;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use super::types::{ErrorResponse, err};
use crate::tool_inventory::ToolInventory;
pub(super) type ApiError = (StatusCode, Json<ErrorResponse>);
pub(super) fn agent_dir(name: &str) -> Result<PathBuf, ApiError> {
match leviath_core::is_safe_path_component(name) {
true => Ok(super::blueprints::agents_dir().join(name)),
false => Err(err(
StatusCode::BAD_REQUEST,
format!(
"Invalid agent name '{name}': names may contain only letters, digits, \
'.', '_' and '-'"
),
)),
}
}
#[derive(Debug, Deserialize)]
pub(super) struct ToolsQuery {
agent: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ToolItem {
pub(super) name: String,
pub(super) source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) agent: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct SkippedItem {
pub(super) path: String,
pub(super) reason: String,
pub(super) source: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ToolsResp {
pub(super) tools: Vec<ToolItem>,
pub(super) skipped: Vec<SkippedItem>,
}
pub(super) async fn list_tools(Query(q): Query<ToolsQuery>) -> Result<Json<ToolsResp>, ApiError> {
let dir = match q.agent.as_deref() {
Some(name) => Some(agent_dir(name)?),
None => None,
};
let inventory = ToolInventory::discover(dir.as_deref(), q.agent.as_deref());
let tools = inventory
.tools
.into_iter()
.map(|t| ToolItem {
name: t.name,
source: t.source.as_str().to_string(),
path: t.path.map(|p| p.display().to_string()),
agent: t.agent,
})
.collect();
let skipped = inventory
.skipped
.into_iter()
.map(|s| SkippedItem {
path: s.path.display().to_string(),
reason: s.reason,
source: s.source.as_str().to_string(),
})
.collect();
Ok(Json(ToolsResp { tools, skipped }))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::body::Body;
use axum::http::Request;
use axum::routing::get;
use std::path::Path;
use tower::ServiceExt;
use super::super::testutil::with_home;
fn write_tool(dir: &Path, file: &str, body: &str) {
std::fs::create_dir_all(dir).expect("the directory");
std::fs::write(dir.join(file), body).expect("the script");
}
async fn get_tools(uri: &str) -> (StatusCode, serde_json::Value) {
let app = Router::new().route("/api/tools", get(list_tools));
let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
let resp = app.oneshot(req).await.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
(status, serde_json::from_slice(&bytes).unwrap())
}
#[tokio::test]
async fn listing_without_an_agent_covers_the_machine() {
with_home(|home| async move {
write_tool(
&home.join(".leviath").join("tools"),
"summarize.rhai",
"// @tool summarize\n// @description sums\n1",
);
let (status, body) = get_tools("/api/tools").await;
assert_eq!(status, StatusCode::OK);
let tools = body["tools"].as_array().expect("a tools array");
let summarize = tools
.iter()
.find(|t| t["name"] == "summarize")
.expect("the global tool");
assert_eq!(summarize["source"], "global");
let path = summarize["path"].as_str().expect("a path");
assert!(path.ends_with("summarize.rhai"), "{path}");
assert!(tools.iter().any(|t| t["source"] == "builtin"));
assert!(tools.iter().any(|t| t["source"] == "subagent"));
assert!(tools.iter().all(|t| t["source"] != "agent"));
let builtin = tools
.iter()
.find(|t| t["source"] == "builtin")
.expect("a built-in");
assert!(builtin.get("path").is_none());
assert!(builtin.get("agent").is_none());
})
.await;
}
#[tokio::test]
async fn listing_with_an_agent_adds_that_agents_own_tools() {
with_home(|home| async move {
let agent = home.join(".leviath").join("agents").join("researcher");
write_tool(
&agent.join("tools"),
"web_search.rhai",
"// @tool web_search\n// @description searches\n1",
);
let (status, body) = get_tools("/api/tools?agent=researcher").await;
assert_eq!(status, StatusCode::OK);
let tools = body["tools"].as_array().expect("a tools array");
let own = tools
.iter()
.find(|t| t["name"] == "web_search")
.expect("the agent's own tool");
assert_eq!(own["source"], "agent");
assert_eq!(own["agent"], "researcher");
let path = own["path"].as_str().expect("a path");
assert!(path.ends_with("web_search.rhai"), "{path}");
})
.await;
}
#[tokio::test]
async fn a_script_that_fails_to_compile_is_reported_as_skipped() {
with_home(|home| async move {
let agent = home.join(".leviath").join("agents").join("broken");
write_tool(&agent.join("tools"), "bad.rhai", "// no directive\nlet");
let (status, body) = get_tools("/api/tools?agent=broken").await;
assert_eq!(status, StatusCode::OK);
let skipped = body["skipped"].as_array().expect("a skipped array");
assert_eq!(skipped.len(), 1);
let path = skipped[0]["path"].as_str().expect("a path");
assert!(path.ends_with("bad.rhai"), "{path}");
assert_eq!(skipped[0]["source"], "agent");
assert!(!skipped[0]["reason"].as_str().expect("a reason").is_empty());
let tools = body["tools"].as_array().expect("a tools array");
assert!(tools.iter().all(|t| t["name"] != "bad"));
})
.await;
}
#[tokio::test]
async fn a_traversing_agent_name_is_rejected() {
with_home(|_home| async move {
let (status, body) = get_tools("/api/tools?agent=..%2F..%2Fetc").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let message = body["error"].as_str().expect("an error message");
assert!(message.contains("Invalid agent name"), "{message}");
})
.await;
}
#[tokio::test]
async fn agent_dir_accepts_a_plain_name() {
with_home(|home| async move {
let dir = agent_dir("researcher").expect("a plain name resolves");
assert!(dir.starts_with(home.join(".leviath").join("agents")));
})
.await;
}
}