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}