vtcode_core/tools/handlers/
compact.rs1use serde_json::{Value, json};
4use vtcode_config::ToolDocumentationMode;
5
6pub const MCP_TOOL_DESCRIPTION_MAX_LEN: usize = 512;
11
12pub const MINIMAL_DESCRIPTION_MAX_CHARS: usize = 64;
15
16pub const PROGRESSIVE_DESCRIPTION_MAX_CHARS: usize = 1024;
22
23pub const PROGRESSIVE_PARAMETER_DESCRIPTION_MAX_CHARS: usize = 640;
26
27pub fn compact_tool_description(original: &str, mode: ToolDocumentationMode, per_tool_max: Option<usize>) -> String {
38 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
68fn 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
96fn 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
128struct McpFraming {
131 open_tag: String,
132 inner: String,
133}
134
135fn 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 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
171pub 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 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}