Skip to main content

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}