Skip to main content

rto_render/
tool_text.rs

1//! The **one** place each shared tool description is written.
2//!
3//! `roteiro:ignore-file` — this file is prose *about* the tools, and two of them
4//! are about intent debt: `debt`'s description has to name TODO/FIXME/HACK and
5//! `todo!()`/`unimplemented!()` stubs, and `debt_density`'s has to name the prose
6//! matches ("for now", "deferred", "tbd") a reader might otherwise be surprised
7//! by. Scanned, those descriptions register as three markers the repository does
8//! not have. The same opt-out is on `markers.rs`, `check_cli.rs` and the
9//! tool-choice fixture, for the same reason: a file that documents a scanner is
10//! not a file that owes work.
11//!
12//! # Why a module of string constants
13//!
14//! Four tools are advertised on two surfaces: the served-chat registry in
15//! `roteiro`, and this crate's MCP server. The prose was written twice, and
16//! nothing compared the copies — so they drifted. Measured when this module was
17//! introduced: `sandbox_status` differed by 363 bytes, `sandbox_clear` by 197,
18//! `security_list` by 55. A served model and an MCP client were told materially
19//! different things about the same tool, including `sandbox_clear`, the one tool
20//! on either surface that destroys anything.
21//!
22//! A description is not decoration here. It carries the warnings that prevent the
23//! likeliest misuses — that `no-analyzer-on-record` is not a clean repository,
24//! that `bytes.exclusive` and not `bytes.total` is what clearing an image frees —
25//! so one surface quietly holding an older draft is a real divergence.
26//!
27//! # What #675 removed, and the rule it used
28//!
29//! This prose is the **dominant cost of every tooled turn**: measured on the
30//! 15-tool surface before the trim, `rto_serve::advertised_system_prompt` rendered
31//! 14,586 bytes of which 12,354 — 85% — were these constants, and they reach a
32//! local model a second time through its own chat template's tool slot (#681), so
33//! this is the one lever that pays on both paths.
34//!
35//! What went was the prose that **restates a guarantee something else already
36//! upholds**, on the surface where it is upheld:
37//!
38//! * **The schema states it.** `sandbox_clear`'s `dry_run` semantics and the
39//!   consequence of naming neither scope are in its `everything`/`dry_run`
40//!   argument descriptions, which go out beside this text on both surfaces. Two
41//!   tools also declared the *absence* of an argument (*"No `project` argument"*,
42//!   *"Takes `key` and nothing else"*) beside a rendered signature that already
43//!   showed what they take — and one of those two had gone stale, because
44//!   `context` had since gained `project`.
45//! * **The code refuses it.** `sandbox_clear`'s three store-integrity refusals
46//!   (a registered box, an unrecognised store entry, an index row pointing
47//!   outside the root) are `rto_exec::sandbox_store`'s, each with its own test,
48//!   and each reaches the caller as an **error** rather than as prose read
49//!   beforehand.
50//! * **The result body says it.** `no-analyzer-on-record` carries
51//!   `rto_exec`'s `NO_RESULT_REASON`, and `check`'s `not-run` carries
52//!   `not_run_reason` with no `report` at all — read every time, where a
53//!   description is read once.
54//! * **The system turn says it.** `search`'s *"read the `snippet`, then `explain`"*
55//!   is `rto_serve::advertised_system_prompt`'s grounding rule, stated once for
56//!   every tool.
57//!
58//! What stayed is what **nothing but the words upholds**: look before you delete,
59//! quote `freed_bytes`, escalate a `complete: false` retention, `config_secrets`
60//! is not a secret scanner, a truncated findings page hides only the least severe.
61//! Those were left even where they are long, because no measurement in this
62//! repository can yet say what a model loses without them — see #675.
63//!
64//! # Why the cut is 14% and not the 59% #675 hoped for
65//!
66//! Because most of what the rule above marks cuttable is **already pinned by a
67//! test that was written on purpose**, and each pin encodes a decision this issue
68//! has no standing to reverse:
69//!
70//! | phrase | pinned by | the decision behind it |
71//! | --- | --- | --- |
72//! | `limit` is 1-`n` … no unlimited setting | `every_limit_tool_advertises_the_bound_it_enforces`, both surfaces | #393: *"a model reads the description even when it does not validate against the schema"* |
73//! | `It needs no limit` / `COUNTS, NEVER FINDINGS` | `security_status_advertises_no_bound_on_either_surface`, `security_status_states_why_it_needs_no_bound` | #402: a schema that disagrees with the clamp |
74//! | `DIFFERENT REQUESTS` | `the_mutating_tool_states_its_obligations_where_a_model_reads_them`, and its served twin | ADR-0014 v1.6: the obligations that *"do not survive living in a doc comment"* |
75//! | `THREE states` … `are ALWAYS present` | `security_status_description_says_what_ready_has_checked`, both surfaces | #464: a host missing both assets and binary must not be two round trips |
76//! | `carries NO report` … `rather than report zero findings` | `security_list_description_refuses_the_clean_reading` | the never-run reading, refused where a model reads it |
77//!
78//! Every one of those is duplication with a schema, a refusal in code, or a
79//! result field — and every one was put there **knowing that**, on the stated
80//! ground that a model reads this string and may act before it reads anything
81//! else. So the honest reading of #675 is not *"the prose is bloated"* but *"the
82//! prose is the same fact stated in three places, and the repository has already
83//! decided it wants it stated in all three."* Recovering those bytes is a
84//! different piece of work from shortening: it means moving the shared statements
85//! to the one-per-server places that already exist — `crate::mcp`'s `instructions`
86//! and `rto_serve`'s system turn, where #599's working-tree caveat already lives —
87//! and re-pointing five tests at the new home. That is a design change, and it
88//! needs the interpretation measurement #675 describes, because it trades *"every
89//! tool says it"* for *"the server says it once"*.
90//!
91//! # Why the MCP side still repeats the text
92//!
93//! It should not have to, and this is the closest the framework allows. `rmcp`'s
94//! `#[tool(description = …)]` is parsed by `darling` into a `String`, so it takes
95//! a **string literal** and rejects a path: `description = SANDBOX_STATUS` fails
96//! to compile with *"Unexpected type `path`"*. Nor can the served side simply read
97//! the MCP surface, because `serve` does not imply `mcp` — a serve-only build has
98//! no MCP module to ask.
99//!
100//! So the constant here is the **source**, `roteiro` uses it directly, and the
101//! literal `mcp.rs` is forced to carry is compared against it by
102//! `both_tool_surfaces_describe_a_tool_the_same_way`. One authority, one
103//! mechanically-checked copy — rather than two copies and a hope.
104//!
105//! Ungated on purpose: a `serve` build without `mcp` needs these too.
106
107/// `sandbox_status` — what the machine-global sandbox image store holds.
108///
109/// Trimmed by #675 to the sentences nothing else carries. What went: the absence
110/// of `project` and of `limit` (the rendered signature is `sandbox_status()`, so
111/// the prose was restating an empty argument list — and unlike the other tools'
112/// `limit` clauses this one states no bound, so #393's contract does not reach
113/// it), `objects`' definition, and the `reference`↔`image` correspondence
114/// (`sandbox_clear`'s `image` argument description states it on both surfaces).
115pub const SANDBOX_STATUS: &str = "Report what the machine-global SANDBOX IMAGE STORE holds: one row per cached container \
116    image, with its reference, digests, object counts and a size breakdown. MACHINE-GLOBAL, \
117    and `scope` says so: one store per asset root, shared by every repository here, so never \
118    attribute a size to the project under discussion. `bytes.total` is what an image \
119    references; `bytes.exclusive` is what dropping that image alone would free — they differ \
120    when images share a layer, so quote `exclusive` when saying what clearing one would give \
121    back. Extracted trees and disk images are a cache below this one, built on first run, so \
122    a pulled-only image is complete without them. `preserved` is state no pinned digest \
123    re-obtains, which `sandbox_clear` never removes. Read this before `sandbox_clear` and show \
124    the user the numbers: a destructive verb with no way to see what it will destroy is \
125    invoked blind. Read-only.";
126
127/// `sandbox_clear` — delete cached images; the one tool that changes anything.
128///
129/// # What #675 cut, and why none of it was a safety change
130///
131/// The scope rule is **enforced, not merely described**, and on both surfaces:
132/// `crate::mcp`'s `sandbox_clear` and `roteiro`'s `GraphToolRegistry::sandbox_clear`
133/// each refuse `image` with `everything` (*"different requests; pass exactly
134/// one"*) and each refuse neither (*"Supplying neither does not mean
135/// everything"*), pinned by `sandbox_clear_refuses_a_scope_it_was_not_given` on
136/// each. It is stated again in the **argument** descriptions, which go out beside
137/// this text on both surfaces (*"Mutually exclusive with `image`; supplying
138/// neither is an error"*, *"Report what would be removed and remove nothing"*).
139/// So the description's own *"Neither is an error and does not mean everything;
140/// both is an error"* was the third statement of one rule, and the second one to
141/// be unenforceable prose — it went, along with the `dry_run`/`applied` sentence
142/// the `dry_run` argument description already carries.
143///
144/// The three refusals at the end — a registered box, an unrecognised entry under
145/// the store root, an index row pointing outside it — went for a different
146/// reason: they are `rto_exec::sandbox_store`'s, each with its own test, and each
147/// reaches the caller as an **error**. Prose read beforehand cannot improve on a
148/// refusal that already happened.
149///
150/// **`DIFFERENT REQUESTS` stayed**, not because it is unenforced but because
151/// `the_mutating_tool_states_its_obligations_where_a_model_reads_them` and its
152/// served twin require it: ADR-0014 v1.6's obligations were deliberately put in
153/// the one string a model reads. What also stayed is the part nothing else
154/// upholds at all: look before you delete, quote what it freed, and escalate a
155/// `complete: false` retention.
156pub const SANDBOX_CLEAR: &str = "DELETE cached container images from the machine-global sandbox image store, and report \
157    what that freed. The one tool here that changes anything, and it cannot reach findings, \
158    memory or the graph: everything it drops is re-obtainable from a pinned digest, so it \
159    costs a re-download and never information. MACHINE-GLOBAL: one store per asset root, \
160    shared by every repository this server hosts, so clearing for one project slows the next \
161    sandboxed run for all. Call `sandbox_status` first and show the user what is cached and \
162    what it costs — a re-pull is minutes to tens of minutes and gigabytes. `image` and \
163    `everything` are DIFFERENT REQUESTS with no default: pass exactly one, `image` with a \
164    reference from `sandbox_status` or `everything: true`. Report what it \
165    freed: quote `freed_bytes`, with `store_bytes_before`/`store_bytes_after` either side, \
166    rather than saying it worked. `retained` re-checks every surviving image against the disk \
167    afterwards; if any `complete` is false say so prominently — that is a damaged store, not \
168    a successful clear, and `roteiro security prefetch` is the repair.";
169
170/// `security_list` — stored findings, with the run evidence behind them.
171///
172/// The long restatement of what `no-analyzer-on-record` means went in #675, and
173/// the pointer to it stayed. `rto_exec::tool_security::NO_RESULT_REASON` is
174/// emitted in the **result body** for exactly that coverage — *"This is NOT a
175/// clean result and must not be reported as one"* — and its own comment gives the
176/// argument: *"the description of a tool is read once and the body of its result
177/// is read every time."*
178pub const SECURITY_LIST: &str = "List the SECURITY FINDINGS stored for this repository: every live findings layer, the run \
179    evidence behind it, and a page of its findings. READ `coverage` FIRST: \
180    `no-analyzer-on-record` means nothing was \
181    analyzed and is NOT a clean repository — it carries NO `report` at all, so say so rather \
182    than report zero findings; `analyzed` with `findings` 0 is the other case, an analyzer \
183    that ran and found nothing. `limit` is 1-100 (default 20) — no unlimited setting — and is findings PER LAYER; \
184    each layer carries its true `findings` count beside the `page` returned. A page keeps the most severe findings first, so what is \
185    omitted is the least severe — never conclude a severity is absent from a truncated page. \
186    `cross_reference` is a view over those findings, not a replacement: it groups dependency \
187    advisories both analyzers reported, `confirmed_by` counts how many, `1` is normal rather \
188    than a discrepancy, and the `findings` total is unchanged by it. Read-only: it cannot run \
189    an analyzer or ingest a report — ask the user to run `roteiro security run` or `roteiro \
190    security ingest`, because a tool call is not a person consenting to execution.";
191
192/// `security_status` — readiness, in two separately scoped halves.
193pub const SECURITY_STATUS: &str = "Report SECURITY READINESS in TWO SEPARATELY SCOPED SECTIONS; report them separately, never \
194    merged. `machine`: this HOST — its pinned-asset cache and each analyzer's coverage matrix \
195    with `host_readiness`. Identical for every project here, and \
196    says nothing whatsoever about whether anything has been run; `ready` is readiness to run \
197    ON THIS HOST and no more. `host_readiness` has THREE states with different remedies: \
198    `ready` (assets provisioned AND the analyzer's program on PATH); `assets-not-provisioned` \
199    (ask the user to run `roteiro security prefetch`); `binary-not-found` (ROTEIRO NEVER \
200    INSTALLS ANALYZERS — ask the user to install it or to `roteiro security ingest` a report \
201    from elsewhere). `assets_provisioned` and `missing_programs` are ALWAYS present, so when \
202    the state is not `ready` read both: a host can lack both, and `host_readiness` names only \
203    the first remedy. The sandboxed backend supplies analyzers from a digest-pinned image, so \
204    `binary-not-found` \
205    does not block it, and this tool does not inspect the image store, so it reports no \
206    sandbox verdict. `repository` describes ONE PROJECT: which findings layers are live, how \
207    many findings each holds, how old the advisory data behind each is. \
208    `possibly_stale: true` whenever advisory data is involved and NEVER means current; `false` \
209    means only that there is no advisory axis. Read `repository.coverage` first: \
210    `no-analyzer-on-record` means nothing has been analyzed and is NOT a clean repository. \
211    COUNTS, NEVER FINDINGS; use `security_list` for those, and it needs no `limit`. Read-only: \
212    it cannot provision, and \
213    `roteiro security prefetch` needs human consent, so ask the user to run it.";
214
215/// `list_tool_classes` — the index that keeps a withheld class discoverable.
216///
217/// Deliberately the shortest description here. It stands in for whole classes of
218/// prose an operator chose not to advertise, and a long stand-in would spend the
219/// saving it exists to protect: at ~90 tokens it replaces the `security` class's
220/// ~819 or the `sandbox` class's ~713.
221pub const LIST_TOOL_CLASSES: &str = "Name this server's tool CLASSES — `query`, `quality`, `security`, `sandbox` — the tools in \
222    each, and which are LOADED here. Call it before telling a user Roteiro cannot do \
223    something: a class can be left out at startup to keep its descriptions out of every \
224    turn's prompt, and `not-loaded-here` means not advertised to this session, NOT a missing \
225    capability. Report the class name so the user can restart the server with it. Read-only.";
226
227/// `check`.
228pub const CHECK: &str = "Run the AUTHORED-LAYER DRIFT CHECK — the same gate `roteiro check` exits non-zero on and \
229    the pre-commit hook reads — and return its verdict as data: unresolvable ADR \
230    `[[path#Symbol]]` links, `@rto:` annotations naming an unknown or superseded ADR, \
231    malformed ADRs, duplicate `adr-id`s. READ `gate` FIRST: `pass`, `fail`, or `not-run`. \
232    `not-run` is a real outcome — the check refuses rather than answer about a tree that is \
233    nobody's — and carries NO `report` at all, so if you are looking for `violations` and \
234    there is no `report`, nothing was checked and you must say so rather than report a clean \
235    repository; `not_run_reason` says what to fix (usually: run `roteiro sync`). Read-only: it \
236    does not rebuild the graph, which is the one thing the CLI gate does that this cannot.";
237
238/// `config_secrets`.
239///
240/// **#675 left the four `CANNOT` sentences alone**, and this is the record of why.
241/// They are not enforced anywhere: the result carries no caveat field, and an
242/// empty report is byte-identical whether the repository has no credentials or
243/// simply names them innocuously. [`rto_graph::config_secrets`] states the
244/// intent — *"Every surface carries this limitation in its own words, so a model
245/// calling the tool passes it on rather than reporting a security guarantee that
246/// was never offered"* — so cutting them would be cutting the only thing that
247/// upholds it. Whether a shorter phrasing would carry as far is a question about
248/// what a model does with the text, and nothing in this repository can answer it
249/// yet (#675).
250pub const CONFIG_SECRETS: &str = "Inventory the SECRET-NAMED config keys in the graph: their file paths, their key names, \
251    and whether each value was redacted before being stored (`state` = redacted | declared | \
252    present). Answers \"which of this repo's config surfaces deal in credentials\" and \"did \
253    anything unredacted get into this graph\". THIS IS NOT A SECRET SCANNER — state the limits \
254    when you report it, and never imply a security guarantee. It CANNOT find a hardcoded \
255    credential in source code: it reads config-key nodes, so a token in a Rust or Python \
256    string literal produces nothing here and is invisible. It CANNOT judge whether a value is \
257    valid, because it never sees one — values are redacted before they reach the store. It \
258    CANNOT tell a real secret from a placeholder: `API_TOKEN=changeme` in a committed \
259    `.env.example` and a live token are the same row. And an EMPTY RESULT DOES NOT MEAN THERE \
260    ARE NO SECRETS — it means no config key is secret-NAMED; a credential under an innocuous \
261    key like `dsn` or `endpoint` never appears. If asked to scan for secrets, say plainly that \
262    this tool cannot do it. `limit` is 1-200 (default 50) — no unlimited setting.";
263
264/// `context`.
265///
266/// *"Takes `key` and nothing else"* went in #675 because it had stopped being
267/// true: every graph tool gained the `project` selector, and the rendered
268/// signature is `context(key: str, project?: str)`. The `{cap}` clause stayed —
269/// see `context_states_the_edge_cap_the_code_enforces`.
270pub const CONTEXT: &str = "Fetch a node's CONTEXT BUNDLE: the node, its metadata, and its one-hop provenance-labelled \
271    neighbourhood, with a validity `fingerprint` that moves when the node or any neighbour \
272    changes. The grounding to answer “what is this and what is it wired to” from. BOUNDED, \
273    and it tells you when it bound something: each direction carries at most {cap} edges, and \
274    beyond that `truncated` is true, `outgoing.total`/`incoming.total` give the real counts, \
275    and `omitted` names each edge kind and how many of it are missing. Read `omitted` before \
276    concluding anything from an absence — a large file's missing definitions are counted \
277    rather than silently dropped — and use `explain` or `search` to reach what was left out.";
278
279/// `coupling`.
280pub const COUPLING: &str = "Rank symbols by DIRECTED call coupling over `calls` edges: `fan_in` (how many distinct \
281    symbols call this one), `fan_out` (how many it calls), `instability` = \
282    fan_out/(fan_in+fan_out). `order`=fan_in finds what the codebase most depends on, \
283    `order`=fan_out the symbols that reach furthest, `total` (the default) overall coupling. \
284    Call edges are resolved by simple name, so a short generically-named function can absorb \
285    every call to that name — say so if you report a high `fan_in` on one. `limit` is 1-100 \
286    (default 20) — no unlimited setting.";
287
288/// `debt`.
289///
290/// # The sentence #675 removed was false on one of the two surfaces
291///
292/// *"Optional `kind` restricts to given categories"* named an argument the served
293/// registry does not have: `rto_render::mcp`'s `DebtArgs` calls it `kind`, and
294/// `roteiro`'s served schema calls it `categories`. One shared description cannot
295/// name both, and naming either is wrong half the time — so it names neither, and
296/// each surface's own schema carries the argument. The surfaces disagreeing at all
297/// is a separate defect from this one and is not fixed here; renaming an argument
298/// is a wire change on whichever surface loses.
299pub const DEBT: &str = "List intent-debt markers found in the codebase — TODO/FIXME/HACK comments, \
300    todo!()/unimplemented!() stubs, and deferred-work notes — grouped by category (todo, \
301    fixme, hack, stub, deferred), optionally restricted to some of them. Each marker links to \
302    its enclosing symbol or file via a `contains` edge.";
303
304/// `debt_density`.
305pub const DEBT_DENSITY: &str = "Rank FILES by intent-debt DENSITY — markers per 1,000 lines — rather than by raw marker \
306    count, which ranks the biggest file first by construction. `overall_per_kloc` is the \
307    repository baseline to read a file's `per_kloc` against. Use `debt` instead when the \
308    question is which markers exist, not where they are concentrated. Two limits to pass on \
309    rather than reporting a number as a finding: the denominator is FILE LENGTH — every line, \
310    blanks and comments included — not source lines of code, so figures run lower than an SLOC \
311    tool's and flatter verbose or generated files; and the markers beneath it include prose \
312    matches (`for now`, `deferred`, `tbd`), so a design document can rank as dense debt. This \
313    is a measurement, not a gate. `limit` is 1-100 (default 20) — no unlimited setting.";
314
315/// `explain`.
316pub const EXPLAIN: &str = "Explain a graph node: its record and its provenance-labelled incoming/outgoing edges. Keys \
317    look like `sym:<lang>:<path>#<Name>`, `file:<path>`, `adr:<id>`. A key may be \
318    project-qualified (`<project>::<key>`) to follow a cross-repo link into another hosted \
319    project (see `list_projects`).";
320
321/// `list_projects`.
322pub const LIST_PROJECTS: &str = "List the projects this server hosts (often just one). Pass one as `project` to the other \
323    tools to query it (ADR-0008). A single-project server needs no `project`.";
324
325/// `path`.
326pub const PATH: &str = "Find a shortest path between two graph nodes, following edges in either direction. Each \
327    hop records the edge kind, provenance and direction. A path lives within one project: a \
328    project-qualified `from` (<project>::<key>) selects that project (see list_projects).";
329
330/// `search`.
331///
332/// *"Read the `snippet`, and call `explain` on a returned key for the full
333/// content"* went in #675: the system turn the served surface wraps this listing
334/// in already says it, in more words and to every tool at once — *"read each hit's
335/// `snippet` or call `explain` on its key to read the node's actual content BEFORE
336/// describing it"* (`rto_serve::advertised_system_prompt`). Two statements of one
337/// instruction is what the prompt was paying for twice.
338pub const SEARCH: &str = "Search graph nodes by text — names, keys, paths, and captured content (doc comments, \
339    README/ADR/blueprint prose). Returns the top matches with keys and, for content-bearing \
340    nodes, a short `snippet` of the node's actual content to ground your answer; curated \
341    ADRs/blueprints and READMEs rank first, so this is the entry point for \"what is X / why\" \
342    questions. `limit` is 1-25 (default 10) — no unlimited setting.";
343
344/// The description for `name`, or `None` for a tool this module does not own.
345///
346/// The lookup exists so [`crate::mcp`] can set descriptions on its routes at
347/// build time instead of repeating the prose in a `#[tool(description = …)]`
348/// literal. That is what makes this module the **only** definition rather than an
349/// authority with a copy beside it.
350#[must_use]
351pub fn for_tool(name: &str) -> Option<String> {
352    let raw = match name {
353        "check" => CHECK,
354        "config_secrets" => CONFIG_SECRETS,
355        "context" => CONTEXT,
356        "coupling" => COUPLING,
357        "debt" => DEBT,
358        "debt_density" => DEBT_DENSITY,
359        "explain" => EXPLAIN,
360        "list_projects" => LIST_PROJECTS,
361        "list_tool_classes" => LIST_TOOL_CLASSES,
362        "path" => PATH,
363        "sandbox_clear" => SANDBOX_CLEAR,
364        "sandbox_status" => SANDBOX_STATUS,
365        "search" => SEARCH,
366        "security_list" => SECURITY_LIST,
367        "security_status" => SECURITY_STATUS,
368        _ => return None,
369    };
370    // Every description goes through the substitution, not just the one that
371    // needs it: `CONTEXT` is the only const carrying a `{cap}` placeholder
372    // today, and for the other thirteen this is a no-op. An early return for
373    // `context` beside a `context` arm in the match would leave a second path
374    // that returns the placeholder unreplaced — dead until somebody reorders
375    // the function, and then wrong in the output rather than at compile time.
376    Some(raw.replace("{cap}", &rto_graph::TOOL_CONTEXT_EDGE_CAP.to_string()))
377}
378
379#[cfg(test)]
380mod tests {
381    use super::for_tool;
382
383    /// Every name this module owns.
384    const OWNED: [&str; 15] = [
385        "check",
386        "config_secrets",
387        "context",
388        "coupling",
389        "debt",
390        "debt_density",
391        "explain",
392        "list_projects",
393        "list_tool_classes",
394        "path",
395        "sandbox_clear",
396        "sandbox_status",
397        "search",
398        "security_list",
399        "security_status",
400    ];
401
402    /// The ceiling on the sum of every description this module owns.
403    ///
404    /// #675 cut the total from 12,354 bytes to 10,645, and this is set at 10,700 —
405    /// **55 bytes of slack, which is not room for a sentence.** A budget with
406    /// comfortable headroom would be worse than none: the failure to guard against
407    /// is not one careless paragraph but the slow return of the 12 KB, where every
408    /// individual addition looked justified on its own. That is how the surface got
409    /// there the first time. Set this tight, a change that genuinely needs the room
410    /// raises the number in the same commit, and the diff shows the cost being
411    /// accepted rather than absorbed.
412    ///
413    /// Proven non-vacuous by restoring one cut sentence — `sandbox_clear`'s three
414    /// store-integrity refusals, 166 bytes — which takes the total past this
415    /// ceiling and fails naming `sandbox_clear` as the tool that grew.
416    const DESCRIPTION_BYTE_BUDGET: usize = 10_700;
417
418    /// No advertised description may still carry a `{…}` placeholder.
419    ///
420    /// `CONTEXT` holds `{cap}` so the edge cap has one source rather than a
421    /// hardcoded `50` on one surface and an interpolation on the other. The risk
422    /// that creates is a path returning the raw constant — which is exactly what a
423    /// special case beside a `match` arm for the same name would give, dead until
424    /// somebody reorders the function and then wrong in a model's prompt rather
425    /// than at compile time.
426    ///
427    /// Asserted over **every** tool, not just `context`: a placeholder added to
428    /// another constant later is the same defect, and naming only the one that has
429    /// it today would not catch it.
430    #[test]
431    fn no_description_reaches_a_caller_with_a_placeholder_in_it() {
432        for name in OWNED {
433            let text = for_tool(name).expect("this module owns every name above");
434            assert!(
435                !text.contains('{'),
436                "`{name}` still carries a placeholder: {text}"
437            );
438        }
439        assert!(for_tool("list_kind").is_none(), "MCP-only, not owned here");
440        assert!(for_tool("nope").is_none());
441    }
442
443    /// **No advertised description may contain a run of spaces.**
444    ///
445    /// Every constant here is one long chain of Rust's string-continuation
446    /// escape, and that escape is unforgiving in both directions. A trailing
447    /// `\` eats the newline *and* the continued line's indentation, so the
448    /// single separating space has to be written **before** the backslash:
449    /// forget it and two words weld together, put one on each side and a double
450    /// space reaches the model.
451    ///
452    /// Worth a guard rather than an eyeball, for the reason #675 exists: the
453    /// defect is invisible in review — the source reads the same either way and
454    /// nothing fails to compile — and its only symptom is bytes, in a surface
455    /// this module now budgets to tens of bytes of slack.
456    ///
457    /// It also settles the question mechanically. Copilot read
458    /// `SANDBOX_CLEAR`'s *"Report what it \ freed:"* break as producing several
459    /// spaces on the #675 PR. It does not, because the escape swallows the
460    /// indentation — but "I read the reference and disagree" is a weaker answer
461    /// than a test that runs the real `for_tool` and would fail if it ever did.
462    #[test]
463    fn no_advertised_description_carries_a_run_of_spaces() {
464        for name in OWNED {
465            let text = for_tool(name).expect("owned");
466            let Some(at) = text.find("  ") else { continue };
467            panic!(
468                "`{name}` has a run of spaces at byte {at}. It reaches the model and \
469                 costs bytes against `DESCRIPTION_BYTE_BUDGET`. A continued line \
470                 carries its separating space BEFORE the backslash, never after it \
471                 as well: …{}…",
472                window_around(&text, at, 60),
473            );
474        }
475    }
476
477    /// `radius` bytes either side of `at`, snapped out to character boundaries.
478    ///
479    /// # Why this is not `&text[at - radius..at + radius]`
480    ///
481    /// **That form panics, and it panics only when the guard above finally
482    /// fires** — the one moment the guard exists for. These descriptions are far
483    /// from ASCII: 74 em dashes at three bytes each, plus ellipses, `↔` and curly
484    /// quotes. A window whose end lands inside one of those aborts with `byte
485    /// index N is not a char boundary` *before* the assertion message is built, so
486    /// the diagnostic that justifies the whole test is the part that breaks.
487    /// Caught by Copilot on the #675 PR, and reproduced by injecting a double
488    /// space beside an em dash: the guard fired, and reported the em dash instead
489    /// of the defect.
490    ///
491    /// Snapping outward rather than clamping, because a window that silently
492    /// shrank could hide the very characters that caused the trouble. `0` and
493    /// `len()` are always boundaries, so both loops terminate, and no input can
494    /// panic here — not merely no input this repository holds today.
495    fn window_around(text: &str, at: usize, radius: usize) -> &str {
496        let mut from = at.saturating_sub(radius);
497        while !text.is_char_boundary(from) {
498            from -= 1;
499        }
500        let mut to = at.saturating_add(radius).min(text.len());
501        while !text.is_char_boundary(to) {
502            to += 1;
503        }
504        &text[from..to]
505    }
506
507    /// [`window_around`] survives the multi-byte characters this prose is full of.
508    ///
509    /// The fix for a panic needs a test that would fail without it, and the guard
510    /// above cannot be that test: it only builds a window when it is already
511    /// failing, so on a healthy tree it never exercises this at all. Every case
512    /// here puts a boundary demand inside an em dash, which is what byte
513    /// arithmetic gets wrong.
514    #[test]
515    fn a_diagnostic_window_never_splits_a_character() {
516        // `—` is three bytes, so every offset inside it is a trap.
517        let text = "alpha — bravo — charlie — delta";
518        let dash = text.find('—').expect("an em dash");
519        for radius in 0..text.len() + 4 {
520            for at in [0, dash, dash + 3, text.len()] {
521                let got = window_around(text, at, radius);
522                assert!(
523                    text.contains(got),
524                    "window must be a real substring: {got:?}"
525                );
526            }
527        }
528        // And it really does widen past a character rather than truncating it.
529        assert!(
530            window_around(text, dash + 3, 1).contains('—'),
531            "a window abutting a multi-byte character must include it whole",
532        );
533    }
534
535    /// The cap really is substituted, rather than the placeholder merely being
536    /// absent because somebody deleted it from the prose.
537    #[test]
538    fn context_states_the_edge_cap_the_code_enforces() {
539        let text = for_tool("context").expect("context");
540        assert!(
541            text.contains(&format!(
542                "at most {} edges",
543                rto_graph::TOOL_CONTEXT_EDGE_CAP
544            )),
545            "the cap in the prose must be the one `bound_edges` applies: {text}"
546        );
547    }
548
549    /// **The advertised prose has a budget, and this is it.**
550    ///
551    /// Nothing measured this before #675, which is the whole reason there was
552    /// 12,354 bytes of it: every sentence was added deliberately, none was ever
553    /// weighed against the ones already there, and the cost lands on **every
554    /// tooled turn** — in the served system listing and, since #681, a second time
555    /// through a local model's own chat-template tool slot.
556    ///
557    /// Measured on `for_tool` rather than on the constants, because that is what a
558    /// caller receives: the `{cap}` substitution makes the rendered text longer
559    /// than the literal, and a budget the substitution can silently exceed is not
560    /// a budget. The per-tool figure is in the failure message so a breach names
561    /// the tool that grew rather than only the total.
562    #[test]
563    fn the_advertised_description_prose_stays_within_its_budget() {
564        let mut rows: Vec<(usize, &str)> = OWNED
565            .iter()
566            .map(|name| {
567                let text = for_tool(name).expect("owned");
568                (text.len(), *name)
569            })
570            .collect();
571        rows.sort_unstable_by(|a, b| b.cmp(a));
572        let total: usize = rows.iter().map(|(bytes, _)| bytes).sum();
573        assert!(
574            total <= DESCRIPTION_BYTE_BUDGET,
575            "advertised description prose is {total} bytes, over the \
576             {DESCRIPTION_BYTE_BUDGET}-byte budget by {}. Every byte here is \
577             prefilled on every tooled turn, on both the served listing and a \
578             local model's chat template. Either cut the sentence something else \
579             already upholds — the schema, a refusal in code, or the result body \
580             — or raise the budget in this commit and say what it bought. \
581             Largest first: {rows:?}",
582            total - DESCRIPTION_BYTE_BUDGET,
583        );
584    }
585}