vtcode-core 0.177.1

Core library for VT Code - a Rust-based terminal coding agent
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Tool schema compaction utilities for session tool catalog.

use serde_json::{Value, json};
use vtcode_config::ToolDocumentationMode;

/// Default max description length for MCP tool descriptions.
/// MCP tools from external servers can have arbitrarily long descriptions;
/// capping them prevents token inflation. The cap applies in every mode and
/// never raises a mode's own limit.
pub const MCP_TOOL_DESCRIPTION_MAX_LEN: usize = 512;

/// Minimal mode sends only the first sentence of a tool description, capped
/// at this many characters, and strips parameter descriptions.
pub const MINIMAL_DESCRIPTION_MAX_CHARS: usize = 64;

/// Progressive mode (the default) keeps whole leading sentences of a tool
/// description up to this many characters. Builtin descriptions are limited
/// to 1500 characters by the description contract test, matching this cap,
/// so they reach the model intact; only unusually long
/// tails are dropped, at a sentence boundary.
pub const PROGRESSIVE_DESCRIPTION_MAX_CHARS: usize = 1500;

/// Progressive mode keeps parameter descriptions, trimming each one to whole
/// leading sentences within this many characters.
pub const PROGRESSIVE_PARAMETER_DESCRIPTION_MAX_CHARS: usize = 640;

/// Project a tool description for the given documentation mode.
///
/// - `Minimal`: first sentence without its terminal period, at most
///   [`MINIMAL_DESCRIPTION_MAX_CHARS`].
/// - `Progressive`: whole leading sentences within
///   [`PROGRESSIVE_DESCRIPTION_MAX_CHARS`].
/// - `Full`: the complete description.
///
/// `per_tool_max` (used for MCP tools) further lowers the limit in every mode.
/// Whitespace runs are collapsed to single spaces in all modes.
pub fn compact_tool_description(original: &str, mode: ToolDocumentationMode, per_tool_max: Option<usize>) -> String {
    // MCP tool descriptions arrive wrapped in host policy framing plus an
    // `<untrusted_mcp_description>` fence. Summarizing the raw text would
    // yield only the framing ("Host tool and permission policy remains
    // authoritative.") for every MCP tool, making them indistinguishable. So
    // the server-provided text is summarized on its own and then re-fenced:
    // Progressive and Full send up to `per_tool_max` characters of it, which
    // must stay marked as untrusted. Minimal sends only a short first sentence
    // and stays unfenced, since the fence would outweigh the summary.
    let framing = split_mcp_policy_framing(original);
    let text = framing.as_ref().map_or(original, |framing| framing.inner.as_str());
    let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
    let limit = |mode_max: usize| per_tool_max.map_or(mode_max, |per_tool| per_tool.min(mode_max));

    let summary = match mode {
        ToolDocumentationMode::Minimal => {
            let first = first_sentence(&normalized);
            let first = first.strip_suffix('.').unwrap_or(first);
            return truncate_chars_with_ellipsis(first, limit(MINIMAL_DESCRIPTION_MAX_CHARS));
        }
        ToolDocumentationMode::Progressive => {
            trim_to_leading_sentences(&normalized, limit(PROGRESSIVE_DESCRIPTION_MAX_CHARS))
        }
        ToolDocumentationMode::Full => trim_to_leading_sentences(&normalized, limit(usize::MAX)),
    };
    match framing {
        Some(framing) => format!("{}\n{summary}\n{MCP_FENCE_CLOSE}", framing.open_tag),
        None => summary,
    }
}

/// Byte offsets just past each sentence terminator (`.`, `!`, `?`) that is
/// followed by the end of the text or by whitespace and a character that is
/// not lowercase. This keeps `.vtcode`, `llms.txt`, `0.5`, and `e.g. foo`
/// inside their sentence.
fn sentence_end_offsets(text: &str) -> impl Iterator<Item = usize> + '_ {
    text.char_indices().filter_map(move |(index, ch)| {
        if !matches!(ch, '.' | '!' | '?') {
            return None;
        }
        let end = index + ch.len_utf8();
        let rest = &text[end..];
        if rest.is_empty() {
            return Some(end);
        }
        if !rest.starts_with(char::is_whitespace) {
            return None;
        }
        match rest.trim_start().chars().next() {
            Some(following) if following.is_lowercase() => None,
            _ => Some(end),
        }
    })
}

