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.effect_of(call)?;
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;