Skip to main content

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}