fn first_sentence(text: &str) -> &str {
    sentence_end_offsets(text).next().map_or(text, |end| &text[..end])
}

/// Keep the longest run of whole leading sentences that fits in `max_chars`.
/// When even the first sentence is too long, cut it at a character boundary
/// and append an ellipsis.
fn trim_to_leading_sentences(text: &str, max_chars: usize) -> String {
    if text.chars().count() <= max_chars {
        return text.to_string();
    }
    let mut kept = None;
    for end in sentence_end_offsets(text) {
        if text[..end].chars().count() > max_chars {
            break;
        }
        kept = Some(end);
    }
    match kept {
        Some(end) => text[..end].to_string(),
        None => truncate_chars_with_ellipsis(text, max_chars),
    }
}

fn truncate_chars_with_ellipsis(text: &str, max_chars: usize) -> String {
    if text.chars().count() <= max_chars {
        return text.to_string();
    }
    let keep = max_chars.saturating_sub(1);
    let end = text.char_indices().nth(keep).map_or(text.len(), |(index, _)| index);
    format!("{}…", text[..end].trim_end())
}

const MCP_FENCE_OPEN_PREFIX: &str = "<untrusted_mcp_description";
const MCP_FENCE_CLOSE: &str = "</untrusted_mcp_description>";

/// An MCP description split into its host-rendered opening fence tag and the
/// server-provided text inside it.
struct McpFraming {
    open_tag: String,
    inner: String,
}

/// Split MCP host-policy framing from the server-provided text.
///
/// Returns `None` when the framing is absent, so non-MCP descriptions are
/// never altered by this path. The opening tag (with its already-escaped
/// provider and tool attributes) is kept so callers can re-fence a summary;
/// the policy sentences, fence lines, and fence comment are dropped.
fn split_mcp_policy_framing(original: &str) -> Option<McpFraming> {
    use crate::tools::mcp::{MCP_POLICY_SENTENCE, MCP_UNTRUSTED_NOTE_SENTENCE};

    if !original.contains(MCP_POLICY_SENTENCE) {
        return None;
    }
    let without_policy = original
        .replace(MCP_POLICY_SENTENCE, "")
        .replace(MCP_UNTRUSTED_NOTE_SENTENCE, "");
    let mut open_tag = None;
    let mut lines: Vec<&str> = Vec::new();
    for line in without_policy.lines() {
        let trimmed = line.trim();
        // The fence tags and comment are host markup; the server text is
        // XML-escaped by `render_untrusted_mcp_description`, so no inner line
        // starts with `<`.
        if trimmed.starts_with('<') {
            if open_tag.is_none() && trimmed.starts_with(MCP_FENCE_OPEN_PREFIX) {
                open_tag = Some(trimmed.to_string());
            }
            continue;
        }
        lines.push(line);
    }
    Some(McpFraming {
        open_tag: open_tag.unwrap_or_else(|| format!("{MCP_FENCE_OPEN_PREFIX}>")),
        inner: lines.join("\n"),
    })
}

/// Project a parameter schema for the given documentation mode.
///
/// `Full` returns the schema unchanged, `Progressive` keeps every parameter
/// description but trims long tails to
/// [`PROGRESSIVE_PARAMETER_DESCRIPTION_MAX_CHARS`], and `Minimal` removes
/// schema descriptions entirely.
pub fn compact_parameters(parameters: Value, mode: ToolDocumentationMode) -> Value {
    let mut compacted = parameters;
    match mode {
        ToolDocumentationMode::Full => {}
        ToolDocumentationMode::Progressive => {
            trim_schema_descriptions(&mut compacted, PROGRESSIVE_PARAMETER_DESCRIPTION_MAX_CHARS, false);
        }
        ToolDocumentationMode::Minimal => remove_schema_descriptions(&mut compacted),
    }
    compacted
}

fn trim_schema_descriptions(value: &mut Value, max_chars: usize, inside_properties_map: bool) {
    match value {
        Value::Object(map) => {
            if !inside_properties_map
                && let Some(Value::String(description)) = map.get_mut("description")
                && description.chars().count() > max_chars
            {
                *description = trim_to_leading_sentences(description, max_chars);
            }
            for (key, nested) in map.iter_mut() {
                trim_schema_descriptions(nested, max_chars, key == "properties");
            }
        }
        Value::Array(items) => {
            for item in items {
                trim_schema_descriptions(item, max_chars, false);
            }
        }
        _ => {}
    }
}

