use std::sync::Arc;
use color_eyre::Result;
use crate::config::AppConfig;
use crate::tools::{self, approval::Approver, ToolRegistry};
pub struct Loaded {
pub registry: ToolRegistry,
pub mcp_connected: Vec<String>,
pub problems: Vec<String>,
pub notices: Vec<String>,
}
pub async fn load_tools(cfg: &AppConfig, approver: Option<Arc<Approver>>) -> Loaded {
let mut registry = match approver {
Some(approver) => {
tools::approval::install(approver.clone());
ToolRegistry::with_approver(approver)
}
None => ToolRegistry::new(),
};
registry.register(Box::new(tools::search::ListDirTool));
registry.register(Box::new(tools::search::GlobTool));
registry.register(Box::new(tools::search::GrepTool));
registry.register(Box::new(tools::project::ProjectInitTool));
registry.register(Box::new(tools::project::ProjectInfoTool));
registry.register(Box::new(tools::caatinga::CaatingaBuildTool));
registry.register(Box::new(tools::caatinga::CaatingaDeployTool));
registry.register(Box::new(tools::file::ReadFileTool));
registry.register(Box::new(tools::file::WriteFileTool));
registry.register(Box::new(tools::file::EditFileTool));
registry.register(Box::new(tools::invoke::CaatingaInvokeTool));
registry.register(Box::new(tools::invoke::CaatingaReadTool));
registry.register(Box::new(tools::invoke::StellarCliInvokeTool));
registry.register(Box::new(tools::caatinga::CaatingaDoctorTool));
registry.register(Box::new(tools::accounts::AccountCreateTool));
registry.register(Box::new(tools::accounts::AccountListTool));
registry.register(Box::new(tools::accounts::AccountBalanceTool));
registry.register(Box::new(tools::test::RunTestsTool));
registry.register(Box::new(tools::bindings::GenerateBindingsTool));
registry.register(Box::new(tools::docs::GenerateDocsTool));
registry.register(Box::new(tools::events::SubscribeEventsTool));
registry.register(Box::new(tools::events::FilterEventsTool));
registry.register(Box::new(tools::plugin::ListPluginsTool));
registry.register(Box::new(tools::update::CheckUpdateTool));
registry.register(Box::new(tools::skill::RunSkillTool));
registry.register(Box::new(tools::skill::ListSkillsTool));
registry.register(Box::new(tools::persona::TalkToTool));
registry.register(Box::new(tools::persona::ListPersonasTool));
registry.register(Box::new(tools::party::PartyModeTool));
let mut problems = Vec::new();
let mut notices = Vec::new();
let (mcp_tools, mcp_connected, mcp_problems) = crate::mcp::load_servers(&cfg.mcp_servers).await;
for tool in mcp_tools {
if let Err(e) = registry.try_register(tool) {
notices.push(format!("MCP {}", e));
}
}
problems.extend(mcp_problems);
let (plugin_tools, plugin_warnings) = tools::plugin::load_plugin_tools();
let plugin_count = plugin_tools.len();
let mut shadowed = 0;
for tool in plugin_tools {
if let Err(e) = registry.try_register(tool) {
shadowed += 1;
notices.push(format!("Plugin {}", e));
}
}
if plugin_count > 0 {
notices.push(format!("Loaded {} plugin tool(s)", plugin_count - shadowed));
}
notices.extend(plugin_warnings);
Loaded {
registry,
mcp_connected,
problems,
notices,
}
}
const OLLAMA_CORE_TOOLS: &[&str] = &[
"list_dir",
"glob",
"grep",
"project_init",
"project_info",
"read_file",
"write_file",
"edit_file",
"caatinga_build",
"caatinga_deploy",
"caatinga_invoke",
"caatinga_read",
"caatinga_doctor",
"stellar_invoke",
];
pub fn tools_for_provider(
provider: crate::config::Provider,
tools: &[crate::agent::ToolDefinition],
) -> Vec<crate::agent::ToolDefinition> {
if provider != crate::config::Provider::Ollama {
return tools.to_vec();
}
tools
.iter()
.filter(|t| OLLAMA_CORE_TOOLS.contains(&t.name.as_str()))
.cloned()
.collect()
}
pub async fn persona_tools(cfg: &AppConfig, persona: &crate::personas::Persona) -> Loaded {
let mut loaded = load_tools(cfg, tools::approval::session()).await;
loaded.registry.retain(|tool| {
if !tool.capability().within(persona.ceiling) {
return false;
}
match &persona.tools {
Some(allowed) => allowed.iter().any(|name| name == tool.name()),
None => true,
}
});
loaded
}
#[derive(Debug, Default)]
pub struct Run {
pub text: String,
pub calls: Vec<(String, Option<String>)>,
}
impl Run {
pub fn tool_calls(&self) -> usize {
self.calls.len()
}
pub fn failed_calls(&self) -> usize {
self.calls.iter().filter(|(_, err)| err.is_some()).count()
}
pub fn unsupported_calls(&self) -> usize {
self.calls
.iter()
.filter(|(_, err)| {
err.as_deref()
.is_some_and(|e| e.starts_with("Unknown tool:"))
})
.count()
}
}
#[derive(Default)]
struct CallLog {
calls: std::sync::Mutex<Vec<(String, Option<String>)>>,
}
impl crate::tools::ToolObserver for CallLog {
fn observe(&self, tool: &str, outcome: Result<(), &str>) {
if let Ok(mut calls) = self.calls.lock() {
calls.push((tool.to_string(), outcome.err().map(str::to_string)));
}
}
}
pub async fn run_once(cfg: &AppConfig, prompt: &str, allow_changes: bool) -> Result<Run> {
let approver = Arc::new(Approver::unattended(allow_changes));
let mut loaded = load_tools(cfg, Some(approver)).await;
let calls = Arc::new(CallLog::default());
loaded.registry.observe(calls.clone());
let allowed_tools: Vec<String> =
tools_for_provider(cfg.provider, &loaded.registry.definitions())
.into_iter()
.map(|definition| definition.name)
.collect();
let cwd = std::env::current_dir()?;
let context = crate::context::WorkspaceContext::gather(&cwd, &loaded.mcp_connected)
.await
.with_unverified(crate::verify::session().pending());
let response = crate::agent::subagent::run_subagent(
cfg,
crate::agent::subagent::SubAgentConfig {
system_prompt: context.system_prompt(),
message: prompt.to_string(),
model: None,
max_tokens: None,
max_rounds: None,
allowed_tools: Some(allowed_tools),
timeout_secs: None,
},
&loaded.registry,
)
.await?;
Ok(Run {
text: response.text,
calls: calls
.calls
.lock()
.map(|calls| calls.clone())
.unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> AppConfig {
AppConfig::default()
}
#[tokio::test]
async fn the_tool_set_loads_with_no_terminal_attached() {
let loaded = load_tools(&config(), None).await;
let names: Vec<String> = loaded
.registry
.definitions()
.into_iter()
.map(|d| d.name)
.collect();
for expected in [
"read_file",
"write_file",
"caatinga_build",
"caatinga_deploy",
"run_tests",
"account_list",
] {
assert!(
names.contains(&expected.to_string()),
"missing {}",
expected
);
}
}
#[tokio::test]
async fn gating_the_registry_does_not_change_what_it_offers() {
let ungated = load_tools(&config(), None).await;
let gated = load_tools(&config(), Some(Arc::new(Approver::unattended(false)))).await;
let names = |loaded: &Loaded| -> Vec<String> {
let mut names: Vec<String> = loaded
.registry
.definitions()
.into_iter()
.map(|d| d.name)
.collect();
names.sort();
names
};
assert_eq!(names(&ungated), names(&gated));
}
#[tokio::test]
async fn a_config_with_no_servers_reports_no_problems() {
let loaded = load_tools(&config(), None).await;
assert!(loaded.mcp_connected.is_empty());
assert!(loaded.problems.is_empty(), "{:?}", loaded.problems);
}
fn persona_with(tools: &[&str], ceiling: crate::risk::Capability) -> crate::personas::Persona {
crate::personas::Persona {
skill_name: "test-persona".to_string(),
name: "TestPersona".to_string(),
title: String::new(),
icon: String::new(),
role: String::new(),
identity: String::new(),
communication_style: String::new(),
principles: Vec::new(),
body: String::new(),
when_to_use: String::new(),
tools: Some(tools.iter().map(|t| t.to_string()).collect()),
skills: Vec::new(),
ceiling,
}
}
#[tokio::test]
async fn a_persona_s_registry_holds_only_what_it_asked_for() {
let persona = persona_with(
&["read_file", "caatinga_build"],
crate::risk::Capability::Build,
);
let names: Vec<String> = persona_tools(&config(), &persona)
.await
.registry
.definitions()
.into_iter()
.map(|d| d.name)
.collect();
assert_eq!(names.len(), 2, "{:?}", names);
assert!(names.contains(&"read_file".to_string()));
assert!(names.contains(&"caatinga_build".to_string()));
}
#[tokio::test]
async fn the_ceiling_wins_over_the_persona_s_own_list() {
let persona = persona_with(
&["read_file", "write_file", "caatinga_deploy"],
crate::risk::Capability::ReadOnly,
);
let names: Vec<String> = persona_tools(&config(), &persona)
.await
.registry
.definitions()
.into_iter()
.map(|d| d.name)
.collect();
assert_eq!(names, vec!["read_file".to_string()]);
}
#[tokio::test]
async fn no_declared_tools_means_everything_under_the_ceiling() {
let mut persona = persona_with(&[], crate::risk::Capability::ReadOnly);
persona.tools = None;
let names: Vec<String> = persona_tools(&config(), &persona)
.await
.registry
.definitions()
.into_iter()
.map(|d| d.name)
.collect();
assert!(names.contains(&"read_file".to_string()));
assert!(!names.contains(&"write_file".to_string()));
assert!(!names.contains(&"caatinga_deploy".to_string()));
}
}