Skip to main content

runtime/
catalog.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//! Progressive disclosure and the truncation lock
7//! (`crates/runtime/Spec.lean` §8-60, §8-61). Only what this session can
8//! actually reach is listed — admission is the caller's evidence: the
9//! assembler registers the tools a building's vocation, rules and book
10//! allow, and reads each building's reading room off its `city::policy`
11//! rules. Of what is admitted, the mode's core travels as tools; the rest
12//! is one line each in the dormant index, and a run fetches a guide with
13//! `describe` and runs a dormant tool through `call`.
14//!
15//! `tool_defs` is the single source of `ChatRequest.tools`: a tool absent
16//! here and absent from the dormant index does not exist for the model.
17
18use std::collections::BTreeMap;
19
20pub use kernel::event::record::SkillPin;
21
22use kernel::{AxCode, AxError, B3Hash, ToolCall, ToolDef, ToolMeta};
23
24use crate::mode::catalog_entry;
25use kernel::Mode;
26
27mod dormant;
28mod fit;
29mod guide;
30
31/// The most bytes the dormant index may take, header and `+N more` line
32/// included, whatever the building admits (runtime D19).
33pub const DORMANT_INDEX_CEILING: usize = 1024;
34
35/// One disclosed row: "what it is + when to use it" resident-side, the
36/// "how to use it" expansion fetched on demand.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct CatalogEntry {
39    pub name: String,
40    pub disclosure: String,
41    pub expansion: String,
42    /// What the document behind this entry hashed to when the shelf was
43    /// read. `None` for an entry the catalog holds as text of its own,
44    /// which has no document to change behind anybody's back.
45    pub hash: Option<B3Hash>,
46    /// The package directory, when the skill is filed as one: the files
47    /// beside its document open by `<name>/<path>`. Said by the shelf's
48    /// scan, never guessed from how `expansion` is spelled.
49    pub package: Option<String>,
50}
51
52/// What a second-level disclosure turns out to be.
53///
54/// Exhaustive: an entry either lives somewhere the run can open, or is
55/// text the catalog itself holds. Neither is in the prompt — the prompt
56/// carries one line per entry, and this is what that line was standing
57/// in for.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Expansion {
60    /// A skill, the address it is kept at, and its package directory
61    /// when it is filed as one.
62    Skill {
63        addr: String,
64        package: Option<String>,
65    },
66    /// Text the catalog holds: the mode's own discipline, the developer
67    /// entry's, or a skill carried in from a shelf outside the city.
68    Said { text: String },
69}
70
71#[derive(Debug, Default)]
72pub struct Catalog {
73    tools: BTreeMap<String, ToolDef>,
74    skills: BTreeMap<String, Admitted>,
75    mode: Option<Mode>,
76}
77
78/// One admitted skill, and which door it came in by: the door decides
79/// whether its `expansion` is an address or the document's text.
80#[derive(Debug)]
81struct Admitted {
82    entry: CatalogEntry,
83    kept: Kept,
84}
85
86/// Where a run finds the text of an admitted skill.
87#[derive(Debug, Clone, Copy)]
88enum Kept {
89    /// On a shelf inside the city, at the address `expansion` spells.
90    Shelved,
91    /// In the catalog itself: `expansion` is the text the scan read off a
92    /// shelf outside the city, which has no address (`crates/runtime/spec/Tools/Read.lean`
93    /// §8-29-6).
94    Carried,
95}
96
97impl Catalog {
98    pub fn new() -> Catalog {
99        Catalog::default()
100    }
101
102    /// Registers an L2 tool (or an L0 tool for wire schema purposes).
103    /// Eight-field completeness is the type's business; what is checked
104    /// here is catalog hygiene: non-empty disclosure, no duplicate name.
105    pub fn admit_tool(&mut self, meta: &ToolMeta) -> Result<(), AxError> {
106        if meta.disclosure.trim().is_empty() {
107            return Err(AxError::failure(
108                AxCode::InvalidArgs,
109                "admit tool to catalog",
110                format!("{}: empty disclosure", meta.name),
111            )
112            .with_recovery(format!(
113                "write one sentence in the `disclosure` field of `{}`'s `ToolMeta` \
114                 saying what the tool does",
115                meta.name
116            )));
117        }
118        let key = meta.name.as_str().to_owned();
119        if self.tools.contains_key(&key) {
120            return Err(AxError::failure(
121                AxCode::InvalidArgs,
122                "admit tool to catalog",
123                format!("{key}: duplicate name"),
124            )
125            .with_recovery(format!(
126                "rename one of the two tools declaring `{key}` in its `ToolMeta`: a \
127                 catalog name points at one tool"
128            )));
129        }
130        self.tools.insert(
131            key,
132            ToolDef {
133                name: meta.name.clone(),
134                description: meta.disclosure.clone(),
135                input_schema: meta.params.clone(),
136            },
137        );
138        Ok(())
139    }
140
141    /// Registers one reading-room-admitted SKILL entry kept inside the
142    /// city: `expansion` is the address a run opens it at.
143    ///
144    /// # Errors
145    /// An empty name or disclosure, and a name already admitted by
146    /// either door, are `E_INVALID_ARGS`.
147    pub fn admit_skill(&mut self, entry: CatalogEntry) -> Result<(), AxError> {
148        self.admit(entry, Kept::Shelved)
149    }
150
151    /// Registers one reading-room-admitted SKILL entry from a shelf
152    /// outside the city: `expansion` is the document's text, which a
153    /// read by name hands back as it stands (`crates/runtime/spec/Tools/Read.lean` §8-29-6).
154    ///
155    /// # Errors
156    /// The same refusals as [`Catalog::admit_skill`].
157    pub fn admit_carried_skill(&mut self, entry: CatalogEntry) -> Result<(), AxError> {
158        self.admit(entry, Kept::Carried)
159    }
160
161    /// The one hygiene check both doors share.
162    fn admit(&mut self, entry: CatalogEntry, kept: Kept) -> Result<(), AxError> {
163        if entry.name.trim().is_empty() || entry.disclosure.trim().is_empty() {
164            return Err(AxError::failure(
165                AxCode::InvalidArgs,
166                "admit skill to catalog",
167                "empty name or disclosure",
168            )
169            .with_recovery(
170                "give the SKILL file a title and a one-line description; the catalog \
171                 shows both and a resident picks the skill by them",
172            ));
173        }
174        if self.skills.contains_key(&entry.name) {
175            return Err(AxError::failure(
176                AxCode::InvalidArgs,
177                "admit skill to catalog",
178                format!("{}: duplicate name", entry.name),
179            )
180            .with_recovery(format!(
181                "rename one of the two SKILL files titled `{}`: a catalog name points \
182                 at one skill",
183                entry.name
184            )));
185        }
186        self.skills
187            .insert(entry.name.clone(), Admitted { entry, kept });
188        Ok(())
189    }
190
191    /// The run sits in exactly one mode; the catalog shows only it.
192    pub fn set_mode(&mut self, mode: Mode) {
193        self.mode = Some(mode);
194    }
195
196    /// The Resident-segment text: the header line, the mode this run sits
197    /// in, the developer entry, then the dormant index. BTreeMap order
198    /// makes the bytes a pure function of the content.
199    ///
200    /// **The core tools are not among them.** Their name, disclosure and
201    /// schema travel in `ChatRequest.tools` on every turn, and writing
202    /// them here as well put every tool's sentence into the prompt twice.
203    /// Every other admitted tool and every admitted skill is one line of
204    /// the dormant index, which stays under [`DORMANT_INDEX_CEILING`]
205    /// however much the building admits.
206    pub fn render(&self) -> String {
207        let mut out = String::from(
208            "Catalog: what you can reach beyond the tools listed with this request. \
209             Open an entry by name with `read` before first use.\n",
210        );
211        if let Some(mode) = self.mode {
212            let entry = catalog_entry(mode);
213            out.push_str("- ");
214            out.push_str(&entry.name);
215            out.push_str(": ");
216            out.push_str(&entry.disclosure);
217            out.push('\n');
218        }
219        // The one line that says this city is changeable from inside
220        // it. The discipline behind it is fetched, not carried.
221        let dev = crate::mode::dev_entry();
222        out.push_str("- ");
223        out.push_str(&dev.name);
224        out.push_str(": ");
225        out.push_str(&dev.disclosure);
226        out.push('\n');
227        out.push_str(&dormant::index(&self.dormant_entries()));
228        out
229    }
230
231    /// Every admitted capability a session does not carry as a tool, in
232    /// the index's order: the tools outside the core, then the skills.
233    fn dormant_entries(&self) -> Vec<dormant::Entry<'_>> {
234        self.tools
235            .iter()
236            .filter(|(name, _)| !self.is_core(name))
237            .map(|(name, def)| dormant::Entry {
238                label: name.clone(),
239                about: &def.description,
240            })
241            .chain(self.skills.iter().map(|(name, admitted)| dormant::Entry {
242                label: format!("skill {name}"),
243                about: &admitted.entry.disclosure,
244            }))
245            .collect()
246    }
247
248    /// Whether `name` travels as a tool in this run's requests: it is in
249    /// the core of the mode the run sits in (`mode::core_tools`). A
250    /// catalog told no mode carries no tool, so nothing reaches a model
251    /// that the mode did not decide.
252    fn is_core(&self, name: &str) -> bool {
253        self.mode
254            .is_some_and(|mode| crate::mode::core_tools(mode).contains(&name))
255    }
256
257    /// The call a `call` stands for, with the id the model gave it, so its
258    /// result pairs with the `call` in the conversation and its tool
259    /// passes its own doors (`crates/runtime/Spec.lean` §8-61).
260    ///
261    /// # Errors
262    /// `E_INVALID_ARGS` when the arguments name no tool or carry no
263    /// object, when they name `call` itself, when the name is not a tool
264    /// this run was admitted, and when the arguments do not fit the
265    /// tool's input schema; the last carries the schema.
266    pub fn resolve_call(&self, call: &ToolCall) -> Result<ToolCall, AxError> {
267        fit::resolve(&self.tools, call)
268    }
269
270    /// The only source of `ChatRequest.tools`: the admitted tools in the
271    /// core of this run's mode, and no other.
272    pub fn tool_defs(&self) -> Vec<ToolDef> {
273        self.tools
274            .iter()
275            .filter(|(name, _)| self.is_core(name))
276            .map(|(_, def)| def.clone())
277            .collect()
278    }
279
280    /// What this run was given, and what each one hashed to.
281    ///
282    /// Taken from the catalog rather than from a second scan of the
283    /// shelves: the catalog is already the authority on what a run can
284    /// reach, and a second reading would be a second answer to that
285    /// question taken at a different instant.
286    pub fn skill_pins(&self) -> Vec<SkillPin> {
287        self.skills
288            .iter()
289            .filter_map(|(name, admitted)| {
290                admitted.entry.hash.map(|hash| SkillPin {
291                    name: name.clone(),
292                    hash,
293                })
294            })
295            .collect()
296    }
297
298    /// Second-level disclosure. Tools expand to their schema via
299    /// `tool_defs` (the wire always carries it); skills and the mode
300    /// expand through here.
301    ///
302    /// The two answers are different kinds of thing — a place to open
303    /// and a text already in the prompt — so they are different
304    /// variants. Collapsing both into one string made the caller guess
305    /// which it had by trying to parse it as an address, and a mode
306    /// whose text happened to parse would have been opened as a file.
307    pub fn expand(&self, name: &str) -> Option<Expansion> {
308        if let Some(Admitted { entry, kept }) = self.skills.get(name) {
309            return Some(match kept {
310                Kept::Shelved => Expansion::Skill {
311                    addr: entry.expansion.clone(),
312                    package: entry.package.clone(),
313                },
314                Kept::Carried => Expansion::Said {
315                    text: entry.expansion.clone(),
316                },
317            });
318        }
319        if let Some(mode) = self.mode {
320            let entry = catalog_entry(mode);
321            if entry.name == name {
322                return Some(Expansion::Said {
323                    text: entry.expansion,
324                });
325            }
326        }
327        if name == crate::mode::DEV_ENTRY {
328            return Some(Expansion::Said {
329                text: crate::mode::dev_entry().expansion,
330            });
331        }
332        None
333    }
334}
335
336#[cfg(test)]
337#[allow(
338    clippy::unwrap_used,
339    clippy::expect_used,
340    clippy::panic,
341    clippy::indexing_slicing,
342    reason = "test code"
343)]
344mod tests;