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;