1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
//! What tools an agent on this machine can actually use, and where each one
//! came from.
//!
//! One answer, two callers. The lint asks "is this name a tool" when it checks a
//! blueprint's `available_tools`, and `GET /api/tools` asks "what may I pick"
//! on behalf of an editor. Those were the same four discovery rules - built-ins,
//! sub-agent tools, the agent's own `tools/`, and the global drop-in directory -
//! and a second copy of them would have drifted from the first the moment either
//! side gained a source.
//!
//! The compile failures come out with the tools rather than being dropped. A
//! script missing because its file has a syntax error looks exactly like a
//! script that was never written, and only one of those is worth telling
//! somebody about.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// Where a tool comes from, which is the part that answers "will this work if I
/// pick it".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSource {
/// Compiled into this build of Leviath. Available to every agent, always.
Builtin,
/// A sub-agent tool, offered to an agent that may spawn children.
Subagent,
/// A `.rhai` script in the agent's own `tools/` directory, so it travels
/// with that agent and no other.
Agent,
/// A `.rhai` script in the global tools directory, so every agent on this
/// machine gets it.
Global,
}
impl ToolSource {
/// The wire name for this source, as the REST API spells it.
pub fn as_str(self) -> &'static str {
match self {
Self::Builtin => "builtin",
Self::Subagent => "subagent",
Self::Agent => "agent",
Self::Global => "global",
}
}
}
/// One tool an agent may name in `available_tools`.
#[derive(Debug, Clone)]
pub struct ToolEntry {
/// The name the model calls and a blueprint lists.
pub name: String,
/// Where the tool comes from.
pub source: ToolSource,
/// The `.rhai` file behind it, for the script-backed sources only.
pub path: Option<PathBuf>,
/// Which agent owns it, for [`ToolSource::Agent`] only.
pub agent: Option<String>,
}
/// A `.rhai` file that was found but did not become a usable tool.
#[derive(Debug, Clone)]
pub struct SkippedScript {
/// The file that was passed over.
pub path: PathBuf,
/// Why, in the words the compiler or the shadowing rule used.
pub reason: String,
/// Which directory it was found in.
pub source: ToolSource,
}
/// The full tool inventory for one scope: an agent plus the global directory,
/// or the global directory alone.
#[derive(Debug, Clone, Default)]
pub struct ToolInventory {
/// Every usable tool, built-ins first, then the agent's scripts, then the
/// global ones.
pub tools: Vec<ToolEntry>,
/// Every `.rhai` file that was found and could not be offered.
pub skipped: Vec<SkippedScript>,
}
impl ToolInventory {
/// Discover everything an agent rooted at `agent_dir` could call.
///
/// `agent_dir` is `None` for a question about the machine rather than about
/// one agent: built-ins and the global directory, with no agent scripts.
/// `agent_name` only labels the agent-scoped entries, so a caller that has
/// a directory but no name (the daemon's own offline lint) may leave it out.
///
/// A script whose name is already taken - by a built-in, by a sub-agent
/// tool, or by the agent's own copy shadowing a global one - is reported in
/// [`skipped`](Self::skipped) rather than listed twice. That mirrors what
/// the daemon does at spawn: the earlier directory wins and a core tool is
/// never shadowed, so listing the loser as available would be a lie.
pub fn discover(agent_dir: Option<&Path>, agent_name: Option<&str>) -> Self {
let ctx_dir = agent_dir.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let builtins = leviath_tools::BuiltinTools::new(leviath_tools::ToolContext::new(ctx_dir));
let mut tools: Vec<ToolEntry> = Vec::new();
for name in builtins.names() {
tools.push(ToolEntry {
name,
source: ToolSource::Builtin,
path: None,
agent: None,
});
}
for name in leviath_tools::BuiltinTools::subagent_tool_names() {
tools.push(ToolEntry {
name,
source: ToolSource::Subagent,
path: None,
agent: None,
});
}
let mut taken: HashSet<String> = tools.iter().map(|t| t.name.clone()).collect();
let mut skipped: Vec<SkippedScript> = Vec::new();
// The agent's own `tools/` first, then the global one every agent gets:
// the same order, and so the same precedence, the daemon scans in.
let scopes = [
(ToolSource::Agent, agent_dir.map(|d| d.join("tools"))),
(ToolSource::Global, leviath_core::tools_dir()),
];
for (source, dir) in scopes {
let Some(dir) = dir else {
continue;
};
let (set, failed) = leviath_scripting::ScriptToolSet::discover(&[dir]);
for f in failed {
skipped.push(SkippedScript {
path: f.path,
reason: f.reason,
source,
});
}
for (meta, path) in set.sources() {
if taken.contains(&meta.name) {
skipped.push(SkippedScript {
path,
reason: format!(
"the name '{}' is already taken by a tool that wins over this one",
meta.name
),
source,
});
continue;
}
taken.insert(meta.name.clone());
let agent = match source {
ToolSource::Agent => agent_name.map(str::to_string),
_ => None,
};
tools.push(ToolEntry {
name: meta.name,
source,
path: Some(path),
agent,
});
}
}
Self { tools, skipped }
}
/// Just the names, which is all the lint needs to answer "is this a tool".
pub fn names(&self) -> HashSet<String> {
self.tools.iter().map(|t| t.name.clone()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Point the global tools directory at a scratch root, so a developer's real
/// `~/.leviath/tools` never decides what these assert.
fn with_home<R>(f: impl FnOnce(&Path) -> R) -> R {
let dir = tempfile::tempdir().expect("a temp dir");
temp_env::with_var("LEVIATH_HOME", Some(dir.path()), || f(dir.path()))
}
/// The global tools directory under a `LEVIATH_HOME` scratch root.
fn global_tools(home: &Path) -> PathBuf {
let dir = home.join(".leviath").join("tools");
std::fs::create_dir_all(&dir).expect("the global tools dir");
dir
}
fn write_tool(dir: &Path, file: &str, body: &str) {
std::fs::create_dir_all(dir).expect("the tools dir");
std::fs::write(dir.join(file), body).expect("the script");
}
#[test]
fn source_names_are_the_wire_spelling() {
assert_eq!(ToolSource::Builtin.as_str(), "builtin");
assert_eq!(ToolSource::Subagent.as_str(), "subagent");
assert_eq!(ToolSource::Agent.as_str(), "agent");
assert_eq!(ToolSource::Global.as_str(), "global");
}
/// All four sources in one inventory, which is the whole point of the
/// `source` field: three different answers to whether a name will work.
#[test]
fn every_source_appears_with_the_path_behind_it() {
with_home(|home| {
let agent = home.join("agents").join("researcher");
write_tool(
&agent.join("tools"),
"web_search.rhai",
"// @tool web_search\n// @description searches\n1",
);
write_tool(
&global_tools(home),
"summarize.rhai",
"// @tool summarize\n// @description sums\n2",
);
let inv = ToolInventory::discover(Some(&agent), Some("researcher"));
let own = inv
.tools
.iter()
.find(|t| t.name == "web_search")
.expect("the agent's own tool");
assert_eq!(own.source, ToolSource::Agent);
assert_eq!(own.agent.as_deref(), Some("researcher"));
assert_eq!(
own.path.as_deref(),
Some(agent.join("tools").join("web_search.rhai").as_path())
);
let global = inv
.tools
.iter()
.find(|t| t.name == "summarize")
.expect("the global tool");
assert_eq!(global.source, ToolSource::Global);
assert!(global.agent.is_none());
assert!(global.path.is_some());
assert!(inv.tools.iter().any(|t| t.source == ToolSource::Builtin));
assert!(inv.tools.iter().any(|t| t.source == ToolSource::Subagent));
assert!(
inv.tools
.iter()
.all(|t| t.source != ToolSource::Builtin || t.path.is_none())
);
assert!(inv.skipped.is_empty());
});
}
/// Without a name, an agent-scoped entry still resolves; it just cannot say
/// whose it is. That is the daemon's own offline lint, which has the
/// directory and never had a name.
#[test]
fn an_unnamed_scope_still_finds_the_agents_own_tools() {
with_home(|home| {
let agent = home.join("agents").join("nameless");
write_tool(&agent.join("tools"), "local.rhai", "// @tool local\n1");
let inv = ToolInventory::discover(Some(&agent), None);
let own = inv
.tools
.iter()
.find(|t| t.name == "local")
.expect("the agent's own tool");
assert_eq!(own.source, ToolSource::Agent);
assert!(own.agent.is_none());
});
}
/// No agent means no agent scripts, and the global directory still answers.
#[test]
fn without_an_agent_only_the_machine_wide_scripts_are_listed() {
with_home(|home| {
write_tool(
&global_tools(home),
"summarize.rhai",
"// @tool summarize\n1",
);
let inv = ToolInventory::discover(None, None);
assert!(inv.tools.iter().any(|t| t.name == "summarize"));
assert!(inv.tools.iter().all(|t| t.source != ToolSource::Agent));
});
}
/// A file that will not compile is reported instead of quietly vanishing.
#[test]
fn a_script_that_does_not_compile_is_reported_with_its_reason() {
with_home(|home| {
let agent = home.join("agents").join("broken");
write_tool(&agent.join("tools"), "bad.rhai", "// no directive\nlet");
let inv = ToolInventory::discover(Some(&agent), Some("broken"));
assert_eq!(inv.skipped.len(), 1);
assert!(inv.skipped[0].path.ends_with("bad.rhai"));
assert_eq!(inv.skipped[0].source, ToolSource::Agent);
assert!(!inv.skipped[0].reason.is_empty());
});
}
/// A script named after a built-in never routes, so it is reported as
/// skipped rather than listed twice under two sources.
#[test]
fn a_script_shadowed_by_a_builtin_is_skipped_not_listed_twice() {
with_home(|home| {
let agent = home.join("agents").join("shadow");
write_tool(
&agent.join("tools"),
"read_file.rhai",
"// @tool read_file\n1",
);
let inv = ToolInventory::discover(Some(&agent), Some("shadow"));
let listed: Vec<_> = inv.tools.iter().filter(|t| t.name == "read_file").collect();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].source, ToolSource::Builtin);
assert_eq!(inv.skipped.len(), 1);
assert!(inv.skipped[0].reason.contains("already taken"));
});
}
/// The agent's own copy wins over the global one of the same name, and the
/// global copy is reported so a picker can explain why it is not the one
/// that will run.
#[test]
fn an_agents_own_script_wins_over_the_global_one() {
with_home(|home| {
let agent = home.join("agents").join("winner");
write_tool(&agent.join("tools"), "dup.rhai", "// @tool dup\n1");
write_tool(&global_tools(home), "dup.rhai", "// @tool dup\n2");
let inv = ToolInventory::discover(Some(&agent), Some("winner"));
let listed: Vec<_> = inv.tools.iter().filter(|t| t.name == "dup").collect();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].source, ToolSource::Agent);
assert_eq!(inv.skipped.len(), 1);
assert_eq!(inv.skipped[0].source, ToolSource::Global);
});
}
/// The set the lint checks `available_tools` against carries every listed
/// name and nothing that was skipped.
#[test]
fn names_carry_the_builtins_and_the_scripts_that_compiled() {
with_home(|home| {
let agent = home.join("agents").join("named");
write_tool(&agent.join("tools"), "ok.rhai", "// @tool ok\n1");
write_tool(&agent.join("tools"), "bad.rhai", "// nothing\nlet");
let names = ToolInventory::discover(Some(&agent), Some("named")).names();
assert!(names.contains("ok"));
assert!(names.contains("read_file"));
assert!(!names.contains("bad"));
});
}
}