1use std::collections::HashSet;
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ToolSource {
23 Builtin,
25 Subagent,
27 Agent,
30 Global,
33}
34
35impl ToolSource {
36 pub fn as_str(self) -> &'static str {
38 match self {
39 Self::Builtin => "builtin",
40 Self::Subagent => "subagent",
41 Self::Agent => "agent",
42 Self::Global => "global",
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
49pub struct ToolEntry {
50 pub name: String,
52 pub source: ToolSource,
54 pub path: Option<PathBuf>,
56 pub agent: Option<String>,
58}
59
60#[derive(Debug, Clone)]
62pub struct SkippedScript {
63 pub path: PathBuf,
65 pub reason: String,
67 pub source: ToolSource,
69}
70
71#[derive(Debug, Clone, Default)]
74pub struct ToolInventory {
75 pub tools: Vec<ToolEntry>,
78 pub skipped: Vec<SkippedScript>,
80}
81
82impl ToolInventory {
83 pub fn discover(agent_dir: Option<&Path>, agent_name: Option<&str>) -> Self {
96 let ctx_dir = agent_dir.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
97 let builtins = leviath_tools::BuiltinTools::new(leviath_tools::ToolContext::new(ctx_dir));
98
99 let mut tools: Vec<ToolEntry> = Vec::new();
100 for name in builtins.names() {
101 tools.push(ToolEntry {
102 name,
103 source: ToolSource::Builtin,
104 path: None,
105 agent: None,
106 });
107 }
108 for name in leviath_tools::BuiltinTools::subagent_tool_names() {
109 tools.push(ToolEntry {
110 name,
111 source: ToolSource::Subagent,
112 path: None,
113 agent: None,
114 });
115 }
116
117 let mut taken: HashSet<String> = tools.iter().map(|t| t.name.clone()).collect();
118 let mut skipped: Vec<SkippedScript> = Vec::new();
119
120 let scopes = [
123 (ToolSource::Agent, agent_dir.map(|d| d.join("tools"))),
124 (ToolSource::Global, leviath_core::tools_dir()),
125 ];
126 for (source, dir) in scopes {
127 let Some(dir) = dir else {
128 continue;
129 };
130 let (set, failed) = leviath_scripting::ScriptToolSet::discover(&[dir]);
131 for f in failed {
132 skipped.push(SkippedScript {
133 path: f.path,
134 reason: f.reason,
135 source,
136 });
137 }
138 for (meta, path) in set.sources() {
139 if taken.contains(&meta.name) {
140 skipped.push(SkippedScript {
141 path,
142 reason: format!(
143 "the name '{}' is already taken by a tool that wins over this one",
144 meta.name
145 ),
146 source,
147 });
148 continue;
149 }
150 taken.insert(meta.name.clone());
151 let agent = match source {
152 ToolSource::Agent => agent_name.map(str::to_string),
153 _ => None,
154 };
155 tools.push(ToolEntry {
156 name: meta.name,
157 source,
158 path: Some(path),
159 agent,
160 });
161 }
162 }
163
164 Self { tools, skipped }
165 }
166
167 pub fn names(&self) -> HashSet<String> {
169 self.tools.iter().map(|t| t.name.clone()).collect()
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn with_home<R>(f: impl FnOnce(&Path) -> R) -> R {
180 let dir = tempfile::tempdir().expect("a temp dir");
181 temp_env::with_var("LEVIATH_HOME", Some(dir.path()), || f(dir.path()))
182 }
183
184 fn global_tools(home: &Path) -> PathBuf {
186 let dir = home.join(".leviath").join("tools");
187 std::fs::create_dir_all(&dir).expect("the global tools dir");
188 dir
189 }
190
191 fn write_tool(dir: &Path, file: &str, body: &str) {
192 std::fs::create_dir_all(dir).expect("the tools dir");
193 std::fs::write(dir.join(file), body).expect("the script");
194 }
195
196 #[test]
197 fn source_names_are_the_wire_spelling() {
198 assert_eq!(ToolSource::Builtin.as_str(), "builtin");
199 assert_eq!(ToolSource::Subagent.as_str(), "subagent");
200 assert_eq!(ToolSource::Agent.as_str(), "agent");
201 assert_eq!(ToolSource::Global.as_str(), "global");
202 }
203
204 #[test]
207 fn every_source_appears_with_the_path_behind_it() {
208 with_home(|home| {
209 let agent = home.join("agents").join("researcher");
210 write_tool(
211 &agent.join("tools"),
212 "web_search.rhai",
213 "// @tool web_search\n// @description searches\n1",
214 );
215 write_tool(
216 &global_tools(home),
217 "summarize.rhai",
218 "// @tool summarize\n// @description sums\n2",
219 );
220
221 let inv = ToolInventory::discover(Some(&agent), Some("researcher"));
222
223 let own = inv
224 .tools
225 .iter()
226 .find(|t| t.name == "web_search")
227 .expect("the agent's own tool");
228 assert_eq!(own.source, ToolSource::Agent);
229 assert_eq!(own.agent.as_deref(), Some("researcher"));
230 assert_eq!(
231 own.path.as_deref(),
232 Some(agent.join("tools").join("web_search.rhai").as_path())
233 );
234
235 let global = inv
236 .tools
237 .iter()
238 .find(|t| t.name == "summarize")
239 .expect("the global tool");
240 assert_eq!(global.source, ToolSource::Global);
241 assert!(global.agent.is_none());
242 assert!(global.path.is_some());
243
244 assert!(inv.tools.iter().any(|t| t.source == ToolSource::Builtin));
245 assert!(inv.tools.iter().any(|t| t.source == ToolSource::Subagent));
246 assert!(
247 inv.tools
248 .iter()
249 .all(|t| t.source != ToolSource::Builtin || t.path.is_none())
250 );
251 assert!(inv.skipped.is_empty());
252 });
253 }
254
255 #[test]
259 fn an_unnamed_scope_still_finds_the_agents_own_tools() {
260 with_home(|home| {
261 let agent = home.join("agents").join("nameless");
262 write_tool(&agent.join("tools"), "local.rhai", "// @tool local\n1");
263
264 let inv = ToolInventory::discover(Some(&agent), None);
265 let own = inv
266 .tools
267 .iter()
268 .find(|t| t.name == "local")
269 .expect("the agent's own tool");
270 assert_eq!(own.source, ToolSource::Agent);
271 assert!(own.agent.is_none());
272 });
273 }
274
275 #[test]
277 fn without_an_agent_only_the_machine_wide_scripts_are_listed() {
278 with_home(|home| {
279 write_tool(
280 &global_tools(home),
281 "summarize.rhai",
282 "// @tool summarize\n1",
283 );
284
285 let inv = ToolInventory::discover(None, None);
286 assert!(inv.tools.iter().any(|t| t.name == "summarize"));
287 assert!(inv.tools.iter().all(|t| t.source != ToolSource::Agent));
288 });
289 }
290
291 #[test]
293 fn a_script_that_does_not_compile_is_reported_with_its_reason() {
294 with_home(|home| {
295 let agent = home.join("agents").join("broken");
296 write_tool(&agent.join("tools"), "bad.rhai", "// no directive\nlet");
297
298 let inv = ToolInventory::discover(Some(&agent), Some("broken"));
299 assert_eq!(inv.skipped.len(), 1);
300 assert!(inv.skipped[0].path.ends_with("bad.rhai"));
301 assert_eq!(inv.skipped[0].source, ToolSource::Agent);
302 assert!(!inv.skipped[0].reason.is_empty());
303 });
304 }
305
306 #[test]
309 fn a_script_shadowed_by_a_builtin_is_skipped_not_listed_twice() {
310 with_home(|home| {
311 let agent = home.join("agents").join("shadow");
312 write_tool(
313 &agent.join("tools"),
314 "read_file.rhai",
315 "// @tool read_file\n1",
316 );
317
318 let inv = ToolInventory::discover(Some(&agent), Some("shadow"));
319 let listed: Vec<_> = inv.tools.iter().filter(|t| t.name == "read_file").collect();
320 assert_eq!(listed.len(), 1);
321 assert_eq!(listed[0].source, ToolSource::Builtin);
322 assert_eq!(inv.skipped.len(), 1);
323 assert!(inv.skipped[0].reason.contains("already taken"));
324 });
325 }
326
327 #[test]
331 fn an_agents_own_script_wins_over_the_global_one() {
332 with_home(|home| {
333 let agent = home.join("agents").join("winner");
334 write_tool(&agent.join("tools"), "dup.rhai", "// @tool dup\n1");
335 write_tool(&global_tools(home), "dup.rhai", "// @tool dup\n2");
336
337 let inv = ToolInventory::discover(Some(&agent), Some("winner"));
338 let listed: Vec<_> = inv.tools.iter().filter(|t| t.name == "dup").collect();
339 assert_eq!(listed.len(), 1);
340 assert_eq!(listed[0].source, ToolSource::Agent);
341 assert_eq!(inv.skipped.len(), 1);
342 assert_eq!(inv.skipped[0].source, ToolSource::Global);
343 });
344 }
345
346 #[test]
349 fn names_carry_the_builtins_and_the_scripts_that_compiled() {
350 with_home(|home| {
351 let agent = home.join("agents").join("named");
352 write_tool(&agent.join("tools"), "ok.rhai", "// @tool ok\n1");
353 write_tool(&agent.join("tools"), "bad.rhai", "// nothing\nlet");
354
355 let names = ToolInventory::discover(Some(&agent), Some("named")).names();
356 assert!(names.contains("ok"));
357 assert!(names.contains("read_file"));
358 assert!(!names.contains("bad"));
359 });
360 }
361}