Skip to main content

runtime/
bench.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The tool bench: which door one call goes through, in which order,
7//! and what comes back when a door says no.
8//!
9//! Gate routing is turn-layer work (Handoff verdict 10), so the
10//! executor above stays thin: it hands the bench a call and receives an
11//! exhaustive [`BenchOutcome`] rather than deciding anything itself.
12//!
13//! **Three orderings are load-bearing.**
14//! - Dedup runs before any side effect, so a replayed call cannot bill
15//!   or write twice.
16//! - `exec` is forecast for discards *before* the Write door, because
17//!   "this command deletes things" is a stronger claim than "this
18//!   command writes somewhere" and deserves the stricter door.
19//! - A `Deny` comes back as a `tool_result` carrying the refusal rather
20//!   than ending the turn: the model that asked for something it may not
21//!   have should learn that, and continue.
22//!
23//! The tool itself arrives as a `Box<dyn Tool>` and the sandbox behind
24//! it is a port, so what this module owns is the ordering rather than
25//! the effect.
26
27use std::collections::BTreeMap;
28
29use kernel::{
30    Address, AxCode, AxError, DiscardForecast, Effect, EgressOutcome, EgressTarget, GateOutcome,
31    GateSubject, IdemKey, Locator, TaintSet, Tool, ToolCall, ToolName, ToolOutcome, WriteDomain,
32};
33use serde_json::Value;
34use storage::{Checkpoint, Provenance};
35
36mod admit;
37mod outside;
38
39/// The checkpoint net a run works under: the repository, what a checkpoint
40/// covers, and who is signing it.
41///
42/// Three values that are meaningless apart - a checkpoint with no scope
43/// stages what the run never held, and a checkpoint with no provenance
44/// commits under nobody's name - so they are handed over together
45/// rather than assembled inside the bench.
46pub struct CheckpointNet {
47    pub checkpoint: Checkpoint,
48    /// The prefixes a checkpoint stages, which are the run's write domain and
49    /// not its room. A checkpoint narrower than the domain leaves whatever
50    /// the run wrote in between outside every checkpoint, and therefore
51    /// outside `changes` and outside restoration.
52    pub scope: Vec<String>,
53    pub of: Provenance,
54}
55
56/// The tool bench: the turn layer's routing of a call through the gate
57/// its own declared Effect names, in the three orderings the module
58/// documentation states.
59pub struct ToolBench {
60    tools: BTreeMap<ToolName, Box<dyn Tool>>,
61    domain: WriteDomain,
62    taint: TaintSet,
63    /// The floor's limits, which decide whether a connector may reach
64    /// what nothing here can take back.
65    sandbox: kernel::SandboxLimits,
66    /// What each key already answered, success or failure. A key alone
67    /// would stop the second call's side effect and still owe the model
68    /// an answer; the answer is the first call's own, whichever way it
69    /// went (`crates/runtime/spec/Bench.lean` §8-35).
70    seen: BTreeMap<IdemKey, Result<ToolOutcome, AxError>>,
71    prior_public_egress: bool,
72    /// The checkpoint net. A command the forecast suspects of deleting
73    /// things does not get refused — text prediction is obfuscatable, so
74    /// refusing on a substring would be security theatre that also
75    /// blocks honest work. It gets checkpointed instead: commit first, then
76    /// run, so whatever it deletes is restorable. Absent a net, such a
77    /// command is refused, because running it unprotected is the one
78    /// outcome nobody chose.
79    net: Option<CheckpointNet>,
80    /// What this run was given to do, as the approvals list refers to
81    /// it. An item that named no artifact would leave a person deciding
82    /// about a spawn with nothing to open.
83    job: Option<Locator>,
84    /// Where this run works, which is what a delegation approval
85    /// clusters by: the person is asked whether this resident may hand
86    /// work down, once.
87    asking: Option<Address>,
88}
89
90/// What the bench decided, alongside what the tool produced.
91#[derive(Debug)]
92pub enum BenchOutcome {
93    /// The tool ran; this is its result. `checkpointed` carries the commit
94    /// the wave was checkpointed against when the forecast suspected a
95    /// discard, so the post-wave sweep knows what to restore from.
96    /// `wrote` is the tool's own account of what the call may have
97    /// written, which the next checkpoint stages (`crates/runtime/spec/Run/Checkpoint.lean` §8-45).
98    Ran {
99        outcome: ToolOutcome,
100        checkpointed: Option<String>,
101        wrote: kernel::Writes,
102    },
103    /// A gate refused, or asked. Either way the answer travels back as
104    /// a tool_result, which keeps the turn alive and tells the model
105    /// what it may not do or what the person has to do; a pending
106    /// question carries `E_APPROVAL_PENDING` and its recovery sentence
107    /// is the question.
108    Refused { refusal: Box<AxError> },
109    /// The call was already made, and this is what it answered. A replay
110    /// is owed the first result: an error here would teach the model a
111    /// call failed when it succeeded.
112    Duplicate { outcome: ToolOutcome },
113}
114
115/// Each registered tool's answer to what it may write, by declared effect.
116pub struct DeclaredWrites(BTreeMap<ToolName, kernel::Writes>);
117
118impl DeclaredWrites {
119    /// A name the bench does not hold answers `Domain`: the call will be
120    /// refused, and a guess that it writes nothing is the one guess that
121    /// could leave a write outside every checkpoint.
122    pub fn of(&self, call: &ToolCall) -> kernel::Writes {
123        self.0
124            .get(&call.name)
125            .cloned()
126            .unwrap_or(kernel::Writes::Domain)
127    }
128}
129
130impl ToolBench {
131    pub fn new(domain: WriteDomain) -> ToolBench {
132        ToolBench {
133            tools: BTreeMap::new(),
134            domain,
135            taint: TaintSet::empty(),
136            sandbox: kernel::SandboxLimits::default(),
137            seen: BTreeMap::new(),
138            prior_public_egress: false,
139            net: None,
140            job: None,
141            asking: None,
142        }
143    }
144
145    /// Hands the bench the work it serves: where the run stands and what
146    /// it was given to do.
147    ///
148    /// Without it a spawn is refused rather than allowed, because an
149    /// approval item that named neither the asker nor an artifact would
150    /// reach a person as a question about nothing.
151    #[must_use]
152    pub fn for_job(mut self, asking: Address, job: Locator) -> ToolBench {
153        self.asking = Some(asking);
154        self.job = Some(job);
155        self
156    }
157
158    /// Hands the bench its checkpoint net. Without one, a suspected
159    /// discard is refused rather than run unprotected.
160    pub fn with_checkpoint(mut self, net: CheckpointNet) -> ToolBench {
161        self.net = Some(net);
162        self
163    }
164
165    /// Registers a tool under its own declared name. A second tool
166    /// claiming a taken name is refused rather than shadowing the first.
167    pub fn register(&mut self, tool: Box<dyn Tool>) -> Result<(), AxError> {
168        let name = tool.meta().name.clone();
169        if self.tools.contains_key(&name) {
170            return Err(AxError::failure(
171                AxCode::InvalidArgs,
172                "register tool",
173                format!("`{name}` is already registered"),
174            )
175            .with_recovery(format!(
176                "rename one of the two tools declaring the name `{name}` in its \
177                 `ToolMeta`, then register it again"
178            )));
179        }
180        self.tools.insert(name, tool);
181        Ok(())
182    }
183
184    /// What each registered tool may write, read off its declared
185    /// effect alone, taken out before the bench is lent to the run so the
186    /// checkpoint policy can ask it while a wave waits (`crates/runtime/spec/Run/Checkpoint.lean` §8-45).
187    pub fn declared_writes(&self) -> DeclaredWrites {
188        DeclaredWrites(
189            self.tools
190                .iter()
191                .map(|(name, tool)| (name.clone(), kernel::Writes::of(&tool.meta().effect)))
192                .collect(),
193        )
194    }
195
196    pub fn taint_mut(&mut self) -> &mut TaintSet {
197        &mut self.taint
198    }
199
200    /// The registered tool's declaration. Callers packaging a result
201    /// need its `temporal` to decide whether a clock line is due.
202    pub fn meta_of(&self, name: &ToolName) -> Option<&kernel::ToolMeta> {
203        self.tools.get(name).map(|tool| tool.meta())
204    }
205
206    /// Routes one call: dedup, then the door its Effect names, then the
207    /// tool itself: `clear`, then the tool `tool_for` names, then
208    /// `account`, in order, on one thread.
209    pub fn invoke(
210        &mut self,
211        call: &ToolCall,
212        key: &IdemKey,
213        now: kernel::TimeMs,
214    ) -> Result<BenchOutcome, AxError> {
215        match self.clear(call, key, now)? {
216            Clearance::Answered(outcome) => Ok(outcome),
217            Clearance::Cleared(ticket) => {
218                let answered = self.tool_for(&ticket).and_then(|tool| tool.invoke(call));
219                self.account(ticket, answered)
220            }
221        }
222    }
223
224    /// Dedup, the doors, and the discard forecast's checkpoint: everything
225    /// that decides whether a call may run, and reads or writes what
226    /// the calls before it left. A concurrent wave clears its calls one
227    /// at a time, in call order.
228    pub fn clear(
229        &mut self,
230        call: &ToolCall,
231        key: &IdemKey,
232        now: kernel::TimeMs,
233    ) -> Result<Clearance, AxError> {
234        // Before any unreplayable effect (8.2). `seen` holds a key only
235        // once the tool answered it, so its keys are the claimed set and
236        // a second copy of them would be a second authority.
237        if let Some(answered) = self.seen.get(key) {
238            return match answered {
239                Ok(outcome) => Ok(Clearance::Answered(BenchOutcome::Duplicate {
240                    outcome: outcome.clone(),
241                })),
242                Err(refused) => Err(refused.clone()),
243            };
244        }
245        let name = call.name.as_str();
246        let Some(tool) = self.tools.get(&call.name) else {
247            return Err(AxError::failure(
248                AxCode::ToolUnavailable,
249                "invoke tool",
250                format!("no tool named `{name}` is registered"),
251            )
252            .with_recovery("call one of the tools listed in your catalog"));
253        };
254        let effect = tool.meta().effect.clone();
255        let wrote = tool.writes(call);
256        // What the call is about, in the tool's own grammar.
257        // Read before any door: a subject this tool cannot read is a
258        // call no door may judge.
259        let subject = tool.subject(call)?;
260
261        // The command door stands before the forecast: a command a
262        // tainted run may not execute owes no checkpoint.
263        if name == ToolName::EXEC
264            && let Some(answered) = self.settled(kernel::gate::command(&self.taint))
265        {
266            return Ok(Clearance::Answered(answered));
267        }
268
269        // exec is forecast first. A hit does not refuse: it checkpoints.
270        let mut checkpointed = None;
271        if name == ToolName::EXEC
272            && let Ok(arm) = crate::tools::parse_arm(call.args.as_map())
273            && let DiscardForecast::Suspected { pattern } = kernel::discard::forecast(&arm)
274        {
275            let Some(net) = self.net.as_mut() else {
276                return Err(AxError::failure(
277                    AxCode::ToolUnavailable,
278                    "invoke tool",
279                    format!("`{pattern}` may discard files and no checkpoint net is configured"),
280                )
281                .with_recovery(
282                    "configure the checkpoint net, or run a command that does not delete",
283                ));
284            };
285            let payload = net
286                .checkpoint
287                .wave_pre(&net.scope, now, &net.of)
288                .map_err(kernel_error_from_storage)?;
289            checkpointed = payload
290                .as_map()
291                .get("oid")
292                .and_then(Value::as_str)
293                .map(str::to_owned);
294        }
295
296        if let Some(answered) = self.admit(call, name, &effect, &subject)? {
297            return Ok(Clearance::Answered(answered));
298        }
299        Ok(Clearance::Cleared(Ticket {
300            key: *key,
301            name: call.name.clone(),
302            effect,
303            checkpointed,
304            wrote,
305        }))
306    }
307
308    /// The tool a cleared call runs on. What comes back is the tool
309    /// rather than the bench, because the bench is not `Sync` (its
310    /// checkpoint holds a git repository handle) while a tool is: the
311    /// calls of one wave invoke their tools on several threads at once,
312    /// and none of them touches the dedup table or the taint.
313    ///
314    /// # Errors
315    /// A ticket naming a tool this bench does not hold.
316    pub fn tool_for(&self, ticket: &Ticket) -> Result<&dyn Tool, AxError> {
317        self.tool_named(&ticket.name).ok_or_else(|| {
318            AxError::failure(
319                AxCode::ToolUnavailable,
320                "invoke tool",
321                format!("no tool named `{}` is registered", ticket.name),
322            )
323            .with_nearby(self.tools.keys().map(ToString::to_string).collect())
324            .with_recovery(
325                "call one of the tools listed beside this error; those are the tools \
326                 this run holds",
327            )
328        })
329    }
330
331    /// The tool registered under `name`, lent out before any call to it
332    /// is cleared, so a read the model hands over can start while it is
333    /// still generating (`crates/runtime/spec/Turn.lean` §8-3). A name this bench does not
334    /// hold starts nothing early; admitting the call reports it.
335    pub fn tool_named(&self, name: &ToolName) -> Option<&dyn Tool> {
336        self.tools.get(name).map(AsRef::as_ref)
337    }
338
339    /// Records what a cleared call answered. A concurrent wave accounts
340    /// its calls in call order, so the dedup table and the taint grow
341    /// in the order a serial wave grows them.
342    ///
343    /// The key is recorded with the answer it earned, so a retry after
344    /// a gate refusal is not a replay, and a call the tool itself
345    /// failed answers its replay the same way it answered the first
346    /// time rather than running again. Outside content enters the run
347    /// here and nowhere else, so every later call's doors read the
348    /// taint it brought.
349    ///
350    /// # Errors
351    /// The tool's own refusal, and outside content whose source label
352    /// is empty.
353    pub fn account(
354        &mut self,
355        ticket: Ticket,
356        answered: Result<ToolOutcome, AxError>,
357    ) -> Result<BenchOutcome, AxError> {
358        let answered = answered.and_then(|outcome| {
359            self.taint = outside::entered(&ticket.effect, &self.taint)?;
360            Ok(outcome)
361        });
362        self.seen.insert(ticket.key, answered.clone());
363        let outcome = answered?;
364        Ok(BenchOutcome::Ran {
365            outcome,
366            checkpointed: ticket.checkpointed,
367            wrote: ticket.wrote,
368        })
369    }
370}
371
372/// What `clear` decided about one call.
373#[derive(Debug)]
374pub enum Clearance {
375    /// The bench answered without running the tool: a replay, or a door
376    /// that refused or asked.
377    Answered(BenchOutcome),
378    /// The call may run; `tool_for` and then `account` take the ticket.
379    Cleared(Ticket),
380}
381
382/// A call `clear` let through: the tool `tool_for` names, and what
383/// `account` records the answer under.
384#[derive(Debug)]
385pub struct Ticket {
386    key: IdemKey,
387    name: ToolName,
388    effect: Effect,
389    /// The commit the discard forecast checkpointed this call against.
390    checkpointed: Option<String>,
391    /// What the tool says the call may write, asked while the bench
392    /// still holds the tool; `account` hands it on in the outcome.
393    wrote: kernel::Writes,
394}
395
396/// The storage crate owns its own error root; the turn layer speaks
397/// AxError, so the conversion happens once, here.
398fn kernel_error_from_storage(err: storage::StorageError) -> AxError {
399    err.into_ax()
400}
401#[cfg(test)]
402#[allow(
403    clippy::unwrap_used,
404    clippy::expect_used,
405    clippy::panic,
406    clippy::indexing_slicing,
407    clippy::wildcard_enum_match_arm,
408    clippy::let_underscore_must_use,
409    clippy::let_underscore_untyped,
410    reason = "test code"
411)]
412mod tests;