mecha_core/tool/mod.rs
1//! Tools: the things an agent can actually do.
2//!
3//! A tool is a name, a description, a JSON Schema, and an async function. The
4//! registry holds them; MCP servers and native Rust functions both land here as
5//! the same trait object, so the agent loop never learns the difference.
6
7pub mod ask;
8pub mod builtin;
9pub mod recall;
10pub mod skill;
11pub mod todo;
12
13use crate::config::{PermissionMode, SecurityConfig, ToolsConfig};
14use crate::message::ToolSpec;
15use anyhow::Result;
16use async_trait::async_trait;
17use serde_json::Value;
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22#[derive(Debug, Clone)]
23pub struct ToolOutput {
24 pub content: String,
25 /// Returned to the model as `is_error: true` so it can recover rather than
26 /// treating the failure as a result.
27 pub is_error: bool,
28 /// True when this content actually came from outside the machine.
29 ///
30 /// Distinct from the tool's declared `untrusted_input` capability, which
31 /// says what the tool *can* return. A refusal generated by mecha's own
32 /// guards is not third-party content, and labelling it as such makes the
33 /// model invent explanations for its own harness's behaviour.
34 pub external: bool,
35}
36
37impl ToolOutput {
38 pub fn ok(content: impl Into<String>) -> Self {
39 ToolOutput {
40 content: content.into(),
41 is_error: false,
42 external: false,
43 }
44 }
45
46 pub fn err(content: impl Into<String>) -> Self {
47 ToolOutput {
48 content: content.into(),
49 is_error: true,
50 external: false,
51 }
52 }
53
54 /// Mark this content as having come from outside the machine.
55 pub fn from_outside(mut self) -> Self {
56 self.external = true;
57 self
58 }
59}
60
61/// What a tool can do — the vocabulary MCP standardized (`readOnly`,
62/// `destructive`, `openWorld`) plus the two axes that decide whether an agent
63/// can be turned into an exfiltration tool.
64///
65/// The *lethal trifecta* is private data + untrusted content + a way out. Any
66/// agent holding all three can be instructed, by text hidden in the content it
67/// reads, to take the private data and send it somewhere. Annotating tools on
68/// these axes is what lets the loop refuse that combination structurally.
69#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
70pub struct Capabilities {
71 /// Returns data the user considers private.
72 pub private_data: bool,
73 /// Returns content a third party can influence — a web page, an email body,
74 /// a calendar invite title. Treat everything it returns as hostile.
75 pub untrusted_input: bool,
76 /// Can transmit data outside the user's control. Note that a plain HTTP GET
77 /// qualifies: the secret goes in the query string.
78 pub external_send: bool,
79 /// May destroy or overwrite data.
80 pub destructive: bool,
81}
82
83impl Capabilities {
84 pub fn private(mut self) -> Self {
85 self.private_data = true;
86 self
87 }
88 pub fn untrusted(mut self) -> Self {
89 self.untrusted_input = true;
90 self
91 }
92 pub fn sends(mut self) -> Self {
93 self.external_send = true;
94 self
95 }
96 pub fn destructive(mut self) -> Self {
97 self.destructive = true;
98 self
99 }
100
101 /// Everything either side declares.
102 ///
103 /// Union rather than assignment, because the only safe direction for an
104 /// override is *wider*. Letting config narrow a tool's declared
105 /// capabilities would disarm the interlock on the strength of a claim
106 /// nothing enforces — the same mistake as a sandbox that silently degrades,
107 /// and it would make the cheapest configuration the most dangerous one. A
108 /// server that genuinely over-declares is what `TrifectaPolicy` is for: one
109 /// deliberate, visible decision instead of a quiet per-server exemption.
110 pub fn union(self, other: Capabilities) -> Self {
111 Capabilities {
112 private_data: self.private_data || other.private_data,
113 untrusted_input: self.untrusted_input || other.untrusted_input,
114 external_send: self.external_send || other.external_send,
115 destructive: self.destructive || other.destructive,
116 }
117 }
118}
119
120#[async_trait]
121pub trait Tool: Send + Sync {
122 fn name(&self) -> &str;
123 fn description(&self) -> &str;
124 fn input_schema(&self) -> Value;
125
126 /// Read-only tools skip the approval gate and are safe to run in parallel.
127 fn read_only(&self) -> bool {
128 false
129 }
130
131 /// Declared risk surface. The default is the conservative one for a tool
132 /// nobody has classified: assume it does nothing special.
133 fn capabilities(&self) -> Capabilities {
134 Capabilities::default()
135 }
136
137 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput>;
138
139 /// State this tool holds that a compaction must not lose.
140 ///
141 /// Compaction replaces the middle of a transcript with prose, and the
142 /// measured failure mode is that a summariser preserves *what is true* and
143 /// drops *how far you got*. Some of "how far you got" does not live in the
144 /// messages at all — it lives in a tool — and for that state a summary is
145 /// the wrong mechanism twice over: it is lossy, and the tool already has
146 /// the exact current answer.
147 ///
148 /// So a tool may hand its state to the compaction to be carried across
149 /// **verbatim**. Three rules make this safe rather than a second source of
150 /// truth:
151 ///
152 /// - It is read at compaction time, so it is current by construction. A
153 /// stale copy is impossible because nothing stores one.
154 /// - Exactly one copy survives: the carried block replaces the previous
155 /// one rather than accumulating beside it, or an old list would sit in
156 /// the prompt contradicting the new one.
157 /// - It is for state the tool *owns*, not a summary of what happened. A
158 /// tool that returned prose here would be smuggling a second summariser
159 /// into the loop, unvalidated.
160 ///
161 /// `None` — the default — means "nothing worth carrying", which is the
162 /// honest answer for every stateless tool.
163 fn carried_state(&self) -> Option<CarriedState> {
164 None
165 }
166
167 /// How the *operator* could make a call like the one just refused safe —
168 /// one sentence appended to a trifecta denial, or `None` when nothing
169 /// short of policy would change the answer.
170 ///
171 /// The interlock's refusal message has to route somewhere, and the loop
172 /// cannot write that route: it sees capability bits, and the same
173 /// `external_send: true` means "this is HTTP" on one tool and "the shell
174 /// is unconfined" on another, with completely different fixes. The tool is
175 /// the only party that knows which condition set the bit, so the tool
176 /// carries the remedy — same division of labour as
177 /// [`carried_state`](Tool::carried_state) and
178 /// [`fixed_workspace`](Tool::fixed_workspace): the loop learns that a
179 /// remedy exists, never what kind of tool it is talking to.
180 ///
181 /// This is the difference between a security posture that redirects work
182 /// and one that dead-ends it. A refusal that names no exit teaches the
183 /// operator to weaken policy (`trifecta = "allow"`), which is the worst
184 /// possible outcome of a control that was working correctly. The measured
185 /// case: `shell` denials in the TUI advised delegating to subagents, none
186 /// of which had a shell — advice that could not work, for a call whose
187 /// real fix (`[sandbox]`, one config section) went unmentioned.
188 ///
189 /// Addressed to the person, relayed by the model. It must not be an
190 /// instruction the model could act on itself — "enable X in config.toml"
191 /// is for hands on a keyboard, and a model that tried to do it would find
192 /// config edits are not among its tools.
193 fn denial_remedy(&self) -> Option<String> {
194 None
195 }
196
197 /// The root this tool's relative paths actually resolve against, when the
198 /// tool was constructed over a fixed directory rather than following the
199 /// per-run [`ToolCtx`] workspace.
200 ///
201 /// Most tools return `None` — the default — because they resolve paths
202 /// through the context they are called with. But a tool backed by a
203 /// process spawned once for many runs (an MCP server) resolves relative
204 /// paths against the directory it was spawned in, whatever workspace the
205 /// current run carries. A staged (deferred) call records a jail so its
206 /// release can rebuild the tool surface where the paths mean what they
207 /// meant at drafting time — and for these tools that jail must be the
208 /// spawn root, not the narrower per-run workspace, or every relative path
209 /// in the draft resolves outside the release jail forever.
210 ///
211 /// Like [`carried_state`](Tool::carried_state), the loop learns only that
212 /// some tools have a fixed root, never which kind of tool they are.
213 fn fixed_workspace(&self) -> Option<PathBuf> {
214 None
215 }
216
217 /// Tool names this tool is currently restricting the surface to, if it is
218 /// restricting it at all.
219 ///
220 /// The third method in the family with [`carried_state`](Tool::carried_state)
221 /// and [`fixed_workspace`](Tool::fixed_workspace), and it exists for the
222 /// same reason: the loop learns that *some* tool may narrow the surface,
223 /// never which kind of tool or why. A `skill` whose frontmatter names the
224 /// tools its procedure needs is the first caller, and the loop stays
225 /// unable to tell a skill from an MCP server.
226 ///
227 /// **Narrow only, never widen.** [`Registry::specs_for`] intersects the
228 /// restriction with what it already holds, so a name here that matches no
229 /// registered tool adds nothing — the same one-way rule as a config
230 /// capability override, and for the same reason: a mechanism that could
231 /// widen the surface by declaring a name would make the cheapest
232 /// configuration the most dangerous one.
233 ///
234 /// `None` — the default — is "no opinion", which is what every stateless
235 /// tool honestly has. Note that it is *not* the same as an empty list:
236 /// nothing may restrict the surface to nothing, and the parser refuses an
237 /// empty list upstream rather than leaving a run with no way to act.
238 fn narrows_surface_to(&self) -> Option<Vec<String>> {
239 None
240 }
241
242 /// Drop state that belonged to the conversation that just ended.
243 ///
244 /// Most tools are stateless and the default no-op is honest for them. A
245 /// tool that *is* stateful has a scope problem the registry cannot see:
246 /// the registry belongs to the **agent**, and an agent can outlive a
247 /// conversation — a batch item, a `/clear`, a Slack thread. State scoped
248 /// to the conversation therefore has to be told when one ends, or it
249 /// leaks into the next.
250 ///
251 /// The leak is not merely untidy where the state gates the tool surface:
252 /// a `skill` narrowing that survived would constrain a task nobody had
253 /// started yet. Same family as [`carried_state`](Tool::carried_state) —
254 /// the loop learns that some tools have conversation-scoped state, never
255 /// which ones or what it is.
256 ///
257 /// Note what this is *not*: an unload verb. Nothing calls it mid-run, and
258 /// a procedure that has been read cannot be un-read.
259 fn forget_conversation_state(&self) {}
260
261 fn spec(&self) -> ToolSpec {
262 ToolSpec {
263 name: self.name().to_string(),
264 description: self.description().to_string(),
265 input_schema: self.input_schema(),
266 }
267 }
268}
269
270/// A tool's own state, on its way across a compaction.
271///
272/// `label` names it in the rebuilt prompt (the tool's name is the obvious
273/// choice); `body` is reproduced exactly, because verbatim is the whole point.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct CarriedState {
276 pub label: String,
277 pub body: String,
278}
279
280/// What a tool is allowed to touch.
281#[derive(Debug, Clone)]
282pub struct ToolCtx {
283 /// Filesystem tools refuse paths outside this root.
284 pub workspace: PathBuf,
285 pub shell_timeout: std::time::Duration,
286 pub security: SecurityConfig,
287 /// The byte budget one *turn's* tool results share, divided equally
288 /// across the calls in the batch so one runaway tool cannot starve its
289 /// siblings (mecha executes a turn's calls concurrently, so they land
290 /// together). The old per-tool cap was 200 KB — ~50k tokens, 1.5× the
291 /// whole local context window, which is not a cap so much as a promise
292 /// to overflow.
293 pub output_budget_bytes: usize,
294 /// Where an oversized result is saved in full before its transcript copy
295 /// is cut. `None` disables spilling — the cut then names what was lost
296 /// instead of where to find it. Per-context on purpose: two eval cases
297 /// sharing one spill directory could read each other's output through it.
298 pub spill_dir: Option<PathBuf>,
299 /// The run's event channel, so a tool that *contains* a run — a subagent —
300 /// can surface its progress instead of going dark until it returns.
301 ///
302 /// Display-only, and treat it that way: any tool (including a third-party
303 /// MCP server's) can send fabricated events down this channel, so nothing
304 /// that matters may key off it. Conversation state, taint, and run
305 /// completion all come from the loop and the caller's join handle, never
306 /// from events. Stamped by [`Agent::run_in`] per run; `None` everywhere
307 /// nobody is watching (batch, eval).
308 ///
309 /// [`Agent::run_in`]: crate::agent::Agent::run_in
310 pub events: Option<tokio::sync::mpsc::UnboundedSender<crate::agent::AgentEvent>>,
311 /// The run's cancellation token. A tool that contains a run passes it on,
312 /// so cancelling the parent actually cancels the child instead of politely
313 /// waiting out its entire run. Stamped by `Agent::run_in`, like `events`.
314 pub cancel: Option<tokio_util::sync::CancellationToken>,
315 /// The run's phase. A tool that contains a run passes it on, so delegation
316 /// is not the way to get a write executed from a planning run. Stamped by
317 /// `Agent::run_in`, like `events`.
318 pub phase: crate::agent::Phase,
319 /// The `tool_use` id of the call this context was built for. Stamped per
320 /// dispatch (only when `events` is watched), so a tool that contains a
321 /// run can tag its forwarded events with the call that spawned it — two
322 /// subagents running in parallel are otherwise indistinguishable to a
323 /// renderer.
324 pub call_id: Option<String>,
325 /// The conversation's taint as of this turn, stamped per dispatch when a
326 /// mailbox is attached. The conservative pre-gate value — it includes
327 /// what the *batch* can return, so a read and a `message_send` in one
328 /// turn cannot stamp a clean label on the outgoing message. `None` means
329 /// nobody stamped it, and a consumer must fail closed (treat it as fully
330 /// tainted): a subagent's context, or any run wired outside the loop,
331 /// must never pass as a clean sender by omission.
332 pub taint: Option<crate::agent::Taint>,
333}
334
335impl Default for ToolCtx {
336 fn default() -> Self {
337 ToolCtx {
338 workspace: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
339 shell_timeout: std::time::Duration::from_secs(120),
340 security: SecurityConfig::default(),
341 output_budget_bytes: 24_000,
342 spill_dir: fresh_spill_dir(),
343 events: None,
344 cancel: None,
345 phase: crate::agent::Phase::default(),
346 call_id: None,
347 taint: None,
348 }
349 }
350}
351
352/// A spill directory no other context shares. Not created until first used.
353fn fresh_spill_dir() -> Option<PathBuf> {
354 Some(std::env::temp_dir().join(format!("mecha-spill-{}", uuid::Uuid::new_v4())))
355}
356
357impl ToolCtx {
358 /// The same policy pointed at a different root. Used to give one run — an
359 /// eval case, a batch item — its own isolated copy of a workspace without
360 /// rebuilding the agent around it. The spill directory is re-derived too:
361 /// a re-rooted context is a new isolation domain, and inheriting the old
362 /// one would let its runs read each other's spilled output.
363 pub fn with_workspace(&self, workspace: impl Into<PathBuf>) -> Self {
364 ToolCtx {
365 workspace: workspace.into(),
366 spill_dir: fresh_spill_dir(),
367 ..self.clone()
368 }
369 }
370
371 /// Resolve a model-supplied path against the workspace and prove it stays
372 /// inside. The path is untrusted input: `..`, symlinks, and absolute paths
373 /// all have to be checked after canonicalization, not before.
374 pub fn resolve(&self, raw: &str) -> Result<PathBuf> {
375 let candidate = {
376 let p = Path::new(raw);
377 if p.is_absolute() {
378 p.to_path_buf()
379 } else {
380 self.workspace.join(p)
381 }
382 };
383
384 // The file may not exist yet (a write), so canonicalize the nearest
385 // existing ancestor and re-append the rest.
386 let mut existing = candidate.as_path();
387 let mut trailing = Vec::new();
388 let canonical_root = loop {
389 match existing.canonicalize() {
390 Ok(c) => break c,
391 Err(_) => match existing.parent() {
392 Some(parent) => {
393 if let Some(name) = existing.file_name() {
394 trailing.push(name.to_owned());
395 }
396 existing = parent;
397 }
398 None => anyhow::bail!("cannot resolve path {raw:?}"),
399 },
400 }
401 };
402 let mut resolved = canonical_root;
403 for part in trailing.iter().rev() {
404 resolved.push(part);
405 }
406
407 let root = self
408 .workspace
409 .canonicalize()
410 .unwrap_or_else(|_| self.workspace.clone());
411 if resolved.starts_with(&root) {
412 return Ok(resolved);
413 }
414 // The spill directory is the one sanctioned exception: oversized tool
415 // output is saved there, and the truncation marker tells the model to
416 // read the rest from exactly that path. Its contents are this
417 // context's own tool results, so nothing new becomes reachable.
418 if let Some(spill) = &self.spill_dir {
419 let spill_root = spill.canonicalize().unwrap_or_else(|_| spill.clone());
420 if resolved.starts_with(&spill_root) {
421 return Ok(resolved);
422 }
423 }
424 anyhow::bail!(
425 "path {raw:?} resolves outside the workspace ({})",
426 root.display()
427 )
428 }
429}
430
431/// Floor under a result's share of the turn budget. A wide batch must not
432/// starve every result down to a marker with no content: below this, the
433/// division stops and the total budget is allowed to overrun instead.
434pub const SPILL_FLOOR_BYTES: usize = 4_096;
435
436/// Cut an oversized tool result down to `cap` bytes, saving the full output
437/// where the model can get it back.
438///
439/// The marker is written for the model, and it names the recovery — a
440/// truncation notice that only says "gone" leaves the model to conclude the
441/// rest never existed, and the elision line number is what makes the recovery
442/// a single call instead of a scan. A failed spill degrades to a plain cut
443/// that says the output was *not* saved; losing the tail must never lose the
444/// run.
445pub fn cap_result(
446 content: String,
447 cap: usize,
448 spill_dir: Option<&Path>,
449 tool: &str,
450 id: &str,
451) -> String {
452 if content.len() <= cap {
453 return content;
454 }
455 // Cut on a char boundary, never mid-codepoint.
456 let mut cut = cap;
457 while cut > 0 && !content.is_char_boundary(cut) {
458 cut -= 1;
459 }
460 let head = &content[..cut];
461 // The line the elision starts on. A cut mid-line means that same line —
462 // re-reading from it overlaps a little, which is the right direction.
463 let line = head.matches('\n').count() + 1;
464 let total = content.len();
465
466 let saved = spill_dir.and_then(|dir| {
467 // Owner-only: spilled output is tool results in full — the same
468 // sensitivity as the transcript, sitting in the shared temp dir.
469 crate::create_private_dir(dir).ok()?;
470 // A random component, because the call id alone can collide: batch
471 // items and non-sandboxed eval cases share one context, and a local
472 // server under a pinned seed can hand identical requests identical
473 // call ids. A collision would silently overwrite, leaving one
474 // conversation's marker pointing at another conversation's content.
475 let tag = &uuid::Uuid::new_v4().to_string()[..8];
476 let file = dir.join(format!("{}-{}-{tag}.txt", safe_name(tool), safe_name(id)));
477 std::fs::write(&file, &content).ok()?;
478 Some(file)
479 });
480
481 match saved {
482 Some(path) => format!(
483 "{head}\n\n[truncated by the harness: showing the first {cut} of {total} bytes; \
484 the rest begins on line {line}. The full output is saved at {path} — continue \
485 with fs_read {{\"path\": \"{path}\", \"offset\": {line}}}, or search it with \
486 grep.]",
487 path = path.display()
488 ),
489 None => format!(
490 "{head}\n\n[truncated by the harness: {omitted} of {total} bytes were dropped \
491 from line {line} on, and the full output could not be saved. Narrow the \
492 request and re-run the tool if the rest is needed.]",
493 omitted = total - cut
494 ),
495 }
496}
497
498/// Tool names and call ids become file names; anything else becomes `-`.
499fn safe_name(s: &str) -> String {
500 s.chars()
501 .map(|c| {
502 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
503 c
504 } else {
505 '-'
506 }
507 })
508 .collect()
509}
510
511/// The decision an approver hands back for one pending call.
512///
513/// Two ways to say no, and the difference is load-bearing rather than
514/// cosmetic. The learning miner keys on the exact string `"Denied by the
515/// user:"` to find corrections worth learning from, so **a refusal that no
516/// human made must not wear that label**. `Blocked` is the machine's no — a
517/// permission mode, a policy, a remote prompt nobody answered — and the loop
518/// renders it as `"Blocked by policy:"`, joining `"Blocked by a hook:"` in
519/// the family of refusals the miner ignores.
520///
521/// Without the split, a read-only run's refusals and a 2am approval nobody was
522/// awake to answer both become training data attributed to a user who never
523/// spoke. It is the same mistake as mining a publish's changed path as a voice
524/// correction, and it was live in `ModeApprover` until a Slack approver needed
525/// to express "nobody answered" and found there was no way to.
526#[derive(Debug, Clone)]
527pub enum Decision {
528 Allow,
529 /// A human said no. The reason is passed to the model so it can pick
530 /// another approach — and it is mined as a correction.
531 Deny(String),
532 /// Machine policy said no, and no human was consulted. Never mined.
533 Blocked(String),
534}
535
536/// Gates tool calls that aren't read-only. The CLI implements this with a
537/// terminal prompt; a headless caller can auto-allow or auto-deny.
538#[async_trait]
539pub trait Approver: Send + Sync {
540 async fn approve(&self, tool: &dyn Tool, input: &Value) -> Decision;
541}
542
543/// Answers from the configured [`PermissionMode`] without asking anyone.
544pub struct ModeApprover {
545 pub mode: PermissionMode,
546}
547
548#[async_trait]
549impl Approver for ModeApprover {
550 async fn approve(&self, tool: &dyn Tool, _input: &Value) -> Decision {
551 match self.mode {
552 PermissionMode::Allow => Decision::Allow,
553 PermissionMode::ReadOnly if tool.read_only() => Decision::Allow,
554 // `Blocked`, not `Deny`: a permission mode is policy this run was
555 // started with, not a correction anybody made.
556 PermissionMode::ReadOnly => Decision::Blocked(format!(
557 "`{}` modifies state and this run is read-only",
558 tool.name()
559 )),
560 // Nothing is watching to answer, so the safe reading of "ask" is no.
561 PermissionMode::Ask => Decision::Blocked(format!(
562 "`{}` needs approval and this run is non-interactive (use --yes to allow)",
563 tool.name()
564 )),
565 }
566 }
567}
568
569#[derive(Default)]
570pub struct Registry {
571 tools: BTreeMap<String, Arc<dyn Tool>>,
572}
573
574impl Registry {
575 pub fn new() -> Self {
576 Self::default()
577 }
578
579 /// Register a tool. A later registration with the same name replaces the
580 /// earlier one, so MCP servers can shadow built-ins deliberately.
581 pub fn insert(&mut self, tool: Arc<dyn Tool>) {
582 self.tools.insert(tool.name().to_string(), tool);
583 }
584
585 pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
586 self.tools.get(name)
587 }
588
589 /// The tool a call may actually reach: registered **and** inside whatever
590 /// restriction is currently active.
591 ///
592 /// Dispatch goes through this rather than [`get`](Registry::get), because
593 /// a restriction that only shortened the spec list would be advisory. A
594 /// model that saw `fs_write` three turns ago can still name it, and a
595 /// narrowing enforced only in the list is one the model routes around by
596 /// remembering — the same reason the phase filter makes tools genuinely
597 /// absent rather than merely refused.
598 pub fn available(&self, name: &str) -> Option<&Arc<dyn Tool>> {
599 let tool = self.tools.get(name)?;
600 match self.surface_restriction() {
601 Some(allowed) if !allowed.contains(name) => None,
602 _ => Some(tool),
603 }
604 }
605
606 /// Names a call may reach right now, for the message that says so.
607 pub fn available_names(&self) -> Vec<&str> {
608 let restriction = self.surface_restriction();
609 self.tools
610 .values()
611 .map(|t| t.name())
612 .filter(|n| {
613 restriction
614 .as_ref()
615 .is_none_or(|allowed| allowed.contains(*n))
616 })
617 .collect()
618 }
619
620 pub fn is_empty(&self) -> bool {
621 self.tools.is_empty()
622 }
623
624 pub fn len(&self) -> usize {
625 self.tools.len()
626 }
627
628 pub fn iter(&self) -> impl Iterator<Item = &Arc<dyn Tool>> {
629 self.tools.values()
630 }
631
632 /// Everything the registered tools want carried across a compaction.
633 ///
634 /// In the registry's stable order, so a compaction does not reorder the
635 /// prompt for a reason nobody can see. Asked of every tool, including an
636 /// MCP server's — the loop does not learn which tools have state, only
637 /// that some do, which is the same reason it never learns where a tool
638 /// came from.
639 pub fn carried_state(&self) -> Vec<CarriedState> {
640 self.tools
641 .values()
642 .filter_map(|t| t.carried_state())
643 .collect()
644 }
645
646 /// Specs in a stable order — the tool list is the very front of the prompt
647 /// prefix, so reordering it would invalidate the cache on every request.
648 pub fn specs(&self) -> Vec<ToolSpec> {
649 self.tools.values().map(|t| t.spec()).collect()
650 }
651
652 /// Specs a given phase permits, in the same stable order.
653 ///
654 /// Note what this does to the prompt cache: planning sends a shorter tool
655 /// list, so switching phase changes the front of the prefix and the next
656 /// turn re-pays for it. That is the price of the tools being genuinely
657 /// absent rather than merely refused, and it is the right trade.
658 pub fn specs_for(&self, phase: crate::agent::Phase) -> Vec<ToolSpec> {
659 let restriction = self.surface_restriction();
660 self.tools
661 .values()
662 .filter(|t| phase.allows(t.read_only()))
663 .filter(|t| {
664 restriction
665 .as_ref()
666 .is_none_or(|allowed| allowed.contains(t.name()))
667 })
668 .map(|t| t.spec())
669 .collect()
670 }
671
672 /// The names the surface is currently narrowed to, if anything is
673 /// narrowing it.
674 ///
675 /// The **union** across everything that has an opinion, which is the only
676 /// composition that lets two restrictions coexist: each names the tools
677 /// its own procedure needs, and intersecting them would strand a run that
678 /// loaded two skills. The invariant that matters is not "smallest" but
679 /// "never larger than the unrestricted surface", and a union of subsets is
680 /// still a subset — [`specs_for`](Registry::specs_for) intersects with
681 /// what is registered, so a name nothing matches adds nothing.
682 ///
683 /// A tool that is *itself* restricting stays in the surface whatever it
684 /// declared. Otherwise the first `skill` call could remove `skill`, and a
685 /// procedure that says "then load the follow-up skill" would name a tool
686 /// that had just been taken away — a restriction that eats its own
687 /// mechanism is a trap rather than a policy.
688 /// Tell every tool the conversation ended. See
689 /// [`Tool::forget_conversation_state`].
690 pub fn forget_conversation_state(&self) {
691 for tool in self.tools.values() {
692 tool.forget_conversation_state();
693 }
694 }
695
696 pub fn surface_restriction(&self) -> Option<BTreeSet<String>> {
697 let mut allowed: Option<BTreeSet<String>> = None;
698 for tool in self.tools.values() {
699 let Some(names) = tool.narrows_surface_to() else {
700 continue;
701 };
702 let set = allowed.get_or_insert_with(BTreeSet::new);
703 set.extend(names);
704 set.insert(tool.name().to_string());
705 }
706 allowed
707 }
708
709 /// Register the built-ins permitted by config.
710 ///
711 /// The sandbox is passed in rather than read from config here because it
712 /// changes what `shell` *is* — an unconfined shell and a confined one
713 /// declare different capabilities, and the loop's interlock reads them.
714 pub fn with_builtins(
715 mut self,
716 cfg: &ToolsConfig,
717 sandbox: Arc<crate::sandbox::Sandbox>,
718 ) -> Self {
719 for tool in builtin::all(sandbox) {
720 let name = tool.name();
721 let allowed = cfg.enabled.is_empty() || cfg.enabled.iter().any(|e| e == name);
722 let blocked = cfg.disabled.iter().any(|d| d == name);
723 if allowed && !blocked {
724 self.insert(tool);
725 }
726 }
727 self
728 }
729}
730
731#[cfg(test)]
732mod cap_tests {
733 use super::*;
734 use serde_json::json;
735
736 fn scratch(name: &str) -> PathBuf {
737 let dir = std::env::temp_dir().join(format!("mecha-cap-{name}-{}", uuid::Uuid::new_v4()));
738 std::fs::create_dir_all(&dir).unwrap();
739 dir
740 }
741
742 #[test]
743 fn a_result_under_the_cap_is_untouched() {
744 let out = cap_result("short".into(), 100, None, "shell", "t1");
745 assert_eq!(out, "short");
746 }
747
748 #[test]
749 fn an_oversized_result_is_spilled_whole_and_the_marker_names_the_recovery() {
750 let dir = scratch("spill");
751 let body: String = (1..=100).map(|i| format!("line {i}\n")).collect();
752
753 let out = cap_result(body.clone(), 200, Some(&dir), "shell", "t1");
754
755 // The transcript copy is bounded...
756 assert!(out.len() < body.len());
757 assert!(out.starts_with("line 1\n"));
758 // ...the disk copy is not: byte-identical, so nothing was lost. The
759 // name carries a random tag, so it is discovered rather than assumed.
760 let file = std::fs::read_dir(&dir)
761 .unwrap()
762 .next()
763 .unwrap()
764 .unwrap()
765 .path();
766 assert!(file
767 .file_name()
768 .unwrap()
769 .to_str()
770 .unwrap()
771 .starts_with("shell-t1-"));
772 assert_eq!(std::fs::read_to_string(&file).unwrap(), body);
773
774 // The marker gives the model a single call back to the rest: the
775 // path, and the line the elision starts on.
776 let line = body[..200].matches('\n').count() + 1;
777 assert!(out.contains(&file.display().to_string()), "{out}");
778 assert!(out.contains(&format!("\"offset\": {line}")), "{out}");
779 assert!(out.contains("fs_read"), "the recovery must be named: {out}");
780
781 std::fs::remove_dir_all(&dir).ok();
782 }
783
784 #[test]
785 fn a_failed_spill_degrades_to_a_cut_that_admits_the_loss() {
786 // A directory that cannot exist: spilling fails, the run must not.
787 let impossible = PathBuf::from("/dev/null/not-a-dir");
788 let body = "x".repeat(1000);
789 let out = cap_result(body, 100, Some(&impossible), "shell", "t1");
790
791 assert!(out.contains("could not be saved"), "{out}");
792 assert!(
793 out.contains("re-run the tool"),
794 "the fallback still names a recovery: {out}"
795 );
796 assert!(
797 !out.contains("/dev/null"),
798 "no path is promised that does not exist"
799 );
800 }
801
802 #[test]
803 fn the_cut_lands_on_a_char_boundary() {
804 // A cap that falls mid-codepoint must back up, not panic.
805 let body = "é".repeat(100); // 2 bytes per char
806 let out = cap_result(body, 33, None, "shell", "t1");
807 assert!(out.starts_with(&"é".repeat(16)));
808 }
809
810 #[test]
811 fn the_jail_admits_the_spill_directory_and_nothing_else_new() {
812 let workspace = scratch("ws");
813 let spill = scratch("spilldir");
814 let ctx = ToolCtx {
815 workspace: workspace.clone(),
816 spill_dir: Some(spill.clone()),
817 ..ToolCtx::default()
818 };
819
820 // The marker names an absolute spill path; fs_read must be able to
821 // follow it, or the recovery the model was promised is a lie.
822 std::fs::write(spill.join("shell-t1.txt"), "spilled").unwrap();
823 let resolved = ctx
824 .resolve(&spill.join("shell-t1.txt").display().to_string())
825 .unwrap();
826 assert!(resolved.ends_with("shell-t1.txt"));
827
828 // The exception is the spill directory, not the temp dir around it.
829 let elsewhere = std::env::temp_dir().join("mecha-cap-elsewhere.txt");
830 std::fs::write(&elsewhere, "no").unwrap();
831 assert!(ctx.resolve(&elsewhere.display().to_string()).is_err());
832
833 // And with spilling disabled there is no exception at all.
834 let no_spill = ToolCtx {
835 workspace,
836 spill_dir: None,
837 ..ToolCtx::default()
838 };
839 assert!(no_spill
840 .resolve(&spill.join("shell-t1.txt").display().to_string())
841 .is_err());
842
843 std::fs::remove_dir_all(&spill).ok();
844 std::fs::remove_file(&elsewhere).ok();
845 }
846
847 #[test]
848 fn a_rerooted_context_gets_its_own_spill_directory() {
849 // Two eval cases sharing one spill directory could read each other's
850 // output through it — the same isolation rule as the workspace copy.
851 let ctx = ToolCtx::default();
852 let rerooted = ctx.with_workspace(std::env::temp_dir());
853 assert_ne!(ctx.spill_dir, rerooted.spill_dir);
854 }
855
856 /// A tool that declares a restriction, so the registry rules can be tested
857 /// without a skill store on disk.
858 struct Narrowing(&'static str, Option<Vec<String>>);
859
860 #[async_trait]
861 impl Tool for Narrowing {
862 fn name(&self) -> &str {
863 self.0
864 }
865 fn description(&self) -> &str {
866 "test"
867 }
868 fn input_schema(&self) -> Value {
869 json!({"type": "object"})
870 }
871 fn read_only(&self) -> bool {
872 true
873 }
874 fn narrows_surface_to(&self) -> Option<Vec<String>> {
875 self.1.clone()
876 }
877 async fn call(&self, _i: Value, _c: &ToolCtx) -> Result<ToolOutput> {
878 Ok(ToolOutput::ok(""))
879 }
880 }
881
882 fn registry_with(tools: Vec<Arc<dyn Tool>>) -> Registry {
883 let mut r = Registry::new();
884 for t in tools {
885 r.insert(t);
886 }
887 r
888 }
889
890 #[test]
891 fn nothing_narrows_until_something_says_so() {
892 let r = registry_with(vec![
893 Arc::new(Narrowing("a", None)),
894 Arc::new(Narrowing("b", None)),
895 ]);
896 assert!(r.surface_restriction().is_none());
897 assert_eq!(r.specs_for(crate::agent::Phase::Execute).len(), 2);
898 }
899
900 #[test]
901 fn a_restriction_can_never_widen_the_surface() {
902 // The invariant the whole mechanism rests on. `gate` names a tool that
903 // is not registered; naming it must not conjure it, or a mechanism
904 // that declares a name could add capability rather than remove it.
905 let r = registry_with(vec![
906 Arc::new(Narrowing("a", None)),
907 Arc::new(Narrowing("b", None)),
908 Arc::new(Narrowing(
909 "gate",
910 Some(vec!["a".into(), "not_registered".into()]),
911 )),
912 ]);
913 let names: Vec<String> = r
914 .specs_for(crate::agent::Phase::Execute)
915 .into_iter()
916 .map(|s| s.name)
917 .collect();
918 assert!(names.contains(&"a".to_string()));
919 assert!(!names.contains(&"b".to_string()), "b was narrowed away");
920 assert!(
921 !names.iter().any(|n| n == "not_registered"),
922 "a name nothing matches adds nothing: {names:?}"
923 );
924 assert!(
925 names.contains(&"gate".to_string()),
926 "the tool doing the narrowing stays reachable, or it eats its own mechanism"
927 );
928 }
929
930 #[test]
931 fn a_narrowed_tool_is_out_of_reach_for_dispatch_and_not_merely_unlisted() {
932 // A shorter spec list alone would be advisory: a model that saw `b`
933 // three turns ago can still name it.
934 let r = registry_with(vec![
935 Arc::new(Narrowing("a", None)),
936 Arc::new(Narrowing("b", None)),
937 Arc::new(Narrowing("gate", Some(vec!["a".into()]))),
938 ]);
939 assert!(r.available("a").is_some());
940 assert!(r.available("b").is_none(), "narrowed away, so unreachable");
941 assert!(
942 r.get("b").is_some(),
943 "still registered — `get` is a lookup, `available` is the gate"
944 );
945 assert!(!r.available_names().contains(&"b"));
946 }
947
948 #[test]
949 fn two_restrictions_union_rather_than_intersect() {
950 // Intersecting would strand a run that loaded two skills, each naming
951 // what its own procedure needs. The union is still a subset of the
952 // registered surface, which is the property that matters.
953 let r = registry_with(vec![
954 Arc::new(Narrowing("a", None)),
955 Arc::new(Narrowing("b", None)),
956 Arc::new(Narrowing("c", None)),
957 Arc::new(Narrowing("g1", Some(vec!["a".into()]))),
958 Arc::new(Narrowing("g2", Some(vec!["b".into()]))),
959 ]);
960 let allowed = r.surface_restriction().unwrap();
961 assert!(allowed.contains("a") && allowed.contains("b"));
962 assert!(!allowed.contains("c"), "still a subset: {allowed:?}");
963 }
964}