supercode_harness/tools/tiers.rs
1//! Deterministic tool-schema tiering (TR-8 / T5).
2//!
3//! Shrinks what's ADVERTISED to the model, never what's stored: tool
4//! definitions are config, never session content (SPEC ground rule 4), so
5//! this operates purely on the wire-shape `description`/`parameters` pair
6//! built fresh at request time ([`crate::agent::Agent::schema_for`]) — there
7//! is nothing here to leak into an export or sidecar, since a tier is never
8//! persisted anywhere and is recomputed from the registry + [`crate::Config`]
9//! on every call.
10//!
11//! [`SchemaTier::Medium`] and [`SchemaTier::Minimal`] are deterministic,
12//! rule-based text transforms — no LLM in the loop, so the same
13//! (registry, tier) pair always produces byte-identical output (dev/05). The
14//! model must never see an INVALID schema: `required`, every property's
15//! `type`, `enum`, and the `properties`/`items` structure itself are never
16//! touched by [`minify`] — only prose (`description`) and the
17//! `examples`/`title` metadata keys are stripped or trimmed.
18
19use serde_json::Value;
20
21/// How verbose an advertised tool schema is. `Full` is today's behavior —
22/// byte-identical to the tool's own `description()`/`parameters()`. Builtins
23/// default to `Full` (small, load-bearing); the win target is fat activated
24/// MCP tools (set via the global knob or a per-tool override, see
25/// [`crate::Config::tool_schema_tier`] / [`crate::config::ToolOverride::schema_tier`]).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
27pub enum SchemaTier {
28 /// As-shipped: `description()`/`parameters()` verbatim.
29 #[default]
30 Full,
31 /// Trimmed descriptions (top-level and per-param, truncated at a
32 /// sentence boundary); `examples`/`title` stripped everywhere.
33 /// `required` and every param's `type` are untouched.
34 Medium,
35 /// One-sentence top-level description; only *required* params keep a
36 /// (one-sentence) description — optional params keep name+type only,
37 /// with their `description` dropped. `required` and every param's
38 /// `type` are untouched, so the schema stays valid and fully typed.
39 Minimal,
40}
41
42impl SchemaTier {
43 /// Parse a config/CLI string form (`"full"` / `"medium"` / `"minimal"`).
44 pub fn parse(s: &str) -> Option<Self> {
45 match s {
46 "full" => Some(SchemaTier::Full),
47 "medium" => Some(SchemaTier::Medium),
48 "minimal" => Some(SchemaTier::Minimal),
49 _ => None,
50 }
51 }
52
53 /// The canonical string form (round-trips through [`Self::parse`]).
54 pub fn as_str(&self) -> &'static str {
55 match self {
56 SchemaTier::Full => "full",
57 SchemaTier::Medium => "medium",
58 SchemaTier::Minimal => "minimal",
59 }
60 }
61}
62
63/// Apply `tier` to a tool's advertised `description`/`parameters`, returning
64/// the (possibly) minified pair. Deterministic and LLM-free: the same inputs
65/// always produce the same output.
66///
67/// Per-tool byte floor: if the minified wire form (description + serialized
68/// parameters) is not smaller than the original, the original is returned
69/// unchanged — a tool that's already terse is advertised as-is at any tier,
70/// never bloated by the transform.
71pub fn minify(description: &str, parameters: &Value, tier: SchemaTier) -> (String, Value) {
72 if tier == SchemaTier::Full {
73 return (description.to_string(), parameters.clone());
74 }
75 let budget = if tier == SchemaTier::Minimal { 1 } else { 2 };
76 let new_description = truncate_sentences(description, budget);
77 let mut new_parameters = parameters.clone();
78 minify_node(&mut new_parameters, tier);
79
80 let orig_bytes = description.len()
81 + serde_json::to_string(parameters)
82 .map(|s| s.len())
83 .unwrap_or(0);
84 let new_bytes = new_description.len()
85 + serde_json::to_string(&new_parameters)
86 .map(|s| s.len())
87 .unwrap_or(0);
88 if new_bytes >= orig_bytes {
89 // Byte floor: never grow, and skip the churn on already-terse tools.
90 (description.to_string(), parameters.clone())
91 } else {
92 (new_description, new_parameters)
93 }
94}
95
96/// Recursively strip `examples`/`title` and trim/drop `description` per
97/// `tier`, over a JSON-Schema object node. Applied uniformly at every depth:
98/// each object node's own `required` array decides which of ITS OWN
99/// `properties` keep a description at [`SchemaTier::Minimal`], so nested
100/// object/array schemas get the same required-vs-optional treatment as the
101/// tool's direct parameters — never just a single global flag at depth 0.
102/// Recursion also follows every JSON-Schema combinator shape a node can
103/// hold: `items` (both single-schema and draft-4 tuple-style array-of-
104/// schemas), `anyOf`/`oneOf`/`allOf`, `if`/`then`/`else`, and the schema-map
105/// keys `$defs`/`definitions`/`patternProperties` — so a schema whose bulk
106/// lives inside a combinator gets the same treatment as one that lives
107/// directly under `properties`. `required`, `type`, `enum`, and the
108/// `properties`/`items` keys themselves are never touched (dev/02:
109/// type/required equality across tiers).
110fn minify_node(node: &mut Value, tier: SchemaTier) {
111 let Some(map) = node.as_object_mut() else {
112 return;
113 };
114 map.remove("examples");
115 map.remove("title");
116
117 // Trim this node's own `description` (e.g. an object-typed param's
118 // blurb), if present.
119 if let Some(Value::String(d)) = map.get("description").cloned() {
120 let n = if tier == SchemaTier::Minimal { 1 } else { 2 };
121 map.insert(
122 "description".to_string(),
123 Value::String(truncate_sentences(&d, n)),
124 );
125 }
126
127 let required: Vec<String> = map
128 .get("required")
129 .and_then(Value::as_array)
130 .map(|a| {
131 a.iter()
132 .filter_map(|v| v.as_str().map(str::to_string))
133 .collect()
134 })
135 .unwrap_or_default();
136
137 if let Some(props) = map.get_mut("properties").and_then(|p| p.as_object_mut()) {
138 let keys: Vec<String> = props.keys().cloned().collect();
139 for key in keys {
140 let is_required = required.iter().any(|r| r == &key);
141 let Some(prop) = props.get_mut(&key) else {
142 continue;
143 };
144 if let Some(pm) = prop.as_object_mut() {
145 pm.remove("examples");
146 pm.remove("title");
147 // `minify_node` is only ever reached for Medium/Minimal
148 // (`minify()` returns early for `Full`), so there's no
149 // `Full` case to handle here.
150 if tier == SchemaTier::Minimal && !is_required {
151 pm.remove("description");
152 } else if let Some(Value::String(d)) = pm.get("description").cloned() {
153 pm.insert(
154 "description".to_string(),
155 Value::String(truncate_sentences(&d, 1)),
156 );
157 }
158 }
159 // Recurse into whatever nested schema shape this property
160 // holds (nested object `properties`/`required`, array `items`,
161 // or a nested combinator) so the same rule applies at every
162 // depth, no matter which JSON-Schema shape carries the bulk.
163 minify_node(prop, tier);
164 }
165 }
166
167 // Array `items`: either a single schema, or (draft-4 tuple validation)
168 // an array of per-position schemas.
169 if let Some(items) = map.get_mut("items") {
170 match items {
171 Value::Array(items) => {
172 for item in items {
173 minify_node(item, tier);
174 }
175 }
176 _ => minify_node(items, tier),
177 }
178 }
179
180 // Combinator schema lists: each entry is itself a full schema node.
181 for key in ["anyOf", "oneOf", "allOf"] {
182 if let Some(Value::Array(arr)) = map.get_mut(key) {
183 for item in arr {
184 minify_node(item, tier);
185 }
186 }
187 }
188
189 // Conditional schema keys: each holds a single schema node.
190 for key in ["if", "then", "else"] {
191 if let Some(v) = map.get_mut(key) {
192 minify_node(v, tier);
193 }
194 }
195
196 // Schema-map keys: each value is itself a full schema node, keyed by
197 // definition name (`$defs`/`definitions`) or regex (`patternProperties`)
198 // rather than by required-tracked property name.
199 for key in ["$defs", "definitions", "patternProperties"] {
200 if let Some(Value::Object(sub)) = map.get_mut(key) {
201 for v in sub.values_mut() {
202 minify_node(v, tier);
203 }
204 }
205 }
206}
207
208/// Common abbreviations whose trailing `.` must not be mistaken for a
209/// sentence boundary. Checked as a suffix of the text scanned so far, so
210/// multi-period forms like "e.g." are matched whole (the earlier internal
211/// `.` in "e.g" is never itself a boundary candidate, since it isn't
212/// followed by whitespace).
213const ABBREVIATIONS: &[&str] = &["e.g.", "i.e.", "etc.", "Mr.", "Mrs.", "Dr.", "vs.", "cf."];
214
215/// Truncate `s` to at most `n` sentences, cutting only at a sentence
216/// boundary (`.`/`!`/`?` immediately followed by whitespace or
217/// end-of-string) — never mid-sentence. Abbreviation-aware: a `.` boundary
218/// candidate that closes a known abbreviation (see [`ABBREVIATIONS`]), e.g.
219/// "e.g." or "Dr.", is not counted as a sentence end. Returns `s` unchanged
220/// if fewer than `n` boundaries are found (nothing sensible to cut at).
221/// Byte-index-safe: every cut point sits right after a single-byte ASCII
222/// punctuation character, which is always a valid UTF-8 boundary regardless
223/// of what multi-byte content surrounds it.
224fn truncate_sentences(s: &str, n: usize) -> String {
225 if n == 0 || s.is_empty() {
226 return s.to_string();
227 }
228 let bytes = s.as_bytes();
229 let mut count = 0;
230 for (i, &b) in bytes.iter().enumerate() {
231 if b == b'.' || b == b'!' || b == b'?' {
232 let boundary = i + 1 == bytes.len() || bytes[i + 1] == b' ' || bytes[i + 1] == b'\n';
233 if boundary {
234 if b == b'.' && ABBREVIATIONS.iter().any(|a| s[..=i].ends_with(a)) {
235 continue;
236 }
237 count += 1;
238 if count >= n {
239 return s[..=i].to_string();
240 }
241 }
242 }
243 }
244 s.to_string()
245}