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 some mode (`mode::in_some_core`, runtime D25), whatever
250 /// mode the run sits in. A catalog told no mode carries no tool, so
251 /// nothing reaches a model before a mode was decided.
252 fn is_core(&self, name: &str) -> bool {
253 self.mode.is_some() && crate::mode::in_some_core(name)
254 }
255
256 /// The call a `call` stands for, with the id the model gave it, so its
257 /// result pairs with the `call` in the conversation and its tool
258 /// passes its own doors (`crates/runtime/Spec.lean` §8-61).
259 ///
260 /// # Errors
261 /// `E_INVALID_ARGS` when the arguments name no tool or carry no
262 /// object, when they name `call` itself, when the name is not a tool
263 /// this run was admitted, and when the arguments do not fit the
264 /// tool's input schema; the last carries the schema.
265 pub fn resolve_call(&self, call: &ToolCall) -> Result<ToolCall, AxError> {
266 fit::resolve(&self.tools, call)
267 }
268
269 /// The only source of `ChatRequest.tools`: the admitted tools in the
270 /// union of the modes' cores, and no other.
271 pub fn tool_defs(&self) -> Vec<ToolDef> {
272 self.tools
273 .iter()
274 .filter(|(name, _)| self.is_core(name))
275 .map(|(_, def)| def.clone())
276 .collect()
277 }
278
279 /// What this run was given, and what each one hashed to.
280 ///
281 /// Taken from the catalog rather than from a second scan of the
282 /// shelves: the catalog is already the authority on what a run can
283 /// reach, and a second reading would be a second answer to that
284 /// question taken at a different instant.
285 pub fn skill_pins(&self) -> Vec<SkillPin> {
286 self.skills
287 .iter()
288 .filter_map(|(name, admitted)| {
289 admitted.entry.hash.map(|hash| SkillPin {
290 name: name.clone(),
291 hash,
292 })
293 })
294 .collect()
295 }
296
297 /// Second-level disclosure. Tools expand to their schema via
298 /// `tool_defs` (the wire always carries it); skills and the mode
299 /// expand through here.
300 ///
301 /// The two answers are different kinds of thing — a place to open
302 /// and a text already in the prompt — so they are different
303 /// variants. Collapsing both into one string made the caller guess
304 /// which it had by trying to parse it as an address, and a mode
305 /// whose text happened to parse would have been opened as a file.
306 pub fn expand(&self, name: &str) -> Option<Expansion> {
307 if let Some(Admitted { entry, kept }) = self.skills.get(name) {
308 return Some(match kept {
309 Kept::Shelved => Expansion::Skill {
310 addr: entry.expansion.clone(),
311 package: entry.package.clone(),
312 },
313 Kept::Carried => Expansion::Said {
314 text: entry.expansion.clone(),
315 },
316 });
317 }
318 if let Some(mode) = self.mode {
319 let entry = catalog_entry(mode);
320 if entry.name == name {
321 return Some(Expansion::Said {
322 text: entry.expansion,
323 });
324 }
325 }
326 if name == crate::mode::DEV_ENTRY {
327 return Some(Expansion::Said {
328 text: crate::mode::dev_entry().expansion,
329 });
330 }
331 None
332 }
333}
334
335#[cfg(test)]
336#[allow(
337 clippy::unwrap_used,
338 clippy::expect_used,
339 clippy::panic,
340 clippy::indexing_slicing,
341 reason = "test code"
342)]
343mod tests;