pub fn remove_schema_descriptions(value: &mut Value) {
    remove_schema_descriptions_impl(value, false);
}

fn remove_schema_descriptions_impl(value: &mut Value, inside_properties_map: bool) {
    match value {
        Value::Object(map) => {
            if !inside_properties_map {
                map.remove("description");
            }
            for (key, nested) in map.iter_mut() {
                remove_schema_descriptions_impl(nested, key == "properties");
            }
        }
        Value::Array(items) => {
            for item in items {
                remove_schema_descriptions_impl(item, false);
            }
        }
        _ => {}
    }
}

pub fn default_parameter_schema() -> Value {
    json!({
        "type": "object",
        "properties": {},
        "additionalProperties": true
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::mcp::{MCP_POLICY_SENTENCE, MCP_UNTRUSTED_NOTE_SENTENCE};

    fn wrapped_mcp_description(inner: &str) -> String {
        format!(
            "{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}"
        )
    }

    #[test]
    fn compact_mcp_description_summarizes_inner_text_not_framing() {
        let wrapped = wrapped_mcp_description("Ask a question about a repository. Returns an answer with citations.");
        for mode in [
            ToolDocumentationMode::Minimal,
            ToolDocumentationMode::Progressive,
            ToolDocumentationMode::Full,
        ] {
            let summary = compact_tool_description(&wrapped, mode, None);
            let body = summary.lines().find(|line| !line.starts_with('<')).unwrap_or_default();
            assert!(
                body.starts_with("Ask a question about a repository"),
                "summary must describe the tool, got: {summary}"
            );
            assert!(!summary.contains("remains authoritative"), "{summary}");
            assert!(!summary.contains("<!--"), "{summary}");
        }
    }

    #[test]
    fn progressive_and_full_mcp_descriptions_stay_fenced() {
        let wrapped = wrapped_mcp_description("Ask a question about a repository. Returns an answer with citations.");
        for mode in [ToolDocumentationMode::Progressive, ToolDocumentationMode::Full] {
            let projected = compact_tool_description(&wrapped, mode, Some(MCP_TOOL_DESCRIPTION_MAX_LEN));
            assert_eq!(
                projected,
                "<untrusted_mcp_description provider=\"deepwiki\" tool=\"ask_question\">\n\
                 Ask a question about a repository. Returns an answer with citations.\n\
                 </untrusted_mcp_description>",
                "{mode:?}"
            );
        }

        // The per-tool cap bounds the server-provided text inside the fence.
        let long = wrapped_mcp_description(&"Server sentence here. ".repeat(60));
        let projected =
            compact_tool_description(&long, ToolDocumentationMode::Full, Some(MCP_TOOL_DESCRIPTION_MAX_LEN));
        let inner = projected
            .strip_prefix("<untrusted_mcp_description provider=\"deepwiki\" tool=\"ask_question\">\n")
            .and_then(|rest| rest.strip_suffix("\n</untrusted_mcp_description>"))
            .expect("fenced projection");
        assert!(inner.chars().count() <= MCP_TOOL_DESCRIPTION_MAX_LEN, "{inner}");
        assert!(!inner.contains('<'), "{inner}");
    }

    #[test]
    fn compact_plain_description_is_unchanged() {
        let description = "Read a file from disk. Returns contents.";
        for mode in [ToolDocumentationMode::Progressive, ToolDocumentationMode::Full] {
            assert_eq!(compact_tool_description(description, mode, None), description);
        }
        assert_eq!(
            compact_tool_description(description, ToolDocumentationMode::Minimal, None),
            "Read a file from disk"
        );
    }

    #[test]
    fn progressive_description_keeps_later_sentences_and_trims_only_long_tails() {
        let description = "Run a shell command. For file edits, use apply_patch instead. Expanded modes need approval.";
        assert_eq!(compact_tool_description(description, ToolDocumentationMode::Progressive, None), description);

        let tail = " Extra detail sentence.".repeat(80);
        let long = format!("{description}{tail}");
        let trimmed = compact_tool_description(&long, ToolDocumentationMode::Progressive, None);
        assert!(trimmed.starts_with(description), "{trimmed}");
        assert!(trimmed.ends_with('.'), "trim must stop at a sentence boundary: {trimmed}");
        assert!(trimmed.chars().count() <= PROGRESSIVE_DESCRIPTION_MAX_CHARS);
    }

    #[test]
    fn progressive_description_preserves_builtin_contract_limit_and_mcp_cap() {
        let description = format!("{}.", "A".repeat(1499));
        assert_eq!(compact_tool_description(&description, ToolDocumentationMode::Progressive, None), description);

        let over_limit = format!("{description} Extra sentence.");
        assert_eq!(compact_tool_description(&over_limit, ToolDocumentationMode::Progressive, None), description);
        let mcp = compact_tool_description(
            &description,
            ToolDocumentationMode::Progressive,
            Some(MCP_TOOL_DESCRIPTION_MAX_LEN),
        );
        assert_eq!(mcp.chars().count(), MCP_TOOL_DESCRIPTION_MAX_LEN);
        assert!(mcp.ends_with('…'));
    }

    #[test]
    fn sentence_boundaries_ignore_dots_inside_paths_and_abbreviations() {
        let description = "Plans live under .vtcode/plans/ and llms.txt, e.g. foo.md or 0.5 files. Second sentence.";
        assert_eq!(
            first_sentence(description),
            "Plans live under .vtcode/plans/ and llms.txt, e.g. foo.md or 0.5 files."
        );
        assert_eq!(first_sentence("No terminator here"), "No terminator here");
    }

    #[test]
    fn per_tool_max_never_raises_the_mode_limit() {
        let description = "A".repeat(200);
        let minimal =
            compact_tool_description(&description, ToolDocumentationMode::Minimal, Some(MCP_TOOL_DESCRIPTION_MAX_LEN));
        assert_eq!(minimal.chars().count(), MINIMAL_DESCRIPTION_MAX_CHARS);
        let full =
            compact_tool_description(&"B".repeat(900), ToolDocumentationMode::Full, Some(MCP_TOOL_DESCRIPTION_MAX_LEN));
        assert_eq!(full.chars().count(), MCP_TOOL_DESCRIPTION_MAX_LEN);
    }

    #[test]
    fn progressive_parameters_keep_descriptions_and_trim_long_tails() {
        let long_tail = " More detail.".repeat(80);
        let schema = json!({
            "type": "object",
            "description": "Top-level schema description.",
            "properties": {
                "cmd": {"type": "string", "description": "Command to run."},
                "mode": {"type": "string", "description": format!("Mode to use.{long_tail}")},
                "description": {"type": "string", "description": "A property literally named description."}
            }
        });

        let progressive = compact_parameters(schema.clone(), ToolDocumentationMode::Progressive);
        assert_eq!(progressive["properties"]["cmd"]["description"], json!("Command to run."));
        assert_eq!(
            progressive["properties"]["description"]["description"],
            json!("A property literally named description.")
        );
        let mode = progressive["properties"]["mode"]["description"]
            .as_str()
            .expect("mode description");
        assert!(mode.starts_with("Mode to use."));
        assert!(mode.chars().count() <= PROGRESSIVE_PARAMETER_DESCRIPTION_MAX_CHARS);

        let minimal = compact_parameters(schema.clone(), ToolDocumentationMode::Minimal);
        assert!(minimal["properties"]["cmd"].get("description").is_none());
        assert!(minimal["properties"]["description"].is_object());

        assert_eq!(compact_parameters(schema.clone(), ToolDocumentationMode::Full), schema);
    }

    #[test]
    fn compact_mcp_description_never_leaks_fence_markup() {
        let summary = compact_tool_description(
            &wrapped_mcp_description("Read the contents of a wiki. Returns markdown."),
            ToolDocumentationMode::Minimal,
            None,
        );
        assert!(!summary.contains("<untrusted"), "fence markup must not leak, got: {summary}");
        assert!(!summary.contains("<!--"), "fence comment must not leak, got: {summary}");
        assert!(summary.starts_with("Read the contents of a wiki"), "summary must describe the tool, got: {summary}");
    }
}