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