Skip to main content

vtcode_core/prompts/
guidelines.rs

1use std::collections::BTreeSet;
2use std::fmt::Write as _;
3
4use crate::config::constants::tools;
5use crate::config::types::CapabilityLevel;
6use crate::core::agent::harness_kernel::SessionToolCatalogSnapshot;
7use crate::prompts::sections::SectionBoundaryMode;
8
9const TOOL_UNIFIED_EXEC: &str = tools::UNIFIED_EXEC;
10const TOOL_UNIFIED_FILE: &str = tools::UNIFIED_FILE;
11const TOOL_UNIFIED_SEARCH: &str = tools::UNIFIED_SEARCH;
12const TOOL_READ_FILE: &str = tools::READ_FILE;
13const TOOL_LIST_FILES: &str = tools::LIST_FILES;
14const TOOL_APPLY_PATCH: &str = tools::APPLY_PATCH;
15const TOOL_REQUEST_USER_INPUT: &str = tools::REQUEST_USER_INPUT;
16const TOOL_TASK_TRACKER: &str = tools::TASK_TRACKER;
17
18/// Generate compact cross-tool guidance based on the tools available in the session.
19pub fn generate_tool_guidelines(
20    available_tools: &[String],
21    capability_level: Option<CapabilityLevel>,
22) -> String {
23    let has_exec = available_tools.iter().any(|tool| tool == TOOL_UNIFIED_EXEC);
24    let has_file = available_tools.iter().any(|tool| tool == TOOL_UNIFIED_FILE);
25    let has_search = available_tools
26        .iter()
27        .any(|tool| tool == TOOL_UNIFIED_SEARCH);
28    let has_read_file = available_tools.iter().any(|tool| tool == TOOL_READ_FILE);
29    let has_list_files = available_tools.iter().any(|tool| tool == TOOL_LIST_FILES);
30    let has_apply_patch = available_tools.iter().any(|tool| tool == TOOL_APPLY_PATCH);
31
32    let mut lines = Vec::new();
33    if let Some(mode_line) = capability_mode_line(capability_level, has_exec, has_file) {
34        lines.push(mode_line.to_string());
35    }
36    if let Some(browse_guidance) =
37        browse_tool_guidance(has_search, has_file, has_list_files, has_read_file)
38    {
39        lines.push(browse_guidance);
40    }
41    if has_file || has_apply_patch {
42        lines.push("- Read before edit and keep patches small.".to_string());
43    }
44    if has_exec {
45        lines.push(
46            "- Use `unified_exec` for verification, `git diff -- <path>`, and commands the public tools cannot express."
47                .to_string(),
48        );
49    }
50    if has_exec || has_file || has_apply_patch {
51        lines.push(
52            "- Completion is a checkpoint: keep `task_tracker` current and verification resolved."
53                .to_string(),
54        );
55    }
56    if has_search && has_exec {
57        lines.push("- Prefer search over shell for exploration.".to_string());
58    }
59    if has_file || has_apply_patch || has_exec {
60        lines.push(
61            "- If calls repeat without progress, re-plan instead of retrying identically."
62                .to_string(),
63        );
64    }
65    if has_search || has_file || has_exec {
66        lines.push(
67            "- When calling multiple tools with no dependencies, run them in parallel (e.g., read files or run independent commands at once)."
68                .to_string(),
69        );
70    }
71
72    if lines.is_empty() {
73        return String::new();
74    }
75
76    format!("\n\n## Active Tools\n{}", lines.join("\n"))
77}
78
79pub fn append_runtime_tool_prompt_sections(
80    prompt: &mut String,
81    tool_snapshot: &SessionToolCatalogSnapshot,
82    include_catalog_metadata: bool,
83) {
84    remove_prompt_section(prompt, "## Active Tools");
85    remove_prompt_section(prompt, "[Runtime Tool Catalog]");
86    while prompt.ends_with('\n') {
87        prompt.pop();
88    }
89
90    let available_tools = snapshot_tool_names(tool_snapshot);
91    let guidelines =
92        generate_runtime_tool_guidelines(&available_tools, tool_snapshot.planning_active);
93    if !guidelines.is_empty() {
94        append_prompt_block(prompt, guidelines.trim_start_matches('\n'));
95    }
96
97    if include_catalog_metadata && tool_snapshot.snapshot.is_some() {
98        let active_tools = if tool_snapshot.active_tool_names.is_empty() {
99            "none".to_string()
100        } else {
101            tool_snapshot.active_tool_names.join(", ")
102        };
103        let catalog_metadata = format!(
104            "[Runtime Tool Catalog]\n- version: {}\n- epoch: {}\n- catalog_tools: {}\n- available_tools: {}\n- currently_available_tools: {}\n- request_user_input_enabled: {}",
105            tool_snapshot.version,
106            tool_snapshot.epoch,
107            tool_snapshot.catalog_tools(),
108            tool_snapshot.available_tools(),
109            active_tools,
110            tool_snapshot.request_user_input_enabled,
111        );
112        append_prompt_block(prompt, &catalog_metadata);
113    }
114}
115
116fn append_prompt_block(prompt: &mut String, block: &str) {
117    if block.is_empty() {
118        return;
119    }
120
121    if prompt.is_empty() {
122        prompt.push_str(block);
123    } else {
124        let _ = write!(prompt, "\n\n{block}");
125    }
126}
127
128fn remove_prompt_section(prompt: &mut String, section_header: &str) {
129    while let Some((section_start, section_end)) =
130        find_prompt_section_bounds(prompt, section_header)
131    {
132        prompt.replace_range(section_start..section_end, "");
133    }
134}
135
136fn find_prompt_section_bounds(prompt: &str, section_header: &str) -> Option<(usize, usize)> {
137    crate::prompts::sections::find_prompt_section_bounds(
138        prompt,
139        section_header,
140        SectionBoundaryMode::BracketOrMarkdown,
141    )
142}
143
144fn generate_runtime_tool_guidelines(available_tools: &[String], planning_active: bool) -> String {
145    if !planning_active {
146        return generate_tool_guidelines(available_tools, None);
147    }
148
149    let has_exec = available_tools.iter().any(|tool| tool == TOOL_UNIFIED_EXEC);
150    let has_file = available_tools.iter().any(|tool| tool == TOOL_UNIFIED_FILE);
151    let has_search = available_tools
152        .iter()
153        .any(|tool| tool == TOOL_UNIFIED_SEARCH);
154    let has_read_file = available_tools.iter().any(|tool| tool == TOOL_READ_FILE);
155    let has_list_files = available_tools.iter().any(|tool| tool == TOOL_LIST_FILES);
156    let has_request_user_input = available_tools
157        .iter()
158        .any(|tool| tool == TOOL_REQUEST_USER_INPUT);
159    let has_task_tracker = available_tools
160        .iter()
161        .any(|tool| matches!(tool.as_str(), TOOL_TASK_TRACKER));
162
163    let mut lines =
164        vec!["- Planning workflow active: stay within the read-safe tool list.".to_string()];
165    if let Some(browse_guidance) =
166        browse_tool_guidance(has_search, has_file, has_list_files, has_read_file)
167    {
168        lines.push(browse_guidance);
169    }
170    if has_file {
171        lines.push(
172            "- In Planning workflow, use `unified_file` only for read-style access.".to_string(),
173        );
174    }
175    if has_exec {
176        lines.push(
177            "- In Planning workflow, use `unified_exec` only for read-only verification, poll, or inspect actions."
178                .to_string(),
179        );
180    }
181    if has_task_tracker {
182        lines.push("- Keep `task_tracker` updated as you refine the plan.".to_string());
183        lines.push(
184            "- Keep blockers and verification open in `task_tracker` until resolved.".to_string(),
185        );
186    }
187    if has_request_user_input {
188        lines.push(
189            "- Use `request_user_input` only for material blockers that remain after repository exploration."
190                .to_string(),
191        );
192    }
193    if has_search || has_file || has_exec {
194        lines.push(
195            "- If calls repeat without progress, tighten the plan instead of retrying identically."
196                .to_string(),
197        );
198    }
199
200    format!("\n\n## Active Tools\n{}", lines.join("\n"))
201}
202
203fn snapshot_tool_names(tool_snapshot: &SessionToolCatalogSnapshot) -> Vec<String> {
204    tool_snapshot
205        .active_tool_names
206        .iter()
207        .cloned()
208        .collect::<BTreeSet<_>>()
209        .into_iter()
210        .collect()
211}
212
213fn browse_tool_guidance(
214    has_search: bool,
215    has_file: bool,
216    has_list_files: bool,
217    has_read_file: bool,
218) -> Option<String> {
219    let mut tool_names = Vec::new();
220    if has_search {
221        tool_names.push("`unified_search`");
222    } else if has_list_files {
223        tool_names.push("`list_files`");
224    }
225    if has_file {
226        tool_names.push("`unified_file`");
227    } else if has_read_file {
228        tool_names.push("`read_file`");
229    }
230    if tool_names.is_empty() {
231        return None;
232    }
233
234    Some(format!(
235        "- Prefer {} over shell browsing.",
236        tool_names.join(" and ")
237    ))
238}
239
240fn capability_mode_line(
241    capability_level: Option<CapabilityLevel>,
242    has_exec: bool,
243    has_file: bool,
244) -> Option<&'static str> {
245    match capability_level {
246        Some(CapabilityLevel::Basic) => Some(
247            "- Capabilities: limited. Ask the user to enable more capabilities if file work is required.",
248        ),
249        Some(CapabilityLevel::FileReading | CapabilityLevel::FileListing) => Some(
250            "- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.",
251        ),
252        _ if !has_exec && !has_file => Some(
253            "- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.",
254        ),
255        _ => None,
256    }
257}
258
259/// Infer capability level from available tools.
260pub fn infer_capability_level(available_tools: &[String]) -> CapabilityLevel {
261    let has_search = available_tools.iter().any(|t| t == TOOL_UNIFIED_SEARCH);
262    let has_edit = available_tools.iter().any(|t| t == TOOL_UNIFIED_FILE);
263    let has_read = has_edit || available_tools.iter().any(|t| t == TOOL_READ_FILE);
264    let has_list = has_search || available_tools.iter().any(|t| t == TOOL_LIST_FILES);
265    let has_exec = available_tools.iter().any(|t| t == TOOL_UNIFIED_EXEC);
266
267    if has_search {
268        CapabilityLevel::CodeSearch
269    } else if has_edit {
270        CapabilityLevel::Editing
271    } else if has_exec {
272        CapabilityLevel::Bash
273    } else if has_list {
274        CapabilityLevel::FileListing
275    } else if has_read {
276        CapabilityLevel::FileReading
277    } else {
278        CapabilityLevel::Basic
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_read_only_capability_detection() {
288        let tools = vec!["unified_search".to_string()];
289        let guidelines = generate_tool_guidelines(&tools, None);
290        assert!(guidelines.contains("Capabilities: read-only"));
291        assert!(guidelines.contains("do not modify files"));
292    }
293
294    #[test]
295    fn test_tool_preference_guidance() {
296        let tools = vec!["unified_exec".to_string(), "unified_search".to_string()];
297        let guidelines = generate_tool_guidelines(&tools, None);
298        assert!(guidelines.contains("Prefer search over shell"));
299        assert!(guidelines.contains("git diff -- <path>"));
300        assert!(guidelines.contains("Completion is a checkpoint"));
301    }
302
303    #[test]
304    fn test_edit_workflow_guidance() {
305        let tools = vec!["unified_file".to_string()];
306        let guidelines = generate_tool_guidelines(&tools, None);
307        assert!(guidelines.contains("Read before edit"));
308        assert!(guidelines.contains("patches small"));
309        assert!(guidelines.contains("verification resolved"));
310    }
311
312    #[test]
313    fn test_harness_browse_tool_guidance() {
314        let tools = vec![TOOL_LIST_FILES.to_string(), TOOL_READ_FILE.to_string()];
315        let guidelines = generate_tool_guidelines(&tools, None);
316        assert!(guidelines.contains("Prefer `list_files` and `read_file`"));
317        assert!(!guidelines.contains("offset"));
318        assert!(!guidelines.contains("per_page"));
319    }
320
321    #[test]
322    fn test_canonical_browse_tool_guidance_prefers_public_tools() {
323        let tools = vec![
324            "unified_search".to_string(),
325            "unified_file".to_string(),
326            TOOL_LIST_FILES.to_string(),
327            "read_file".to_string(),
328        ];
329        let guidelines = generate_tool_guidelines(&tools, None);
330        assert!(guidelines.contains("Prefer `unified_search` and `unified_file`"));
331        assert!(!guidelines.contains("Prefer `list_files` and `read_file`"));
332    }
333
334    #[test]
335    fn test_capability_basic_guidance() {
336        let tools = vec![];
337        let guidelines = generate_tool_guidelines(&tools, Some(CapabilityLevel::Basic));
338        assert!(guidelines.contains("Capabilities: limited"));
339        assert!(guidelines.contains("enable more capabilities"));
340    }
341
342    #[test]
343    fn test_capability_file_reading_guidance() {
344        let tools = vec!["unified_file".to_string()];
345        let guidelines = generate_tool_guidelines(&tools, Some(CapabilityLevel::FileReading));
346        assert!(guidelines.contains("Capabilities: read-only"));
347        assert!(guidelines.contains("do not modify"));
348    }
349
350    #[test]
351    fn test_full_capabilities_no_special_guidance() {
352        let tools = vec![
353            "unified_file".to_string(),
354            "unified_exec".to_string(),
355            "unified_search".to_string(),
356        ];
357        let guidelines = generate_tool_guidelines(&tools, Some(CapabilityLevel::Editing));
358
359        assert!(!guidelines.contains("Capabilities: limited"));
360        assert!(!guidelines.contains("Capabilities: read-only"));
361    }
362
363    #[test]
364    fn test_empty_tools_shows_read_only_capabilities() {
365        let tools = vec![];
366        let guidelines = generate_tool_guidelines(&tools, None);
367        assert!(guidelines.contains("Capabilities: read-only"));
368    }
369
370    #[test]
371    fn test_planning_workflow_guidance_keeps_verification_open() {
372        let tools = vec![
373            TOOL_UNIFIED_EXEC.to_string(),
374            TOOL_TASK_TRACKER.to_string(),
375            TOOL_UNIFIED_SEARCH.to_string(),
376        ];
377        let guidelines = generate_runtime_tool_guidelines(&tools, true);
378        assert!(guidelines.contains("Keep `task_tracker` updated"));
379        assert!(guidelines.contains("blockers and verification open"));
380    }
381
382    #[test]
383    fn test_capability_inference_precedence() {
384        let tools = vec!["unified_file".to_string(), "unified_search".to_string()];
385        assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
386
387        let tools = vec!["unified_exec".to_string(), "unified_file".to_string()];
388        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
389    }
390
391    #[test]
392    fn test_capability_inference_variants() {
393        let tools = vec!["unified_file".to_string()];
394        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
395
396        let tools = vec!["unified_exec".to_string()];
397        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Bash);
398
399        let tools = vec!["unified_search".to_string()];
400        assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
401
402        let tools = vec![TOOL_LIST_FILES.to_string()];
403        assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileListing);
404
405        let tools = vec!["read_file".to_string()];
406        assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileReading);
407
408        let tools = vec!["unknown_tool".to_string()];
409        assert_eq!(infer_capability_level(&tools), CapabilityLevel::Basic);
410    }
411
412    #[test]
413    fn test_guidelines_stay_compact() {
414        let tools = vec![
415            "unified_exec".to_string(),
416            "unified_search".to_string(),
417            "unified_file".to_string(),
418            "read_file".to_string(),
419            TOOL_LIST_FILES.to_string(),
420            "apply_patch".to_string(),
421        ];
422        let guidelines = generate_tool_guidelines(&tools, None);
423        let approx_tokens = guidelines.len() / 4;
424        assert!(approx_tokens < 145, "got ~{} tokens", approx_tokens);
425    }
426
427    #[test]
428    fn test_parallel_tool_call_guidance() {
429        let tools = vec![
430            "unified_exec".to_string(),
431            "unified_search".to_string(),
432            "unified_file".to_string(),
433        ];
434        let guidelines = generate_tool_guidelines(&tools, None);
435        assert!(
436            guidelines.contains("parallel"),
437            "Should include parallel tool call guidance"
438        );
439        assert!(
440            guidelines.contains("read files"),
441            "Should mention reading files in parallel"
442        );
443    }
444
445    #[test]
446    fn planning_workflow_runtime_guidance_keeps_unified_file_read_only() {
447        let tools = vec![
448            TOOL_UNIFIED_FILE.to_string(),
449            TOOL_UNIFIED_EXEC.to_string(),
450            TOOL_UNIFIED_SEARCH.to_string(),
451        ];
452        let guidelines = generate_runtime_tool_guidelines(&tools, true);
453
454        assert!(guidelines.contains("Planning workflow active"));
455        assert!(guidelines.contains("`unified_file` only for read-style access"));
456        assert!(guidelines.contains("`unified_exec` only for read-only verification"));
457        assert!(!guidelines.contains("Read before edit"));
458    }
459
460    #[test]
461    fn runtime_tool_prompt_sections_include_catalog_metadata() {
462        let mut prompt = "Base prompt".to_string();
463        let snapshot = SessionToolCatalogSnapshot::new(
464            7,
465            9,
466            true,
467            false,
468            Some(std::sync::Arc::new(vec![
469                crate::llm::provider::ToolDefinition::function(
470                    TOOL_UNIFIED_SEARCH.to_string(),
471                    "Search".to_string(),
472                    serde_json::json!({"type": "object"}),
473                ),
474                crate::llm::provider::ToolDefinition::function(
475                    TOOL_UNIFIED_FILE.to_string(),
476                    "File".to_string(),
477                    serde_json::json!({"type": "object"}),
478                ),
479            ])),
480            false,
481        );
482
483        append_runtime_tool_prompt_sections(&mut prompt, &snapshot, true);
484
485        assert!(prompt.contains("## Active Tools"));
486        assert!(prompt.contains("[Runtime Tool Catalog]"));
487        assert!(prompt.contains("catalog_tools: 2"));
488        assert!(prompt.contains("currently_available_tools: unified_search, unified_file"));
489        assert!(prompt.contains("request_user_input_enabled: false"));
490    }
491
492    #[test]
493    fn runtime_tool_prompt_sections_replace_existing_runtime_sections() {
494        let mut prompt = "Base prompt".to_string();
495        let first = SessionToolCatalogSnapshot::new(
496            1,
497            2,
498            false,
499            false,
500            Some(std::sync::Arc::new(vec![
501                crate::llm::provider::ToolDefinition::function(
502                    TOOL_UNIFIED_SEARCH.to_string(),
503                    "Search".to_string(),
504                    serde_json::json!({"type": "object"}),
505                ),
506            ])),
507            false,
508        );
509        let second = SessionToolCatalogSnapshot::new(
510            7,
511            9,
512            true,
513            true,
514            Some(std::sync::Arc::new(vec![
515                crate::llm::provider::ToolDefinition::function(
516                    TOOL_UNIFIED_FILE.to_string(),
517                    "File".to_string(),
518                    serde_json::json!({"type": "object"}),
519                ),
520            ])),
521            false,
522        );
523
524        append_runtime_tool_prompt_sections(&mut prompt, &first, true);
525        append_runtime_tool_prompt_sections(&mut prompt, &second, true);
526
527        assert_eq!(prompt.matches("## Active Tools").count(), 1);
528        assert_eq!(prompt.matches("[Runtime Tool Catalog]").count(), 1);
529        assert!(prompt.contains("version: 7"));
530        assert!(!prompt.contains("version: 1"));
531        assert!(prompt.contains("request_user_input_enabled: true"));
532        assert!(!prompt.contains("request_user_input_enabled: false"));
533    }
534}