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 todo;
10
11use crate::config::{PermissionMode, SecurityConfig, ToolsConfig};
12use crate::message::ToolSpec;
13use anyhow::Result;
14use async_trait::async_trait;
15use serde_json::Value;
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20#[derive(Debug, Clone)]
21pub struct ToolOutput {
22    pub content: String,
23    /// Returned to the model as `is_error: true` so it can recover rather than
24    /// treating the failure as a result.
25    pub is_error: bool,
26    /// True when this content actually came from outside the machine.
27    ///
28    /// Distinct from the tool's declared `untrusted_input` capability, which
29    /// says what the tool *can* return. A refusal generated by mecha's own
30    /// guards is not third-party content, and labelling it as such makes the
31    /// model invent explanations for its own harness's behaviour.
32    pub external: bool,
33}
34
35impl ToolOutput {
36    pub fn ok(content: impl Into<String>) -> Self {
37        ToolOutput {
38            content: content.into(),
39            is_error: false,
40            external: false,
41        }
42    }
43
44    pub fn err(content: impl Into<String>) -> Self {
45        ToolOutput {
46            content: content.into(),
47            is_error: true,
48            external: false,
49        }
50    }
51
52    /// Mark this content as having come from outside the machine.
53    pub fn from_outside(mut self) -> Self {
54        self.external = true;
55        self
56    }
57}
58
59/// What a tool can do — the vocabulary MCP standardized (`readOnly`,
60/// `destructive`, `openWorld`) plus the two axes that decide whether an agent
61/// can be turned into an exfiltration tool.
62///
63/// The *lethal trifecta* is private data + untrusted content + a way out. Any
64/// agent holding all three can be instructed, by text hidden in the content it
65/// reads, to take the private data and send it somewhere. Annotating tools on
66/// these axes is what lets the loop refuse that combination structurally.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68pub struct Capabilities {
69    /// Returns data the user considers private.
70    pub private_data: bool,
71    /// Returns content a third party can influence — a web page, an email body,
72    /// a calendar invite title. Treat everything it returns as hostile.
73    pub untrusted_input: bool,
74    /// Can transmit data outside the user's control. Note that a plain HTTP GET
75    /// qualifies: the secret goes in the query string.
76    pub external_send: bool,
77    /// May destroy or overwrite data.
78    pub destructive: bool,
79}
80
81impl Capabilities {
82    pub fn private(mut self) -> Self {
83        self.private_data = true;
84        self
85    }
86    pub fn untrusted(mut self) -> Self {
87        self.untrusted_input = true;
88        self
89    }
90    pub fn sends(mut self) -> Self {
91        self.external_send = true;
92        self
93    }
94    pub fn destructive(mut self) -> Self {
95        self.destructive = true;
96        self
97    }
98
99    /// Everything either side declares.
100    ///
101    /// Union rather than assignment, because the only safe direction for an
102    /// override is *wider*. Letting config narrow a tool's declared
103    /// capabilities would disarm the interlock on the strength of a claim
104    /// nothing enforces — the same mistake as a sandbox that silently degrades,
105    /// and it would make the cheapest configuration the most dangerous one. A
106    /// server that genuinely over-declares is what `TrifectaPolicy` is for: one
107    /// deliberate, visible decision instead of a quiet per-server exemption.
108    pub fn union(self, other: Capabilities) -> Self {
109        Capabilities {
110            private_data: self.private_data || other.private_data,
111            untrusted_input: self.untrusted_input || other.untrusted_input,
112            external_send: self.external_send || other.external_send,
113            destructive: self.destructive || other.destructive,
114        }
115    }
116}
117
118#[async_trait]
119pub trait Tool: Send + Sync {
120    fn name(&self) -> &str;
121    fn description(&self) -> &str;
122    fn input_schema(&self) -> Value;
123
124    /// Read-only tools skip the approval gate and are safe to run in parallel.
125    fn read_only(&self) -> bool {
126        false
127    }
128
129    /// Declared risk surface. The default is the conservative one for a tool
130    /// nobody has classified: assume it does nothing special.
131    fn capabilities(&self) -> Capabilities {
132        Capabilities::default()
133    }
134
135    async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput>;
136
137    /// State this tool holds that a compaction must not lose.
138    ///
139    /// Compaction replaces the middle of a transcript with prose, and the
140    /// measured failure mode is that a summariser preserves *what is true* and
141    /// drops *how far you got*. Some of "how far you got" does not live in the
142    /// messages at all — it lives in a tool — and for that state a summary is
143    /// the wrong mechanism twice over: it is lossy, and the tool already has
144    /// the exact current answer.
145    ///
146    /// So a tool may hand its state to the compaction to be carried across
147    /// **verbatim**. Three rules make this safe rather than a second source of
148    /// truth:
149    ///
150    /// - It is read at compaction time, so it is current by construction. A
151    ///   stale copy is impossible because nothing stores one.
152    /// - Exactly one copy survives: the carried block replaces the previous
153    ///   one rather than accumulating beside it, or an old list would sit in
154    ///   the prompt contradicting the new one.
155    /// - It is for state the tool *owns*, not a summary of what happened. A
156    ///   tool that returned prose here would be smuggling a second summariser
157    ///   into the loop, unvalidated.
158    ///
159    /// `None` — the default — means "nothing worth carrying", which is the
160    /// honest answer for every stateless tool.
161    fn carried_state(&self) -> Option<CarriedState> {
162        None
163    }
164
165    fn spec(&self) -> ToolSpec {
166        ToolSpec {
167            name: self.name().to_string(),
168            description: self.description().to_string(),
169            input_schema: self.input_schema(),
170        }
171    }
172}
173
174/// A tool's own state, on its way across a compaction.
175///
176/// `label` names it in the rebuilt prompt (the tool's name is the obvious
177/// choice); `body` is reproduced exactly, because verbatim is the whole point.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct CarriedState {
180    pub label: String,
181    pub body: String,
182}
183
184/// What a tool is allowed to touch.
185#[derive(Debug, Clone)]
186pub struct ToolCtx {
187    /// Filesystem tools refuse paths outside this root.
188    pub workspace: PathBuf,
189    pub shell_timeout: std::time::Duration,
190    pub security: SecurityConfig,
191    /// The byte budget one *turn's* tool results share, divided equally
192    /// across the calls in the batch so one runaway tool cannot starve its
193    /// siblings (mecha executes a turn's calls concurrently, so they land
194    /// together). The old per-tool cap was 200 KB — ~50k tokens, 1.5× the
195    /// whole local context window, which is not a cap so much as a promise
196    /// to overflow.
197    pub output_budget_bytes: usize,
198    /// Where an oversized result is saved in full before its transcript copy
199    /// is cut. `None` disables spilling — the cut then names what was lost
200    /// instead of where to find it. Per-context on purpose: two eval cases
201    /// sharing one spill directory could read each other's output through it.
202    pub spill_dir: Option<PathBuf>,
203    /// The run's event channel, so a tool that *contains* a run — a subagent —
204    /// can surface its progress instead of going dark until it returns.
205    ///
206    /// Display-only, and treat it that way: any tool (including a third-party
207    /// MCP server's) can send fabricated events down this channel, so nothing
208    /// that matters may key off it. Conversation state, taint, and run
209    /// completion all come from the loop and the caller's join handle, never
210    /// from events. Stamped by [`Agent::run_in`] per run; `None` everywhere
211    /// nobody is watching (batch, eval).
212    ///
213    /// [`Agent::run_in`]: crate::agent::Agent::run_in
214    pub events: Option<tokio::sync::mpsc::UnboundedSender<crate::agent::AgentEvent>>,
215    /// The run's cancellation token. A tool that contains a run passes it on,
216    /// so cancelling the parent actually cancels the child instead of politely
217    /// waiting out its entire run. Stamped by `Agent::run_in`, like `events`.
218    pub cancel: Option<tokio_util::sync::CancellationToken>,
219    /// The run's phase. A tool that contains a run passes it on, so delegation
220    /// is not the way to get a write executed from a planning run. Stamped by
221    /// `Agent::run_in`, like `events`.
222    pub phase: crate::agent::Phase,
223    /// The `tool_use` id of the call this context was built for. Stamped per
224    /// dispatch (only when `events` is watched), so a tool that contains a
225    /// run can tag its forwarded events with the call that spawned it — two
226    /// subagents running in parallel are otherwise indistinguishable to a
227    /// renderer.
228    pub call_id: Option<String>,
229}
230
231impl Default for ToolCtx {
232    fn default() -> Self {
233        ToolCtx {
234            workspace: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
235            shell_timeout: std::time::Duration::from_secs(120),
236            security: SecurityConfig::default(),
237            output_budget_bytes: 24_000,
238            spill_dir: fresh_spill_dir(),
239            events: None,
240            cancel: None,
241            phase: crate::agent::Phase::default(),
242            call_id: None,
243        }
244    }
245}
246
247/// A spill directory no other context shares. Not created until first used.
248fn fresh_spill_dir() -> Option<PathBuf> {
249    Some(std::env::temp_dir().join(format!("mecha-spill-{}", uuid::Uuid::new_v4())))
250}
251
252impl ToolCtx {
253    /// The same policy pointed at a different root. Used to give one run — an
254    /// eval case, a batch item — its own isolated copy of a workspace without
255    /// rebuilding the agent around it. The spill directory is re-derived too:
256    /// a re-rooted context is a new isolation domain, and inheriting the old
257    /// one would let its runs read each other's spilled output.
258    pub fn with_workspace(&self, workspace: impl Into<PathBuf>) -> Self {
259        ToolCtx {
260            workspace: workspace.into(),
261            spill_dir: fresh_spill_dir(),
262            ..self.clone()
263        }
264    }
265
266    /// Resolve a model-supplied path against the workspace and prove it stays
267    /// inside. The path is untrusted input: `..`, symlinks, and absolute paths
268    /// all have to be checked after canonicalization, not before.
269    pub fn resolve(&self, raw: &str) -> Result<PathBuf> {
270        let candidate = {
271            let p = Path::new(raw);
272            if p.is_absolute() {
273                p.to_path_buf()
274            } else {
275                self.workspace.join(p)
276            }
277        };
278
279        // The file may not exist yet (a write), so canonicalize the nearest
280        // existing ancestor and re-append the rest.
281        let mut existing = candidate.as_path();
282        let mut trailing = Vec::new();
283        let canonical_root = loop {
284            match existing.canonicalize() {
285                Ok(c) => break c,
286                Err(_) => match existing.parent() {
287                    Some(parent) => {
288                        if let Some(name) = existing.file_name() {
289                            trailing.push(name.to_owned());
290                        }
291                        existing = parent;
292                    }
293                    None => anyhow::bail!("cannot resolve path {raw:?}"),
294                },
295            }
296        };
297        let mut resolved = canonical_root;
298        for part in trailing.iter().rev() {
299            resolved.push(part);
300        }
301
302        let root = self
303            .workspace
304            .canonicalize()
305            .unwrap_or_else(|_| self.workspace.clone());
306        if resolved.starts_with(&root) {
307            return Ok(resolved);
308        }
309        // The spill directory is the one sanctioned exception: oversized tool
310        // output is saved there, and the truncation marker tells the model to
311        // read the rest from exactly that path. Its contents are this
312        // context's own tool results, so nothing new becomes reachable.
313        if let Some(spill) = &self.spill_dir {
314            let spill_root = spill.canonicalize().unwrap_or_else(|_| spill.clone());
315            if resolved.starts_with(&spill_root) {
316                return Ok(resolved);
317            }
318        }
319        anyhow::bail!(
320            "path {raw:?} resolves outside the workspace ({})",
321            root.display()
322        )
323    }
324}
325
326/// Floor under a result's share of the turn budget. A wide batch must not
327/// starve every result down to a marker with no content: below this, the
328/// division stops and the total budget is allowed to overrun instead.
329pub const SPILL_FLOOR_BYTES: usize = 4_096;
330
331/// Cut an oversized tool result down to `cap` bytes, saving the full output
332/// where the model can get it back.
333///
334/// The marker is written for the model, and it names the recovery — a
335/// truncation notice that only says "gone" leaves the model to conclude the
336/// rest never existed, and the elision line number is what makes the recovery
337/// a single call instead of a scan. A failed spill degrades to a plain cut
338/// that says the output was *not* saved; losing the tail must never lose the
339/// run.
340pub fn cap_result(
341    content: String,
342    cap: usize,
343    spill_dir: Option<&Path>,
344    tool: &str,
345    id: &str,
346) -> String {
347    if content.len() <= cap {
348        return content;
349    }
350    // Cut on a char boundary, never mid-codepoint.
351    let mut cut = cap;
352    while cut > 0 && !content.is_char_boundary(cut) {
353        cut -= 1;
354    }
355    let head = &content[..cut];
356    // The line the elision starts on. A cut mid-line means that same line —
357    // re-reading from it overlaps a little, which is the right direction.
358    let line = head.matches('\n').count() + 1;
359    let total = content.len();
360
361    let saved = spill_dir.and_then(|dir| {
362        // Owner-only: spilled output is tool results in full — the same
363        // sensitivity as the transcript, sitting in the shared temp dir.
364        crate::create_private_dir(dir).ok()?;
365        // A random component, because the call id alone can collide: batch
366        // items and non-sandboxed eval cases share one context, and a local
367        // server under a pinned seed can hand identical requests identical
368        // call ids. A collision would silently overwrite, leaving one
369        // conversation's marker pointing at another conversation's content.
370        let tag = &uuid::Uuid::new_v4().to_string()[..8];
371        let file = dir.join(format!("{}-{}-{tag}.txt", safe_name(tool), safe_name(id)));
372        std::fs::write(&file, &content).ok()?;
373        Some(file)
374    });
375
376    match saved {
377        Some(path) => format!(
378            "{head}\n\n[truncated by the harness: showing the first {cut} of {total} bytes; \
379             the rest begins on line {line}. The full output is saved at {path} — continue \
380             with fs_read {{\"path\": \"{path}\", \"offset\": {line}}}, or search it with \
381             grep.]",
382            path = path.display()
383        ),
384        None => format!(
385            "{head}\n\n[truncated by the harness: {omitted} of {total} bytes were dropped \
386             from line {line} on, and the full output could not be saved. Narrow the \
387             request and re-run the tool if the rest is needed.]",
388            omitted = total - cut
389        ),
390    }
391}
392
393/// Tool names and call ids become file names; anything else becomes `-`.
394fn safe_name(s: &str) -> String {
395    s.chars()
396        .map(|c| {
397            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
398                c
399            } else {
400                '-'
401            }
402        })
403        .collect()
404}
405
406/// The decision an approver hands back for one pending call.
407#[derive(Debug, Clone)]
408pub enum Decision {
409    Allow,
410    /// The reason is passed to the model so it can pick another approach.
411    Deny(String),
412}
413
414/// Gates tool calls that aren't read-only. The CLI implements this with a
415/// terminal prompt; a headless caller can auto-allow or auto-deny.
416#[async_trait]
417pub trait Approver: Send + Sync {
418    async fn approve(&self, tool: &dyn Tool, input: &Value) -> Decision;
419}
420
421/// Answers from the configured [`PermissionMode`] without asking anyone.
422pub struct ModeApprover {
423    pub mode: PermissionMode,
424}
425
426#[async_trait]
427impl Approver for ModeApprover {
428    async fn approve(&self, tool: &dyn Tool, _input: &Value) -> Decision {
429        match self.mode {
430            PermissionMode::Allow => Decision::Allow,
431            PermissionMode::ReadOnly if tool.read_only() => Decision::Allow,
432            PermissionMode::ReadOnly => Decision::Deny(format!(
433                "`{}` modifies state and this run is read-only",
434                tool.name()
435            )),
436            // Nothing is watching to answer, so the safe reading of "ask" is no.
437            PermissionMode::Ask => Decision::Deny(format!(
438                "`{}` needs approval and this run is non-interactive (use --yes to allow)",
439                tool.name()
440            )),
441        }
442    }
443}
444
445#[derive(Default)]
446pub struct Registry {
447    tools: BTreeMap<String, Arc<dyn Tool>>,
448}
449
450impl Registry {
451    pub fn new() -> Self {
452        Self::default()
453    }
454
455    /// Register a tool. A later registration with the same name replaces the
456    /// earlier one, so MCP servers can shadow built-ins deliberately.
457    pub fn insert(&mut self, tool: Arc<dyn Tool>) {
458        self.tools.insert(tool.name().to_string(), tool);
459    }
460
461    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
462        self.tools.get(name)
463    }
464
465    pub fn is_empty(&self) -> bool {
466        self.tools.is_empty()
467    }
468
469    pub fn len(&self) -> usize {
470        self.tools.len()
471    }
472
473    pub fn iter(&self) -> impl Iterator<Item = &Arc<dyn Tool>> {
474        self.tools.values()
475    }
476
477    /// Everything the registered tools want carried across a compaction.
478    ///
479    /// In the registry's stable order, so a compaction does not reorder the
480    /// prompt for a reason nobody can see. Asked of every tool, including an
481    /// MCP server's — the loop does not learn which tools have state, only
482    /// that some do, which is the same reason it never learns where a tool
483    /// came from.
484    pub fn carried_state(&self) -> Vec<CarriedState> {
485        self.tools
486            .values()
487            .filter_map(|t| t.carried_state())
488            .collect()
489    }
490
491    /// Specs in a stable order — the tool list is the very front of the prompt
492    /// prefix, so reordering it would invalidate the cache on every request.
493    pub fn specs(&self) -> Vec<ToolSpec> {
494        self.tools.values().map(|t| t.spec()).collect()
495    }
496
497    /// Specs a given phase permits, in the same stable order.
498    ///
499    /// Note what this does to the prompt cache: planning sends a shorter tool
500    /// list, so switching phase changes the front of the prefix and the next
501    /// turn re-pays for it. That is the price of the tools being genuinely
502    /// absent rather than merely refused, and it is the right trade.
503    pub fn specs_for(&self, phase: crate::agent::Phase) -> Vec<ToolSpec> {
504        self.tools
505            .values()
506            .filter(|t| phase.allows(t.read_only()))
507            .map(|t| t.spec())
508            .collect()
509    }
510
511    /// Register the built-ins permitted by config.
512    ///
513    /// The sandbox is passed in rather than read from config here because it
514    /// changes what `shell` *is* — an unconfined shell and a confined one
515    /// declare different capabilities, and the loop's interlock reads them.
516    pub fn with_builtins(
517        mut self,
518        cfg: &ToolsConfig,
519        sandbox: Arc<crate::sandbox::Sandbox>,
520    ) -> Self {
521        for tool in builtin::all(sandbox) {
522            let name = tool.name();
523            let allowed = cfg.enabled.is_empty() || cfg.enabled.iter().any(|e| e == name);
524            let blocked = cfg.disabled.iter().any(|d| d == name);
525            if allowed && !blocked {
526                self.insert(tool);
527            }
528        }
529        self
530    }
531}
532
533#[cfg(test)]
534mod cap_tests {
535    use super::*;
536
537    fn scratch(name: &str) -> PathBuf {
538        let dir = std::env::temp_dir().join(format!("mecha-cap-{name}-{}", uuid::Uuid::new_v4()));
539        std::fs::create_dir_all(&dir).unwrap();
540        dir
541    }
542
543    #[test]
544    fn a_result_under_the_cap_is_untouched() {
545        let out = cap_result("short".into(), 100, None, "shell", "t1");
546        assert_eq!(out, "short");
547    }
548
549    #[test]
550    fn an_oversized_result_is_spilled_whole_and_the_marker_names_the_recovery() {
551        let dir = scratch("spill");
552        let body: String = (1..=100).map(|i| format!("line {i}\n")).collect();
553
554        let out = cap_result(body.clone(), 200, Some(&dir), "shell", "t1");
555
556        // The transcript copy is bounded...
557        assert!(out.len() < body.len());
558        assert!(out.starts_with("line 1\n"));
559        // ...the disk copy is not: byte-identical, so nothing was lost. The
560        // name carries a random tag, so it is discovered rather than assumed.
561        let file = std::fs::read_dir(&dir)
562            .unwrap()
563            .next()
564            .unwrap()
565            .unwrap()
566            .path();
567        assert!(file
568            .file_name()
569            .unwrap()
570            .to_str()
571            .unwrap()
572            .starts_with("shell-t1-"));
573        assert_eq!(std::fs::read_to_string(&file).unwrap(), body);
574
575        // The marker gives the model a single call back to the rest: the
576        // path, and the line the elision starts on.
577        let line = body[..200].matches('\n').count() + 1;
578        assert!(out.contains(&file.display().to_string()), "{out}");
579        assert!(out.contains(&format!("\"offset\": {line}")), "{out}");
580        assert!(out.contains("fs_read"), "the recovery must be named: {out}");
581
582        std::fs::remove_dir_all(&dir).ok();
583    }
584
585    #[test]
586    fn a_failed_spill_degrades_to_a_cut_that_admits_the_loss() {
587        // A directory that cannot exist: spilling fails, the run must not.
588        let impossible = PathBuf::from("/dev/null/not-a-dir");
589        let body = "x".repeat(1000);
590        let out = cap_result(body, 100, Some(&impossible), "shell", "t1");
591
592        assert!(out.contains("could not be saved"), "{out}");
593        assert!(
594            out.contains("re-run the tool"),
595            "the fallback still names a recovery: {out}"
596        );
597        assert!(
598            !out.contains("/dev/null"),
599            "no path is promised that does not exist"
600        );
601    }
602
603    #[test]
604    fn the_cut_lands_on_a_char_boundary() {
605        // A cap that falls mid-codepoint must back up, not panic.
606        let body = "é".repeat(100); // 2 bytes per char
607        let out = cap_result(body, 33, None, "shell", "t1");
608        assert!(out.starts_with(&"é".repeat(16)));
609    }
610
611    #[test]
612    fn the_jail_admits_the_spill_directory_and_nothing_else_new() {
613        let workspace = scratch("ws");
614        let spill = scratch("spilldir");
615        let ctx = ToolCtx {
616            workspace: workspace.clone(),
617            spill_dir: Some(spill.clone()),
618            ..ToolCtx::default()
619        };
620
621        // The marker names an absolute spill path; fs_read must be able to
622        // follow it, or the recovery the model was promised is a lie.
623        std::fs::write(spill.join("shell-t1.txt"), "spilled").unwrap();
624        let resolved = ctx
625            .resolve(&spill.join("shell-t1.txt").display().to_string())
626            .unwrap();
627        assert!(resolved.ends_with("shell-t1.txt"));
628
629        // The exception is the spill directory, not the temp dir around it.
630        let elsewhere = std::env::temp_dir().join("mecha-cap-elsewhere.txt");
631        std::fs::write(&elsewhere, "no").unwrap();
632        assert!(ctx.resolve(&elsewhere.display().to_string()).is_err());
633
634        // And with spilling disabled there is no exception at all.
635        let no_spill = ToolCtx {
636            workspace,
637            spill_dir: None,
638            ..ToolCtx::default()
639        };
640        assert!(no_spill
641            .resolve(&spill.join("shell-t1.txt").display().to_string())
642            .is_err());
643
644        std::fs::remove_dir_all(&spill).ok();
645        std::fs::remove_file(&elsewhere).ok();
646    }
647
648    #[test]
649    fn a_rerooted_context_gets_its_own_spill_directory() {
650        // Two eval cases sharing one spill directory could read each other's
651        // output through it — the same isolation rule as the workspace copy.
652        let ctx = ToolCtx::default();
653        let rerooted = ctx.with_workspace(std::env::temp_dir());
654        assert_ne!(ctx.spill_dir, rerooted.spill_dir);
655    }
656}