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