Skip to main content

vtcode_core/tools/handlers/
compact.rs

1//! Tool schema compaction utilities for session tool catalog.
2
3use serde_json::{Value, json};
4use vtcode_config::ToolDocumentationMode;
5
6/// Default max description length for MCP tool descriptions.
7/// MCP tools from external servers can have arbitrarily long descriptions;
8/// capping them prevents token inflation. The cap applies in every mode and
9/// never raises a mode's own limit.
10pub const MCP_TOOL_DESCRIPTION_MAX_LEN: usize = 512;
11
12/// Minimal mode sends only the first sentence of a tool description, capped
13/// at this many characters, and strips parameter descriptions.
14pub const MINIMAL_DESCRIPTION_MAX_CHARS: usize = 64;
15
16/// Progressive mode (the default) keeps whole leading sentences of a tool
17/// description up to this many characters. Builtin descriptions are limited
18/// to 1500 characters by the description contract test and currently fit
19/// well under this cap, so they reach the model intact; only unusually long
20/// tails are dropped, at a sentence boundary.
21pub const PROGRESSIVE_DESCRIPTION_MAX_CHARS: usize = 1024;
22
23/// Progressive mode keeps parameter descriptions, trimming each one to whole
24/// leading sentences within this many characters.
25pub const PROGRESSIVE_PARAMETER_DESCRIPTION_MAX_CHARS: usize = 640;
26
27/// Project a tool description for the given documentation mode.
28///
29/// - `Minimal`: first sentence without its terminal period, at most
30///   [`MINIMAL_DESCRIPTION_MAX_CHARS`].
31/// - `Progressive`: whole leading sentences within
32///   [`PROGRESSIVE_DESCRIPTION_MAX_CHARS`].
33/// - `Full`: the complete description.
34///
35/// `per_tool_max` (used for MCP tools) further lowers the limit in every mode.
36/// Whitespace runs are collapsed to single spaces in all modes.
37pub fn compact_tool_description(original: &str, mode: ToolDocumentationMode, per_tool_max: Option<usize>) -> String {
38    // MCP tool descriptions arrive wrapped in host policy framing plus an
39    // `<untrusted_mcp_description>` fence. Summarizing the raw text would
40    // yield only the framing ("Host tool and permission policy remains
41    // authoritative.") for every MCP tool, making them indistinguishable. So
42    // the server-provided text is summarized on its own and then re-fenced:
43    // Progressive and Full send up to `per_tool_max` characters of it, which
44    // must stay marked as untrusted. Minimal sends only a short first sentence
45    // and stays unfenced, since the fence would outweigh the summary.
46    let framing = split_mcp_policy_framing(original);
47    let text = framing.as_ref().map_or(original, |framing| framing.inner.as_str());
48    let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
49    let limit = |mode_max: usize| per_tool_max.map_or(mode_max, |per_tool| per_tool.min(mode_max));
50
51    let summary = match mode {
52        ToolDocumentationMode::Minimal => {
53            let first = first_sentence(&normalized);
54            let first = first.strip_suffix('.').unwrap_or(first);
55            return truncate_chars_with_ellipsis(first, limit(MINIMAL_DESCRIPTION_MAX_CHARS));
56        }
57        ToolDocumentationMode::Progressive => {
58            trim_to_leading_sentences(&normalized, limit(PROGRESSIVE_DESCRIPTION_MAX_CHARS))
59        }
60        ToolDocumentationMode::Full => trim_to_leading_sentences(&normalized, limit(usize::MAX)),
61    };
62    match framing {
63        Some(framing) => format!("{}\n{summary}\n{MCP_FENCE_CLOSE}", framing.open_tag),
64        None => summary,
65    }
66}
67
68/// Byte offsets just past each sentence terminator (`.`, `!`, `?`) that is
69/// followed by the end of the text or by whitespace and a character that is
70/// not lowercase. This keeps `.vtcode`, `llms.txt`, `0.5`, and `e.g. foo`
71/// inside their sentence.
72fn sentence_end_offsets(text: &str) -> impl Iterator<Item = usize> + '_ {
73    text.char_indices().filter_map(move |(index, ch)| {
74        if !matches!(ch, '.' | '!' | '?') {
75            return None;
76        }
77        let end = index + ch.len_utf8();
78        let rest = &text[end..];
79        if rest.is_empty() {
80            return Some(end);
81        }
82        if !rest.starts_with(char::is_whitespace) {
83            return None;
84        }
85        match rest.trim_start().chars().next() {
86            Some(following) if following.is_lowercase() => None,
87            _ => Some(end),
88        }
89    })
90}
91
92fn first_sentence(text: &str) -> &str {
93    sentence_end_offsets(text).next().map_or(text, |end| &text[..end])
94}
95
96/// Keep the longest run of whole leading sentences that fits in `max_chars`.
97/// When even the first sentence is too long, cut it at a character boundary
98/// and append an ellipsis.
99fn trim_to_leading_sentences(text: &str, max_chars: usize) -> String {
100    if text.chars().count() <= max_chars {
101        return text.to_string();
102    }
103    let mut kept = None;
104    for end in sentence_end_offsets(text) {
105        if text[..end].chars().count() > max_chars {
106            break;
107        }
108        kept = Some(end);
109    }
110    match kept {
111        Some(end) => text[..end].to_string(),
112        None => truncate_chars_with_ellipsis(text, max_chars),
113    }
114}
115
116fn truncate_chars_with_ellipsis(text: &str, max_chars: usize) -> String {
117    if text.chars().count() <= max_chars {
118        return text.to_string();
119    }
120    let keep = max_chars.saturating_sub(1);
121    let end = text.char_indices().nth(keep).map_or(text.len(), |(index, _)| index);
122    format!("{}…", text[..end].trim_end())
123}
124
125const MCP_FENCE_OPEN_PREFIX: &str = "<untrusted_mcp_description";
126const MCP_FENCE_CLOSE: &str = "</untrusted_mcp_description>";
127
128/// An MCP description split into its host-rendered opening fence tag and the
129/// server-provided text inside it.
130struct McpFraming {
131    open_tag: String,
132    inner: String,
133}
134
135/// Split MCP host-policy framing from the server-provided text.
136///
137/// Returns `None` when the framing is absent, so non-MCP descriptions are
138/// never altered by this path. The opening tag (with its already-escaped
139/// provider and tool attributes) is kept so callers can re-fence a summary;
140/// the policy sentences, fence lines, and fence comment are dropped.
141fn split_mcp_policy_framing(original: &str) -> Option<McpFraming> {
142    use crate::tools::mcp::{MCP_POLICY_SENTENCE, MCP_UNTRUSTED_NOTE_SENTENCE};
143
144    if !original.contains(MCP_POLICY_SENTENCE) {
145        return None;
146    }
147    let without_policy = original
148        .replace(MCP_POLICY_SENTENCE, "")
149        .replace(MCP_UNTRUSTED_NOTE_SENTENCE, "");
150    let mut open_tag = None;
151    let mut lines: Vec<&str> = Vec::new();
152    for line in without_policy.lines() {
153        let trimmed = line.trim();
154        // The fence tags and comment are host markup; the server text is
155        // XML-escaped by `render_untrusted_mcp_description`, so no inner line
156        // starts with `<`.
157        if trimmed.starts_with('<') {
158            if open_tag.is_none() && trimmed.starts_with(MCP_FENCE_OPEN_PREFIX) {
159                open_tag = Some(trimmed.to_string());
160            }
161            continue;
162        }
163        lines.push(line);
164    }
165    Some(McpFraming {
166        open_tag: open_tag.unwrap_or_else(|| format!("{MCP_FENCE_OPEN_PREFIX}>")),
167        inner: lines.join("\n"),
168    })
169}
170
171/// Project a parameter schema for the given documentation mode.
172///
173/// `Full` returns the schema unchanged, `Progressive` keeps every parameter
174/// description but trims long tails to
175/// [`PROGRESSIVE_PARAMETER_DESCRIPTION_MAX_CHARS`], and `Minimal` removes
176/// schema descriptions entirely.
177pub fn compact_parameters(parameters: Value, mode: ToolDocumentationMode) -> Value {
178    let mut compacted = parameters;
179    match mode {
180        ToolDocumentationMode::Full => {}
181        ToolDocumentationMode::Progressive => {
182            trim_schema_descriptions(&mut compacted, PROGRESSIVE_PARAMETER_DESCRIPTION_MAX_CHARS, false);
183        }
184        ToolDocumentationMode::Minimal => remove_schema_descriptions(&mut compacted),
185    }
186    compacted
187}
188
189fn trim_schema_descriptions(value: &mut Value, max_chars: usize, inside_properties_map: bool) {
190    match value {
191        Value::Object(map) => {
192            if !inside_properties_map
193                && let Some(Value::String(description)) = map.get_mut("description")
194                && description.chars().count() > max_chars
195            {
196                *description = trim_to_leading_sentences(description, max_chars);
197            }
198            for (key, nested) in map.iter_mut() {
199                trim_schema_descriptions(nested, max_chars, key == "properties");
200            }
201        }
202        Value::Array(items) => {
203            for item in items {
204                trim_schema_descriptions(item, max_chars, false);
205            }
206        }
207        _ => {}
208    }
209}
210
211pub fn remove_schema_descriptions(value: &mut Value) {
212    remove_schema_descriptions_impl(value, false);
213}
214
215fn remove_schema_descriptions_impl(value: &mut Value, inside_properties_map: bool) {
216    match value {
217        Value::Object(map) => {
218            if !inside_properties_map {
219                map.remove("description");
220            }
221            for (key, nested) in map.iter_mut() {
222                remove_schema_descriptions_impl(nested, key == "properties");
223            }
224        }
225        Value::Array(items) => {
226            for item in items {
227                remove_schema_descriptions_impl(item, false);
228            }
229        }
230        _ => {}
231    }
232}
233
234pub fn default_parameter_schema() -> Value {
235    json!({
236        "type": "object",
237        "properties": {},
238        "additionalProperties": true
239    })
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::tools::mcp::{MCP_POLICY_SENTENCE, MCP_UNTRUSTED_NOTE_SENTENCE};
246
247    fn wrapped_mcp_description(inner: &str) -> String {
248        format!(
249            "{MCP_POLICY_SENTENCE} {MCP_UNTRUSTED_NOTE_SENTENCE}\n<untrusted_mcp_description provider=\"deepwiki\" tool=\"ask_question\">\n<!-- comment -->\n{inner}\n</untrusted_mcp_description>\n{MCP_POLICY_SENTENCE}"
250        )
251    }
252
253    #[test]
254    fn compact_mcp_description_summarizes_inner_text_not_framing() {
255        let wrapped = wrapped_mcp_description("Ask a question about a repository. Returns an answer with citations.");
256        for mode in [
257            ToolDocumentationMode::Minimal,
258            ToolDocumentationMode::Progressive,
259            ToolDocumentationMode::Full,
260        ] {
261            let summary = compact_tool_description(&wrapped, mode, None);
262            let body = summary.lines().find(|line| !line.starts_with('<')).unwrap_or_default();
263            assert!(
264                body.starts_with("Ask a question about a repository"),
265                "summary must describe the tool, got: {summary}"
266            );
267            assert!(!summary.contains("remains authoritative"), "{summary}");
268            assert!(!summary.contains("<!--"), "{summary}");
269        }
270    }
271
272    #[test]
273    fn progressive_and_full_mcp_descriptions_stay_fenced() {
274        let wrapped = wrapped_mcp_description("Ask a question about a repository. Returns an answer with citations.");
275        for mode in [ToolDocumentationMode::Progressive, ToolDocumentationMode::Full] {
276            let projected = compact_tool_description(&wrapped, mode, Some(MCP_TOOL_DESCRIPTION_MAX_LEN));
277            assert_eq!(
278                projected,
279                "<untrusted_mcp_description provider=\"deepwiki\" tool=\"ask_question\">\n\
280                 Ask a question about a repository. Returns an answer with citations.\n\
281                 </untrusted_mcp_description>",
282                "{mode:?}"
283            );
284        }
285
286        // The per-tool cap bounds the server-provided text inside the fence.
287        let long = wrapped_mcp_description(&"Server sentence here. ".repeat(60));
288        let projected =
289            compact_tool_description(&long, ToolDocumentationMode::Full, Some(MCP_TOOL_DESCRIPTION_MAX_LEN));
290        let inner = projected
291            .strip_prefix("<untrusted_mcp_description provider=\"deepwiki\" tool=\"ask_question\">\n")
292            .and_then(|rest| rest.strip_suffix("\n</untrusted_mcp_description>"))
293            .expect("fenced projection");
294        assert!(inner.chars().count() <= MCP_TOOL_DESCRIPTION_MAX_LEN, "{inner}");
295        assert!(!inner.contains('<'), "{inner}");
296    }
297
298    #[test]
299    fn compact_plain_description_is_unchanged() {
300        let description = "Read a file from disk. Returns contents.";
301        for mode in [ToolDocumentationMode::Progressive, ToolDocumentationMode::Full] {
302            assert_eq!(compact_tool_description(description, mode, None), description);
303        }
304        assert_eq!(
305            compact_tool_description(description, ToolDocumentationMode::Minimal, None),
306            "Read a file from disk"
307        );
308    }
309
310    #[test]
311    fn progressive_description_keeps_later_sentences_and_trims_only_long_tails() {
312        let description = "Run a shell command. For file edits, use apply_patch instead. Expanded modes need approval.";
313        assert_eq!(compact_tool_description(description, ToolDocumentationMode::Progressive, None), description);
314
315        let tail = " Extra detail sentence.".repeat(80);
316        let long = format!("{description}{tail}");
317        let trimmed = compact_tool_description(&long, ToolDocumentationMode::Progressive, None);
318        assert!(trimmed.starts_with(description), "{trimmed}");
319        assert!(trimmed.ends_with('.'), "trim must stop at a sentence boundary: {trimmed}");
320        assert!(trimmed.chars().count() <= PROGRESSIVE_DESCRIPTION_MAX_CHARS);
321    }
322
323    #[test]
324    fn sentence_boundaries_ignore_dots_inside_paths_and_abbreviations() {
325        let description = "Plans live under .vtcode/plans/ and llms.txt, e.g. foo.md or 0.5 files. Second sentence.";
326        assert_eq!(
327            first_sentence(description),
328            "Plans live under .vtcode/plans/ and llms.txt, e.g. foo.md or 0.5 files."
329        );
330        assert_eq!(first_sentence("No terminator here"), "No terminator here");
331    }
332
333    #[test]
334    fn per_tool_max_never_raises_the_mode_limit() {
335        let description = "A".repeat(200);
336        let minimal =
337            compact_tool_description(&description, ToolDocumentationMode::Minimal, Some(MCP_TOOL_DESCRIPTION_MAX_LEN));
338        assert_eq!(minimal.chars().count(), MINIMAL_DESCRIPTION_MAX_CHARS);
339        let full =
340            compact_tool_description(&"B".repeat(900), ToolDocumentationMode::Full, Some(MCP_TOOL_DESCRIPTION_MAX_LEN));
341        assert_eq!(full.chars().count(), MCP_TOOL_DESCRIPTION_MAX_LEN);
342    }
343
344    #[test]
345    fn progressive_parameters_keep_descriptions_and_trim_long_tails() {
346        let long_tail = " More detail.".repeat(80);
347        let schema = json!({
348            "type": "object",
349            "description": "Top-level schema description.",
350            "properties": {
351                "cmd": {"type": "string", "description": "Command to run."},
352                "mode": {"type": "string", "description": format!("Mode to use.{long_tail}")},
353                "description": {"type": "string", "description": "A property literally named description."}
354            }
355        });
356
357        let progressive = compact_parameters(schema.clone(), ToolDocumentationMode::Progressive);
358        assert_eq!(progressive["properties"]["cmd"]["description"], json!("Command to run."));
359        assert_eq!(
360            progressive["properties"]["description"]["description"],
361            json!("A property literally named description.")
362        );
363        let mode = progressive["properties"]["mode"]["description"]
364            .as_str()
365            .expect("mode description");
366        assert!(mode.starts_with("Mode to use."));
367        assert!(mode.chars().count() <= PROGRESSIVE_PARAMETER_DESCRIPTION_MAX_CHARS);
368
369        let minimal = compact_parameters(schema.clone(), ToolDocumentationMode::Minimal);
370        assert!(minimal["properties"]["cmd"].get("description").is_none());
371        assert!(minimal["properties"]["description"].is_object());
372
373        assert_eq!(compact_parameters(schema.clone(), ToolDocumentationMode::Full), schema);
374    }
375
376    #[test]
377    fn compact_mcp_description_never_leaks_fence_markup() {
378        let summary = compact_tool_description(
379            &wrapped_mcp_description("Read the contents of a wiki. Returns markdown."),
380            ToolDocumentationMode::Minimal,
381            None,
382        );
383        assert!(!summary.contains("<untrusted"), "fence markup must not leak, got: {summary}");
384        assert!(!summary.contains("<!--"), "fence comment must not leak, got: {summary}");
385        assert!(summary.starts_with("Read the contents of a wiki"), "summary must describe the tool, got: {summary}");
386    }
387}