rto_render/tool_class.rs
1//! The **one** place a tool's class is written, and the report that stands in for
2//! a class an operator did not load.
3//!
4//! # Why classes at all
5//!
6//! Every advertised tool costs tokens on **every turn**, whether or not the
7//! session could ever reach it. Measured on the surface this module classifies,
8//! `security` and `sandbox` are 51% of the description mass and are the two
9//! groups a code-navigation session never touches — so a session that only wants
10//! to read code pays roughly half its tool budget to advertise `sandbox_clear`,
11//! which is also the only tool on the surface that changes anything.
12//!
13//! [`crate::mcp::restrict`] already resolves an operator's list of tool *names*
14//! (issue #584). A class is that same mechanism with a name a person can actually
15//! type: `--tools query,quality` rather than ten names that go stale the moment a
16//! tool is added. Narrowing stays opt-in — the default advertises every class, so
17//! a server nobody configured behaves exactly as it did.
18//!
19//! # Why a class must stay discoverable
20//!
21//! A withheld class is invisible from the client side, and an invisible tool and
22//! an impossible one look identical to a model: asked about analyzer findings on
23//! a `query`-only server it would answer *"Roteiro cannot do that"*, which is
24//! false. [`CLASS_INDEX_TOOL`] is the fix and is why it is never withheld — it
25//! names every class, says which are loaded here, and costs a fraction of the
26//! prose it stands in for. The answer becomes "not loaded in this session, and
27//! here is the flag that loads it".
28//!
29//! # The taxonomy is total
30//!
31//! Every tool belongs to exactly one class, and `every_tool_has_exactly_one_class`
32//! in [`crate::mcp`] fails if a tool is added to the surface and not to a class —
33//! which would otherwise make it unreachable through the class aliases while
34//! remaining reachable by name, a surface with two disagreeing halves.
35
36/// The tool that names the classes, and the one tool belonging to none of them.
37///
38/// It is not in [`CLASSES`] on purpose: it is the index, not an entry, and a
39/// restriction that could withhold it would remove the only way a client learns
40/// what was withheld.
41pub const CLASS_INDEX_TOOL: &str = "list_tool_classes";
42
43/// Each class and the tools it names, in the order a reader meets them.
44///
45/// Alphabetical within a class so a diff to this table is readable, and the
46/// classes themselves ordered by what a session reaches for first.
47pub const CLASSES: [(&str, &[&str]); 4] = [
48 (
49 "query",
50 &[
51 "context",
52 "explain",
53 "list_kind",
54 "list_projects",
55 "path",
56 "search",
57 ],
58 ),
59 (
60 "quality",
61 &[
62 "check",
63 "config_secrets",
64 "coupling",
65 "debt",
66 "debt_density",
67 ],
68 ),
69 ("security", &["security_list", "security_status"]),
70 ("sandbox", &["sandbox_clear", "sandbox_status"]),
71];
72
73/// The tools `class` names, or `None` for a word that is not a class.
74#[must_use]
75pub fn tools_in(class: &str) -> Option<&'static [&'static str]> {
76 CLASSES
77 .iter()
78 .find(|(name, _)| *name == class)
79 .map(|(_, tools)| *tools)
80}
81
82/// The class `tool` belongs to, or `None` for [`CLASS_INDEX_TOOL`] and for a name
83/// this taxonomy does not carry.
84#[must_use]
85pub fn class_of(tool: &str) -> Option<&'static str> {
86 CLASSES
87 .iter()
88 .find(|(_, tools)| tools.contains(&tool))
89 .map(|(name, _)| *name)
90}
91
92/// Every class name, for an error message that has to list them.
93#[must_use]
94pub fn class_names() -> Vec<&'static str> {
95 CLASSES.iter().map(|(name, _)| *name).collect()
96}
97
98/// A tool advertised on this server.
99const LOADED: &str = "loaded";
100/// A tool this build carries that the operator's selection did not keep.
101const WITHHELD: &str = "withheld";
102/// A tool this build or surface does not carry at all.
103const UNAVAILABLE: &str = "unavailable";
104/// A class with no tool advertised here.
105const NOT_LOADED_HERE: &str = "not-loaded-here";
106/// A class advertised in part.
107const PARTLY_LOADED: &str = "partly-loaded";
108
109/// Every state this module can emit, each with the one line that defines it.
110///
111/// # Why the note is generated from this rather than written beside it
112///
113/// The note exists to stop a model reporting a withheld tool as a capability
114/// Roteiro does not have, and it can only do that if a model reading **only the
115/// JSON** can tell the states apart. A hand-written note did not hold that: it
116/// explained `not-loaded-here` and `unavailable` while the payload also emitted
117/// `loaded`, `withheld` and `partly-loaded` with nothing defining them — and
118/// `withheld` is precisely the case the note is for.
119///
120/// Building the note from this table makes *no observable state is undefined*
121/// true by construction rather than by remembering. The states are consts rather
122/// than literals at each match arm for the same reason: the value in the payload
123/// and the entry that explains it are one string, so they cannot come apart.
124///
125/// Definitions are terse deliberately. This travels back on every call, and a
126/// feature whose point is spending fewer tokens should not restate itself.
127const STATE_GLOSSARY: [(&str, &str); 5] = [
128 (LOADED, "advertised here"),
129 // No definition may contain a semicolon: `note` joins the entries with one, so
130 // an internal semicolon reads as a further state with no name.
131 (
132 WITHHELD,
133 "this build has the tool but the operator's `--tools` did not select its class",
134 ),
135 (NOT_LOADED_HERE, "no tool of the class is advertised here"),
136 (
137 PARTLY_LOADED,
138 "some of the class is advertised, some withheld",
139 ),
140 (
141 UNAVAILABLE,
142 "this build or surface does not carry it at all, and no startup flag reaches it",
143 ),
144];
145
146/// What a caller should *do* about those states — advice rather than definition,
147/// so it is stated once instead of repeated per entry.
148const STATE_GUIDANCE: &str = "`withheld`, `not-loaded-here` and `partly-loaded` are STARTUP CHOICES made to keep unused tool descriptions out of every turn's prompt (`roteiro serve --tools query,quality`, or `[mcp] tools` in `roteiro.toml`) — they are NOT capabilities Roteiro lacks. Name the class so the user can restart the server with it, rather than reporting that Roteiro cannot answer the question.";
149
150/// The rendered definition of `state` as it appears in a [`note`].
151///
152/// Shared with the test so "is this state defined" asks about the **definition**
153/// and not about the word appearing anywhere. [`STATE_GUIDANCE`] names three
154/// states in passing, so a bare substring check would call those defined however
155/// the glossary changed underneath it.
156fn definition_prefix(state: &str) -> String {
157 format!("`{state}` = ")
158}
159
160/// The `note` a [`report`] carries: every observable state defined, then what to
161/// do about them.
162fn note() -> String {
163 let defined: Vec<String> = STATE_GLOSSARY
164 .iter()
165 .map(|(name, meaning)| format!("{}{meaning}", definition_prefix(name)))
166 .collect();
167 format!("`state` values: {}. {STATE_GUIDANCE}", defined.join("; "))
168}
169
170/// What a single tool's presence is, from a caller's point of view.
171///
172/// Three states rather than a boolean because the **remedies differ**, and a
173/// client told only "absent" would guess. [`WITHHELD`] is an operator's `--tools`
174/// and is undone by widening it; [`UNAVAILABLE`] is a tool this build or this
175/// surface does not carry at all, and no flag reaches it.
176fn tool_state(in_build: bool, advertised: bool) -> &'static str {
177 match (in_build, advertised) {
178 (false, _) => UNAVAILABLE,
179 (true, true) => LOADED,
180 (true, false) => WITHHELD,
181 }
182}
183
184/// The document [`CLASS_INDEX_TOOL`] returns: every class, every tool in it, and
185/// what each one's presence is here.
186///
187/// Both predicates are asked per tool, and both are needed. `in_build` answers
188/// whether this build or surface carries the tool at all; `advertised` answers
189/// whether the operator's selection kept it. Collapsing them into one would make
190/// a feature gate and a `--tools` flag indistinguishable in the reply, and they
191/// have different remedies — see [`tool_state`].
192///
193/// Shared by both surfaces rather than written twice: a model that reads a
194/// different class table over MCP than over served chat has been told two
195/// different things about one server, which is the drift [`crate::tool_text`]
196/// exists to prevent for descriptions.
197#[must_use]
198pub fn report(
199 in_build: impl Fn(&str) -> bool,
200 advertised: impl Fn(&str) -> bool,
201) -> serde_json::Value {
202 let classes: Vec<serde_json::Value> = CLASSES
203 .iter()
204 .map(|(class, tools)| {
205 let rows: Vec<serde_json::Value> = tools
206 .iter()
207 .map(|tool| {
208 serde_json::json!({
209 "tool": tool,
210 "state": tool_state(in_build(tool), advertised(tool)),
211 })
212 })
213 .collect();
214 let present = tools.iter().filter(|t| in_build(t)).count();
215 let loaded = tools
216 .iter()
217 .filter(|t| in_build(t) && advertised(t))
218 .count();
219 let state = match (present, loaded) {
220 (0, _) => UNAVAILABLE,
221 (_, 0) => NOT_LOADED_HERE,
222 (p, l) if p == l => LOADED,
223 _ => PARTLY_LOADED,
224 };
225 serde_json::json!({ "class": class, "state": state, "tools": rows })
226 })
227 .collect();
228 serde_json::json!({
229 "classes": classes,
230 "note": note(),
231 })
232}
233
234#[cfg(test)]
235mod tests {
236 use super::{
237 CLASS_INDEX_TOOL, CLASSES, STATE_GLOSSARY, class_names, class_of, definition_prefix, note,
238 report, tools_in,
239 };
240 use std::collections::BTreeSet;
241
242 /// No tool may sit in two classes.
243 ///
244 /// [`class_of`] returns the first match, so a duplicate would silently pick a
245 /// winner and make `--tools <other class>` quietly not select it.
246 #[test]
247 fn no_tool_belongs_to_two_classes() {
248 let mut seen: BTreeSet<&str> = BTreeSet::new();
249 for (class, tools) in CLASSES {
250 for tool in tools {
251 assert!(
252 seen.insert(tool),
253 "`{tool}` appears twice; the second time in `{class}`"
254 );
255 }
256 }
257 assert!(
258 !seen.contains(CLASS_INDEX_TOOL),
259 "the class index belongs to no class — a restriction able to withhold it \
260 would remove the only way a client learns what was withheld"
261 );
262 }
263
264 /// The class names are distinct and none of them is a tool name, because both
265 /// are accepted in the same `--tools` list and a collision would make one
266 /// unreachable.
267 #[test]
268 fn a_class_name_is_never_also_a_tool_name() {
269 let names: BTreeSet<&str> = class_names().into_iter().collect();
270 assert_eq!(names.len(), CLASSES.len(), "duplicate class name");
271 for class in class_names() {
272 assert!(
273 class_of(class).is_none(),
274 "`{class}` is both a class and a tool"
275 );
276 assert!(tools_in(class).is_some());
277 }
278 assert!(tools_in("query").is_some_and(|t| t.contains(&"search")));
279 assert!(tools_in("nope").is_none());
280 }
281
282 /// The report distinguishes an operator's withholding from a missing build.
283 ///
284 /// Both look like "no such tool" from the client side and only one of them has
285 /// a remedy the operator can apply, so a report that collapsed them would send
286 /// a user to change a flag that cannot help.
287 #[test]
288 fn the_report_separates_a_withheld_tool_from_an_absent_one() {
289 // `list_kind` stands for the not-on-this-surface case, `search` for the
290 // loaded one, and everything else for withheld.
291 let doc = report(|t| t != "list_kind", |t| t == "search");
292 let classes = doc["classes"].as_array().expect("classes array");
293 let query = classes
294 .iter()
295 .find(|c| c["class"] == "query")
296 .expect("query class");
297 assert_eq!(query["state"], "partly-loaded", "{doc}");
298 let state_of = |name: &str| {
299 query["tools"]
300 .as_array()
301 .expect("tools array")
302 .iter()
303 .find(|t| t["tool"] == name)
304 .map(|t| t["state"].clone())
305 .expect("tool row")
306 };
307 assert_eq!(state_of("search"), "loaded", "{doc}");
308 assert_eq!(state_of("explain"), "withheld", "{doc}");
309 assert_eq!(state_of("list_kind"), "unavailable", "{doc}");
310
311 let security = classes
312 .iter()
313 .find(|c| c["class"] == "security")
314 .expect("security class");
315 assert_eq!(security["state"], "not-loaded-here", "{doc}");
316 }
317
318 /// Every `state` string a caller can observe is **defined in the note**.
319 ///
320 /// The note's whole job is to stop a model reporting a withheld tool as a
321 /// capability Roteiro does not have, and it can only do that if a model
322 /// reading nothing but this JSON can tell the states apart. It could not: it
323 /// explained two of the five, and the three it omitted included `withheld` —
324 /// exactly the case it exists for.
325 ///
326 /// Both sides are **derived**, not listed. The emitted set is collected by
327 /// walking the real payload for every `state` key, over scenarios chosen to
328 /// produce each one; the defined set is [`STATE_GLOSSARY`], which the note is
329 /// built from. A test that grepped for today's words would pass while a sixth
330 /// state shipped undefined — the same doc-describes-code-inaccurately shape
331 /// this module already had once.
332 ///
333 /// The membership check asks for the rendered **definition**
334 /// ([`definition_prefix`]), not for the word: [`STATE_GUIDANCE`] names three
335 /// states in passing, so a bare substring test would call those defined no
336 /// matter what the glossary did.
337 #[test]
338 fn every_state_the_report_emits_is_defined_in_its_note() {
339 use std::collections::BTreeSet;
340
341 /// Every `"state"` value anywhere in the document, at any depth, so the
342 /// collection does not depend on the payload's current shape.
343 fn states_in(value: &serde_json::Value, found: &mut BTreeSet<String>) {
344 match value {
345 serde_json::Value::Object(map) => {
346 for (key, child) in map {
347 if key == "state"
348 && let Some(state) = child.as_str()
349 {
350 found.insert(state.to_owned());
351 }
352 states_in(child, found);
353 }
354 }
355 serde_json::Value::Array(items) => {
356 for item in items {
357 states_in(item, found);
358 }
359 }
360 _ => {}
361 }
362 }
363
364 // Chosen to drive every arm of both state machines. The last one is not
365 // redundant with the one above it, and the set equality below is what
366 // proved that: a whole-class selection only ever yields `loaded` or
367 // `not-loaded-here`, so without a *partially* selected class nothing
368 // emitted `partly-loaded` and it would have shipped undefined. That is the
369 // case for deriving the emitted set instead of listing it.
370 /// One predicate pair to drive [`report`] with, and a label for the
371 /// failure message. Named because the tuple is otherwise too dense to
372 /// read — and clippy says so.
373 type Scenario = (&'static str, fn(&str) -> bool, fn(&str) -> bool);
374
375 let scenarios: [Scenario; 5] = [
376 ("nothing carried", |_| false, |_| false),
377 ("all carried, none advertised", |_| true, |_| false),
378 ("all carried, all advertised", |_| true, |_| true),
379 (
380 "all carried, one class advertised",
381 |_| true,
382 |name| class_of(name).is_some_and(|c| c == "query"),
383 ),
384 (
385 "all carried, one tool of each class advertised",
386 |_| true,
387 |name| {
388 class_of(name)
389 .and_then(tools_in)
390 .and_then(<[&str]>::first)
391 .is_some_and(|first| *first == name)
392 },
393 ),
394 ];
395
396 let mut emitted: BTreeSet<String> = BTreeSet::new();
397 for (label, in_build, advertised) in scenarios {
398 let doc = report(in_build, advertised);
399 let mut here = BTreeSet::new();
400 states_in(&doc, &mut here);
401 assert!(!here.is_empty(), "`{label}` produced no state at all");
402 emitted.extend(here);
403 }
404
405 let note = note();
406 for state in &emitted {
407 assert!(
408 note.contains(&definition_prefix(state)),
409 "the report can emit `{state}` and the note never defines it. A client \
410 that sees only this JSON cannot tell it from a capability Roteiro \
411 lacks, which is the one thing the note exists to prevent. Add it to \
412 `STATE_GLOSSARY`",
413 );
414 }
415
416 // The glossary must not accumulate entries for states nothing emits
417 // either: an unreachable definition is prompt tokens spent on a value no
418 // client will ever see.
419 let defined: BTreeSet<String> = STATE_GLOSSARY
420 .iter()
421 .map(|(name, _)| (*name).to_owned())
422 .collect();
423 assert_eq!(
424 emitted, defined,
425 "the states the report emits and the states the glossary defines must be \
426 the same set — left is emitted, right is defined",
427 );
428
429 // The glossary renders into one sentence with `; ` between entries, so a
430 // definition carrying its own semicolon reads as an extra, nameless state.
431 for (name, meaning) in STATE_GLOSSARY {
432 assert!(
433 !meaning.contains(';'),
434 "`{name}`'s definition contains the separator `note` joins with, so it \
435 reads as two entries: {meaning:?}",
436 );
437 }
438 }
439
440 /// A fully loaded surface reports every class loaded — the default, and the
441 /// state a server nobody configured is in.
442 #[test]
443 fn an_unrestricted_surface_reports_every_class_loaded() {
444 let doc = report(|_| true, |_| true);
445 for class in doc["classes"].as_array().expect("classes array") {
446 assert_eq!(class["state"], "loaded", "{doc}");
447 }
448 }
449}