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
pub mod accounts;
pub mod approval;
pub mod bindings;
pub mod caatinga;
pub mod docs;
pub mod events;
pub mod file;
pub mod invoke;
pub mod mainnet;
pub mod party;
pub mod paths;
pub mod persona;
pub mod plugin;
pub mod project;
pub mod search;
pub mod skill;
pub mod test;
pub mod update;
use async_trait::async_trait;
use serde_json::Value;
// A Stellar contract id is exactly 56 chars of uppercase base32 starting with 'C'.
pub fn is_contract_id(candidate: &str) -> bool {
candidate.len() == 56
&& candidate.starts_with('C')
&& candidate
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn input_schema(&self) -> Value;
async fn execute(&self, input: Value) -> Result<String, String>;
/// What this tool does, for `crate::risk` to decide what that means here.
///
/// Declared per tool rather than looked up in a table keyed by name, because a table only ever
/// knows the names its author wrote down: MCP tools arrive as `server__tool` and plugin tools
/// are named by a user-authored manifest, and both used to fall through such a table as
/// "nothing to ask about". The default is the cautious one, so forgetting to classify a tool
/// costs a prompt rather than the user's files.
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::default()
}
}
/// Watches every call the model makes.
///
/// Attached to the registry for the same reason the gate is: `execute` is the one path every caller
/// shares, so `spawn_agent`, `run_skill` and party mode are counted too. An observer that sat in
/// the turn loop would have measured only the calls the top-level agent made itself, which is not
/// what a benchmark is asking about.
pub trait ToolObserver: Send + Sync {
/// Called once per call, after it resolves. `Err` carries what the model was told, denials
/// included — a refused call is an outcome, not an absence of one.
fn observe(&self, tool: &str, outcome: Result<(), &str>);
}
pub struct ToolRegistry {
tools: Vec<Box<dyn Tool>>,
/// `None` means nothing is gated — the registry runs whatever it is asked to. That is what a
/// test wants and what the real app must never have, so the app builds its registry through
/// `with_approver`.
approver: Option<std::sync::Arc<approval::Approver>>,
observer: Option<std::sync::Arc<dyn ToolObserver>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: Vec::new(),
approver: None,
observer: None,
}
}
pub fn with_approver(approver: std::sync::Arc<approval::Approver>) -> Self {
Self {
tools: Vec::new(),
approver: Some(approver),
observer: None,
}
}
/// Attaches an observer. There is one, not a list: the caller that wants several can fan out
/// behind its own, and a registry holding a list would invite ordering questions nobody needs.
pub fn observe(&mut self, observer: std::sync::Arc<dyn ToolObserver>) {
self.observer = Some(observer);
}
pub fn register(&mut self, tool: Box<dyn Tool>) {
self.tools.push(tool);
}
// Plugin tools are named by a user-authored manifest, so a name may collide with a builtin.
// Refusing keeps a plugin from shadowing something like `write_file`.
pub fn try_register(&mut self, tool: Box<dyn Tool>) -> Result<(), String> {
if self.get_tool(tool.name()).is_some() {
return Err(format!(
"tool '{}' is already registered and was skipped",
tool.name()
));
}
self.tools.push(tool);
Ok(())
}
/// Drops every tool the predicate rejects.
///
/// How a specialist's registry is built: see `runtime::persona_tools`. Removing the tool is the
/// point — a restriction expressed as a prompt is a request, and one expressed as an absent
/// tool is a fact.
pub fn retain(&mut self, keep: impl Fn(&dyn Tool) -> bool) {
self.tools.retain(|tool| keep(tool.as_ref()));
}
pub fn get_tool(&self, name: &str) -> Option<&dyn Tool> {
self.tools
.iter()
.find(|t| t.name() == name)
.map(|t| t.as_ref())
}
pub fn definitions(&self) -> Vec<crate::agent::ToolDefinition> {
self.tools
.iter()
.map(|t| crate::agent::ToolDefinition {
name: t.name().to_string(),
description: t.description().to_string(),
input_schema: t.input_schema(),
})
.collect()
}
pub async fn execute(&self, name: &str, input: Value) -> Result<String, String> {
// Reported, not just returned: a call to a tool that does not exist is the clearest signal
// there is that the model invented a capability, and it is the one outcome an observer
// hooked further in would never see.
let Some(tool) = self.get_tool(name) else {
let unknown = format!("Unknown tool: {}", name);
self.report(name, Err(&unknown));
return Err(unknown);
};
// Asked here, at the one place every caller funnels through, rather than in the turn loop:
// `agent::subagent` runs tools too, and a gate in the loop would have left `spawn_agent`,
// `run_skill` and `party_mode` as a way around it.
// A denial is reported to the observer like any other outcome, so it is done inside this
// function rather than around the `execute` below.
if let Some(approver) = &self.approver {
if let Err(refusal) = approver.approve(name, tool.capability(), &input).await {
self.report(name, Err(&refusal));
return Err(refusal);
}
}
let outcome = tool.execute(input).await;
self.report(name, outcome.as_ref().map(|_| ()).map_err(String::as_str));
outcome
}
fn report(&self, name: &str, outcome: Result<(), &str>) {
if let Some(observer) = &self.observer {
observer.observe(name, outcome);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
// Stands in for a slow external CLI: it awaits, so a correctly-written tool yields the
// runtime while it waits.
struct SlowTool;
#[async_trait]
impl Tool for SlowTool {
fn name(&self) -> &str {
"slow"
}
fn description(&self) -> &str {
"sleeps"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
// Slow, but it changes nothing — it stands in for a lookup against a remote server.
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
async fn execute(&self, _input: Value) -> Result<String, String> {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
Ok("done".to_string())
}
}
#[tokio::test(flavor = "current_thread")]
async fn a_slow_tool_does_not_stall_the_render_loop() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(SlowTool));
let frames = Arc::new(AtomicUsize::new(0));
let ticker = {
let frames = frames.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
frames.fetch_add(1, Ordering::Relaxed);
}
})
};
let result = registry.execute("slow", json!({})).await.unwrap();
ticker.abort();
assert_eq!(result, "done");
// A single-threaded runtime is used on purpose: a blocking tool would starve the ticker
// completely, exactly as it starved the TUI redraw.
assert!(
frames.load(Ordering::Relaxed) > 5,
"render loop only advanced {} frames during a 300ms tool",
frames.load(Ordering::Relaxed)
);
}
// Stands in for a tool that changes something. Named `write_file` because the gate keys off
// the name, and counts its runs so a blocked call is distinguishable from a silent one.
struct WritingTool(Arc<AtomicUsize>);
#[async_trait]
impl Tool for WritingTool {
fn name(&self) -> &str {
"write_file"
}
fn description(&self) -> &str {
"writes"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
async fn execute(&self, _input: Value) -> Result<String, String> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok("written".to_string())
}
}
#[tokio::test]
async fn a_denied_tool_never_reaches_its_own_execute() {
let (updates, _updates_rx) = tokio::sync::mpsc::unbounded_channel();
let (decisions_tx, decisions) = tokio::sync::mpsc::unbounded_channel();
let runs = Arc::new(AtomicUsize::new(0));
let mut registry = ToolRegistry::with_approver(Arc::new(approval::Approver::new(
updates,
decisions,
crate::channels::CancelFlag::default(),
)));
registry.register(Box::new(WritingTool(runs.clone())));
decisions_tx
.send(crate::channels::ApprovalDecision::Deny)
.unwrap();
assert!(registry.execute("write_file", json!({})).await.is_err());
assert_eq!(
runs.load(Ordering::SeqCst),
0,
"a denial that still ran the tool is not a denial"
);
// And the gate is not a one-shot: approving lets the same call through.
decisions_tx
.send(crate::channels::ApprovalDecision::Once)
.unwrap();
assert!(registry.execute("write_file", json!({})).await.is_ok());
assert_eq!(runs.load(Ordering::SeqCst), 1);
}
// The gate sits on `execute` rather than in the turn loop precisely so that `agent::subagent`,
// which calls `execute` directly, cannot route around it.
#[tokio::test]
async fn the_gate_is_on_the_path_every_caller_shares() {
let (updates, mut updates_rx) = tokio::sync::mpsc::unbounded_channel();
let (decisions_tx, decisions) = tokio::sync::mpsc::unbounded_channel();
let runs = Arc::new(AtomicUsize::new(0));
let mut registry = ToolRegistry::with_approver(Arc::new(approval::Approver::new(
updates,
decisions,
crate::channels::CancelFlag::default(),
)));
registry.register(Box::new(WritingTool(runs.clone())));
registry.register(Box::new(SlowTool));
decisions_tx
.send(crate::channels::ApprovalDecision::Once)
.unwrap();
let _ = registry.execute("write_file", json!({})).await;
assert!(
matches!(
updates_rx.try_recv(),
Ok(crate::channels::AgentUpdate::Approval(_))
),
"execute must be what raises the question"
);
// A tool that changes nothing still runs without one.
assert!(registry.execute("slow", json!({})).await.is_ok());
assert!(updates_rx.try_recv().is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn npx_check_does_not_spawn_a_process() {
// A PATH lookup must stay fast enough to sit on the hot path of every build/deploy.
let start = std::time::Instant::now();
for _ in 0..50 {
let _ = crate::tools::caatinga::check_npx_available();
}
assert!(
start.elapsed() < std::time::Duration::from_millis(250),
"50 npx checks took {:?}; that is process-spawn territory",
start.elapsed()
);
}
}