kernel/tool.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 seam (seam registry ARCHITECTURE 3).
7//! `ToolMeta` is the eight-field registration: a tool without a complete
8//! meta is invisible to the model. `effect` decides which gate a call
9//! passes; `temporal` decides whether its envelope carries a clock stamp.
10//! The part `crates/kernel/spec/Tool.lean` specifies this module.
11
12use serde::{Deserialize, Serialize};
13
14use crate::address::Address;
15use crate::error::{AxCode, AxError};
16use crate::event::Payload;
17
18pub mod writes;
19
20/// Tool identity as it appears in catalog and events: non-empty ASCII
21/// lowercase, digits, underscore.
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
23pub struct ToolName(String);
24
25impl ToolName {
26 /// The built-in browser tool's name. One authority, because the
27 /// tool's own `ToolMeta`, the building rule that admits it and the
28 /// ledger fixtures that name its record all spell it.
29 pub const BROWSER: &'static str = "browser";
30
31 /// The tool that drives the browser a person already has open, with
32 /// that person's own profile.
33 pub const USER_BROWSER: &'static str = "usersbrowser";
34
35 /// The three-arm execution tool. One authority, because the discard
36 /// forecast, the lane's command count and its sieve all route on it.
37 pub const EXEC: &'static str = "exec";
38
39 pub fn parse(raw: &str) -> Result<Self, AxError> {
40 let well_formed = !raw.is_empty()
41 && raw
42 .chars()
43 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
44 if well_formed {
45 Ok(ToolName(raw.to_owned()))
46 } else {
47 Err(
48 AxError::failure(AxCode::InvalidArgs, "parse tool name", raw)
49 .with_recovery("use ascii lowercase, digits and underscore, non-empty"),
50 )
51 }
52 }
53
54 pub fn as_str(&self) -> &str {
55 &self.0
56 }
57}
58
59impl std::fmt::Display for ToolName {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.write_str(&self.0)
62 }
63}
64
65/// How one external server is named inside this city.
66///
67/// The label is the first segment of every tool that server offers
68/// (`{label}_{tool}`), so two servers that both offer `search` stay two
69/// tools rather than becoming one that sometimes does the wrong thing.
70/// Its grammar is [`ToolName`]'s minus the underscore: allowing one
71/// would let `apps_foo_bar` be read as two different splits, and this
72/// name routes a call.
73///
74/// It lives here rather than beside the protocol that uses it because
75/// the rule it enforces is a rule about tool names, and a rule written
76/// in two crates is a rule with two authorities.
77#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
78#[serde(try_from = "String", into = "String")]
79pub struct ServerLabel(String);
80
81impl ServerLabel {
82 /// Sole constructor.
83 ///
84 /// # Errors
85 /// Refuses anything a tool name cannot contain, and the underscore
86 /// that would make the resulting tool name ambiguous.
87 pub fn parse(raw: &str) -> Result<ServerLabel, AxError> {
88 let well_formed = !raw.is_empty()
89 && raw
90 .chars()
91 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
92 if well_formed {
93 Ok(ServerLabel(raw.to_owned()))
94 } else {
95 Err(
96 AxError::failure(AxCode::InvalidArgs, "name an mcp server", raw.to_owned())
97 .with_recovery("use ascii lowercase and digits, non-empty"),
98 )
99 }
100 }
101
102 #[must_use]
103 pub fn as_str(&self) -> &str {
104 &self.0
105 }
106}
107
108impl std::fmt::Display for ServerLabel {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.write_str(&self.0)
111 }
112}
113
114impl TryFrom<String> for ServerLabel {
115 type Error = AxError;
116
117 fn try_from(raw: String) -> Result<ServerLabel, AxError> {
118 ServerLabel::parse(&raw)
119 }
120}
121
122impl From<ServerLabel> for String {
123 fn from(label: ServerLabel) -> String {
124 label.0
125 }
126}
127
128impl Serialize for ToolName {
129 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
130 serializer.serialize_str(&self.0)
131 }
132}
133
134impl<'de> Deserialize<'de> for ToolName {
135 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
136 let raw = String::deserialize(deserializer)?;
137 ToolName::parse(&raw).map_err(serde::de::Error::custom)
138 }
139}
140
141/// Declaring a timeout is a promise of cooperative cancellation; absence
142/// means no deadline.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144pub struct TimeoutMs(pub u64);
145
146/// What kind of boundary a call crosses — this field routes the call to
147/// its gate; it is machine input, not documentation.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
151pub enum Effect {
152 Read,
153 Write {
154 domain: Address,
155 },
156 /// Leaves this machine for a destination the call names.
157 Egress,
158 /// Leaves this process for an external server the building
159 /// configured. The destination is fixed by the registration rather
160 /// than named per call, because every call to one connector tool
161 /// goes to the same place — and a model filling in a `host`
162 /// argument would be inventing a fact the city already knows.
163 Connector {
164 #[cfg_attr(feature = "schema", schemars(with = "String"))]
165 label: ServerLabel,
166 },
167 /// Starts a second agent on part of this work. Its own class rather
168 /// than a `Read`: what a spawn costs and what it can reach is not
169 /// bounded by anything the calling run's other gates check, and the
170 /// only thing that bounds it is the person.
171 Spawn,
172 /// Changes what governs a scope - the rules its own runs are judged
173 /// by. Deliberately not a `Write`: the reserved subtree is outside
174 /// every write domain, so the write door would refuse it, and the
175 /// reason it refuses is that this decision is the person's.
176 Govern,
177 /// Attaches to a browser the person is already driving, with that
178 /// person's own login state.
179 ///
180 /// Its own class rather than an `Egress`: the bytes a call sends are
181 /// the smaller half of what it reaches for, and a run allowed to
182 /// open pages in a browser this city started has not thereby been
183 /// granted every account that browser is signed into. The address
184 /// is the registration's, not the call's; `None` is a person who
185 /// enabled the tool without saying where it answers, which the
186 /// attach door answers with a question.
187 AttachUserBrowser {
188 address: Option<String>,
189 },
190 /// Moves money. No tool in this build declares it; the bench refuses
191 /// it with `E_TOOL_UNAVAILABLE`, so the first tool that does meets a
192 /// door rather than a gap (`crates/runtime/spec/Tools.lean`).
193 Spend,
194}
195
196/// Whether "now" is load-bearing for this tool's results (4.3).
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "snake_case")]
199pub enum Temporal {
200 Timeless,
201 Timestamped,
202}
203
204/// Cost bucket for budget and routing; the tool layer consumes it.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum CostTier {
208 Free,
209 Light,
210 Heavy,
211}
212
213/// Presentation intent; per-call `locations` are a pure function of args
214/// (tool side). Meta-level declarations use an empty list.
215///
216/// `Signal` and `Delegate` carry nothing for the same reason: who a
217/// letter goes to and which room takes the work are per call, so a page
218/// reads them from the call's arguments (`crates/kernel/spec/Tool.lean`
219/// D37).
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223pub enum RenderIntent {
224 Generic,
225 Terminal,
226 Diff {
227 locations: Vec<Address>,
228 },
229 /// Speaking to another resident, or taking what waits for this one.
230 Signal,
231 /// Handing one piece of work to another room, one level down.
232 Delegate,
233}
234
235/// The eight-field registration, none optional in spirit: `timeout: None`
236/// is itself a declaration (no deadline), not an omission.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct ToolMeta {
239 pub name: ToolName,
240 pub disclosure: String,
241 pub params: Payload,
242 pub effect: Effect,
243 pub cost_tier: CostTier,
244 pub timeout: Option<TimeoutMs>,
245 pub render: RenderIntent,
246 pub temporal: Temporal,
247}
248
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250pub struct ToolCall {
251 /// Wire identity: `tool_use` and `tool_result` pair by this id on
252 /// both dialects. Scripted adapters synthesize deterministic ids.
253 pub id: String,
254 pub name: ToolName,
255 pub args: Payload,
256}
257
258impl ToolCall {
259 /// The bytes that say what this call does, for `IdemKey::derive` to
260 /// take as its `action_canonical`.
261 ///
262 /// The name and the arguments together are the action: two calls of
263 /// one tool with different arguments are two actions, and a key that
264 /// read only the name made them one. `id` stays out, because two
265 /// calls differing only by wire id are the same action.
266 ///
267 /// # Errors
268 /// Propagates arguments that do not serialise, which keeps the key
269 /// honest: substituting an empty string would collide
270 /// two different calls into one key.
271 pub fn action(&self) -> Result<Vec<u8>, AxError> {
272 let mut action = self.name.as_str().as_bytes().to_vec();
273 let args = serde_json::to_string(&self.args).map_err(|err| {
274 AxError::failure(
275 AxCode::InvalidArgs,
276 "read a tool call's action",
277 self.name.as_str().to_owned(),
278 )
279 .with_recovery(format!(
280 "JSON refused these arguments ({err}); call the tool again with whole \
281 numbers and strings, which is all this city's payloads carry"
282 ))
283 })?;
284 action.extend_from_slice(args.as_bytes());
285 Ok(action)
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290pub struct ToolOutcome {
291 pub result: Payload,
292 /// Pictures this call produced, already in the content store.
293 ///
294 /// They ride here rather than inside `result` because the model has
295 /// to see them: the turn loop puts them into the tool result block's
296 /// own attachments, and a locator buried in a payload would only
297 /// ever be text.
298 #[serde(default)]
299 pub attachments: Vec<crate::ImageRef>,
300}
301
302/// What one call is about, in the terms its own grammar names it.
303///
304/// The tool owning the call's grammar is the only place that can read
305/// it off the arguments. A sixth kind is a change here, not a
306/// string that reaches a gate.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub enum GateSubject {
309 /// A city position a call names as its target.
310 Area(Address),
311 /// The room a call names when it works in one.
312 Room(Address),
313 /// The scope a governance call would rewrite.
314 Scope(String),
315 /// The host a call's own bytes would leave for.
316 Host(String),
317 /// This call names no subject; the tool's grammar has none for it.
318 /// A door judges such a call against this machine rather than
319 /// skipping it, and refuses it when its effect needs a subject.
320 None,
321}
322
323/// The tool port. Adapters: runtime L0 three, browser, protocol;
324/// second adapter: citysim scripted tools.
325///
326/// `Send`, because a bench of tools is driven on a pool thread rather
327/// than on the thread that built it (`crates/sprawling/Spec.lean` §8-44). A tool that
328/// holds something a thread cannot give up has no place on a bench.
329///
330/// `Sync`, and `invoke` takes `&self`, because the read-only calls at
331/// the head of a wave run at the same time (`crates/runtime/Spec.lean` §8-3), so one
332/// tool can be inside `invoke` on several threads at once. A tool with
333/// state of its own keeps that state behind its own lock.
334pub trait Tool: Send + Sync {
335 fn meta(&self) -> &ToolMeta;
336
337 /// Fail-closed identity: a call whose name differs from `meta().name`
338 /// must return `E_INVALID_ARGS`, never route silently.
339 fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError>;
340
341 /// What this call is about, read by the grammar this tool already
342 /// parses its arguments with.
343 ///
344 /// The default is [`GateSubject::None`] rather than an inference: a
345 /// bench that guessed a subject from an argument name has already
346 /// been wrong once. `None` says the tool read the call and it names
347 /// none; a door then judges it against this machine, never skips it.
348 ///
349 /// # Errors
350 /// Refuses arguments this tool cannot read - the same refusal its
351 /// `invoke` would produce.
352 fn subject(&self, _call: &ToolCall) -> Result<GateSubject, AxError> {
353 Ok(GateSubject::None)
354 }
355
356 /// The effect this one call has, which picks the door it meets.
357 ///
358 /// The default is the registered [`ToolMeta::effect`]. A tool whose
359 /// operations differ in kind overrides it: a governance tool answers
360 /// [`Effect::Read`] for an operation that only reads the rules, so a
361 /// run can see what it is judged by while every operation that
362 /// rewrites them still meets the Govern refusal
363 /// (`crates/kernel/spec/Gate.lean` D26).
364 ///
365 /// # Errors
366 /// Refuses arguments this tool cannot read - the same refusal its
367 /// `invoke` would produce.
368 fn effect_of(&self, _call: &ToolCall) -> Result<Effect, AxError> {
369 Ok(self.meta().effect.clone())
370 }
371
372 /// What this call may have written to the city's tree once it ran;
373 /// the default reads the declared effect and nothing more.
374 fn writes(&self, _call: &ToolCall) -> writes::Writes {
375 writes::Writes::of(&self.meta().effect)
376 }
377}
378
379/// The exec three-arm shape. Lives on the tool face because
380/// `discard::forecast` consumes it and kernel cannot depend on runtime.
381/// Exactly three arms — deliberately exhaustive.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "snake_case")]
384pub enum ExecArm {
385 Program { path: String, args: Vec<String> },
386 Python { code: String },
387 Shell { text: String },
388}
389
390#[cfg(feature = "conformance")]
391pub mod conformance;
392
393#[cfg(test)]
394#[allow(
395 clippy::unwrap_used,
396 clippy::expect_used,
397 clippy::panic,
398 clippy::indexing_slicing,
399 reason = "test code"
400)]
401mod tests;