Skip to main content

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;