use async_trait::async_trait;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex};
use tokio::sync::RwLock;
use crate::acp::ToolKind;
use crate::agent::PermissionDecision;
use crate::error::Result;
use crate::llm::ToolSpec;
use crate::permissions::auto_classifier::AutoClassifier;
use crate::permissions::SharedPermissions;
use crate::permissions::{PermissionMode, PermissionsConfig};
use crate::tools::fs::ReadFileState;
use super::audit::TouchedFiles;
use super::policy_sandbox;
pub type SpecWithHint = (ToolSpec, Option<String>);
#[async_trait]
pub trait Tool: Send + Sync {
fn spec(&self) -> ToolSpec;
async fn execute(&self, arguments: Value) -> Result<String>;
fn side_effect_class(&self) -> super::audit::ToolSideEffect {
super::audit::ToolSideEffect::External
}
fn is_deferred(&self) -> bool {
false
}
fn kind(&self) -> ToolKind {
ToolKind::Other
}
fn is_readonly(&self) -> bool {
matches!(
self.side_effect_class(),
super::audit::ToolSideEffect::ReadOnly
)
}
fn is_readonly_for_args(&self, _arguments: &Value) -> bool {
self.is_readonly()
}
}
#[async_trait]
pub trait PermissionHook: Send + Sync {
async fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision;
}
pub struct PermissionHookDisabled;
#[async_trait]
impl PermissionHook for PermissionHookDisabled {
async fn check(&self, _tool_name: &str, _args: &serde_json::Value) -> PermissionDecision {
PermissionDecision::Allow
}
}
#[derive(Clone)]
pub struct ToolRegistry {
tools: BTreeMap<String, Arc<dyn Tool>>,
aliases: BTreeMap<String, String>,
transport: Arc<dyn super::transport::ToolTransport>,
pub(crate) permissions: Option<SharedPermissions>,
pub(crate) permission_mode: PermissionMode,
pub(crate) touched: Option<Arc<Mutex<TouchedFiles>>>,
read_file_state: Option<Arc<Mutex<ReadFileState>>>,
session_roots: Option<super::dispatch::SharedSandboxRoots>,
#[cfg(feature = "mcp")]
elicitation: Option<crate::mcp::SharedElicitationHandler>,
pub(crate) permission_hook: Option<Arc<dyn PermissionHook>>,
pub(crate) policy: Option<policy_sandbox::PolicyConfig>,
pub(crate) headless: bool,
pub(crate) hook_runner: crate::hooks::ExternalHookRunner,
pub(crate) auto_classifier: Option<Arc<tokio::sync::Mutex<AutoClassifier>>>,
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::local()
}
}
impl ToolRegistry {
pub fn new(transport: Arc<dyn super::transport::ToolTransport>) -> Self {
Self {
tools: BTreeMap::new(),
aliases: BTreeMap::new(),
transport,
permissions: None,
auto_classifier: None,
permission_mode: PermissionMode::Default,
touched: None,
read_file_state: None,
session_roots: None,
#[cfg(feature = "mcp")]
elicitation: None,
permission_hook: None,
policy: None,
headless: false,
hook_runner: crate::hooks::ExternalHookRunner::discover(&[]),
}
}
pub fn local() -> Self {
Self::new(Arc::new(super::transport::LocalTransport))
}
pub fn transport(&self) -> &Arc<dyn super::transport::ToolTransport> {
&self.transport
}
pub fn with_same_transport(&self) -> Self {
Self {
tools: BTreeMap::new(),
aliases: BTreeMap::new(),
transport: self.transport.clone(),
permissions: self.permissions.clone(),
auto_classifier: self.auto_classifier.clone(),
permission_mode: self.permission_mode.clone(),
touched: self.touched.clone(),
read_file_state: self.read_file_state.clone(),
session_roots: self.session_roots.clone(),
#[cfg(feature = "mcp")]
elicitation: self.elicitation.clone(),
permission_hook: self.permission_hook.clone(),
policy: self.policy.clone(),
headless: self.headless,
hook_runner: self.hook_runner.clone(),
}
}
pub fn fork(&self) -> Self {
self.clone()
}
pub fn with_permission_hook(mut self, hook: Arc<dyn PermissionHook>) -> Self {
self.permission_hook = Some(hook);
self
}
pub fn set_permission_hook(&mut self, hook: Arc<dyn PermissionHook>) {
self.permission_hook = Some(hook);
}
pub fn clear_permission_hook(&mut self) {
self.permission_hook = None;
}
pub fn with_policy(mut self, policy: policy_sandbox::PolicyConfig) -> Self {
self.policy = Some(policy);
self
}
pub fn set_policy(&mut self, policy: policy_sandbox::PolicyConfig) {
self.policy = Some(policy);
}
pub fn policy(&self) -> Option<&policy_sandbox::PolicyConfig> {
self.policy.as_ref()
}
pub fn with_headless(mut self, headless: bool) -> Self {
self.headless = headless;
self
}
pub fn set_headless(&mut self, headless: bool) {
self.headless = headless;
}
pub fn with_hook_runner(mut self, hook_runner: crate::hooks::ExternalHookRunner) -> Self {
self.hook_runner = hook_runner;
self
}
pub fn set_hook_runner(&mut self, hook_runner: crate::hooks::ExternalHookRunner) {
self.hook_runner = hook_runner;
}
pub fn with_permissions(mut self, permissions: PermissionsConfig) -> Self {
self.permission_mode = permissions.mode.clone();
self.permissions = Some(Arc::new(RwLock::new(permissions)));
self
}
pub fn with_shared_permissions(mut self, sp: SharedPermissions) -> Self {
if let Ok(guard) = sp.try_read() {
self.permission_mode = guard.mode.clone();
}
self.permissions = Some(sp);
self
}
pub fn with_auto_classifier(mut self, classifier: AutoClassifier) -> Self {
self.auto_classifier = Some(Arc::new(tokio::sync::Mutex::new(classifier)));
self
}
pub fn permission_mode(&self) -> PermissionMode {
self.permission_mode.clone()
}
pub fn set_permission_mode(&mut self, mode: PermissionMode) {
self.permission_mode = mode.clone();
match &self.permissions {
Some(sp) => {
if let Ok(mut guard) = sp.try_write() {
guard.mode = mode;
}
}
None => {
self.permissions = Some(Arc::new(RwLock::new(
crate::permissions::LayeredPermissionsConfig {
mode,
layers: Vec::new(),
},
)));
}
}
}
pub fn shared_permissions(&self) -> Option<SharedPermissions> {
self.permissions.clone()
}
pub fn permissions_config(&self) -> Option<PermissionsConfig> {
self.permissions
.as_ref()
.and_then(|sp| sp.try_read().ok())
.map(|guard| guard.clone())
}
pub fn is_plan_mode(&self, tool_name: &str) -> bool {
self.permissions
.as_ref()
.and_then(|sp| sp.try_read().ok())
.map(|guard| guard.is_plan_mode(tool_name))
.unwrap_or(false)
}
pub fn with_touched_files(mut self, slot: Arc<Mutex<TouchedFiles>>) -> Self {
self.touched = Some(slot);
self
}
pub fn clear_touched_files(&mut self) {
self.touched = None;
}
pub fn touched_files(&self) -> Option<Arc<Mutex<TouchedFiles>>> {
self.touched.clone()
}
pub fn with_read_file_state(mut self, slot: Arc<Mutex<ReadFileState>>) -> Self {
self.read_file_state = Some(slot);
self
}
pub fn read_file_state(&self) -> Option<Arc<Mutex<ReadFileState>>> {
self.read_file_state.clone()
}
pub fn with_session_roots(mut self, roots: super::dispatch::SharedSandboxRoots) -> Self {
self.session_roots = Some(roots);
self
}
pub fn session_roots(&self) -> Option<super::dispatch::SharedSandboxRoots> {
self.session_roots.clone()
}
#[cfg(feature = "mcp")]
pub fn with_elicitation_slot(mut self, slot: crate::mcp::SharedElicitationHandler) -> Self {
self.elicitation = Some(slot);
self
}
#[cfg(feature = "mcp")]
pub fn elicitation_slot(&self) -> Option<crate::mcp::SharedElicitationHandler> {
self.elicitation.clone()
}
pub fn register(mut self, tool: Arc<dyn Tool>) -> Self {
let name = tool.spec().name;
self.tools.insert(name, tool);
self
}
pub fn register_with_aliases(mut self, tool: Arc<dyn Tool>, aliases: &[&str]) -> Self {
let name = tool.spec().name.clone();
for &alias in aliases {
self.aliases.insert(alias.to_string(), name.clone());
}
self.tools.insert(name, tool);
self
}
pub fn register_mut(&mut self, tool: Arc<dyn Tool>) {
let name = tool.spec().name;
self.tools.insert(name, tool);
}
pub fn register_mut_with_aliases(&mut self, tool: Arc<dyn Tool>, aliases: &[&str]) {
let name = tool.spec().name.clone();
for &alias in aliases {
self.aliases.insert(alias.to_string(), name.clone());
}
self.tools.insert(name, tool);
}
pub fn find_by_name(&self, name: &str) -> Option<Arc<dyn Tool>> {
if let Some(tool) = self.tools.get(name) {
return Some(tool.clone());
}
if let Some(primary) = self.aliases.get(name) {
return self.tools.get(primary).cloned();
}
None
}
pub fn specs(&self) -> Vec<ToolSpec> {
self.tools.values().map(|t| t.spec()).collect()
}
pub fn specs_partitioned(&self) -> (Vec<ToolSpec>, Vec<ToolSpec>) {
let mut eager = Vec::new();
let mut deferred = Vec::new();
for tool in self.tools.values() {
if tool.is_deferred() {
deferred.push(tool.spec());
} else {
eager.push(tool.spec());
}
}
(eager, deferred)
}
pub fn retain_tools(&mut self, allow: &[String]) {
let allowed: std::collections::HashSet<String> =
allow.iter().map(|n| n.to_lowercase()).collect();
self.tools
.retain(|name, _| allowed.contains(&name.to_lowercase()));
self.aliases
.retain(|_, primary| self.tools.contains_key(primary));
}
pub fn split_eager_deferred(&self) -> (Vec<SpecWithHint>, Vec<SpecWithHint>) {
let mut eager = Vec::new();
let mut deferred = Vec::new();
for tool in self.tools.values() {
let spec = tool.spec();
let hint = spec
.description
.split('.')
.next()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if tool.is_deferred() {
deferred.push((spec, hint));
} else {
eager.push((spec, hint));
}
}
(eager, deferred)
}
pub fn is_deferred_spec(&self, spec: &ToolSpec) -> bool {
self.tools
.get(&spec.name)
.map(|t| t.is_deferred())
.unwrap_or(false)
}
pub fn freeze_deferred_specs(&mut self) {
let deferred_specs: Vec<ToolSpec> = self
.tools
.values()
.filter(|t| t.is_deferred())
.map(|t| t.spec())
.collect();
if deferred_specs.is_empty() {
return;
}
let catalog: super::tool_search::DeferredCatalog =
Arc::new(std::sync::RwLock::new(deferred_specs));
let tool = Arc::new(super::tool_search::ToolSearchTool::new(catalog));
self.tools
.insert(super::tool_search::TOOL_SEARCH_TOOL_NAME.to_string(), tool);
}
pub fn names(&self) -> Vec<String> {
self.tools.keys().cloned().collect()
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.get(name).cloned()
}
pub fn is_readonly(&self, name: &str) -> bool {
self.tools
.get(name)
.map(|t| t.is_readonly())
.unwrap_or(false)
}
pub fn build_kind_map(&self) -> HashMap<String, ToolKind> {
self.tools
.iter()
.map(|(name, tool)| (name.clone(), tool.kind()))
.collect()
}
pub fn is_readonly_for_call(&self, name: &str, args: &Value) -> bool {
self.tools
.get(name)
.map(|t| t.is_readonly_for_args(args))
.unwrap_or(false)
}
}
pub fn build_standard_tools(
workspace: &std::path::Path,
skills: &[crate::skills::Skill],
shell_timeout_secs: u64,
) -> ToolRegistry {
build_standard_tools_with_roots(
workspace,
&[],
None,
skills,
shell_timeout_secs,
None,
None,
None,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn build_standard_tools_with_roots(
workspace: &std::path::Path,
extra_roots: &[(std::path::PathBuf, super::dispatch::AccessTier)],
session_roots: Option<super::dispatch::SharedSandboxRoots>,
skills: &[crate::skills::Skill],
shell_timeout_secs: u64,
web_search_provider: Option<String>,
web_search_api_key: Option<String>,
web_search_jina_key: Option<String>,
bg_manager: Option<Arc<tokio::sync::Mutex<super::run_background::BackgroundJobManager>>>,
) -> ToolRegistry {
let bg_manager = bg_manager.unwrap_or_else(|| {
Arc::new(tokio::sync::Mutex::new(
super::run_background::BackgroundJobManager::new(),
))
});
let todo_list = Arc::new(std::sync::RwLock::new(Vec::<super::todo::TodoItem>::new()));
let read_state = Arc::new(Mutex::new(ReadFileState::new()));
let mut registry = ToolRegistry::local()
.with_read_file_state(read_state.clone())
.register(Arc::new(
super::fs::ReadFile::new(workspace)
.with_extra_roots(extra_roots.iter().cloned())
.with_session_roots_opt(session_roots.clone())
.with_read_state(read_state.clone()),
))
.register(Arc::new(
super::fs::WriteFile::new(workspace)
.with_extra_roots(extra_roots.iter().cloned())
.with_session_roots_opt(session_roots.clone())
.with_read_state(read_state.clone()),
))
.register(Arc::new(
super::edit::EditTool::new(workspace)
.with_extra_roots(extra_roots.iter().cloned())
.with_session_roots_opt(session_roots.clone())
.with_read_state(read_state.clone()),
))
.register(Arc::new(
super::shell::RunShell::new(workspace)
.with_timeout(std::time::Duration::from_secs(shell_timeout_secs)),
))
.register(Arc::new(
super::search::SearchFiles::new(workspace)
.with_extra_roots(extra_roots.iter().cloned())
.with_session_roots_opt(session_roots.clone()),
))
.register(Arc::new(
super::glob::GlobTool::new(workspace)
.with_extra_roots(extra_roots.iter().cloned())
.with_session_roots_opt(session_roots.clone()),
))
.register(Arc::new(super::run_background::RunBackground::new(
workspace,
bg_manager.clone(),
)))
.register(Arc::new(super::run_background::CheckBackground::new(
bg_manager.clone(),
)))
.register(Arc::new(super::watch_file::WatchFile::new(
workspace,
bg_manager.clone(),
)))
.register(Arc::new(super::stop_loop::StopLoop::new(
workspace, bg_manager,
)))
.register(Arc::new(
super::estimate_tokens::EstimateTokens::new(workspace)
.with_extra_roots(extra_roots.iter().cloned())
.with_session_roots_opt(session_roots.clone()),
))
.register(Arc::new(super::memory::Remember::new(workspace)))
.register(Arc::new(super::memory::Recall::new(workspace)))
.register(Arc::new(super::memory::Forget::new(workspace)))
.register(Arc::new(super::facts::RememberFact::new(workspace)))
.register(Arc::new(super::facts::RecallFact::new(workspace)))
.register(Arc::new(super::facts::ForgetFact::new(workspace)))
.register(Arc::new(super::facts::UpdateFact::new(workspace)))
.register(Arc::new(super::episodic_recall::EpisodicRecall::new(
workspace,
)))
.register(Arc::new(super::memory::WorkingMemoryTool::new(workspace)))
.register(Arc::new(super::memory::ScratchpadGet::new(workspace)))
.register(Arc::new(super::memory::ScratchpadDelete::new(workspace)))
.register(Arc::new(super::memory::ScratchpadList::new(workspace)))
.register(Arc::new(super::todo::TodoWriteTool::new(
todo_list,
Arc::new(crate::event::NullSink),
)))
.register(Arc::new(super::a2a::A2aCallTool::new()))
.register(Arc::new(super::a2a::A2aCardTool::new()))
.register(Arc::new(super::a2a::A2aTaskCheckTool::new()));
#[cfg(feature = "web_fetch")]
{
registry = registry.register(Arc::new(super::web_fetch::WebFetch::new()));
}
#[cfg(feature = "web_search")]
{
let search = super::web_search::WebSearch::new().with_search_config(
web_search_provider,
web_search_api_key,
web_search_jina_key,
);
registry = registry.register(Arc::new(search));
}
if !skills.is_empty() {
registry = registry.register(Arc::new(super::load_skill::LoadSkill::new(skills.to_vec())));
}
registry = registry.register(Arc::new(
super::client_fs::ClientReadFile::new(workspace)
.with_extra_roots(extra_roots.iter().cloned())
.with_session_roots_opt(session_roots.clone()),
));
registry = registry.register(Arc::new(
super::client_fs::ClientWriteFile::new(workspace)
.with_extra_roots(extra_roots.iter().cloned())
.with_session_roots_opt(session_roots.clone()),
));
registry
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
struct ReadOnlyTool {
name: &'static str,
}
#[async_trait]
impl Tool for ReadOnlyTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: self.name.to_string(),
description: "read-only test tool".into(),
parameters: serde_json::json!({"type":"object","properties":{}}),
}
}
fn side_effect_class(&self) -> super::super::audit::ToolSideEffect {
super::super::audit::ToolSideEffect::ReadOnly
}
fn kind(&self) -> ToolKind {
ToolKind::Read
}
async fn execute(&self, _args: Value) -> crate::error::Result<String> {
Ok("read-result".into())
}
}
struct MutatingTool {
name: &'static str,
}
#[async_trait]
impl Tool for MutatingTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: self.name.to_string(),
description: "mutating test tool".into(),
parameters: serde_json::json!({"type":"object","properties":{}}),
}
}
fn kind(&self) -> ToolKind {
ToolKind::Other
}
async fn execute(&self, _args: Value) -> crate::error::Result<String> {
Ok("mutated".into())
}
}
fn make_registry() -> ToolRegistry {
ToolRegistry::local()
}
#[test]
fn register_and_find_by_name() {
let reg = make_registry().register(Arc::new(ReadOnlyTool { name: "Alpha" }));
let found = reg.find_by_name("Alpha");
assert!(found.is_some(), "registered tool must be findable by name");
assert!(reg.find_by_name("NoSuch").is_none());
}
#[test]
fn names_returns_registered_tool_names() {
let reg = make_registry()
.register(Arc::new(ReadOnlyTool { name: "T1" }))
.register(Arc::new(MutatingTool { name: "T2" }));
let mut names = reg.names();
names.sort();
assert_eq!(names, vec!["T1", "T2"]);
}
#[test]
fn names_empty_on_fresh_registry() {
assert!(make_registry().names().is_empty());
}
#[test]
fn specs_returns_tool_specs() {
let reg = make_registry().register(Arc::new(ReadOnlyTool { name: "SpecTool" }));
let specs = reg.specs();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].name, "SpecTool");
}
#[test]
fn get_returns_tool_by_name() {
let reg = make_registry().register(Arc::new(MutatingTool { name: "Getter" }));
assert!(reg.get("Getter").is_some());
assert!(reg.get("Missing").is_none());
}
#[test]
fn register_mut_adds_tool() {
let mut reg = make_registry();
reg.register_mut(Arc::new(ReadOnlyTool { name: "MutReg" }));
assert!(reg.find_by_name("MutReg").is_some());
}
#[test]
fn is_readonly_true_for_readonly_tool() {
let reg = make_registry().register(Arc::new(ReadOnlyTool { name: "RO" }));
assert!(
reg.is_readonly("RO"),
"ReadOnly tool must be is_readonly=true"
);
assert!(
!reg.is_readonly("Missing"),
"unknown tool must be is_readonly=false"
);
}
#[test]
fn is_readonly_false_for_mutating_tool() {
let reg = make_registry().register(Arc::new(MutatingTool { name: "Mut" }));
assert!(
!reg.is_readonly("Mut"),
"External tool must be is_readonly=false"
);
}
#[test]
fn is_readonly_for_call_delegates_to_tool() {
let reg = make_registry().register(Arc::new(ReadOnlyTool { name: "Ro2" }));
let args = serde_json::json!({});
assert!(reg.is_readonly_for_call("Ro2", &args));
assert!(!reg.is_readonly_for_call("Missing", &args));
}
#[test]
fn retain_tools_keeps_only_allowed() {
let mut reg = make_registry()
.register(Arc::new(ReadOnlyTool { name: "Keep" }))
.register(Arc::new(MutatingTool { name: "Drop" }));
reg.retain_tools(&["Keep".to_string()]);
assert!(reg.find_by_name("Keep").is_some());
assert!(reg.find_by_name("Drop").is_none());
}
#[test]
fn with_same_transport_returns_empty_registry() {
let reg = make_registry().register(Arc::new(ReadOnlyTool { name: "Src" }));
let empty = reg.with_same_transport();
assert!(
empty.names().is_empty(),
"with_same_transport must return empty registry"
);
}
#[test]
fn fork_clones_tools() {
let reg = make_registry().register(Arc::new(ReadOnlyTool { name: "ForkMe" }));
let forked = reg.fork();
assert!(forked.find_by_name("ForkMe").is_some());
}
#[test]
fn build_standard_tools_is_nonempty() {
let tmp = tempfile::tempdir().expect("tempdir");
let registry = build_standard_tools(tmp.path(), &[], 30);
assert!(
!registry.names().is_empty(),
"build_standard_tools must register at least one tool"
);
}
struct DeferredTool {
name: &'static str,
description: &'static str,
}
#[async_trait]
impl Tool for DeferredTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: self.name.to_string(),
description: self.description.to_string(),
parameters: serde_json::json!({"type":"object","properties":{}}),
}
}
fn is_deferred(&self) -> bool {
true
}
fn kind(&self) -> ToolKind {
ToolKind::Other
}
async fn execute(&self, _args: Value) -> crate::error::Result<String> {
Ok("deferred-result".into())
}
}
#[test]
fn register_with_aliases_finds_by_primary_and_alias() {
let reg = make_registry().register_with_aliases(
Arc::new(ReadOnlyTool { name: "Primary" }),
&["alias1", "alias2"],
);
assert!(
reg.find_by_name("Primary").is_some(),
"primary name must work"
);
assert!(reg.find_by_name("alias1").is_some(), "alias1 must resolve");
assert!(reg.find_by_name("alias2").is_some(), "alias2 must resolve");
assert!(
reg.find_by_name("unknown").is_none(),
"unknown must return None"
);
}
#[test]
fn register_mut_with_aliases_adds_alias() {
let mut reg = make_registry();
reg.register_mut_with_aliases(Arc::new(ReadOnlyTool { name: "MutAliased" }), &["malias"]);
assert!(reg.find_by_name("MutAliased").is_some());
assert!(
reg.find_by_name("malias").is_some(),
"mutably-registered alias must resolve"
);
}
#[test]
fn specs_partitioned_separates_eager_from_deferred() {
let reg = make_registry()
.register(Arc::new(ReadOnlyTool { name: "Eager1" }))
.register(Arc::new(ReadOnlyTool { name: "Eager2" }))
.register(Arc::new(DeferredTool {
name: "Defer1",
description: "deferred.",
}));
let (eager, deferred) = reg.specs_partitioned();
assert_eq!(eager.len(), 2, "two eager tools expected");
assert_eq!(deferred.len(), 1, "one deferred tool expected");
assert_eq!(deferred[0].name, "Defer1");
}
#[test]
fn specs_partitioned_all_eager_when_no_deferred() {
let reg = make_registry()
.register(Arc::new(ReadOnlyTool { name: "E1" }))
.register(Arc::new(MutatingTool { name: "E2" }));
let (eager, deferred) = reg.specs_partitioned();
assert_eq!(eager.len(), 2);
assert!(deferred.is_empty(), "no deferred tools expected");
}
#[test]
fn split_eager_deferred_puts_deferred_in_second_slot() {
let reg = make_registry()
.register(Arc::new(ReadOnlyTool { name: "EagerX" }))
.register(Arc::new(DeferredTool {
name: "DeferX",
description: "First sentence. Second sentence.",
}));
let (eager, deferred) = reg.split_eager_deferred();
assert_eq!(eager.len(), 1);
assert_eq!(deferred.len(), 1);
let (_spec, hint) = &deferred[0];
assert_eq!(
hint.as_deref(),
Some("First sentence"),
"hint must be first sentence"
);
}
#[test]
fn split_eager_deferred_hint_is_none_for_empty_description() {
let reg = make_registry().register(Arc::new(DeferredTool {
name: "NoDesc",
description: ".",
}));
let (_eager, deferred) = reg.split_eager_deferred();
let (_spec, hint) = &deferred[0];
assert!(
hint.is_none(),
"empty sentence before '.' → hint must be None"
);
}
#[test]
fn is_deferred_spec_true_for_deferred_tool() {
let reg = make_registry().register(Arc::new(DeferredTool {
name: "DS",
description: "d.",
}));
let specs = reg.specs();
let ds_spec = specs.iter().find(|s| s.name == "DS").unwrap();
assert!(
reg.is_deferred_spec(ds_spec),
"deferred tool spec must be deferred"
);
}
#[test]
fn is_deferred_spec_false_for_eager_tool() {
let reg = make_registry().register(Arc::new(ReadOnlyTool { name: "EagerSpec" }));
let specs = reg.specs();
let spec = &specs[0];
assert!(
!reg.is_deferred_spec(spec),
"eager tool must not be deferred"
);
}
#[test]
fn is_deferred_spec_false_for_unknown_spec() {
let reg = make_registry();
let fake_spec = ToolSpec {
name: "Phantom".to_string(),
description: "not registered".to_string(),
parameters: serde_json::json!({}),
};
assert!(
!reg.is_deferred_spec(&fake_spec),
"unknown spec must return false"
);
}
#[test]
fn retain_tools_removes_aliases_of_dropped_tools() {
let mut reg = make_registry()
.register_with_aliases(Arc::new(ReadOnlyTool { name: "Keep2" }), &["k_alias"])
.register_with_aliases(Arc::new(MutatingTool { name: "Drop2" }), &["d_alias"]);
reg.retain_tools(&["Keep2".to_string()]);
assert!(reg.find_by_name("Keep2").is_some());
assert!(
reg.find_by_name("k_alias").is_some(),
"kept tool's alias must survive"
);
assert!(reg.find_by_name("Drop2").is_none());
assert!(
reg.find_by_name("d_alias").is_none(),
"dropped tool's alias must be removed"
);
}
#[test]
fn with_touched_files_and_clear() {
use std::sync::{Arc, Mutex};
let slot: Arc<Mutex<TouchedFiles>> = Arc::new(Mutex::new(TouchedFiles::default()));
let mut reg = make_registry().with_touched_files(slot);
assert!(
reg.touched_files().is_some(),
"slot must be present after with_touched_files"
);
reg.clear_touched_files();
assert!(
reg.touched_files().is_none(),
"slot must be gone after clear_touched_files"
);
}
#[test]
fn with_read_file_state_roundtrip() {
let slot: Arc<Mutex<ReadFileState>> = Arc::new(Mutex::new(ReadFileState::default()));
let reg = make_registry().with_read_file_state(slot);
assert!(
reg.read_file_state().is_some(),
"read_file_state must be Some after setter"
);
let reg2 = make_registry();
assert!(
reg2.read_file_state().is_none(),
"fresh registry has no read_file_state"
);
}
#[test]
fn permission_mode_default_on_fresh_registry() {
let reg = make_registry();
assert!(
matches!(reg.permission_mode(), PermissionMode::Default),
"fresh registry must have Default permission mode"
);
}
#[test]
fn with_permissions_sets_permission_mode() {
let config = PermissionsConfig {
mode: PermissionMode::AcceptEdits,
..Default::default()
};
let reg = make_registry().with_permissions(config);
assert!(
matches!(reg.permission_mode(), PermissionMode::AcceptEdits),
"permission_mode must reflect config.mode"
);
}
#[test]
fn permissions_config_none_on_fresh_registry() {
let reg = make_registry();
assert!(
reg.permissions_config().is_none(),
"fresh registry has no permissions_config"
);
}
#[test]
fn permissions_config_some_after_with_permissions() {
let config = PermissionsConfig {
mode: PermissionMode::Default,
..Default::default()
};
let reg = make_registry().with_permissions(config);
assert!(
reg.permissions_config().is_some(),
"permissions_config must be Some"
);
}
#[test]
fn is_plan_mode_false_for_default_tool_and_no_config() {
let reg = make_registry().register(Arc::new(ReadOnlyTool { name: "PlanTool" }));
assert!(
!reg.is_plan_mode("PlanTool"),
"no plan-mode config → must be false"
);
assert!(!reg.is_plan_mode("Unknown"), "unknown tool → must be false");
}
struct DenyAllHook;
#[async_trait]
impl PermissionHook for DenyAllHook {
async fn check(&self, _tool_name: &str, _args: &serde_json::Value) -> PermissionDecision {
PermissionDecision::Deny("no".into())
}
}
#[test]
fn with_permission_hook_installs_hook() {
let reg = make_registry().with_permission_hook(Arc::new(DenyAllHook));
assert!(reg.permission_hook.is_some(), "hook must be installed");
}
#[test]
fn clear_permission_hook_removes_hook() {
let mut reg = make_registry().with_permission_hook(Arc::new(DenyAllHook));
reg.clear_permission_hook();
assert!(reg.permission_hook.is_none(), "hook must be cleared");
}
#[test]
fn with_policy_and_policy_roundtrip() {
use super::policy_sandbox::PolicyConfig;
let reg = make_registry();
assert!(reg.policy().is_none(), "fresh registry has no policy");
let cfg = PolicyConfig::default();
let reg2 = reg.with_policy(cfg);
assert!(
reg2.policy().is_some(),
"policy must be Some after with_policy"
);
}
#[test]
fn with_headless_sets_flag() {
let reg = make_registry();
assert!(!reg.headless, "fresh registry must not be headless");
let reg2 = reg.with_headless(true);
assert!(
reg2.headless,
"headless must be true after with_headless(true)"
);
}
#[test]
fn transport_accessor_returns_same_arc() {
let reg = make_registry();
let _transport = reg.transport();
}
#[test]
fn fork_is_independent_from_original() {
let mut reg = make_registry().register(Arc::new(ReadOnlyTool { name: "ForkOrig" }));
let mut forked = reg.fork();
forked.register_mut(Arc::new(MutatingTool { name: "ForkedOnly" }));
assert!(
reg.find_by_name("ForkedOnly").is_none(),
"forked-only tool must not appear in original"
);
reg.retain_tools(&["ForkOrig".to_string()]);
assert!(
forked.find_by_name("ForkOrig").is_some(),
"original retain must not affect fork"
);
}
#[test]
fn names_does_not_include_aliases() {
let reg = make_registry()
.register_with_aliases(Arc::new(ReadOnlyTool { name: "PrimeName" }), &["anAlias"]);
let names = reg.names();
assert!(
names.contains(&"PrimeName".to_string()),
"primary name must be in names()"
);
assert!(
!names.contains(&"anAlias".to_string()),
"alias must NOT be in names()"
);
}
struct DefaultKindTool;
#[async_trait]
impl Tool for DefaultKindTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "DefaultKind".into(),
description: "defaults to Other".into(),
parameters: serde_json::json!({"type":"object","properties":{}}),
}
}
async fn execute(&self, _args: Value) -> crate::error::Result<String> {
Ok("ok".into())
}
}
#[test]
fn default_kind_is_other() {
let tool = DefaultKindTool;
assert_eq!(tool.kind(), ToolKind::Other);
}
#[test]
fn build_kind_map_works() {
let reg = make_registry()
.register(Arc::new(ReadOnlyTool { name: "ReadTool" }))
.register(Arc::new(MutatingTool { name: "MutTool" }));
let map = reg.build_kind_map();
assert_eq!(map.len(), 2);
assert_eq!(map.get("ReadTool"), Some(&ToolKind::Read));
assert_eq!(map.get("MutTool"), Some(&ToolKind::Other));
}
#[test]
fn build_kind_map_empty_registry() {
let map = make_registry().build_kind_map();
assert!(map.is_empty());
}
}