Skip to main content

leviath_cli/
tool_inventory.rs

1//! What tools an agent on this machine can actually use, and where each one
2//! came from.
3//!
4//! One answer, two callers. The lint asks "is this name a tool" when it checks a
5//! blueprint's `available_tools`, and `GET /api/tools` asks "what may I pick"
6//! on behalf of an editor. Those were the same four discovery rules - built-ins,
7//! sub-agent tools, the agent's own `tools/`, and the global drop-in directory -
8//! and a second copy of them would have drifted from the first the moment either
9//! side gained a source.
10//!
11//! The compile failures come out with the tools rather than being dropped. A
12//! script missing because its file has a syntax error looks exactly like a
13//! script that was never written, and only one of those is worth telling
14//! somebody about.
15
16use std::collections::HashSet;
17use std::path::{Path, PathBuf};
18
19/// Where a tool comes from, which is the part that answers "will this work if I
20/// pick it".
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ToolSource {
23    /// Compiled into this build of Leviath. Available to every agent, always.
24    Builtin,
25    /// A sub-agent tool, offered to an agent that may spawn children.
26    Subagent,
27    /// A `.rhai` script in the agent's own `tools/` directory, so it travels
28    /// with that agent and no other.
29    Agent,
30    /// A `.rhai` script in the global tools directory, so every agent on this
31    /// machine gets it.
32    Global,
33}
34
35impl ToolSource {
36    /// The wire name for this source, as the REST API spells it.
37    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/// One tool an agent may name in `available_tools`.
48#[derive(Debug, Clone)]
49pub struct ToolEntry {
50    /// The name the model calls and a blueprint lists.
51    pub name: String,
52    /// Where the tool comes from.
53    pub source: ToolSource,
54    /// The `.rhai` file behind it, for the script-backed sources only.
55    pub path: Option<PathBuf>,
56    /// Which agent owns it, for [`ToolSource::Agent`] only.
57    pub agent: Option<String>,
58}
59
60/// A `.rhai` file that was found but did not become a usable tool.
61#[derive(Debug, Clone)]
62pub struct SkippedScript {
63    /// The file that was passed over.
64    pub path: PathBuf,
65    /// Why, in the words the compiler or the shadowing rule used.
66    pub reason: String,
67    /// Which directory it was found in.
68    pub source: ToolSource,
69}
70
71/// The full tool inventory for one scope: an agent plus the global directory,
72/// or the global directory alone.
73#[derive(Debug, Clone, Default)]
74pub struct ToolInventory {
75    /// Every usable tool, built-ins first, then the agent's scripts, then the
76    /// global ones.
77    pub tools: Vec<ToolEntry>,
78    /// Every `.rhai` file that was found and could not be offered.
79    pub skipped: Vec<SkippedScript>,
80}
81
82impl ToolInventory {
83    /// Discover everything an agent rooted at `agent_dir` could call.
84    ///
85    /// `agent_dir` is `None` for a question about the machine rather than about
86    /// one agent: built-ins and the global directory, with no agent scripts.
87    /// `agent_name` only labels the agent-scoped entries, so a caller that has
88    /// a directory but no name (the daemon's own offline lint) may leave it out.
89    ///
90    /// A script whose name is already taken - by a built-in, by a sub-agent
91    /// tool, or by the agent's own copy shadowing a global one - is reported in
92    /// [`skipped`](Self::skipped) rather than listed twice. That mirrors what
93    /// the daemon does at spawn: the earlier directory wins and a core tool is
94    /// never shadowed, so listing the loser as available would be a lie.
95    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        // The agent's own `tools/` first, then the global one every agent gets:
121        // the same order, and so the same precedence, the daemon scans in.
122        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    /// Just the names, which is all the lint needs to answer "is this a tool".
168    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    /// Point the global tools directory at a scratch root, so a developer's real
178    /// `~/.leviath/tools` never decides what these assert.
179    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    /// The global tools directory under a `LEVIATH_HOME` scratch root.
185    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    /// All four sources in one inventory, which is the whole point of the
205    /// `source` field: three different answers to whether a name will work.
206    #[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    /// Without a name, an agent-scoped entry still resolves; it just cannot say
256    /// whose it is. That is the daemon's own offline lint, which has the
257    /// directory and never had a name.
258    #[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    /// No agent means no agent scripts, and the global directory still answers.
276    #[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    /// A file that will not compile is reported instead of quietly vanishing.
292    #[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    /// A script named after a built-in never routes, so it is reported as
307    /// skipped rather than listed twice under two sources.
308    #[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    /// The agent's own copy wins over the global one of the same name, and the
328    /// global copy is reported so a picker can explain why it is not the one
329    /// that will run.
330    #[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    /// The set the lint checks `available_tools` against carries every listed
347    /// name and nothing that was skipped.
348    #[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}