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}