Skip to main content

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}