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