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}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use serde_json::json;
251
252    #[test]
253    fn full_tier_is_identity() {
254        let desc = "A very long description. With two sentences.";
255        let params = json!({"type":"object","properties":{"a":{"type":"string","description":"x","examples":["e"]}},"required":["a"]});
256        let (d, p) = minify(desc, &params, SchemaTier::Full);
257        assert_eq!(d, desc);
258        assert_eq!(p, params);
259    }
260
261    #[test]
262    fn truncate_sentences_cuts_at_boundary() {
263        assert_eq!(truncate_sentences("One. Two. Three.", 1), "One.");
264        assert_eq!(truncate_sentences("One. Two. Three.", 2), "One. Two.");
265        assert_eq!(
266            truncate_sentences("No punctuation here", 1),
267            "No punctuation here"
268        );
269        assert_eq!(truncate_sentences("", 1), "");
270    }
271
272    #[test]
273    fn minimal_drops_optional_param_descriptions_keeps_required() {
274        let desc = "Does a thing. Has more detail. Even more.";
275        let params = json!({
276            "type": "object",
277            "properties": {
278                "req": {"type": "string", "description": "The required one. More detail here."},
279                "opt": {"type": "integer", "description": "The optional one. More detail here."}
280            },
281            "required": ["req"]
282        });
283        let (d, p) = minify(desc, &params, SchemaTier::Minimal);
284        assert_eq!(d, "Does a thing.");
285        assert_eq!(p["properties"]["req"]["description"], "The required one.");
286        assert!(p["properties"]["opt"].get("description").is_none());
287        // Types and required are untouched.
288        assert_eq!(p["properties"]["req"]["type"], "string");
289        assert_eq!(p["properties"]["opt"]["type"], "integer");
290        assert_eq!(p["required"], json!(["req"]));
291    }
292
293    #[test]
294    fn examples_and_title_stripped_at_every_tier_above_full() {
295        let params = json!({
296            "type": "object",
297            "title": "Top title",
298            "properties": {
299                "a": {"type": "string", "examples": ["x"], "title": "A title"}
300            },
301            "required": []
302        });
303        let (_, p) = minify("desc.", &params, SchemaTier::Medium);
304        assert!(p.get("title").is_none());
305        assert!(p["properties"]["a"].get("examples").is_none());
306        assert!(p["properties"]["a"].get("title").is_none());
307    }
308
309    #[test]
310    fn byte_floor_never_grows_already_terse_schema() {
311        let desc = "Short.";
312        let params = json!({"type":"object","properties":{"a":{"type":"string"}},"required":["a"]});
313        let (d, p) = minify(desc, &params, SchemaTier::Minimal);
314        assert_eq!(d, desc);
315        assert_eq!(p, params);
316    }
317
318    #[test]
319    fn nested_object_properties_get_required_aware_treatment_too() {
320        let params = json!({
321            "type": "object",
322            "properties": {
323                "outer": {
324                    "type": "object",
325                    "properties": {
326                        "inner_req": {"type": "string", "description": "Inner required. More."},
327                        "inner_opt": {"type": "string", "description": "Inner optional. More."}
328                    },
329                    "required": ["inner_req"]
330                }
331            },
332            "required": ["outer"]
333        });
334        let (_, p) = minify("desc. more.", &params, SchemaTier::Minimal);
335        let outer = &p["properties"]["outer"];
336        assert_eq!(
337            outer["properties"]["inner_req"]["description"],
338            "Inner required."
339        );
340        assert!(outer["properties"]["inner_opt"]
341            .get("description")
342            .is_none());
343        assert_eq!(outer["properties"]["inner_req"]["type"], "string");
344        assert_eq!(outer["properties"]["inner_opt"]["type"], "string");
345    }
346
347    #[test]
348    fn array_items_are_recursed_into() {
349        let params = json!({
350            "type": "object",
351            "properties": {
352                "list": {
353                    "type": "array",
354                    "items": {
355                        "type": "object",
356                        "properties": {
357                            "field": {"type": "string", "description": "Field desc. More detail.", "examples": ["e"]}
358                        },
359                        "required": []
360                    }
361                }
362            },
363            "required": []
364        });
365        let (_, p) = minify("desc. more.", &params, SchemaTier::Minimal);
366        let field = &p["properties"]["list"]["items"]["properties"]["field"];
367        assert!(field.get("examples").is_none());
368        assert!(
369            field.get("description").is_none(),
370            "not required at that nesting level"
371        );
372        assert_eq!(field["type"], "string");
373    }
374
375    #[test]
376    fn truncate_sentences_ignores_common_abbreviations() {
377        assert_eq!(
378            truncate_sentences("See e.g. the docs. Second sentence.", 1),
379            "See e.g. the docs."
380        );
381        assert_eq!(
382            truncate_sentences(
383                "Ask Dr. Smith for the etc. items, i.e. all of them. Next.",
384                1
385            ),
386            "Ask Dr. Smith for the etc. items, i.e. all of them."
387        );
388        // A real sentence end right after an abbreviation is still found.
389        assert_eq!(
390            truncate_sentences("Contact Mr. Lee. Thanks.", 2),
391            "Contact Mr. Lee. Thanks."
392        );
393        assert_eq!(
394            truncate_sentences("Contact Mr. Lee. Thanks.", 1),
395            "Contact Mr. Lee."
396        );
397    }
398
399    /// dev/01: a schema whose bulk lives inside combinators (`anyOf`,
400    /// `oneOf`, `allOf`, `$defs`) is minified — same description/example
401    /// stripping as under `properties` — while every `type`/`required` is
402    /// preserved and the result stays a structurally valid schema.
403    #[test]
404    fn combinators_are_recursed_into_and_minified() {
405        let params = json!({
406            "type": "object",
407            "properties": {
408                "payload": {
409                    "anyOf": [
410                        {
411                            "type": "object",
412                            "description": "First shape of the payload, used for the legacy request format. It carries a lot of historical baggage. Keep reading for details.",
413                            "properties": {
414                                "a": {"type": "string", "description": "The a field. It represents something important. More context follows here."}
415                            },
416                            "required": ["a"]
417                        },
418                        {
419                            "type": "object",
420                            "description": "Second shape of the payload, used for the modern request format. It is much simpler than the legacy one. Keep reading for details.",
421                            "properties": {
422                                "b": {"type": "integer", "description": "The b field. It represents something else important. More context follows here."}
423                            },
424                            "required": ["b"]
425                        }
426                    ]
427                },
428                "mode": {
429                    "oneOf": [
430                        {"type": "string", "const": "fast", "description": "Fast mode trades accuracy for speed. Use when latency matters most. Read the docs for tradeoffs."},
431                        {"type": "string", "const": "slow", "description": "Slow mode trades speed for accuracy. Use when correctness matters most. Read the docs for tradeoffs."}
432                    ]
433                },
434                "combo": {
435                    "allOf": [
436                        {
437                            "type": "object",
438                            "description": "Base combo shape shared by every variant. It defines the common envelope fields. Read carefully before extending.",
439                            "properties": {
440                                "id": {"type": "string", "description": "The identifier. Must be globally unique. Formatted as a UUID."}
441                            },
442                            "required": ["id"]
443                        },
444                        {
445                            "type": "object",
446                            "description": "Extension combo shape layered on top of the base envelope. It adds variant-specific fields. Read carefully before extending.",
447                            "properties": {
448                                "extra": {"type": "string", "description": "Extra data. Optional free-form text. Formatted as plain UTF-8."}
449                            },
450                            "required": []
451                        }
452                    ]
453                }
454            },
455            "$defs": {
456                "Widget": {
457                    "type": "object",
458                    "description": "A reusable widget definition referenced elsewhere in this schema via $ref. It has a long explanatory blurb here for testing.",
459                    "properties": {
460                        "name": {"type": "string", "description": "The widget's name. Must be unique within its namespace. Free-form text otherwise."}
461                    },
462                    "required": ["name"]
463                }
464            },
465            "required": ["payload"]
466        });
467
468        let orig_bytes = serde_json::to_string(&params).unwrap().len();
469        let (_, p) = minify("desc. more. even more.", &params, SchemaTier::Minimal);
470        let new_bytes = serde_json::to_string(&p).unwrap().len();
471
472        // Meaningful size cut: the bulk of this fixture's bytes live inside
473        // combinators, so minification only "counts" if it reaches them.
474        assert!(
475            new_bytes < orig_bytes * 7 / 10,
476            "expected a meaningful size cut, got {orig_bytes} -> {new_bytes} bytes"
477        );
478
479        // anyOf branches: minified, required-aware, still valid.
480        let any_of = &p["properties"]["payload"]["anyOf"];
481        assert_eq!(
482            any_of[0]["description"],
483            "First shape of the payload, used for the legacy request format."
484        );
485        assert_eq!(any_of[0]["properties"]["a"]["description"], "The a field.");
486        assert_eq!(any_of[0]["properties"]["a"]["type"], "string");
487        assert_eq!(any_of[0]["required"], json!(["a"]));
488        assert_eq!(any_of[1]["properties"]["b"]["type"], "integer");
489        assert_eq!(any_of[1]["required"], json!(["b"]));
490
491        // oneOf branches: minified.
492        let one_of = &p["properties"]["mode"]["oneOf"];
493        assert_eq!(
494            one_of[0]["description"],
495            "Fast mode trades accuracy for speed."
496        );
497        assert_eq!(one_of[0]["type"], "string");
498        assert_eq!(one_of[0]["const"], "fast");
499
500        // allOf branches: minified, each with its own required-aware
501        // per-branch property treatment.
502        let all_of = &p["properties"]["combo"]["allOf"];
503        assert_eq!(
504            all_of[0]["description"],
505            "Base combo shape shared by every variant."
506        );
507        assert_eq!(
508            all_of[0]["properties"]["id"]["description"],
509            "The identifier."
510        );
511        assert!(all_of[1]["properties"]["extra"]
512            .get("description")
513            .is_none());
514        assert_eq!(all_of[1]["required"], json!([]));
515
516        // $defs: minified, required-aware.
517        let widget = &p["$defs"]["Widget"];
518        assert_eq!(
519            widget["description"],
520            "A reusable widget definition referenced elsewhere in this schema via $ref."
521        );
522        assert_eq!(
523            widget["properties"]["name"]["description"],
524            "The widget's name."
525        );
526        assert_eq!(widget["properties"]["name"]["type"], "string");
527        assert_eq!(widget["required"], json!(["name"]));
528
529        // Top-level required/type shape is completely untouched.
530        assert_eq!(p["required"], json!(["payload"]));
531        assert_eq!(p["type"], "object");
532    }
533
534    /// `if`/`then`/`else`, `definitions`, and `patternProperties` are all
535    /// recursed into the same way as `anyOf`/`oneOf`/`allOf`/`$defs`.
536    #[test]
537    fn if_then_else_definitions_and_pattern_properties_are_recursed_into() {
538        let params = json!({
539            "type": "object",
540            "if": {"type": "object", "description": "Condition branch description. More detail here.", "properties": {"x": {"type": "string"}}},
541            "then": {"type": "object", "description": "Then branch description. More detail here.", "properties": {"y": {"type": "string", "description": "Y field. More detail here."}}, "required": ["y"]},
542            "else": {"type": "object", "description": "Else branch description. More detail here.", "properties": {"z": {"type": "string", "description": "Z field. More detail here."}}, "required": []},
543            "definitions": {
544                "Old": {"type": "object", "description": "Legacy definition kept for draft-7 compatibility. More detail here.", "properties": {"n": {"type": "string"}}}
545            },
546            "patternProperties": {
547                "^S_": {"type": "string", "description": "Pattern-matched string property. More detail here."}
548            },
549            "properties": {},
550            "required": []
551        });
552        let (_, p) = minify("desc. more.", &params, SchemaTier::Minimal);
553        assert_eq!(p["if"]["description"], "Condition branch description.");
554        assert_eq!(p["then"]["description"], "Then branch description.");
555        assert_eq!(p["then"]["properties"]["y"]["description"], "Y field.");
556        assert_eq!(p["else"]["description"], "Else branch description.");
557        assert!(p["else"]["properties"]["z"].get("description").is_none());
558        assert_eq!(
559            p["definitions"]["Old"]["description"],
560            "Legacy definition kept for draft-7 compatibility."
561        );
562        assert_eq!(
563            p["patternProperties"]["^S_"]["description"],
564            "Pattern-matched string property."
565        );
566    }
567
568    /// Draft-4 tuple-style `items` (an array of per-position schemas, as
569    /// opposed to a single schema applied to every element) is recursed
570    /// into position by position.
571    #[test]
572    fn tuple_style_items_array_is_recursed_into() {
573        let params = json!({
574            "type": "array",
575            "items": [
576                {"type": "string", "description": "First tuple slot description. More detail here.", "examples": ["e"]},
577                {"type": "integer", "description": "Second tuple slot description. More detail here.", "title": "t"}
578            ]
579        });
580        let (_, p) = minify("desc. more.", &params, SchemaTier::Minimal);
581        assert_eq!(
582            p["items"][0]["description"],
583            "First tuple slot description."
584        );
585        assert!(p["items"][0].get("examples").is_none());
586        assert_eq!(p["items"][0]["type"], "string");
587        assert_eq!(
588            p["items"][1]["description"],
589            "Second tuple slot description."
590        );
591        assert!(p["items"][1].get("title").is_none());
592        assert_eq!(p["items"][1]["type"], "integer");
593    }
594}