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    Spend,
191}
192
193/// Whether "now" is load-bearing for this tool's results (4.3).
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum Temporal {
197    Timeless,
198    Timestamped,
199}
200
201/// Cost bucket for budget and routing; the tool layer consumes it.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum CostTier {
205    Free,
206    Light,
207    Heavy,
208}
209
210/// Presentation intent; per-call `locations` are a pure function of args
211/// (tool side). Meta-level declarations use an empty list.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "snake_case")]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215pub enum RenderIntent {
216    Generic,
217    Terminal,
218    Diff { locations: Vec<Address> },
219}
220
221/// The eight-field registration, none optional in spirit: `timeout: None`
222/// is itself a declaration (no deadline), not an omission.
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224pub struct ToolMeta {
225    pub name: ToolName,
226    pub disclosure: String,
227    pub params: Payload,
228    pub effect: Effect,
229    pub cost_tier: CostTier,
230    pub timeout: Option<TimeoutMs>,
231    pub render: RenderIntent,
232    pub temporal: Temporal,
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236pub struct ToolCall {
237    /// Wire identity: `tool_use` and `tool_result` pair by this id on
238    /// both dialects. Scripted adapters synthesize deterministic ids.
239    pub id: String,
240    pub name: ToolName,
241    pub args: Payload,
242}
243
244impl ToolCall {
245    /// The bytes that say what this call does, for `IdemKey::derive` to
246    /// take as its `action_canonical`.
247    ///
248    /// The name and the arguments together are the action: two calls of
249    /// one tool with different arguments are two actions, and a key that
250    /// read only the name made them one. `id` stays out, because two
251    /// calls differing only by wire id are the same action.
252    ///
253    /// # Errors
254    /// Propagates arguments that do not serialise, which keeps the key
255    /// honest: substituting an empty string would collide
256    /// two different calls into one key.
257    pub fn action(&self) -> Result<Vec<u8>, AxError> {
258        let mut action = self.name.as_str().as_bytes().to_vec();
259        let args = serde_json::to_string(&self.args).map_err(|err| {
260            AxError::failure(
261                AxCode::InvalidArgs,
262                "read a tool call's action",
263                self.name.as_str().to_owned(),
264            )
265            .with_recovery(format!(
266                "JSON refused these arguments ({err}); call the tool again with whole \
267                 numbers and strings, which is all this city's payloads carry"
268            ))
269        })?;
270        action.extend_from_slice(args.as_bytes());
271        Ok(action)
272    }
273}
274
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276pub struct ToolOutcome {
277    pub result: Payload,
278    /// Pictures this call produced, already in the content store.
279    ///
280    /// They ride here rather than inside `result` because the model has
281    /// to see them: the turn loop puts them into the tool result block's
282    /// own attachments, and a locator buried in a payload would only
283    /// ever be text.
284    #[serde(default)]
285    pub attachments: Vec<crate::ImageRef>,
286}
287
288/// What one call is about, in the terms its own grammar names it.
289///
290/// The tool owning the call's grammar is the only place that can read
291/// it off the arguments. A sixth kind is a change here, not a
292/// string that reaches a gate.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum GateSubject {
295    /// A city position a call names as its target.
296    Area(Address),
297    /// The room a call names when it works in one.
298    Room(Address),
299    /// The scope a governance call would rewrite.
300    Scope(String),
301    /// The host a call's own bytes would leave for.
302    Host(String),
303    /// This call names no subject; the tool's grammar has none for it.
304    /// A door judges such a call against this machine rather than
305    /// skipping it, and refuses it when its effect needs a subject.
306    None,
307}
308
309/// The tool port. Adapters: runtime L0 three, browser, protocol;
310/// second adapter: citysim scripted tools.
311///
312/// `Send`, because a bench of tools is driven on a pool thread rather
313/// than on the thread that built it (`crates/sprawling/Spec.lean` §8-44). A tool that
314/// holds something a thread cannot give up has no place on a bench.
315///
316/// `Sync`, and `invoke` takes `&self`, because the read-only calls at
317/// the head of a wave run at the same time (`crates/runtime/Spec.lean` §8-3), so one
318/// tool can be inside `invoke` on several threads at once. A tool with
319/// state of its own keeps that state behind its own lock.
320pub trait Tool: Send + Sync {
321    fn meta(&self) -> &ToolMeta;
322
323    /// Fail-closed identity: a call whose name differs from `meta().name`
324    /// must return `E_INVALID_ARGS`, never route silently.
325    fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError>;
326
327    /// What this call is about, read by the grammar this tool already
328    /// parses its arguments with.
329    ///
330    /// The default is [`GateSubject::None`] rather than an inference: a
331    /// bench that guessed a subject from an argument name has already
332    /// been wrong once. `None` says the tool read the call and it names
333    /// none; a door then judges it against this machine, never skips it.
334    ///
335    /// # Errors
336    /// Refuses arguments this tool cannot read - the same refusal its
337    /// `invoke` would produce.
338    fn subject(&self, _call: &ToolCall) -> Result<GateSubject, AxError> {
339        Ok(GateSubject::None)
340    }
341
342    /// What this call may have written to the city's tree once it ran;
343    /// the default reads the declared effect and nothing more.
344    fn writes(&self, _call: &ToolCall) -> writes::Writes {
345        writes::Writes::of(&self.meta().effect)
346    }
347}
348
349/// The exec three-arm shape. Lives on the tool face because
350/// `discard::forecast` consumes it and kernel cannot depend on runtime.
351/// Exactly three arms — deliberately exhaustive.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum ExecArm {
355    Program { path: String, args: Vec<String> },
356    Python { code: String },
357    Shell { text: String },
358}
359
360#[cfg(feature = "conformance")]
361pub mod conformance;
362
363#[cfg(test)]
364#[allow(
365    clippy::unwrap_used,
366    clippy::expect_used,
367    clippy::panic,
368    clippy::indexing_slicing,
369    reason = "test code"
370)]
371mod tests;